From 7f4bcae67df170229bcc720dc65f860c14763b8c Mon Sep 17 00:00:00 2001 From: patsy Date: Wed, 16 Sep 2026 13:10:15 +0200 Subject: [PATCH] full commit --- .gitignore | 2 + ...-10_quick-sorter_archive-cutoff-safe_v4.py | 1469 ++++++++++++ ...9-10_quick-sorter_context-aware-date_v3.py | 1470 ++++++++++++ ...-10_quick-sorter_context-aware-mtime_v2.py | 1446 ++++++++++++ 2026-09-10_quick-sorter_global-dedupe.patch | 214 ++ 2026-09-10_quick-sorter_global-dedupe.py | 1419 ++++++++++++ ...orter-policy_cafebach-source-alias_v2.json | 164 ++ ...13_quick-sorter_deterministic-naming_v6.py | 1671 ++++++++++++++ ...quick-sorter_single-request-fallback_v5.py | 1593 +++++++++++++ ...14_nonwork-routing_post-apply-verify_v1.py | 230 ++ ...-14_quick-sorter_extractor-hardening_v9.py | 1863 +++++++++++++++ 2026-09-14_quick-sorter_naming-cleanup_v7.py | 1804 +++++++++++++++ ...9-14_quick-sorter_root-bounded-dates_v8.py | 1823 +++++++++++++++ ...-09-14_requirements_document-sorter_v9.txt | 8 + ...pe-triage_curated-archive-provenance_v3.py | 624 ++++++ ...9-14_scope-triage_effective-routing_v1.tsv | 272 +++ ...9-14_scope-triage_effective-routing_v2.tsv | 272 +++ ...09-14_scope-triage_manual-overrides_v1.tsv | 19 + ...09-14_scope-triage_manual-overrides_v2.tsv | 30 + ...9-14_scope-triage_nonwork-hash-audit_v1.py | 230 ++ ...9-14_scope-triage_work-private-inbox_v1.py | 508 +++++ ...09-15_quick-sorter_dedupe-hardening_v17.py | 1954 ++++++++++++++++ ...ck-sorter_destination-normalization_v18.py | 1992 +++++++++++++++++ ...9-15_quick-sorter_destination-retry_v16.py | 1920 ++++++++++++++++ ..._quick-sorter_full-inbox-candidates_v15.py | 1895 ++++++++++++++++ ..._quick-sorter_inbox-purpose-routing_v12.py | 1891 ++++++++++++++++ ...9-15_quick-sorter_inbox-review-only_v11.py | 1883 ++++++++++++++++ ..._quick-sorter_inbox-staging-context_v10.py | 1877 ++++++++++++++++ ...-09-15_quick-sorter_semantic-naming_v13.py | 1876 ++++++++++++++++ ...quick-sorter_vault-specific-routing_v14.py | 1885 ++++++++++++++++ ...orter-policy_before-03-privat-cleanup.json | 159 ++ ...sorter-policy_before-05-vault-cleanup.json | 159 ++ ...15_sorter-policy_before-inbox-cleanup.json | 164 ++ ...policy_before-remove-02dev-from-inbox.json | 160 ++ 2026-09-16_inbox-sort_daily-wrapper.py | 573 +++++ README_V1_4.md | 42 + README_V1_5.md | 51 + README_V2.md | 106 + ...orter_dedupe-hardening_v17.cpython-313.pyc | Bin 0 -> 110448 bytes ...tination-normalization_v18.cpython-313.pyc | Bin 0 -> 111557 bytes ...rter_destination-retry_v16.cpython-313.pyc | Bin 0 -> 109563 bytes ..._full-inbox-candidates_v15.cpython-313.pyc | Bin 0 -> 108842 bytes ..._inbox-purpose-routing_v12.cpython-313.pyc | Bin 0 -> 108852 bytes ...rter_inbox-review-only_v11.cpython-313.pyc | Bin 0 -> 107762 bytes ..._inbox-staging-context_v10.cpython-313.pyc | Bin 0 -> 107641 bytes ...sorter_semantic-naming_v13.cpython-313.pyc | Bin 0 -> 107483 bytes ...vault-specific-routing_v14.cpython-313.pyc | Bin 0 -> 108544 bytes ...6_inbox-sort_daily-wrapper.cpython-313.pyc | Bin 0 -> 27729 bytes __pycache__/quick_sorter.cpython-313.pyc | Bin 0 -> 107445 bytes __pycache__/quick_sorter.cpython-314.pyc | Bin 0 -> 94970 bytes __pycache__/scope_triage.cpython-313.pyc | Bin 0 -> 29278 bytes nonwork_controlled_apply.py | 407 ++++ nonwork_execution_preflight.py | 301 +++ nonwork_hash_audit.py | 230 ++ nonwork_post_apply_verify.py | 230 ++ quick_sorter.py | 1863 +++++++++++++++ requirements.txt | 8 + result1.md | 268 +++ result2.md | 445 ++++ result3.md | 399 ++++ results4.md | 182 ++ results5.md | 222 ++ scope_triage.py | 624 ++++++ scope_triage_v1_working.py | 605 +++++ scope_triage_v2_working.py | 605 +++++ sorter_policy.json | 159 ++ 66 files changed, 42266 insertions(+) create mode 100644 .gitignore create mode 100644 2026-09-10_quick-sorter_archive-cutoff-safe_v4.py create mode 100644 2026-09-10_quick-sorter_context-aware-date_v3.py create mode 100644 2026-09-10_quick-sorter_context-aware-mtime_v2.py create mode 100644 2026-09-10_quick-sorter_global-dedupe.patch create mode 100644 2026-09-10_quick-sorter_global-dedupe.py create mode 100644 2026-09-10_sorter-policy_cafebach-source-alias_v2.json create mode 100644 2026-09-13_quick-sorter_deterministic-naming_v6.py create mode 100644 2026-09-13_quick-sorter_single-request-fallback_v5.py create mode 100644 2026-09-14_nonwork-routing_post-apply-verify_v1.py create mode 100644 2026-09-14_quick-sorter_extractor-hardening_v9.py create mode 100644 2026-09-14_quick-sorter_naming-cleanup_v7.py create mode 100644 2026-09-14_quick-sorter_root-bounded-dates_v8.py create mode 100644 2026-09-14_requirements_document-sorter_v9.txt create mode 100644 2026-09-14_scope-triage_curated-archive-provenance_v3.py create mode 100644 2026-09-14_scope-triage_effective-routing_v1.tsv create mode 100644 2026-09-14_scope-triage_effective-routing_v2.tsv create mode 100644 2026-09-14_scope-triage_manual-overrides_v1.tsv create mode 100644 2026-09-14_scope-triage_manual-overrides_v2.tsv create mode 100644 2026-09-14_scope-triage_nonwork-hash-audit_v1.py create mode 100644 2026-09-14_scope-triage_work-private-inbox_v1.py create mode 100644 2026-09-15_quick-sorter_dedupe-hardening_v17.py create mode 100644 2026-09-15_quick-sorter_destination-normalization_v18.py create mode 100644 2026-09-15_quick-sorter_destination-retry_v16.py create mode 100644 2026-09-15_quick-sorter_full-inbox-candidates_v15.py create mode 100644 2026-09-15_quick-sorter_inbox-purpose-routing_v12.py create mode 100644 2026-09-15_quick-sorter_inbox-review-only_v11.py create mode 100644 2026-09-15_quick-sorter_inbox-staging-context_v10.py create mode 100644 2026-09-15_quick-sorter_semantic-naming_v13.py create mode 100644 2026-09-15_quick-sorter_vault-specific-routing_v14.py create mode 100644 2026-09-15_sorter-policy_before-03-privat-cleanup.json create mode 100644 2026-09-15_sorter-policy_before-05-vault-cleanup.json create mode 100644 2026-09-15_sorter-policy_before-inbox-cleanup.json create mode 100644 2026-09-15_sorter-policy_before-remove-02dev-from-inbox.json create mode 100755 2026-09-16_inbox-sort_daily-wrapper.py create mode 100644 README_V1_4.md create mode 100644 README_V1_5.md create mode 100644 README_V2.md create mode 100644 __pycache__/2026-09-15_quick-sorter_dedupe-hardening_v17.cpython-313.pyc create mode 100644 __pycache__/2026-09-15_quick-sorter_destination-normalization_v18.cpython-313.pyc create mode 100644 __pycache__/2026-09-15_quick-sorter_destination-retry_v16.cpython-313.pyc create mode 100644 __pycache__/2026-09-15_quick-sorter_full-inbox-candidates_v15.cpython-313.pyc create mode 100644 __pycache__/2026-09-15_quick-sorter_inbox-purpose-routing_v12.cpython-313.pyc create mode 100644 __pycache__/2026-09-15_quick-sorter_inbox-review-only_v11.cpython-313.pyc create mode 100644 __pycache__/2026-09-15_quick-sorter_inbox-staging-context_v10.cpython-313.pyc create mode 100644 __pycache__/2026-09-15_quick-sorter_semantic-naming_v13.cpython-313.pyc create mode 100644 __pycache__/2026-09-15_quick-sorter_vault-specific-routing_v14.cpython-313.pyc create mode 100644 __pycache__/2026-09-16_inbox-sort_daily-wrapper.cpython-313.pyc create mode 100644 __pycache__/quick_sorter.cpython-313.pyc create mode 100644 __pycache__/quick_sorter.cpython-314.pyc create mode 100644 __pycache__/scope_triage.cpython-313.pyc create mode 100644 nonwork_controlled_apply.py create mode 100644 nonwork_execution_preflight.py create mode 100644 nonwork_hash_audit.py create mode 100644 nonwork_post_apply_verify.py create mode 100644 quick_sorter.py create mode 100644 requirements.txt create mode 100644 result1.md create mode 100644 result2.md create mode 100644 result3.md create mode 100644 results4.md create mode 100644 results5.md create mode 100644 scope_triage.py create mode 100644 scope_triage_v1_working.py create mode 100644 scope_triage_v2_working.py create mode 100644 sorter_policy.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91ec669 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/data/02_DEV/13_document_sorter/__pycache__ + diff --git a/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py b/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py new file mode 100644 index 0000000..2b1fc58 --- /dev/null +++ b/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py @@ -0,0 +1,1469 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v2.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import urllib.request +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + for part in reversed(path.parent.parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + except ImportError: + return "", "missing:pypdf" + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + for slide in prs.slides[:5]: + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +def build_structured_filename(rec: Record) -> str: + """Build the filename deterministically from model-derived semantic parts.""" + parts = [] + for value in (rec.topic, rec.subtopic, rec.document_type, rec.detail): + token = sanitise_token(value) + if token and token not in parts: + parts.append(token) + ver = sanitise_token(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + parts.append(ver) + if not parts: + return rec.filename + return "_".join([rec.date_guess] + parts) + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + current = str(p.parent.relative_to(root)) + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + source_context = root.name + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{root.name}/{relative_parent}" + d, source = best_date(p) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +- If uncertain, keep the current folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- For destination, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Preserve a filename that is already clear AND context-specific unless there is a concrete improvement. Never reduce an informative original filename to a generic name such as "allgemein_dokument". +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Prefer preserving an already-useful filename unless renaming clearly helps. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def llm_batches(records: list[Record], endpoint: str, model: str, batch_size: int = 10): + for start in range(0, len(records), batch_size): + batch = records[start:start + batch_size] + items = [] + for idx, rec in enumerate(batch): + items.append({ + "id": idx, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + }) + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps(items, ensure_ascii=False) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.1, + "max_tokens": 1400, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=90) as resp: + data = json.loads(resp.read().decode("utf-8")) + text = data["choices"][0]["message"]["content"] + parsed = extract_json_array(text) + if not isinstance(parsed, list): + raise ValueError("model did not return a JSON array") + yield batch, parsed, None + except Exception as exc: + yield batch, None, str(exc) + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + structured_name = build_structured_filename(rec) + + # Filesystem mtime is provenance metadata, not a trustworthy document + # date. If the incoming filename already carries useful semantics, keep + # it instead of replacing it with an apparently precise mtime-based name. + # We still allow the LLM to suggest the destination and semantic fields. + informative_existing_name = not is_generic_filename(rec.filename) + generic_model_type = sanitise_token(rec.document_type) in { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", "misc" + } + preserve_existing_name = ( + rec.date_source == "mtime" and informative_existing_name + ) or ( + generic_model_type and informative_existing_name + ) + name = rec.filename if preserve_existing_name else structured_name + rec.proposed_name = name + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # If the only available date is mtime and a generic filename still needs + # a generated rename, require explicit human review. An informative + # existing filename is preserved above, so no unverified date is injected. + if rec.date_source == "mtime" and name != rec.filename: + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s) in batches…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates(rec, self.destinations, aliases=aliases) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--batch-size", type=int, default=10) + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-10_quick-sorter_context-aware-date_v3.py b/2026-09-10_quick-sorter_context-aware-date_v3.py new file mode 100644 index 0000000..da6c87c --- /dev/null +++ b/2026-09-10_quick-sorter_context-aware-date_v3.py @@ -0,0 +1,1470 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v2.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import urllib.request +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path) -> Optional[str]: + """Use a parent-folder date only when the interpretation is trustworthy. + + A bare two-digit hyphen date such as ``25-10-16`` is ambiguous: it could be + legacy YY-MM-DD (2025-10-16) or German DD-MM-YY (2016-10-25). Parent + folders therefore do not use the legacy interpretation by default. A phrase + such as ``Archiv bis 25-10-16`` supplies enough context to interpret it as + German DD-MM-YY. + """ + for part in reversed(path.parent.parts): + # Explicit German cutoff/archive wording: "... bis DD-MM-YY". + m = re.search(r"(?i)\bbis\s+(\d{1,2})[-_.](\d{1,2})[-_.](\d{2})(?!\d)", part) + if m: + value = _valid(2000 + int(m[3]), int(m[2]), int(m[1])) + if value: + return value + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + except ImportError: + return "", "missing:pypdf" + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + for slide in prs.slides[:5]: + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +def build_structured_filename(rec: Record) -> str: + """Build the filename deterministically from model-derived semantic parts.""" + parts = [] + for value in (rec.topic, rec.subtopic, rec.document_type, rec.detail): + token = sanitise_token(value) + if token and token not in parts: + parts.append(token) + ver = sanitise_token(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + parts.append(ver) + if not parts: + return rec.filename + return "_".join([rec.date_guess] + parts) + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + current = str(p.parent.relative_to(root)) + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + source_context = root.name + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{root.name}/{relative_parent}" + d, source = best_date(p) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +- If uncertain, keep the current folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- For destination, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Preserve a filename that is already clear AND context-specific unless there is a concrete improvement. Never reduce an informative original filename to a generic name such as "allgemein_dokument". +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Prefer preserving an already-useful filename unless renaming clearly helps. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def llm_batches(records: list[Record], endpoint: str, model: str, batch_size: int = 10): + for start in range(0, len(records), batch_size): + batch = records[start:start + batch_size] + items = [] + for idx, rec in enumerate(batch): + items.append({ + "id": idx, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + }) + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps(items, ensure_ascii=False) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.1, + "max_tokens": 1400, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=90) as resp: + data = json.loads(resp.read().decode("utf-8")) + text = data["choices"][0]["message"]["content"] + parsed = extract_json_array(text) + if not isinstance(parsed, list): + raise ValueError("model did not return a JSON array") + yield batch, parsed, None + except Exception as exc: + yield batch, None, str(exc) + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + structured_name = build_structured_filename(rec) + + # Filesystem mtime is provenance metadata, not a trustworthy document + # date. If the incoming filename already carries useful semantics, keep + # it instead of replacing it with an apparently precise mtime-based name. + # We still allow the LLM to suggest the destination and semantic fields. + informative_existing_name = not is_generic_filename(rec.filename) + generic_model_type = sanitise_token(rec.document_type) in { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", "misc" + } + preserve_existing_name = ( + rec.date_source == "mtime" and informative_existing_name + ) or ( + generic_model_type and informative_existing_name + ) + name = rec.filename if preserve_existing_name else structured_name + rec.proposed_name = name + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # If the only available date is mtime and a generic filename still needs + # a generated rename, require explicit human review. An informative + # existing filename is preserved above, so no unverified date is injected. + if rec.date_source == "mtime" and name != rec.filename: + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s) in batches…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates(rec, self.destinations, aliases=aliases) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--batch-size", type=int, default=10) + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-10_quick-sorter_context-aware-mtime_v2.py b/2026-09-10_quick-sorter_context-aware-mtime_v2.py new file mode 100644 index 0000000..7bbef8b --- /dev/null +++ b/2026-09-10_quick-sorter_context-aware-mtime_v2.py @@ -0,0 +1,1446 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v2.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import urllib.request +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata.""" + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path) -> Optional[str]: + """Use a parent folder date only when it is explicitly parseable.""" + for part in reversed(path.parent.parts): + parsed = date_from_filename(part) + if parsed: + return parsed + return None + + +def best_date(path: Path) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + except ImportError: + return "", "missing:pypdf" + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + for slide in prs.slides[:5]: + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +def build_structured_filename(rec: Record) -> str: + """Build the filename deterministically from model-derived semantic parts.""" + parts = [] + for value in (rec.topic, rec.subtopic, rec.document_type, rec.detail): + token = sanitise_token(value) + if token and token not in parts: + parts.append(token) + ver = sanitise_token(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + parts.append(ver) + if not parts: + return rec.filename + return "_".join([rec.date_guess] + parts) + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + current = str(p.parent.relative_to(root)) + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + source_context = root.name + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{root.name}/{relative_parent}" + d, source = best_date(p) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +- If uncertain, keep the current folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- For destination, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Preserve a filename that is already clear AND context-specific unless there is a concrete improvement. Never reduce an informative original filename to a generic name such as "allgemein_dokument". +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Prefer preserving an already-useful filename unless renaming clearly helps. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def llm_batches(records: list[Record], endpoint: str, model: str, batch_size: int = 10): + for start in range(0, len(records), batch_size): + batch = records[start:start + batch_size] + items = [] + for idx, rec in enumerate(batch): + items.append({ + "id": idx, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + }) + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps(items, ensure_ascii=False) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.1, + "max_tokens": 1400, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=90) as resp: + data = json.loads(resp.read().decode("utf-8")) + text = data["choices"][0]["message"]["content"] + parsed = extract_json_array(text) + if not isinstance(parsed, list): + raise ValueError("model did not return a JSON array") + yield batch, parsed, None + except Exception as exc: + yield batch, None, str(exc) + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + structured_name = build_structured_filename(rec) + + # Filesystem mtime is provenance metadata, not a trustworthy document + # date. If the incoming filename already carries useful semantics, keep + # it instead of replacing it with an apparently precise mtime-based name. + # We still allow the LLM to suggest the destination and semantic fields. + informative_existing_name = not is_generic_filename(rec.filename) + generic_model_type = sanitise_token(rec.document_type) in { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", "misc" + } + preserve_existing_name = ( + rec.date_source == "mtime" and informative_existing_name + ) or ( + generic_model_type and informative_existing_name + ) + name = rec.filename if preserve_existing_name else structured_name + rec.proposed_name = name + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # If the only available date is mtime and a generic filename still needs + # a generated rename, require explicit human review. An informative + # existing filename is preserved above, so no unverified date is injected. + if rec.date_source == "mtime" and name != rec.filename: + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s) in batches…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates(rec, self.destinations, aliases=aliases) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--batch-size", type=int, default=10) + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-10_quick-sorter_global-dedupe.patch b/2026-09-10_quick-sorter_global-dedupe.patch new file mode 100644 index 0000000..d081510 --- /dev/null +++ b/2026-09-10_quick-sorter_global-dedupe.patch @@ -0,0 +1,214 @@ +--- quick_sorter.py ++++ quick_sorter.py +@@ -93,6 +93,7 @@ + detail: str = "" + version: str = "" + collision: str = "" # exact duplicate / different-content variant handling ++ duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + + @property + def target(self) -> Path: +@@ -738,6 +739,93 @@ + return False + + ++def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: ++ """Mark exact duplicates before content extraction or LLM analysis. ++ ++ The pre-pass is deliberately enabled only for *separate* source and target ++ trees. This covers migration/staging imports without changing the established ++ V2 Inbox behaviour where the Inbox is nested inside a broader target root. ++ ++ Full SHA-256 is used for every exact-duplicate decision. Existing target files ++ are authoritative. If no target copy exists, the first source copy becomes the ++ representative and later byte-identical source copies are marked duplicates. ++ """ ++ stats: dict[str, int | bool | str] = { ++ "enabled": False, ++ "target_files": 0, ++ "source_files": 0, ++ "target_duplicates": 0, ++ "source_duplicates": 0, ++ "hash_errors": 0, ++ "reason": "", ++ } ++ ++ source_root = source_root.resolve() ++ target_root = target_root.resolve() ++ ++ if source_root == target_root or source_root.is_relative_to(target_root): ++ stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" ++ return stats ++ if target_root.is_relative_to(source_root): ++ stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" ++ return stats ++ ++ stats["enabled"] = True ++ target_by_hash: dict[str, Path] = {} ++ ++ for p in sorted(target_root.rglob("*")): ++ if p.is_symlink() or not p.is_file() or ignored(p, target_root): ++ continue ++ try: ++ digest = sha256_file(p) ++ except OSError: ++ stats["hash_errors"] += 1 ++ continue ++ stats["target_files"] += 1 ++ target_by_hash.setdefault(digest, p) ++ ++ source_seen: dict[str, Path] = {} ++ for rec in records: ++ # scan() has already applied the normal ignore rules. Archives are hashed ++ # too: an archive that is already byte-identical in TARGET needs no LLM. ++ try: ++ digest = sha256_file(rec.path) ++ except OSError: ++ stats["hash_errors"] += 1 ++ continue ++ stats["source_files"] += 1 ++ ++ target_match = target_by_hash.get(digest) ++ if target_match is not None: ++ rec.duplicate_of = str(target_match) ++ rec.status = "exact-duplicate" ++ rec.collision = "EXACT → already in target" ++ try: ++ display = str(target_match.relative_to(target_root)) ++ except ValueError: ++ display = str(target_match) ++ rec.reason = f"SHA-256 match in target: {display}" ++ stats["target_duplicates"] += 1 ++ continue ++ ++ first_source = source_seen.get(digest) ++ if first_source is not None: ++ rec.duplicate_of = str(first_source) ++ rec.status = "exact-duplicate" ++ rec.collision = "EXACT → duplicate incoming" ++ try: ++ display = str(first_source.relative_to(source_root)) ++ except ValueError: ++ display = str(first_source) ++ rec.reason = f"SHA-256 match in source: {display}" ++ stats["source_duplicates"] += 1 ++ continue ++ ++ source_seen[digest] = rec.path ++ ++ return stats ++ ++ + def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) +@@ -785,6 +873,29 @@ + if rec.status != "confirmed": + continue + source = rec.path ++ ++ # A global pre-pass duplicate bypasses semantic destination/name planning. ++ # Re-verify the bytes immediately before planning deletion; if the other ++ # copy changed or vanished, fail closed and touch nothing. ++ if rec.duplicate_of: ++ duplicate_of = Path(rec.duplicate_of) ++ if duplicate_of.exists() and same_content(source, duplicate_of): ++ result = "EXACT duplicate -> would remove source" ++ resolved = duplicate_of ++ collision = "EXACT → remove duplicate" ++ else: ++ result = "SKIPPED - pre-pass duplicate no longer matches" ++ resolved = duplicate_of ++ collision = "RECHECK → duplicate changed" ++ rec.collision = collision ++ rows.append({ ++ "old_path": str(source), ++ "new_path": str(resolved), ++ "requested_path": str(rec.target), ++ "result": result, ++ }) ++ continue ++ + target = rec.target + + if target == source: +@@ -980,10 +1091,11 @@ + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): +- # Full hashes are calculated only for rows that actually compete for +- # the same target, so this stays cheap in normal use. ++ # Preserve global pre-pass markers on rows that have not yet been ++ # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: +- rec.collision = "" ++ if not rec.duplicate_of: ++ rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) +@@ -1007,6 +1119,7 @@ + merged.append(rec) + new_count += 1 + self.records = merged ++ global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", +@@ -1018,10 +1131,20 @@ + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] +- chosen = [r for r in selected if r.status != "archive" and not is_archive_file(r.path)] +- archive_count = len(selected) - len(chosen) ++ duplicate_count = sum(1 for r in selected if r.duplicate_of) ++ archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) ++ chosen = [ ++ r for r in selected ++ if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) ++ ] + if not chosen: +- if archive_count: ++ if duplicate_count: ++ messagebox.showinfo( ++ "Exact duplicate selected", ++ f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" ++ "Confirm them only when you want Apply to remove the redundant SOURCE copy.", ++ ) ++ elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" +@@ -1261,12 +1384,22 @@ + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) ++ dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") ++ if dedupe["enabled"]: ++ print( ++ "Global SHA-256 pre-pass: " ++ f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " ++ f"already in target={dedupe['target_duplicates']}, " ++ f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" ++ ) ++ else: ++ print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") +@@ -1275,7 +1408,8 @@ + + if args.scan_only: + for r in records[:25]: +- print(f"{r.date_guess} [{r.date_source:8}] {r.rel_path}") ++ duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" ++ print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), aliases=aliases, inbox_mode=inbox_mode) diff --git a/2026-09-10_quick-sorter_global-dedupe.py b/2026-09-10_quick-sorter_global-dedupe.py new file mode 100644 index 0000000..83c8841 --- /dev/null +++ b/2026-09-10_quick-sorter_global-dedupe.py @@ -0,0 +1,1419 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v2.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import urllib.request +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata.""" + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path) -> Optional[str]: + """Use a parent folder date only when it is explicitly parseable.""" + for part in reversed(path.parent.parts): + parsed = date_from_filename(part) + if parsed: + return parsed + return None + + +def best_date(path: Path) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + except ImportError: + return "", "missing:pypdf" + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + for slide in prs.slides[:5]: + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +def build_structured_filename(rec: Record) -> str: + """Build the filename deterministically from model-derived semantic parts.""" + parts = [] + for value in (rec.topic, rec.subtopic, rec.document_type, rec.detail): + token = sanitise_token(value) + if token and token not in parts: + parts.append(token) + ver = sanitise_token(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + parts.append(ver) + if not parts: + return rec.filename + return "_".join([rec.date_guess] + parts) + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + current = str(p.parent.relative_to(root)) + if current == ".": + current = "" + d, source = best_date(p) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +- If uncertain, keep the current folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- For destination, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Preserve a filename that is already clear AND context-specific unless there is a concrete improvement. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Prefer preserving an already-useful filename unless renaming clearly helps. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def llm_batches(records: list[Record], endpoint: str, model: str, batch_size: int = 10): + for start in range(0, len(records), batch_size): + batch = records[start:start + batch_size] + items = [] + for idx, rec in enumerate(batch): + items.append({ + "id": idx, + "filename": rec.filename, + "current_folder": rec.current_folder, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + }) + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps(items, ensure_ascii=False) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.1, + "max_tokens": 1400, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=90) as resp: + data = json.loads(resp.read().decode("utf-8")) + text = data["choices"][0]["message"]["content"] + parsed = extract_json_array(text) + if not isinstance(parsed, list): + raise ValueError("model did not return a JSON array") + yield batch, parsed, None + except Exception as exc: + yield batch, None, str(exc) + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + name = build_structured_filename(rec) + rec.proposed_name = name + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # mtime is only a fallback, not a verified creation date. If the model + # builds a NEW filename from that date, never let the row enter the + # one-click high-confidence confirmation group. The human can still + # review and confirm it explicitly. + if rec.date_source == "mtime" and name != rec.filename: + conf = min(conf, 0.79) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s) in batches…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates(rec, self.destinations, aliases=aliases) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--batch-size", type=int, default=10) + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-10_sorter-policy_cafebach-source-alias_v2.json b/2026-09-10_sorter-policy_cafebach-source-alias_v2.json new file mode 100644 index 0000000..324a612 --- /dev/null +++ b/2026-09-10_sorter-policy_cafebach-source-alias_v2.json @@ -0,0 +1,164 @@ +{ + "01_INBOX": [ + "01_INBOX", + "02_DEV", + "02_DEV/Projekte", + "02_DEV/Experimente", + "03_PRIVAT", + "03_PRIVAT/Gesundheit", + "03_PRIVAT/Dokumente", + "03_PRIVAT/Finanzen", + "03_PRIVAT/Organisation", + "03_PRIVAT/Chrystal", + "04_ARBEIT", + "04_ARBEIT/Allgemein", + "04_ARBEIT/Allgemein/Kommunikation", + "04_ARBEIT/Allgemein/Kontakte", + "04_ARBEIT/Allgemein/Protokolle", + "04_ARBEIT/Allgemein/Verwaltung", + "04_ARBEIT/Allgemein/Vorlagen", + "04_ARBEIT/Allgemein/Anleitungen", + "04_ARBEIT/Cafe_Bach", + "04_ARBEIT/Cafe_Bach/Planung", + "04_ARBEIT/Cafe_Bach/Kommunikation", + "04_ARBEIT/Cafe_Bach/Verwaltung", + "04_ARBEIT/Cafe_Bach/Veranstaltungen", + "04_ARBEIT/Cafe_Bach/Materialien", + "04_ARBEIT/Cafe_Bach/Vorlagen", + "04_ARBEIT/Cafe_Bach/Anleitungen", + "04_ARBEIT/Checkpoint", + "04_ARBEIT/CSD-Kampagnen", + "04_ARBEIT/Ehrenamt", + "04_ARBEIT/FAK", + "04_ARBEIT/SP5-Treffen", + "04_ARBEIT/Vor-Ort-Arbeit", + "05_VAULT", + "05_VAULT/Wissen", + "05_VAULT/Wissen/IT", + "05_VAULT/Wissen/Design", + "05_VAULT/Wissen/Biologie", + "05_VAULT/Wissen/Mathematik", + "05_VAULT/Wissen/HIV_Praevention", + "05_VAULT/Wissen/Organisation", + "05_VAULT/Prozesse", + "05_VAULT/Prompts", + "05_VAULT/Tools", + "05_VAULT/Vorlagen", + "99_ARCHIV" + ], + "02_DEV": [ + "Projekte", + "Experimente" + ], + "03_PRIVAT": [ + "Gesundheit", + "Dokumente", + "Finanzen", + "Organisation", + "Chrystal" + ], + "04_ARBEIT": [ + "Allgemein", + "Allgemein/Kommunikation", + "Allgemein/Kontakte", + "Allgemein/Protokolle", + "Allgemein/Verwaltung", + "Allgemein/Vorlagen", + "Allgemein/Anleitungen", + "Cafe_Bach", + "Cafe_Bach/Planung", + "Cafe_Bach/Kommunikation", + "Cafe_Bach/Verwaltung", + "Cafe_Bach/Veranstaltungen", + "Cafe_Bach/Materialien", + "Cafe_Bach/Vorlagen", + "Cafe_Bach/Anleitungen", + "Checkpoint", + "CSD-Kampagnen", + "Ehrenamt", + "FAK", + "SP5-Treffen", + "Vor-Ort-Arbeit" + ], + "05_VAULT": [ + "Wissen", + "Wissen/IT", + "Wissen/Design", + "Wissen/Biologie", + "Wissen/Mathematik", + "Wissen/HIV_Praevention", + "Wissen/Organisation", + "Prozesse", + "Prompts", + "Tools", + "Vorlagen" + ], + "_strong_aliases": { + "04_ARBEIT": { + "Cafe_Bach": [ + "cb", + "cafe bach", + "café bach", + "cafe_bach", + "caba", + "cafebach" + ], + "Checkpoint": [ + "checkpoint", + "check point" + ], + "CSD-Kampagnen": [ + "csd", + "cologne pride" + ], + "SP5-Treffen": [ + "sp5", + "4s treffen" + ], + "Vor-Ort-Arbeit": [ + "voa", + "vor ort arbeit", + "vor-ort-arbeit" + ], + "FAK": [ + "fak", + "facharbeitskreis" + ] + }, + "01_INBOX": { + "04_ARBEIT/Cafe_Bach": [ + "cb", + "cafe bach", + "café bach", + "cafe_bach", + "caba", + "cafebach" + ], + "04_ARBEIT/Checkpoint": [ + "checkpoint", + "check point" + ], + "04_ARBEIT/CSD-Kampagnen": [ + "csd", + "cologne pride" + ], + "04_ARBEIT/SP5-Treffen": [ + "sp5", + "4s treffen" + ], + "04_ARBEIT/Vor-Ort-Arbeit": [ + "voa", + "vor ort arbeit", + "vor-ort-arbeit" + ], + "04_ARBEIT/FAK": [ + "fak", + "facharbeitskreis" + ], + "03_PRIVAT/Chrystal": [ + "chrystal math", + "chrystal-math" + ] + } + } +} diff --git a/2026-09-13_quick-sorter_deterministic-naming_v6.py b/2026-09-13_quick-sorter_deterministic-naming_v6.py new file mode 100644 index 0000000..a87aff7 --- /dev/null +++ b/2026-09-13_quick-sorter_deterministic-naming_v6.py @@ -0,0 +1,1671 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v5.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import urllib.request +import unicodedata +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + name_title: str = "" + name_subtitle: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + for part in reversed(path.parent.parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + except ImportError: + return "", "missing:pypdf" + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + for slide in prs.slides[:5]: + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +FILENAME_PREFIXES = { + "Allgemein": "Allgemein", + "Cafe_Bach": "CB", + "Checkpoint": "Checkpoint", + "CSD-Kampagnen": "CSD", + "Ehrenamt": "Ehrenamt", + "FAK": "FAK", + "SP5-Treffen": "SP5", + "Vor-Ort-Arbeit": "VOA", +} + + +def _filename_prefix(destination: str) -> str: + branch = destination.split("/", 1)[0].strip() + return FILENAME_PREFIXES.get(branch, branch.replace("_", "-") or "Datei") + + +def _filename_component(text: str, *, title_case_first: bool = False) -> str: + """Return one Windows/Linux-safe filename component while preserving Unicode. + + Unlike sanitise_token(), this deliberately keeps German umlauts and readable + capitalization. Underscores remain reserved for the naming-schema separators; + whitespace inside a component becomes a hyphen. + """ + value = unicodedata.normalize("NFC", str(text or "")).strip() + value = re.sub(r"^\d{4}[-_. ]\d{1,2}[-_. ]\d{1,2}[-_ ]*", "", value) + value = re.sub(r"\.[A-Za-z0-9]{1,6}$", "", value) + value = value.replace("_", " ") + value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", value) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"-+", "-", value).strip("-. ") + if title_case_first and value and value[0].islower(): + value = value[0].upper() + value[1:] + return value + + +def _semantic_key(text: str) -> str: + folded = unicodedata.normalize("NFKD", str(text or "")).casefold() + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.replace("ß", "ss") + return re.sub(r"[^a-z0-9]+", "", folded) + + +def _fallback_title(rec: Record) -> str: + generic = {"dokument", "document", "datei", "file", "unterlagen", "sonstiges", "misc", "info"} + for value in (rec.document_type, rec.subtopic, rec.detail): + candidate = _filename_component(value, title_case_first=True) + if candidate and _semantic_key(candidate) not in generic: + return candidate + stem = _filename_component(Path(rec.filename).stem, title_case_first=True) + return stem or "Dokument" + + +def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: + blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} + for value in (rec.subtopic, rec.detail): + candidate = _filename_component(value) + key = _semantic_key(candidate) + if candidate and key and key not in blocked: + return candidate + return "" + + +def build_structured_filename(rec: Record) -> str: + """Build YYYY-MM-DD_PREFIX_Title_Subtitle[_vN].ext deterministically.""" + prefix = _filename_component(_filename_prefix(rec.destination), title_case_first=True) + + title = _filename_component(rec.name_title, title_case_first=True) + if not title or _semantic_key(title) == _semantic_key(prefix): + title = _fallback_title(rec) + + subtitle = _filename_component(rec.name_subtitle) + blocked = {_semantic_key(prefix), _semantic_key(title)} + if not subtitle or _semantic_key(subtitle) in blocked: + subtitle = _fallback_subtitle(rec, title, prefix) + + parts = [rec.date_guess, prefix, title] + if subtitle and _semantic_key(subtitle) not in {_semantic_key(x) for x in parts}: + parts.append(subtitle) + + ver = _filename_component(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + elif not re.fullmatch(r"v\d+", ver, flags=re.I): + ver = f"v{ver}" + parts.append(ver) + + # Keep enough headroom for the destination path and collision suffixes. + stem = "_".join(x for x in parts if x) + if len(stem) > 180: + overflow = len(stem) - 180 + if subtitle and len(subtitle) > overflow + 12: + subtitle = subtitle[: len(subtitle) - overflow].rstrip("-_. ") + parts = [rec.date_guess, prefix, title] + ([subtitle] if subtitle else []) + if ver: + parts.append(ver) + stem = "_".join(x for x in parts if x) + stem = stem[:180].rstrip("-_. ") + return stem + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + current = str(p.parent.relative_to(root)) + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + source_context = root.name + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{root.name}/{relative_parent}" + d, source = best_date(p) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +- If uncertain, keep the current folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- For destination, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + rec.name_title = str(result.get("title", "")).strip() + rec.name_subtitle = str(result.get("subtitle", "")).strip() + rec.proposed_name = build_structured_filename(rec) + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # Every V6 proposal follows the structured naming scheme. If its date + # comes only from filesystem mtime, the date is useful but historically + # unverified, so require explicit human review before apply. + if rec.date_source == "mtime": + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates(rec, self.destinations, aliases=aliases) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V5 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-13_quick-sorter_single-request-fallback_v5.py b/2026-09-13_quick-sorter_single-request-fallback_v5.py new file mode 100644 index 0000000..61080ce --- /dev/null +++ b/2026-09-13_quick-sorter_single-request-fallback_v5.py @@ -0,0 +1,1593 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v5.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import urllib.request +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + for part in reversed(path.parent.parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + except ImportError: + return "", "missing:pypdf" + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + for slide in prs.slides[:5]: + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +def build_structured_filename(rec: Record) -> str: + """Build the filename deterministically from model-derived semantic parts.""" + parts = [] + for value in (rec.topic, rec.subtopic, rec.document_type, rec.detail): + token = sanitise_token(value) + if token and token not in parts: + parts.append(token) + ver = sanitise_token(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + parts.append(ver) + if not parts: + return rec.filename + return "_".join([rec.date_guess] + parts) + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + current = str(p.parent.relative_to(root)) + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + source_context = root.name + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{root.name}/{relative_parent}" + d, source = best_date(p) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +- If uncertain, keep the current folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- For destination, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Preserve a filename that is already clear AND context-specific unless there is a concrete improvement. Never reduce an informative original filename to a generic name such as "allgemein_dokument". +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Prefer preserving an already-useful filename unless renaming clearly helps. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + structured_name = build_structured_filename(rec) + + # Filesystem mtime is provenance metadata, not a trustworthy document + # date. If the incoming filename already carries useful semantics, keep + # it instead of replacing it with an apparently precise mtime-based name. + # We still allow the LLM to suggest the destination and semantic fields. + informative_existing_name = not is_generic_filename(rec.filename) + generic_model_type = sanitise_token(rec.document_type) in { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", "misc" + } + preserve_existing_name = ( + rec.date_source == "mtime" and informative_existing_name + ) or ( + generic_model_type and informative_existing_name + ) + name = rec.filename if preserve_existing_name else structured_name + rec.proposed_name = name + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # If the only available date is mtime and a generic filename still needs + # a generated rename, require explicit human review. An informative + # existing filename is preserved above, so no unverified date is injected. + if rec.date_source == "mtime" and name != rec.filename: + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates(rec, self.destinations, aliases=aliases) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V5 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-14_nonwork-routing_post-apply-verify_v1.py b/2026-09-14_nonwork-routing_post-apply-verify_v1.py new file mode 100644 index 0000000..c1e36a0 --- /dev/null +++ b/2026-09-14_nonwork-routing_post-apply-verify_v1.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +""" +Independent post-apply verification for the non-work recovery import. + +Read-only checks: +- all 18 staging sources still exist and retain approved SHA-256 hashes +- all 14 COPY_NEW destinations exist and match approved SHA-256 hashes +- all 4 SKIP_EXACT_DUPLICATE target files still exist and match +- recovery folders contain exactly the 14 planned files and no extras +- apply report contains 14 COPIED_AND_VERIFIED + 4 SKIPPED_VERIFIED_DUPLICATE +- no apply failures are recorded + +No files or Syncthing settings are modified. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +from collections import Counter +from pathlib import Path + + +CHUNK = 1024 * 1024 + +PRIVATE_BASE = Path("/data/03_PRIVAT/01_INBOX/Recovered_Work-PC_2026-09-14") +INBOX_BASE = Path("/data/01_INBOX/Recovered_Work-PC_2026-09-14") + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(CHUNK), b""): + h.update(chunk) + return h.hexdigest() + + +def read_tsv(path: Path) -> list[dict[str, str]]: + with path.open("r", encoding="utf-8", newline="") as f: + return list(csv.DictReader(f, delimiter="\t")) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--manifest", required=True, type=Path) + ap.add_argument("--apply-report", required=True, type=Path) + args = ap.parse_args() + + manifest = args.manifest.resolve() + apply_report = args.apply_report.resolve() + + rows = read_tsv(manifest) + report_rows = read_tsv(apply_report) + + problems: list[str] = [] + + print("=== INDEPENDENT POST-APPLY VERIFICATION ===") + print(f"Manifest rows: {len(rows)}") + print(f"Apply report rows: {len(report_rows)}") + print() + + if len(rows) != 18: + problems.append(f"Expected 18 manifest rows, found {len(rows)}.") + if len(report_rows) != 18: + problems.append(f"Expected 18 apply-report rows, found {len(report_rows)}.") + + # Verify apply report statuses independently. + report_counts = Counter(r.get("apply_status", "") for r in report_rows) + expected_report = { + "COPIED_AND_VERIFIED": 14, + "SKIPPED_VERIFIED_DUPLICATE": 4, + } + + for status, expected in expected_report.items(): + actual = report_counts.get(status, 0) + if actual != expected: + problems.append( + f"Apply report status {status}: expected {expected}, found {actual}." + ) + + unexpected_statuses = { + k: v for k, v in report_counts.items() + if k not in expected_report and v + } + if unexpected_statuses: + problems.append( + "Unexpected/non-success apply statuses: " + + ", ".join(f"{k}={v}" for k, v in sorted(unexpected_statuses.items())) + ) + + # Verify sources, destinations, and duplicate targets. + verified_sources = 0 + verified_destinations = 0 + verified_duplicates = 0 + planned_destinations: set[Path] = set() + + for i, row in enumerate(rows, 1): + action = row["dry_run_action"].strip() + expected = row["sha256"].strip().lower() + source = Path(row["source_path"]) + + # Source must still be intact. + if not source.is_file(): + problems.append(f"Source missing: {source}") + else: + actual = sha256_file(source) + if actual.lower() != expected: + problems.append( + f"Source hash mismatch: {source}\n" + f" expected {expected}\n" + f" actual {actual}" + ) + else: + verified_sources += 1 + + if action == "COPY_NEW": + dest = Path(row["proposed_destination"]) + planned_destinations.add(dest.resolve(strict=False)) + + if not dest.is_file(): + problems.append(f"Copied destination missing: {dest}") + print(f"[{i:>2}/18] COPY MISSING {row['filename']}") + continue + + actual = sha256_file(dest) + if actual.lower() != expected: + problems.append( + f"Destination hash mismatch: {dest}\n" + f" expected {expected}\n" + f" actual {actual}" + ) + print(f"[{i:>2}/18] COPY HASH_FAIL {row['filename']}") + else: + verified_destinations += 1 + print(f"[{i:>2}/18] COPY VERIFIED {row['filename']}") + + elif action == "SKIP_EXACT_DUPLICATE": + target_text = row["proposed_destination"].strip() + targets = [Path(p) for p in target_text.split(" | ") if p] + if not targets: + problems.append( + f"No duplicate target listed for {row['relative_path']}" + ) + print(f"[{i:>2}/18] SKIP NO_TARGET {row['filename']}") + continue + + row_ok = True + for target in targets: + if not target.is_file(): + problems.append(f"Duplicate target missing: {target}") + row_ok = False + continue + actual = sha256_file(target) + if actual.lower() != expected: + problems.append( + f"Duplicate target hash mismatch: {target}\n" + f" expected {expected}\n" + f" actual {actual}" + ) + row_ok = False + + if row_ok: + verified_duplicates += 1 + print(f"[{i:>2}/18] SKIP VERIFIED {row['filename']}") + else: + print(f"[{i:>2}/18] SKIP VERIFY_FAIL {row['filename']}") + + else: + problems.append(f"Unexpected action in manifest: {action!r}") + + # Recovery folder contents must equal exactly the 14 planned destinations. + print() + print("=== RECOVERY FOLDER INVENTORY ===") + + actual_files: set[Path] = set() + for base in (PRIVATE_BASE, INBOX_BASE): + if not base.is_dir(): + problems.append(f"Recovery folder missing: {base}") + print(f"{base}: MISSING") + continue + + files = sorted( + p for p in base.rglob("*") + if p.is_file() and not p.is_symlink() + ) + print(f"{base}: {len(files)} file(s)") + actual_files.update(p.resolve(strict=False) for p in files) + + extra = sorted(actual_files - planned_destinations) + missing = sorted(planned_destinations - actual_files) + + if extra: + problems.append( + "Unexpected extra files in recovery folders:\n " + + "\n ".join(str(p) for p in extra) + ) + if missing: + problems.append( + "Planned files missing from recovery folders:\n " + + "\n ".join(str(p) for p in missing) + ) + + print() + print("=== SUMMARY ===") + print(f"Sources verified: {verified_sources}/18") + print(f"Copied destinations verified: {verified_destinations}/14") + print(f"Skipped duplicates verified: {verified_duplicates}/4") + print(f"Recovery files found: {len(actual_files)}/14") + print(f"Extra recovery files: {len(extra)}") + print(f"Problems: {len(problems)}") + + if problems: + print() + print("POST-APPLY VERIFY FAILED — KEEP SYNCTHING PAUSED.") + for item in problems: + print(" -", item) + return 2 + + print() + print("POST-APPLY VERIFY PASS") + print("The 14 copied files and 4 skipped duplicates are independently verified.") + print("All 18 staging source files remain intact.") + print("No unexpected files exist in the two recovery folders.") + print("This script made no filesystem or Syncthing changes.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/2026-09-14_quick-sorter_extractor-hardening_v9.py b/2026-09-14_quick-sorter_extractor-hardening_v9.py new file mode 100644 index 0000000..fa924b8 --- /dev/null +++ b/2026-09-14_quick-sorter_extractor-hardening_v9.py @@ -0,0 +1,1863 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v9.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import zipfile +import xml.etree.ElementTree as ET +import urllib.request +import unicodedata +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + name_title: str = "" + name_subtitle: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path, root: Optional[Path] = None) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + When ``root`` is supplied, only parent folders *inside* that scan root are + considered. The root directory itself and every ancestor above it are + deliberately excluded. This prevents staging/workspace names such as + ``FULL_STAGING_2026-09-14`` from leaking into document dates. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + if root is not None: + try: + parts = path.parent.relative_to(root).parts + except ValueError: + # A caller supplied a boundary that does not contain this file. + # Be conservative: do not inspect arbitrary ancestors outside it. + return None + else: + # Backwards-compatible behaviour for direct helper calls that do not + # provide a scan root. Production scanning always passes the root. + parts = path.parent.parts + + for part in reversed(parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path, root: Optional[Path] = None) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + # Parent-folder lookup is root-bounded so workspace/staging names can never + # become document metadata. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path, root=root) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + + # Some DOCX files keep visible text in drawing/text-box XML that + # python-docx does not expose through doc.paragraphs or tables. + # Only use this raw XML path as a fallback to avoid duplicating + # ordinary paragraph/table text. + if not text: + xml_parts = [] + try: + with zipfile.ZipFile(path) as zf: + for name in zf.namelist(): + if not (name.startswith("word/") and name.endswith(".xml")): + continue + try: + root = ET.fromstring(zf.read(name)) + except ET.ParseError: + continue + for node in root.iter(): + if node.tag.endswith("}t") and node.text and node.text.strip(): + xml_parts.append(node.text.strip()) + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + except (OSError, zipfile.BadZipFile): + xml_parts = [] + text = _clip(xml_parts) + + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + from pypdf.errors import DependencyError + except ImportError: + return "", "missing:pypdf" + try: + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + except DependencyError as exc: + if "cryptography" in str(exc).lower(): + return "", "missing:cryptography" + raise + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + # python-pptx Slides supports integer indexing but slicing can + # raise AttributeError. Iterate normally and stop after 5 slides. + for slide_index, slide in enumerate(prs.slides): + if slide_index >= 5: + break + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +FILENAME_PREFIXES = { + "Allgemein": "Allgemein", + "Cafe_Bach": "CB", + "Checkpoint": "Checkpoint", + "CSD-Kampagnen": "CSD", + "Ehrenamt": "Ehrenamt", + "FAK": "FAK", + "SP5-Treffen": "SP5", + "Vor-Ort-Arbeit": "VOA", +} + + +def _filename_prefix(destination: str) -> str: + branch = destination.split("/", 1)[0].strip() + return FILENAME_PREFIXES.get(branch, branch.replace("_", "-") or "Datei") + + +GENERIC_FILENAME_LABELS = { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", + "misc", "info", "information", +} + +CATEGORY_NOISE_LABELS = { + "allgemein", "anleitungen", "kommunikation", "materialien", "planung", + "protokolle", "veranstaltung", "veranstaltungen", "verwaltung", "vorlagen", +} + +PREFIX_ALIAS_PHRASES = { + "Cafe_Bach": ("Café Bach", "Cafe Bach", "Cafe_Bach", "cafebach", "CB"), + "Checkpoint": ("Checkpoint", "CP"), + "CSD-Kampagnen": ("CSD-Kampagnen", "CSD Kampagnen", "CSD"), + "Ehrenamt": ("Ehrenamt",), + "FAK": ("FAK",), + "SP5-Treffen": ("SP5-Treffen", "SP5 Treffen", "SP5"), + "Vor-Ort-Arbeit": ("Vor-Ort-Arbeit", "Vor Ort Arbeit", "VOA"), + "Allgemein": ("Allgemein",), +} + + +def _filename_component(text: str, *, title_case_first: bool = False) -> str: + """Return one Windows/Linux-safe filename component while preserving Unicode. + + German spelling/capitalization is kept. Underscores remain reserved for the + schema separators. Cosmetic punctuation such as commas/semicolons is removed + because it adds noise without helping identification. + """ + value = unicodedata.normalize("NFC", str(text or "")).strip() + value = re.sub(r"^\d{4}[-_. ]\d{1,2}[-_. ]\d{1,2}[-_ ]*", "", value) + value = re.sub(r"\.[A-Za-z0-9]{1,6}$", "", value) + value = value.replace("_", " ") + value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", value) + value = re.sub(r"[,;]+", " ", value) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"-+", "-", value).strip("-. ") + if title_case_first and value and value[0].islower(): + value = value[0].upper() + value[1:] + return value + + +def _semantic_key(text: str) -> str: + folded = unicodedata.normalize("NFKD", str(text or "")).casefold() + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.replace("ß", "ss") + return re.sub(r"[^a-z0-9]+", "", folded) + + +def _branch_name(destination: str) -> str: + return destination.split("/", 1)[0].strip() + + +def _prefix_alias_keys(destination: str) -> set[str]: + branch = _branch_name(destination) + values = set(PREFIX_ALIAS_PHRASES.get(branch, ())) + values.add(_filename_prefix(destination)) + values.add(branch) + return {_semantic_key(x) for x in values if x} + + +def _strip_redundant_date_text(text: str, date_guess: str) -> str: + """Remove a full/date-fragment that merely repeats the leading date. + + We deliberately do not remove a bare year: `März 2024` and `Inventur 2023` + can carry useful semantic meaning even when mtime happens to be in that year. + """ + value = str(text or "") + m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", str(date_guess or "")) + if not m: + return value + y, mo, d = m.groups() + yy = y[-2:] + patterns = [ + rf"(? str: + return _filename_component( + _strip_redundant_date_text(text, rec.date_guess), + title_case_first=title_case_first, + ) + + +def _fallback_title(rec: Record) -> str: + for value in (rec.document_type, rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec, title_case_first=True) + if candidate and _semantic_key(candidate) not in GENERIC_FILENAME_LABELS: + return candidate + stem = _component_for_record(Path(rec.filename).stem, rec, title_case_first=True) + if stem and _semantic_key(stem) not in GENERIC_FILENAME_LABELS: + return stem + return "Dokument" + + +def _source_remainder(rec: Record, *blocked_values: str) -> str: + """Recover useful detail from the original filename when model fields are noisy. + + This is intentionally conservative: it is only used as a fallback subtitle. + Examples: `Getränkeliste Verzehrkarte` -> `Verzehrkarte`; + `Inventur Café Bach 2023` -> `2023`. + """ + raw = _strip_redundant_date_text(Path(rec.filename).stem, rec.date_guess) + + # Remove the branch/prefix phrase before token-level subtraction. + for phrase in sorted(PREFIX_ALIAS_PHRASES.get(_branch_name(rec.destination), ()), key=len, reverse=True): + raw = re.sub(rf"(?= 3): + continue + kept.append(candidate) + + return _filename_component(" ".join(kept)) + + +def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: + blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} + blocked |= _prefix_alias_keys(rec.destination) + blocked |= CATEGORY_NOISE_LABELS + + for value in (rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec) + key = _semantic_key(candidate) + if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS: + return candidate + + return _source_remainder(rec, title, prefix, rec.topic) + + +def build_structured_filename(rec: Record) -> str: + """Build YYYY-MM-DD_PREFIX_Title_Subtitle[_vN].ext deterministically.""" + prefix = _filename_component(_filename_prefix(rec.destination), title_case_first=True) + prefix_aliases = _prefix_alias_keys(rec.destination) + + raw_title = _component_for_record(rec.name_title, rec, title_case_first=True) + raw_subtitle = _component_for_record(rec.name_subtitle, rec) + title_key = _semantic_key(raw_title) + subtitle_key = _semantic_key(raw_subtitle) + + # Promote a useful subtitle when the model supplied a generic label like + # `Info`; this avoids names such as Allgemein_Info_SurvStat... In that + # special case, do not immediately add another model-derived subtitle: only + # recover a genuinely distinct remainder from the original filename. + promoted_generic_title = False + if title_key in GENERIC_FILENAME_LABELS and raw_subtitle: + title = raw_subtitle + raw_subtitle = "" + promoted_generic_title = True + elif not raw_title or title_key in prefix_aliases: + title = _fallback_title(rec) + else: + title = raw_title + + title_key = _semantic_key(title) + blocked_subtitle = {title_key, _semantic_key(prefix)} | prefix_aliases | CATEGORY_NOISE_LABELS + subtitle_key = _semantic_key(raw_subtitle) + + if promoted_generic_title: + subtitle = _source_remainder(rec, title, prefix, rec.topic) + elif ( + not raw_subtitle + or subtitle_key in blocked_subtitle + or subtitle_key in GENERIC_FILENAME_LABELS + ): + subtitle = _fallback_subtitle(rec, title, prefix) + else: + subtitle = raw_subtitle + + # A subtitle can still repeat the branch semantically (e.g. `Café-Bach`). + if _semantic_key(subtitle) in prefix_aliases | {title_key, _semantic_key(prefix)}: + subtitle = _source_remainder(rec, title, prefix, rec.topic) + + parts = [rec.date_guess, prefix, title] + if subtitle and _semantic_key(subtitle) not in {_semantic_key(x) for x in parts}: + parts.append(subtitle) + + ver = _filename_component(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + elif not re.fullmatch(r"v\d+", ver, flags=re.I): + ver = f"v{ver}" + parts.append(ver) + + # Keep enough headroom for the destination path and collision suffixes. + stem = "_".join(x for x in parts if x) + if len(stem) > 180: + overflow = len(stem) - 180 + if subtitle and len(subtitle) > overflow + 12: + subtitle = subtitle[: len(subtitle) - overflow].rstrip("-_. ") + parts = [rec.date_guess, prefix, title] + ([subtitle] if subtitle else []) + if ver: + parts.append(ver) + stem = "_".join(x for x in parts if x) + stem = stem[:180].rstrip("-_. ") + return stem + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + current = str(p.parent.relative_to(root)) + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + source_context = root.name + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{root.name}/{relative_parent}" + d, source = best_date(p, root=root) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +- If uncertain, keep the current folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- For destination, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + rec.name_title = str(result.get("title", "")).strip() + rec.name_subtitle = str(result.get("subtitle", "")).strip() + rec.proposed_name = build_structured_filename(rec) + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # Every V7 proposal follows the structured naming scheme. If its date + # comes only from filesystem mtime, the date is useful but historically + # unverified, so require explicit human review before apply. + if rec.date_source == "mtime": + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates(rec, self.destinations, aliases=aliases) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("PDF AES", "cryptography", "cryptography"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V7 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-14_quick-sorter_naming-cleanup_v7.py b/2026-09-14_quick-sorter_naming-cleanup_v7.py new file mode 100644 index 0000000..1a2d398 --- /dev/null +++ b/2026-09-14_quick-sorter_naming-cleanup_v7.py @@ -0,0 +1,1804 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v7.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import urllib.request +import unicodedata +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + name_title: str = "" + name_subtitle: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + for part in reversed(path.parent.parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + except ImportError: + return "", "missing:pypdf" + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + for slide in prs.slides[:5]: + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +FILENAME_PREFIXES = { + "Allgemein": "Allgemein", + "Cafe_Bach": "CB", + "Checkpoint": "Checkpoint", + "CSD-Kampagnen": "CSD", + "Ehrenamt": "Ehrenamt", + "FAK": "FAK", + "SP5-Treffen": "SP5", + "Vor-Ort-Arbeit": "VOA", +} + + +def _filename_prefix(destination: str) -> str: + branch = destination.split("/", 1)[0].strip() + return FILENAME_PREFIXES.get(branch, branch.replace("_", "-") or "Datei") + + +GENERIC_FILENAME_LABELS = { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", + "misc", "info", "information", +} + +CATEGORY_NOISE_LABELS = { + "allgemein", "anleitungen", "kommunikation", "materialien", "planung", + "protokolle", "veranstaltung", "veranstaltungen", "verwaltung", "vorlagen", +} + +PREFIX_ALIAS_PHRASES = { + "Cafe_Bach": ("Café Bach", "Cafe Bach", "Cafe_Bach", "cafebach", "CB"), + "Checkpoint": ("Checkpoint", "CP"), + "CSD-Kampagnen": ("CSD-Kampagnen", "CSD Kampagnen", "CSD"), + "Ehrenamt": ("Ehrenamt",), + "FAK": ("FAK",), + "SP5-Treffen": ("SP5-Treffen", "SP5 Treffen", "SP5"), + "Vor-Ort-Arbeit": ("Vor-Ort-Arbeit", "Vor Ort Arbeit", "VOA"), + "Allgemein": ("Allgemein",), +} + + +def _filename_component(text: str, *, title_case_first: bool = False) -> str: + """Return one Windows/Linux-safe filename component while preserving Unicode. + + German spelling/capitalization is kept. Underscores remain reserved for the + schema separators. Cosmetic punctuation such as commas/semicolons is removed + because it adds noise without helping identification. + """ + value = unicodedata.normalize("NFC", str(text or "")).strip() + value = re.sub(r"^\d{4}[-_. ]\d{1,2}[-_. ]\d{1,2}[-_ ]*", "", value) + value = re.sub(r"\.[A-Za-z0-9]{1,6}$", "", value) + value = value.replace("_", " ") + value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", value) + value = re.sub(r"[,;]+", " ", value) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"-+", "-", value).strip("-. ") + if title_case_first and value and value[0].islower(): + value = value[0].upper() + value[1:] + return value + + +def _semantic_key(text: str) -> str: + folded = unicodedata.normalize("NFKD", str(text or "")).casefold() + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.replace("ß", "ss") + return re.sub(r"[^a-z0-9]+", "", folded) + + +def _branch_name(destination: str) -> str: + return destination.split("/", 1)[0].strip() + + +def _prefix_alias_keys(destination: str) -> set[str]: + branch = _branch_name(destination) + values = set(PREFIX_ALIAS_PHRASES.get(branch, ())) + values.add(_filename_prefix(destination)) + values.add(branch) + return {_semantic_key(x) for x in values if x} + + +def _strip_redundant_date_text(text: str, date_guess: str) -> str: + """Remove a full/date-fragment that merely repeats the leading date. + + We deliberately do not remove a bare year: `März 2024` and `Inventur 2023` + can carry useful semantic meaning even when mtime happens to be in that year. + """ + value = str(text or "") + m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", str(date_guess or "")) + if not m: + return value + y, mo, d = m.groups() + yy = y[-2:] + patterns = [ + rf"(? str: + return _filename_component( + _strip_redundant_date_text(text, rec.date_guess), + title_case_first=title_case_first, + ) + + +def _fallback_title(rec: Record) -> str: + for value in (rec.document_type, rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec, title_case_first=True) + if candidate and _semantic_key(candidate) not in GENERIC_FILENAME_LABELS: + return candidate + stem = _component_for_record(Path(rec.filename).stem, rec, title_case_first=True) + if stem and _semantic_key(stem) not in GENERIC_FILENAME_LABELS: + return stem + return "Dokument" + + +def _source_remainder(rec: Record, *blocked_values: str) -> str: + """Recover useful detail from the original filename when model fields are noisy. + + This is intentionally conservative: it is only used as a fallback subtitle. + Examples: `Getränkeliste Verzehrkarte` -> `Verzehrkarte`; + `Inventur Café Bach 2023` -> `2023`. + """ + raw = _strip_redundant_date_text(Path(rec.filename).stem, rec.date_guess) + + # Remove the branch/prefix phrase before token-level subtraction. + for phrase in sorted(PREFIX_ALIAS_PHRASES.get(_branch_name(rec.destination), ()), key=len, reverse=True): + raw = re.sub(rf"(?= 3): + continue + kept.append(candidate) + + return _filename_component(" ".join(kept)) + + +def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: + blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} + blocked |= _prefix_alias_keys(rec.destination) + blocked |= CATEGORY_NOISE_LABELS + + for value in (rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec) + key = _semantic_key(candidate) + if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS: + return candidate + + return _source_remainder(rec, title, prefix, rec.topic) + + +def build_structured_filename(rec: Record) -> str: + """Build YYYY-MM-DD_PREFIX_Title_Subtitle[_vN].ext deterministically.""" + prefix = _filename_component(_filename_prefix(rec.destination), title_case_first=True) + prefix_aliases = _prefix_alias_keys(rec.destination) + + raw_title = _component_for_record(rec.name_title, rec, title_case_first=True) + raw_subtitle = _component_for_record(rec.name_subtitle, rec) + title_key = _semantic_key(raw_title) + subtitle_key = _semantic_key(raw_subtitle) + + # Promote a useful subtitle when the model supplied a generic label like + # `Info`; this avoids names such as Allgemein_Info_SurvStat... In that + # special case, do not immediately add another model-derived subtitle: only + # recover a genuinely distinct remainder from the original filename. + promoted_generic_title = False + if title_key in GENERIC_FILENAME_LABELS and raw_subtitle: + title = raw_subtitle + raw_subtitle = "" + promoted_generic_title = True + elif not raw_title or title_key in prefix_aliases: + title = _fallback_title(rec) + else: + title = raw_title + + title_key = _semantic_key(title) + blocked_subtitle = {title_key, _semantic_key(prefix)} | prefix_aliases | CATEGORY_NOISE_LABELS + subtitle_key = _semantic_key(raw_subtitle) + + if promoted_generic_title: + subtitle = _source_remainder(rec, title, prefix, rec.topic) + elif ( + not raw_subtitle + or subtitle_key in blocked_subtitle + or subtitle_key in GENERIC_FILENAME_LABELS + ): + subtitle = _fallback_subtitle(rec, title, prefix) + else: + subtitle = raw_subtitle + + # A subtitle can still repeat the branch semantically (e.g. `Café-Bach`). + if _semantic_key(subtitle) in prefix_aliases | {title_key, _semantic_key(prefix)}: + subtitle = _source_remainder(rec, title, prefix, rec.topic) + + parts = [rec.date_guess, prefix, title] + if subtitle and _semantic_key(subtitle) not in {_semantic_key(x) for x in parts}: + parts.append(subtitle) + + ver = _filename_component(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + elif not re.fullmatch(r"v\d+", ver, flags=re.I): + ver = f"v{ver}" + parts.append(ver) + + # Keep enough headroom for the destination path and collision suffixes. + stem = "_".join(x for x in parts if x) + if len(stem) > 180: + overflow = len(stem) - 180 + if subtitle and len(subtitle) > overflow + 12: + subtitle = subtitle[: len(subtitle) - overflow].rstrip("-_. ") + parts = [rec.date_guess, prefix, title] + ([subtitle] if subtitle else []) + if ver: + parts.append(ver) + stem = "_".join(x for x in parts if x) + stem = stem[:180].rstrip("-_. ") + return stem + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + current = str(p.parent.relative_to(root)) + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + source_context = root.name + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{root.name}/{relative_parent}" + d, source = best_date(p) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +- If uncertain, keep the current folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- For destination, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + rec.name_title = str(result.get("title", "")).strip() + rec.name_subtitle = str(result.get("subtitle", "")).strip() + rec.proposed_name = build_structured_filename(rec) + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # Every V7 proposal follows the structured naming scheme. If its date + # comes only from filesystem mtime, the date is useful but historically + # unverified, so require explicit human review before apply. + if rec.date_source == "mtime": + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates(rec, self.destinations, aliases=aliases) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V7 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-14_quick-sorter_root-bounded-dates_v8.py b/2026-09-14_quick-sorter_root-bounded-dates_v8.py new file mode 100644 index 0000000..5f5fff4 --- /dev/null +++ b/2026-09-14_quick-sorter_root-bounded-dates_v8.py @@ -0,0 +1,1823 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v7.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import urllib.request +import unicodedata +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + name_title: str = "" + name_subtitle: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path, root: Optional[Path] = None) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + When ``root`` is supplied, only parent folders *inside* that scan root are + considered. The root directory itself and every ancestor above it are + deliberately excluded. This prevents staging/workspace names such as + ``FULL_STAGING_2026-09-14`` from leaking into document dates. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + if root is not None: + try: + parts = path.parent.relative_to(root).parts + except ValueError: + # A caller supplied a boundary that does not contain this file. + # Be conservative: do not inspect arbitrary ancestors outside it. + return None + else: + # Backwards-compatible behaviour for direct helper calls that do not + # provide a scan root. Production scanning always passes the root. + parts = path.parent.parts + + for part in reversed(parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path, root: Optional[Path] = None) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + # Parent-folder lookup is root-bounded so workspace/staging names can never + # become document metadata. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path, root=root) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + except ImportError: + return "", "missing:pypdf" + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + for slide in prs.slides[:5]: + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +FILENAME_PREFIXES = { + "Allgemein": "Allgemein", + "Cafe_Bach": "CB", + "Checkpoint": "Checkpoint", + "CSD-Kampagnen": "CSD", + "Ehrenamt": "Ehrenamt", + "FAK": "FAK", + "SP5-Treffen": "SP5", + "Vor-Ort-Arbeit": "VOA", +} + + +def _filename_prefix(destination: str) -> str: + branch = destination.split("/", 1)[0].strip() + return FILENAME_PREFIXES.get(branch, branch.replace("_", "-") or "Datei") + + +GENERIC_FILENAME_LABELS = { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", + "misc", "info", "information", +} + +CATEGORY_NOISE_LABELS = { + "allgemein", "anleitungen", "kommunikation", "materialien", "planung", + "protokolle", "veranstaltung", "veranstaltungen", "verwaltung", "vorlagen", +} + +PREFIX_ALIAS_PHRASES = { + "Cafe_Bach": ("Café Bach", "Cafe Bach", "Cafe_Bach", "cafebach", "CB"), + "Checkpoint": ("Checkpoint", "CP"), + "CSD-Kampagnen": ("CSD-Kampagnen", "CSD Kampagnen", "CSD"), + "Ehrenamt": ("Ehrenamt",), + "FAK": ("FAK",), + "SP5-Treffen": ("SP5-Treffen", "SP5 Treffen", "SP5"), + "Vor-Ort-Arbeit": ("Vor-Ort-Arbeit", "Vor Ort Arbeit", "VOA"), + "Allgemein": ("Allgemein",), +} + + +def _filename_component(text: str, *, title_case_first: bool = False) -> str: + """Return one Windows/Linux-safe filename component while preserving Unicode. + + German spelling/capitalization is kept. Underscores remain reserved for the + schema separators. Cosmetic punctuation such as commas/semicolons is removed + because it adds noise without helping identification. + """ + value = unicodedata.normalize("NFC", str(text or "")).strip() + value = re.sub(r"^\d{4}[-_. ]\d{1,2}[-_. ]\d{1,2}[-_ ]*", "", value) + value = re.sub(r"\.[A-Za-z0-9]{1,6}$", "", value) + value = value.replace("_", " ") + value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", value) + value = re.sub(r"[,;]+", " ", value) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"-+", "-", value).strip("-. ") + if title_case_first and value and value[0].islower(): + value = value[0].upper() + value[1:] + return value + + +def _semantic_key(text: str) -> str: + folded = unicodedata.normalize("NFKD", str(text or "")).casefold() + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.replace("ß", "ss") + return re.sub(r"[^a-z0-9]+", "", folded) + + +def _branch_name(destination: str) -> str: + return destination.split("/", 1)[0].strip() + + +def _prefix_alias_keys(destination: str) -> set[str]: + branch = _branch_name(destination) + values = set(PREFIX_ALIAS_PHRASES.get(branch, ())) + values.add(_filename_prefix(destination)) + values.add(branch) + return {_semantic_key(x) for x in values if x} + + +def _strip_redundant_date_text(text: str, date_guess: str) -> str: + """Remove a full/date-fragment that merely repeats the leading date. + + We deliberately do not remove a bare year: `März 2024` and `Inventur 2023` + can carry useful semantic meaning even when mtime happens to be in that year. + """ + value = str(text or "") + m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", str(date_guess or "")) + if not m: + return value + y, mo, d = m.groups() + yy = y[-2:] + patterns = [ + rf"(? str: + return _filename_component( + _strip_redundant_date_text(text, rec.date_guess), + title_case_first=title_case_first, + ) + + +def _fallback_title(rec: Record) -> str: + for value in (rec.document_type, rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec, title_case_first=True) + if candidate and _semantic_key(candidate) not in GENERIC_FILENAME_LABELS: + return candidate + stem = _component_for_record(Path(rec.filename).stem, rec, title_case_first=True) + if stem and _semantic_key(stem) not in GENERIC_FILENAME_LABELS: + return stem + return "Dokument" + + +def _source_remainder(rec: Record, *blocked_values: str) -> str: + """Recover useful detail from the original filename when model fields are noisy. + + This is intentionally conservative: it is only used as a fallback subtitle. + Examples: `Getränkeliste Verzehrkarte` -> `Verzehrkarte`; + `Inventur Café Bach 2023` -> `2023`. + """ + raw = _strip_redundant_date_text(Path(rec.filename).stem, rec.date_guess) + + # Remove the branch/prefix phrase before token-level subtraction. + for phrase in sorted(PREFIX_ALIAS_PHRASES.get(_branch_name(rec.destination), ()), key=len, reverse=True): + raw = re.sub(rf"(?= 3): + continue + kept.append(candidate) + + return _filename_component(" ".join(kept)) + + +def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: + blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} + blocked |= _prefix_alias_keys(rec.destination) + blocked |= CATEGORY_NOISE_LABELS + + for value in (rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec) + key = _semantic_key(candidate) + if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS: + return candidate + + return _source_remainder(rec, title, prefix, rec.topic) + + +def build_structured_filename(rec: Record) -> str: + """Build YYYY-MM-DD_PREFIX_Title_Subtitle[_vN].ext deterministically.""" + prefix = _filename_component(_filename_prefix(rec.destination), title_case_first=True) + prefix_aliases = _prefix_alias_keys(rec.destination) + + raw_title = _component_for_record(rec.name_title, rec, title_case_first=True) + raw_subtitle = _component_for_record(rec.name_subtitle, rec) + title_key = _semantic_key(raw_title) + subtitle_key = _semantic_key(raw_subtitle) + + # Promote a useful subtitle when the model supplied a generic label like + # `Info`; this avoids names such as Allgemein_Info_SurvStat... In that + # special case, do not immediately add another model-derived subtitle: only + # recover a genuinely distinct remainder from the original filename. + promoted_generic_title = False + if title_key in GENERIC_FILENAME_LABELS and raw_subtitle: + title = raw_subtitle + raw_subtitle = "" + promoted_generic_title = True + elif not raw_title or title_key in prefix_aliases: + title = _fallback_title(rec) + else: + title = raw_title + + title_key = _semantic_key(title) + blocked_subtitle = {title_key, _semantic_key(prefix)} | prefix_aliases | CATEGORY_NOISE_LABELS + subtitle_key = _semantic_key(raw_subtitle) + + if promoted_generic_title: + subtitle = _source_remainder(rec, title, prefix, rec.topic) + elif ( + not raw_subtitle + or subtitle_key in blocked_subtitle + or subtitle_key in GENERIC_FILENAME_LABELS + ): + subtitle = _fallback_subtitle(rec, title, prefix) + else: + subtitle = raw_subtitle + + # A subtitle can still repeat the branch semantically (e.g. `Café-Bach`). + if _semantic_key(subtitle) in prefix_aliases | {title_key, _semantic_key(prefix)}: + subtitle = _source_remainder(rec, title, prefix, rec.topic) + + parts = [rec.date_guess, prefix, title] + if subtitle and _semantic_key(subtitle) not in {_semantic_key(x) for x in parts}: + parts.append(subtitle) + + ver = _filename_component(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + elif not re.fullmatch(r"v\d+", ver, flags=re.I): + ver = f"v{ver}" + parts.append(ver) + + # Keep enough headroom for the destination path and collision suffixes. + stem = "_".join(x for x in parts if x) + if len(stem) > 180: + overflow = len(stem) - 180 + if subtitle and len(subtitle) > overflow + 12: + subtitle = subtitle[: len(subtitle) - overflow].rstrip("-_. ") + parts = [rec.date_guess, prefix, title] + ([subtitle] if subtitle else []) + if ver: + parts.append(ver) + stem = "_".join(x for x in parts if x) + stem = stem[:180].rstrip("-_. ") + return stem + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + current = str(p.parent.relative_to(root)) + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + source_context = root.name + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{root.name}/{relative_parent}" + d, source = best_date(p, root=root) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +- If uncertain, keep the current folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- For destination, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + rec.name_title = str(result.get("title", "")).strip() + rec.name_subtitle = str(result.get("subtitle", "")).strip() + rec.proposed_name = build_structured_filename(rec) + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # Every V7 proposal follows the structured naming scheme. If its date + # comes only from filesystem mtime, the date is useful but historically + # unverified, so require explicit human review before apply. + if rec.date_source == "mtime": + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates(rec, self.destinations, aliases=aliases) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V7 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-14_requirements_document-sorter_v9.txt b/2026-09-14_requirements_document-sorter_v9.txt new file mode 100644 index 0000000..85856db --- /dev/null +++ b/2026-09-14_requirements_document-sorter_v9.txt @@ -0,0 +1,8 @@ +python-docx +pypdf +openpyxl +python-pptx +odfpy +Pillow +fonttools +cryptography diff --git a/2026-09-14_scope-triage_curated-archive-provenance_v3.py b/2026-09-14_scope-triage_curated-archive-provenance_v3.py new file mode 100644 index 0000000..2f51c1e --- /dev/null +++ b/2026-09-14_scope-triage_curated-archive-provenance_v3.py @@ -0,0 +1,624 @@ +#!/usr/bin/env python3 +""" +Scope triage v2 for the staged document-recovery project. + +Purpose: +- Read-only top-level classification before detailed sorting. +- Exactly three outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX. +- One file per LLM request. +- Curated files under ARCHIVE_EXTRACTED/WORK are deterministically 04_ARBEIT. +- Daily model first for ordinary SOURCE files; Architect verifies uncertain or policy-fragile cases. +- Architect receives a larger reasoning/output budget. +- If Daily and Architect disagree, route conservatively to 01_INBOX. +- Report keeps both model votes and review flags for auditability. +- Resume-safe TSV report written after every completed file. + +This script does NOT move, rename, delete, or modify source/target files. +It only reads files and writes a TSV report under the staging MANIFESTS folder. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import os +import tempfile +import urllib.request +from collections import Counter +from pathlib import Path +from typing import Optional + +import quick_sorter as q + +READABLE = { + ".pdf", ".docx", ".xlsx", ".pptx", + ".odt", ".txt", ".md", ".csv", +} + +MIXED_SOURCE_NAME = "Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden" + +SCOPE_PROMPT = """You are a conservative top-level file-ownership classifier. +Your only job is to decide the PRIMARY ownership/life area of ONE file. + +Return one JSON OBJECT only with exactly these keys: +scope, confidence, reason + +Allowed scope values: +- 04_ARBEIT = primarily an operational/professional record used for the user's employer, + job role, work projects, work teams, professional events, or work administration. +- 03_PRIVAT = primarily the user's own personal/private record as an individual. +- 01_INBOX = ownership/purpose is genuinely unclear, mixed, generic, or unsafe to decide. + +CRITICAL OWNERSHIP RULE: +Do NOT ask merely whether the subject is 'related to work'. Ask who primarily owns/uses +the record and why it exists. A document can mention work but still be a private record. + +Decision rules: +- Source folder is provenance/evidence, NOT authority. +- A file found on a work computer can still be private. +- A file found in a private folder can still be work material. +- Choose 04_ARBEIT for correspondence written/sent in a professional role, employer + projects, Checkpoint/Aidshilfe/Cafe-Bach operations, work-team records, professional + prevention work, work-event planning, employer administration, and work expenses or + reimbursements created for professional activity. +- Friendly or personal wording inside professional correspondence does NOT make it private. +- Choose 03_PRIVAT for the user's own employment contract, payslip, personal bank/account + documents, personal insurance/tax records, personal travel/event tickets, personal daily + planning, and personal health/vaccination records. The user's own employment contract is + PRIVATE documentation even though employment is work-related. +- Generic sample files, blank import templates, font/software licences, generic reference + material, or context-free technical assets are NOT automatically work. If their concrete + use/ownership is unclear, choose 01_INBOX. +- Generic medical/scientific guidance is not automatically a personal health record. If no + personal patient information and no clear work use is present, choose 01_INBOX. +- Product comparisons/offers are 04_ARBEIT only when the excerpt shows a concrete employer + project or professional purchase context; otherwise choose 01_INBOX. +- Do NOT route to DEV, VAULT, ARCHIV, or any other area in this recovery pass. +- If work/private ownership cannot be determined safely, choose 01_INBOX. + +Anchor examples: +- A New-Year greeting to a venue that says the sender works for Checkpoint/Aidshilfe and + discusses prevention materials/collaboration -> 04_ARBEIT. +- The user's own Arbeitsvertrag -> 03_PRIVAT. +- A blank/sample contact-import CSV -> 01_INBOX unless actual work records are present. +- A generic Roboto/font LICENSE -> 01_INBOX unless tied to a concrete work deliverable. + +Confidence calibration: +- 0.90-0.95 only with direct ownership/purpose evidence. +- 0.75-0.89 when likely but some interpretation is required. +- <=0.70 when evidence is thin or mixed; prefer 01_INBOX. +- Never use high confidence merely because of the source folder. +- reason: maximum 14 words, factual and concise. +""" + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def build_queue(stage: Path, policy_path: Path) -> list[dict]: + source = stage / "SOURCE" + target = stage / "TARGET" + extracted = stage / "ARCHIVE_EXTRACTED" / "WORK" + + policy = q.load_policy(policy_path) + destinations = q.allowed_destinations(source, policy, "04_ARBEIT") + aliases = policy.get("_strong_aliases", {}).get("04_ARBEIT", {}) + + records = q.scan(source, target, destinations, aliases=aliases) + q.global_duplicate_prepass(records, source, target) + + items: list[dict] = [] + + for rec in records: + if rec.status != "pending": + continue + if q.is_archive_file(rec.path): + continue + if rec.ext.lower() not in READABLE: + continue + + items.append({ + "key": "SOURCE\t" + rec.rel_path, + "origin": "SOURCE", + "path": rec.path, + "rel_path": rec.rel_path, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + }) + + source_hashes = { + sha256(p) + for p in source.rglob("*") + if p.is_file() + } + target_hashes = { + sha256(p) + for p in target.rglob("*") + if p.is_file() + } + + seen: set[str] = set() + + for p in sorted(extracted.rglob("*")): + if not p.is_file() or p.suffix.lower() not in READABLE: + continue + + digest = sha256(p) + if digest in source_hashes or digest in target_hashes or digest in seen: + continue + seen.add(digest) + + rel = str(p.relative_to(extracted)) + # Preserve meaningful archive provenance at least at recovery-group level. + group = Path(rel).parts[0] if Path(rel).parts else "ARCHIVE" + source_context = f"archive-recovered/{group}/{Path(rel).parent}" + + date_guess, date_source = q.best_date(p, root=extracted) + + items.append({ + "key": "ARCHIVE\t" + rel, + "origin": "ARCHIVE", + "path": p, + "rel_path": rel, + "source_context": source_context, + "date_guess": date_guess, + "date_source": date_source, + }) + + return items + + +def model_limits(model: str) -> tuple[int, int]: + # Reasoning models may spend substantial tokens before emitting their tiny JSON answer. + name = model.lower() + if "gpt-oss" in name or "architect" in name: + return 2500, 240 + if "35b" in name or "debug" in name: + return 6000, 300 + return 500, 120 + + +def extract_json_object(text: str) -> dict: + text = text.strip() + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:].lstrip() + + try: + value = json.loads(text) + except json.JSONDecodeError: + start = text.find("{") + end = text.rfind("}") + if start < 0 or end <= start: + raise + value = json.loads(text[start:end + 1]) + + if not isinstance(value, dict): + raise ValueError("model did not return a JSON object") + return value + + +def call_scope_model(item: dict, endpoint: str, model: str) -> dict: + text, content_status = q.extract_excerpt(item["path"]) + + payload_item = { + "filename": item["path"].name, + "relative_path": item["rel_path"], + "source_context": item["source_context"], + "date_guess": item["date_guess"], + "date_source": item["date_source"], + "content_status": content_status, + "excerpt": text[:q.MAX_EXCERPT], + } + + prompt = ( + SCOPE_PROMPT + + "\nINPUT:\n" + + json.dumps(payload_item, ensure_ascii=False) + ) + + max_tokens, timeout = model_limits(model) + request_payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + + req = urllib.request.Request( + endpoint, + data=json.dumps(request_payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + + choice = data["choices"][0] + answer = choice.get("message", {}).get("content") or "" + result = extract_json_object(answer) + + scope = str(result.get("scope", "")).strip() + try: + confidence = float(result.get("confidence", 0)) + except (TypeError, ValueError): + confidence = 0.0 + + reason = str(result.get("reason", "")).strip()[:160] + + if scope not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: + raise ValueError(f"invalid scope: {scope!r}") + + confidence = max(0.0, min(1.0, confidence)) + + return { + "scope": scope, + "confidence": confidence, + "reason": reason, + "content_status": content_status, + } + + +def scope_review_flags(item: dict, result: Optional[dict]) -> list[str]: + flags: list[str] = [] + if not isinstance(result, dict): + return ["no-primary-result"] + + scope = str(result.get("scope", "")) + filename = item["path"].name.lower() + rel = str(item.get("rel_path", "")).lower() + + try: + excerpt, _ = q.extract_excerpt(item["path"]) + except Exception: + excerpt = "" + content = excerpt.lower() + combined = " ".join((filename, rel, content)) + + # Classes where broad 'work-related' reasoning often confuses ownership. + always_fragile = ( + "anschreiben", "arbeitsvertrag", "ticket", "konto", + "kundeninformation", "sample", "testsample", "license", "lizenz", + "preisvergleich", "impf", "tagesplan", + ) + if any(term in filename for term in always_fragile): + flags.append("ownership-fragile-filename") + + strong_work = ( + "aidshilfe", "checkpoint", "cafe bach", "café bach", "herzenslust", + "prävention", "praevention", "vor-ort", "fak", "dienstplan", + ) + if scope == "03_PRIVAT" and any(term in combined for term in strong_work): + flags.append("private-vs-strong-work-evidence") + + personal_record = ( + "arbeitsvertrag", "konto", "ticket", "versicherung", "steuer", + "gehalts", "lohnabrechnung", "impfpass", + ) + if scope == "04_ARBEIT" and any(term in combined for term in personal_record): + flags.append("work-vs-personal-record-class") + + generic_asset = ("sample", "testsample", "license", "lizenz") + if scope == "04_ARBEIT" and any(term in filename for term in generic_asset): + flags.append("work-vs-generic-asset") + + return flags + + +def needs_fallback(item: dict, result: Optional[dict]) -> tuple[bool, list[str]]: + flags = scope_review_flags(item, result) + if not isinstance(result, dict): + return True, flags + if result.get("scope") not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: + flags.append("invalid-primary-scope") + return True, flags + try: + if float(result.get("confidence", 0)) < 0.85: + flags.append("primary-confidence-below-0.85") + except (TypeError, ValueError): + flags.append("invalid-primary-confidence") + + status = str(result.get("content_status", "")) + if "no-text" in status or status.startswith("error:") or status.startswith("missing:"): + flags.append("limited-content") + + return bool(flags), flags + + +def classify(item: dict, endpoint: str, model: str, fallback_model: str) -> dict: + # ARCHIVE items in this queue come only from ARCHIVE_EXTRACTED/WORK. + # That directory was already curated and verified as work material during + # the archive-recovery phase; calendar/private archive payload is held + # separately under HOLD_CALENDAR and never enters build_queue(). + if item.get("origin") == "ARCHIVE": + return { + "scope": "04_ARBEIT", + "confidence": 0.99, + "reason": "Curated work archive provenance verified before scope triage.", + "content_status": "provenance:curated-work-archive", + "model_path": "deterministic-provenance", + "review_flags": "", + "primary_scope": "", + "primary_confidence": "", + "fallback_scope": "", + "fallback_confidence": "", + } + + primary = None + primary_error = "" + + try: + primary = call_scope_model(item, endpoint, model) + except Exception as exc: + primary_error = f"{type(exc).__name__}: {exc}" + + use_fallback, review_flags = needs_fallback(item, primary) + use_fallback = bool(fallback_model) and use_fallback + + def decorate(result: dict, model_path: str, fallback: Optional[dict] = None) -> dict: + out = dict(result) + out["model_path"] = model_path + out["review_flags"] = ",".join(review_flags) + out["primary_scope"] = primary.get("scope", "") if primary else "" + out["primary_confidence"] = primary.get("confidence", "") if primary else "" + out["fallback_scope"] = fallback.get("scope", "") if fallback else "" + out["fallback_confidence"] = fallback.get("confidence", "") if fallback else "" + return out + + if not use_fallback: + return decorate(primary, "primary") + + fallback = None + fallback_error = "" + try: + fallback = call_scope_model(item, endpoint, fallback_model) + except Exception as exc: + fallback_error = f"{type(exc).__name__}: {exc}" + + if primary is None and fallback is None: + result = { + "scope": "01_INBOX", + "confidence": 0.0, + "reason": f"both models failed: {primary_error}; {fallback_error}"[:160], + "content_status": "model-error", + } + return decorate(result, "failure") + + if primary is None: + return decorate(fallback, "fallback-only", fallback) + + if fallback is None: + result = dict(primary) + result["confidence"] = min(float(result.get("confidence", 0)), 0.69) + result["reason"] = ( + f"fallback failed; {result.get('reason', '')}" + )[:160] + return decorate(result, "primary-fallback-failed") + + if primary["scope"] == fallback["scope"]: + # Agreement is useful, but keep confidence conservative: never higher than the weaker vote. + result = dict(fallback) + result["confidence"] = min( + float(primary.get("confidence", 0)), + float(fallback.get("confidence", 0)), + ) + return decorate(result, "models-agree", fallback) + + result = { + "scope": "01_INBOX", + "confidence": 0.50, + "reason": ( + f"model disagreement: {model}={primary['scope']}; " + f"{fallback_model}={fallback['scope']}" + )[:160], + "content_status": primary.get("content_status", ""), + } + return decorate(result, "models-disagree", fallback) + + +FIELDS = [ + "key", "origin", "relative_path", "source_context", + "filename", "extension", "date_guess", "date_source", + "scope", "confidence", "reason", "content_status", "model_path", + "review_flags", "primary_scope", "primary_confidence", + "fallback_scope", "fallback_confidence", +] + + +def load_existing(report: Path) -> dict[str, dict]: + if not report.is_file(): + return {} + rows = {} + with report.open(newline="", encoding="utf-8") as f: + reader = csv.DictReader(f, delimiter="\t") + for row in reader: + key = row.get("key", "") + if key: + rows[key] = row + return rows + + +def write_report_atomic(report: Path, rows: dict[str, dict], order: list[str]) -> None: + report.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp( + prefix=report.name + ".", + suffix=".tmp", + dir=report.parent, + text=True, + ) + os.close(fd) + temp = Path(temp_name) + try: + with temp.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=FIELDS, delimiter="\t") + writer.writeheader() + for key in order: + if key in rows: + writer.writerow({name: rows[key].get(name, "") for name in FIELDS}) + temp.replace(report) + finally: + if temp.exists(): + temp.unlink() + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument( + "--stage", + default="/data/90_SORT_TEST_ARBEIT_IMPORT/FULL_STAGING_2026-09-14", + ) + ap.add_argument( + "--policy", + default=str(Path(__file__).with_name("sorter_policy.json")), + ) + ap.add_argument("--endpoint", default=q.DEFAULT_ENDPOINT) + ap.add_argument("--model", default=q.DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=q.DEFAULT_FALLBACK_MODEL) + ap.add_argument("--no-fallback", action="store_true") + ap.add_argument( + "--report", + default="", + help="TSV report path; defaults under STAGE/MANIFESTS", + ) + ap.add_argument( + "--pilot-mixed", + action="store_true", + help="Only the original readable files from the known mixed backup", + ) + ap.add_argument("--limit", type=int, default=0) + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + stage = Path(args.stage).expanduser().resolve() + policy_path = Path(args.policy).expanduser().resolve() + + if not stage.is_dir(): + raise SystemExit(f"Not a staging directory: {stage}") + if not policy_path.is_file(): + raise SystemExit(f"Policy not found: {policy_path}") + + items = build_queue(stage, policy_path) + + if args.pilot_mixed: + prefix = MIXED_SOURCE_NAME + "/" + items = [ + item for item in items + if item["origin"] == "SOURCE" + and item["rel_path"].startswith(prefix) + ] + + if args.limit > 0: + items = items[:args.limit] + + if args.report: + report = Path(args.report).expanduser().resolve() + else: + name = ( + "scope_triage_pilot_mixed_v3.tsv" + if args.pilot_mixed + else "scope_triage_readable_v3.tsv" + ) + report = stage / "MANIFESTS" / name + + print("=== SCOPE TRIAGE ===") + print("Queue:", len(items)) + print("Mode:", "pilot-mixed" if args.pilot_mixed else "full-readable") + print("Allowed outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX") + print("Primary model:", args.model) + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print("Fallback model:", fallback_model or "disabled") + print("Report:", report) + + if args.dry_run: + print("DRY RUN: no LLM requests made") + for item in items[:20]: + print(item["origin"], item["rel_path"]) + return + + existing = load_existing(report) + order = [item["key"] for item in items] + rows = {k: v for k, v in existing.items() if k in set(order)} + + completed = len(rows) + if completed: + print(f"Resume: {completed} already completed") + + for index, item in enumerate(items, 1): + key = item["key"] + if key in rows: + continue + + result = classify( + item, + args.endpoint, + args.model, + fallback_model, + ) + + row = { + "key": key, + "origin": item["origin"], + "relative_path": item["rel_path"], + "source_context": item["source_context"], + "filename": item["path"].name, + "extension": item["path"].suffix.lower(), + "date_guess": item["date_guess"], + "date_source": item["date_source"], + "scope": result["scope"], + "confidence": f"{float(result['confidence']):.2f}", + "reason": result["reason"], + "content_status": result["content_status"], + "model_path": result["model_path"], + "review_flags": result.get("review_flags", ""), + "primary_scope": result.get("primary_scope", ""), + "primary_confidence": ( + f"{float(result['primary_confidence']):.2f}" + if result.get("primary_confidence", "") != "" else "" + ), + "fallback_scope": result.get("fallback_scope", ""), + "fallback_confidence": ( + f"{float(result['fallback_confidence']):.2f}" + if result.get("fallback_confidence", "") != "" else "" + ), + } + + rows[key] = row + write_report_atomic(report, rows, order) + + print( + f"[{index:3}/{len(items)}] " + f"{result['scope']:9} " + f"{float(result['confidence']):.2f} " + f"{item['path'].name}" + ) + + counts = Counter(row["scope"] for row in rows.values()) + low = sum(float(row["confidence"]) < 0.70 for row in rows.values()) + disagreements = sum( + row.get("model_path") == "models-disagree" + for row in rows.values() + ) + + print() + print("=== RESULT ===") + print("Completed:", len(rows)) + print("04_ARBEIT:", counts["04_ARBEIT"]) + print("03_PRIVAT:", counts["03_PRIVAT"]) + print("01_INBOX:", counts["01_INBOX"]) + print("Confidence < 0.70:", low) + print("Model disagreements:", disagreements) + print("Report:", report) + + +if __name__ == "__main__": + main() diff --git a/2026-09-14_scope-triage_effective-routing_v1.tsv b/2026-09-14_scope-triage_effective-routing_v1.tsv new file mode 100644 index 0000000..042c69f --- /dev/null +++ b/2026-09-14_scope-triage_effective-routing_v1.tsv @@ -0,0 +1,272 @@ +key origin relative_path source_context filename extension date_guess date_source scope confidence reason content_status model_path review_flags primary_scope primary_confidence fallback_scope fallback_confidence effective_scope override_applied override_basis override_reason +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Teamtreffen_Protokoll_09_01 - for merge.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Teamtreffen_Protokoll_09_01 - for merge.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden Teamtreffen_Protokoll_09_01 - for merge.docx .docx 2026-09-01 mtime 04_ARBEIT 0.95 Contains Checkpoint team meeting details, project budgets, and work tasks. docx:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/06AnOverviewofCellBiology.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/06AnOverviewofCellBiology.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert 06AnOverviewofCellBiology.pdf .pdf 2022-05-30 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:947 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.80 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf .pdf 2022-05-30 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:436 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.78 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Babylon Sauna.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Babylon Sauna.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben - Babylon Sauna.docx .docx 2022-01-10 mtime 04_ARBEIT 0.92 Professional outreach letter proposing collaboration with Babylon Sauna. docx:1489 models-agree ownership-fragile-filename 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Gasto Innenstadt.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Gasto Innenstadt.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben - Gasto Innenstadt.docx .docx 2022-01-07 mtime 04_ARBEIT 0.92 Professional New Year's greeting for Checkpoints collaboration. docx:552 models-agree ownership-fragile-filename 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Gastro Frohes Neues Jahr.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Gastro Frohes Neues Jahr.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben - Gastro Frohes Neues Jahr.docx .docx 2022-01-07 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT docx:61 models-disagree ownership-fragile-filename 04_ARBEIT 0.95 03_PRIVAT 0.80 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Parties kurzform FB.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Parties kurzform FB.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben - Parties kurzform FB.docx .docx 2022-03-31 mtime 04_ARBEIT 0.92 Professional outreach letter for prevention work at CheckPoint. docx:514 models-agree ownership-fragile-filename 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Phönix Sauna.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Phönix Sauna.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben - Phönix Sauna.docx .docx 2022-01-10 mtime 04_ARBEIT 0.92 Professional New Year greeting from Checkpoints to sauna team. docx:573 models-agree ownership-fragile-filename 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Phönix Sauna.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Phönix Sauna.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben - Phönix Sauna.pdf .pdf 2022-01-10 mtime 04_ARBEIT 0.92 Professional correspondence from Aidshilfe Köln to Phönix Sauna. pdf:1213 models-agree ownership-fragile-filename,work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben kleine Show Preis.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben kleine Show Preis.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben kleine Show Preis.docx .docx 2022-01-04 mtime 04_ARBEIT 0.92 Professional congratulatory letter from Aidshilfe NRW to participant. docx:595 models-agree ownership-fragile-filename 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben.docx .docx 2022-01-07 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT docx:61 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.85 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Arbeitsprotokoll.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Arbeitsprotokoll.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Arbeitsprotokoll.docx .docx 2022-01-28 mtime 04_ARBEIT 0.95 Contains work-related tasks and team coordination for CSD campaign and volunteer activities. docx:542 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben models - CSD 2022.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben models - CSD 2022.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Anschreiben models - CSD 2022.docx .docx 2022-03-04 mtime 04_ARBEIT 0.92 Professional invitation from Aidshilfe Köln for campaign participation docx:1722 models-agree ownership-fragile-filename 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Arbeitsvertrag Patrick Jacobs (1) (1).pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Arbeitsvertrag Patrick Jacobs (1) (1).pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Arbeitsvertrag Patrick Jacobs (1) (1).pdf .pdf 2021-12-16 mtime 03_PRIVAT 0.95 User's personal employment contract. pdf:26 models-agree ownership-fragile-filename 03_PRIVAT 0.95 03_PRIVAT 0.95 03_PRIVAT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Bolsover2004CellBiology.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Bolsover2004CellBiology.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Bolsover2004CellBiology.pdf .pdf 2022-05-30 mtime 01_INBOX 0.75 Generic textbook, no evidence of work use or ownership. pdf:2400 models-agree primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.80 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Covid IMpfungen 2022.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Covid IMpfungen 2022.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Covid IMpfungen 2022.docx .docx 2022-01-18 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT docx:318 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 03_PRIVAT yes content-context Personal vaccination record; not an organizational work record. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Fahrtkostenabrechnung_4236_20220201_162815 (1).pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Fahrtkostenabrechnung_4236_20220201_162815 (1).pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Fahrtkostenabrechnung_4236_20220201_162815 (1).pdf .pdf 2022-02-01 filename 04_ARBEIT 0.95 Contains work-related travel reimbursement for a DAH seminar. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Preisvergleich Angebote.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Preisvergleich Angebote.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Preisvergleich Angebote.docx .docx 2022-01-03 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT docx:476 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.92 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Quiz Terasse 21-07.pptx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Quiz Terasse 21-07.pptx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Quiz Terasse 21-07.pptx .pptx 2022-08-10 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pptx:462 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.80 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen (1).pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen (1).pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert QuiztalMath Antwortbogen (1).pdf .pdf 2022-11-02 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:173 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.75 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert QuiztalMath Antwortbogen.pdf .pdf 2022-11-02 mtime 01_INBOX 0.65 Generic quiz answer sheet, no clear work context or ownership. pdf:141 models-agree primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.65 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Roboto/LICENSE.txt" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Roboto/LICENSE.txt SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Roboto LICENSE.txt .txt 2022-01-17 mtime 01_INBOX 0.75 Generic font license, no work context. text:2400 models-agree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.95 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/S2OGKM_C25021111716590.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/S2OGKM_C25021111716590.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert S2OGKM_C25021111716590.pdf .pdf 2021-11-17 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT pdf:29 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/SENIOR TICKET NL 20220301.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/SENIOR TICKET NL 20220301.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert SENIOR TICKET NL 20220301.pdf .pdf 2022-03-01 filename 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:2400 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 03_PRIVAT yes content-context Personal travel ticket rather than work documentation. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Sample CSV file for importing contacts (1).csv" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Sample CSV file for importing contacts (1).csv SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Sample CSV file for importing contacts (1).csv .csv 2022-01-07 mtime 01_INBOX 0.65 Generic sample CSV for contact import, no work context. text:1994 models-agree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.65 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Tagesplan.xlsx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Tagesplan.xlsx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Tagesplan.xlsx .xlsx 2022-01-11 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT xlsx:951 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Testsample.csv" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Testsample.csv SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Testsample.csv .csv 2022-01-07 mtime 01_INBOX 0.75 Sample contact list, no real personal or work data. text:1996 models-agree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.85 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf .pdf 2022-08-10 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT pdf:2400 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Wichtige_Kundeninformation_zu_Konto_0125273623.PDF" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Wichtige_Kundeninformation_zu_Konto_0125273623.PDF SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Wichtige_Kundeninformation_zu_Konto_0125273623.PDF .pdf 2022-08-15 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:2400 models-disagree ownership-fragile-filename,work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.95 03_PRIVAT yes content-context Personal bank-account/customer information rather than work documentation. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Data.csv" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Data.csv SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat Data.csv .csv 2022-08-29 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT text:362 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 04_ARBEIT yes paired-document-context Paired with SurvStat Info.pdf in the same folder; the companion file is confirmed work health-surveillance material. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Info.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Info.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat Info.pdf .pdf 2022-08-29 mtime 04_ARBEIT 0.95 Contains RKI analysis tool reference and health data query, work-related health surveillance. pdf:973 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_1.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_1.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert ticket_patrick-ja_queerfeld-festiv_1.pdf .pdf 2022-08-29 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:381 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 03_PRIVAT yes paired-document-context Personal festival ticket; matching companion ticket is already classified private. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_2.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_2.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert ticket_patrick-ja_queerfeld-festiv_2.pdf .pdf 2022-08-29 mtime 03_PRIVAT 0.85 Personal ticket for user’s private festival attendance pdf:381 models-agree ownership-fragile-filename 03_PRIVAT 0.85 03_PRIVAT 0.92 03_PRIVAT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Getränkeliste Verzehrkarte.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Getränkeliste Verzehrkarte.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB Getränkeliste Verzehrkarte.pdf .pdf 2026-09-03 mtime 04_ARBEIT 0.95 Document lists beverages for a team event with payment instructions, indicating work use. pdf:787 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Kühlschrank 07-08-13,08.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Kühlschrank 07-08-13,08.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB Kühlschrank 07-08-13,08.pdf .pdf 2026-09-09 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:27 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.80 04_ARBEIT yes structural-context Café-Bach operational file in the CB inbox; refrigerator-related document belongs to work context. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/SP5 treffen Samstag 15-08.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/SP5 treffen Samstag 15-08.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB SP5 treffen Samstag 15-08.pdf .pdf 2026-09-09 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT pdf:27 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 04_ARBEIT yes structural-context SP5 meeting document in Café-Bach work backup; SP5 is a work project. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/A5 Aufsteller.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/A5 Aufsteller.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 A5 Aufsteller.docx .docx 2024-11-08 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT docx:53 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.90 04_ARBEIT yes structural-context Display/signage document inside Café-Bach archive; work-material context is stronger than sparse extracted text. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/3 Semmelknödel.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/3 Semmelknödel.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe 3 Semmelknödel.pdf .pdf 2022-12-13 mtime 04_ARBEIT 0.95 Contains allergen documentation for cafebach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/650Gramm Grill Haxe vom Schwein.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/650Gramm Grill Haxe vom Schwein.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe 650Gramm Grill Haxe vom Schwein.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Backfisch vom Seelachs.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Backfisch vom Seelachs.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Backfisch vom Seelachs.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items, work-related food safety. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Fischeintopf.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Fischeintopf.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Fischeintopf.pdf .pdf 2022-11-07 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Formblatt_Allergeninformation.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Formblatt_Allergeninformation.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Formblatt_Allergeninformation.pdf .pdf 2022-09-19 mtime 04_ARBEIT 0.95 Contains allergen documentation for food items relevant to work operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Fusilli Gorgonzolasoße.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Fusilli Gorgonzolasoße.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Fusilli Gorgonzolasoße.pdf .pdf 2022-11-07 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gebackene Cannelloni.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gebackene Cannelloni.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Gebackene Cannelloni.pdf .pdf 2022-12-02 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item, likely for Cafe-Bach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gedünstetes Pangasius Filet.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gedünstetes Pangasius Filet.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Gedünstetes Pangasius Filet.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Geflügel Gyros.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Geflügel Gyros.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Geflügel Gyros.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gefüllte Paprikaschote.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gefüllte Paprikaschote.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Gefüllte Paprikaschote.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for food items relevant to work operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gemüse Quiche.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gemüse Quiche.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Gemüse Quiche.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for work menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gnocchi in Feta-Tomatensoße.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gnocchi in Feta-Tomatensoße.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Gnocchi in Feta-Tomatensoße.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Hühnerfrikassee.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Hühnerfrikassee.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Hühnerfrikassee.pdf .pdf 2022-12-02 mtime 04_ARBEIT 0.95 Document lists allergens and additives for menu items, likely for work compliance. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Kartoffel Eintopf.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Kartoffel Eintopf.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Kartoffel Eintopf.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for work menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Kartoffel Zucchini Puffer.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Kartoffel Zucchini Puffer.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Kartoffel Zucchini Puffer.pdf .pdf 2022-11-07 mtime 04_ARBEIT 0.95 Contains allergen documentation for cafebach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Kohlroulade.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Kohlroulade.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Kohlroulade.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items, likely work-related. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Lachsfilet.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Lachsfilet.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Lachsfilet.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items, likely work-related. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Leberkäse.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Leberkäse.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Leberkäse.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for work menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Marinierte Hähnchenkeule.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Marinierte Hähnchenkeule.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Marinierte Hähnchenkeule.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for work menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Nochmal Kind sein!.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Nochmal Kind sein!.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Nochmal Kind sein!.pdf .pdf 2022-12-02 mtime 04_ARBEIT 0.95 Contains allergen documentation for work-related food items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Paprika Rahmgeschnetzeltes, Hähnchen, Reis.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Paprika Rahmgeschnetzeltes, Hähnchen, Reis.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Paprika Rahmgeschnetzeltes, Hähnchen, Reis.pdf .pdf 2022-09-19 mtime 04_ARBEIT 0.95 Contains allergen documentation for menu items, likely work-related. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Paprika Tomatenpfanne.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Paprika Tomatenpfanne.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Paprika Tomatenpfanne.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Pasta Arrabiata.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Pasta Arrabiata.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Pasta Arrabiata.pdf .pdf 2022-12-02 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items, likely work-related. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Pasta Rote Linsen.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Pasta Rote Linsen.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Pasta Rote Linsen.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items, likely work-related. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Pasta Thunfischsoße.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Pasta Thunfischsoße.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Pasta Thunfischsoße.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Putengeschnetzelets.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Putengeschnetzelets.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Putengeschnetzelets.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for cafe operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Quinoa Suppe.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Quinoa Suppe.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Quinoa Suppe.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item, likely related to Cafe-Bach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Semmelknödel.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Semmelknödel.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Semmelknödel.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for cafebach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Speckpfannekuchen.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Speckpfannekuchen.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Speckpfannekuchen.pdf .pdf 2022-11-07 mtime 04_ARBEIT 0.95 Contains allergen documentation for cafebach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganer Bauerneintopf.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganer Bauerneintopf.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Veganer Bauerneintopf.pdf .pdf 2022-11-07 mtime 04_ARBEIT 0.95 Contains allergen documentation for work menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganer Kichererbsen Eintopf.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganer Kichererbsen Eintopf.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Veganer Kichererbsen Eintopf.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganes Curry.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganes Curry.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Veganes Curry.pdf .pdf 2022-12-02 mtime 04_ARBEIT 0.95 Contains allergen documentation for cafebach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganes Soja-Schnitzel mit Ebly.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganes Soja-Schnitzel mit Ebly.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Veganes Soja-Schnitzel mit Ebly.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Vegetarische Maultaschen.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Vegetarische Maultaschen.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Vegetarische Maultaschen.pdf .pdf 2022-12-02 mtime 04_ARBEIT 0.95 Contains allergen documentation for work menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veggi tortellini.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veggi tortellini.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Veggi tortellini.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for cafebach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Weizen Wrap.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Weizen Wrap.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Weizen Wrap.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for food items relevant to work operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Zanderfilet in Nuss Kruste.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Zanderfilet in Nuss Kruste.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Zanderfilet in Nuss Kruste.pdf .pdf 2022-12-02 mtime 04_ARBEIT 0.95 Contains allergen documentation for a restaurant menu item, likely work-related. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Zusatzstoffe_-_Kenntlichmachung_auf_Speise-_und_Getraenkekarten.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Zusatzstoffe_-_Kenntlichmachung_auf_Speise-_und_Getraenkekarten.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Zusatzstoffe_-_Kenntlichmachung_auf_Speise-_und_Getraenkekarten.pdf .pdf 2022-09-19 mtime 04_ARBEIT 0.95 Contains professional guidelines for allergen labeling in restaurant operations. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/himmel un Äd.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/himmel un Äd.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe himmel un Äd.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for work menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Überbackener Macceroni Auflauf.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Überbackener Macceroni Auflauf.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Überbackener Macceroni Auflauf.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Aufstellung Kassenbestandteile.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Aufstellung Kassenbestandteile.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Aufstellung Kassenbestandteile.pdf .pdf 2025-02-19 mtime 04_ARBEIT 0.95 File contains cash inventory details for a work location. pdf:29 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Bedienung Spülmaschine Caféküche.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Bedienung Spülmaschine Caféküche.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Bedienung Spülmaschine Caféküche.docx .docx 2025-03-28 mtime 04_ARBEIT 0.95 Work-specific instruction for kitchen equipment operation docx:1920 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/CaBa Terminübersicht Aushang A3 Fenster Türen Fahrstuhl Vorlage.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/CaBa Terminübersicht Aushang A3 Fenster Türen Fahrstuhl Vorlage.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 CaBa Terminübersicht Aushang A3 Fenster Türen Fahrstuhl Vorlage.docx .docx 2025-07-24 mtime 04_ARBEIT 0.95 Document contains event planning for Café Bach, a work-related venue. docx:244 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Finanzen/Bereinigung Kasse.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Finanzen/Bereinigung Kasse.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Finanzen Bereinigung Kasse.xlsx .xlsx 2024-01-19 mtime 04_ARBEIT 0.95 Financial records of a work cash register tied to cafebach operations. xlsx:916 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Finanzen/Catering Rechner.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Finanzen/Catering Rechner.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Finanzen Catering Rechner.xlsx .xlsx 2024-07-16 mtime 04_ARBEIT 0.95 Catering cost calculation for a work event with budget and personnel details. xlsx:207 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Aushang für Notfälle.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Aushang für Notfälle.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Getränkeliste Aushang für Notfälle.docx .docx 2023-05-16 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT docx:216 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.90 04_ARBEIT yes structural-context Emergency beverage-list signage for Café-Bach operations. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste OHNE KAFFEE.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste OHNE KAFFEE.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Getränkeliste OHNE KAFFEE.docx .docx 2025-10-15 mtime 04_ARBEIT 0.95 Document lists beverages and prices, likely for work menu management. docx:355 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Veranstaltungen.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Veranstaltungen.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Getränkeliste Veranstaltungen.xlsx .xlsx 2025-03-28 mtime 04_ARBEIT 0.95 Contains pricing and inventory for event beverages, likely work-related. xlsx:424 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkepreise.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkepreise.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Getränkepreise.xlsx .xlsx 2025-01-29 mtime 04_ARBEIT 0.95 Contains pricing data for beverages and coffee, likely used for work operations. xlsx:1688 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Gruppen und Veranstaltungen im Cafe Bach (version 1).xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Gruppen und Veranstaltungen im Cafe Bach (version 1).xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Gruppen und Veranstaltungen im Cafe Bach (version 1).xlsx .xlsx 2025-02-05 mtime 04_ARBEIT 0.95 Contains event planning for professional collaboration and organizational activities. xlsx:1108 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Gruppen und Veranstaltungen im Cafe Bach.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Gruppen und Veranstaltungen im Cafe Bach.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Gruppen und Veranstaltungen im Cafe Bach.xlsx .xlsx 2025-01-22 mtime 04_ARBEIT 0.95 Contains work-related group meetings and events for Aidshilfe Köln e.V. xlsx:1106 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/Botzenhardt_Albrecht.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/Botzenhardt_Albrecht.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP Botzenhardt_Albrecht.pdf .pdf 2016-08-03 mtime 04_ARBEIT 0.95 HACCP document for cheese production, professional context pdf:2128 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/HACCP_in_der_Praxis.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/HACCP_in_der_Praxis.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP HACCP_in_der_Praxis.pdf .pdf 2016-08-03 mtime 04_ARBEIT 0.95 HACCP guidelines for food safety in a professional kitchen setting. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/Infektionsschutzgesetz-gesamt.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/Infektionsschutzgesetz-gesamt.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP Infektionsschutzgesetz-gesamt.pdf .pdf 2016-11-02 mtime 04_ARBEIT 0.95 Legal document related to public health regulations, likely used for professional compliance. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/eu_lebensmittelhygiene_haccp_2014.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/eu_lebensmittelhygiene_haccp_2014.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP eu_lebensmittelhygiene_haccp_2014.pdf .pdf 2016-08-03 mtime 04_ARBEIT 0.95 Contains HACCP guidelines for food safety, relevant to work operations. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/fm_Leitfaden_fuer_die_Kontrolle_und_Anwendungen_des_HACCP_Konzeptes.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/fm_Leitfaden_fuer_die_Kontrolle_und_Anwendungen_des_HACCP_Konzeptes.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP fm_Leitfaden_fuer_die_Kontrolle_und_Anwendungen_des_HACCP_Konzeptes.pdf .pdf 2016-08-03 mtime 04_ARBEIT 0.95 HACCP guide for food safety in workplace operations pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/haccp_handbuch_de_rd14_11.06.2014.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/haccp_handbuch_de_rd14_11.06.2014.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP haccp_handbuch_de_rd14_11.06.2014.pdf .pdf 2014-06-11 filename 04_ARBEIT 0.95 HACCP manual for food safety in professional kitchen operations. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Handout Cafe' Bach Externe Nutzung.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Handout Cafe' Bach Externe Nutzung.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Handout Cafe' Bach Externe Nutzung.docx .docx 2025-11-04 mtime 04_ARBEIT 0.95 Document outlines operational guidelines for external use of Cafe' Bach spaces by Checkpoint/Aidshilfe. docx:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Eröffnungsinventur Café Bach Aug 2022.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Eröffnungsinventur Café Bach Aug 2022.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar Eröffnungsinventur Café Bach Aug 2022.pdf .pdf 2023-08-18 mtime 04_ARBEIT 0.95 Inventory of work supplies for Café Bach operation. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventarliste Café Bach 2022.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventarliste Café Bach 2022.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar Inventarliste Café Bach 2022.xlsx .xlsx 2025-01-08 mtime 04_ARBEIT 0.95 Inventory list for Café Bach work assets xlsx:1772 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 2022.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 2022.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar Inventur Café Bach 2022.xlsx .xlsx 2023-08-18 mtime 04_ARBEIT 0.95 Inventory of work supplies for Café Bach, professional context xlsx:1795 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 2023.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 2023.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar Inventur Café Bach 2023.xlsx .xlsx 2024-04-29 mtime 04_ARBEIT 0.95 Inventory of work inventory for Café Bach, professional context xlsx:1594 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 2024.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 2024.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar Inventur Café Bach 2024.xlsx .xlsx 2024-12-20 mtime 04_ARBEIT 0.95 Inventory of work inventory for Café Bach, includes product prices and quantities. xlsx:1611 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 31.12.2022.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 31.12.2022.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar Inventur Café Bach 31.12.2022.pdf .pdf 2022-12-31 filename 04_ARBEIT 0.95 Inventory of work supplies for Café Bach, professional context pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Jour Fixe/Jour Fix Basti.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Jour Fixe/Jour Fix Basti.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Jour Fixe Jour Fix Basti.xlsx .xlsx 2025-01-06 mtime 04_ARBEIT 0.95 Contains work tasks, team assignments, and project planning for Cafe-Bach operations. xlsx:1011 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Jour Fixe/Jour Fix Basti_2025.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Jour Fixe/Jour Fix Basti_2025.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Jour Fixe Jour Fix Basti_2025.xlsx .xlsx 2025-04-08 mtime 04_ARBEIT 0.95 Contains work project tasks and team assignments for Cafe-Bach operations. xlsx:1011 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Auswertung Bewertungsbögen Soft Opening.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Auswertung Bewertungsbögen Soft Opening.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation Auswertung Bewertungsbögen Soft Opening.xlsx .xlsx 2022-08-31 mtime 04_ARBEIT 0.95 File contains work evaluation data for a café project. xlsx:635 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Kalkulation 2022.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Kalkulation 2022.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation Kalkulation 2022.xlsx .xlsx 2022-08-12 mtime 04_ARBEIT 0.95 Contains cost calculations for meals at Cafe-Bach, likely work-related. xlsx:1801 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Kalkulation Longdrinks.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Kalkulation Longdrinks.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation Kalkulation Longdrinks.xlsx .xlsx 2024-06-19 mtime 04_ARBEIT 0.95 Contains cost calculations for beverages used in work operations. xlsx:367 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Kalkulation Rouladen_Überprüfung.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Kalkulation Rouladen_Überprüfung.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation Kalkulation Rouladen_Überprüfung.xlsx .xlsx 2022-11-17 mtime 04_ARBEIT 0.95 Contains cost calculations for restaurant dishes, likely work-related. xlsx:1021 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Speisenkalkulation 2022.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Speisenkalkulation 2022.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation Speisenkalkulation 2022.xlsx .xlsx 2023-03-17 mtime 04_ARBEIT 0.95 Contains cost calculations for food items in a work context. xlsx:861 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Speisenkalkulation 2023 - 13.03.-17.03..xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Speisenkalkulation 2023 - 13.03.-17.03..xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation Speisenkalkulation 2023 - 13.03.-17.03..xlsx .xlsx 2023-04-06 mtime 04_ARBEIT 0.95 Contains cost calculations for food items in a work context. xlsx:861 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kasse Café Bach/Kassenbericht Café Bach.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kasse Café Bach/Kassenbericht Café Bach.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kasse Café Bach Kassenbericht Café Bach.xlsx .xlsx 2025-07-04 mtime 04_ARBEIT 0.95 Kassenbericht for Café Bach, work financial records xlsx:891 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kasse Café Bach/Zählprotokoll Café Bach.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kasse Café Bach/Zählprotokoll Café Bach.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kasse Café Bach Zählprotokoll Café Bach.xlsx .xlsx 2025-04-28 mtime 04_ARBEIT 0.95 Café Bach cash register records for work operations. xlsx:768 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/AG-Inso-Nr-1b-Unternehmen--.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/AG-Inso-Nr-1b-Unternehmen--.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession AG-Inso-Nr-1b-Unternehmen--.pdf .pdf 2022-07-13 mtime 04_ARBEIT 0.95 Formal request for business clearance tied to legal work process. pdf:1494 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Antrag auf Auszug aus dem Gewerbezentralregister.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Antrag auf Auszug aus dem Gewerbezentralregister.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Antrag auf Auszug aus dem Gewerbezentralregister.docx .docx 2022-10-05 mtime 04_ARBEIT 0.95 Document is a formal business request related to a work license and operations. docx:871 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Auszug_aus_Liegenschaftskataster.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Auszug_aus_Liegenschaftskataster.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Auszug_aus_Liegenschaftskataster.pdf .pdf 2022-07-21 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:no-text-or-scan models-disagree primary-confidence-below-0.85,limited-content 01_INBOX 0.75 03_PRIVAT 0.75 04_ARBEIT yes structural-context Property extract stored in the Café-Bach concession application package; work licensing record. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf .pdf 2022-11-22 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:29 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.92 04_ARBEIT yes structural-context Certificate is part of the Café-Bach concession application package; retained as work licensing documentation. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Equipment Cafe Bach.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Equipment Cafe Bach.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Equipment Cafe Bach.xlsx .xlsx 2022-07-19 mtime 04_ARBEIT 0.95 Contains equipment pricing for Cafe Bach, work-related procurement xlsx:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Notwendige Unterlagen für Konzessionsantrag.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Notwendige Unterlagen für Konzessionsantrag.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Notwendige Unterlagen für Konzessionsantrag.docx .docx 2022-06-13 mtime 04_ARBEIT 0.95 Contains work-related licensing documents for a business operation. docx:1383 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Teilnahmebescheinigung nach dem Gasstättengesetz, Schubert Oliver.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Teilnahmebescheinigung nach dem Gasstättengesetz, Schubert Oliver.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Teilnahmebescheinigung nach dem Gasstättengesetz, Schubert Oliver.pdf .pdf 2022-10-26 mtime 04_ARBEIT 0.95 Document relates to gas station concession and professional work context. pdf:29 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Unterlagen für Konzessionsantrag_27.09.2022.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Unterlagen für Konzessionsantrag_27.09.2022.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Unterlagen für Konzessionsantrag_27.09.2022.pdf .pdf 2022-09-27 filename 04_ARBEIT 0.95 File contains work-related licensing application materials for a professional operation. pdf:29 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/öffentliche_Einsichtnahme_20220718-1437.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/öffentliche_Einsichtnahme_20220718-1437.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession öffentliche_Einsichtnahme_20220718-1437.pdf .pdf 2022-07-18 filename 04_ARBEIT 0.95 Contains a work-related Konzessionsantrag for Café Bach, professional context. pdf:843 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Lieferanten und Dienstleister.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Lieferanten und Dienstleister.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Lieferanten und Dienstleister.xlsx .xlsx 2025-06-03 mtime 04_ARBEIT 0.95 Contains supplier and service provider info for Café Bach operations. xlsx:1352 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Namensschilder.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Namensschilder.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Namensschilder.docx .docx 2024-06-04 mtime 04_ARBEIT 0.95 Contains names likely for work identification in a professional context. docx:16 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Abschied Ute Hummler 16.08.24.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Abschied Ute Hummler 16.08.24.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024 Abschied Ute Hummler 16.08.24.pdf .pdf 2024-08-16 filename 04_ARBEIT 0.95 Contains work schedule and staff names from a café, indicating operational use. pdf:189 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Apple Event 22.08.24.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Apple Event 22.08.24.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024 Apple Event 22.08.24.pdf .pdf 2024-08-22 filename 04_ARBEIT 0.95 Contains staff schedule for Apple Event, work-related personnel assignments. pdf:183 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 01.04.-05.04.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 01.04.-05.04.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April DP 01.04.-05.04.pdf .pdf 2024-03-22 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, indicating operational use. pdf:757 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 08.04.-12.04.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 08.04.-12.04.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April DP 08.04.-12.04.pdf .pdf 2024-03-22 mtime 04_ARBEIT 0.95 Contains work schedule for employees at Cafe-Bach, including roles and shifts. pdf:715 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 15.04. - 19.04.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 15.04. - 19.04.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April DP 15.04. - 19.04.pdf .pdf 2024-04-11 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, including roles and shifts. pdf:681 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 22.04. - 26.04.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 22.04. - 26.04.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April DP 22.04. - 26.04.pdf .pdf 2024-04-25 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, including roles and shifts. pdf:663 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 29.04. - 03.05.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 29.04. - 03.05.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April DP 29.04. - 03.05.pdf .pdf 2024-04-26 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, including shifts and roles. pdf:727 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP August 24.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP August 24.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024 DP August 24.pdf .pdf 2024-07-24 mtime 04_ARBEIT 0.95 Work schedule for cafebach employees, includes shift times and roles. pdf:1792 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP Juli 24.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP Juli 24.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024 DP Juli 24.pdf .pdf 2024-07-05 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, indicating operational use. pdf:2303 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP November 24.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP November 24.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024 DP November 24.pdf .pdf 2024-10-31 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, including shifts and names. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP Oktober 24.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP Oktober 24.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024 DP Oktober 24.pdf .pdf 2024-10-02 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, indicating professional use. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP September 24.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP September 24.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024 DP September 24.pdf .pdf 2024-08-23 mtime 04_ARBEIT 0.95 Contains work schedule for employees at Cafe-Bach, indicating professional use. pdf:2296 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar/DP 05.02.-09.02..pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar/DP 05.02.-09.02..pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar DP 05.02.-09.02..pdf .pdf 2024-01-30 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, including roles and shifts. pdf:805 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar/DP 19.02.-23.02..pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar/DP 19.02.-23.02..pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar DP 19.02.-23.02..pdf .pdf 2024-02-15 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, indicating operational use. pdf:704 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar/DP KW09.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar/DP KW09.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar DP KW09.pdf .pdf 2024-02-29 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, including shifts and roles. pdf:714 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Januar/DP 22.01.-26.01..pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Januar/DP 22.01.-26.01..pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Januar DP 22.01.-26.01..pdf .pdf 2024-01-18 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, including roles and shifts. pdf:632 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Januar/DP 29.01.-02.02..pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Januar/DP 29.01.-02.02..pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Januar DP 29.01.-02.02..pdf .pdf 2024-01-26 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, work-related context. pdf:658 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 03.06. - 07.06.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 03.06. - 07.06.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni DP 03.06. - 07.06.pdf .pdf 2024-05-28 mtime 04_ARBEIT 0.95 Contains work schedule for Cafe-Bach staff, indicating operational use. pdf:637 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 10.06. - 14.06.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 10.06. - 14.06.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni DP 10.06. - 14.06.pdf .pdf 2024-05-28 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, including roles and shifts. pdf:649 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 17.06. - 21.06.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 17.06. - 21.06.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni DP 17.06. - 21.06.pdf .pdf 2024-06-13 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, indicating operational use. pdf:743 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 24.06. - 28.06.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 24.06. - 28.06.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni DP 24.06. - 28.06.pdf .pdf 2024-06-13 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees pdf:696 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 06.05. - 10.05.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 06.05. - 10.05.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai DP 06.05. - 10.05.pdf .pdf 2024-04-30 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, clearly tied to operational shifts. pdf:959 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 13.05. - 17.05.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 13.05. - 17.05.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai DP 13.05. - 17.05.pdf .pdf 2024-05-03 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, including roles and shifts. pdf:667 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 20.05. - 24.05.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 20.05. - 24.05.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai DP 20.05. - 24.05.pdf .pdf 2024-05-21 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, indicating operational use. pdf:724 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 27.05. - 31.05.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 27.05. - 31.05.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai DP 27.05. - 31.05.pdf .pdf 2024-05-21 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, including roles and shifts. pdf:781 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP 18.03.-22.03.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP 18.03.-22.03.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März DP 18.03.-22.03.pdf .pdf 2024-03-12 mtime 04_ARBEIT 0.95 Contains work schedule for employees at Cafe-Bach, indicating professional use. pdf:733 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP 25.03 - 29.03.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP 25.03 - 29.03.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März DP 25.03 - 29.03.pdf .pdf 2024-03-12 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, indicating operational use. pdf:761 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP KW10.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP KW10.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März DP KW10.pdf .pdf 2024-02-29 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, including roles and shifts. pdf:696 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP KW11.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP KW11.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März DP KW11.pdf .pdf 2024-03-12 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, work shifts, and operational planning. pdf:807 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Ehrenamtsbescheinigung Vorlage.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Ehrenamtsbescheinigung Vorlage.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal Ehrenamtsbescheinigung Vorlage.docx .docx 2025-01-17 mtime 04_ARBEIT 0.95 Document is a work-related certificate for an employee at Aidshilfe Köln. docx:1826 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Perso-Essen.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Perso-Essen.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal Perso-Essen.xlsx .xlsx 2024-11-04 mtime 03_PRIVAT 0.95 Contains personal meal records for individuals, likely private personal data. xlsx:912 primary 03_PRIVAT 0.95 04_ARBEIT yes user-confirmed User confirmed that 'Perso'/'Personal' here means staff/personnel, not private/personal. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Personalplanung (neu).xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Personalplanung (neu).xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal Personalplanung (neu).xlsx .xlsx 2024-11-27 mtime 04_ARBEIT 0.95 Contains workforce scheduling and personnel data for Cafe-Bach operations. xlsx:705 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Cafe Bach RE.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Cafe Bach RE.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen Cafe Bach RE.xlsx .xlsx 2025-01-20 mtime 04_ARBEIT 0.92 Invoice record for Aidshilfe events at Café Bach. xlsx:2400 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Café Bach Vorlage RE.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Café Bach Vorlage RE.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen Café Bach Vorlage RE.docx .docx 2024-02-15 mtime 04_ARBEIT 0.92 Professional invoice for Café Bach, used in work administration. docx:842 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2401 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2401 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2401 Aidshilfe Köln.pdf .pdf 2024-02-15 mtime 04_ARBEIT 0.92 Invoice for professional meeting expenses, used for work expense tracking. pdf:1151 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2402 Deutsche Aidshilfe.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2402 Deutsche Aidshilfe.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2402 Deutsche Aidshilfe.pdf .pdf 2024-02-15 mtime 04_ARBEIT 0.92 Invoice for professional catering services to Café Bach pdf:1188 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2403 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2403 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2403 Junge Stadt Köln.pdf .pdf 2024-02-15 mtime 04_ARBEIT 0.92 Invoice for professional event, work expense. pdf:1049 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2404 Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2404 Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2404 Stadt Köln.pdf .pdf 2024-02-15 mtime 04_ARBEIT 0.92 Invoice for professional event catering services. pdf:1142 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2405 Fraktion DIE LINKE.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2405 Fraktion DIE LINKE.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2405 Fraktion DIE LINKE.pdf .pdf 2024-03-05 mtime 04_ARBEIT 0.92 Invoice for Café Bach event, work expense record. pdf:1063 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2406 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2406 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2406 Junge Stadt Köln.pdf .pdf 2024-03-05 mtime 04_ARBEIT 0.69 fallback failed; Invoice for work event with employer details and professional context. pdf:1069 primary-fallback-failed work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2407 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2407 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2407 Aidshilfe Köln.pdf .pdf 2024-03-08 mtime 04_ARBEIT 0.92 Business invoice for professional event expenses. pdf:1066 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2408 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2408 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2408 Aidshilfe Köln.pdf .pdf 2024-03-08 mtime 04_ARBEIT 0.69 fallback failed; Rechnung for work event with employer details and professional context. pdf:1003 primary-fallback-failed work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2409 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2409 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2409 Aidshilfe Köln.pdf .pdf 2024-03-08 mtime 04_ARBEIT 0.92 Invoice for professional meeting, expense record pdf:1202 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2410 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2410 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2410 Lebenshaus-Stiftung.pdf .pdf 2024-04-11 mtime 04_ARBEIT 0.92 Professional invoice for work-related catering expense. pdf:965 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2411 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2411 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2411 Aidshilfe Köln.pdf .pdf 2024-04-11 mtime 04_ARBEIT 0.92 Professional invoice for work-related catering service. pdf:1037 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2412 SCHLAU NRW.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2412 SCHLAU NRW.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2412 SCHLAU NRW.pdf .pdf 2024-04-11 mtime 04_ARBEIT 0.92 Professional invoice for Aidshilfe Köln event. pdf:1225 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2413 Lebenshaus-Stifung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2413 Lebenshaus-Stifung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2413 Lebenshaus-Stifung.pdf .pdf 2024-05-03 mtime 04_ARBEIT 0.92 Business invoice for professional meeting, addressed to user, in invoices folder. pdf:1092 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2414 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2414 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2414 Junge Stadt Köln.pdf .pdf 2024-05-03 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1062 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2415 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2415 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2415 Aidshilfe Köln.pdf .pdf 2024-05-03 mtime 04_ARBEIT 0.92 Invoice for professional catering expense. pdf:1049 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2416 Jana Kühnel.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2416 Jana Kühnel.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2416 Jana Kühnel.pdf .pdf 2024-05-24 mtime 04_ARBEIT 0.92 Invoice from Aidshilfe Köln to Jana Kühnel for a professional event. pdf:1056 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2417 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2417 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2417 Junge Stadt Köln.pdf .pdf 2024-06-04 mtime 04_ARBEIT 0.92 Invoice for work event, addressed to employee, used for expense reimbursement. pdf:1077 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2418 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2418 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2418 Aidshilfe Köln.pdf .pdf 2024-06-05 mtime 04_ARBEIT 0.92 Invoice for professional event, business transaction. pdf:1044 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2419 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2419 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2419 Lebenshaus-Stiftung.pdf .pdf 2024-06-05 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1027 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.92 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2420 Academy of Media Arts Cologne.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2420 Academy of Media Arts Cologne.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2420 Academy of Media Arts Cologne.pdf .pdf 2024-06-20 mtime 04_ARBEIT 0.92 Invoice for catering services, professional expense record. pdf:1163 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2421 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2421 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2421 Junge Stadt Köln.pdf .pdf 2024-06-27 mtime 04_ARBEIT 0.69 fallback failed; Rechnung for work event with employer details and professional context pdf:1143 primary-fallback-failed work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2422 Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2422 Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2422 Stadt Köln.pdf .pdf 2024-07-24 mtime 04_ARBEIT 0.92 Invoice for professional event, used for work expenses. pdf:1380 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2423 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2423 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2423 Junge Stadt Köln.pdf .pdf 2024-08-12 mtime 04_ARBEIT 0.92 Invoice for work event catering, professional expense documentation. pdf:1104 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2424 Queeres Netzwerk NRW.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2424 Queeres Netzwerk NRW.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2424 Queeres Netzwerk NRW.pdf .pdf 2024-08-21 mtime 04_ARBEIT 0.92 Business invoice for catering event, professional record. pdf:1205 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2424 Ute Hummler.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2424 Ute Hummler.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2424 Ute Hummler.pdf .pdf 2024-08-19 mtime 04_ARBEIT 0.92 Invoice for work farewell party, professional expense. pdf:1147 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2425 Queeres Netzwerk NRW.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2425 Queeres Netzwerk NRW.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2425 Queeres Netzwerk NRW.pdf .pdf 2024-08-20 mtime 04_ARBEIT 0.95 Business invoice for catering event, professional use. pdf:1100 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2426 Apple Retail Germany.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2426 Apple Retail Germany.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2426 Apple Retail Germany.pdf .pdf 2024-08-22 mtime 04_ARBEIT 0.92 Business invoice for professional event, used for accounting. pdf:1105 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2427 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2427 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2427 Junge Stadt Köln.pdf .pdf 2024-09-02 mtime 04_ARBEIT 0.90 Business invoice for work-related expense. pdf:1160 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.90 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2428 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2428 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2428 Aidshilfe Köln.pdf .pdf 2024-09-04 mtime 04_ARBEIT 0.92 Invoice for business meeting expenses, issued by Aidshilfe Köln to employee. pdf:1047 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2429 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2429 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2429 Lebenshaus-Stiftung.pdf .pdf 2024-10-01 mtime 04_ARBEIT 0.92 Invoice for professional event expense. pdf:1077 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2430 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2430 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2430 Lebenshaus-Stiftung.pdf .pdf 2024-10-01 mtime 04_ARBEIT 0.92 Invoice for business event expenses, professional use. pdf:1072 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2431 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2431 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2431 Lebenshaus-Stiftung.pdf .pdf 2024-10-01 mtime 04_ARBEIT 0.69 fallback failed; Rechnung for work event with employer details and professional context. pdf:1075 primary-fallback-failed work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2432 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2432 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2432 Aidshilfe Köln.pdf .pdf 2024-10-01 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1038 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2433 zwo zwo eins Konzept GmbH.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2433 zwo zwo eins Konzept GmbH.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2433 zwo zwo eins Konzept GmbH.pdf .pdf 2024-10-02 mtime 04_ARBEIT 0.92 Business invoice for professional event expenses. pdf:1128 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2434 SHALK NRW.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2434 SHALK NRW.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2434 SHALK NRW.pdf .pdf 2024-10-15 mtime 04_ARBEIT 0.95 Invoice for professional event expenses, used by Aidshilfe Köln. pdf:1206 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2435 Aidshilfe NRW.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2435 Aidshilfe NRW.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2435 Aidshilfe NRW.pdf .pdf 2024-10-15 mtime 04_ARBEIT 0.92 Invoice for professional catering service to Café Bach. pdf:1040 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2436 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2436 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2436 Aidshilfe Köln.pdf .pdf 2024-10-28 mtime 04_ARBEIT 0.92 Invoice for professional event expenses. pdf:1168 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2437 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2437 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2437 Aidshilfe Köln.pdf .pdf 2024-10-29 mtime 04_ARBEIT 0.92 Professional invoice for work-related catering expense. pdf:1007 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2438 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2438 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2438 Junge Stadt Köln.pdf .pdf 2024-10-31 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1137 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2439 Aidshilfe NRW.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2439 Aidshilfe NRW.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2439 Aidshilfe NRW.pdf .pdf 2024-11-05 mtime 04_ARBEIT 0.92 Invoice for business expense between Aidshilfe and Café Bach pdf:1024 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2440 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2440 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2440 Aidshilfe Köln.pdf .pdf 2024-11-14 mtime 04_ARBEIT 0.92 Invoice for professional expense to employee Oliver Schubert. pdf:1054 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2441 Deutsche Aidshilfe.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2441 Deutsche Aidshilfe.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2441 Deutsche Aidshilfe.pdf .pdf 2024-11-14 mtime 04_ARBEIT 0.92 Invoice for catering service to Deutsche Aidshilfe, professional record. pdf:1058 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2442 Deutsche Aidshilfe.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2442 Deutsche Aidshilfe.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2442 Deutsche Aidshilfe.pdf .pdf 2024-11-14 mtime 04_ARBEIT 0.92 Professional invoice for catering services between Aidshilfe and Café Bach. pdf:1114 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2443 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2443 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2443 Lebenshaus-Stiftung.pdf .pdf 2024-11-28 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1086 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.90 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2444 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2444 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2444 Junge Stadt Köln.pdf .pdf 2024-11-28 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1139 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2445 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2445 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2445 Aidshilfe Köln.pdf .pdf 2024-12-03 mtime 04_ARBEIT 0.92 Invoice for professional catering expense to Oliver Schubert. pdf:1075 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2446 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2446 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2446 Aidshilfe Köln.pdf .pdf 2024-12-17 mtime 04_ARBEIT 0.92 Invoice for professional event, work expense. pdf:1037 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2447 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2447 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2447 Aidshilfe Köln.pdf .pdf 2024-12-17 mtime 04_ARBEIT 0.90 Invoice for company Christmas party catering, business expense. pdf:1059 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.90 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2448 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2448 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2448 Aidshilfe Köln.pdf .pdf 2025-01-22 mtime 04_ARBEIT 0.69 fallback failed; Rechnung for work-related supplies from Aidshilfe Köln. pdf:1094 primary-fallback-failed work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Aushang Personalhygiene und Händehygiene 2018.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Aushang Personalhygiene und Händehygiene 2018.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene Aushang Personalhygiene und Händehygiene 2018.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Hygiene guidelines for workplace safety and procedures pdf:1600 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Check Liste Temperaturkontrolle Kühlungen 2018.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Check Liste Temperaturkontrolle Kühlungen 2018.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene Check Liste Temperaturkontrolle Kühlungen 2018.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Document details temperature checks for refrigeration units in a work context. pdf:712 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Check Liste Wareneingang 2018.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Check Liste Wareneingang 2018.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene Check Liste Wareneingang 2018.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Contains work-specific hygiene and inventory checks for a café operation. pdf:696 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Checkliste Temperaturen Küche 2018.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Checkliste Temperaturen Küche 2018.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene Checkliste Temperaturen Küche 2018.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Contains kitchen temperature checks for work hygiene, tied to cafebach operations. pdf:902 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/HACCP Liste Fritteuse 2018.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/HACCP Liste Fritteuse 2018.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene HACCP Liste Fritteuse 2018.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Contains HACCP checklist for fryer hygiene in a work context. pdf:746 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Reinigungsliste Getränkeschankanlage 2018.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Reinigungsliste Getränkeschankanlage 2018.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene Reinigungsliste Getränkeschankanlage 2018.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Reinigung guidelines for work equipment in a professional setting. pdf:751 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Reinigungsliste Küchenbereich 2018.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Reinigungsliste Küchenbereich 2018.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene Reinigungsliste Küchenbereich 2018.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Cleaning schedule for kitchen area tied to work hygiene operations. pdf:784 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/neues-lebensmittelrecht-haccp-checklisten-data.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/neues-lebensmittelrecht-haccp-checklisten-data.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene neues-lebensmittelrecht-haccp-checklisten-data.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Contains HACCP checklists for food safety in a professional kitchen setting. pdf:1763 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Übersicht Allergene für Buffet mit Zusatzstoffen.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Übersicht Allergene für Buffet mit Zusatzstoffen.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene Übersicht Allergene für Buffet mit Zusatzstoffen.docx .docx 2024-01-10 mtime 04_ARBEIT 0.95 Document details allergens and additives for buffet, likely work hygiene/food safety. docx:613 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/CSD/CSD Ausschank Getränkepreise.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/CSD/CSD Ausschank Getränkepreise.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/CSD CSD Ausschank Getränkepreise.xlsx .xlsx 2024-07-23 mtime 04_ARBEIT 0.95 Contains pricing data for CSD drinks at a work event, likely related to cafe operations. xlsx:482 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/IDAHOBITA/IDAHOBITA Get-Together Gästeliste 17.05.23.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/IDAHOBITA/IDAHOBITA Get-Together Gästeliste 17.05.23.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/IDAHOBITA IDAHOBITA Get-Together Gästeliste 17.05.23.xlsx .xlsx 2023-05-17 filename 04_ARBEIT 0.95 Guest list for a work event under IDAHOBITA folder xlsx:359 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kaffeeklatsch - Bitte mit Sahne/Karte Kaffeeaklatsch.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kaffeeklatsch - Bitte mit Sahne/Karte Kaffeeaklatsch.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kaffeeklatsch - Bitte mit Sahne Karte Kaffeeaklatsch.docx .docx 2025-04-24 mtime 04_ARBEIT 0.95 Event planning for work venue with menu details and 'Please with cream' note. docx:243 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kaffeeklatsch - Bitte mit Sahne/Karte Kaffeeklatsch Getränke.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kaffeeklatsch - Bitte mit Sahne/Karte Kaffeeklatsch Getränke.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kaffeeklatsch - Bitte mit Sahne Karte Kaffeeklatsch Getränke.docx .docx 2025-03-28 mtime 04_ARBEIT 0.95 Document details work event beverages for Cafe-Bach operations. docx:228 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kleine Show/Getränkeverbrauch Kleine Show 2023.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kleine Show/Getränkeverbrauch Kleine Show 2023.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kleine Show Getränkeverbrauch Kleine Show 2023.xlsx .xlsx 2023-11-30 mtime 04_ARBEIT 0.95 Contains event beverage consumption data for a work event. xlsx:842 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kleine Show/Getränkeverbrauch Kleine Show 2024.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kleine Show/Getränkeverbrauch Kleine Show 2024.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kleine Show Getränkeverbrauch Kleine Show 2024.xlsx .xlsx 2024-11-25 mtime 04_ARBEIT 0.95 Tracks beverage consumption for a work event, includes pricing and totals. xlsx:287 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Nubbelverbrennung/Ablaufplanung Nubbelverbrennung 2025.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Nubbelverbrennung/Ablaufplanung Nubbelverbrennung 2025.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Nubbelverbrennung Ablaufplanung Nubbelverbrennung 2025.docx .docx 2025-03-17 mtime 04_ARBEIT 0.95 Event planning for work-related activity with team roles and resources. docx:1241 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Run of Colors Bierwagen/Vor-Orga Getränkewagen 2025.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Run of Colors Bierwagen/Vor-Orga Getränkewagen 2025.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Run of Colors Bierwagen Vor-Orga Getränkewagen 2025.xlsx .xlsx 2025-08-19 mtime 04_ARBEIT 0.95 Contains 2025 beverage planning for a work event. xlsx:954 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Weihnachtsfeier/Xmas 2024 Verbrauch Getränke.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Weihnachtsfeier/Xmas 2024 Verbrauch Getränke.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Weihnachtsfeier Xmas 2024 Verbrauch Getränke.xlsx .xlsx 2024-12-17 mtime 04_ARBEIT 0.95 File tracks beverage consumption for a work event, tied to employer operations. xlsx:571 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/laufende Veranstaltungen.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/laufende Veranstaltungen.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen laufende Veranstaltungen.xlsx .xlsx 2025-05-19 mtime 04_ARBEIT 0.95 Contains event planning details for work-related events at Cafe-Bach. xlsx:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Übergabe Rainbow Day 06.06.25 an Johannes.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Übergabe Rainbow Day 06.06.25 an Johannes.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen Übergabe Rainbow Day 06.06.25 an Johannes.docx .docx 2025-06-06 filename 04_ARBEIT 0.95 Document details a work event organized by the team, including logistics and collaboration with external contacts. docx:2137 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Verträge/Café Bach x Vytal.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Verträge/Café Bach x Vytal.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Verträge Café Bach x Vytal.pdf .pdf 2024-07-10 mtime 04_ARBEIT 0.95 Partnership agreement for Café Bach operations. pdf:2400 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Warenbestand Getränke 21.05.2025.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Warenbestand Getränke 21.05.2025.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Warenbestand Getränke 21.05.2025.xlsx .xlsx 2025-05-21 filename 04_ARBEIT 0.95 Inventory of beverages for Café Bach, work-related stock management. xlsx:888 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Vorlagen/25-10-Getränkelisten/2026-08-21_cafe-bach_getrnkekarte_mit-pink_A4.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Vorlagen/25-10-Getränkelisten/2026-08-21_cafe-bach_getrnkekarte_mit-pink_A4.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Vorlagen/25-10-Getränkelisten 2026-08-21_cafe-bach_getrnkekarte_mit-pink_A4.docx .docx 2026-08-21 filename 04_ARBEIT 0.95 Document contains work-specific drink pricing for Cafe-Bach operations. docx:367 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Überlassungsverträge/Überlassungsdaten.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Überlassungsverträge/Überlassungsdaten.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Überlassungsverträge Überlassungsdaten.xlsx .xlsx 2026-09-09 mtime 04_ARBEIT 0.95 Contains work-related event data for Café Bach operations. xlsx:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips/einkaufschips_absatzplus.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips/einkaufschips_absatzplus.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips einkaufschips_absatzplus.pdf .pdf 2026-05-15 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips/einkaufschips_coolconcept.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips/einkaufschips_coolconcept.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips einkaufschips_coolconcept.pdf .pdf 2026-05-15 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips/einkaufschips_giffits.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips/einkaufschips_giffits.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips einkaufschips_giffits.pdf .pdf 2026-05-15 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_faecher/angebot_absatzplus_faecher_schwarz.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_faecher/angebot_absatzplus_faecher_schwarz.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_faecher angebot_absatzplus_faecher_schwarz.pdf .pdf 2026-04-29 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten/angebot_postkarten_auflage_2500_pro_motiv_flyeralarm.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten/angebot_postkarten_auflage_2500_pro_motiv_flyeralarm.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten angebot_postkarten_auflage_2500_pro_motiv_flyeralarm.pdf .pdf 2026-05-20 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten/angebot_postkarten_auflage_2500_pro_motiv_laserline.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten/angebot_postkarten_auflage_2500_pro_motiv_laserline.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten angebot_postkarten_auflage_2500_pro_motiv_laserline.pdf .pdf 2026-05-20 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten/angebot_postkarten_auflage_2500_pro_motiv_viaprinto.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten/angebot_postkarten_auflage_2500_pro_motiv_viaprinto.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten angebot_postkarten_auflage_2500_pro_motiv_viaprinto.pdf .pdf 2026-05-20 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts/shirts_herzundlust_absatzplus.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts/shirts_herzundlust_absatzplus.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts shirts_herzundlust_absatzplus.pdf .pdf 2026-05-18 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts/shirts_herzundlust_coolconcept.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts/shirts_herzundlust_coolconcept.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts shirts_herzundlust_coolconcept.pdf .pdf 2026-05-20 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts/shirts_herzundlust_promostore.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts/shirts_herzundlust_promostore.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts shirts_herzundlust_promostore.pdf .pdf 2026-05-18 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche/unhaengetasche_herz_und_lust_absatzplus.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche/unhaengetasche_herz_und_lust_absatzplus.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche unhaengetasche_herz_und_lust_absatzplus.pdf .pdf 2026-05-04 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche/unhaengetasche_herz_und_lust_coolconcept.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche/unhaengetasche_herz_und_lust_coolconcept.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche unhaengetasche_herz_und_lust_coolconcept.pdf .pdf 2026-05-04 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche/unhaengetasche_herz_und_lust_giffits.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche/unhaengetasche_herz_und_lust_giffits.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche unhaengetasche_herz_und_lust_giffits.pdf .pdf 2026-05-04 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/Betriebsanweisung Desinfektionsmittel.pdf" ARCHIVE HYGIENE/Betriebsanweisung Desinfektionsmittel.pdf archive-recovered/HYGIENE/HYGIENE Betriebsanweisung Desinfektionsmittel.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/Biostoffverordnung.pdf" ARCHIVE HYGIENE/Biostoffverordnung.pdf archive-recovered/HYGIENE/HYGIENE Biostoffverordnung.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/Download_Arbeitssicherheit_-_Hygiene.pdf" ARCHIVE HYGIENE/Download_Arbeitssicherheit_-_Hygiene.pdf archive-recovered/HYGIENE/HYGIENE Download_Arbeitssicherheit_-_Hygiene.pdf .pdf 2016-03-05 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/Gefaehrdungsbeurteilung von Biostoffen.pdf" ARCHIVE HYGIENE/Gefaehrdungsbeurteilung von Biostoffen.pdf archive-recovered/HYGIENE/HYGIENE Gefaehrdungsbeurteilung von Biostoffen.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/Handlungshilfe zur Umsetzung der Biostoffverordnung.pdf" ARCHIVE HYGIENE/Handlungshilfe zur Umsetzung der Biostoffverordnung.pdf archive-recovered/HYGIENE/HYGIENE Handlungshilfe zur Umsetzung der Biostoffverordnung.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/Infektionsschutzgesetz.pdf" ARCHIVE HYGIENE/Infektionsschutzgesetz.pdf archive-recovered/HYGIENE/HYGIENE Infektionsschutzgesetz.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-001.pdf" ARCHIVE HYGIENE/TRBA-001.pdf archive-recovered/HYGIENE/HYGIENE TRBA-001.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-100.pdf" ARCHIVE HYGIENE/TRBA-100.pdf archive-recovered/HYGIENE/HYGIENE TRBA-100.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-105.pdf" ARCHIVE HYGIENE/TRBA-105.pdf archive-recovered/HYGIENE/HYGIENE TRBA-105.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-120.pdf" ARCHIVE HYGIENE/TRBA-120.pdf archive-recovered/HYGIENE/HYGIENE TRBA-120.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-212.pdf" ARCHIVE HYGIENE/TRBA-212.pdf archive-recovered/HYGIENE/HYGIENE TRBA-212.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-213.pdf" ARCHIVE HYGIENE/TRBA-213.pdf archive-recovered/HYGIENE/HYGIENE TRBA-213.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-214.pdf" ARCHIVE HYGIENE/TRBA-214.pdf archive-recovered/HYGIENE/HYGIENE TRBA-214.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-220.pdf" ARCHIVE HYGIENE/TRBA-220.pdf archive-recovered/HYGIENE/HYGIENE TRBA-220.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-230.pdf" ARCHIVE HYGIENE/TRBA-230.pdf archive-recovered/HYGIENE/HYGIENE TRBA-230.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-240.pdf" ARCHIVE HYGIENE/TRBA-240.pdf archive-recovered/HYGIENE/HYGIENE TRBA-240.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-250.pdf" ARCHIVE HYGIENE/TRBA-250.pdf archive-recovered/HYGIENE/HYGIENE TRBA-250.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-310.pdf" ARCHIVE HYGIENE/TRBA-310.pdf archive-recovered/HYGIENE/HYGIENE TRBA-310.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-400.pdf" ARCHIVE HYGIENE/TRBA-400.pdf archive-recovered/HYGIENE/HYGIENE TRBA-400.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-405.pdf" ARCHIVE HYGIENE/TRBA-405.pdf archive-recovered/HYGIENE/HYGIENE TRBA-405.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-406.pdf" ARCHIVE HYGIENE/TRBA-406.pdf archive-recovered/HYGIENE/HYGIENE TRBA-406.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-450.pdf" ARCHIVE HYGIENE/TRBA-450.pdf archive-recovered/HYGIENE/HYGIENE TRBA-450.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-460.pdf" ARCHIVE HYGIENE/TRBA-460.pdf archive-recovered/HYGIENE/HYGIENE TRBA-460.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-462.pdf" ARCHIVE HYGIENE/TRBA-462.pdf archive-recovered/HYGIENE/HYGIENE TRBA-462.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-464.pdf" ARCHIVE HYGIENE/TRBA-464.pdf archive-recovered/HYGIENE/HYGIENE TRBA-464.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-466.pdf" ARCHIVE HYGIENE/TRBA-466.pdf archive-recovered/HYGIENE/HYGIENE TRBA-466.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-500.pdf" ARCHIVE HYGIENE/TRBA-500.pdf archive-recovered/HYGIENE/HYGIENE TRBA-500.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE SURVSTAT/Data.csv" ARCHIVE SURVSTAT/Data.csv archive-recovered/SURVSTAT/SURVSTAT Data.csv .csv 2022-10-24 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE SURVSTAT/Info.pdf" ARCHIVE SURVSTAT/Info.pdf archive-recovered/SURVSTAT/SURVSTAT Info.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no diff --git a/2026-09-14_scope-triage_effective-routing_v2.tsv b/2026-09-14_scope-triage_effective-routing_v2.tsv new file mode 100644 index 0000000..0128ef8 --- /dev/null +++ b/2026-09-14_scope-triage_effective-routing_v2.tsv @@ -0,0 +1,272 @@ +key origin relative_path source_context filename extension date_guess date_source scope confidence reason content_status model_path review_flags primary_scope primary_confidence fallback_scope fallback_confidence effective_scope override_applied override_basis override_reason +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Teamtreffen_Protokoll_09_01 - for merge.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Teamtreffen_Protokoll_09_01 - for merge.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden Teamtreffen_Protokoll_09_01 - for merge.docx .docx 2026-09-01 mtime 04_ARBEIT 0.95 Contains Checkpoint team meeting details, project budgets, and work tasks. docx:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/06AnOverviewofCellBiology.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/06AnOverviewofCellBiology.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert 06AnOverviewofCellBiology.pdf .pdf 2022-05-30 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:947 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.80 03_PRIVAT yes reviewed-content Generic academic/reference material with no Aidshilfe or Café-Bach work context. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf .pdf 2022-05-30 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:436 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.78 03_PRIVAT yes reviewed-content Generic academic textbook/reference material with no work-specific context. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Babylon Sauna.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Babylon Sauna.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben - Babylon Sauna.docx .docx 2022-01-10 mtime 04_ARBEIT 0.92 Professional outreach letter proposing collaboration with Babylon Sauna. docx:1489 models-agree ownership-fragile-filename 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Gasto Innenstadt.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Gasto Innenstadt.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben - Gasto Innenstadt.docx .docx 2022-01-07 mtime 04_ARBEIT 0.92 Professional New Year's greeting for Checkpoints collaboration. docx:552 models-agree ownership-fragile-filename 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Gastro Frohes Neues Jahr.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Gastro Frohes Neues Jahr.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben - Gastro Frohes Neues Jahr.docx .docx 2022-01-07 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT docx:61 models-disagree ownership-fragile-filename 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes structural-context Sparse extract, but it belongs to the same Checkpoint/Gastro professional correspondence set as neighboring confirmed work letters. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Parties kurzform FB.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Parties kurzform FB.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben - Parties kurzform FB.docx .docx 2022-03-31 mtime 04_ARBEIT 0.92 Professional outreach letter for prevention work at CheckPoint. docx:514 models-agree ownership-fragile-filename 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Phönix Sauna.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Phönix Sauna.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben - Phönix Sauna.docx .docx 2022-01-10 mtime 04_ARBEIT 0.92 Professional New Year greeting from Checkpoints to sauna team. docx:573 models-agree ownership-fragile-filename 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Phönix Sauna.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Phönix Sauna.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben - Phönix Sauna.pdf .pdf 2022-01-10 mtime 04_ARBEIT 0.92 Professional correspondence from Aidshilfe Köln to Phönix Sauna. pdf:1213 models-agree ownership-fragile-filename,work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben kleine Show Preis.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben kleine Show Preis.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben kleine Show Preis.docx .docx 2022-01-04 mtime 04_ARBEIT 0.92 Professional congratulatory letter from Aidshilfe NRW to participant. docx:595 models-agree ownership-fragile-filename 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben.docx .docx 2022-01-07 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT docx:61 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.85 04_ARBEIT yes user-confirmed User confirmed this is Aidshilfe/Checkpoint work correspondence. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Arbeitsprotokoll.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Arbeitsprotokoll.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Arbeitsprotokoll.docx .docx 2022-01-28 mtime 04_ARBEIT 0.95 Contains work-related tasks and team coordination for CSD campaign and volunteer activities. docx:542 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben models - CSD 2022.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben models - CSD 2022.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Anschreiben models - CSD 2022.docx .docx 2022-03-04 mtime 04_ARBEIT 0.92 Professional invitation from Aidshilfe Köln for campaign participation docx:1722 models-agree ownership-fragile-filename 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Arbeitsvertrag Patrick Jacobs (1) (1).pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Arbeitsvertrag Patrick Jacobs (1) (1).pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Arbeitsvertrag Patrick Jacobs (1) (1).pdf .pdf 2021-12-16 mtime 03_PRIVAT 0.95 User's personal employment contract. pdf:26 models-agree ownership-fragile-filename 03_PRIVAT 0.95 03_PRIVAT 0.95 03_PRIVAT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Bolsover2004CellBiology.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Bolsover2004CellBiology.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Bolsover2004CellBiology.pdf .pdf 2022-05-30 mtime 01_INBOX 0.75 Generic textbook, no evidence of work use or ownership. pdf:2400 models-agree primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.80 03_PRIVAT yes reviewed-content Generic academic textbook/reference material with no work-specific context. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Covid IMpfungen 2022.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Covid IMpfungen 2022.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Covid IMpfungen 2022.docx .docx 2022-01-18 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT docx:318 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 03_PRIVAT yes content-context Personal vaccination record; not an organizational work record. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Fahrtkostenabrechnung_4236_20220201_162815 (1).pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Fahrtkostenabrechnung_4236_20220201_162815 (1).pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Fahrtkostenabrechnung_4236_20220201_162815 (1).pdf .pdf 2022-02-01 filename 04_ARBEIT 0.95 Contains work-related travel reimbursement for a DAH seminar. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Preisvergleich Angebote.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Preisvergleich Angebote.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Preisvergleich Angebote.docx .docx 2022-01-03 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT docx:476 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.92 04_ARBEIT yes reviewed-content Contains a Queeres Netzwerk project-application link alongside projector price comparisons; fits work procurement/project context. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Quiz Terasse 21-07.pptx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Quiz Terasse 21-07.pptx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Quiz Terasse 21-07.pptx .pptx 2022-08-10 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pptx:462 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.80 03_PRIVAT yes user-confirmed User confirmed this belongs to private/Chrystal activities. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen (1).pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen (1).pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert QuiztalMath Antwortbogen (1).pdf .pdf 2022-11-02 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:173 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.75 03_PRIVAT yes user-confirmed User confirmed this belongs to private/Chrystal activities. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert QuiztalMath Antwortbogen.pdf .pdf 2022-11-02 mtime 01_INBOX 0.65 Generic quiz answer sheet, no clear work context or ownership. pdf:141 models-agree primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.65 03_PRIVAT yes user-confirmed User confirmed this belongs to private/Chrystal activities. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Roboto/LICENSE.txt" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Roboto/LICENSE.txt SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Roboto LICENSE.txt .txt 2022-01-17 mtime 01_INBOX 0.75 Generic font license, no work context. text:2400 models-agree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.95 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/S2OGKM_C25021111716590.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/S2OGKM_C25021111716590.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert S2OGKM_C25021111716590.pdf .pdf 2021-11-17 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT pdf:29 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/SENIOR TICKET NL 20220301.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/SENIOR TICKET NL 20220301.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert SENIOR TICKET NL 20220301.pdf .pdf 2022-03-01 filename 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:2400 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 03_PRIVAT yes content-context Personal travel ticket rather than work documentation. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Sample CSV file for importing contacts (1).csv" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Sample CSV file for importing contacts (1).csv SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Sample CSV file for importing contacts (1).csv .csv 2022-01-07 mtime 01_INBOX 0.65 Generic sample CSV for contact import, no work context. text:1994 models-agree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.65 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Tagesplan.xlsx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Tagesplan.xlsx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Tagesplan.xlsx .xlsx 2022-01-11 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT xlsx:951 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 03_PRIVAT yes reviewed-content Personal daily planning sheet containing private routine items such as waking, showering and going to work. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Testsample.csv" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Testsample.csv SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Testsample.csv .csv 2022-01-07 mtime 01_INBOX 0.75 Sample contact list, no real personal or work data. text:1996 models-agree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.85 01_INBOX no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf .pdf 2022-08-10 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT pdf:2400 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 03_PRIVAT yes reviewed-content Generic educational material for children with no work-specific context. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Wichtige_Kundeninformation_zu_Konto_0125273623.PDF" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Wichtige_Kundeninformation_zu_Konto_0125273623.PDF SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Wichtige_Kundeninformation_zu_Konto_0125273623.PDF .pdf 2022-08-15 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:2400 models-disagree ownership-fragile-filename,work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.95 03_PRIVAT yes content-context Personal bank-account/customer information rather than work documentation. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Data.csv" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Data.csv SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat Data.csv .csv 2022-08-29 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT text:362 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 04_ARBEIT yes paired-document-context Paired with SurvStat Info.pdf in the same folder; the companion file is confirmed work health-surveillance material. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Info.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Info.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat Info.pdf .pdf 2022-08-29 mtime 04_ARBEIT 0.95 Contains RKI analysis tool reference and health data query, work-related health surveillance. pdf:973 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_1.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_1.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert ticket_patrick-ja_queerfeld-festiv_1.pdf .pdf 2022-08-29 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:381 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 03_PRIVAT yes paired-document-context Personal festival ticket; matching companion ticket is already classified private. +"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_2.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_2.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert ticket_patrick-ja_queerfeld-festiv_2.pdf .pdf 2022-08-29 mtime 03_PRIVAT 0.85 Personal ticket for user’s private festival attendance pdf:381 models-agree ownership-fragile-filename 03_PRIVAT 0.85 03_PRIVAT 0.92 03_PRIVAT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Getränkeliste Verzehrkarte.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Getränkeliste Verzehrkarte.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB Getränkeliste Verzehrkarte.pdf .pdf 2026-09-03 mtime 04_ARBEIT 0.95 Document lists beverages for a team event with payment instructions, indicating work use. pdf:787 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Kühlschrank 07-08-13,08.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Kühlschrank 07-08-13,08.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB Kühlschrank 07-08-13,08.pdf .pdf 2026-09-09 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:27 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.80 04_ARBEIT yes structural-context Café-Bach operational file in the CB inbox; refrigerator-related document belongs to work context. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/SP5 treffen Samstag 15-08.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/SP5 treffen Samstag 15-08.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB SP5 treffen Samstag 15-08.pdf .pdf 2026-09-09 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT pdf:27 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 04_ARBEIT yes structural-context SP5 meeting document in Café-Bach work backup; SP5 is a work project. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/A5 Aufsteller.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/A5 Aufsteller.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 A5 Aufsteller.docx .docx 2024-11-08 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT docx:53 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.90 04_ARBEIT yes structural-context Display/signage document inside Café-Bach archive; work-material context is stronger than sparse extracted text. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/3 Semmelknödel.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/3 Semmelknödel.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe 3 Semmelknödel.pdf .pdf 2022-12-13 mtime 04_ARBEIT 0.95 Contains allergen documentation for cafebach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/650Gramm Grill Haxe vom Schwein.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/650Gramm Grill Haxe vom Schwein.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe 650Gramm Grill Haxe vom Schwein.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Backfisch vom Seelachs.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Backfisch vom Seelachs.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Backfisch vom Seelachs.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items, work-related food safety. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Fischeintopf.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Fischeintopf.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Fischeintopf.pdf .pdf 2022-11-07 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Formblatt_Allergeninformation.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Formblatt_Allergeninformation.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Formblatt_Allergeninformation.pdf .pdf 2022-09-19 mtime 04_ARBEIT 0.95 Contains allergen documentation for food items relevant to work operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Fusilli Gorgonzolasoße.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Fusilli Gorgonzolasoße.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Fusilli Gorgonzolasoße.pdf .pdf 2022-11-07 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gebackene Cannelloni.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gebackene Cannelloni.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Gebackene Cannelloni.pdf .pdf 2022-12-02 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item, likely for Cafe-Bach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gedünstetes Pangasius Filet.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gedünstetes Pangasius Filet.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Gedünstetes Pangasius Filet.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Geflügel Gyros.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Geflügel Gyros.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Geflügel Gyros.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gefüllte Paprikaschote.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gefüllte Paprikaschote.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Gefüllte Paprikaschote.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for food items relevant to work operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gemüse Quiche.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gemüse Quiche.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Gemüse Quiche.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for work menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gnocchi in Feta-Tomatensoße.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Gnocchi in Feta-Tomatensoße.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Gnocchi in Feta-Tomatensoße.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Hühnerfrikassee.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Hühnerfrikassee.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Hühnerfrikassee.pdf .pdf 2022-12-02 mtime 04_ARBEIT 0.95 Document lists allergens and additives for menu items, likely for work compliance. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Kartoffel Eintopf.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Kartoffel Eintopf.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Kartoffel Eintopf.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for work menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Kartoffel Zucchini Puffer.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Kartoffel Zucchini Puffer.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Kartoffel Zucchini Puffer.pdf .pdf 2022-11-07 mtime 04_ARBEIT 0.95 Contains allergen documentation for cafebach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Kohlroulade.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Kohlroulade.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Kohlroulade.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items, likely work-related. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Lachsfilet.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Lachsfilet.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Lachsfilet.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items, likely work-related. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Leberkäse.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Leberkäse.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Leberkäse.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for work menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Marinierte Hähnchenkeule.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Marinierte Hähnchenkeule.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Marinierte Hähnchenkeule.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for work menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Nochmal Kind sein!.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Nochmal Kind sein!.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Nochmal Kind sein!.pdf .pdf 2022-12-02 mtime 04_ARBEIT 0.95 Contains allergen documentation for work-related food items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Paprika Rahmgeschnetzeltes, Hähnchen, Reis.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Paprika Rahmgeschnetzeltes, Hähnchen, Reis.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Paprika Rahmgeschnetzeltes, Hähnchen, Reis.pdf .pdf 2022-09-19 mtime 04_ARBEIT 0.95 Contains allergen documentation for menu items, likely work-related. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Paprika Tomatenpfanne.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Paprika Tomatenpfanne.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Paprika Tomatenpfanne.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Pasta Arrabiata.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Pasta Arrabiata.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Pasta Arrabiata.pdf .pdf 2022-12-02 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items, likely work-related. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Pasta Rote Linsen.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Pasta Rote Linsen.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Pasta Rote Linsen.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for restaurant menu items, likely work-related. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Pasta Thunfischsoße.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Pasta Thunfischsoße.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Pasta Thunfischsoße.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Putengeschnetzelets.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Putengeschnetzelets.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Putengeschnetzelets.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for cafe operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Quinoa Suppe.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Quinoa Suppe.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Quinoa Suppe.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item, likely related to Cafe-Bach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Semmelknödel.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Semmelknödel.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Semmelknödel.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for cafebach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Speckpfannekuchen.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Speckpfannekuchen.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Speckpfannekuchen.pdf .pdf 2022-11-07 mtime 04_ARBEIT 0.95 Contains allergen documentation for cafebach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganer Bauerneintopf.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganer Bauerneintopf.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Veganer Bauerneintopf.pdf .pdf 2022-11-07 mtime 04_ARBEIT 0.95 Contains allergen documentation for work menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganer Kichererbsen Eintopf.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganer Kichererbsen Eintopf.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Veganer Kichererbsen Eintopf.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganes Curry.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganes Curry.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Veganes Curry.pdf .pdf 2022-12-02 mtime 04_ARBEIT 0.95 Contains allergen documentation for cafebach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganes Soja-Schnitzel mit Ebly.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veganes Soja-Schnitzel mit Ebly.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Veganes Soja-Schnitzel mit Ebly.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Vegetarische Maultaschen.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Vegetarische Maultaschen.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Vegetarische Maultaschen.pdf .pdf 2022-12-02 mtime 04_ARBEIT 0.95 Contains allergen documentation for work menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veggi tortellini.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Veggi tortellini.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Veggi tortellini.pdf .pdf 2022-09-23 mtime 04_ARBEIT 0.95 Contains allergen documentation for cafebach operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Weizen Wrap.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Weizen Wrap.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Weizen Wrap.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for food items relevant to work operations. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Zanderfilet in Nuss Kruste.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Zanderfilet in Nuss Kruste.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Zanderfilet in Nuss Kruste.pdf .pdf 2022-12-02 mtime 04_ARBEIT 0.95 Contains allergen documentation for a restaurant menu item, likely work-related. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Zusatzstoffe_-_Kenntlichmachung_auf_Speise-_und_Getraenkekarten.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Zusatzstoffe_-_Kenntlichmachung_auf_Speise-_und_Getraenkekarten.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Zusatzstoffe_-_Kenntlichmachung_auf_Speise-_und_Getraenkekarten.pdf .pdf 2022-09-19 mtime 04_ARBEIT 0.95 Contains professional guidelines for allergen labeling in restaurant operations. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/himmel un Äd.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/himmel un Äd.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe himmel un Äd.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for work menu items. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Überbackener Macceroni Auflauf.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe/Überbackener Macceroni Auflauf.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Allergene und Zusatzstoffe Überbackener Macceroni Auflauf.pdf .pdf 2022-11-28 mtime 04_ARBEIT 0.95 Contains allergen documentation for a work menu item. pdf:1686 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Aufstellung Kassenbestandteile.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Aufstellung Kassenbestandteile.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Aufstellung Kassenbestandteile.pdf .pdf 2025-02-19 mtime 04_ARBEIT 0.95 File contains cash inventory details for a work location. pdf:29 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Bedienung Spülmaschine Caféküche.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Bedienung Spülmaschine Caféküche.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Bedienung Spülmaschine Caféküche.docx .docx 2025-03-28 mtime 04_ARBEIT 0.95 Work-specific instruction for kitchen equipment operation docx:1920 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/CaBa Terminübersicht Aushang A3 Fenster Türen Fahrstuhl Vorlage.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/CaBa Terminübersicht Aushang A3 Fenster Türen Fahrstuhl Vorlage.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 CaBa Terminübersicht Aushang A3 Fenster Türen Fahrstuhl Vorlage.docx .docx 2025-07-24 mtime 04_ARBEIT 0.95 Document contains event planning for Café Bach, a work-related venue. docx:244 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Finanzen/Bereinigung Kasse.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Finanzen/Bereinigung Kasse.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Finanzen Bereinigung Kasse.xlsx .xlsx 2024-01-19 mtime 04_ARBEIT 0.95 Financial records of a work cash register tied to cafebach operations. xlsx:916 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Finanzen/Catering Rechner.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Finanzen/Catering Rechner.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Finanzen Catering Rechner.xlsx .xlsx 2024-07-16 mtime 04_ARBEIT 0.95 Catering cost calculation for a work event with budget and personnel details. xlsx:207 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Aushang für Notfälle.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Aushang für Notfälle.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Getränkeliste Aushang für Notfälle.docx .docx 2023-05-16 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT docx:216 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.90 04_ARBEIT yes structural-context Emergency beverage-list signage for Café-Bach operations. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste OHNE KAFFEE.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste OHNE KAFFEE.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Getränkeliste OHNE KAFFEE.docx .docx 2025-10-15 mtime 04_ARBEIT 0.95 Document lists beverages and prices, likely for work menu management. docx:355 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Veranstaltungen.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Veranstaltungen.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Getränkeliste Veranstaltungen.xlsx .xlsx 2025-03-28 mtime 04_ARBEIT 0.95 Contains pricing and inventory for event beverages, likely work-related. xlsx:424 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkepreise.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkepreise.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Getränkepreise.xlsx .xlsx 2025-01-29 mtime 04_ARBEIT 0.95 Contains pricing data for beverages and coffee, likely used for work operations. xlsx:1688 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Gruppen und Veranstaltungen im Cafe Bach (version 1).xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Gruppen und Veranstaltungen im Cafe Bach (version 1).xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Gruppen und Veranstaltungen im Cafe Bach (version 1).xlsx .xlsx 2025-02-05 mtime 04_ARBEIT 0.95 Contains event planning for professional collaboration and organizational activities. xlsx:1108 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Gruppen und Veranstaltungen im Cafe Bach.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Gruppen und Veranstaltungen im Cafe Bach.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Gruppen und Veranstaltungen im Cafe Bach.xlsx .xlsx 2025-01-22 mtime 04_ARBEIT 0.95 Contains work-related group meetings and events for Aidshilfe Köln e.V. xlsx:1106 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/Botzenhardt_Albrecht.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/Botzenhardt_Albrecht.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP Botzenhardt_Albrecht.pdf .pdf 2016-08-03 mtime 04_ARBEIT 0.95 HACCP document for cheese production, professional context pdf:2128 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/HACCP_in_der_Praxis.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/HACCP_in_der_Praxis.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP HACCP_in_der_Praxis.pdf .pdf 2016-08-03 mtime 04_ARBEIT 0.95 HACCP guidelines for food safety in a professional kitchen setting. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/Infektionsschutzgesetz-gesamt.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/Infektionsschutzgesetz-gesamt.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP Infektionsschutzgesetz-gesamt.pdf .pdf 2016-11-02 mtime 04_ARBEIT 0.95 Legal document related to public health regulations, likely used for professional compliance. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/eu_lebensmittelhygiene_haccp_2014.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/eu_lebensmittelhygiene_haccp_2014.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP eu_lebensmittelhygiene_haccp_2014.pdf .pdf 2016-08-03 mtime 04_ARBEIT 0.95 Contains HACCP guidelines for food safety, relevant to work operations. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/fm_Leitfaden_fuer_die_Kontrolle_und_Anwendungen_des_HACCP_Konzeptes.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/fm_Leitfaden_fuer_die_Kontrolle_und_Anwendungen_des_HACCP_Konzeptes.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP fm_Leitfaden_fuer_die_Kontrolle_und_Anwendungen_des_HACCP_Konzeptes.pdf .pdf 2016-08-03 mtime 04_ARBEIT 0.95 HACCP guide for food safety in workplace operations pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/haccp_handbuch_de_rd14_11.06.2014.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP/haccp_handbuch_de_rd14_11.06.2014.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/HACCP haccp_handbuch_de_rd14_11.06.2014.pdf .pdf 2014-06-11 filename 04_ARBEIT 0.95 HACCP manual for food safety in professional kitchen operations. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Handout Cafe' Bach Externe Nutzung.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Handout Cafe' Bach Externe Nutzung.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Handout Cafe' Bach Externe Nutzung.docx .docx 2025-11-04 mtime 04_ARBEIT 0.95 Document outlines operational guidelines for external use of Cafe' Bach spaces by Checkpoint/Aidshilfe. docx:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Eröffnungsinventur Café Bach Aug 2022.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Eröffnungsinventur Café Bach Aug 2022.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar Eröffnungsinventur Café Bach Aug 2022.pdf .pdf 2023-08-18 mtime 04_ARBEIT 0.95 Inventory of work supplies for Café Bach operation. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventarliste Café Bach 2022.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventarliste Café Bach 2022.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar Inventarliste Café Bach 2022.xlsx .xlsx 2025-01-08 mtime 04_ARBEIT 0.95 Inventory list for Café Bach work assets xlsx:1772 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 2022.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 2022.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar Inventur Café Bach 2022.xlsx .xlsx 2023-08-18 mtime 04_ARBEIT 0.95 Inventory of work supplies for Café Bach, professional context xlsx:1795 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 2023.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 2023.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar Inventur Café Bach 2023.xlsx .xlsx 2024-04-29 mtime 04_ARBEIT 0.95 Inventory of work inventory for Café Bach, professional context xlsx:1594 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 2024.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 2024.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar Inventur Café Bach 2024.xlsx .xlsx 2024-12-20 mtime 04_ARBEIT 0.95 Inventory of work inventory for Café Bach, includes product prices and quantities. xlsx:1611 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 31.12.2022.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar/Inventur Café Bach 31.12.2022.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Inventur - Inventar Inventur Café Bach 31.12.2022.pdf .pdf 2022-12-31 filename 04_ARBEIT 0.95 Inventory of work supplies for Café Bach, professional context pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Jour Fixe/Jour Fix Basti.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Jour Fixe/Jour Fix Basti.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Jour Fixe Jour Fix Basti.xlsx .xlsx 2025-01-06 mtime 04_ARBEIT 0.95 Contains work tasks, team assignments, and project planning for Cafe-Bach operations. xlsx:1011 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Jour Fixe/Jour Fix Basti_2025.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Jour Fixe/Jour Fix Basti_2025.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Jour Fixe Jour Fix Basti_2025.xlsx .xlsx 2025-04-08 mtime 04_ARBEIT 0.95 Contains work project tasks and team assignments for Cafe-Bach operations. xlsx:1011 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Auswertung Bewertungsbögen Soft Opening.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Auswertung Bewertungsbögen Soft Opening.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation Auswertung Bewertungsbögen Soft Opening.xlsx .xlsx 2022-08-31 mtime 04_ARBEIT 0.95 File contains work evaluation data for a café project. xlsx:635 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Kalkulation 2022.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Kalkulation 2022.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation Kalkulation 2022.xlsx .xlsx 2022-08-12 mtime 04_ARBEIT 0.95 Contains cost calculations for meals at Cafe-Bach, likely work-related. xlsx:1801 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Kalkulation Longdrinks.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Kalkulation Longdrinks.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation Kalkulation Longdrinks.xlsx .xlsx 2024-06-19 mtime 04_ARBEIT 0.95 Contains cost calculations for beverages used in work operations. xlsx:367 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Kalkulation Rouladen_Überprüfung.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Kalkulation Rouladen_Überprüfung.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation Kalkulation Rouladen_Überprüfung.xlsx .xlsx 2022-11-17 mtime 04_ARBEIT 0.95 Contains cost calculations for restaurant dishes, likely work-related. xlsx:1021 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Speisenkalkulation 2022.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Speisenkalkulation 2022.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation Speisenkalkulation 2022.xlsx .xlsx 2023-03-17 mtime 04_ARBEIT 0.95 Contains cost calculations for food items in a work context. xlsx:861 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Speisenkalkulation 2023 - 13.03.-17.03..xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation/Speisenkalkulation 2023 - 13.03.-17.03..xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kalkulation Speisenkalkulation 2023 - 13.03.-17.03..xlsx .xlsx 2023-04-06 mtime 04_ARBEIT 0.95 Contains cost calculations for food items in a work context. xlsx:861 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kasse Café Bach/Kassenbericht Café Bach.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kasse Café Bach/Kassenbericht Café Bach.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kasse Café Bach Kassenbericht Café Bach.xlsx .xlsx 2025-07-04 mtime 04_ARBEIT 0.95 Kassenbericht for Café Bach, work financial records xlsx:891 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kasse Café Bach/Zählprotokoll Café Bach.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kasse Café Bach/Zählprotokoll Café Bach.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Kasse Café Bach Zählprotokoll Café Bach.xlsx .xlsx 2025-04-28 mtime 04_ARBEIT 0.95 Café Bach cash register records for work operations. xlsx:768 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/AG-Inso-Nr-1b-Unternehmen--.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/AG-Inso-Nr-1b-Unternehmen--.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession AG-Inso-Nr-1b-Unternehmen--.pdf .pdf 2022-07-13 mtime 04_ARBEIT 0.95 Formal request for business clearance tied to legal work process. pdf:1494 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Antrag auf Auszug aus dem Gewerbezentralregister.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Antrag auf Auszug aus dem Gewerbezentralregister.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Antrag auf Auszug aus dem Gewerbezentralregister.docx .docx 2022-10-05 mtime 04_ARBEIT 0.95 Document is a formal business request related to a work license and operations. docx:871 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Auszug_aus_Liegenschaftskataster.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Auszug_aus_Liegenschaftskataster.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Auszug_aus_Liegenschaftskataster.pdf .pdf 2022-07-21 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:no-text-or-scan models-disagree primary-confidence-below-0.85,limited-content 01_INBOX 0.75 03_PRIVAT 0.75 04_ARBEIT yes structural-context Property extract stored in the Café-Bach concession application package; work licensing record. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf .pdf 2022-11-22 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:29 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.92 04_ARBEIT yes structural-context Certificate is part of the Café-Bach concession application package; retained as work licensing documentation. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Equipment Cafe Bach.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Equipment Cafe Bach.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Equipment Cafe Bach.xlsx .xlsx 2022-07-19 mtime 04_ARBEIT 0.95 Contains equipment pricing for Cafe Bach, work-related procurement xlsx:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Notwendige Unterlagen für Konzessionsantrag.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Notwendige Unterlagen für Konzessionsantrag.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Notwendige Unterlagen für Konzessionsantrag.docx .docx 2022-06-13 mtime 04_ARBEIT 0.95 Contains work-related licensing documents for a business operation. docx:1383 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Teilnahmebescheinigung nach dem Gasstättengesetz, Schubert Oliver.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Teilnahmebescheinigung nach dem Gasstättengesetz, Schubert Oliver.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Teilnahmebescheinigung nach dem Gasstättengesetz, Schubert Oliver.pdf .pdf 2022-10-26 mtime 04_ARBEIT 0.95 Document relates to gas station concession and professional work context. pdf:29 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Unterlagen für Konzessionsantrag_27.09.2022.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Unterlagen für Konzessionsantrag_27.09.2022.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Unterlagen für Konzessionsantrag_27.09.2022.pdf .pdf 2022-09-27 filename 04_ARBEIT 0.95 File contains work-related licensing application materials for a professional operation. pdf:29 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/öffentliche_Einsichtnahme_20220718-1437.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/öffentliche_Einsichtnahme_20220718-1437.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession öffentliche_Einsichtnahme_20220718-1437.pdf .pdf 2022-07-18 filename 04_ARBEIT 0.95 Contains a work-related Konzessionsantrag for Café Bach, professional context. pdf:843 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Lieferanten und Dienstleister.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Lieferanten und Dienstleister.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Lieferanten und Dienstleister.xlsx .xlsx 2025-06-03 mtime 04_ARBEIT 0.95 Contains supplier and service provider info for Café Bach operations. xlsx:1352 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Namensschilder.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Namensschilder.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Namensschilder.docx .docx 2024-06-04 mtime 04_ARBEIT 0.95 Contains names likely for work identification in a professional context. docx:16 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Abschied Ute Hummler 16.08.24.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Abschied Ute Hummler 16.08.24.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024 Abschied Ute Hummler 16.08.24.pdf .pdf 2024-08-16 filename 04_ARBEIT 0.95 Contains work schedule and staff names from a café, indicating operational use. pdf:189 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Apple Event 22.08.24.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Apple Event 22.08.24.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024 Apple Event 22.08.24.pdf .pdf 2024-08-22 filename 04_ARBEIT 0.95 Contains staff schedule for Apple Event, work-related personnel assignments. pdf:183 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 01.04.-05.04.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 01.04.-05.04.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April DP 01.04.-05.04.pdf .pdf 2024-03-22 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, indicating operational use. pdf:757 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 08.04.-12.04.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 08.04.-12.04.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April DP 08.04.-12.04.pdf .pdf 2024-03-22 mtime 04_ARBEIT 0.95 Contains work schedule for employees at Cafe-Bach, including roles and shifts. pdf:715 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 15.04. - 19.04.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 15.04. - 19.04.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April DP 15.04. - 19.04.pdf .pdf 2024-04-11 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, including roles and shifts. pdf:681 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 22.04. - 26.04.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 22.04. - 26.04.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April DP 22.04. - 26.04.pdf .pdf 2024-04-25 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, including roles and shifts. pdf:663 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 29.04. - 03.05.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April/DP 29.04. - 03.05.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/April DP 29.04. - 03.05.pdf .pdf 2024-04-26 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, including shifts and roles. pdf:727 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP August 24.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP August 24.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024 DP August 24.pdf .pdf 2024-07-24 mtime 04_ARBEIT 0.95 Work schedule for cafebach employees, includes shift times and roles. pdf:1792 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP Juli 24.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP Juli 24.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024 DP Juli 24.pdf .pdf 2024-07-05 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, indicating operational use. pdf:2303 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP November 24.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP November 24.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024 DP November 24.pdf .pdf 2024-10-31 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, including shifts and names. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP Oktober 24.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP Oktober 24.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024 DP Oktober 24.pdf .pdf 2024-10-02 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, indicating professional use. pdf:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP September 24.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/DP September 24.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024 DP September 24.pdf .pdf 2024-08-23 mtime 04_ARBEIT 0.95 Contains work schedule for employees at Cafe-Bach, indicating professional use. pdf:2296 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar/DP 05.02.-09.02..pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar/DP 05.02.-09.02..pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar DP 05.02.-09.02..pdf .pdf 2024-01-30 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, including roles and shifts. pdf:805 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar/DP 19.02.-23.02..pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar/DP 19.02.-23.02..pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar DP 19.02.-23.02..pdf .pdf 2024-02-15 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, indicating operational use. pdf:704 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar/DP KW09.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar/DP KW09.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Februar DP KW09.pdf .pdf 2024-02-29 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, including shifts and roles. pdf:714 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Januar/DP 22.01.-26.01..pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Januar/DP 22.01.-26.01..pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Januar DP 22.01.-26.01..pdf .pdf 2024-01-18 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, including roles and shifts. pdf:632 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Januar/DP 29.01.-02.02..pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Januar/DP 29.01.-02.02..pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Januar DP 29.01.-02.02..pdf .pdf 2024-01-26 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, work-related context. pdf:658 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 03.06. - 07.06.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 03.06. - 07.06.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni DP 03.06. - 07.06.pdf .pdf 2024-05-28 mtime 04_ARBEIT 0.95 Contains work schedule for Cafe-Bach staff, indicating operational use. pdf:637 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 10.06. - 14.06.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 10.06. - 14.06.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni DP 10.06. - 14.06.pdf .pdf 2024-05-28 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, including roles and shifts. pdf:649 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 17.06. - 21.06.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 17.06. - 21.06.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni DP 17.06. - 21.06.pdf .pdf 2024-06-13 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, indicating operational use. pdf:743 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 24.06. - 28.06.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni/DP 24.06. - 28.06.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Juni DP 24.06. - 28.06.pdf .pdf 2024-06-13 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees pdf:696 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 06.05. - 10.05.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 06.05. - 10.05.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai DP 06.05. - 10.05.pdf .pdf 2024-04-30 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, clearly tied to operational shifts. pdf:959 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 13.05. - 17.05.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 13.05. - 17.05.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai DP 13.05. - 17.05.pdf .pdf 2024-05-03 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, including roles and shifts. pdf:667 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 20.05. - 24.05.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 20.05. - 24.05.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai DP 20.05. - 24.05.pdf .pdf 2024-05-21 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, indicating operational use. pdf:724 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 27.05. - 31.05.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai/DP 27.05. - 31.05.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/Mai DP 27.05. - 31.05.pdf .pdf 2024-05-21 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, including roles and shifts. pdf:781 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP 18.03.-22.03.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP 18.03.-22.03.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März DP 18.03.-22.03.pdf .pdf 2024-03-12 mtime 04_ARBEIT 0.95 Contains work schedule for employees at Cafe-Bach, indicating professional use. pdf:733 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP 25.03 - 29.03.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP 25.03 - 29.03.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März DP 25.03 - 29.03.pdf .pdf 2024-03-12 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach staff, indicating operational use. pdf:761 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP KW10.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP KW10.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März DP KW10.pdf .pdf 2024-02-29 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, including roles and shifts. pdf:696 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP KW11.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März/DP KW11.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Dienstpläne 2024/März DP KW11.pdf .pdf 2024-03-12 mtime 04_ARBEIT 0.95 Contains work schedule for cafebach employees, work shifts, and operational planning. pdf:807 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Ehrenamtsbescheinigung Vorlage.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Ehrenamtsbescheinigung Vorlage.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal Ehrenamtsbescheinigung Vorlage.docx .docx 2025-01-17 mtime 04_ARBEIT 0.95 Document is a work-related certificate for an employee at Aidshilfe Köln. docx:1826 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Perso-Essen.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Perso-Essen.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal Perso-Essen.xlsx .xlsx 2024-11-04 mtime 03_PRIVAT 0.95 Contains personal meal records for individuals, likely private personal data. xlsx:912 primary 03_PRIVAT 0.95 04_ARBEIT yes user-confirmed User confirmed that 'Perso'/'Personal' here means staff/personnel, not private/personal. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Personalplanung (neu).xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Personalplanung (neu).xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal Personalplanung (neu).xlsx .xlsx 2024-11-27 mtime 04_ARBEIT 0.95 Contains workforce scheduling and personnel data for Cafe-Bach operations. xlsx:705 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Cafe Bach RE.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Cafe Bach RE.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen Cafe Bach RE.xlsx .xlsx 2025-01-20 mtime 04_ARBEIT 0.92 Invoice record for Aidshilfe events at Café Bach. xlsx:2400 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Café Bach Vorlage RE.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Café Bach Vorlage RE.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen Café Bach Vorlage RE.docx .docx 2024-02-15 mtime 04_ARBEIT 0.92 Professional invoice for Café Bach, used in work administration. docx:842 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2401 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2401 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2401 Aidshilfe Köln.pdf .pdf 2024-02-15 mtime 04_ARBEIT 0.92 Invoice for professional meeting expenses, used for work expense tracking. pdf:1151 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2402 Deutsche Aidshilfe.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2402 Deutsche Aidshilfe.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2402 Deutsche Aidshilfe.pdf .pdf 2024-02-15 mtime 04_ARBEIT 0.92 Invoice for professional catering services to Café Bach pdf:1188 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2403 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2403 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2403 Junge Stadt Köln.pdf .pdf 2024-02-15 mtime 04_ARBEIT 0.92 Invoice for professional event, work expense. pdf:1049 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2404 Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2404 Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2404 Stadt Köln.pdf .pdf 2024-02-15 mtime 04_ARBEIT 0.92 Invoice for professional event catering services. pdf:1142 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2405 Fraktion DIE LINKE.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2405 Fraktion DIE LINKE.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2405 Fraktion DIE LINKE.pdf .pdf 2024-03-05 mtime 04_ARBEIT 0.92 Invoice for Café Bach event, work expense record. pdf:1063 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2406 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2406 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2406 Junge Stadt Köln.pdf .pdf 2024-03-05 mtime 04_ARBEIT 0.69 fallback failed; Invoice for work event with employer details and professional context. pdf:1069 primary-fallback-failed work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2407 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2407 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2407 Aidshilfe Köln.pdf .pdf 2024-03-08 mtime 04_ARBEIT 0.92 Business invoice for professional event expenses. pdf:1066 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2408 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2408 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2408 Aidshilfe Köln.pdf .pdf 2024-03-08 mtime 04_ARBEIT 0.69 fallback failed; Rechnung for work event with employer details and professional context. pdf:1003 primary-fallback-failed work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2409 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2409 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2409 Aidshilfe Köln.pdf .pdf 2024-03-08 mtime 04_ARBEIT 0.92 Invoice for professional meeting, expense record pdf:1202 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2410 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2410 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2410 Lebenshaus-Stiftung.pdf .pdf 2024-04-11 mtime 04_ARBEIT 0.92 Professional invoice for work-related catering expense. pdf:965 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2411 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2411 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2411 Aidshilfe Köln.pdf .pdf 2024-04-11 mtime 04_ARBEIT 0.92 Professional invoice for work-related catering service. pdf:1037 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2412 SCHLAU NRW.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2412 SCHLAU NRW.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2412 SCHLAU NRW.pdf .pdf 2024-04-11 mtime 04_ARBEIT 0.92 Professional invoice for Aidshilfe Köln event. pdf:1225 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2413 Lebenshaus-Stifung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2413 Lebenshaus-Stifung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2413 Lebenshaus-Stifung.pdf .pdf 2024-05-03 mtime 04_ARBEIT 0.92 Business invoice for professional meeting, addressed to user, in invoices folder. pdf:1092 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2414 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2414 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2414 Junge Stadt Köln.pdf .pdf 2024-05-03 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1062 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2415 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2415 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2415 Aidshilfe Köln.pdf .pdf 2024-05-03 mtime 04_ARBEIT 0.92 Invoice for professional catering expense. pdf:1049 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2416 Jana Kühnel.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2416 Jana Kühnel.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2416 Jana Kühnel.pdf .pdf 2024-05-24 mtime 04_ARBEIT 0.92 Invoice from Aidshilfe Köln to Jana Kühnel for a professional event. pdf:1056 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2417 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2417 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2417 Junge Stadt Köln.pdf .pdf 2024-06-04 mtime 04_ARBEIT 0.92 Invoice for work event, addressed to employee, used for expense reimbursement. pdf:1077 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2418 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2418 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2418 Aidshilfe Köln.pdf .pdf 2024-06-05 mtime 04_ARBEIT 0.92 Invoice for professional event, business transaction. pdf:1044 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2419 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2419 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2419 Lebenshaus-Stiftung.pdf .pdf 2024-06-05 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1027 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.92 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2420 Academy of Media Arts Cologne.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2420 Academy of Media Arts Cologne.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2420 Academy of Media Arts Cologne.pdf .pdf 2024-06-20 mtime 04_ARBEIT 0.92 Invoice for catering services, professional expense record. pdf:1163 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2421 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2421 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2421 Junge Stadt Köln.pdf .pdf 2024-06-27 mtime 04_ARBEIT 0.69 fallback failed; Rechnung for work event with employer details and professional context pdf:1143 primary-fallback-failed work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2422 Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2422 Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2422 Stadt Köln.pdf .pdf 2024-07-24 mtime 04_ARBEIT 0.92 Invoice for professional event, used for work expenses. pdf:1380 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2423 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2423 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2423 Junge Stadt Köln.pdf .pdf 2024-08-12 mtime 04_ARBEIT 0.92 Invoice for work event catering, professional expense documentation. pdf:1104 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2424 Queeres Netzwerk NRW.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2424 Queeres Netzwerk NRW.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2424 Queeres Netzwerk NRW.pdf .pdf 2024-08-21 mtime 04_ARBEIT 0.92 Business invoice for catering event, professional record. pdf:1205 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2424 Ute Hummler.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2424 Ute Hummler.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2424 Ute Hummler.pdf .pdf 2024-08-19 mtime 04_ARBEIT 0.92 Invoice for work farewell party, professional expense. pdf:1147 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2425 Queeres Netzwerk NRW.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2425 Queeres Netzwerk NRW.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2425 Queeres Netzwerk NRW.pdf .pdf 2024-08-20 mtime 04_ARBEIT 0.95 Business invoice for catering event, professional use. pdf:1100 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2426 Apple Retail Germany.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2426 Apple Retail Germany.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2426 Apple Retail Germany.pdf .pdf 2024-08-22 mtime 04_ARBEIT 0.92 Business invoice for professional event, used for accounting. pdf:1105 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2427 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2427 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2427 Junge Stadt Köln.pdf .pdf 2024-09-02 mtime 04_ARBEIT 0.90 Business invoice for work-related expense. pdf:1160 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.90 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2428 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2428 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2428 Aidshilfe Köln.pdf .pdf 2024-09-04 mtime 04_ARBEIT 0.92 Invoice for business meeting expenses, issued by Aidshilfe Köln to employee. pdf:1047 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2429 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2429 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2429 Lebenshaus-Stiftung.pdf .pdf 2024-10-01 mtime 04_ARBEIT 0.92 Invoice for professional event expense. pdf:1077 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2430 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2430 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2430 Lebenshaus-Stiftung.pdf .pdf 2024-10-01 mtime 04_ARBEIT 0.92 Invoice for business event expenses, professional use. pdf:1072 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2431 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2431 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2431 Lebenshaus-Stiftung.pdf .pdf 2024-10-01 mtime 04_ARBEIT 0.69 fallback failed; Rechnung for work event with employer details and professional context. pdf:1075 primary-fallback-failed work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2432 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2432 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2432 Aidshilfe Köln.pdf .pdf 2024-10-01 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1038 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2433 zwo zwo eins Konzept GmbH.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2433 zwo zwo eins Konzept GmbH.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2433 zwo zwo eins Konzept GmbH.pdf .pdf 2024-10-02 mtime 04_ARBEIT 0.92 Business invoice for professional event expenses. pdf:1128 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2434 SHALK NRW.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2434 SHALK NRW.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2434 SHALK NRW.pdf .pdf 2024-10-15 mtime 04_ARBEIT 0.95 Invoice for professional event expenses, used by Aidshilfe Köln. pdf:1206 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2435 Aidshilfe NRW.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2435 Aidshilfe NRW.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2435 Aidshilfe NRW.pdf .pdf 2024-10-15 mtime 04_ARBEIT 0.92 Invoice for professional catering service to Café Bach. pdf:1040 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2436 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2436 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2436 Aidshilfe Köln.pdf .pdf 2024-10-28 mtime 04_ARBEIT 0.92 Invoice for professional event expenses. pdf:1168 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2437 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2437 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2437 Aidshilfe Köln.pdf .pdf 2024-10-29 mtime 04_ARBEIT 0.92 Professional invoice for work-related catering expense. pdf:1007 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2438 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2438 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2438 Junge Stadt Köln.pdf .pdf 2024-10-31 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1137 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2439 Aidshilfe NRW.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2439 Aidshilfe NRW.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2439 Aidshilfe NRW.pdf .pdf 2024-11-05 mtime 04_ARBEIT 0.92 Invoice for business expense between Aidshilfe and Café Bach pdf:1024 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2440 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2440 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2440 Aidshilfe Köln.pdf .pdf 2024-11-14 mtime 04_ARBEIT 0.92 Invoice for professional expense to employee Oliver Schubert. pdf:1054 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2441 Deutsche Aidshilfe.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2441 Deutsche Aidshilfe.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2441 Deutsche Aidshilfe.pdf .pdf 2024-11-14 mtime 04_ARBEIT 0.92 Invoice for catering service to Deutsche Aidshilfe, professional record. pdf:1058 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2442 Deutsche Aidshilfe.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2442 Deutsche Aidshilfe.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2442 Deutsche Aidshilfe.pdf .pdf 2024-11-14 mtime 04_ARBEIT 0.92 Professional invoice for catering services between Aidshilfe and Café Bach. pdf:1114 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2443 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2443 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2443 Lebenshaus-Stiftung.pdf .pdf 2024-11-28 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1086 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.90 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2444 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2444 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2444 Junge Stadt Köln.pdf .pdf 2024-11-28 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1139 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2445 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2445 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2445 Aidshilfe Köln.pdf .pdf 2024-12-03 mtime 04_ARBEIT 0.92 Invoice for professional catering expense to Oliver Schubert. pdf:1075 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2446 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2446 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2446 Aidshilfe Köln.pdf .pdf 2024-12-17 mtime 04_ARBEIT 0.92 Invoice for professional event, work expense. pdf:1037 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.92 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2447 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2447 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2447 Aidshilfe Köln.pdf .pdf 2024-12-17 mtime 04_ARBEIT 0.90 Invoice for company Christmas party catering, business expense. pdf:1059 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.90 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2448 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2448 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2448 Aidshilfe Köln.pdf .pdf 2025-01-22 mtime 04_ARBEIT 0.69 fallback failed; Rechnung for work-related supplies from Aidshilfe Köln. pdf:1094 primary-fallback-failed work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Aushang Personalhygiene und Händehygiene 2018.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Aushang Personalhygiene und Händehygiene 2018.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene Aushang Personalhygiene und Händehygiene 2018.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Hygiene guidelines for workplace safety and procedures pdf:1600 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Check Liste Temperaturkontrolle Kühlungen 2018.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Check Liste Temperaturkontrolle Kühlungen 2018.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene Check Liste Temperaturkontrolle Kühlungen 2018.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Document details temperature checks for refrigeration units in a work context. pdf:712 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Check Liste Wareneingang 2018.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Check Liste Wareneingang 2018.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene Check Liste Wareneingang 2018.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Contains work-specific hygiene and inventory checks for a café operation. pdf:696 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Checkliste Temperaturen Küche 2018.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Checkliste Temperaturen Küche 2018.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene Checkliste Temperaturen Küche 2018.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Contains kitchen temperature checks for work hygiene, tied to cafebach operations. pdf:902 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/HACCP Liste Fritteuse 2018.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/HACCP Liste Fritteuse 2018.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene HACCP Liste Fritteuse 2018.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Contains HACCP checklist for fryer hygiene in a work context. pdf:746 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Reinigungsliste Getränkeschankanlage 2018.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Reinigungsliste Getränkeschankanlage 2018.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene Reinigungsliste Getränkeschankanlage 2018.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Reinigung guidelines for work equipment in a professional setting. pdf:751 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Reinigungsliste Küchenbereich 2018.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Reinigungsliste Küchenbereich 2018.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene Reinigungsliste Küchenbereich 2018.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Cleaning schedule for kitchen area tied to work hygiene operations. pdf:784 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/neues-lebensmittelrecht-haccp-checklisten-data.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/neues-lebensmittelrecht-haccp-checklisten-data.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene neues-lebensmittelrecht-haccp-checklisten-data.pdf .pdf 2024-01-10 mtime 04_ARBEIT 0.95 Contains HACCP checklists for food safety in a professional kitchen setting. pdf:1763 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Übersicht Allergene für Buffet mit Zusatzstoffen.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene/Übersicht Allergene für Buffet mit Zusatzstoffen.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Reinigung Hygiene Übersicht Allergene für Buffet mit Zusatzstoffen.docx .docx 2024-01-10 mtime 04_ARBEIT 0.95 Document details allergens and additives for buffet, likely work hygiene/food safety. docx:613 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/CSD/CSD Ausschank Getränkepreise.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/CSD/CSD Ausschank Getränkepreise.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/CSD CSD Ausschank Getränkepreise.xlsx .xlsx 2024-07-23 mtime 04_ARBEIT 0.95 Contains pricing data for CSD drinks at a work event, likely related to cafe operations. xlsx:482 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/IDAHOBITA/IDAHOBITA Get-Together Gästeliste 17.05.23.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/IDAHOBITA/IDAHOBITA Get-Together Gästeliste 17.05.23.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/IDAHOBITA IDAHOBITA Get-Together Gästeliste 17.05.23.xlsx .xlsx 2023-05-17 filename 04_ARBEIT 0.95 Guest list for a work event under IDAHOBITA folder xlsx:359 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kaffeeklatsch - Bitte mit Sahne/Karte Kaffeeaklatsch.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kaffeeklatsch - Bitte mit Sahne/Karte Kaffeeaklatsch.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kaffeeklatsch - Bitte mit Sahne Karte Kaffeeaklatsch.docx .docx 2025-04-24 mtime 04_ARBEIT 0.95 Event planning for work venue with menu details and 'Please with cream' note. docx:243 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kaffeeklatsch - Bitte mit Sahne/Karte Kaffeeklatsch Getränke.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kaffeeklatsch - Bitte mit Sahne/Karte Kaffeeklatsch Getränke.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kaffeeklatsch - Bitte mit Sahne Karte Kaffeeklatsch Getränke.docx .docx 2025-03-28 mtime 04_ARBEIT 0.95 Document details work event beverages for Cafe-Bach operations. docx:228 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kleine Show/Getränkeverbrauch Kleine Show 2023.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kleine Show/Getränkeverbrauch Kleine Show 2023.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kleine Show Getränkeverbrauch Kleine Show 2023.xlsx .xlsx 2023-11-30 mtime 04_ARBEIT 0.95 Contains event beverage consumption data for a work event. xlsx:842 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kleine Show/Getränkeverbrauch Kleine Show 2024.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kleine Show/Getränkeverbrauch Kleine Show 2024.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Kleine Show Getränkeverbrauch Kleine Show 2024.xlsx .xlsx 2024-11-25 mtime 04_ARBEIT 0.95 Tracks beverage consumption for a work event, includes pricing and totals. xlsx:287 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Nubbelverbrennung/Ablaufplanung Nubbelverbrennung 2025.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Nubbelverbrennung/Ablaufplanung Nubbelverbrennung 2025.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Nubbelverbrennung Ablaufplanung Nubbelverbrennung 2025.docx .docx 2025-03-17 mtime 04_ARBEIT 0.95 Event planning for work-related activity with team roles and resources. docx:1241 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Run of Colors Bierwagen/Vor-Orga Getränkewagen 2025.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Run of Colors Bierwagen/Vor-Orga Getränkewagen 2025.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Run of Colors Bierwagen Vor-Orga Getränkewagen 2025.xlsx .xlsx 2025-08-19 mtime 04_ARBEIT 0.95 Contains 2025 beverage planning for a work event. xlsx:954 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Weihnachtsfeier/Xmas 2024 Verbrauch Getränke.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Weihnachtsfeier/Xmas 2024 Verbrauch Getränke.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Weihnachtsfeier Xmas 2024 Verbrauch Getränke.xlsx .xlsx 2024-12-17 mtime 04_ARBEIT 0.95 File tracks beverage consumption for a work event, tied to employer operations. xlsx:571 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/laufende Veranstaltungen.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/laufende Veranstaltungen.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen laufende Veranstaltungen.xlsx .xlsx 2025-05-19 mtime 04_ARBEIT 0.95 Contains event planning details for work-related events at Cafe-Bach. xlsx:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Übergabe Rainbow Day 06.06.25 an Johannes.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen/Übergabe Rainbow Day 06.06.25 an Johannes.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Veranstaltungen Übergabe Rainbow Day 06.06.25 an Johannes.docx .docx 2025-06-06 filename 04_ARBEIT 0.95 Document details a work event organized by the team, including logistics and collaboration with external contacts. docx:2137 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Verträge/Café Bach x Vytal.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Verträge/Café Bach x Vytal.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Verträge Café Bach x Vytal.pdf .pdf 2024-07-10 mtime 04_ARBEIT 0.95 Partnership agreement for Café Bach operations. pdf:2400 models-agree work-vs-personal-record-class 04_ARBEIT 0.95 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Warenbestand Getränke 21.05.2025.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Warenbestand Getränke 21.05.2025.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Warenbestand Getränke 21.05.2025.xlsx .xlsx 2025-05-21 filename 04_ARBEIT 0.95 Inventory of beverages for Café Bach, work-related stock management. xlsx:888 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Vorlagen/25-10-Getränkelisten/2026-08-21_cafe-bach_getrnkekarte_mit-pink_A4.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Vorlagen/25-10-Getränkelisten/2026-08-21_cafe-bach_getrnkekarte_mit-pink_A4.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Vorlagen/25-10-Getränkelisten 2026-08-21_cafe-bach_getrnkekarte_mit-pink_A4.docx .docx 2026-08-21 filename 04_ARBEIT 0.95 Document contains work-specific drink pricing for Cafe-Bach operations. docx:367 primary 04_ARBEIT 0.95 04_ARBEIT no +"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Überlassungsverträge/Überlassungsdaten.xlsx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Überlassungsverträge/Überlassungsdaten.xlsx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Überlassungsverträge Überlassungsdaten.xlsx .xlsx 2026-09-09 mtime 04_ARBEIT 0.95 Contains work-related event data for Café Bach operations. xlsx:2400 primary 04_ARBEIT 0.95 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips/einkaufschips_absatzplus.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips/einkaufschips_absatzplus.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips einkaufschips_absatzplus.pdf .pdf 2026-05-15 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips/einkaufschips_coolconcept.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips/einkaufschips_coolconcept.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips einkaufschips_coolconcept.pdf .pdf 2026-05-15 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips/einkaufschips_giffits.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips/einkaufschips_giffits.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzenslust_einkaufschips einkaufschips_giffits.pdf .pdf 2026-05-15 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_faecher/angebot_absatzplus_faecher_schwarz.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_faecher/angebot_absatzplus_faecher_schwarz.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_faecher angebot_absatzplus_faecher_schwarz.pdf .pdf 2026-04-29 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten/angebot_postkarten_auflage_2500_pro_motiv_flyeralarm.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten/angebot_postkarten_auflage_2500_pro_motiv_flyeralarm.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten angebot_postkarten_auflage_2500_pro_motiv_flyeralarm.pdf .pdf 2026-05-20 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten/angebot_postkarten_auflage_2500_pro_motiv_laserline.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten/angebot_postkarten_auflage_2500_pro_motiv_laserline.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten angebot_postkarten_auflage_2500_pro_motiv_laserline.pdf .pdf 2026-05-20 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten/angebot_postkarten_auflage_2500_pro_motiv_viaprinto.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten/angebot_postkarten_auflage_2500_pro_motiv_viaprinto.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_postkarten angebot_postkarten_auflage_2500_pro_motiv_viaprinto.pdf .pdf 2026-05-20 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts/shirts_herzundlust_absatzplus.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts/shirts_herzundlust_absatzplus.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts shirts_herzundlust_absatzplus.pdf .pdf 2026-05-18 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts/shirts_herzundlust_coolconcept.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts/shirts_herzundlust_coolconcept.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts shirts_herzundlust_coolconcept.pdf .pdf 2026-05-20 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts/shirts_herzundlust_promostore.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts/shirts_herzundlust_promostore.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_shirts shirts_herzundlust_promostore.pdf .pdf 2026-05-18 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche/unhaengetasche_herz_und_lust_absatzplus.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche/unhaengetasche_herz_und_lust_absatzplus.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche unhaengetasche_herz_und_lust_absatzplus.pdf .pdf 2026-05-04 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche/unhaengetasche_herz_und_lust_coolconcept.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche/unhaengetasche_herz_und_lust_coolconcept.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche unhaengetasche_herz_und_lust_coolconcept.pdf .pdf 2026-05-04 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche/unhaengetasche_herz_und_lust_giffits.pdf" ARCHIVE CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche/unhaengetasche_herz_und_lust_giffits.pdf archive-recovered/CSD/CSD/2026_vergleichsangebote_herzundlust_aidshilfe_koeln/herzundlust_umhaengetasche unhaengetasche_herz_und_lust_giffits.pdf .pdf 2026-05-04 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/Betriebsanweisung Desinfektionsmittel.pdf" ARCHIVE HYGIENE/Betriebsanweisung Desinfektionsmittel.pdf archive-recovered/HYGIENE/HYGIENE Betriebsanweisung Desinfektionsmittel.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/Biostoffverordnung.pdf" ARCHIVE HYGIENE/Biostoffverordnung.pdf archive-recovered/HYGIENE/HYGIENE Biostoffverordnung.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/Download_Arbeitssicherheit_-_Hygiene.pdf" ARCHIVE HYGIENE/Download_Arbeitssicherheit_-_Hygiene.pdf archive-recovered/HYGIENE/HYGIENE Download_Arbeitssicherheit_-_Hygiene.pdf .pdf 2016-03-05 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/Gefaehrdungsbeurteilung von Biostoffen.pdf" ARCHIVE HYGIENE/Gefaehrdungsbeurteilung von Biostoffen.pdf archive-recovered/HYGIENE/HYGIENE Gefaehrdungsbeurteilung von Biostoffen.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/Handlungshilfe zur Umsetzung der Biostoffverordnung.pdf" ARCHIVE HYGIENE/Handlungshilfe zur Umsetzung der Biostoffverordnung.pdf archive-recovered/HYGIENE/HYGIENE Handlungshilfe zur Umsetzung der Biostoffverordnung.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/Infektionsschutzgesetz.pdf" ARCHIVE HYGIENE/Infektionsschutzgesetz.pdf archive-recovered/HYGIENE/HYGIENE Infektionsschutzgesetz.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-001.pdf" ARCHIVE HYGIENE/TRBA-001.pdf archive-recovered/HYGIENE/HYGIENE TRBA-001.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-100.pdf" ARCHIVE HYGIENE/TRBA-100.pdf archive-recovered/HYGIENE/HYGIENE TRBA-100.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-105.pdf" ARCHIVE HYGIENE/TRBA-105.pdf archive-recovered/HYGIENE/HYGIENE TRBA-105.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-120.pdf" ARCHIVE HYGIENE/TRBA-120.pdf archive-recovered/HYGIENE/HYGIENE TRBA-120.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-212.pdf" ARCHIVE HYGIENE/TRBA-212.pdf archive-recovered/HYGIENE/HYGIENE TRBA-212.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-213.pdf" ARCHIVE HYGIENE/TRBA-213.pdf archive-recovered/HYGIENE/HYGIENE TRBA-213.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-214.pdf" ARCHIVE HYGIENE/TRBA-214.pdf archive-recovered/HYGIENE/HYGIENE TRBA-214.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-220.pdf" ARCHIVE HYGIENE/TRBA-220.pdf archive-recovered/HYGIENE/HYGIENE TRBA-220.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-230.pdf" ARCHIVE HYGIENE/TRBA-230.pdf archive-recovered/HYGIENE/HYGIENE TRBA-230.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-240.pdf" ARCHIVE HYGIENE/TRBA-240.pdf archive-recovered/HYGIENE/HYGIENE TRBA-240.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-250.pdf" ARCHIVE HYGIENE/TRBA-250.pdf archive-recovered/HYGIENE/HYGIENE TRBA-250.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-310.pdf" ARCHIVE HYGIENE/TRBA-310.pdf archive-recovered/HYGIENE/HYGIENE TRBA-310.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-400.pdf" ARCHIVE HYGIENE/TRBA-400.pdf archive-recovered/HYGIENE/HYGIENE TRBA-400.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-405.pdf" ARCHIVE HYGIENE/TRBA-405.pdf archive-recovered/HYGIENE/HYGIENE TRBA-405.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-406.pdf" ARCHIVE HYGIENE/TRBA-406.pdf archive-recovered/HYGIENE/HYGIENE TRBA-406.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-450.pdf" ARCHIVE HYGIENE/TRBA-450.pdf archive-recovered/HYGIENE/HYGIENE TRBA-450.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-460.pdf" ARCHIVE HYGIENE/TRBA-460.pdf archive-recovered/HYGIENE/HYGIENE TRBA-460.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-462.pdf" ARCHIVE HYGIENE/TRBA-462.pdf archive-recovered/HYGIENE/HYGIENE TRBA-462.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-464.pdf" ARCHIVE HYGIENE/TRBA-464.pdf archive-recovered/HYGIENE/HYGIENE TRBA-464.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-466.pdf" ARCHIVE HYGIENE/TRBA-466.pdf archive-recovered/HYGIENE/HYGIENE TRBA-466.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE HYGIENE/TRBA-500.pdf" ARCHIVE HYGIENE/TRBA-500.pdf archive-recovered/HYGIENE/HYGIENE TRBA-500.pdf .pdf 2009-07-23 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE SURVSTAT/Data.csv" ARCHIVE SURVSTAT/Data.csv archive-recovered/SURVSTAT/SURVSTAT Data.csv .csv 2022-10-24 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no +"ARCHIVE SURVSTAT/Info.pdf" ARCHIVE SURVSTAT/Info.pdf archive-recovered/SURVSTAT/SURVSTAT Info.pdf .pdf 2022-10-24 mtime 04_ARBEIT 0.99 Curated work archive provenance verified before scope triage. provenance:curated-work-archive deterministic-provenance 04_ARBEIT no diff --git a/2026-09-14_scope-triage_manual-overrides_v1.tsv b/2026-09-14_scope-triage_manual-overrides_v1.tsv new file mode 100644 index 0000000..db349f5 --- /dev/null +++ b/2026-09-14_scope-triage_manual-overrides_v1.tsv @@ -0,0 +1,19 @@ +relative_path filename original_scope override_scope original_confidence original_model_path override_basis override_reason approval_status +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Perso-Essen.xlsx Perso-Essen.xlsx 03_PRIVAT 04_ARBEIT 0.95 primary user-confirmed User confirmed that 'Perso'/'Personal' here means staff/personnel, not private/personal. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Kühlschrank 07-08-13,08.pdf Kühlschrank 07-08-13,08.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Café-Bach operational file in the CB inbox; refrigerator-related document belongs to work context. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/SP5 treffen Samstag 15-08.pdf SP5 treffen Samstag 15-08.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context SP5 meeting document in Café-Bach work backup; SP5 is a work project. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/A5 Aufsteller.docx A5 Aufsteller.docx 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Display/signage document inside Café-Bach archive; work-material context is stronger than sparse extracted text. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Aushang für Notfälle.docx Getränkeliste Aushang für Notfälle.docx 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Emergency beverage-list signage for Café-Bach operations. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Auszug_aus_Liegenschaftskataster.pdf Auszug_aus_Liegenschaftskataster.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Property extract stored in the Café-Bach concession application package; work licensing record. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Certificate is part of the Café-Bach concession application package; retained as work licensing documentation. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2414 Junge Stadt Köln.pdf CB2414 Junge Stadt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2419 Lebenshaus-Stiftung.pdf CB2419 Lebenshaus-Stiftung.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2432 Aidshilfe Köln.pdf CB2432 Aidshilfe Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2438 Junge Stadt Köln.pdf CB2438 Junge Stadt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2443 Lebenshaus-Stiftung.pdf CB2443 Lebenshaus-Stiftung.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2444 Junge Stadt Köln.pdf CB2444 Junge Stadt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Data.csv Data.csv 01_INBOX 04_ARBEIT 0.50 models-disagree paired-document-context Paired with SurvStat Info.pdf in the same folder; the companion file is confirmed work health-surveillance material. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Covid IMpfungen 2022.docx Covid IMpfungen 2022.docx 01_INBOX 03_PRIVAT 0.50 models-disagree content-context Personal vaccination record; not an organizational work record. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/SENIOR TICKET NL 20220301.pdf SENIOR TICKET NL 20220301.pdf 01_INBOX 03_PRIVAT 0.50 models-disagree content-context Personal travel ticket rather than work documentation. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Wichtige_Kundeninformation_zu_Konto_0125273623.PDF Wichtige_Kundeninformation_zu_Konto_0125273623.PDF 01_INBOX 03_PRIVAT 0.50 models-disagree content-context Personal bank-account/customer information rather than work documentation. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_1.pdf ticket_patrick-ja_queerfeld-festiv_1.pdf 01_INBOX 03_PRIVAT 0.50 models-disagree paired-document-context Personal festival ticket; matching companion ticket is already classified private. approved diff --git a/2026-09-14_scope-triage_manual-overrides_v2.tsv b/2026-09-14_scope-triage_manual-overrides_v2.tsv new file mode 100644 index 0000000..feb4483 --- /dev/null +++ b/2026-09-14_scope-triage_manual-overrides_v2.tsv @@ -0,0 +1,30 @@ +relative_path filename original_scope override_scope original_confidence original_model_path override_basis override_reason approval_status +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/06AnOverviewofCellBiology.pdf 06AnOverviewofCellBiology.pdf 01_INBOX 03_PRIVAT 0.50 models-disagree reviewed-content Generic academic/reference material with no Aidshilfe or Café-Bach work context. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf 01_INBOX 03_PRIVAT 0.50 models-disagree reviewed-content Generic academic textbook/reference material with no work-specific context. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Gastro Frohes Neues Jahr.docx Anschreiben - Gastro Frohes Neues Jahr.docx 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Sparse extract, but it belongs to the same Checkpoint/Gastro professional correspondence set as neighboring confirmed work letters. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben.docx Anschreiben.docx 01_INBOX 04_ARBEIT 0.50 models-disagree user-confirmed User confirmed this is Aidshilfe/Checkpoint work correspondence. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Bolsover2004CellBiology.pdf Bolsover2004CellBiology.pdf 01_INBOX 03_PRIVAT 0.75 models-agree reviewed-content Generic academic textbook/reference material with no work-specific context. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Covid IMpfungen 2022.docx Covid IMpfungen 2022.docx 01_INBOX 03_PRIVAT 0.50 models-disagree content-context Personal vaccination record; not an organizational work record. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Preisvergleich Angebote.docx Preisvergleich Angebote.docx 01_INBOX 04_ARBEIT 0.50 models-disagree reviewed-content Contains a Queeres Netzwerk project-application link alongside projector price comparisons; fits work procurement/project context. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Quiz Terasse 21-07.pptx Quiz Terasse 21-07.pptx 01_INBOX 03_PRIVAT 0.50 models-disagree user-confirmed User confirmed this belongs to private/Chrystal activities. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen (1).pdf QuiztalMath Antwortbogen (1).pdf 01_INBOX 03_PRIVAT 0.50 models-disagree user-confirmed User confirmed this belongs to private/Chrystal activities. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen.pdf QuiztalMath Antwortbogen.pdf 01_INBOX 03_PRIVAT 0.65 models-agree user-confirmed User confirmed this belongs to private/Chrystal activities. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/SENIOR TICKET NL 20220301.pdf SENIOR TICKET NL 20220301.pdf 01_INBOX 03_PRIVAT 0.50 models-disagree content-context Personal travel ticket rather than work documentation. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Tagesplan.xlsx Tagesplan.xlsx 01_INBOX 03_PRIVAT 0.50 models-disagree reviewed-content Personal daily planning sheet containing private routine items such as waking, showering and going to work. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf 01_INBOX 03_PRIVAT 0.50 models-disagree reviewed-content Generic educational material for children with no work-specific context. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Wichtige_Kundeninformation_zu_Konto_0125273623.PDF Wichtige_Kundeninformation_zu_Konto_0125273623.PDF 01_INBOX 03_PRIVAT 0.50 models-disagree content-context Personal bank-account/customer information rather than work documentation. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Data.csv Data.csv 01_INBOX 04_ARBEIT 0.50 models-disagree paired-document-context Paired with SurvStat Info.pdf in the same folder; the companion file is confirmed work health-surveillance material. approved +Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_1.pdf ticket_patrick-ja_queerfeld-festiv_1.pdf 01_INBOX 03_PRIVAT 0.50 models-disagree paired-document-context Personal festival ticket; matching companion ticket is already classified private. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Kühlschrank 07-08-13,08.pdf Kühlschrank 07-08-13,08.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Café-Bach operational file in the CB inbox; refrigerator-related document belongs to work context. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/SP5 treffen Samstag 15-08.pdf SP5 treffen Samstag 15-08.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context SP5 meeting document in Café-Bach work backup; SP5 is a work project. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/A5 Aufsteller.docx A5 Aufsteller.docx 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Display/signage document inside Café-Bach archive; work-material context is stronger than sparse extracted text. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Aushang für Notfälle.docx Getränkeliste Aushang für Notfälle.docx 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Emergency beverage-list signage for Café-Bach operations. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Auszug_aus_Liegenschaftskataster.pdf Auszug_aus_Liegenschaftskataster.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Property extract stored in the Café-Bach concession application package; work licensing record. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Certificate is part of the Café-Bach concession application package; retained as work licensing documentation. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Personal/Perso-Essen.xlsx Perso-Essen.xlsx 03_PRIVAT 04_ARBEIT 0.95 primary user-confirmed User confirmed that 'Perso'/'Personal' here means staff/personnel, not private/personal. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2414 Junge Stadt Köln.pdf CB2414 Junge Stadt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2419 Lebenshaus-Stiftung.pdf CB2419 Lebenshaus-Stiftung.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2432 Aidshilfe Köln.pdf CB2432 Aidshilfe Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2438 Junge Stadt Köln.pdf CB2438 Junge Stadt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2443 Lebenshaus-Stiftung.pdf CB2443 Lebenshaus-Stiftung.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2444 Junge Stadt Köln.pdf CB2444 Junge Stadt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved diff --git a/2026-09-14_scope-triage_nonwork-hash-audit_v1.py b/2026-09-14_scope-triage_nonwork-hash-audit_v1.py new file mode 100644 index 0000000..fb61761 --- /dev/null +++ b/2026-09-14_scope-triage_nonwork-hash-audit_v1.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +""" +Read-only exact-hash audit for scope-triaged files. + +Purpose: +- Compare files routed to 03_PRIVAT / 01_INBOX against the existing + authoritative /data/03_PRIVAT and /data/01_INBOX trees. +- Detect exact duplicates using full-file SHA-256 only. +- Also report cross-scope exact matches. +- Never move, rename, delete, overwrite, or modify target files. + +The only output is a TSV report written to the staging MANIFESTS directory +(or another explicitly supplied path). +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import os +import sys +from collections import defaultdict +from pathlib import Path + + +CHUNK = 1024 * 1024 + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(CHUNK), b""): + h.update(chunk) + return h.hexdigest() + + +def index_tree(root: Path) -> tuple[dict[str, list[Path]], int, list[str]]: + by_hash: dict[str, list[Path]] = defaultdict(list) + count = 0 + errors: list[str] = [] + + if not root.is_dir(): + raise RuntimeError(f"Target root does not exist or is not a directory: {root}") + + for path in sorted(root.rglob("*")): + try: + if path.is_symlink(): + continue + if not path.is_file(): + continue + digest = sha256_file(path) + by_hash[digest].append(path) + count += 1 + except Exception as exc: + errors.append(f"{path}\t{type(exc).__name__}: {exc}") + + return dict(by_hash), count, errors + + +def read_manifest(path: Path) -> list[dict]: + with path.open("r", encoding="utf-8", newline="") as f: + rows = list(csv.DictReader(f, delimiter="\t")) + + required = {"origin", "relative_path", "effective_scope"} + missing = required - set(rows[0].keys() if rows else []) + if missing: + raise RuntimeError( + f"Manifest is missing required columns: {', '.join(sorted(missing))}" + ) + + return rows + + +def resolve_candidate(stage: Path, row: dict) -> Path: + origin = row["origin"] + rel = row["relative_path"] + + if origin == "SOURCE": + return stage / "SOURCE" / rel + + if origin == "ARCHIVE": + # Current non-work audit should not normally contain archive items, + # but resolve them defensively if that changes later. + return stage / "ARCHIVE_EXTRACTED" / "WORK" / rel + + raise RuntimeError(f"Unknown origin {origin!r} for {rel!r}") + + +def join_paths(paths: list[Path]) -> str: + return " | ".join(str(p) for p in paths) + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--stage", required=True, type=Path) + p.add_argument("--manifest", required=True, type=Path) + p.add_argument("--privat-root", type=Path, default=Path("/data/03_PRIVAT")) + p.add_argument("--inbox-root", type=Path, default=Path("/data/01_INBOX")) + p.add_argument("--report", required=True, type=Path) + args = p.parse_args() + + stage = args.stage.resolve() + manifest = args.manifest.resolve() + report = args.report.resolve() + + rows = read_manifest(manifest) + candidates = [ + row for row in rows + if row["effective_scope"] in {"03_PRIVAT", "01_INBOX"} + ] + + print("=== NON-WORK EXACT-HASH AUDIT ===") + print(f"Manifest rows: {len(rows)}") + print(f"Candidates: {len(candidates)}") + print(f" 03_PRIVAT: {sum(r['effective_scope'] == '03_PRIVAT' for r in candidates)}") + print(f" 01_INBOX: {sum(r['effective_scope'] == '01_INBOX' for r in candidates)}") + print() + print("Hashing authoritative target trees (read-only)...") + + privat_idx, privat_count, privat_errors = index_tree(args.privat_root) + inbox_idx, inbox_count, inbox_errors = index_tree(args.inbox_root) + + print(f"/data/03_PRIVAT files hashed: {privat_count}") + print(f"/data/01_INBOX files hashed: {inbox_count}") + print(f"Target hash errors: {len(privat_errors) + len(inbox_errors)}") + + results = [] + candidate_errors = 0 + + for n, row in enumerate(candidates, 1): + candidate = resolve_candidate(stage, row) + scope = row["effective_scope"] + + result = { + "effective_scope": scope, + "origin": row["origin"], + "relative_path": row["relative_path"], + "filename": row.get("filename", candidate.name), + "candidate_path": str(candidate), + "sha256": "", + "same_scope_status": "", + "same_scope_matches": "", + "cross_scope_status": "", + "cross_scope_matches": "", + "audit_status": "", + "error": "", + } + + try: + if not candidate.is_file(): + raise FileNotFoundError(candidate) + + digest = sha256_file(candidate) + result["sha256"] = digest + + same_idx = privat_idx if scope == "03_PRIVAT" else inbox_idx + cross_idx = inbox_idx if scope == "03_PRIVAT" else privat_idx + + same = same_idx.get(digest, []) + cross = cross_idx.get(digest, []) + + result["same_scope_status"] = ( + "EXACT_DUPLICATE" if same else "NOT_FOUND" + ) + result["same_scope_matches"] = join_paths(same) + + result["cross_scope_status"] = ( + "EXACT_MATCH_OTHER_SCOPE" if cross else "NOT_FOUND" + ) + result["cross_scope_matches"] = join_paths(cross) + + if same: + result["audit_status"] = "DUPLICATE_IN_DESTINATION" + elif cross: + result["audit_status"] = "MATCH_IN_OTHER_SCOPE_REVIEW" + else: + result["audit_status"] = "UNIQUE_VS_PRIVAT_AND_INBOX" + + except Exception as exc: + candidate_errors += 1 + result["audit_status"] = "ERROR" + result["error"] = f"{type(exc).__name__}: {exc}" + + results.append(result) + + print( + f"[{n:>2}/{len(candidates)}] " + f"{scope:<9} " + f"{result['audit_status']:<29} " + f"{result['filename']}" + ) + + report.parent.mkdir(parents=True, exist_ok=True) + with report.open("w", encoding="utf-8", newline="") as f: + fieldnames = list(results[0].keys()) if results else [] + writer = csv.DictWriter(f, fieldnames=fieldnames, delimiter="\t") + writer.writeheader() + writer.writerows(results) + + print() + print("=== SUMMARY ===") + statuses = defaultdict(int) + for r in results: + statuses[r["audit_status"]] += 1 + + for key in ( + "DUPLICATE_IN_DESTINATION", + "MATCH_IN_OTHER_SCOPE_REVIEW", + "UNIQUE_VS_PRIVAT_AND_INBOX", + "ERROR", + ): + print(f"{key}: {statuses[key]}") + + print(f"Target hash errors: {len(privat_errors) + len(inbox_errors)}") + print(f"Candidate errors: {candidate_errors}") + print(f"Report: {report}") + + if privat_errors or inbox_errors or candidate_errors: + print() + print("AUDIT INCOMPLETE: errors occurred; do not proceed to routing.") + return 2 + + print() + print("AUDIT COMPLETE: no files were moved, renamed, deleted, or overwritten.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/2026-09-14_scope-triage_work-private-inbox_v1.py b/2026-09-14_scope-triage_work-private-inbox_v1.py new file mode 100644 index 0000000..cc64b37 --- /dev/null +++ b/2026-09-14_scope-triage_work-private-inbox_v1.py @@ -0,0 +1,508 @@ +#!/usr/bin/env python3 +""" +Scope triage v1 for the staged document-recovery project. + +Purpose: +- Read-only top-level classification before detailed sorting. +- Exactly three outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX. +- One file per LLM request. +- Daily model first; Architect only for uncertain/fragile cases. +- If Daily and Architect disagree, route conservatively to 01_INBOX. +- Resume-safe TSV report written after every completed file. + +This script does NOT move, rename, delete, or modify source/target files. +It only reads files and writes a TSV report under the staging MANIFESTS folder. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import os +import tempfile +import urllib.request +from collections import Counter +from pathlib import Path +from typing import Optional + +import quick_sorter as q + +READABLE = { + ".pdf", ".docx", ".xlsx", ".pptx", + ".odt", ".txt", ".md", ".csv", +} + +MIXED_SOURCE_NAME = "Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden" + +SCOPE_PROMPT = """You are a conservative top-level file-scope classifier. +Your only job is to decide the PRIMARY life/work area of ONE file. + +Return one JSON OBJECT only with exactly these keys: +scope, confidence, reason + +Allowed scope values: +- 04_ARBEIT = primarily employer/professional work material +- 03_PRIVAT = primarily the user's personal/private material +- 01_INBOX = genuinely unclear, mixed, or not safe to decide + +Rules: +- Source folder is provenance/evidence, NOT authority. +- A file found on a work computer can still be private. +- A file found in a private folder can still be work-related. +- Choose 04_ARBEIT for professional correspondence, employer projects, work events, + work-team records, work-area administration, professional prevention work, + work reimbursements/expenses, and material primarily owned by the employer/work role. +- Choose 03_PRIVAT for the user's own personal records, personal banking/customer documents, + private travel/event tickets, personal planning, personal study/reference material, + and the user's own employment/contract records kept as personal documentation. +- Do NOT route to DEV, VAULT, ARCHIV, or any other area in this recovery pass. +- If work/private ownership cannot be determined safely, choose 01_INBOX. +- Mentions of an employer inside a personal document do not automatically make it work. +- Mentions of a person/private name inside a work document do not automatically make it private. +- confidence must be 0.0 to 1.0. Use >=0.85 only when genuinely clear. +- reason: maximum 12 words, factual and concise. +""" + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def build_queue(stage: Path, policy_path: Path) -> list[dict]: + source = stage / "SOURCE" + target = stage / "TARGET" + extracted = stage / "ARCHIVE_EXTRACTED" / "WORK" + + policy = q.load_policy(policy_path) + destinations = q.allowed_destinations(source, policy, "04_ARBEIT") + aliases = policy.get("_strong_aliases", {}).get("04_ARBEIT", {}) + + records = q.scan(source, target, destinations, aliases=aliases) + q.global_duplicate_prepass(records, source, target) + + items: list[dict] = [] + + for rec in records: + if rec.status != "pending": + continue + if q.is_archive_file(rec.path): + continue + if rec.ext.lower() not in READABLE: + continue + + items.append({ + "key": "SOURCE\t" + rec.rel_path, + "origin": "SOURCE", + "path": rec.path, + "rel_path": rec.rel_path, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + }) + + source_hashes = { + sha256(p) + for p in source.rglob("*") + if p.is_file() + } + target_hashes = { + sha256(p) + for p in target.rglob("*") + if p.is_file() + } + + seen: set[str] = set() + + for p in sorted(extracted.rglob("*")): + if not p.is_file() or p.suffix.lower() not in READABLE: + continue + + digest = sha256(p) + if digest in source_hashes or digest in target_hashes or digest in seen: + continue + seen.add(digest) + + rel = str(p.relative_to(extracted)) + # Preserve meaningful archive provenance at least at recovery-group level. + group = Path(rel).parts[0] if Path(rel).parts else "ARCHIVE" + source_context = f"archive-recovered/{group}/{Path(rel).parent}" + + date_guess, date_source = q.best_date(p, root=extracted) + + items.append({ + "key": "ARCHIVE\t" + rel, + "origin": "ARCHIVE", + "path": p, + "rel_path": rel, + "source_context": source_context, + "date_guess": date_guess, + "date_source": date_source, + }) + + return items + + +def model_limits(model: str) -> tuple[int, int]: + # Scope output is tiny; timeouts still mirror the model families used by V9. + name = model.lower() + if "gpt-oss" in name or "architect" in name: + return 450, 180 + if "35b" in name or "debug" in name: + return 500, 240 + return 300, 90 + + +def extract_json_object(text: str) -> dict: + text = text.strip() + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:].lstrip() + + try: + value = json.loads(text) + except json.JSONDecodeError: + start = text.find("{") + end = text.rfind("}") + if start < 0 or end <= start: + raise + value = json.loads(text[start:end + 1]) + + if not isinstance(value, dict): + raise ValueError("model did not return a JSON object") + return value + + +def call_scope_model(item: dict, endpoint: str, model: str) -> dict: + text, content_status = q.extract_excerpt(item["path"]) + + payload_item = { + "filename": item["path"].name, + "relative_path": item["rel_path"], + "source_context": item["source_context"], + "date_guess": item["date_guess"], + "date_source": item["date_source"], + "content_status": content_status, + "excerpt": text[:q.MAX_EXCERPT], + } + + prompt = ( + SCOPE_PROMPT + + "\nINPUT:\n" + + json.dumps(payload_item, ensure_ascii=False) + ) + + max_tokens, timeout = model_limits(model) + request_payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + + req = urllib.request.Request( + endpoint, + data=json.dumps(request_payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + + choice = data["choices"][0] + answer = choice.get("message", {}).get("content") or "" + result = extract_json_object(answer) + + scope = str(result.get("scope", "")).strip() + try: + confidence = float(result.get("confidence", 0)) + except (TypeError, ValueError): + confidence = 0.0 + + reason = str(result.get("reason", "")).strip()[:160] + + if scope not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: + raise ValueError(f"invalid scope: {scope!r}") + + confidence = max(0.0, min(1.0, confidence)) + + return { + "scope": scope, + "confidence": confidence, + "reason": reason, + "content_status": content_status, + } + + +def needs_fallback(result: Optional[dict]) -> bool: + if not isinstance(result, dict): + return True + if result.get("scope") not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: + return True + try: + if float(result.get("confidence", 0)) < 0.85: + return True + except (TypeError, ValueError): + return True + + status = str(result.get("content_status", "")) + if "no-text" in status or status.startswith("error:") or status.startswith("missing:"): + return True + return False + + +def classify(item: dict, endpoint: str, model: str, fallback_model: str) -> dict: + primary = None + primary_error = "" + + try: + primary = call_scope_model(item, endpoint, model) + except Exception as exc: + primary_error = f"{type(exc).__name__}: {exc}" + + use_fallback = bool(fallback_model) and ( + primary is None or needs_fallback(primary) + ) + + if not use_fallback: + result = dict(primary) + result["model_path"] = "primary" + return result + + fallback = None + fallback_error = "" + try: + fallback = call_scope_model(item, endpoint, fallback_model) + except Exception as exc: + fallback_error = f"{type(exc).__name__}: {exc}" + + if primary is None and fallback is None: + return { + "scope": "01_INBOX", + "confidence": 0.0, + "reason": f"both models failed: {primary_error}; {fallback_error}"[:160], + "content_status": "model-error", + "model_path": "failure", + } + + if primary is None: + result = dict(fallback) + result["model_path"] = "fallback-only" + return result + + if fallback is None: + result = dict(primary) + result["confidence"] = min(float(result.get("confidence", 0)), 0.69) + result["reason"] = ( + f"fallback failed; {result.get('reason', '')}" + )[:160] + result["model_path"] = "primary-fallback-failed" + return result + + if primary["scope"] == fallback["scope"]: + result = dict(fallback) + result["model_path"] = "models-agree" + return result + + return { + "scope": "01_INBOX", + "confidence": 0.50, + "reason": ( + f"model disagreement: {model}={primary['scope']}; " + f"{fallback_model}={fallback['scope']}" + )[:160], + "content_status": primary.get("content_status", ""), + "model_path": "models-disagree", + } + + +FIELDS = [ + "key", "origin", "relative_path", "source_context", + "filename", "extension", "date_guess", "date_source", + "scope", "confidence", "reason", "content_status", "model_path", +] + + +def load_existing(report: Path) -> dict[str, dict]: + if not report.is_file(): + return {} + rows = {} + with report.open(newline="", encoding="utf-8") as f: + reader = csv.DictReader(f, delimiter="\t") + for row in reader: + key = row.get("key", "") + if key: + rows[key] = row + return rows + + +def write_report_atomic(report: Path, rows: dict[str, dict], order: list[str]) -> None: + report.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp( + prefix=report.name + ".", + suffix=".tmp", + dir=report.parent, + text=True, + ) + os.close(fd) + temp = Path(temp_name) + try: + with temp.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=FIELDS, delimiter="\t") + writer.writeheader() + for key in order: + if key in rows: + writer.writerow({name: rows[key].get(name, "") for name in FIELDS}) + temp.replace(report) + finally: + if temp.exists(): + temp.unlink() + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument( + "--stage", + default="/data/90_SORT_TEST_ARBEIT_IMPORT/FULL_STAGING_2026-09-14", + ) + ap.add_argument( + "--policy", + default=str(Path(__file__).with_name("sorter_policy.json")), + ) + ap.add_argument("--endpoint", default=q.DEFAULT_ENDPOINT) + ap.add_argument("--model", default=q.DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=q.DEFAULT_FALLBACK_MODEL) + ap.add_argument("--no-fallback", action="store_true") + ap.add_argument( + "--report", + default="", + help="TSV report path; defaults under STAGE/MANIFESTS", + ) + ap.add_argument( + "--pilot-mixed", + action="store_true", + help="Only the original readable files from the known mixed backup", + ) + ap.add_argument("--limit", type=int, default=0) + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + stage = Path(args.stage).expanduser().resolve() + policy_path = Path(args.policy).expanduser().resolve() + + if not stage.is_dir(): + raise SystemExit(f"Not a staging directory: {stage}") + if not policy_path.is_file(): + raise SystemExit(f"Policy not found: {policy_path}") + + items = build_queue(stage, policy_path) + + if args.pilot_mixed: + prefix = MIXED_SOURCE_NAME + "/" + items = [ + item for item in items + if item["origin"] == "SOURCE" + and item["rel_path"].startswith(prefix) + ] + + if args.limit > 0: + items = items[:args.limit] + + if args.report: + report = Path(args.report).expanduser().resolve() + else: + name = ( + "scope_triage_pilot_mixed_v1.tsv" + if args.pilot_mixed + else "scope_triage_readable_v1.tsv" + ) + report = stage / "MANIFESTS" / name + + print("=== SCOPE TRIAGE ===") + print("Queue:", len(items)) + print("Mode:", "pilot-mixed" if args.pilot_mixed else "full-readable") + print("Allowed outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX") + print("Primary model:", args.model) + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print("Fallback model:", fallback_model or "disabled") + print("Report:", report) + + if args.dry_run: + print("DRY RUN: no LLM requests made") + for item in items[:20]: + print(item["origin"], item["rel_path"]) + return + + existing = load_existing(report) + order = [item["key"] for item in items] + rows = {k: v for k, v in existing.items() if k in set(order)} + + completed = len(rows) + if completed: + print(f"Resume: {completed} already completed") + + for index, item in enumerate(items, 1): + key = item["key"] + if key in rows: + continue + + result = classify( + item, + args.endpoint, + args.model, + fallback_model, + ) + + row = { + "key": key, + "origin": item["origin"], + "relative_path": item["rel_path"], + "source_context": item["source_context"], + "filename": item["path"].name, + "extension": item["path"].suffix.lower(), + "date_guess": item["date_guess"], + "date_source": item["date_source"], + "scope": result["scope"], + "confidence": f"{float(result['confidence']):.2f}", + "reason": result["reason"], + "content_status": result["content_status"], + "model_path": result["model_path"], + } + + rows[key] = row + write_report_atomic(report, rows, order) + + print( + f"[{index:3}/{len(items)}] " + f"{result['scope']:9} " + f"{float(result['confidence']):.2f} " + f"{item['path'].name}" + ) + + counts = Counter(row["scope"] for row in rows.values()) + low = sum(float(row["confidence"]) < 0.70 for row in rows.values()) + disagreements = sum( + row.get("model_path") == "models-disagree" + for row in rows.values() + ) + + print() + print("=== RESULT ===") + print("Completed:", len(rows)) + print("04_ARBEIT:", counts["04_ARBEIT"]) + print("03_PRIVAT:", counts["03_PRIVAT"]) + print("01_INBOX:", counts["01_INBOX"]) + print("Confidence < 0.70:", low) + print("Model disagreements:", disagreements) + print("Report:", report) + + +if __name__ == "__main__": + main() diff --git a/2026-09-15_quick-sorter_dedupe-hardening_v17.py b/2026-09-15_quick-sorter_dedupe-hardening_v17.py new file mode 100644 index 0000000..f41dcae --- /dev/null +++ b/2026-09-15_quick-sorter_dedupe-hardening_v17.py @@ -0,0 +1,1954 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v9.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import zipfile +import xml.etree.ElementTree as ET +import urllib.request +import unicodedata +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + name_title: str = "" + name_subtitle: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path, root: Optional[Path] = None) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + When ``root`` is supplied, only parent folders *inside* that scan root are + considered. The root directory itself and every ancestor above it are + deliberately excluded. This prevents staging/workspace names such as + ``FULL_STAGING_2026-09-14`` from leaking into document dates. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + if root is not None: + try: + parts = path.parent.relative_to(root).parts + except ValueError: + # A caller supplied a boundary that does not contain this file. + # Be conservative: do not inspect arbitrary ancestors outside it. + return None + else: + # Backwards-compatible behaviour for direct helper calls that do not + # provide a scan root. Production scanning always passes the root. + parts = path.parent.parts + + for part in reversed(parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path, root: Optional[Path] = None) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + # Parent-folder lookup is root-bounded so workspace/staging names can never + # become document metadata. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path, root=root) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + + # Some DOCX files keep visible text in drawing/text-box XML that + # python-docx does not expose through doc.paragraphs or tables. + # Only use this raw XML path as a fallback to avoid duplicating + # ordinary paragraph/table text. + if not text: + xml_parts = [] + try: + with zipfile.ZipFile(path) as zf: + for name in zf.namelist(): + if not (name.startswith("word/") and name.endswith(".xml")): + continue + try: + root = ET.fromstring(zf.read(name)) + except ET.ParseError: + continue + for node in root.iter(): + if node.tag.endswith("}t") and node.text and node.text.strip(): + xml_parts.append(node.text.strip()) + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + except (OSError, zipfile.BadZipFile): + xml_parts = [] + text = _clip(xml_parts) + + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + from pypdf.errors import DependencyError + except ImportError: + return "", "missing:pypdf" + try: + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + except DependencyError as exc: + if "cryptography" in str(exc).lower(): + return "", "missing:cryptography" + raise + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + # python-pptx Slides supports integer indexing but slicing can + # raise AttributeError. Iterate normally and stop after 5 slides. + for slide_index, slide in enumerate(prs.slides): + if slide_index >= 5: + break + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +FILENAME_PREFIXES = { + "Allgemein": "Allgemein", + "Cafe_Bach": "CB", + "Checkpoint": "Checkpoint", + "CSD-Kampagnen": "CSD", + "Ehrenamt": "Ehrenamt", + "FAK": "FAK", + "SP5-Treffen": "SP5", + "Vor-Ort-Arbeit": "VOA", +} + + +def _filename_prefix(destination: str) -> str: + branch = destination.split("/", 1)[0].strip() + return FILENAME_PREFIXES.get(branch, branch.replace("_", "-") or "Datei") + + +GENERIC_FILENAME_LABELS = { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", + "misc", "info", "information", +} + +CATEGORY_NOISE_LABELS = { + "allgemein", "anleitungen", "kommunikation", "materialien", "planung", + "protokolle", "veranstaltung", "veranstaltungen", "verwaltung", "vorlagen", +} + +PREFIX_ALIAS_PHRASES = { + "Cafe_Bach": ("Café Bach", "Cafe Bach", "Cafe_Bach", "cafebach", "CB"), + "Checkpoint": ("Checkpoint", "CP"), + "CSD-Kampagnen": ("CSD-Kampagnen", "CSD Kampagnen", "CSD"), + "Ehrenamt": ("Ehrenamt",), + "FAK": ("FAK",), + "SP5-Treffen": ("SP5-Treffen", "SP5 Treffen", "SP5"), + "Vor-Ort-Arbeit": ("Vor-Ort-Arbeit", "Vor Ort Arbeit", "VOA"), + "Allgemein": ("Allgemein",), +} + + +def _filename_component(text: str, *, title_case_first: bool = False) -> str: + """Return one Windows/Linux-safe filename component while preserving Unicode. + + German spelling/capitalization is kept. Underscores remain reserved for the + schema separators. Cosmetic punctuation such as commas/semicolons is removed + because it adds noise without helping identification. + """ + value = unicodedata.normalize("NFC", str(text or "")).strip() + value = re.sub(r"^\d{4}[-_. ]\d{1,2}[-_. ]\d{1,2}[-_ ]*", "", value) + value = re.sub(r"\.[A-Za-z0-9]{1,6}$", "", value) + value = value.replace("_", " ") + value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", value) + value = re.sub(r"[,;]+", " ", value) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"-+", "-", value).strip("-. ") + if title_case_first and value and value[0].islower(): + value = value[0].upper() + value[1:] + return value + + +def _semantic_key(text: str) -> str: + folded = unicodedata.normalize("NFKD", str(text or "")).casefold() + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.replace("ß", "ss") + return re.sub(r"[^a-z0-9]+", "", folded) + + +def _branch_name(destination: str) -> str: + return destination.split("/", 1)[0].strip() + + +def _prefix_alias_keys(destination: str) -> set[str]: + branch = _branch_name(destination) + values = set(PREFIX_ALIAS_PHRASES.get(branch, ())) + values.add(_filename_prefix(destination)) + values.add(branch) + return {_semantic_key(x) for x in values if x} + + +def _strip_redundant_date_text(text: str, date_guess: str) -> str: + """Remove a full/date-fragment that merely repeats the leading date. + + We deliberately do not remove a bare year: `März 2024` and `Inventur 2023` + can carry useful semantic meaning even when mtime happens to be in that year. + """ + value = str(text or "") + m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", str(date_guess or "")) + if not m: + return value + y, mo, d = m.groups() + yy = y[-2:] + patterns = [ + rf"(? str: + return _filename_component( + _strip_redundant_date_text(text, rec.date_guess), + title_case_first=title_case_first, + ) + + +def _fallback_title(rec: Record) -> str: + for value in (rec.document_type, rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec, title_case_first=True) + if candidate and _semantic_key(candidate) not in GENERIC_FILENAME_LABELS: + return candidate + stem = _component_for_record(Path(rec.filename).stem, rec, title_case_first=True) + if stem and _semantic_key(stem) not in GENERIC_FILENAME_LABELS: + return stem + return "Dokument" + + +def _source_remainder(rec: Record, *blocked_values: str) -> str: + """Recover useful detail from the original filename when model fields are noisy. + + This is intentionally conservative: it is only used as a fallback subtitle. + Examples: `Getränkeliste Verzehrkarte` -> `Verzehrkarte`; + `Inventur Café Bach 2023` -> `2023`. + """ + raw = _strip_redundant_date_text(Path(rec.filename).stem, rec.date_guess) + + # Remove the branch/prefix phrase before token-level subtraction. + for phrase in sorted(PREFIX_ALIAS_PHRASES.get(_branch_name(rec.destination), ()), key=len, reverse=True): + raw = re.sub(rf"(?= 3): + continue + kept.append(candidate) + + return _filename_component(" ".join(kept)) + + +def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: + blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} + blocked |= _prefix_alias_keys(rec.destination) + blocked |= CATEGORY_NOISE_LABELS + + for value in (rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec) + key = _semantic_key(candidate) + if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS: + return candidate + + return _source_remainder(rec, title, prefix, rec.topic) + + +def build_structured_filename(rec: Record) -> str: + """Build YYYY-MM-DD_Topic_Subtopic_DocumentType_Detail[_vN].ext. + + Destination folders are deliberately NOT encoded in filenames. + The filename describes what the document is; the folder describes where it lives. + """ + parts = [rec.date_guess] + seen = {_semantic_key(rec.date_guess)} + + for value in ( + rec.topic, + rec.subtopic, + rec.document_type, + rec.detail, + ): + token = _filename_component(value, title_case_first=True) + key = _semantic_key(token) + + if not token or not key: + continue + + if key in seen: + continue + + if key in CATEGORY_NOISE_LABELS: + continue + + parts.append(token) + seen.add(key) + + ver = _filename_component(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + elif not re.fullmatch(r"v\d+", ver, flags=re.I): + ver = f"v{ver}" + + if _semantic_key(ver) not in seen: + parts.append(ver) + + # If the model supplied no useful semantic information, preserve the + # original filename instead of inventing a meaningless new one. + if len(parts) == 1: + return rec.filename + + stem = "_".join(parts) + + # Leave headroom for destination path and possible collision suffixes. + if len(stem) > 180: + stem = stem[:180].rstrip("-_. ") + + return stem + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + # The 01_INBOX policy is already a curated whitelist of approved + # destinations. When no strong alias has narrowed the file to one branch, + # expose the complete policy to the LLM instead of hiding valid destinations + # behind the generic candidate-ranking limit. + if allow_stay_outside_policy and not branch: + chosen = list(dict.fromkeys(destinations)) + if rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + return chosen + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + relative_parent = str(p.parent.relative_to(root)) + if allow_stay_outside_policy: + current = ( + "01_INBOX" + if relative_parent == "." + else f"01_INBOX/{relative_parent}" + ) + else: + current = relative_parent + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + context_root = "01_INBOX" if allow_stay_outside_policy else root.name + source_context = context_root + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{context_root}/{relative_parent}" + d, source = best_date(p, root=root) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, classify the PURPOSE/ROLE of the document before its owner or topic. +- Top-level branch meaning: + * 03_PRIVAT = personal-life documents and records: health, private administration, household, personal correspondence, Chrystal, personal paperwork. + * 04_ARBEIT = operational documents for Aidshilfe Köln, Cafe Bach, Checkpoint, Vor-Ort-Arbeit, work events, colleagues, work administration, work planning or work communication. + * 05_VAULT = reusable knowledge/reference: technical guides, setup notes, AI prompts, manuals, documentation, Linux, shell, Syncthing, VPS, LLM, software or general instructions. Choose Vault even when the knowledge was created while solving a work or private task. + * 99_ARCHIV = intentionally historical, retired or obsolete material; never choose it merely because a file is old. + * 01_INBOX = quarantine only when the document cannot be classified safely from the available evidence. +- Technical/reference material must NOT become 03_PRIVAT or 04_ARBEIT merely because a private or work context is mentioned. +- After choosing the correct top-level branch, use owner/work-area evidence to choose the most specific candidate inside that branch. +- For 05_VAULT, strongly prefer the most specific suitable Vault candidate instead of the 05_VAULT root: + * 05_VAULT/Prompts = prompts, system prompts, start prompts, reusable LLM instructions or prompt templates. + * 05_VAULT/Tools = tool-specific configuration, commands, helper-tool documentation or operational tool notes. + * 05_VAULT/Wissen/IT = Linux, VPS, Syncthing, networking, hardware, software, LLM setup, system administration, technical guides and technical documentation. + * 05_VAULT/Wissen/Organisation = productivity methods, workflows, planning systems and organisational knowledge. + * 05_VAULT/Wissen = reusable knowledge that belongs in Wissen but has no clearly suitable more-specific Wissen candidate. + * 05_VAULT = use only when no more-specific Vault candidate reasonably fits. +- A document that IS itself a prompt belongs in 05_VAULT/Prompts even when its subject is IT, work, ADHD or another domain. +- A technical guide ABOUT configuring or understanding a system normally belongs in 05_VAULT/Wissen/IT, not 05_VAULT/Tools. +- If uncertain, keep the current Inbox folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- Within the already chosen purpose branch, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch work sub-branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + # Local models can occasionally return a destination that is not one of + # the explicitly supplied candidates, even at temperature 0. Retry the + # same model once before treating the result as invalid. + if primary is not None: + primary_dest = str(primary.get("destination", "")).strip() + + if primary_dest not in rec.candidates: + first_invalid_dest = primary_dest + + try: + retry = _call_llm_one(rec, endpoint, model) + retry_dest = str(retry.get("destination", "")).strip() + + if retry_dest in rec.candidates: + primary = retry + else: + primary["reason"] = ( + f"invalid destination after retry: " + f"{first_invalid_dest} / {retry_dest}" + ) + except Exception: + primary["reason"] = ( + f"invalid destination: {first_invalid_dest}; retry failed" + ) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + rec.name_title = str(result.get("title", "")).strip() + rec.name_subtitle = str(result.get("subtitle", "")).strip() + rec.proposed_name = build_structured_filename(rec) + + # In 01_INBOX mode, keeping a file in Inbox is a quarantine/review + # outcome, not a successfully classified final destination. + if dest == "01_INBOX" or dest.startswith("01_INBOX/"): + conf = min(conf, 0.69) + + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # Every V7 proposal follows the structured naming scheme. If its date + # comes only from filesystem mtime, the date is useful but historically + # unverified, so require explicit human review before apply. + if rec.date_source == "mtime": + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + a_size = a.stat().st_size + b_size = b.stat().st_size + + # Empty files all have the same SHA-256, but their filenames may be + # their only meaningful information. Never auto-deduplicate them. + if a_size == 0 or b_size == 0: + return False + + if a_size != b_size: + return False + + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + + # Empty files are intentionally NOT auto-deduplicated. Their filename + # may be their only useful information. + try: + if rec.path.stat().st_size == 0: + rec.duplicate_of = "" + rec.collision = "" + if rec.status == "exact-duplicate": + rec.status = "review" + rec.reason = "Empty file: automatic duplicate removal disabled" + continue + except OSError: + stats["hash_errors"] += 1 + continue + + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + + # Re-establish exact-duplicate protection immediately before every + # LLM run. This prevents stale GUI state from allowing a known + # duplicate back into semantic analysis. + global_duplicate_prepass(self.records, root, target_root) + + for iid, rec in self.by_iid.items(): + if rec.duplicate_of or rec.status == "exact-duplicate": + self.refresh(iid) + + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates( + rec, + self.destinations, + aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("PDF AES", "cryptography", "cryptography"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V7 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-15_quick-sorter_destination-normalization_v18.py b/2026-09-15_quick-sorter_destination-normalization_v18.py new file mode 100644 index 0000000..d801688 --- /dev/null +++ b/2026-09-15_quick-sorter_destination-normalization_v18.py @@ -0,0 +1,1992 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v9.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import zipfile +import xml.etree.ElementTree as ET +import urllib.request +import unicodedata +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + name_title: str = "" + name_subtitle: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path, root: Optional[Path] = None) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + When ``root`` is supplied, only parent folders *inside* that scan root are + considered. The root directory itself and every ancestor above it are + deliberately excluded. This prevents staging/workspace names such as + ``FULL_STAGING_2026-09-14`` from leaking into document dates. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + if root is not None: + try: + parts = path.parent.relative_to(root).parts + except ValueError: + # A caller supplied a boundary that does not contain this file. + # Be conservative: do not inspect arbitrary ancestors outside it. + return None + else: + # Backwards-compatible behaviour for direct helper calls that do not + # provide a scan root. Production scanning always passes the root. + parts = path.parent.parts + + for part in reversed(parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path, root: Optional[Path] = None) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + # Parent-folder lookup is root-bounded so workspace/staging names can never + # become document metadata. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path, root=root) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + + # Some DOCX files keep visible text in drawing/text-box XML that + # python-docx does not expose through doc.paragraphs or tables. + # Only use this raw XML path as a fallback to avoid duplicating + # ordinary paragraph/table text. + if not text: + xml_parts = [] + try: + with zipfile.ZipFile(path) as zf: + for name in zf.namelist(): + if not (name.startswith("word/") and name.endswith(".xml")): + continue + try: + root = ET.fromstring(zf.read(name)) + except ET.ParseError: + continue + for node in root.iter(): + if node.tag.endswith("}t") and node.text and node.text.strip(): + xml_parts.append(node.text.strip()) + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + except (OSError, zipfile.BadZipFile): + xml_parts = [] + text = _clip(xml_parts) + + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + from pypdf.errors import DependencyError + except ImportError: + return "", "missing:pypdf" + try: + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + except DependencyError as exc: + if "cryptography" in str(exc).lower(): + return "", "missing:cryptography" + raise + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + # python-pptx Slides supports integer indexing but slicing can + # raise AttributeError. Iterate normally and stop after 5 slides. + for slide_index, slide in enumerate(prs.slides): + if slide_index >= 5: + break + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +FILENAME_PREFIXES = { + "Allgemein": "Allgemein", + "Cafe_Bach": "CB", + "Checkpoint": "Checkpoint", + "CSD-Kampagnen": "CSD", + "Ehrenamt": "Ehrenamt", + "FAK": "FAK", + "SP5-Treffen": "SP5", + "Vor-Ort-Arbeit": "VOA", +} + + +def _filename_prefix(destination: str) -> str: + branch = destination.split("/", 1)[0].strip() + return FILENAME_PREFIXES.get(branch, branch.replace("_", "-") or "Datei") + + +GENERIC_FILENAME_LABELS = { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", + "misc", "info", "information", +} + +CATEGORY_NOISE_LABELS = { + "allgemein", "anleitungen", "kommunikation", "materialien", "planung", + "protokolle", "veranstaltung", "veranstaltungen", "verwaltung", "vorlagen", +} + +PREFIX_ALIAS_PHRASES = { + "Cafe_Bach": ("Café Bach", "Cafe Bach", "Cafe_Bach", "cafebach", "CB"), + "Checkpoint": ("Checkpoint", "CP"), + "CSD-Kampagnen": ("CSD-Kampagnen", "CSD Kampagnen", "CSD"), + "Ehrenamt": ("Ehrenamt",), + "FAK": ("FAK",), + "SP5-Treffen": ("SP5-Treffen", "SP5 Treffen", "SP5"), + "Vor-Ort-Arbeit": ("Vor-Ort-Arbeit", "Vor Ort Arbeit", "VOA"), + "Allgemein": ("Allgemein",), +} + + +def _filename_component(text: str, *, title_case_first: bool = False) -> str: + """Return one Windows/Linux-safe filename component while preserving Unicode. + + German spelling/capitalization is kept. Underscores remain reserved for the + schema separators. Cosmetic punctuation such as commas/semicolons is removed + because it adds noise without helping identification. + """ + value = unicodedata.normalize("NFC", str(text or "")).strip() + value = re.sub(r"^\d{4}[-_. ]\d{1,2}[-_. ]\d{1,2}[-_ ]*", "", value) + value = re.sub(r"\.[A-Za-z0-9]{1,6}$", "", value) + value = value.replace("_", " ") + value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", value) + value = re.sub(r"[,;]+", " ", value) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"-+", "-", value).strip("-. ") + if title_case_first and value and value[0].islower(): + value = value[0].upper() + value[1:] + return value + + +def _semantic_key(text: str) -> str: + folded = unicodedata.normalize("NFKD", str(text or "")).casefold() + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.replace("ß", "ss") + return re.sub(r"[^a-z0-9]+", "", folded) + + +def _branch_name(destination: str) -> str: + return destination.split("/", 1)[0].strip() + + +def _prefix_alias_keys(destination: str) -> set[str]: + branch = _branch_name(destination) + values = set(PREFIX_ALIAS_PHRASES.get(branch, ())) + values.add(_filename_prefix(destination)) + values.add(branch) + return {_semantic_key(x) for x in values if x} + + +def _strip_redundant_date_text(text: str, date_guess: str) -> str: + """Remove a full/date-fragment that merely repeats the leading date. + + We deliberately do not remove a bare year: `März 2024` and `Inventur 2023` + can carry useful semantic meaning even when mtime happens to be in that year. + """ + value = str(text or "") + m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", str(date_guess or "")) + if not m: + return value + y, mo, d = m.groups() + yy = y[-2:] + patterns = [ + rf"(? str: + return _filename_component( + _strip_redundant_date_text(text, rec.date_guess), + title_case_first=title_case_first, + ) + + +def _fallback_title(rec: Record) -> str: + for value in (rec.document_type, rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec, title_case_first=True) + if candidate and _semantic_key(candidate) not in GENERIC_FILENAME_LABELS: + return candidate + stem = _component_for_record(Path(rec.filename).stem, rec, title_case_first=True) + if stem and _semantic_key(stem) not in GENERIC_FILENAME_LABELS: + return stem + return "Dokument" + + +def _source_remainder(rec: Record, *blocked_values: str) -> str: + """Recover useful detail from the original filename when model fields are noisy. + + This is intentionally conservative: it is only used as a fallback subtitle. + Examples: `Getränkeliste Verzehrkarte` -> `Verzehrkarte`; + `Inventur Café Bach 2023` -> `2023`. + """ + raw = _strip_redundant_date_text(Path(rec.filename).stem, rec.date_guess) + + # Remove the branch/prefix phrase before token-level subtraction. + for phrase in sorted(PREFIX_ALIAS_PHRASES.get(_branch_name(rec.destination), ()), key=len, reverse=True): + raw = re.sub(rf"(?= 3): + continue + kept.append(candidate) + + return _filename_component(" ".join(kept)) + + +def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: + blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} + blocked |= _prefix_alias_keys(rec.destination) + blocked |= CATEGORY_NOISE_LABELS + + for value in (rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec) + key = _semantic_key(candidate) + if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS: + return candidate + + return _source_remainder(rec, title, prefix, rec.topic) + + +def build_structured_filename(rec: Record) -> str: + """Build YYYY-MM-DD_Topic_Subtopic_DocumentType_Detail[_vN].ext. + + Destination folders are deliberately NOT encoded in filenames. + The filename describes what the document is; the folder describes where it lives. + """ + parts = [rec.date_guess] + seen = {_semantic_key(rec.date_guess)} + + for value in ( + rec.topic, + rec.subtopic, + rec.document_type, + rec.detail, + ): + token = _filename_component(value, title_case_first=True) + key = _semantic_key(token) + + if not token or not key: + continue + + if key in seen: + continue + + if key in CATEGORY_NOISE_LABELS: + continue + + parts.append(token) + seen.add(key) + + ver = _filename_component(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + elif not re.fullmatch(r"v\d+", ver, flags=re.I): + ver = f"v{ver}" + + if _semantic_key(ver) not in seen: + parts.append(ver) + + # If the model supplied no useful semantic information, preserve the + # original filename instead of inventing a meaningless new one. + if len(parts) == 1: + return rec.filename + + stem = "_".join(parts) + + # Leave headroom for destination path and possible collision suffixes. + if len(stem) > 180: + stem = stem[:180].rstrip("-_. ") + + return stem + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + # The 01_INBOX policy is already a curated whitelist of approved + # destinations. When no strong alias has narrowed the file to one branch, + # expose the complete policy to the LLM instead of hiding valid destinations + # behind the generic candidate-ranking limit. + if allow_stay_outside_policy and not branch: + chosen = list(dict.fromkeys(destinations)) + if rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + return chosen + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + relative_parent = str(p.parent.relative_to(root)) + if allow_stay_outside_policy: + current = ( + "01_INBOX" + if relative_parent == "." + else f"01_INBOX/{relative_parent}" + ) + else: + current = relative_parent + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + context_root = "01_INBOX" if allow_stay_outside_policy else root.name + source_context = context_root + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{context_root}/{relative_parent}" + d, source = best_date(p, root=root) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, classify the PURPOSE/ROLE of the document before its owner or topic. +- Top-level branch meaning: + * 03_PRIVAT = personal-life documents and records: health, private administration, household, personal correspondence, Chrystal, personal paperwork. + * 04_ARBEIT = operational documents for Aidshilfe Köln, Cafe Bach, Checkpoint, Vor-Ort-Arbeit, work events, colleagues, work administration, work planning or work communication. + * 05_VAULT = reusable knowledge/reference: technical guides, setup notes, AI prompts, manuals, documentation, Linux, shell, Syncthing, VPS, LLM, software or general instructions. Choose Vault even when the knowledge was created while solving a work or private task. + * 99_ARCHIV = intentionally historical, retired or obsolete material; never choose it merely because a file is old. + * 01_INBOX = quarantine only when the document cannot be classified safely from the available evidence. +- Technical/reference material must NOT become 03_PRIVAT or 04_ARBEIT merely because a private or work context is mentioned. +- After choosing the correct top-level branch, use owner/work-area evidence to choose the most specific candidate inside that branch. +- For 05_VAULT, strongly prefer the most specific suitable Vault candidate instead of the 05_VAULT root: + * 05_VAULT/Prompts = prompts, system prompts, start prompts, reusable LLM instructions or prompt templates. + * 05_VAULT/Tools = tool-specific configuration, commands, helper-tool documentation or operational tool notes. + * 05_VAULT/Wissen/IT = Linux, VPS, Syncthing, networking, hardware, software, LLM setup, system administration, technical guides and technical documentation. + * 05_VAULT/Wissen/Organisation = productivity methods, workflows, planning systems and organisational knowledge. + * 05_VAULT/Wissen = reusable knowledge that belongs in Wissen but has no clearly suitable more-specific Wissen candidate. + * 05_VAULT = use only when no more-specific Vault candidate reasonably fits. +- A document that IS itself a prompt belongs in 05_VAULT/Prompts even when its subject is IT, work, ADHD or another domain. +- A technical guide ABOUT configuring or understanding a system normally belongs in 05_VAULT/Wissen/IT, not 05_VAULT/Tools. +- If uncertain, keep the current Inbox folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- Within the already chosen purpose branch, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch work sub-branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _normalise_model_destination(rec: Record, value) -> str: + """Normalise a model destination only when the result remains policy-approved. + + Some local models return a fully qualified path such as + ``05_VAULT/Wissen/IT`` even when the supplied candidate is ``Wissen/IT``. + Strip a known top-level prefix only if the stripped value is already one of + this record's candidates. Otherwise preserve the raw destination so normal + invalid-destination handling still fails closed. + """ + dest = str(value or "").strip() + + if dest in rec.candidates: + return dest + + for prefix in ( + "01_INBOX/", + "03_PRIVAT/", + "04_ARBEIT/", + "05_VAULT/", + "99_ARCHIV/", + ): + if dest.startswith(prefix): + stripped = dest[len(prefix):] + if stripped in rec.candidates: + return stripped + + return dest + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + + # Models occasionally return an approved relative destination with an + # unnecessary top-level prefix, e.g. 05_VAULT/Wissen/IT instead of + # Wissen/IT. Normalise only when the resulting path is an exact candidate. + result["destination"] = _normalise_model_destination( + rec, + result.get("destination", ""), + ) + + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + # Local models can occasionally return a destination that is not one of + # the explicitly supplied candidates, even at temperature 0. Retry the + # same model once before treating the result as invalid. + if primary is not None: + primary_dest = str(primary.get("destination", "")).strip() + + if primary_dest not in rec.candidates: + first_invalid_dest = primary_dest + + try: + retry = _call_llm_one(rec, endpoint, model) + retry_dest = str(retry.get("destination", "")).strip() + + if retry_dest in rec.candidates: + primary = retry + else: + primary["reason"] = ( + f"invalid destination after retry: " + f"{first_invalid_dest} / {retry_dest}" + ) + except Exception: + primary["reason"] = ( + f"invalid destination: {first_invalid_dest}; retry failed" + ) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + rec.name_title = str(result.get("title", "")).strip() + rec.name_subtitle = str(result.get("subtitle", "")).strip() + rec.proposed_name = build_structured_filename(rec) + + # In 01_INBOX mode, keeping a file in Inbox is a quarantine/review + # outcome, not a successfully classified final destination. + if dest == "01_INBOX" or dest.startswith("01_INBOX/"): + conf = min(conf, 0.69) + + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # Every V7 proposal follows the structured naming scheme. If its date + # comes only from filesystem mtime, the date is useful but historically + # unverified, so require explicit human review before apply. + if rec.date_source == "mtime": + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + a_size = a.stat().st_size + b_size = b.stat().st_size + + # Empty files all have the same SHA-256, but their filenames may be + # their only meaningful information. Never auto-deduplicate them. + if a_size == 0 or b_size == 0: + return False + + if a_size != b_size: + return False + + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + + # Empty files are intentionally NOT auto-deduplicated. Their filename + # may be their only useful information. + try: + if rec.path.stat().st_size == 0: + rec.duplicate_of = "" + rec.collision = "" + if rec.status == "exact-duplicate": + rec.status = "review" + rec.reason = "Empty file: automatic duplicate removal disabled" + continue + except OSError: + stats["hash_errors"] += 1 + continue + + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + + # Re-establish exact-duplicate protection immediately before every + # LLM run. This prevents stale GUI state from allowing a known + # duplicate back into semantic analysis. + global_duplicate_prepass(self.records, root, target_root) + + for iid, rec in self.by_iid.items(): + if rec.duplicate_of or rec.status == "exact-duplicate": + self.refresh(iid) + + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates( + rec, + self.destinations, + aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("PDF AES", "cryptography", "cryptography"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V7 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-15_quick-sorter_destination-retry_v16.py b/2026-09-15_quick-sorter_destination-retry_v16.py new file mode 100644 index 0000000..bc917b4 --- /dev/null +++ b/2026-09-15_quick-sorter_destination-retry_v16.py @@ -0,0 +1,1920 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v9.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import zipfile +import xml.etree.ElementTree as ET +import urllib.request +import unicodedata +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + name_title: str = "" + name_subtitle: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path, root: Optional[Path] = None) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + When ``root`` is supplied, only parent folders *inside* that scan root are + considered. The root directory itself and every ancestor above it are + deliberately excluded. This prevents staging/workspace names such as + ``FULL_STAGING_2026-09-14`` from leaking into document dates. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + if root is not None: + try: + parts = path.parent.relative_to(root).parts + except ValueError: + # A caller supplied a boundary that does not contain this file. + # Be conservative: do not inspect arbitrary ancestors outside it. + return None + else: + # Backwards-compatible behaviour for direct helper calls that do not + # provide a scan root. Production scanning always passes the root. + parts = path.parent.parts + + for part in reversed(parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path, root: Optional[Path] = None) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + # Parent-folder lookup is root-bounded so workspace/staging names can never + # become document metadata. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path, root=root) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + + # Some DOCX files keep visible text in drawing/text-box XML that + # python-docx does not expose through doc.paragraphs or tables. + # Only use this raw XML path as a fallback to avoid duplicating + # ordinary paragraph/table text. + if not text: + xml_parts = [] + try: + with zipfile.ZipFile(path) as zf: + for name in zf.namelist(): + if not (name.startswith("word/") and name.endswith(".xml")): + continue + try: + root = ET.fromstring(zf.read(name)) + except ET.ParseError: + continue + for node in root.iter(): + if node.tag.endswith("}t") and node.text and node.text.strip(): + xml_parts.append(node.text.strip()) + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + except (OSError, zipfile.BadZipFile): + xml_parts = [] + text = _clip(xml_parts) + + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + from pypdf.errors import DependencyError + except ImportError: + return "", "missing:pypdf" + try: + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + except DependencyError as exc: + if "cryptography" in str(exc).lower(): + return "", "missing:cryptography" + raise + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + # python-pptx Slides supports integer indexing but slicing can + # raise AttributeError. Iterate normally and stop after 5 slides. + for slide_index, slide in enumerate(prs.slides): + if slide_index >= 5: + break + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +FILENAME_PREFIXES = { + "Allgemein": "Allgemein", + "Cafe_Bach": "CB", + "Checkpoint": "Checkpoint", + "CSD-Kampagnen": "CSD", + "Ehrenamt": "Ehrenamt", + "FAK": "FAK", + "SP5-Treffen": "SP5", + "Vor-Ort-Arbeit": "VOA", +} + + +def _filename_prefix(destination: str) -> str: + branch = destination.split("/", 1)[0].strip() + return FILENAME_PREFIXES.get(branch, branch.replace("_", "-") or "Datei") + + +GENERIC_FILENAME_LABELS = { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", + "misc", "info", "information", +} + +CATEGORY_NOISE_LABELS = { + "allgemein", "anleitungen", "kommunikation", "materialien", "planung", + "protokolle", "veranstaltung", "veranstaltungen", "verwaltung", "vorlagen", +} + +PREFIX_ALIAS_PHRASES = { + "Cafe_Bach": ("Café Bach", "Cafe Bach", "Cafe_Bach", "cafebach", "CB"), + "Checkpoint": ("Checkpoint", "CP"), + "CSD-Kampagnen": ("CSD-Kampagnen", "CSD Kampagnen", "CSD"), + "Ehrenamt": ("Ehrenamt",), + "FAK": ("FAK",), + "SP5-Treffen": ("SP5-Treffen", "SP5 Treffen", "SP5"), + "Vor-Ort-Arbeit": ("Vor-Ort-Arbeit", "Vor Ort Arbeit", "VOA"), + "Allgemein": ("Allgemein",), +} + + +def _filename_component(text: str, *, title_case_first: bool = False) -> str: + """Return one Windows/Linux-safe filename component while preserving Unicode. + + German spelling/capitalization is kept. Underscores remain reserved for the + schema separators. Cosmetic punctuation such as commas/semicolons is removed + because it adds noise without helping identification. + """ + value = unicodedata.normalize("NFC", str(text or "")).strip() + value = re.sub(r"^\d{4}[-_. ]\d{1,2}[-_. ]\d{1,2}[-_ ]*", "", value) + value = re.sub(r"\.[A-Za-z0-9]{1,6}$", "", value) + value = value.replace("_", " ") + value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", value) + value = re.sub(r"[,;]+", " ", value) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"-+", "-", value).strip("-. ") + if title_case_first and value and value[0].islower(): + value = value[0].upper() + value[1:] + return value + + +def _semantic_key(text: str) -> str: + folded = unicodedata.normalize("NFKD", str(text or "")).casefold() + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.replace("ß", "ss") + return re.sub(r"[^a-z0-9]+", "", folded) + + +def _branch_name(destination: str) -> str: + return destination.split("/", 1)[0].strip() + + +def _prefix_alias_keys(destination: str) -> set[str]: + branch = _branch_name(destination) + values = set(PREFIX_ALIAS_PHRASES.get(branch, ())) + values.add(_filename_prefix(destination)) + values.add(branch) + return {_semantic_key(x) for x in values if x} + + +def _strip_redundant_date_text(text: str, date_guess: str) -> str: + """Remove a full/date-fragment that merely repeats the leading date. + + We deliberately do not remove a bare year: `März 2024` and `Inventur 2023` + can carry useful semantic meaning even when mtime happens to be in that year. + """ + value = str(text or "") + m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", str(date_guess or "")) + if not m: + return value + y, mo, d = m.groups() + yy = y[-2:] + patterns = [ + rf"(? str: + return _filename_component( + _strip_redundant_date_text(text, rec.date_guess), + title_case_first=title_case_first, + ) + + +def _fallback_title(rec: Record) -> str: + for value in (rec.document_type, rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec, title_case_first=True) + if candidate and _semantic_key(candidate) not in GENERIC_FILENAME_LABELS: + return candidate + stem = _component_for_record(Path(rec.filename).stem, rec, title_case_first=True) + if stem and _semantic_key(stem) not in GENERIC_FILENAME_LABELS: + return stem + return "Dokument" + + +def _source_remainder(rec: Record, *blocked_values: str) -> str: + """Recover useful detail from the original filename when model fields are noisy. + + This is intentionally conservative: it is only used as a fallback subtitle. + Examples: `Getränkeliste Verzehrkarte` -> `Verzehrkarte`; + `Inventur Café Bach 2023` -> `2023`. + """ + raw = _strip_redundant_date_text(Path(rec.filename).stem, rec.date_guess) + + # Remove the branch/prefix phrase before token-level subtraction. + for phrase in sorted(PREFIX_ALIAS_PHRASES.get(_branch_name(rec.destination), ()), key=len, reverse=True): + raw = re.sub(rf"(?= 3): + continue + kept.append(candidate) + + return _filename_component(" ".join(kept)) + + +def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: + blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} + blocked |= _prefix_alias_keys(rec.destination) + blocked |= CATEGORY_NOISE_LABELS + + for value in (rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec) + key = _semantic_key(candidate) + if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS: + return candidate + + return _source_remainder(rec, title, prefix, rec.topic) + + +def build_structured_filename(rec: Record) -> str: + """Build YYYY-MM-DD_Topic_Subtopic_DocumentType_Detail[_vN].ext. + + Destination folders are deliberately NOT encoded in filenames. + The filename describes what the document is; the folder describes where it lives. + """ + parts = [rec.date_guess] + seen = {_semantic_key(rec.date_guess)} + + for value in ( + rec.topic, + rec.subtopic, + rec.document_type, + rec.detail, + ): + token = _filename_component(value, title_case_first=True) + key = _semantic_key(token) + + if not token or not key: + continue + + if key in seen: + continue + + if key in CATEGORY_NOISE_LABELS: + continue + + parts.append(token) + seen.add(key) + + ver = _filename_component(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + elif not re.fullmatch(r"v\d+", ver, flags=re.I): + ver = f"v{ver}" + + if _semantic_key(ver) not in seen: + parts.append(ver) + + # If the model supplied no useful semantic information, preserve the + # original filename instead of inventing a meaningless new one. + if len(parts) == 1: + return rec.filename + + stem = "_".join(parts) + + # Leave headroom for destination path and possible collision suffixes. + if len(stem) > 180: + stem = stem[:180].rstrip("-_. ") + + return stem + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + # The 01_INBOX policy is already a curated whitelist of approved + # destinations. When no strong alias has narrowed the file to one branch, + # expose the complete policy to the LLM instead of hiding valid destinations + # behind the generic candidate-ranking limit. + if allow_stay_outside_policy and not branch: + chosen = list(dict.fromkeys(destinations)) + if rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + return chosen + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + relative_parent = str(p.parent.relative_to(root)) + if allow_stay_outside_policy: + current = ( + "01_INBOX" + if relative_parent == "." + else f"01_INBOX/{relative_parent}" + ) + else: + current = relative_parent + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + context_root = "01_INBOX" if allow_stay_outside_policy else root.name + source_context = context_root + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{context_root}/{relative_parent}" + d, source = best_date(p, root=root) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, classify the PURPOSE/ROLE of the document before its owner or topic. +- Top-level branch meaning: + * 03_PRIVAT = personal-life documents and records: health, private administration, household, personal correspondence, Chrystal, personal paperwork. + * 04_ARBEIT = operational documents for Aidshilfe Köln, Cafe Bach, Checkpoint, Vor-Ort-Arbeit, work events, colleagues, work administration, work planning or work communication. + * 05_VAULT = reusable knowledge/reference: technical guides, setup notes, AI prompts, manuals, documentation, Linux, shell, Syncthing, VPS, LLM, software or general instructions. Choose Vault even when the knowledge was created while solving a work or private task. + * 99_ARCHIV = intentionally historical, retired or obsolete material; never choose it merely because a file is old. + * 01_INBOX = quarantine only when the document cannot be classified safely from the available evidence. +- Technical/reference material must NOT become 03_PRIVAT or 04_ARBEIT merely because a private or work context is mentioned. +- After choosing the correct top-level branch, use owner/work-area evidence to choose the most specific candidate inside that branch. +- For 05_VAULT, strongly prefer the most specific suitable Vault candidate instead of the 05_VAULT root: + * 05_VAULT/Prompts = prompts, system prompts, start prompts, reusable LLM instructions or prompt templates. + * 05_VAULT/Tools = tool-specific configuration, commands, helper-tool documentation or operational tool notes. + * 05_VAULT/Wissen/IT = Linux, VPS, Syncthing, networking, hardware, software, LLM setup, system administration, technical guides and technical documentation. + * 05_VAULT/Wissen/Organisation = productivity methods, workflows, planning systems and organisational knowledge. + * 05_VAULT/Wissen = reusable knowledge that belongs in Wissen but has no clearly suitable more-specific Wissen candidate. + * 05_VAULT = use only when no more-specific Vault candidate reasonably fits. +- A document that IS itself a prompt belongs in 05_VAULT/Prompts even when its subject is IT, work, ADHD or another domain. +- A technical guide ABOUT configuring or understanding a system normally belongs in 05_VAULT/Wissen/IT, not 05_VAULT/Tools. +- If uncertain, keep the current Inbox folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- Within the already chosen purpose branch, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch work sub-branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + # Local models can occasionally return a destination that is not one of + # the explicitly supplied candidates, even at temperature 0. Retry the + # same model once before treating the result as invalid. + if primary is not None: + primary_dest = str(primary.get("destination", "")).strip() + + if primary_dest not in rec.candidates: + first_invalid_dest = primary_dest + + try: + retry = _call_llm_one(rec, endpoint, model) + retry_dest = str(retry.get("destination", "")).strip() + + if retry_dest in rec.candidates: + primary = retry + else: + primary["reason"] = ( + f"invalid destination after retry: " + f"{first_invalid_dest} / {retry_dest}" + ) + except Exception: + primary["reason"] = ( + f"invalid destination: {first_invalid_dest}; retry failed" + ) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + rec.name_title = str(result.get("title", "")).strip() + rec.name_subtitle = str(result.get("subtitle", "")).strip() + rec.proposed_name = build_structured_filename(rec) + + # In 01_INBOX mode, keeping a file in Inbox is a quarantine/review + # outcome, not a successfully classified final destination. + if dest == "01_INBOX" or dest.startswith("01_INBOX/"): + conf = min(conf, 0.69) + + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # Every V7 proposal follows the structured naming scheme. If its date + # comes only from filesystem mtime, the date is useful but historically + # unverified, so require explicit human review before apply. + if rec.date_source == "mtime": + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates( + rec, + self.destinations, + aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("PDF AES", "cryptography", "cryptography"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V7 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-15_quick-sorter_full-inbox-candidates_v15.py b/2026-09-15_quick-sorter_full-inbox-candidates_v15.py new file mode 100644 index 0000000..cfd3ae8 --- /dev/null +++ b/2026-09-15_quick-sorter_full-inbox-candidates_v15.py @@ -0,0 +1,1895 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v9.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import zipfile +import xml.etree.ElementTree as ET +import urllib.request +import unicodedata +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + name_title: str = "" + name_subtitle: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path, root: Optional[Path] = None) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + When ``root`` is supplied, only parent folders *inside* that scan root are + considered. The root directory itself and every ancestor above it are + deliberately excluded. This prevents staging/workspace names such as + ``FULL_STAGING_2026-09-14`` from leaking into document dates. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + if root is not None: + try: + parts = path.parent.relative_to(root).parts + except ValueError: + # A caller supplied a boundary that does not contain this file. + # Be conservative: do not inspect arbitrary ancestors outside it. + return None + else: + # Backwards-compatible behaviour for direct helper calls that do not + # provide a scan root. Production scanning always passes the root. + parts = path.parent.parts + + for part in reversed(parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path, root: Optional[Path] = None) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + # Parent-folder lookup is root-bounded so workspace/staging names can never + # become document metadata. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path, root=root) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + + # Some DOCX files keep visible text in drawing/text-box XML that + # python-docx does not expose through doc.paragraphs or tables. + # Only use this raw XML path as a fallback to avoid duplicating + # ordinary paragraph/table text. + if not text: + xml_parts = [] + try: + with zipfile.ZipFile(path) as zf: + for name in zf.namelist(): + if not (name.startswith("word/") and name.endswith(".xml")): + continue + try: + root = ET.fromstring(zf.read(name)) + except ET.ParseError: + continue + for node in root.iter(): + if node.tag.endswith("}t") and node.text and node.text.strip(): + xml_parts.append(node.text.strip()) + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + except (OSError, zipfile.BadZipFile): + xml_parts = [] + text = _clip(xml_parts) + + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + from pypdf.errors import DependencyError + except ImportError: + return "", "missing:pypdf" + try: + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + except DependencyError as exc: + if "cryptography" in str(exc).lower(): + return "", "missing:cryptography" + raise + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + # python-pptx Slides supports integer indexing but slicing can + # raise AttributeError. Iterate normally and stop after 5 slides. + for slide_index, slide in enumerate(prs.slides): + if slide_index >= 5: + break + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +FILENAME_PREFIXES = { + "Allgemein": "Allgemein", + "Cafe_Bach": "CB", + "Checkpoint": "Checkpoint", + "CSD-Kampagnen": "CSD", + "Ehrenamt": "Ehrenamt", + "FAK": "FAK", + "SP5-Treffen": "SP5", + "Vor-Ort-Arbeit": "VOA", +} + + +def _filename_prefix(destination: str) -> str: + branch = destination.split("/", 1)[0].strip() + return FILENAME_PREFIXES.get(branch, branch.replace("_", "-") or "Datei") + + +GENERIC_FILENAME_LABELS = { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", + "misc", "info", "information", +} + +CATEGORY_NOISE_LABELS = { + "allgemein", "anleitungen", "kommunikation", "materialien", "planung", + "protokolle", "veranstaltung", "veranstaltungen", "verwaltung", "vorlagen", +} + +PREFIX_ALIAS_PHRASES = { + "Cafe_Bach": ("Café Bach", "Cafe Bach", "Cafe_Bach", "cafebach", "CB"), + "Checkpoint": ("Checkpoint", "CP"), + "CSD-Kampagnen": ("CSD-Kampagnen", "CSD Kampagnen", "CSD"), + "Ehrenamt": ("Ehrenamt",), + "FAK": ("FAK",), + "SP5-Treffen": ("SP5-Treffen", "SP5 Treffen", "SP5"), + "Vor-Ort-Arbeit": ("Vor-Ort-Arbeit", "Vor Ort Arbeit", "VOA"), + "Allgemein": ("Allgemein",), +} + + +def _filename_component(text: str, *, title_case_first: bool = False) -> str: + """Return one Windows/Linux-safe filename component while preserving Unicode. + + German spelling/capitalization is kept. Underscores remain reserved for the + schema separators. Cosmetic punctuation such as commas/semicolons is removed + because it adds noise without helping identification. + """ + value = unicodedata.normalize("NFC", str(text or "")).strip() + value = re.sub(r"^\d{4}[-_. ]\d{1,2}[-_. ]\d{1,2}[-_ ]*", "", value) + value = re.sub(r"\.[A-Za-z0-9]{1,6}$", "", value) + value = value.replace("_", " ") + value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", value) + value = re.sub(r"[,;]+", " ", value) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"-+", "-", value).strip("-. ") + if title_case_first and value and value[0].islower(): + value = value[0].upper() + value[1:] + return value + + +def _semantic_key(text: str) -> str: + folded = unicodedata.normalize("NFKD", str(text or "")).casefold() + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.replace("ß", "ss") + return re.sub(r"[^a-z0-9]+", "", folded) + + +def _branch_name(destination: str) -> str: + return destination.split("/", 1)[0].strip() + + +def _prefix_alias_keys(destination: str) -> set[str]: + branch = _branch_name(destination) + values = set(PREFIX_ALIAS_PHRASES.get(branch, ())) + values.add(_filename_prefix(destination)) + values.add(branch) + return {_semantic_key(x) for x in values if x} + + +def _strip_redundant_date_text(text: str, date_guess: str) -> str: + """Remove a full/date-fragment that merely repeats the leading date. + + We deliberately do not remove a bare year: `März 2024` and `Inventur 2023` + can carry useful semantic meaning even when mtime happens to be in that year. + """ + value = str(text or "") + m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", str(date_guess or "")) + if not m: + return value + y, mo, d = m.groups() + yy = y[-2:] + patterns = [ + rf"(? str: + return _filename_component( + _strip_redundant_date_text(text, rec.date_guess), + title_case_first=title_case_first, + ) + + +def _fallback_title(rec: Record) -> str: + for value in (rec.document_type, rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec, title_case_first=True) + if candidate and _semantic_key(candidate) not in GENERIC_FILENAME_LABELS: + return candidate + stem = _component_for_record(Path(rec.filename).stem, rec, title_case_first=True) + if stem and _semantic_key(stem) not in GENERIC_FILENAME_LABELS: + return stem + return "Dokument" + + +def _source_remainder(rec: Record, *blocked_values: str) -> str: + """Recover useful detail from the original filename when model fields are noisy. + + This is intentionally conservative: it is only used as a fallback subtitle. + Examples: `Getränkeliste Verzehrkarte` -> `Verzehrkarte`; + `Inventur Café Bach 2023` -> `2023`. + """ + raw = _strip_redundant_date_text(Path(rec.filename).stem, rec.date_guess) + + # Remove the branch/prefix phrase before token-level subtraction. + for phrase in sorted(PREFIX_ALIAS_PHRASES.get(_branch_name(rec.destination), ()), key=len, reverse=True): + raw = re.sub(rf"(?= 3): + continue + kept.append(candidate) + + return _filename_component(" ".join(kept)) + + +def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: + blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} + blocked |= _prefix_alias_keys(rec.destination) + blocked |= CATEGORY_NOISE_LABELS + + for value in (rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec) + key = _semantic_key(candidate) + if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS: + return candidate + + return _source_remainder(rec, title, prefix, rec.topic) + + +def build_structured_filename(rec: Record) -> str: + """Build YYYY-MM-DD_Topic_Subtopic_DocumentType_Detail[_vN].ext. + + Destination folders are deliberately NOT encoded in filenames. + The filename describes what the document is; the folder describes where it lives. + """ + parts = [rec.date_guess] + seen = {_semantic_key(rec.date_guess)} + + for value in ( + rec.topic, + rec.subtopic, + rec.document_type, + rec.detail, + ): + token = _filename_component(value, title_case_first=True) + key = _semantic_key(token) + + if not token or not key: + continue + + if key in seen: + continue + + if key in CATEGORY_NOISE_LABELS: + continue + + parts.append(token) + seen.add(key) + + ver = _filename_component(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + elif not re.fullmatch(r"v\d+", ver, flags=re.I): + ver = f"v{ver}" + + if _semantic_key(ver) not in seen: + parts.append(ver) + + # If the model supplied no useful semantic information, preserve the + # original filename instead of inventing a meaningless new one. + if len(parts) == 1: + return rec.filename + + stem = "_".join(parts) + + # Leave headroom for destination path and possible collision suffixes. + if len(stem) > 180: + stem = stem[:180].rstrip("-_. ") + + return stem + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + # The 01_INBOX policy is already a curated whitelist of approved + # destinations. When no strong alias has narrowed the file to one branch, + # expose the complete policy to the LLM instead of hiding valid destinations + # behind the generic candidate-ranking limit. + if allow_stay_outside_policy and not branch: + chosen = list(dict.fromkeys(destinations)) + if rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + return chosen + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + relative_parent = str(p.parent.relative_to(root)) + if allow_stay_outside_policy: + current = ( + "01_INBOX" + if relative_parent == "." + else f"01_INBOX/{relative_parent}" + ) + else: + current = relative_parent + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + context_root = "01_INBOX" if allow_stay_outside_policy else root.name + source_context = context_root + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{context_root}/{relative_parent}" + d, source = best_date(p, root=root) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, classify the PURPOSE/ROLE of the document before its owner or topic. +- Top-level branch meaning: + * 03_PRIVAT = personal-life documents and records: health, private administration, household, personal correspondence, Chrystal, personal paperwork. + * 04_ARBEIT = operational documents for Aidshilfe Köln, Cafe Bach, Checkpoint, Vor-Ort-Arbeit, work events, colleagues, work administration, work planning or work communication. + * 05_VAULT = reusable knowledge/reference: technical guides, setup notes, AI prompts, manuals, documentation, Linux, shell, Syncthing, VPS, LLM, software or general instructions. Choose Vault even when the knowledge was created while solving a work or private task. + * 99_ARCHIV = intentionally historical, retired or obsolete material; never choose it merely because a file is old. + * 01_INBOX = quarantine only when the document cannot be classified safely from the available evidence. +- Technical/reference material must NOT become 03_PRIVAT or 04_ARBEIT merely because a private or work context is mentioned. +- After choosing the correct top-level branch, use owner/work-area evidence to choose the most specific candidate inside that branch. +- For 05_VAULT, strongly prefer the most specific suitable Vault candidate instead of the 05_VAULT root: + * 05_VAULT/Prompts = prompts, system prompts, start prompts, reusable LLM instructions or prompt templates. + * 05_VAULT/Tools = tool-specific configuration, commands, helper-tool documentation or operational tool notes. + * 05_VAULT/Wissen/IT = Linux, VPS, Syncthing, networking, hardware, software, LLM setup, system administration, technical guides and technical documentation. + * 05_VAULT/Wissen/Organisation = productivity methods, workflows, planning systems and organisational knowledge. + * 05_VAULT/Wissen = reusable knowledge that belongs in Wissen but has no clearly suitable more-specific Wissen candidate. + * 05_VAULT = use only when no more-specific Vault candidate reasonably fits. +- A document that IS itself a prompt belongs in 05_VAULT/Prompts even when its subject is IT, work, ADHD or another domain. +- A technical guide ABOUT configuring or understanding a system normally belongs in 05_VAULT/Wissen/IT, not 05_VAULT/Tools. +- If uncertain, keep the current Inbox folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- Within the already chosen purpose branch, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch work sub-branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + rec.name_title = str(result.get("title", "")).strip() + rec.name_subtitle = str(result.get("subtitle", "")).strip() + rec.proposed_name = build_structured_filename(rec) + + # In 01_INBOX mode, keeping a file in Inbox is a quarantine/review + # outcome, not a successfully classified final destination. + if dest == "01_INBOX" or dest.startswith("01_INBOX/"): + conf = min(conf, 0.69) + + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # Every V7 proposal follows the structured naming scheme. If its date + # comes only from filesystem mtime, the date is useful but historically + # unverified, so require explicit human review before apply. + if rec.date_source == "mtime": + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates( + rec, + self.destinations, + aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("PDF AES", "cryptography", "cryptography"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V7 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-15_quick-sorter_inbox-purpose-routing_v12.py b/2026-09-15_quick-sorter_inbox-purpose-routing_v12.py new file mode 100644 index 0000000..b2a87cc --- /dev/null +++ b/2026-09-15_quick-sorter_inbox-purpose-routing_v12.py @@ -0,0 +1,1891 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v9.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import zipfile +import xml.etree.ElementTree as ET +import urllib.request +import unicodedata +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + name_title: str = "" + name_subtitle: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path, root: Optional[Path] = None) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + When ``root`` is supplied, only parent folders *inside* that scan root are + considered. The root directory itself and every ancestor above it are + deliberately excluded. This prevents staging/workspace names such as + ``FULL_STAGING_2026-09-14`` from leaking into document dates. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + if root is not None: + try: + parts = path.parent.relative_to(root).parts + except ValueError: + # A caller supplied a boundary that does not contain this file. + # Be conservative: do not inspect arbitrary ancestors outside it. + return None + else: + # Backwards-compatible behaviour for direct helper calls that do not + # provide a scan root. Production scanning always passes the root. + parts = path.parent.parts + + for part in reversed(parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path, root: Optional[Path] = None) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + # Parent-folder lookup is root-bounded so workspace/staging names can never + # become document metadata. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path, root=root) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + + # Some DOCX files keep visible text in drawing/text-box XML that + # python-docx does not expose through doc.paragraphs or tables. + # Only use this raw XML path as a fallback to avoid duplicating + # ordinary paragraph/table text. + if not text: + xml_parts = [] + try: + with zipfile.ZipFile(path) as zf: + for name in zf.namelist(): + if not (name.startswith("word/") and name.endswith(".xml")): + continue + try: + root = ET.fromstring(zf.read(name)) + except ET.ParseError: + continue + for node in root.iter(): + if node.tag.endswith("}t") and node.text and node.text.strip(): + xml_parts.append(node.text.strip()) + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + except (OSError, zipfile.BadZipFile): + xml_parts = [] + text = _clip(xml_parts) + + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + from pypdf.errors import DependencyError + except ImportError: + return "", "missing:pypdf" + try: + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + except DependencyError as exc: + if "cryptography" in str(exc).lower(): + return "", "missing:cryptography" + raise + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + # python-pptx Slides supports integer indexing but slicing can + # raise AttributeError. Iterate normally and stop after 5 slides. + for slide_index, slide in enumerate(prs.slides): + if slide_index >= 5: + break + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +FILENAME_PREFIXES = { + "Allgemein": "Allgemein", + "Cafe_Bach": "CB", + "Checkpoint": "Checkpoint", + "CSD-Kampagnen": "CSD", + "Ehrenamt": "Ehrenamt", + "FAK": "FAK", + "SP5-Treffen": "SP5", + "Vor-Ort-Arbeit": "VOA", +} + + +def _filename_prefix(destination: str) -> str: + branch = destination.split("/", 1)[0].strip() + return FILENAME_PREFIXES.get(branch, branch.replace("_", "-") or "Datei") + + +GENERIC_FILENAME_LABELS = { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", + "misc", "info", "information", +} + +CATEGORY_NOISE_LABELS = { + "allgemein", "anleitungen", "kommunikation", "materialien", "planung", + "protokolle", "veranstaltung", "veranstaltungen", "verwaltung", "vorlagen", +} + +PREFIX_ALIAS_PHRASES = { + "Cafe_Bach": ("Café Bach", "Cafe Bach", "Cafe_Bach", "cafebach", "CB"), + "Checkpoint": ("Checkpoint", "CP"), + "CSD-Kampagnen": ("CSD-Kampagnen", "CSD Kampagnen", "CSD"), + "Ehrenamt": ("Ehrenamt",), + "FAK": ("FAK",), + "SP5-Treffen": ("SP5-Treffen", "SP5 Treffen", "SP5"), + "Vor-Ort-Arbeit": ("Vor-Ort-Arbeit", "Vor Ort Arbeit", "VOA"), + "Allgemein": ("Allgemein",), +} + + +def _filename_component(text: str, *, title_case_first: bool = False) -> str: + """Return one Windows/Linux-safe filename component while preserving Unicode. + + German spelling/capitalization is kept. Underscores remain reserved for the + schema separators. Cosmetic punctuation such as commas/semicolons is removed + because it adds noise without helping identification. + """ + value = unicodedata.normalize("NFC", str(text or "")).strip() + value = re.sub(r"^\d{4}[-_. ]\d{1,2}[-_. ]\d{1,2}[-_ ]*", "", value) + value = re.sub(r"\.[A-Za-z0-9]{1,6}$", "", value) + value = value.replace("_", " ") + value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", value) + value = re.sub(r"[,;]+", " ", value) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"-+", "-", value).strip("-. ") + if title_case_first and value and value[0].islower(): + value = value[0].upper() + value[1:] + return value + + +def _semantic_key(text: str) -> str: + folded = unicodedata.normalize("NFKD", str(text or "")).casefold() + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.replace("ß", "ss") + return re.sub(r"[^a-z0-9]+", "", folded) + + +def _branch_name(destination: str) -> str: + return destination.split("/", 1)[0].strip() + + +def _prefix_alias_keys(destination: str) -> set[str]: + branch = _branch_name(destination) + values = set(PREFIX_ALIAS_PHRASES.get(branch, ())) + values.add(_filename_prefix(destination)) + values.add(branch) + return {_semantic_key(x) for x in values if x} + + +def _strip_redundant_date_text(text: str, date_guess: str) -> str: + """Remove a full/date-fragment that merely repeats the leading date. + + We deliberately do not remove a bare year: `März 2024` and `Inventur 2023` + can carry useful semantic meaning even when mtime happens to be in that year. + """ + value = str(text or "") + m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", str(date_guess or "")) + if not m: + return value + y, mo, d = m.groups() + yy = y[-2:] + patterns = [ + rf"(? str: + return _filename_component( + _strip_redundant_date_text(text, rec.date_guess), + title_case_first=title_case_first, + ) + + +def _fallback_title(rec: Record) -> str: + for value in (rec.document_type, rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec, title_case_first=True) + if candidate and _semantic_key(candidate) not in GENERIC_FILENAME_LABELS: + return candidate + stem = _component_for_record(Path(rec.filename).stem, rec, title_case_first=True) + if stem and _semantic_key(stem) not in GENERIC_FILENAME_LABELS: + return stem + return "Dokument" + + +def _source_remainder(rec: Record, *blocked_values: str) -> str: + """Recover useful detail from the original filename when model fields are noisy. + + This is intentionally conservative: it is only used as a fallback subtitle. + Examples: `Getränkeliste Verzehrkarte` -> `Verzehrkarte`; + `Inventur Café Bach 2023` -> `2023`. + """ + raw = _strip_redundant_date_text(Path(rec.filename).stem, rec.date_guess) + + # Remove the branch/prefix phrase before token-level subtraction. + for phrase in sorted(PREFIX_ALIAS_PHRASES.get(_branch_name(rec.destination), ()), key=len, reverse=True): + raw = re.sub(rf"(?= 3): + continue + kept.append(candidate) + + return _filename_component(" ".join(kept)) + + +def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: + blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} + blocked |= _prefix_alias_keys(rec.destination) + blocked |= CATEGORY_NOISE_LABELS + + for value in (rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec) + key = _semantic_key(candidate) + if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS: + return candidate + + return _source_remainder(rec, title, prefix, rec.topic) + + +def build_structured_filename(rec: Record) -> str: + """Build YYYY-MM-DD_PREFIX_Title_Subtitle[_vN].ext deterministically.""" + prefix = _filename_component(_filename_prefix(rec.destination), title_case_first=True) + prefix_aliases = _prefix_alias_keys(rec.destination) + + raw_title = _component_for_record(rec.name_title, rec, title_case_first=True) + raw_subtitle = _component_for_record(rec.name_subtitle, rec) + title_key = _semantic_key(raw_title) + subtitle_key = _semantic_key(raw_subtitle) + + # Promote a useful subtitle when the model supplied a generic label like + # `Info`; this avoids names such as Allgemein_Info_SurvStat... In that + # special case, do not immediately add another model-derived subtitle: only + # recover a genuinely distinct remainder from the original filename. + promoted_generic_title = False + if title_key in GENERIC_FILENAME_LABELS and raw_subtitle: + title = raw_subtitle + raw_subtitle = "" + promoted_generic_title = True + elif not raw_title or title_key in prefix_aliases: + title = _fallback_title(rec) + else: + title = raw_title + + title_key = _semantic_key(title) + blocked_subtitle = {title_key, _semantic_key(prefix)} | prefix_aliases | CATEGORY_NOISE_LABELS + subtitle_key = _semantic_key(raw_subtitle) + + if promoted_generic_title: + subtitle = _source_remainder(rec, title, prefix, rec.topic) + elif ( + not raw_subtitle + or subtitle_key in blocked_subtitle + or subtitle_key in GENERIC_FILENAME_LABELS + ): + subtitle = _fallback_subtitle(rec, title, prefix) + else: + subtitle = raw_subtitle + + # A subtitle can still repeat the branch semantically (e.g. `Café-Bach`). + if _semantic_key(subtitle) in prefix_aliases | {title_key, _semantic_key(prefix)}: + subtitle = _source_remainder(rec, title, prefix, rec.topic) + + parts = [rec.date_guess, prefix, title] + if subtitle and _semantic_key(subtitle) not in {_semantic_key(x) for x in parts}: + parts.append(subtitle) + + ver = _filename_component(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + elif not re.fullmatch(r"v\d+", ver, flags=re.I): + ver = f"v{ver}" + parts.append(ver) + + # Keep enough headroom for the destination path and collision suffixes. + stem = "_".join(x for x in parts if x) + if len(stem) > 180: + overflow = len(stem) - 180 + if subtitle and len(subtitle) > overflow + 12: + subtitle = subtitle[: len(subtitle) - overflow].rstrip("-_. ") + parts = [rec.date_guess, prefix, title] + ([subtitle] if subtitle else []) + if ver: + parts.append(ver) + stem = "_".join(x for x in parts if x) + stem = stem[:180].rstrip("-_. ") + return stem + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + relative_parent = str(p.parent.relative_to(root)) + if allow_stay_outside_policy: + current = ( + "01_INBOX" + if relative_parent == "." + else f"01_INBOX/{relative_parent}" + ) + else: + current = relative_parent + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + context_root = "01_INBOX" if allow_stay_outside_policy else root.name + source_context = context_root + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{context_root}/{relative_parent}" + d, source = best_date(p, root=root) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, classify the PURPOSE/ROLE of the document before its owner or topic. +- Top-level branch meaning: + * 03_PRIVAT = personal-life documents and records: health, private administration, household, personal correspondence, Chrystal, personal paperwork. + * 04_ARBEIT = operational documents for Aidshilfe Köln, Cafe Bach, Checkpoint, Vor-Ort-Arbeit, work events, colleagues, work administration, work planning or work communication. + * 05_VAULT = reusable knowledge/reference: technical guides, setup notes, AI prompts, manuals, documentation, Linux, shell, Syncthing, VPS, LLM, software or general instructions. Choose Vault even when the knowledge was created while solving a work or private task. + * 99_ARCHIV = intentionally historical, retired or obsolete material; never choose it merely because a file is old. + * 01_INBOX = quarantine only when the document cannot be classified safely from the available evidence. +- Technical/reference material must NOT become 03_PRIVAT or 04_ARBEIT merely because a private or work context is mentioned. +- After choosing the correct top-level branch, use owner/work-area evidence to choose the most specific candidate inside that branch. +- If uncertain, keep the current Inbox folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- Within the already chosen purpose branch, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch work sub-branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + rec.name_title = str(result.get("title", "")).strip() + rec.name_subtitle = str(result.get("subtitle", "")).strip() + rec.proposed_name = build_structured_filename(rec) + + # In 01_INBOX mode, keeping a file in Inbox is a quarantine/review + # outcome, not a successfully classified final destination. + if dest == "01_INBOX" or dest.startswith("01_INBOX/"): + conf = min(conf, 0.69) + + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # Every V7 proposal follows the structured naming scheme. If its date + # comes only from filesystem mtime, the date is useful but historically + # unverified, so require explicit human review before apply. + if rec.date_source == "mtime": + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates( + rec, + self.destinations, + aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("PDF AES", "cryptography", "cryptography"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V7 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-15_quick-sorter_inbox-review-only_v11.py b/2026-09-15_quick-sorter_inbox-review-only_v11.py new file mode 100644 index 0000000..e466653 --- /dev/null +++ b/2026-09-15_quick-sorter_inbox-review-only_v11.py @@ -0,0 +1,1883 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v9.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import zipfile +import xml.etree.ElementTree as ET +import urllib.request +import unicodedata +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + name_title: str = "" + name_subtitle: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path, root: Optional[Path] = None) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + When ``root`` is supplied, only parent folders *inside* that scan root are + considered. The root directory itself and every ancestor above it are + deliberately excluded. This prevents staging/workspace names such as + ``FULL_STAGING_2026-09-14`` from leaking into document dates. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + if root is not None: + try: + parts = path.parent.relative_to(root).parts + except ValueError: + # A caller supplied a boundary that does not contain this file. + # Be conservative: do not inspect arbitrary ancestors outside it. + return None + else: + # Backwards-compatible behaviour for direct helper calls that do not + # provide a scan root. Production scanning always passes the root. + parts = path.parent.parts + + for part in reversed(parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path, root: Optional[Path] = None) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + # Parent-folder lookup is root-bounded so workspace/staging names can never + # become document metadata. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path, root=root) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + + # Some DOCX files keep visible text in drawing/text-box XML that + # python-docx does not expose through doc.paragraphs or tables. + # Only use this raw XML path as a fallback to avoid duplicating + # ordinary paragraph/table text. + if not text: + xml_parts = [] + try: + with zipfile.ZipFile(path) as zf: + for name in zf.namelist(): + if not (name.startswith("word/") and name.endswith(".xml")): + continue + try: + root = ET.fromstring(zf.read(name)) + except ET.ParseError: + continue + for node in root.iter(): + if node.tag.endswith("}t") and node.text and node.text.strip(): + xml_parts.append(node.text.strip()) + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + except (OSError, zipfile.BadZipFile): + xml_parts = [] + text = _clip(xml_parts) + + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + from pypdf.errors import DependencyError + except ImportError: + return "", "missing:pypdf" + try: + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + except DependencyError as exc: + if "cryptography" in str(exc).lower(): + return "", "missing:cryptography" + raise + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + # python-pptx Slides supports integer indexing but slicing can + # raise AttributeError. Iterate normally and stop after 5 slides. + for slide_index, slide in enumerate(prs.slides): + if slide_index >= 5: + break + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +FILENAME_PREFIXES = { + "Allgemein": "Allgemein", + "Cafe_Bach": "CB", + "Checkpoint": "Checkpoint", + "CSD-Kampagnen": "CSD", + "Ehrenamt": "Ehrenamt", + "FAK": "FAK", + "SP5-Treffen": "SP5", + "Vor-Ort-Arbeit": "VOA", +} + + +def _filename_prefix(destination: str) -> str: + branch = destination.split("/", 1)[0].strip() + return FILENAME_PREFIXES.get(branch, branch.replace("_", "-") or "Datei") + + +GENERIC_FILENAME_LABELS = { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", + "misc", "info", "information", +} + +CATEGORY_NOISE_LABELS = { + "allgemein", "anleitungen", "kommunikation", "materialien", "planung", + "protokolle", "veranstaltung", "veranstaltungen", "verwaltung", "vorlagen", +} + +PREFIX_ALIAS_PHRASES = { + "Cafe_Bach": ("Café Bach", "Cafe Bach", "Cafe_Bach", "cafebach", "CB"), + "Checkpoint": ("Checkpoint", "CP"), + "CSD-Kampagnen": ("CSD-Kampagnen", "CSD Kampagnen", "CSD"), + "Ehrenamt": ("Ehrenamt",), + "FAK": ("FAK",), + "SP5-Treffen": ("SP5-Treffen", "SP5 Treffen", "SP5"), + "Vor-Ort-Arbeit": ("Vor-Ort-Arbeit", "Vor Ort Arbeit", "VOA"), + "Allgemein": ("Allgemein",), +} + + +def _filename_component(text: str, *, title_case_first: bool = False) -> str: + """Return one Windows/Linux-safe filename component while preserving Unicode. + + German spelling/capitalization is kept. Underscores remain reserved for the + schema separators. Cosmetic punctuation such as commas/semicolons is removed + because it adds noise without helping identification. + """ + value = unicodedata.normalize("NFC", str(text or "")).strip() + value = re.sub(r"^\d{4}[-_. ]\d{1,2}[-_. ]\d{1,2}[-_ ]*", "", value) + value = re.sub(r"\.[A-Za-z0-9]{1,6}$", "", value) + value = value.replace("_", " ") + value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", value) + value = re.sub(r"[,;]+", " ", value) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"-+", "-", value).strip("-. ") + if title_case_first and value and value[0].islower(): + value = value[0].upper() + value[1:] + return value + + +def _semantic_key(text: str) -> str: + folded = unicodedata.normalize("NFKD", str(text or "")).casefold() + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.replace("ß", "ss") + return re.sub(r"[^a-z0-9]+", "", folded) + + +def _branch_name(destination: str) -> str: + return destination.split("/", 1)[0].strip() + + +def _prefix_alias_keys(destination: str) -> set[str]: + branch = _branch_name(destination) + values = set(PREFIX_ALIAS_PHRASES.get(branch, ())) + values.add(_filename_prefix(destination)) + values.add(branch) + return {_semantic_key(x) for x in values if x} + + +def _strip_redundant_date_text(text: str, date_guess: str) -> str: + """Remove a full/date-fragment that merely repeats the leading date. + + We deliberately do not remove a bare year: `März 2024` and `Inventur 2023` + can carry useful semantic meaning even when mtime happens to be in that year. + """ + value = str(text or "") + m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", str(date_guess or "")) + if not m: + return value + y, mo, d = m.groups() + yy = y[-2:] + patterns = [ + rf"(? str: + return _filename_component( + _strip_redundant_date_text(text, rec.date_guess), + title_case_first=title_case_first, + ) + + +def _fallback_title(rec: Record) -> str: + for value in (rec.document_type, rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec, title_case_first=True) + if candidate and _semantic_key(candidate) not in GENERIC_FILENAME_LABELS: + return candidate + stem = _component_for_record(Path(rec.filename).stem, rec, title_case_first=True) + if stem and _semantic_key(stem) not in GENERIC_FILENAME_LABELS: + return stem + return "Dokument" + + +def _source_remainder(rec: Record, *blocked_values: str) -> str: + """Recover useful detail from the original filename when model fields are noisy. + + This is intentionally conservative: it is only used as a fallback subtitle. + Examples: `Getränkeliste Verzehrkarte` -> `Verzehrkarte`; + `Inventur Café Bach 2023` -> `2023`. + """ + raw = _strip_redundant_date_text(Path(rec.filename).stem, rec.date_guess) + + # Remove the branch/prefix phrase before token-level subtraction. + for phrase in sorted(PREFIX_ALIAS_PHRASES.get(_branch_name(rec.destination), ()), key=len, reverse=True): + raw = re.sub(rf"(?= 3): + continue + kept.append(candidate) + + return _filename_component(" ".join(kept)) + + +def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: + blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} + blocked |= _prefix_alias_keys(rec.destination) + blocked |= CATEGORY_NOISE_LABELS + + for value in (rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec) + key = _semantic_key(candidate) + if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS: + return candidate + + return _source_remainder(rec, title, prefix, rec.topic) + + +def build_structured_filename(rec: Record) -> str: + """Build YYYY-MM-DD_PREFIX_Title_Subtitle[_vN].ext deterministically.""" + prefix = _filename_component(_filename_prefix(rec.destination), title_case_first=True) + prefix_aliases = _prefix_alias_keys(rec.destination) + + raw_title = _component_for_record(rec.name_title, rec, title_case_first=True) + raw_subtitle = _component_for_record(rec.name_subtitle, rec) + title_key = _semantic_key(raw_title) + subtitle_key = _semantic_key(raw_subtitle) + + # Promote a useful subtitle when the model supplied a generic label like + # `Info`; this avoids names such as Allgemein_Info_SurvStat... In that + # special case, do not immediately add another model-derived subtitle: only + # recover a genuinely distinct remainder from the original filename. + promoted_generic_title = False + if title_key in GENERIC_FILENAME_LABELS and raw_subtitle: + title = raw_subtitle + raw_subtitle = "" + promoted_generic_title = True + elif not raw_title or title_key in prefix_aliases: + title = _fallback_title(rec) + else: + title = raw_title + + title_key = _semantic_key(title) + blocked_subtitle = {title_key, _semantic_key(prefix)} | prefix_aliases | CATEGORY_NOISE_LABELS + subtitle_key = _semantic_key(raw_subtitle) + + if promoted_generic_title: + subtitle = _source_remainder(rec, title, prefix, rec.topic) + elif ( + not raw_subtitle + or subtitle_key in blocked_subtitle + or subtitle_key in GENERIC_FILENAME_LABELS + ): + subtitle = _fallback_subtitle(rec, title, prefix) + else: + subtitle = raw_subtitle + + # A subtitle can still repeat the branch semantically (e.g. `Café-Bach`). + if _semantic_key(subtitle) in prefix_aliases | {title_key, _semantic_key(prefix)}: + subtitle = _source_remainder(rec, title, prefix, rec.topic) + + parts = [rec.date_guess, prefix, title] + if subtitle and _semantic_key(subtitle) not in {_semantic_key(x) for x in parts}: + parts.append(subtitle) + + ver = _filename_component(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + elif not re.fullmatch(r"v\d+", ver, flags=re.I): + ver = f"v{ver}" + parts.append(ver) + + # Keep enough headroom for the destination path and collision suffixes. + stem = "_".join(x for x in parts if x) + if len(stem) > 180: + overflow = len(stem) - 180 + if subtitle and len(subtitle) > overflow + 12: + subtitle = subtitle[: len(subtitle) - overflow].rstrip("-_. ") + parts = [rec.date_guess, prefix, title] + ([subtitle] if subtitle else []) + if ver: + parts.append(ver) + stem = "_".join(x for x in parts if x) + stem = stem[:180].rstrip("-_. ") + return stem + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + relative_parent = str(p.parent.relative_to(root)) + if allow_stay_outside_policy: + current = ( + "01_INBOX" + if relative_parent == "." + else f"01_INBOX/{relative_parent}" + ) + else: + current = relative_parent + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + context_root = "01_INBOX" if allow_stay_outside_policy else root.name + source_context = context_root + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{context_root}/{relative_parent}" + d, source = best_date(p, root=root) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +- If uncertain, keep the current folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- For destination, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + rec.name_title = str(result.get("title", "")).strip() + rec.name_subtitle = str(result.get("subtitle", "")).strip() + rec.proposed_name = build_structured_filename(rec) + + # In 01_INBOX mode, keeping a file in Inbox is a quarantine/review + # outcome, not a successfully classified final destination. + if dest == "01_INBOX" or dest.startswith("01_INBOX/"): + conf = min(conf, 0.69) + + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # Every V7 proposal follows the structured naming scheme. If its date + # comes only from filesystem mtime, the date is useful but historically + # unverified, so require explicit human review before apply. + if rec.date_source == "mtime": + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates( + rec, + self.destinations, + aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("PDF AES", "cryptography", "cryptography"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V7 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-15_quick-sorter_inbox-staging-context_v10.py b/2026-09-15_quick-sorter_inbox-staging-context_v10.py new file mode 100644 index 0000000..5cbbb7b --- /dev/null +++ b/2026-09-15_quick-sorter_inbox-staging-context_v10.py @@ -0,0 +1,1877 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v9.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import zipfile +import xml.etree.ElementTree as ET +import urllib.request +import unicodedata +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + name_title: str = "" + name_subtitle: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path, root: Optional[Path] = None) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + When ``root`` is supplied, only parent folders *inside* that scan root are + considered. The root directory itself and every ancestor above it are + deliberately excluded. This prevents staging/workspace names such as + ``FULL_STAGING_2026-09-14`` from leaking into document dates. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + if root is not None: + try: + parts = path.parent.relative_to(root).parts + except ValueError: + # A caller supplied a boundary that does not contain this file. + # Be conservative: do not inspect arbitrary ancestors outside it. + return None + else: + # Backwards-compatible behaviour for direct helper calls that do not + # provide a scan root. Production scanning always passes the root. + parts = path.parent.parts + + for part in reversed(parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path, root: Optional[Path] = None) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + # Parent-folder lookup is root-bounded so workspace/staging names can never + # become document metadata. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path, root=root) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + + # Some DOCX files keep visible text in drawing/text-box XML that + # python-docx does not expose through doc.paragraphs or tables. + # Only use this raw XML path as a fallback to avoid duplicating + # ordinary paragraph/table text. + if not text: + xml_parts = [] + try: + with zipfile.ZipFile(path) as zf: + for name in zf.namelist(): + if not (name.startswith("word/") and name.endswith(".xml")): + continue + try: + root = ET.fromstring(zf.read(name)) + except ET.ParseError: + continue + for node in root.iter(): + if node.tag.endswith("}t") and node.text and node.text.strip(): + xml_parts.append(node.text.strip()) + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + except (OSError, zipfile.BadZipFile): + xml_parts = [] + text = _clip(xml_parts) + + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + from pypdf.errors import DependencyError + except ImportError: + return "", "missing:pypdf" + try: + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + except DependencyError as exc: + if "cryptography" in str(exc).lower(): + return "", "missing:cryptography" + raise + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + # python-pptx Slides supports integer indexing but slicing can + # raise AttributeError. Iterate normally and stop after 5 slides. + for slide_index, slide in enumerate(prs.slides): + if slide_index >= 5: + break + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +FILENAME_PREFIXES = { + "Allgemein": "Allgemein", + "Cafe_Bach": "CB", + "Checkpoint": "Checkpoint", + "CSD-Kampagnen": "CSD", + "Ehrenamt": "Ehrenamt", + "FAK": "FAK", + "SP5-Treffen": "SP5", + "Vor-Ort-Arbeit": "VOA", +} + + +def _filename_prefix(destination: str) -> str: + branch = destination.split("/", 1)[0].strip() + return FILENAME_PREFIXES.get(branch, branch.replace("_", "-") or "Datei") + + +GENERIC_FILENAME_LABELS = { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", + "misc", "info", "information", +} + +CATEGORY_NOISE_LABELS = { + "allgemein", "anleitungen", "kommunikation", "materialien", "planung", + "protokolle", "veranstaltung", "veranstaltungen", "verwaltung", "vorlagen", +} + +PREFIX_ALIAS_PHRASES = { + "Cafe_Bach": ("Café Bach", "Cafe Bach", "Cafe_Bach", "cafebach", "CB"), + "Checkpoint": ("Checkpoint", "CP"), + "CSD-Kampagnen": ("CSD-Kampagnen", "CSD Kampagnen", "CSD"), + "Ehrenamt": ("Ehrenamt",), + "FAK": ("FAK",), + "SP5-Treffen": ("SP5-Treffen", "SP5 Treffen", "SP5"), + "Vor-Ort-Arbeit": ("Vor-Ort-Arbeit", "Vor Ort Arbeit", "VOA"), + "Allgemein": ("Allgemein",), +} + + +def _filename_component(text: str, *, title_case_first: bool = False) -> str: + """Return one Windows/Linux-safe filename component while preserving Unicode. + + German spelling/capitalization is kept. Underscores remain reserved for the + schema separators. Cosmetic punctuation such as commas/semicolons is removed + because it adds noise without helping identification. + """ + value = unicodedata.normalize("NFC", str(text or "")).strip() + value = re.sub(r"^\d{4}[-_. ]\d{1,2}[-_. ]\d{1,2}[-_ ]*", "", value) + value = re.sub(r"\.[A-Za-z0-9]{1,6}$", "", value) + value = value.replace("_", " ") + value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", value) + value = re.sub(r"[,;]+", " ", value) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"-+", "-", value).strip("-. ") + if title_case_first and value and value[0].islower(): + value = value[0].upper() + value[1:] + return value + + +def _semantic_key(text: str) -> str: + folded = unicodedata.normalize("NFKD", str(text or "")).casefold() + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.replace("ß", "ss") + return re.sub(r"[^a-z0-9]+", "", folded) + + +def _branch_name(destination: str) -> str: + return destination.split("/", 1)[0].strip() + + +def _prefix_alias_keys(destination: str) -> set[str]: + branch = _branch_name(destination) + values = set(PREFIX_ALIAS_PHRASES.get(branch, ())) + values.add(_filename_prefix(destination)) + values.add(branch) + return {_semantic_key(x) for x in values if x} + + +def _strip_redundant_date_text(text: str, date_guess: str) -> str: + """Remove a full/date-fragment that merely repeats the leading date. + + We deliberately do not remove a bare year: `März 2024` and `Inventur 2023` + can carry useful semantic meaning even when mtime happens to be in that year. + """ + value = str(text or "") + m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", str(date_guess or "")) + if not m: + return value + y, mo, d = m.groups() + yy = y[-2:] + patterns = [ + rf"(? str: + return _filename_component( + _strip_redundant_date_text(text, rec.date_guess), + title_case_first=title_case_first, + ) + + +def _fallback_title(rec: Record) -> str: + for value in (rec.document_type, rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec, title_case_first=True) + if candidate and _semantic_key(candidate) not in GENERIC_FILENAME_LABELS: + return candidate + stem = _component_for_record(Path(rec.filename).stem, rec, title_case_first=True) + if stem and _semantic_key(stem) not in GENERIC_FILENAME_LABELS: + return stem + return "Dokument" + + +def _source_remainder(rec: Record, *blocked_values: str) -> str: + """Recover useful detail from the original filename when model fields are noisy. + + This is intentionally conservative: it is only used as a fallback subtitle. + Examples: `Getränkeliste Verzehrkarte` -> `Verzehrkarte`; + `Inventur Café Bach 2023` -> `2023`. + """ + raw = _strip_redundant_date_text(Path(rec.filename).stem, rec.date_guess) + + # Remove the branch/prefix phrase before token-level subtraction. + for phrase in sorted(PREFIX_ALIAS_PHRASES.get(_branch_name(rec.destination), ()), key=len, reverse=True): + raw = re.sub(rf"(?= 3): + continue + kept.append(candidate) + + return _filename_component(" ".join(kept)) + + +def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: + blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} + blocked |= _prefix_alias_keys(rec.destination) + blocked |= CATEGORY_NOISE_LABELS + + for value in (rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec) + key = _semantic_key(candidate) + if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS: + return candidate + + return _source_remainder(rec, title, prefix, rec.topic) + + +def build_structured_filename(rec: Record) -> str: + """Build YYYY-MM-DD_PREFIX_Title_Subtitle[_vN].ext deterministically.""" + prefix = _filename_component(_filename_prefix(rec.destination), title_case_first=True) + prefix_aliases = _prefix_alias_keys(rec.destination) + + raw_title = _component_for_record(rec.name_title, rec, title_case_first=True) + raw_subtitle = _component_for_record(rec.name_subtitle, rec) + title_key = _semantic_key(raw_title) + subtitle_key = _semantic_key(raw_subtitle) + + # Promote a useful subtitle when the model supplied a generic label like + # `Info`; this avoids names such as Allgemein_Info_SurvStat... In that + # special case, do not immediately add another model-derived subtitle: only + # recover a genuinely distinct remainder from the original filename. + promoted_generic_title = False + if title_key in GENERIC_FILENAME_LABELS and raw_subtitle: + title = raw_subtitle + raw_subtitle = "" + promoted_generic_title = True + elif not raw_title or title_key in prefix_aliases: + title = _fallback_title(rec) + else: + title = raw_title + + title_key = _semantic_key(title) + blocked_subtitle = {title_key, _semantic_key(prefix)} | prefix_aliases | CATEGORY_NOISE_LABELS + subtitle_key = _semantic_key(raw_subtitle) + + if promoted_generic_title: + subtitle = _source_remainder(rec, title, prefix, rec.topic) + elif ( + not raw_subtitle + or subtitle_key in blocked_subtitle + or subtitle_key in GENERIC_FILENAME_LABELS + ): + subtitle = _fallback_subtitle(rec, title, prefix) + else: + subtitle = raw_subtitle + + # A subtitle can still repeat the branch semantically (e.g. `Café-Bach`). + if _semantic_key(subtitle) in prefix_aliases | {title_key, _semantic_key(prefix)}: + subtitle = _source_remainder(rec, title, prefix, rec.topic) + + parts = [rec.date_guess, prefix, title] + if subtitle and _semantic_key(subtitle) not in {_semantic_key(x) for x in parts}: + parts.append(subtitle) + + ver = _filename_component(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + elif not re.fullmatch(r"v\d+", ver, flags=re.I): + ver = f"v{ver}" + parts.append(ver) + + # Keep enough headroom for the destination path and collision suffixes. + stem = "_".join(x for x in parts if x) + if len(stem) > 180: + overflow = len(stem) - 180 + if subtitle and len(subtitle) > overflow + 12: + subtitle = subtitle[: len(subtitle) - overflow].rstrip("-_. ") + parts = [rec.date_guess, prefix, title] + ([subtitle] if subtitle else []) + if ver: + parts.append(ver) + stem = "_".join(x for x in parts if x) + stem = stem[:180].rstrip("-_. ") + return stem + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + relative_parent = str(p.parent.relative_to(root)) + if allow_stay_outside_policy: + current = ( + "01_INBOX" + if relative_parent == "." + else f"01_INBOX/{relative_parent}" + ) + else: + current = relative_parent + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + context_root = "01_INBOX" if allow_stay_outside_policy else root.name + source_context = context_root + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{context_root}/{relative_parent}" + d, source = best_date(p, root=root) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +- If uncertain, keep the current folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- For destination, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + rec.name_title = str(result.get("title", "")).strip() + rec.name_subtitle = str(result.get("subtitle", "")).strip() + rec.proposed_name = build_structured_filename(rec) + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # Every V7 proposal follows the structured naming scheme. If its date + # comes only from filesystem mtime, the date is useful but historically + # unverified, so require explicit human review before apply. + if rec.date_source == "mtime": + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates( + rec, + self.destinations, + aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("PDF AES", "cryptography", "cryptography"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V7 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-15_quick-sorter_semantic-naming_v13.py b/2026-09-15_quick-sorter_semantic-naming_v13.py new file mode 100644 index 0000000..dd65f65 --- /dev/null +++ b/2026-09-15_quick-sorter_semantic-naming_v13.py @@ -0,0 +1,1876 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v9.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import zipfile +import xml.etree.ElementTree as ET +import urllib.request +import unicodedata +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + name_title: str = "" + name_subtitle: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path, root: Optional[Path] = None) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + When ``root`` is supplied, only parent folders *inside* that scan root are + considered. The root directory itself and every ancestor above it are + deliberately excluded. This prevents staging/workspace names such as + ``FULL_STAGING_2026-09-14`` from leaking into document dates. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + if root is not None: + try: + parts = path.parent.relative_to(root).parts + except ValueError: + # A caller supplied a boundary that does not contain this file. + # Be conservative: do not inspect arbitrary ancestors outside it. + return None + else: + # Backwards-compatible behaviour for direct helper calls that do not + # provide a scan root. Production scanning always passes the root. + parts = path.parent.parts + + for part in reversed(parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path, root: Optional[Path] = None) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + # Parent-folder lookup is root-bounded so workspace/staging names can never + # become document metadata. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path, root=root) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + + # Some DOCX files keep visible text in drawing/text-box XML that + # python-docx does not expose through doc.paragraphs or tables. + # Only use this raw XML path as a fallback to avoid duplicating + # ordinary paragraph/table text. + if not text: + xml_parts = [] + try: + with zipfile.ZipFile(path) as zf: + for name in zf.namelist(): + if not (name.startswith("word/") and name.endswith(".xml")): + continue + try: + root = ET.fromstring(zf.read(name)) + except ET.ParseError: + continue + for node in root.iter(): + if node.tag.endswith("}t") and node.text and node.text.strip(): + xml_parts.append(node.text.strip()) + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + except (OSError, zipfile.BadZipFile): + xml_parts = [] + text = _clip(xml_parts) + + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + from pypdf.errors import DependencyError + except ImportError: + return "", "missing:pypdf" + try: + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + except DependencyError as exc: + if "cryptography" in str(exc).lower(): + return "", "missing:cryptography" + raise + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + # python-pptx Slides supports integer indexing but slicing can + # raise AttributeError. Iterate normally and stop after 5 slides. + for slide_index, slide in enumerate(prs.slides): + if slide_index >= 5: + break + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +FILENAME_PREFIXES = { + "Allgemein": "Allgemein", + "Cafe_Bach": "CB", + "Checkpoint": "Checkpoint", + "CSD-Kampagnen": "CSD", + "Ehrenamt": "Ehrenamt", + "FAK": "FAK", + "SP5-Treffen": "SP5", + "Vor-Ort-Arbeit": "VOA", +} + + +def _filename_prefix(destination: str) -> str: + branch = destination.split("/", 1)[0].strip() + return FILENAME_PREFIXES.get(branch, branch.replace("_", "-") or "Datei") + + +GENERIC_FILENAME_LABELS = { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", + "misc", "info", "information", +} + +CATEGORY_NOISE_LABELS = { + "allgemein", "anleitungen", "kommunikation", "materialien", "planung", + "protokolle", "veranstaltung", "veranstaltungen", "verwaltung", "vorlagen", +} + +PREFIX_ALIAS_PHRASES = { + "Cafe_Bach": ("Café Bach", "Cafe Bach", "Cafe_Bach", "cafebach", "CB"), + "Checkpoint": ("Checkpoint", "CP"), + "CSD-Kampagnen": ("CSD-Kampagnen", "CSD Kampagnen", "CSD"), + "Ehrenamt": ("Ehrenamt",), + "FAK": ("FAK",), + "SP5-Treffen": ("SP5-Treffen", "SP5 Treffen", "SP5"), + "Vor-Ort-Arbeit": ("Vor-Ort-Arbeit", "Vor Ort Arbeit", "VOA"), + "Allgemein": ("Allgemein",), +} + + +def _filename_component(text: str, *, title_case_first: bool = False) -> str: + """Return one Windows/Linux-safe filename component while preserving Unicode. + + German spelling/capitalization is kept. Underscores remain reserved for the + schema separators. Cosmetic punctuation such as commas/semicolons is removed + because it adds noise without helping identification. + """ + value = unicodedata.normalize("NFC", str(text or "")).strip() + value = re.sub(r"^\d{4}[-_. ]\d{1,2}[-_. ]\d{1,2}[-_ ]*", "", value) + value = re.sub(r"\.[A-Za-z0-9]{1,6}$", "", value) + value = value.replace("_", " ") + value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", value) + value = re.sub(r"[,;]+", " ", value) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"-+", "-", value).strip("-. ") + if title_case_first and value and value[0].islower(): + value = value[0].upper() + value[1:] + return value + + +def _semantic_key(text: str) -> str: + folded = unicodedata.normalize("NFKD", str(text or "")).casefold() + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.replace("ß", "ss") + return re.sub(r"[^a-z0-9]+", "", folded) + + +def _branch_name(destination: str) -> str: + return destination.split("/", 1)[0].strip() + + +def _prefix_alias_keys(destination: str) -> set[str]: + branch = _branch_name(destination) + values = set(PREFIX_ALIAS_PHRASES.get(branch, ())) + values.add(_filename_prefix(destination)) + values.add(branch) + return {_semantic_key(x) for x in values if x} + + +def _strip_redundant_date_text(text: str, date_guess: str) -> str: + """Remove a full/date-fragment that merely repeats the leading date. + + We deliberately do not remove a bare year: `März 2024` and `Inventur 2023` + can carry useful semantic meaning even when mtime happens to be in that year. + """ + value = str(text or "") + m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", str(date_guess or "")) + if not m: + return value + y, mo, d = m.groups() + yy = y[-2:] + patterns = [ + rf"(? str: + return _filename_component( + _strip_redundant_date_text(text, rec.date_guess), + title_case_first=title_case_first, + ) + + +def _fallback_title(rec: Record) -> str: + for value in (rec.document_type, rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec, title_case_first=True) + if candidate and _semantic_key(candidate) not in GENERIC_FILENAME_LABELS: + return candidate + stem = _component_for_record(Path(rec.filename).stem, rec, title_case_first=True) + if stem and _semantic_key(stem) not in GENERIC_FILENAME_LABELS: + return stem + return "Dokument" + + +def _source_remainder(rec: Record, *blocked_values: str) -> str: + """Recover useful detail from the original filename when model fields are noisy. + + This is intentionally conservative: it is only used as a fallback subtitle. + Examples: `Getränkeliste Verzehrkarte` -> `Verzehrkarte`; + `Inventur Café Bach 2023` -> `2023`. + """ + raw = _strip_redundant_date_text(Path(rec.filename).stem, rec.date_guess) + + # Remove the branch/prefix phrase before token-level subtraction. + for phrase in sorted(PREFIX_ALIAS_PHRASES.get(_branch_name(rec.destination), ()), key=len, reverse=True): + raw = re.sub(rf"(?= 3): + continue + kept.append(candidate) + + return _filename_component(" ".join(kept)) + + +def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: + blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} + blocked |= _prefix_alias_keys(rec.destination) + blocked |= CATEGORY_NOISE_LABELS + + for value in (rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec) + key = _semantic_key(candidate) + if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS: + return candidate + + return _source_remainder(rec, title, prefix, rec.topic) + + +def build_structured_filename(rec: Record) -> str: + """Build YYYY-MM-DD_Topic_Subtopic_DocumentType_Detail[_vN].ext. + + Destination folders are deliberately NOT encoded in filenames. + The filename describes what the document is; the folder describes where it lives. + """ + parts = [rec.date_guess] + seen = {_semantic_key(rec.date_guess)} + + for value in ( + rec.topic, + rec.subtopic, + rec.document_type, + rec.detail, + ): + token = _filename_component(value, title_case_first=True) + key = _semantic_key(token) + + if not token or not key: + continue + + if key in seen: + continue + + if key in CATEGORY_NOISE_LABELS: + continue + + parts.append(token) + seen.add(key) + + ver = _filename_component(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + elif not re.fullmatch(r"v\d+", ver, flags=re.I): + ver = f"v{ver}" + + if _semantic_key(ver) not in seen: + parts.append(ver) + + # If the model supplied no useful semantic information, preserve the + # original filename instead of inventing a meaningless new one. + if len(parts) == 1: + return rec.filename + + stem = "_".join(parts) + + # Leave headroom for destination path and possible collision suffixes. + if len(stem) > 180: + stem = stem[:180].rstrip("-_. ") + + return stem + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + relative_parent = str(p.parent.relative_to(root)) + if allow_stay_outside_policy: + current = ( + "01_INBOX" + if relative_parent == "." + else f"01_INBOX/{relative_parent}" + ) + else: + current = relative_parent + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + context_root = "01_INBOX" if allow_stay_outside_policy else root.name + source_context = context_root + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{context_root}/{relative_parent}" + d, source = best_date(p, root=root) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, classify the PURPOSE/ROLE of the document before its owner or topic. +- Top-level branch meaning: + * 03_PRIVAT = personal-life documents and records: health, private administration, household, personal correspondence, Chrystal, personal paperwork. + * 04_ARBEIT = operational documents for Aidshilfe Köln, Cafe Bach, Checkpoint, Vor-Ort-Arbeit, work events, colleagues, work administration, work planning or work communication. + * 05_VAULT = reusable knowledge/reference: technical guides, setup notes, AI prompts, manuals, documentation, Linux, shell, Syncthing, VPS, LLM, software or general instructions. Choose Vault even when the knowledge was created while solving a work or private task. + * 99_ARCHIV = intentionally historical, retired or obsolete material; never choose it merely because a file is old. + * 01_INBOX = quarantine only when the document cannot be classified safely from the available evidence. +- Technical/reference material must NOT become 03_PRIVAT or 04_ARBEIT merely because a private or work context is mentioned. +- After choosing the correct top-level branch, use owner/work-area evidence to choose the most specific candidate inside that branch. +- If uncertain, keep the current Inbox folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- Within the already chosen purpose branch, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch work sub-branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + rec.name_title = str(result.get("title", "")).strip() + rec.name_subtitle = str(result.get("subtitle", "")).strip() + rec.proposed_name = build_structured_filename(rec) + + # In 01_INBOX mode, keeping a file in Inbox is a quarantine/review + # outcome, not a successfully classified final destination. + if dest == "01_INBOX" or dest.startswith("01_INBOX/"): + conf = min(conf, 0.69) + + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # Every V7 proposal follows the structured naming scheme. If its date + # comes only from filesystem mtime, the date is useful but historically + # unverified, so require explicit human review before apply. + if rec.date_source == "mtime": + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates( + rec, + self.destinations, + aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("PDF AES", "cryptography", "cryptography"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V7 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-15_quick-sorter_vault-specific-routing_v14.py b/2026-09-15_quick-sorter_vault-specific-routing_v14.py new file mode 100644 index 0000000..371f6a5 --- /dev/null +++ b/2026-09-15_quick-sorter_vault-specific-routing_v14.py @@ -0,0 +1,1885 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v9.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import zipfile +import xml.etree.ElementTree as ET +import urllib.request +import unicodedata +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + name_title: str = "" + name_subtitle: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path, root: Optional[Path] = None) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + When ``root`` is supplied, only parent folders *inside* that scan root are + considered. The root directory itself and every ancestor above it are + deliberately excluded. This prevents staging/workspace names such as + ``FULL_STAGING_2026-09-14`` from leaking into document dates. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + if root is not None: + try: + parts = path.parent.relative_to(root).parts + except ValueError: + # A caller supplied a boundary that does not contain this file. + # Be conservative: do not inspect arbitrary ancestors outside it. + return None + else: + # Backwards-compatible behaviour for direct helper calls that do not + # provide a scan root. Production scanning always passes the root. + parts = path.parent.parts + + for part in reversed(parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path, root: Optional[Path] = None) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + # Parent-folder lookup is root-bounded so workspace/staging names can never + # become document metadata. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path, root=root) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + + # Some DOCX files keep visible text in drawing/text-box XML that + # python-docx does not expose through doc.paragraphs or tables. + # Only use this raw XML path as a fallback to avoid duplicating + # ordinary paragraph/table text. + if not text: + xml_parts = [] + try: + with zipfile.ZipFile(path) as zf: + for name in zf.namelist(): + if not (name.startswith("word/") and name.endswith(".xml")): + continue + try: + root = ET.fromstring(zf.read(name)) + except ET.ParseError: + continue + for node in root.iter(): + if node.tag.endswith("}t") and node.text and node.text.strip(): + xml_parts.append(node.text.strip()) + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + except (OSError, zipfile.BadZipFile): + xml_parts = [] + text = _clip(xml_parts) + + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + from pypdf.errors import DependencyError + except ImportError: + return "", "missing:pypdf" + try: + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + except DependencyError as exc: + if "cryptography" in str(exc).lower(): + return "", "missing:cryptography" + raise + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + # python-pptx Slides supports integer indexing but slicing can + # raise AttributeError. Iterate normally and stop after 5 slides. + for slide_index, slide in enumerate(prs.slides): + if slide_index >= 5: + break + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +FILENAME_PREFIXES = { + "Allgemein": "Allgemein", + "Cafe_Bach": "CB", + "Checkpoint": "Checkpoint", + "CSD-Kampagnen": "CSD", + "Ehrenamt": "Ehrenamt", + "FAK": "FAK", + "SP5-Treffen": "SP5", + "Vor-Ort-Arbeit": "VOA", +} + + +def _filename_prefix(destination: str) -> str: + branch = destination.split("/", 1)[0].strip() + return FILENAME_PREFIXES.get(branch, branch.replace("_", "-") or "Datei") + + +GENERIC_FILENAME_LABELS = { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", + "misc", "info", "information", +} + +CATEGORY_NOISE_LABELS = { + "allgemein", "anleitungen", "kommunikation", "materialien", "planung", + "protokolle", "veranstaltung", "veranstaltungen", "verwaltung", "vorlagen", +} + +PREFIX_ALIAS_PHRASES = { + "Cafe_Bach": ("Café Bach", "Cafe Bach", "Cafe_Bach", "cafebach", "CB"), + "Checkpoint": ("Checkpoint", "CP"), + "CSD-Kampagnen": ("CSD-Kampagnen", "CSD Kampagnen", "CSD"), + "Ehrenamt": ("Ehrenamt",), + "FAK": ("FAK",), + "SP5-Treffen": ("SP5-Treffen", "SP5 Treffen", "SP5"), + "Vor-Ort-Arbeit": ("Vor-Ort-Arbeit", "Vor Ort Arbeit", "VOA"), + "Allgemein": ("Allgemein",), +} + + +def _filename_component(text: str, *, title_case_first: bool = False) -> str: + """Return one Windows/Linux-safe filename component while preserving Unicode. + + German spelling/capitalization is kept. Underscores remain reserved for the + schema separators. Cosmetic punctuation such as commas/semicolons is removed + because it adds noise without helping identification. + """ + value = unicodedata.normalize("NFC", str(text or "")).strip() + value = re.sub(r"^\d{4}[-_. ]\d{1,2}[-_. ]\d{1,2}[-_ ]*", "", value) + value = re.sub(r"\.[A-Za-z0-9]{1,6}$", "", value) + value = value.replace("_", " ") + value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", value) + value = re.sub(r"[,;]+", " ", value) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"-+", "-", value).strip("-. ") + if title_case_first and value and value[0].islower(): + value = value[0].upper() + value[1:] + return value + + +def _semantic_key(text: str) -> str: + folded = unicodedata.normalize("NFKD", str(text or "")).casefold() + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.replace("ß", "ss") + return re.sub(r"[^a-z0-9]+", "", folded) + + +def _branch_name(destination: str) -> str: + return destination.split("/", 1)[0].strip() + + +def _prefix_alias_keys(destination: str) -> set[str]: + branch = _branch_name(destination) + values = set(PREFIX_ALIAS_PHRASES.get(branch, ())) + values.add(_filename_prefix(destination)) + values.add(branch) + return {_semantic_key(x) for x in values if x} + + +def _strip_redundant_date_text(text: str, date_guess: str) -> str: + """Remove a full/date-fragment that merely repeats the leading date. + + We deliberately do not remove a bare year: `März 2024` and `Inventur 2023` + can carry useful semantic meaning even when mtime happens to be in that year. + """ + value = str(text or "") + m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", str(date_guess or "")) + if not m: + return value + y, mo, d = m.groups() + yy = y[-2:] + patterns = [ + rf"(? str: + return _filename_component( + _strip_redundant_date_text(text, rec.date_guess), + title_case_first=title_case_first, + ) + + +def _fallback_title(rec: Record) -> str: + for value in (rec.document_type, rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec, title_case_first=True) + if candidate and _semantic_key(candidate) not in GENERIC_FILENAME_LABELS: + return candidate + stem = _component_for_record(Path(rec.filename).stem, rec, title_case_first=True) + if stem and _semantic_key(stem) not in GENERIC_FILENAME_LABELS: + return stem + return "Dokument" + + +def _source_remainder(rec: Record, *blocked_values: str) -> str: + """Recover useful detail from the original filename when model fields are noisy. + + This is intentionally conservative: it is only used as a fallback subtitle. + Examples: `Getränkeliste Verzehrkarte` -> `Verzehrkarte`; + `Inventur Café Bach 2023` -> `2023`. + """ + raw = _strip_redundant_date_text(Path(rec.filename).stem, rec.date_guess) + + # Remove the branch/prefix phrase before token-level subtraction. + for phrase in sorted(PREFIX_ALIAS_PHRASES.get(_branch_name(rec.destination), ()), key=len, reverse=True): + raw = re.sub(rf"(?= 3): + continue + kept.append(candidate) + + return _filename_component(" ".join(kept)) + + +def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: + blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} + blocked |= _prefix_alias_keys(rec.destination) + blocked |= CATEGORY_NOISE_LABELS + + for value in (rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec) + key = _semantic_key(candidate) + if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS: + return candidate + + return _source_remainder(rec, title, prefix, rec.topic) + + +def build_structured_filename(rec: Record) -> str: + """Build YYYY-MM-DD_Topic_Subtopic_DocumentType_Detail[_vN].ext. + + Destination folders are deliberately NOT encoded in filenames. + The filename describes what the document is; the folder describes where it lives. + """ + parts = [rec.date_guess] + seen = {_semantic_key(rec.date_guess)} + + for value in ( + rec.topic, + rec.subtopic, + rec.document_type, + rec.detail, + ): + token = _filename_component(value, title_case_first=True) + key = _semantic_key(token) + + if not token or not key: + continue + + if key in seen: + continue + + if key in CATEGORY_NOISE_LABELS: + continue + + parts.append(token) + seen.add(key) + + ver = _filename_component(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + elif not re.fullmatch(r"v\d+", ver, flags=re.I): + ver = f"v{ver}" + + if _semantic_key(ver) not in seen: + parts.append(ver) + + # If the model supplied no useful semantic information, preserve the + # original filename instead of inventing a meaningless new one. + if len(parts) == 1: + return rec.filename + + stem = "_".join(parts) + + # Leave headroom for destination path and possible collision suffixes. + if len(stem) > 180: + stem = stem[:180].rstrip("-_. ") + + return stem + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + relative_parent = str(p.parent.relative_to(root)) + if allow_stay_outside_policy: + current = ( + "01_INBOX" + if relative_parent == "." + else f"01_INBOX/{relative_parent}" + ) + else: + current = relative_parent + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + context_root = "01_INBOX" if allow_stay_outside_policy else root.name + source_context = context_root + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{context_root}/{relative_parent}" + d, source = best_date(p, root=root) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, classify the PURPOSE/ROLE of the document before its owner or topic. +- Top-level branch meaning: + * 03_PRIVAT = personal-life documents and records: health, private administration, household, personal correspondence, Chrystal, personal paperwork. + * 04_ARBEIT = operational documents for Aidshilfe Köln, Cafe Bach, Checkpoint, Vor-Ort-Arbeit, work events, colleagues, work administration, work planning or work communication. + * 05_VAULT = reusable knowledge/reference: technical guides, setup notes, AI prompts, manuals, documentation, Linux, shell, Syncthing, VPS, LLM, software or general instructions. Choose Vault even when the knowledge was created while solving a work or private task. + * 99_ARCHIV = intentionally historical, retired or obsolete material; never choose it merely because a file is old. + * 01_INBOX = quarantine only when the document cannot be classified safely from the available evidence. +- Technical/reference material must NOT become 03_PRIVAT or 04_ARBEIT merely because a private or work context is mentioned. +- After choosing the correct top-level branch, use owner/work-area evidence to choose the most specific candidate inside that branch. +- For 05_VAULT, strongly prefer the most specific suitable Vault candidate instead of the 05_VAULT root: + * 05_VAULT/Prompts = prompts, system prompts, start prompts, reusable LLM instructions or prompt templates. + * 05_VAULT/Tools = tool-specific configuration, commands, helper-tool documentation or operational tool notes. + * 05_VAULT/Wissen/IT = Linux, VPS, Syncthing, networking, hardware, software, LLM setup, system administration, technical guides and technical documentation. + * 05_VAULT/Wissen/Organisation = productivity methods, workflows, planning systems and organisational knowledge. + * 05_VAULT/Wissen = reusable knowledge that belongs in Wissen but has no clearly suitable more-specific Wissen candidate. + * 05_VAULT = use only when no more-specific Vault candidate reasonably fits. +- A document that IS itself a prompt belongs in 05_VAULT/Prompts even when its subject is IT, work, ADHD or another domain. +- A technical guide ABOUT configuring or understanding a system normally belongs in 05_VAULT/Wissen/IT, not 05_VAULT/Tools. +- If uncertain, keep the current Inbox folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- Within the already chosen purpose branch, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch work sub-branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + rec.name_title = str(result.get("title", "")).strip() + rec.name_subtitle = str(result.get("subtitle", "")).strip() + rec.proposed_name = build_structured_filename(rec) + + # In 01_INBOX mode, keeping a file in Inbox is a quarantine/review + # outcome, not a successfully classified final destination. + if dest == "01_INBOX" or dest.startswith("01_INBOX/"): + conf = min(conf, 0.69) + + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # Every V7 proposal follows the structured naming scheme. If its date + # comes only from filesystem mtime, the date is useful but historically + # unverified, so require explicit human review before apply. + if rec.date_source == "mtime": + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates( + rec, + self.destinations, + aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("PDF AES", "cryptography", "cryptography"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V7 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/2026-09-15_sorter-policy_before-03-privat-cleanup.json b/2026-09-15_sorter-policy_before-03-privat-cleanup.json new file mode 100644 index 0000000..180469d --- /dev/null +++ b/2026-09-15_sorter-policy_before-03-privat-cleanup.json @@ -0,0 +1,159 @@ +{ + "01_INBOX": [ + "01_INBOX", + "03_PRIVAT", + "03_PRIVAT/Gesundheit", + "03_PRIVAT/Dokumente", + "03_PRIVAT/Chrystal", + "04_ARBEIT", + "04_ARBEIT/Allgemein", + "04_ARBEIT/Allgemein/Kommunikation", + "04_ARBEIT/Allgemein/Kontakte", + "04_ARBEIT/Allgemein/Verwaltung", + "04_ARBEIT/Allgemein/Vorlagen", + "04_ARBEIT/Allgemein/Anleitungen", + "04_ARBEIT/Cafe_Bach", + "04_ARBEIT/Cafe_Bach/Planung", + "04_ARBEIT/Cafe_Bach/Kommunikation", + "04_ARBEIT/Cafe_Bach/Verwaltung", + "04_ARBEIT/Cafe_Bach/Veranstaltungen", + "04_ARBEIT/Cafe_Bach/Materialien", + "04_ARBEIT/Cafe_Bach/Vorlagen", + "04_ARBEIT/Cafe_Bach/Anleitungen", + "04_ARBEIT/Checkpoint", + "04_ARBEIT/CSD-Kampagnen", + "04_ARBEIT/Ehrenamt", + "04_ARBEIT/FAK", + "04_ARBEIT/SP5-Treffen", + "04_ARBEIT/Vor-Ort-Arbeit", + "05_VAULT", + "05_VAULT/Wissen", + "05_VAULT/Wissen/IT", + "05_VAULT/Wissen/Design", + "05_VAULT/Wissen/Biologie", + "05_VAULT/Wissen/Mathematik", + "05_VAULT/Wissen/HIV_Praevention", + "05_VAULT/Wissen/Organisation", + "05_VAULT/Prompts", + "05_VAULT/Tools", + "03_PRIVAT/Anleitungen", + "04_ARBEIT/Allgemein/Antraege", + "04_ARBEIT/Allgemein/Praesentationen", + "99_ARCHIV" + ], + "02_DEV": [ + "Projekte", + "Experimente" + ], + "03_PRIVAT": [ + "Gesundheit", + "Dokumente", + "Finanzen", + "Organisation", + "Chrystal" + ], + "04_ARBEIT": [ + "Allgemein", + "Allgemein/Kommunikation", + "Allgemein/Kontakte", + "Allgemein/Protokolle", + "Allgemein/Verwaltung", + "Allgemein/Vorlagen", + "Allgemein/Anleitungen", + "Cafe_Bach", + "Cafe_Bach/Planung", + "Cafe_Bach/Kommunikation", + "Cafe_Bach/Verwaltung", + "Cafe_Bach/Veranstaltungen", + "Cafe_Bach/Materialien", + "Cafe_Bach/Vorlagen", + "Cafe_Bach/Anleitungen", + "Checkpoint", + "CSD-Kampagnen", + "Ehrenamt", + "FAK", + "SP5-Treffen", + "Vor-Ort-Arbeit" + ], + "05_VAULT": [ + "Wissen", + "Wissen/IT", + "Wissen/Design", + "Wissen/Biologie", + "Wissen/Mathematik", + "Wissen/HIV_Praevention", + "Wissen/Organisation", + "Prozesse", + "Prompts", + "Tools", + "Vorlagen" + ], + "_strong_aliases": { + "04_ARBEIT": { + "Cafe_Bach": [ + "cb", + "cafe bach", + "café bach", + "cafe_bach", + "caba", + "cafebach" + ], + "Checkpoint": [ + "checkpoint", + "check point" + ], + "CSD-Kampagnen": [ + "csd", + "cologne pride" + ], + "SP5-Treffen": [ + "sp5", + "4s treffen" + ], + "Vor-Ort-Arbeit": [ + "voa", + "vor ort arbeit", + "vor-ort-arbeit" + ], + "FAK": [ + "fak", + "facharbeitskreis" + ] + }, + "01_INBOX": { + "04_ARBEIT/Cafe_Bach": [ + "cb", + "cafe bach", + "café bach", + "cafe_bach", + "caba", + "cafebach" + ], + "04_ARBEIT/Checkpoint": [ + "checkpoint", + "check point" + ], + "04_ARBEIT/CSD-Kampagnen": [ + "csd", + "cologne pride" + ], + "04_ARBEIT/SP5-Treffen": [ + "sp5", + "4s treffen" + ], + "04_ARBEIT/Vor-Ort-Arbeit": [ + "voa", + "vor ort arbeit", + "vor-ort-arbeit" + ], + "04_ARBEIT/FAK": [ + "fak", + "facharbeitskreis" + ], + "03_PRIVAT/Chrystal": [ + "chrystal math", + "chrystal-math" + ] + } + } +} diff --git a/2026-09-15_sorter-policy_before-05-vault-cleanup.json b/2026-09-15_sorter-policy_before-05-vault-cleanup.json new file mode 100644 index 0000000..e2ded66 --- /dev/null +++ b/2026-09-15_sorter-policy_before-05-vault-cleanup.json @@ -0,0 +1,159 @@ +{ + "01_INBOX": [ + "01_INBOX", + "03_PRIVAT", + "03_PRIVAT/Gesundheit", + "03_PRIVAT/Dokumente", + "03_PRIVAT/Chrystal", + "04_ARBEIT", + "04_ARBEIT/Allgemein", + "04_ARBEIT/Allgemein/Kommunikation", + "04_ARBEIT/Allgemein/Kontakte", + "04_ARBEIT/Allgemein/Verwaltung", + "04_ARBEIT/Allgemein/Vorlagen", + "04_ARBEIT/Allgemein/Anleitungen", + "04_ARBEIT/Cafe_Bach", + "04_ARBEIT/Cafe_Bach/Planung", + "04_ARBEIT/Cafe_Bach/Kommunikation", + "04_ARBEIT/Cafe_Bach/Verwaltung", + "04_ARBEIT/Cafe_Bach/Veranstaltungen", + "04_ARBEIT/Cafe_Bach/Materialien", + "04_ARBEIT/Cafe_Bach/Vorlagen", + "04_ARBEIT/Cafe_Bach/Anleitungen", + "04_ARBEIT/Checkpoint", + "04_ARBEIT/CSD-Kampagnen", + "04_ARBEIT/Ehrenamt", + "04_ARBEIT/FAK", + "04_ARBEIT/SP5-Treffen", + "04_ARBEIT/Vor-Ort-Arbeit", + "05_VAULT", + "05_VAULT/Wissen", + "05_VAULT/Wissen/IT", + "05_VAULT/Wissen/Design", + "05_VAULT/Wissen/Biologie", + "05_VAULT/Wissen/Mathematik", + "05_VAULT/Wissen/HIV_Praevention", + "05_VAULT/Wissen/Organisation", + "05_VAULT/Prompts", + "05_VAULT/Tools", + "03_PRIVAT/Anleitungen", + "04_ARBEIT/Allgemein/Antraege", + "04_ARBEIT/Allgemein/Praesentationen", + "99_ARCHIV" + ], + "02_DEV": [ + "Projekte", + "Experimente" + ], + "03_PRIVAT": [ + "Gesundheit", + "Dokumente", + "Organisation", + "Anleitungen", + "Chrystal" + ], + "04_ARBEIT": [ + "Allgemein", + "Allgemein/Kommunikation", + "Allgemein/Kontakte", + "Allgemein/Protokolle", + "Allgemein/Verwaltung", + "Allgemein/Vorlagen", + "Allgemein/Anleitungen", + "Cafe_Bach", + "Cafe_Bach/Planung", + "Cafe_Bach/Kommunikation", + "Cafe_Bach/Verwaltung", + "Cafe_Bach/Veranstaltungen", + "Cafe_Bach/Materialien", + "Cafe_Bach/Vorlagen", + "Cafe_Bach/Anleitungen", + "Checkpoint", + "CSD-Kampagnen", + "Ehrenamt", + "FAK", + "SP5-Treffen", + "Vor-Ort-Arbeit" + ], + "05_VAULT": [ + "Wissen", + "Wissen/IT", + "Wissen/Design", + "Wissen/Biologie", + "Wissen/Mathematik", + "Wissen/HIV_Praevention", + "Wissen/Organisation", + "Prozesse", + "Prompts", + "Tools", + "Vorlagen" + ], + "_strong_aliases": { + "04_ARBEIT": { + "Cafe_Bach": [ + "cb", + "cafe bach", + "café bach", + "cafe_bach", + "caba", + "cafebach" + ], + "Checkpoint": [ + "checkpoint", + "check point" + ], + "CSD-Kampagnen": [ + "csd", + "cologne pride" + ], + "SP5-Treffen": [ + "sp5", + "4s treffen" + ], + "Vor-Ort-Arbeit": [ + "voa", + "vor ort arbeit", + "vor-ort-arbeit" + ], + "FAK": [ + "fak", + "facharbeitskreis" + ] + }, + "01_INBOX": { + "04_ARBEIT/Cafe_Bach": [ + "cb", + "cafe bach", + "café bach", + "cafe_bach", + "caba", + "cafebach" + ], + "04_ARBEIT/Checkpoint": [ + "checkpoint", + "check point" + ], + "04_ARBEIT/CSD-Kampagnen": [ + "csd", + "cologne pride" + ], + "04_ARBEIT/SP5-Treffen": [ + "sp5", + "4s treffen" + ], + "04_ARBEIT/Vor-Ort-Arbeit": [ + "voa", + "vor ort arbeit", + "vor-ort-arbeit" + ], + "04_ARBEIT/FAK": [ + "fak", + "facharbeitskreis" + ], + "03_PRIVAT/Chrystal": [ + "chrystal math", + "chrystal-math" + ] + } + } +} diff --git a/2026-09-15_sorter-policy_before-inbox-cleanup.json b/2026-09-15_sorter-policy_before-inbox-cleanup.json new file mode 100644 index 0000000..324a612 --- /dev/null +++ b/2026-09-15_sorter-policy_before-inbox-cleanup.json @@ -0,0 +1,164 @@ +{ + "01_INBOX": [ + "01_INBOX", + "02_DEV", + "02_DEV/Projekte", + "02_DEV/Experimente", + "03_PRIVAT", + "03_PRIVAT/Gesundheit", + "03_PRIVAT/Dokumente", + "03_PRIVAT/Finanzen", + "03_PRIVAT/Organisation", + "03_PRIVAT/Chrystal", + "04_ARBEIT", + "04_ARBEIT/Allgemein", + "04_ARBEIT/Allgemein/Kommunikation", + "04_ARBEIT/Allgemein/Kontakte", + "04_ARBEIT/Allgemein/Protokolle", + "04_ARBEIT/Allgemein/Verwaltung", + "04_ARBEIT/Allgemein/Vorlagen", + "04_ARBEIT/Allgemein/Anleitungen", + "04_ARBEIT/Cafe_Bach", + "04_ARBEIT/Cafe_Bach/Planung", + "04_ARBEIT/Cafe_Bach/Kommunikation", + "04_ARBEIT/Cafe_Bach/Verwaltung", + "04_ARBEIT/Cafe_Bach/Veranstaltungen", + "04_ARBEIT/Cafe_Bach/Materialien", + "04_ARBEIT/Cafe_Bach/Vorlagen", + "04_ARBEIT/Cafe_Bach/Anleitungen", + "04_ARBEIT/Checkpoint", + "04_ARBEIT/CSD-Kampagnen", + "04_ARBEIT/Ehrenamt", + "04_ARBEIT/FAK", + "04_ARBEIT/SP5-Treffen", + "04_ARBEIT/Vor-Ort-Arbeit", + "05_VAULT", + "05_VAULT/Wissen", + "05_VAULT/Wissen/IT", + "05_VAULT/Wissen/Design", + "05_VAULT/Wissen/Biologie", + "05_VAULT/Wissen/Mathematik", + "05_VAULT/Wissen/HIV_Praevention", + "05_VAULT/Wissen/Organisation", + "05_VAULT/Prozesse", + "05_VAULT/Prompts", + "05_VAULT/Tools", + "05_VAULT/Vorlagen", + "99_ARCHIV" + ], + "02_DEV": [ + "Projekte", + "Experimente" + ], + "03_PRIVAT": [ + "Gesundheit", + "Dokumente", + "Finanzen", + "Organisation", + "Chrystal" + ], + "04_ARBEIT": [ + "Allgemein", + "Allgemein/Kommunikation", + "Allgemein/Kontakte", + "Allgemein/Protokolle", + "Allgemein/Verwaltung", + "Allgemein/Vorlagen", + "Allgemein/Anleitungen", + "Cafe_Bach", + "Cafe_Bach/Planung", + "Cafe_Bach/Kommunikation", + "Cafe_Bach/Verwaltung", + "Cafe_Bach/Veranstaltungen", + "Cafe_Bach/Materialien", + "Cafe_Bach/Vorlagen", + "Cafe_Bach/Anleitungen", + "Checkpoint", + "CSD-Kampagnen", + "Ehrenamt", + "FAK", + "SP5-Treffen", + "Vor-Ort-Arbeit" + ], + "05_VAULT": [ + "Wissen", + "Wissen/IT", + "Wissen/Design", + "Wissen/Biologie", + "Wissen/Mathematik", + "Wissen/HIV_Praevention", + "Wissen/Organisation", + "Prozesse", + "Prompts", + "Tools", + "Vorlagen" + ], + "_strong_aliases": { + "04_ARBEIT": { + "Cafe_Bach": [ + "cb", + "cafe bach", + "café bach", + "cafe_bach", + "caba", + "cafebach" + ], + "Checkpoint": [ + "checkpoint", + "check point" + ], + "CSD-Kampagnen": [ + "csd", + "cologne pride" + ], + "SP5-Treffen": [ + "sp5", + "4s treffen" + ], + "Vor-Ort-Arbeit": [ + "voa", + "vor ort arbeit", + "vor-ort-arbeit" + ], + "FAK": [ + "fak", + "facharbeitskreis" + ] + }, + "01_INBOX": { + "04_ARBEIT/Cafe_Bach": [ + "cb", + "cafe bach", + "café bach", + "cafe_bach", + "caba", + "cafebach" + ], + "04_ARBEIT/Checkpoint": [ + "checkpoint", + "check point" + ], + "04_ARBEIT/CSD-Kampagnen": [ + "csd", + "cologne pride" + ], + "04_ARBEIT/SP5-Treffen": [ + "sp5", + "4s treffen" + ], + "04_ARBEIT/Vor-Ort-Arbeit": [ + "voa", + "vor ort arbeit", + "vor-ort-arbeit" + ], + "04_ARBEIT/FAK": [ + "fak", + "facharbeitskreis" + ], + "03_PRIVAT/Chrystal": [ + "chrystal math", + "chrystal-math" + ] + } + } +} diff --git a/2026-09-15_sorter-policy_before-remove-02dev-from-inbox.json b/2026-09-15_sorter-policy_before-remove-02dev-from-inbox.json new file mode 100644 index 0000000..b9ffc17 --- /dev/null +++ b/2026-09-15_sorter-policy_before-remove-02dev-from-inbox.json @@ -0,0 +1,160 @@ +{ + "01_INBOX": [ + "01_INBOX", + "02_DEV", + "03_PRIVAT", + "03_PRIVAT/Gesundheit", + "03_PRIVAT/Dokumente", + "03_PRIVAT/Chrystal", + "04_ARBEIT", + "04_ARBEIT/Allgemein", + "04_ARBEIT/Allgemein/Kommunikation", + "04_ARBEIT/Allgemein/Kontakte", + "04_ARBEIT/Allgemein/Verwaltung", + "04_ARBEIT/Allgemein/Vorlagen", + "04_ARBEIT/Allgemein/Anleitungen", + "04_ARBEIT/Cafe_Bach", + "04_ARBEIT/Cafe_Bach/Planung", + "04_ARBEIT/Cafe_Bach/Kommunikation", + "04_ARBEIT/Cafe_Bach/Verwaltung", + "04_ARBEIT/Cafe_Bach/Veranstaltungen", + "04_ARBEIT/Cafe_Bach/Materialien", + "04_ARBEIT/Cafe_Bach/Vorlagen", + "04_ARBEIT/Cafe_Bach/Anleitungen", + "04_ARBEIT/Checkpoint", + "04_ARBEIT/CSD-Kampagnen", + "04_ARBEIT/Ehrenamt", + "04_ARBEIT/FAK", + "04_ARBEIT/SP5-Treffen", + "04_ARBEIT/Vor-Ort-Arbeit", + "05_VAULT", + "05_VAULT/Wissen", + "05_VAULT/Wissen/IT", + "05_VAULT/Wissen/Design", + "05_VAULT/Wissen/Biologie", + "05_VAULT/Wissen/Mathematik", + "05_VAULT/Wissen/HIV_Praevention", + "05_VAULT/Wissen/Organisation", + "05_VAULT/Prompts", + "05_VAULT/Tools", + "03_PRIVAT/Anleitungen", + "04_ARBEIT/Allgemein/Antraege", + "04_ARBEIT/Allgemein/Praesentationen", + "99_ARCHIV" + ], + "02_DEV": [ + "Projekte", + "Experimente" + ], + "03_PRIVAT": [ + "Gesundheit", + "Dokumente", + "Finanzen", + "Organisation", + "Chrystal" + ], + "04_ARBEIT": [ + "Allgemein", + "Allgemein/Kommunikation", + "Allgemein/Kontakte", + "Allgemein/Protokolle", + "Allgemein/Verwaltung", + "Allgemein/Vorlagen", + "Allgemein/Anleitungen", + "Cafe_Bach", + "Cafe_Bach/Planung", + "Cafe_Bach/Kommunikation", + "Cafe_Bach/Verwaltung", + "Cafe_Bach/Veranstaltungen", + "Cafe_Bach/Materialien", + "Cafe_Bach/Vorlagen", + "Cafe_Bach/Anleitungen", + "Checkpoint", + "CSD-Kampagnen", + "Ehrenamt", + "FAK", + "SP5-Treffen", + "Vor-Ort-Arbeit" + ], + "05_VAULT": [ + "Wissen", + "Wissen/IT", + "Wissen/Design", + "Wissen/Biologie", + "Wissen/Mathematik", + "Wissen/HIV_Praevention", + "Wissen/Organisation", + "Prozesse", + "Prompts", + "Tools", + "Vorlagen" + ], + "_strong_aliases": { + "04_ARBEIT": { + "Cafe_Bach": [ + "cb", + "cafe bach", + "café bach", + "cafe_bach", + "caba", + "cafebach" + ], + "Checkpoint": [ + "checkpoint", + "check point" + ], + "CSD-Kampagnen": [ + "csd", + "cologne pride" + ], + "SP5-Treffen": [ + "sp5", + "4s treffen" + ], + "Vor-Ort-Arbeit": [ + "voa", + "vor ort arbeit", + "vor-ort-arbeit" + ], + "FAK": [ + "fak", + "facharbeitskreis" + ] + }, + "01_INBOX": { + "04_ARBEIT/Cafe_Bach": [ + "cb", + "cafe bach", + "café bach", + "cafe_bach", + "caba", + "cafebach" + ], + "04_ARBEIT/Checkpoint": [ + "checkpoint", + "check point" + ], + "04_ARBEIT/CSD-Kampagnen": [ + "csd", + "cologne pride" + ], + "04_ARBEIT/SP5-Treffen": [ + "sp5", + "4s treffen" + ], + "04_ARBEIT/Vor-Ort-Arbeit": [ + "voa", + "vor ort arbeit", + "vor-ort-arbeit" + ], + "04_ARBEIT/FAK": [ + "fak", + "facharbeitskreis" + ], + "03_PRIVAT/Chrystal": [ + "chrystal math", + "chrystal-math" + ] + } + } +} diff --git a/2026-09-16_inbox-sort_daily-wrapper.py b/2026-09-16_inbox-sort_daily-wrapper.py new file mode 100755 index 0000000..2dc2eee --- /dev/null +++ b/2026-09-16_inbox-sort_daily-wrapper.py @@ -0,0 +1,573 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import csv +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +from datetime import datetime + +PROJECT = Path("/data/02_DEV/13_document_sorter") +SORTER = PROJECT / "2026-09-15_quick-sorter_destination-normalization_v18.py" +POLICY = PROJECT / "sorter_policy.json" +DATA_ROOT = Path("/data") +INBOX = DATA_ROOT / "01_INBOX" +RUNS_ROOT = DATA_ROOT / "90_SORT_DAILY_INBOX" +STATE_DIR = Path.home() / ".local" / "state" / "inbox-sort" +STATE_FILE = STATE_DIR / "deferred.json" +HISTORY_FILE = STATE_DIR / "history.log" +ENDPOINT = "http://100.105.210.21:8080/v1/chat/completions" +MODEL = "architect" + +def hash_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + +def ignored(rel: Path) -> bool: + if ".stfolder" in rel.parts: + return True + name = rel.name + if name in {".directory", ".quick_sorter_log.csv"}: + return True + if name.startswith(".sync-conflict-") and name.endswith(".directory"): + return True + return False + +def scan_inbox() -> dict[str, dict[str, object]]: + out: dict[str, dict[str, object]] = {} + if not INBOX.is_dir(): + raise SystemExit(f"STOP: Inbox does not exist: {INBOX}") + for p in INBOX.rglob("*"): + if not p.is_file(): + continue + rel = p.relative_to(INBOX) + if ignored(rel): + continue + out[str(rel)] = { + "sha256": hash_file(p), + "size": p.stat().st_size, + } + return out + +def load_state() -> dict[str, dict[str, object]]: + if not STATE_FILE.exists(): + return {} + try: + data = json.loads(STATE_FILE.read_text(encoding="utf-8")) + if isinstance(data, dict) and "deferred" in data: + data = data["deferred"] + if not isinstance(data, dict): + return {} + return data + except Exception as exc: + raise SystemExit(f"STOP: cannot read deferred state: {exc}") + +def save_state(deferred: dict[str, dict[str, object]]) -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + payload = { + "version": 1, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "deferred": dict(sorted(deferred.items())), + } + tmp = STATE_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + os.replace(tmp, STATE_FILE) + +def load_sorter_module(): + if not SORTER.is_file(): + raise SystemExit(f"STOP: sorter missing: {SORTER}") + spec = importlib.util.spec_from_file_location("inbox_sorter_v18", SORTER) + if spec is None or spec.loader is None: + raise SystemExit("STOP: could not load sorter V18") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + +def allowed_destinations() -> list[str]: + module = load_sorter_module() + policy = module.load_policy(POLICY) + return module.allowed_destinations(INBOX, policy, "01_INBOX") + +def destination_roots(destinations: list[str]) -> list[str]: + roots = sorted({d.split("/", 1)[0] for d in destinations if d != "01_INBOX"}) + bad = [r for r in roots if r not in {"03_PRIVAT", "04_ARBEIT", "05_VAULT", "99_ARCHIV"}] + if bad: + raise SystemExit(f"STOP: unexpected destination root(s) in policy: {', '.join(bad)}") + return roots + +def copy_file_preserving(src: Path, dst: Path) -> None: + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + +def stage_baseline(target: Path, roots: list[str]) -> None: + print("\nBuilding safe staging baseline...") + for root in roots: + src = DATA_ROOT / root + dst = target / root + if not src.is_dir(): + raise SystemExit(f"STOP: destination root missing: {src}") + dst.mkdir(parents=True, exist_ok=True) + cmd = [ + "rsync", "-a", + "--exclude=.stfolder/", + "--exclude=.directory", + "--exclude=.sync-conflict-*.directory", + f"{src}/", f"{dst}/", + ] + subprocess.run(cmd, check=True) + (target / "01_INBOX").mkdir(parents=True, exist_ok=True) + +def parse_log(source: Path, target: Path) -> dict[str, Path]: + logfile = source / ".quick_sorter_log.csv" + out: dict[str, Path] = {} + if not logfile.is_file(): + return out + with logfile.open("r", encoding="utf-8", newline="") as f: + for row in csv.reader(f): + if len(row) < 3: + continue + try: + src = Path(row[1]) + dst = Path(row[2]) + rel = src.relative_to(source) + except (ValueError, OSError): + continue + out[str(rel)] = dst + return out + +def target_hash_index(target: Path) -> dict[tuple[int, str], list[Path]]: + out: dict[tuple[int, str], list[Path]] = {} + for p in target.rglob("*"): + if not p.is_file(): + continue + rel = p.relative_to(target) + if rel.parts and rel.parts[0] == "01_INBOX": + continue + size = p.stat().st_size + digest = hash_file(p) + out.setdefault((size, digest), []).append(p) + return out + +def under(child: Path, parent: Path) -> bool: + try: + child.relative_to(parent) + return True + except ValueError: + return False + +def verify_target_against_live(target: Path, roots: list[str]): + additions: list[tuple[Path, Path]] = [] + collisions: list[Path] = [] + for root in roots: + base = target / root + if not base.exists(): + continue + for p in base.rglob("*"): + if not p.is_file(): + continue + rel = p.relative_to(target) + live = DATA_ROOT / rel + if not live.exists(): + additions.append((p, live)) + continue + if not live.is_file(): + collisions.append(rel) + continue + if p.stat().st_size != live.stat().st_size or hash_file(p) != hash_file(live): + collisions.append(rel) + return additions, collisions + +def show_dry_run(target: Path, roots: list[str]) -> None: + print("\n=== REAL IMPORT DRY RUN ===") + for root in roots: + base = target / root + if not base.exists(): + continue + cmd = [ + "rsync", "-aivn", + "--ignore-existing", + "--omit-dir-times", + f"{base}/", f"{DATA_ROOT / root}/", + ] + result = subprocess.run(cmd, text=True, capture_output=True, check=True) + lines = [line for line in result.stdout.splitlines() if line.startswith(">f+++++++++")] + if lines: + print(f"\n[{root}]") + for line in lines: + print(line) + +def append_history(message: str) -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + with HISTORY_FILE.open("a", encoding="utf-8") as f: + f.write(message.rstrip() + "\n") + +def clean_empty_dirs() -> None: + dirs = [p for p in INBOX.rglob("*") if p.is_dir() and ".stfolder" not in p.parts] + for d in sorted(dirs, key=lambda p: len(p.parts), reverse=True): + try: + d.rmdir() + except OSError: + pass + +def main() -> int: + parser = argparse.ArgumentParser( + prog="inbox-sort", + description="Safely stage, review and import new/changed files from /data/01_INBOX.", + ) + parser.add_argument( + "--review-deferred", + action="store_true", + help="include unchanged deferred files in this run", + ) + parser.add_argument( + "--status", + action="store_true", + help="show new/changed and deferred counts without changing anything", + ) + parser.add_argument( + "--defer-current", + action="store_true", + help="one-time: mark every current Inbox file as deferred without sorting", + ) + args = parser.parse_args() + + if not POLICY.is_file(): + raise SystemExit(f"STOP: sorter policy missing: {POLICY}") + + current = scan_inbox() + deferred = load_state() + + unchanged_deferred = { + rel for rel, info in current.items() + if rel in deferred + and deferred[rel].get("sha256") == info["sha256"] + and deferred[rel].get("size") == info["size"] + } + + if args.review_deferred: + selected = set(current) + else: + selected = set(current) - unchanged_deferred + + print("=== INBOX SORT ===") + print(f"Inbox files: {len(current)}") + print(f"New/changed for this run: {len(selected)}") + print(f"Deferred unchanged: {len(unchanged_deferred)}") + if args.review_deferred: + print("Mode: review deferred") + + if not STATE_FILE.exists(): + print("\nNote: no deferred state exists yet.") + print("This first run treats every current Inbox file as new.") + print("Anything you leave unresolved will become deferred.") + + if args.status: + return 0 + + if args.defer_current: + print("\nWARNING: this marks every current Inbox file as already reviewed/deferred.") + answer = input("Type DEFER CURRENT to continue: ").strip() + if answer != "DEFER CURRENT": + print("Aborted.") + return 0 + save_state(current) + print(f"Deferred baseline saved: {len(current)} file(s).") + return 0 + + if not selected: + print("\nNothing new or changed to sort.") + return 0 + + destinations = allowed_destinations() + roots = destination_roots(destinations) + + stamp = datetime.now().strftime("%Y-%m-%d_%H%M%S") + run_dir = RUNS_ROOT / f"{stamp}_{os.getpid()}" + source = run_dir / "SOURCE" + target = run_dir / "TARGET" + source.mkdir(parents=True) + target.mkdir(parents=True) + + manifest = {rel: current[rel] for rel in sorted(selected)} + (run_dir / "manifest.json").write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + print(f"\nStaging run: {run_dir}") + print("Copying new/changed Inbox files...") + for rel in sorted(selected): + copy_file_preserving(INBOX / rel, source / rel) + + stage_baseline(target, roots) + + print("\nOpening V18.") + print("Review the suggestions in the GUI.") + print("- Confirm + Apply files you want sorted.") + print("- Leave uncertain files unapplied, or route them to 01_INBOX.") + print("- Exact non-empty duplicates are resolved automatically by SHA-256.") + print("- Close the GUI when you are finished.\n") + + cmd = [ + sys.executable, str(SORTER), + str(source), + "--profile", "01_INBOX", + "--target-root", str(target), + "--endpoint", ENDPOINT, + "--model", MODEL, + "--no-fallback", + ] + result = subprocess.run(cmd, cwd=PROJECT) + if result.returncode != 0: + print(f"\nSTOP: sorter exited with code {result.returncode}.") + print(f"Staging retained: {run_dir}") + return 1 + + log_map = parse_log(source, target) + index = target_hash_index(target) + + unresolved: set[str] = set() + resolved: dict[str, Path] = {} + problems: list[str] = [] + + inbox_target = target / "01_INBOX" + + for rel, info in manifest.items(): + staged_source = source / rel + log_dest = log_map.get(rel) + + # Explicitly routed back to Inbox => defer. + if log_dest is not None and under(log_dest, inbox_target): + unresolved.add(rel) + continue + + key = (int(info["size"]), str(info["sha256"])) + matches = index.get(key, []) + + # If the source still exists but a non-empty exact canonical duplicate + # exists, resolve it automatically. Zero-byte files are never auto-deduped. + if staged_source.exists(): + if int(info["size"]) > 0 and matches: + resolved[rel] = matches[0] + else: + unresolved.add(rel) + continue + + # Applied/moved file: prefer its logged destination if it verifies. + evidence = None + if log_dest is not None and under(log_dest, target) and not under(log_dest, inbox_target): + if log_dest.is_file(): + if ( + log_dest.stat().st_size == int(info["size"]) + and hash_file(log_dest) == str(info["sha256"]) + ): + evidence = log_dest + + if evidence is None and matches: + evidence = matches[0] + + if evidence is None: + problems.append(f"{rel}: removed from staging SOURCE but no verified canonical copy found") + else: + resolved[rel] = evidence + + if problems: + print("\nSTOP: post-GUI accounting failed.") + for problem in problems: + print(" -", problem) + print(f"Staging retained: {run_dir}") + return 1 + + additions, collisions = verify_target_against_live(target, roots) + if collisions: + print("\nSTOP: staging TARGET differs from an existing live destination path:") + for rel in collisions: + print(" -", rel) + print("No live changes were made.") + print(f"Staging retained: {run_dir}") + return 1 + + addition_roots = sorted({p.relative_to(target).parts[0] for p, _ in additions}) + affected = list(addition_roots) + if resolved: + affected = sorted(set(affected) | {"01_INBOX"}) + + print("\n=== REVIEW RESULT ===") + print(f"Resolved from Inbox: {len(resolved)}") + print(f"Deferred after this run: {len(unresolved)}") + print(f"New destination files: {len(additions)}") + + show_dry_run(target, addition_roots) + + # If nothing will change live, just record the deferred decisions. + if not resolved and not additions: + latest = scan_inbox() + new_state: dict[str, dict[str, object]] = {} + for rel, info in latest.items(): + old = deferred.get(rel) + if old and old.get("sha256") == info["sha256"] and old.get("size") == info["size"]: + new_state[rel] = info + elif rel in unresolved and rel in manifest: + if info == manifest[rel]: + new_state[rel] = info + save_state(new_state) + shutil.rmtree(run_dir) + print("\nNo live file changes were needed.") + print(f"Deferred state updated: {len(new_state)} file(s).") + return 0 + + print("\nAffected Syncthing folders:") + for name in affected: + print(" -", name) + + answer = input( + "\nPause those folders manually in Syncthing now.\n" + "Type PAUSED when they are paused, or press Enter to abort: " + ).strip() + if answer != "PAUSED": + print("\nAborted. No live changes made.") + print(f"Staging retained: {run_dir}") + return 0 + + confirm = input( + "\nApply the verified additions and remove resolved originals from 01_INBOX? [y/N]: " + ).strip().lower() + if confirm != "y": + print("\nAborted. Resume the folders you paused.") + print(f"Staging retained: {run_dir}") + return 0 + + # Pre-flight check: every resolved Inbox original must still be unchanged. + live_now = scan_inbox() + changed = [] + for rel, info in manifest.items(): + if rel not in resolved: + continue + if rel not in live_now or live_now[rel] != info: + changed.append(rel) + if changed: + print("\nSTOP: resolved Inbox file(s) changed or disappeared during the run:") + for rel in changed: + print(" -", rel) + print("No live import/deletion was performed.") + print("Resume Syncthing and rerun inbox-sort.") + print(f"Staging retained: {run_dir}") + return 1 + + # Additive imports. + for root in addition_roots: + subprocess.run( + [ + "rsync", "-aiv", + "--ignore-existing", + "--omit-dir-times", + f"{target / root}/", f"{DATA_ROOT / root}/", + ], + check=True, + ) + + # Verify every new destination path exactly. + import_failures = [] + for staged_file, live_file in additions: + if not live_file.is_file(): + import_failures.append(f"missing after import: {live_file}") + continue + if ( + staged_file.stat().st_size != live_file.stat().st_size + or hash_file(staged_file) != hash_file(live_file) + ): + import_failures.append(f"content mismatch after import: {live_file}") + + # Verify every resolved Inbox file has exact canonical evidence in live data. + for rel, evidence in resolved.items(): + evidence_rel = evidence.relative_to(target) + live_evidence = DATA_ROOT / evidence_rel + info = manifest[rel] + if not live_evidence.is_file(): + import_failures.append(f"{rel}: verified destination missing: {live_evidence}") + continue + if ( + live_evidence.stat().st_size != int(info["size"]) + or hash_file(live_evidence) != str(info["sha256"]) + ): + import_failures.append(f"{rel}: verified destination mismatch: {live_evidence}") + + if import_failures: + print("\nSTOP: post-import verification failed.") + for item in import_failures: + print(" -", item) + print("Inbox originals were NOT deleted.") + print("Keep Syncthing paused and inspect this run:") + print(run_dir) + return 1 + + # Re-check all resolved originals immediately before deletion. + live_now = scan_inbox() + changed = [ + rel for rel, info in manifest.items() + if rel in resolved and (rel not in live_now or live_now[rel] != info) + ] + if changed: + print("\nSTOP: Inbox changed after import; originals were NOT deleted:") + for rel in changed: + print(" -", rel) + print("Destination imports are verified and additive.") + print("Keep Syncthing paused and inspect before continuing.") + print(run_dir) + return 1 + + for rel in sorted(resolved): + (INBOX / rel).unlink() + + clean_empty_dirs() + + # Update deferred state. Previously deferred unchanged files stay deferred; + # unresolved files from this run are added. New files arriving mid-run are not. + latest = scan_inbox() + new_state: dict[str, dict[str, object]] = {} + for rel, info in latest.items(): + old = deferred.get(rel) + if old and old.get("sha256") == info["sha256"] and old.get("size") == info["size"]: + new_state[rel] = info + elif rel in unresolved and rel in manifest and info == manifest[rel]: + new_state[rel] = info + + save_state(new_state) + + append_history( + f"{datetime.now().astimezone().isoformat(timespec='seconds')} " + f"resolved={len(resolved)} additions={len(additions)} " + f"deferred={len(new_state)} affected={','.join(affected)}" + ) + + print("\n=== LOCAL RESULT CLEAN ===") + print(f"Resolved Inbox files removed: {len(resolved)}") + print(f"Verified destination additions: {len(additions)}") + print(f"Deferred unchanged for future runs: {len(new_state)}") + print("\nResume these Syncthing folders:") + for name in affected: + print(" -", name) + + input("\nWait until all of them show Up to Date, then press Enter to finish: ") + + shutil.rmtree(run_dir) + try: + RUNS_ROOT.rmdir() + except OSError: + pass + + print("\nDone. Daily use is simply: inbox-sort") + return 0 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/README_V1_4.md b/README_V1_4.md new file mode 100644 index 0000000..2c50f58 --- /dev/null +++ b/README_V1_4.md @@ -0,0 +1,42 @@ +# Quick Sorter v1.4 – focused content-reading upgrade + +Changes from v1.3: + +- Document extraction failures are no longer silent. The GUI shows a **Content** status such as `docx:1834`, `missing:python-docx`, or `pdf:no-text-or-scan`. +- DOCX extraction reads document metadata, normal paragraphs, **tables**, headers, and footers. This matters for Ablaufpläne and forms whose useful text often lives in tables. +- PDF: metadata + first 3 text pages. +- XLSX: sheet names + first rows of first 2 sheets. +- PPTX support via `python-pptx`. +- ODT support via `odfpy`. +- The LLM now extracts structured semantic fields: topic, subtopic, document type, detail, version. Python builds the final filename deterministically from those fields. +- Generic filenames with no readable content are capped below auto-confirm confidence instead of guessed confidently. + +## Recommended packages inside your pyenv virtualenv + +```bash +python -m pip install python-docx pypdf openpyxl python-pptx odfpy pillow +``` + +No `--break-system-packages` is needed inside a proper virtualenv. + +Check what this exact Python environment can read: + +```bash +python quick_sorter.py --check-deps +``` + +(`dummy` is ignored when `--check-deps` is used, but the positional argument is still required by argparse in v1.4.) + +## Best first test + +Start the app against the test copy and select only `2026-08-21_Ablaufplan.docx`. +After **Analyse selected**, inspect the new `Content` and `Semantic` columns. +If the content reader works and the document actually contains the event name, you should see something like: + +- Content: `docx:...` +- Semantic: `cafe-bach / bach-voices / ablaufplan` +- Destination: `Cafe_Bach/Veranstaltungen` +- Name: `2026-08-21_cafe-bach_bach-voices_ablaufplan.docx` + +If Content says `missing:python-docx`, install `python-docx` in the active virtualenv. +If Content says `docx:no-text`, the file may use unsupported drawing/textbox content; that is a separate extraction case to add only if needed. diff --git a/README_V1_5.md b/README_V1_5.md new file mode 100644 index 0000000..ccf7cf6 --- /dev/null +++ b/README_V1_5.md @@ -0,0 +1,51 @@ +# Quick Sorter v1.5 — automatic collision resolution + +V1.5 removes the manual collision-cleanup step from v1.4. + +## Collision rule + +When two confirmed files want the same target name, or the target already exists: + +1. The sorter compares the **complete file contents with SHA-256**. +2. If they are byte-identical: + - the already-existing / first planned copy is kept; + - the redundant source is marked `EXACT → remove duplicate`; + - it is deleted **only when you explicitly click Apply confirmed**. +3. If the bytes differ: + - neither file is overwritten or deleted; + - the incoming file is renamed automatically with a visible suffix such as + `_variant-2`, `_variant-3`, etc. + +Example: + +```text +2026-09-01_cafe-bach_abrechnung.pdf +2026-09-01_cafe-bach_abrechnung_variant-2.pdf +``` + +## GUI + +A new `Collision` column shows the resolution directly in the main table: + +```text +EXACT → remove duplicate +DIFF → 2026-09-01_..._variant-2.pdf +``` + +You no longer need to deselect collision rows before Apply. + +`Apply confirmed` gives one summary before touching disk: + +- normal moves/renames +- different-content collisions kept as variants +- exact SHA-256 duplicates that will be removed +- unchanged files + +## Important safety boundary + +`EXACT` means **byte-for-byte identical** after a full-file SHA-256 comparison. +A PDF/DOCX that looks the same but has different embedded metadata is treated as +`DIFF` and kept as a variant. This is intentionally conservative. + +Existing files are never overwritten. +Every Apply is logged in `.quick_sorter_log.csv`. diff --git a/README_V2.md b/README_V2.md new file mode 100644 index 0000000..d66df76 --- /dev/null +++ b/README_V2.md @@ -0,0 +1,106 @@ +# Fast Sorter V2.0 — Inbox Mode + +V2.0 baut auf V1.5 auf und ergänzt einen echten Top-Level-Inbox-Workflow. + +## Struktur + +```text +/ +├── 01_INBOX/ +├── 02_DEV/ +├── 03_PRIVAT/ +├── 04_ARBEIT/ +├── 05_VAULT/ +└── 99_ARCHIV/ +``` + +`01_INBOX` ist damit **kein Unterordner von 03_PRIVAT**, sondern die gemeinsame Eingangsstelle. + +## Start + +Wenn der Ordner wirklich `01_INBOX` heißt, erkennt V2 den Modus automatisch: + +```bash +python quick_sorter.py "/pfad/zum/HAUPTORDNER/01_INBOX" +``` + +Alternativ explizit: + +```bash +python quick_sorter.py "/pfad/zum/HAUPTORDNER/01_INBOX" --profile 01_INBOX +``` + +Bei einem Testordner an anderer Stelle kann der Ziel-Hauptordner angegeben werden: + +```bash +python quick_sorter.py "/pfad/zu/INBOX_TEST" \ + --profile 01_INBOX \ + --target-root "/pfad/zum/HAUPTORDNER" +``` + +## Was V2.0 neu kann + +### Cross-root sorting + +Eine Datei aus `01_INBOX` kann z. B. nach + +```text +04_ARBEIT/Cafe_Bach/Verwaltung +03_PRIVAT/Gesundheit +05_VAULT/Wissen/IT +``` + +verschoben werden. Ziele liegen nicht mehr innerhalb der Inbox. + +### Archive zuerst erkennen + +Erkannt werden u. a.: + +- `.zip` +- `.7z` +- `.rar` +- `.tar`, `.tar.gz`, `.tar.xz`, `.tar.bz2` +- `.tgz`, `.txz` +- `.gz`, `.bz2`, `.xz` + +Sie erhalten den Status `archive` und werden nicht automatisch an die Dokumentanalyse geschickt. + +Vereinbarter Workflow: + +```text +Archiv erkennen +→ entpacken +→ enthaltene Dateien normal analysieren/umbenennen/sortieren +→ bei Bedarf sauber neu packen +→ Archiv nur behalten, wenn das Paket selbst einen Zweck hat +``` + +### Rescan Inbox + +Nach manuellem Entpacken: + +1. Dateien in `01_INBOX` entpacken. +2. In der App `Rescan Inbox` klicken. +3. Neue Dateien erscheinen sofort. + +Bereits analysierte unveränderte Dateien behalten während dieser laufenden Session ihren Zustand. + +### Apply räumt die Inbox wirklich auf + +Nach `Apply confirmed` werden bestätigte Dateien in ihre Zielbereiche verschoben. Danach scannt die App die Inbox neu. Erfolgreich verschobene Dateien verschwinden aus der Ansicht; übrig bleiben nur noch nicht bearbeitete, Review- oder Archivfälle. + +## Sicherheit + +Wie V1.5: + +- keine Änderung während Scan/LLM-Analyse +- nur bestätigte Zeilen werden angewendet +- kein Überschreiben vorhandener Dateien +- Kollisionen werden mit vollständigem SHA-256 geprüft +- exakt identische Dubletten dürfen entfernt werden +- unterschiedliche Dateien bleiben als `_variant-N` erhalten +- Änderungen werden in `.quick_sorter_log.csv` protokolliert + +## Wichtig vor dem echten Inbox-Durchlauf + +Die Technik für die Inbox steht. Die Zielstruktur für `04_ARBEIT` ist bereits gut getestet. Die aktuell eingetragene Struktur für `03_PRIVAT` und `05_VAULT` ist noch die bisherige Arbeitsfassung. Diese Ordner sollten vor einem großen echten Inbox-Apply gemeinsam überprüft und anschließend nur in `sorter_policy.json` angepasst werden — dafür muss der Python-Code nicht erneut umgebaut werden. diff --git a/__pycache__/2026-09-15_quick-sorter_dedupe-hardening_v17.cpython-313.pyc b/__pycache__/2026-09-15_quick-sorter_dedupe-hardening_v17.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a147ffccb9b301971742ad3bd478fdb56c8902b9 GIT binary patch literal 110448 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�S=3tBQ32mO}3prc~(>E(x(zv->o=greo_oCNglY;RH!T980T0t;5Ets5g`RKyYpgsPwX~7iC%nzof1=DhZ z8JSm$uM`K?e7O5Muw4^IsEk{fq zo(LB2gB@cAo;@227pF1yOR*PY=RE;a=EuWYO>EKKlUnrhLf7Y?p3rLS8J}okFd{i< z*!9wDrGfnIzWnVg)BgP3{+vB?hd+!f#jvN9EEo7wE5QLv&b^aaxYX}0+~!T+{zi?q{}wadB*FhOwaC4lEPd zdWLbJE7`^Z%hioILnIQ0q9U>jj#E+UXTXlm!2H<(R`qZNGpZg0Lwb6c zv#NzHymSJ@e0Pxo?>Bu956+OhT|FdID(Vyi3)BmJ&fum z$X1AKOU%u1mgW$%Pt16xsr}HZoOFo1<$CkjPzB>ti+Yt6XfRYV5p7A-$;4YxDYKv4 zXj5iV%S1kk8Nu`MJ2Kco2P&wJljLn_5U>vux*FW73y0hap5KCh(R1eb%WzH4JXete)kbq_YrtG zukJp4sF{}!RZ+1ssFpF`$6y7|_~95a-gPRtXX*^1LY;+Kl_-%A?bYke4oA6Tb4~w| z?zW>1J&rvz2k4)BymEXD@&u`L2rMRt(02xZ>fwS45O}nL81S&W8itoDF7bfKfeje^ zRC$I+(Jf>V%fldoknzAPmpG7(qr!P|ofnqnj2pR0vQfpGRomateW1CGngy5Xym0q; zSfkKmN5j~V2R^>g*p9dV!#HM37KGqd_9t24Snu?)f=f^^B#1RdGdvEr0V6ZRxaXpk zh#s+rXT}jyi=+{#mOUUZUgYG8bz5xow*I3Hu0zyjVR*(vUh^HNC#RkmA08SRu7-q! zj;ui#s!b0Mj?$;&j*%HGE>sM6CNpPfAJLD7HjFOpr>S+=WHat@+(^?Q4m0z7hVS4J z48!O=4>x0@Xde1{#985humta>PEJ2T!X`ACXlViUjlpFhslu|&i7_IkgX5@s28Sw* z2X|jU@)fz=jwjq$!EAF6;Wc?w^GuC1cWQ1v5-46;4AX9pIDvI_n83!?wxbw~^)`Dj zgP z1(iNN18;03{Gu%h!4s20QO~4-q-}`-r#&m~v@x`Ve90js#tgTW@!+(tvE0KSwhhHh?_7xsOaD#1+OwY}7$f5IBr(RyrC^Vt^cM zSZF~~pV$T)gywg|UauhYOHoEfWmYn=q0|$C=i=y6Arn)8sqlz5NRH(cphas2J?9XO zgAi&(3w1_Z@K|e1Fol65+Zwd){_;FCaIi-^g0D82(Z!Y|+;&JnO)AqtObro6iPcme z;kyE}EAI~v0Lkz~*l&s>q5PEhA&C#e7e6gIUI=z0tU%L8zb&R0t0}L*r4Q_Z&;|L-0~V@nb_(MgJCQO+v*V&K8)~w!;{qv=!yHA?dGuE zJvls0^T>8O32AA6D?6QSd=5+SkdI6xe2#=Y47w{1rQM+d)~dz!9TrC5@WjLW#-?J3 zAj;XXX_8pOQ6Y^6weloXg>Wl_PTnGZo=k-m0=OiD8`UAfB*+sf=M)#@Fgz%ZkXJ$Ievge4)}qy znC(@0De&gDIRVH#!{aA$_KV98!8xQJNNZnS=n71xvmIy*g-j%X(AL9;7>7kuE5!K_ z_G3js43GLIfD`e$m1$+?jHBVeVOI};1=sEpPcwLB!8uL(fAqjDtsCyM5nWMhpS)5z z?kBgKZ5gV&?IctjKxzUGe2yRK=Q`Lk915Kc4nUx$%L}<#XNuamG4$+6Gv|}7B&I;49 z2rs3=!IU%_CgCPj7#VMw02%*+!2zdIF9)kaXAvh45Cr2>qqpZ6`J=9aIRywx^1ZbGEuA(E=49^zFmOMFE zk}GE*lVMorpa(dx=gc-~{&;D8-Uwa}Gn8u@<1!2$2bs zl_T!+o=O@x_r%0FTQ%f*)-!yLs*u(pBW>!jM?iqE36ZFTF%-lVh6cvnGbafw6B;l2 z!6sx(T8q#$8iLq@mC7ijdL`s6U?O?HidcS}kdjsoumM_=_b(uE(!tX?cLuun*!0pe zU^p5+kmFuC1_+Asoy_%nCa1&og!mfeHBm!dgy;~g2I$9b2hf)`Jqu_=k9pH2yi%mW zPzSVbsDz&Iy$={m3z7zqmC9Ks3`r4Yrbpo!81>OxQPGERDWZ|kANCm9PQcq~c*QxT z9S@2(?AW7U(!S+DP(H5^HHzbUqD~QQFswR){3sDc!B0B?m4u{@AtWyYY+@@3b*dIg zOV&^fH2N=&ElrJ#0umWT-B_+Ay_Rx%>D6GeE zW>CmZ0Y-R0eVqXE9*`cxj#8=;Bs;m_aO5}0YcapVk$*&5jr_;kugX#C=z#<*^UEq6 zO^4b1h)6U*40Fm{O)i+Z0|wSBv37YORXG}lhS;0%Z75EX2sEWrRvuk8_><&L8rTIt zr`SZ%nt2$GnSpxH2)f4!G{Gkcljgzmvv7upD~>VKMw1a^5Zs*&-NzhtI%&Ltv4sVq*5NJ^1VNTl;n1kYf6Af_4{Rzi-TdWhj2!xCm2fNB2iV%V7k zA&3Cs7M@TSlqO7q0E}@Z>JAbL5s6q3pu$R-0j)^zLs87+P=yU37A*)<0^0X`u0gxj z@27QV4tUhSNX3px3ze`yEGSUCgfO6FI?LB1B(l?NHVhoKVQ7sA>}bu=jz)9>XlDXo z^b-#Q*1lp@GLLC!>?Gqnpb>P+v2}fx7#`9~8YYeMeJV@wk0;5<> z0%$~3D?8VrIB@vhT}4P4Et(B#r>Yu>8w50VYT)b`m_G;7Z6?sWrD`(=$$R%~uG+bc!)yXH%Dl2Yi)HB{La&nW2r#?q zaaNpjY}raHlE{{mBHX5Bf{F;wt1_?Cr~vPrOA?_m#I!#iWbE-E1FuOab8$5vH@NE~ z@W~bS3lTuVenpK$mzidRvZ65qYczB?q`@);%?as!1G;!KN#epdWv-M~ujxJPy zdqx3%8B=SwW{~2*d%baJcwh#OvB%Cpe)mxY zhC?7R7#{o>!Ym<356D*l9{g85a-3uLf`C8lC6r9wru)a(bJ%@SznIu1T;(K`*3Vw_ zv*BY)te@#@c!WC4>Yt)y(SU`bgvDRlb#d1-djggmpC!j{aa?E$M#s(Q^Lu8Z{{| zY4uBGN|HnQH?3bJCOed+#)=fNRtGsETI|r|Lc?8_|4#CKQ9R(rr8r*M>@fn@A5hk?d$7MCzz^Rj-i@zvy|v)5;@&8}43 z()vpd`12b5X-)pPX0NIF^EGSo3$tEJKEi4l6Mv2`_4s?w14TH^VpT)5=B;RZLx%pX zbSTP%`jx0))6P_C6AMy>JLJc`G;i)O|8FT|I(lfl5Zo~dc z^NY9gY$~tuRF)5~d#WPgc4|(Lav4hoXtGtAVMt2)Lo7MzI-U27?c_AAqmb#&a8JRFjG2JA7 zj!2UBNH+|P6-yAWLbhWk7<*DjP|kt>x~}fg=W0%B z%Tl=3ChT8zV?*5498(f#MiJcGNd@jgR(6DlWDQpoU}F3_yqq853NTE!4k~_GQ$TQ=g^{M(ImSC zdVpq(GByKZ!Skwx7BqxuXv}V6Cnl)*CPje?Ek%v2v^ayt`H^2#ICeHAaITSS`07D!gEr`c~ zVsKM*y6RI&aR$Bl=ueE=#{ib2%?CoY>MYqq^4YSIOtt*J=Tjw*RUTD7)pAyU|TuJdk z-!`cZ6$VorAIBKuqAnbS*BeXxrK*cn0kgwrb_7lKHL}h8_VLANzs(V_mH2EW%edVf zjL-EVx~07q3Y&LhG?u)(P-KjPheE5yY`>IqF((j{>x;=<(tGLewwp;X@3}Pl?b*e` zt7TWpUfRSvzx=R2W1By2`)rhoAvpDhQ)43dfLd|*j7Z5>B5kSKiU458Mj()Z+WZjZ>IxwJ-)i0wb(RodW%2y zpw|SIm-zYO#rTD?XZle?V$y|{x!Qko2vuF^{45boct2*_J!Z7^!!M ziE|l&CKIvPV2EA^)i8)MNa2Gq0-gXHw5o?|YSD4?PcV z58*3_J6f4L%ls2mE+-)Nhu=~eu+;c0HGa!Bq8}_2O&msRlr zV^D`#>U}6IhFr8Odf@kP;>uJ9ya&rvhx7$-4Yj3*$&O6&(lht|;f71^C~1C9S~hHx zHLNw(f0fqQdf!)V4Vq6hkjpw5eb@?3M+?ltj4m7W9S!gv7e)94TB&RjIZTOyGr9Ay zjx@ao>PNy|JGp@wI$(O^)E?3*c?q|1XcX5by$?&K%DajI?qx~nw7Wp*Vno#_9QWA%%5^CVL-lktj1 zbeI;j{2s9z0i`Hm9&EH$ov|(*W;TxLRomGt$GH+Pi>3`{O4peoS^7Z7Sf*jY8^8(% zg`>a)#Q+kTzlZ|BlZbdjN1|8<$$pqz7wTBB`r+>o^LBiOw8|LUZwxwAjK>S5 zB~T5dfB7)+PN6C#9$}mnN1Ri}WnoTUpl!)SOCV05hC?zmYBVV*a2tELSDX?m%v_KF zK0J2@AS+oa>>8#k@C10WJGU|6mRpt{NqqV$Go%1gxx{3Z+XaD)dqfx}aa*`l2@L2k z=@c+<2vs0J81tCPi8dR)u==T66m1=x?RC&qV+9;^wNa-aOm%R@RgC1=@Do+$ zE(ML4%>&aqvU?6U)3(BN3g_z`_wHauf7@--qU_-IeG;j_nCxVP@MU!tJwhTfx z@ur`x2LW2lAcUo7ggk{3Sa9Heab%c}Ao!7@;RwMq1^)0yRxC6br-D(NxHQ9B!E11! zt#G*@zGmNg$dCv4S!Ck)nuQ1`)@ry|IESybfLtC64nHy-yqm9yNem_z1d_{q$z}fJ zO&7ZUAueT3AB<0$H^R;;A!RN)n3gqX16MJoXf35+sS(sqN*&bFW1+AOHtwB;5^~Y@q7H#p`^Y0pB<5SkM%9aam zCi}DY%{Q;*6)w51AG>yJ`5QL}{6!7^yaR!}R$pGLKd*hh{Z4A$^}cI;{?wXae#w<+ zXnp7I*9LR9c%kK9uyShU5wI+?8?|@!nsmpK>Af^^g#fZ7$8U9frqS6twLyE1&tCGG zp6>6&=PV6*s#e4P&DDK}im}8RHQVW-yD_#E7#<^Cen16BKT5|eg>GOSaEy4WF-n=a+4=T{*OzeN*Sp+T~B%9Y{Oi zOFIB=F0A}4sf&C3mQt7`H)rR4cX=UUbZidReOZkB*^e5=V{(1zCU z_E{Zw5{j2*mPTMv@YM3;%>%*2Ja0n&Qr=SM(mqT`d@be!_7K!h==vw7$vs8kzZ}tN z?5UrFxV5K)?~FB-1=9AV*5B*=H9n0g`t_$(nzX!6H2V0II~mp9%?G^IjkorC)4EY+ za@Jz&YEsc%JzoC$(^6J~33HMS1HG4e=emM%DZaRTERxhLapwH?3Yx~ArQYR<<^G!; zw`08rANCgXdQ*Dev-f^zP5SlSc$Dxjo^N9deJi)ULi=57YmM$t_TcWViiW7R6y1-u zYVqSoH5R(x+YpBzKaRDwZPxubqbVIfZr7OG5{W$eB}OZS!RezUo~%=m77L3^R`C)v@+`IAB;p8SO6Tqj&3v;w;o;t~Lt7d4->+(d9y*G^~*jw=#3rtp2gb9-FoI;R>^G;<}aX zd&E=D?Y{mm%<613e&TImNP8lreN-`)M*uo@#_dNYZb$w%sEm7xCe9#Z@rB}u@7TmR zd`zemGC(3ZgwuxpfUbWB-@r3_E~QVMTDOI%dKzLzESIo+dpRzU(BCc%3m6S`u#O%k|IIFAgmyEsA;F-;F^D_t+`;R#9@pF3nrJ z8ZsL*^lzu>a9zi7A|j6MrT+)36nhC?LyjUY*=CsG!zaXcAk*RQ!NMYhO5}sIdzjY= zkOPW15*`j^0sys$3COli`c_)cE?vK_6PSqwD|Ql5)xt%WQXViT^sShS8T@~ySv`uV z;&?yYN&XOcPuOe5aguwm1Y+GdF7L|DDzsYRRYj?`+K4@ankh*wQrlTF)FIPbd1t{` zRBa@pouy`#$&Df1Gj!f&E*_$8+5?e~8jmtgz(mq)vSqmSrDfZ`cGPyQ8<3?Ix zUu7>~oq!#{QXaZ4g@FhQ=3s)@PhS6F zVn1Uypjl+PudEYnj}es=ksh?$<(6y)q)Emet&jK^KS`oBetcqjhFJfZzOed!uWzkiKD5S?KF1XCa{+Pq&X zQTgl4WcUVZb!Hvbu7}(Q^__s?bWV)mgmU2jN!>{eSU}3okk=YhQUR2AXq?>aT-WuQ zjC*8vF+rs8PrwxrOM=n)vvJILgXn6mW1>*~iY5ya!;bv^6-qzfC(xMlfI?sbf{3Qe ze!8$ALMBNIo>{gFo6sj=6qniP$*D@x)txm>z#au!R`tS7`f3;Dpj9vIz}?76mi}>H zD8@50LJE$BFHS6X`x7^Vb`Qku@Wt)$ z$JG%sr;kZp15KB5HRDRg(xH_+e|oJyaoZe41F_mKKfQn`VnxBklt5yUFR=)&4ii(* zCMTT1GzBwqo{d{ef~&);<}2o<9m}9ztNlrv14-L`N!$HNJ7FCj1E-0L+ppGNsb4w; z=Q)|%{Hfamsk?otyZxzq@!m@GVid)MTHbf_fZwstpS3@b)#A&7Q<>H|{VJR%CrgcM zT^>L@RDbeTxI==3)}LID0?cG+UwpOvO8L^U6`en$#-CCfNU8Uw)RVQn{#nafde-96 ztH-Y#Uyfc$@MqWh)3*iEclpwH`P28{eXP=e`W3}Vxtql6J zP#IbS0@!MwucrVbN!N3)<*sA~^7i=h_V8ipd4Atq6XQ;15r~ zemb!Ipl|!Z!1gxZ_O{!{yvH9693S@`ANO~Td(U_v&v;zhsQZ}zVZXZguP)tP3Waw} z#~vQ%50C4@>bq;!SmQ43ySQ(01XC6Ef_mi>7?ovDWJI&k?>NoHr3?muNEbZ#~Nr!87sXLHFp)d^@ZgW)*?-OCeT3WG08 z7d(9Q(U7ITwZYli-`LRD+PsctCTy4C`4p0@&-A5KvsMxLDRd_=bL={v)G%5_eT#V} zH%WPwziCx$>F2ipl4kyd>2j&13SL}^YMhaO@kjzoMt?!sjcP9AzmQH zRHuIR(z&YBOqjy3bdwbFMe(Qz8VDx~s0m}Yj2}t0qfIg$4gY*Ncj#OKeS`m`v(rHs zd#dtP;ZMO!i3b^*x%?{y)w!!T?xI5exO>?R#7dT-y#P&SdUyhOiAPk8{P&{$} zA&cnZ5eIa_z@Ri*^cRbl`_rSNZLfBtxIhY92j2B`U1J)JouYw|t`D@Cv?rM==>>MO z2#Iv=WP3*47$@eOCCr<-=uPi0=#(M;%I9#Wmv_DRTJdt`YW_ARa_1M6A^H!t3V%WO z?CAYA9&??dXwNhg_k=9CVcPN-Flbt#@1(30#j6*7pFV9yE1|}8hIH&hF{8uhhTx^y zGmW#f0Ix$j>~YdagW?cP49-!{*hok_3g2_1Gn1#G%fzw^zlX}#ouvCk#&=j(g_n`( z2h_W_P&9f6ZDw=qrSyyG&tzUWux7T+4LwtapfR?zOa0KAapL?Wtr;8Bni1$w^O}n( z(v%GZIe`M978{7f(F%DCz{Cx8VxHSS>YNi&~EI zrdIz}$GqNWNaA`m7UcikT&`C0BV!pcIWo+RrR&vd2xu|jcLz5wg4G7VfN_jW(xz0? z(M2E@?brTB4JyY0of&B&Qh2b zHqc-Z4CrV7sfSFcWEtJS`RUbPZp4dvrgQ>V)v_y8V5V9>{9?&@;y%uYWr4$roo|kOK+rWObT5c=7viBTNPSd z*QuM+$;uY=-{Z(2b#w5grT?JTUlq;HU-UgPNlP3ao*x*01lnH;9j+QK8mN zCt~)9Y8`rbTvm-;q-cw+`!YKGODKEmoiTLwZKn9MbTG2C0Z&m@&z4ShoZFZ{Qe;w7 z^^itEs(+{m+ta9KBoUd@Z3nNxCbg!wTRP{Zl1L}B6GR}me=^@q3HAv*LhrQg(d}WF zjMG|)l0143KjRH*ocP{hsRm#er#Xg!5s7BXTH4m*=p)p#;H6ilf2Fn|;=lv&4mBR5 z=->||A!u&XxZF$ji_SonP!}!Mh~$iApp8~Q9KMzN?zH0<#*C*v|WvuD$S~tT@`kEQWpEP0KMNDZ4;!v)P$Q zDl|2^@SbDdlhpgs`CkaX>wa)wpr$|IT-O&l1nC3%lDuJGEUJBx51O#Pq^R~KRlP4z z-8zKcsLpBcSsFg%fS)_UlU;xhILhR;fujsuS$giYsFzM^Kb`*#3i;we_rLJmbSbGEysFytXnI}2r^|Q^9J8xSFQU_p4CZnuBPiv>8r9dcLD(xH$r+a9V5pO zs6SHV#~~`(+=&Mm-ZK?;GRgy9Ps&GbrkylKYCeS2Lp`YS3`}S5JJMk<4nxW?-JWvK zFj-1Z50?YMC!c^!dx%pAW*`f|)?w5K%zzoR!xnUGgxh3RjCpf%hL9hpUQZy8c~3{f(}z}*47 zW)v2^Ft~?pZxz`ngHtHhIyiNP3dN_eXQX~H^%iNw*jw)6iSV6aXt*%zUbw|>&i6f5h$1us%2t+bDjp>nFjtOny0#Q#mrI555P#PZ_y zi3Qv@tGpW6ZBLRZFFXmHmyA;>(j3s&p<%RhxJP&eJA%6oU8cK9jDhef?(W60Euze@ z@GU%@-Q3LFmC&5R+8uZ*8EOwIqX}GiVhWfiQ>mhTAp9Xdo5@76&AkncJ&wQs?dN3c zeIjhxnww3R78;6!wW|E0-XZ)EeNQYM<$HOAk&!!NP$__q01CK{0?LvZ3l+Z9hC{LN zAu4@D6aE}^1qpMH17-}& zJx!C89!z8ZvU7s?2Vb2DNgv0V%A+nc-%Zja<^&RodpQ8L7nwt5MZTn>r9niY`M|ab4pA-EOS>=bzO?t^-lh2K>DSV)XI{(vv*?@J zz~+O#%?C-tN@svJPqJevp46$xO=#@gq~Dai6z?fj=5rU%UHZnwZ#?tOKwPmeu6Q{QY6!uCqIvzQ-4S%;&+A{X zfe%S6Zg1XJs1XG7%H|tiXb)x|#w#?m4nP2%;mYia)EaoK#_UCS%Yg~n@7w4=8v+BTL?*#wcRzVJ1#{)UreL34#p7iH5 z`ZJp5Nx2P-O6c3czq=K!b^HSJXvFhh zBTV;~W`P#imz9Erodfbw95jE%Cb)Ig?hmVEPMfeW9tTA<_uGyG=@w-X{*8DY^9fEl9 zS6Iy7S-?$-NNEgjeV3QQ-}Y>0To;f$#wg zykm>I^zg-p=Z{|QU+8DnY#)|Y&uu541@qr{IdSpmOPPySpQUK!=nqf4egYazmiT}r z(`U(CoL)S+)VAzicCMtXn7mnaeoMXAP|q!mFt>6-U3{(`Xe7<`q#i^Q?s@7>l*m^w z_*Iz!VWho0>eA>F`96RHSefNsIn$aG&$kl0}@k$opbw8Rk6l8aQnQ^%sc7-nB+f#Mlf62onzWyDWTV6NTFaMlCo zM?kKn)xu`B!qEjqe|R(k!c|JQaDpR|0R2-Z%g8wmH19?4LX`|11^#80ssNf5fnUNM z3O{JUMw>$4yLb@1D7J>UOqpQ-#1Iqy2Q5KDED*-Lh91zu44EL09Tv_4RiY&d2`-S? zkdZkU^bio>r#C;hGk|_h4#|C*5|k5M_>U;DnO5y3e8f1>F=jB0%}|lcL}-k-;yFXm zo_5)~V13!Sl=RXuzr6&C*cQ9blErca;>vw-<-yd8qakS+6~B-B)pm^F4F32t3P|yu6^% zKokCF;B5sTt=7!>L*Qyqnrlu7Xm@_{@XBp4y{_4%`h`E zd5ecVlXXqQp6R>63y4fozhJNvRV9}MzSv)}(Ny_jGN$(h#+NEU4=B!E40>Qf@Cu!p zG%u`v*<1-4h2ktxP=&+!_)>+druL=OS_*(MaAe&Gw@YQ-H^S6D8sY!0MAhnT!_g*! z+!?gx%wM{!1JaDZ^pNHMz-7T*3`{o7;3Z{5j+}-f(jf>#?}4zE%HvLanZth$cM~!9 ze|H}c{9TN7j9T2b`*Iep>vP+EeFivXt4i#1?{i^olgFR6ZRBgrSBzATBP=r6R~p0G z-*^lYofc({tsCog{rTUrW8@klcgI0pXQ#+<(*m8by4wNFj=-xUH|-K8z*0akR`4!l zq8O$$BpQ+&c#D)ec|ceE^-Ip}IXjBZ7Ue*~`4z^3DRWfrg}g@e?JAv6A`_&V$%@eg z;*(UELFEw@&e=Y2`3Y)KWZu*RaI3~%OtUk9OSN%yN|`FNk=Oaqrodx4CyV6 z(J|jg^C9f!1hbYVh*PslM2$?Vf{4Q3s7(|?QhCJRzIiVO?9?!7W zd)ZBGEiFn%*yKPyDB=|}1Tt6-;*Q)i^WIV>_{boLz_|^;#-I>K7lueMl%kP-vfyeA z&0v`B9|OK|)Pqn0jubPj!^X|hb`l!$f72^wkk2p?RyJ!op(Dg68RF~=hN4)=V<0$a zL;VBiL(yWZAt;|Y1FXpdTv0?)II6;V41%AUMi(-l_mC|dkdh>gH5M4MBmY7xw+FMt zk}P~N6aFIz&xlXDoVJiQ7j>6(iGW4p-&!UNc><2jKF4N%=9alb!MMyoTs{nP0&#`D zxWXS~t(**$b@<9Ug6R$OQNhfTc~dZJ=R(Y#^fvkDuKAc?R?)MaL96{z$Hfk&YV{KP zV|uA`Eg#&3>!+@rdZjv;L2fqEV?WgxZE+u4HMZnCXi>&O#$1#ricEIEAS*R{t~HpJ zG1nHfCIzgyK5Oo)`lXgvEXzkfu-1a!&MdfEai!u_&+_(H5N@Srt|J(i0fxa^B2>%* ziM77O+F*9`d`mF5XudV**uBv955@a$rFb7Y<}W_JaCrV8Dgfyp%xhbW3Ko-d&}6-2xo9D?w3e6HpVpUb6tJcE zrPINbEKE+a`7@0%#_}g$3a(VKNgps)Pu(kkMMN*ha1HL0%oh3EbVv0VMntYTb&vn{KBjTV& zxnMDJP}>S@4Dw0fB}5y-MHX!`mhj9#&!l$1OgsFYaosyJ$TwMvC>DS}VSSWOJh?mz zTqZ>ALau{aH<5MbucWh$kpgaL0(RGoFI2EdHC)kY!hbP!ItO7xD&MG=#BfpM#y2Wh zq)V1!!z{h*M4HrVHGD2#btJ+@M=ndjhom`inpr!r=A0CE%nE$iB4eV@!^-RXK5kWr-~d`F2ah^P;4VyPrmnwO?fyY(ku9R&RLn>n;a>dX_xJd$%Exd&)@1=4C7kXyr;95Hb2emdf)|K3zXt7a$jVCX=XEe`(NGkxXyZ_Sp*U`aAyYfTe?whwW3PKi*A-4zV-x0g zJTr0O;LnRI{kE#_jx5DLJBYY`#S6vHoL=1W?a6B+7h1fwsx{)|J!gGBHefIH*-KZ- zy!KMBefNd7pxJt%QFgV^yO52bxhS({es*D>Kek{_zy4P+HtEu-i>H8uIbTZi$L7!J zDF|$G)2gNE4kTW)7iWpt7ff$`F6xdw@v?cr%p=z>C*3r7?TBH!N0fLiC0@_Cmf=s_ zMBejQyuud`|3gy7<&Nh%fEzcyV){YB^5FMNSCeX>+rMnO*17VCzp&v-s`ucr`KS*Q zQ-ZnqFSJ64mE5UaD?#+%*Y@4mceCcrop0>)Z))I~+UkUZ}5 zJGMk`>OQ}1-(5qrtp&auSjS#K(8H~~E$gzAywG~de9=7Lv6SvjY+4?~Wo75hqrSwZ zfT{W8he%}gs~)Y!WC;TC-*;u7KeJ*rwE}W9Tk@qxFFv|hv#eWrczUfaZeaZXhT0V@+M6_QX@_E%>h`7E1YZe?GEIJX{_p=yH>|RC^ zU1*=HxzNty@Wxhu{^?dt7TG^U4BEao_W3t;_)88g>aSWM)WYjuf9lXAWspp50jV^N#jB!(VI5+KYAXB$sqJbniwt#CGh~ z|D?F4Bis1XEG=Cf=8ih!Ps@!RwZ@;;8tHzw`H&@gFx)_aP*5BZ+E2(jt54IX?bG$? z`wV@?zNo(FK2u*zpS91{7t2&IH65A>_!f2QIyCLtiP(1Cgw>%*)4*`w1Rq!MzlJ!o zF^C;&bb(py(jtazY^}|y&(NGQDV~@o%{?|X(+cS*gvVAhN|Y6aa5P#T#JD3vxia`= zX7EvmnBUW<;U$!N@I-W-L3B$_BL7eO$r>K*wY{i$L?dXOfUQog099smBc>Ks7}Q_^ z7St|lExaMHUq+`P1Mt;p#1*d3a5XujagA}Ba5Xz)a7BP%d;=b=%Uo-8n%Q?&HV`y4 z^oEAVTpQ!Guy;166<@_VZMeocV{x^+EUeBr_C8+xWoN$uO41&yt*p zC?VOIB&3Wam!*VK5!?ip2k?eJrfB_xyELZ9DX~tRX~19rtJHpy0+6~_|DvYXJ}wVg z4+M(ic+OJ~CRV|nH$!YWF5?#`=(u4?%>qWDvha;ekC7YcaiRxVa#DG6DZtibTvy5g zoKn{V_iQyf2_wex(5lievJDJ!JWx}Vy$5|6nWP2D7MP){WrpmvADS5(Jne8o=L>AJ zzrXN;BV^jLZS&@HJGO5wvxW3ez}GkPFO)^+6dhV)um&{3TkFhl$oLTb5%TDjUD8?W z7Z6+sf~=4YxC3;r`iFIFd`CphOg5)s6=`3DOunU#E4Smg{Zu7M%C$6%Y)E`6U#luQZc)` zOtW$MW|SWeg~e~aLiroD7{CYM0RRQC_RCwjQ7JaGi$*>o{RSn*vUV|m_LWLeVToWq z?9TW)-3B9{00J(lPXB<}Oh&I{@*uBzq~|e+N2J< zKxP3bqk|FwSt1kcebSv7&dfSPk7it6Db6fswll|>>vTBtocYcIXQ8vmS?o-!vOrV} zrdjJ@Fv{?Sno@o@7!4@B#U3 zX8}J$hVkK(2zp9|z@Iy*Er$#!>fEfgdl)W`C%M-1ti6#7kr4Wb7qkv9BOHPOd+Nbg zHhmOsVPI~6B!=#1KUA%!XFNhH3b@Y#a`T3#9y?SO^k}ze6q!n=AVDIWg136!>3^%= z>w0Xp+0Blr83H9zcmDq3A2L6Wvo`1Hu`~agy~ALlM8S-)QSFsr#hdi-h8Dh4{Z_TN zyLa`#G1kzK2_!wUh{JqFLA#sGzedpbkLYNFbEs%4TTd4gE;Gcdb|S_NkC^uN7yf8A z{s{LQp-56^q_8v@eCE}IM z#zxVUkB-nCF;YJIN8GKm6qoT`I;z=f+dzR&v2rfd+|YHk+|UY{s0s^Rj|MXI%Y)w< z2gkFRxhpkAT+VL2hnHCgbd}3^9dKCOE*cm{~NiHj= zN5PL`6B3F7s|w0Sv*z7RQ!~)WtK71;EGcAc5x}%zC?D;zL0H5dn3ENUnpK=XvpDYrb97KZuT%G@V&z)oIG4IFCif#$XQI!PFpP4h2AE z&NhYrfsypk0(y)@l_KgY{0p9javr>Y<7u>ifQ(-NXH7`HT((fQ*mSk^O6#(FzRa5d zm0ZF;VT8L_FxTqi_-Xsa_IVF{@ZanS?CJLH>7Hx%TAcxFug}`+?Hlr*9{1Ay#D{U| zA`4=%W~uLH&zmRSI5BVb+S>y5E}y;2+jHFO9`MrrAk`t2y1K_r1r7wPBR=bhcf#YH zebP(!Ple~Ax}Cl~PRiO7upaYSk9qrtz2g&Jx}TJ@F6FM)AEQji1J-_@wck5@+ADax zbU#g*IxluE7A&2b>-1W;1gzVA*6lZ2ZYO%3Jzo0Z3M=Pk;hWWOR8vvy0qbF(^|04< z!aFeNrTd|;QpSselE5uf#l_vjmi@@5I~f-|1mG!yYCt_NgX=)2}#SmYQ18GC%E2gy(`nV%Du-17p>m3ArKgCkV!8$ z-b}b9c#j_Qo|^DJ`Se|lrcHN<++IAQ>!Uvtx-<0WDcv{8`$fur{anj0lGEqGKuWqC zvk()o=lSe;OL?!A+$i~e*=_9ydme*AoX_r---BNUCG?f}8)Y|L;GX&IjUOA~6BA-l zgm3^ga|wjXQq=PFT$j&UOB9UwY;pH;(rY<4a#pQ75ovPi9kIi=6XXlhf^P?fw0hk@3VN`@GG9G2mxy?P&fkb2@X!zA2BpPHN;Zc zj++9F1M^AhldZrhrsAcxFo%Meb(fZVu)V(v0>l7`ZcAzP-^OIT7!XZSSY>l$Jxc{7Sh|+t}n6RW+{)VIq-8 z+p&!BmCDwCFe_EjlEv358(abaSjw%Ur^qdiaqXgPSfj=bl*UEm<+0^0T)-+MTn;QcftDUn*{xWQ6fueXJ5Y2h zpfsan5IZ5F(yx#ken`fL5jE*Ba&takF{m#ECF&U~Bz%Xst7C zt^>5-2}WbVi!1(rQlWVpZDg6(x0x3G9KOV&=SL3?llLHj7`i%kWh{_W>&vP2!?wt} zLq2P=eb($%YwlWB(bCZM@oVEBWYsK~=Jn(WtKj+3c|F82mn{pH#YUWa>kq+%CD)4n z&5k!ZZtMNK53#eb^W$hub}ghWI19nW``xblPcNEmw!8)0Y_z;xT3WND+@rS8)o9gT zyDEoHX8$_1M1_Nr)pY?oXR~NvQ^lrII6;F?Pj85jhvtCI#yZGD7KpG&@Qj9XH<}AE zLmkZoK|4Y*t9t4OUNe_Q$Y!8u0MJ0$(0&^$&R_M_pCb~01FPFX)lrohz-Ii zVxNJCV#h5S9+{vuOv@=EDO8J-g&*uqP|HR8uyeD#XH?2j8+fN)4StYpBn)zByNQyF zNYV~eAV}mBr!fqYs6ssHTjKxI=>o=&mQ8*%7yob*+4~M->Ki+ZNl_u}Fb1kJgY|JH zfy|;HdmzIoF_=sz5Egb6YgrHsonXa8b@9{q+bCt->Jiv+ymh0MBf4X_^7|C&tKT*9h_L5eB3LX4G2&Bgs1(J-@J>B zplu*5e?WVHu>7I}IW=6-lliKFHjY--%R%TDhRR|1+36C)DRj6mU~mcqKRYJa;eZ*z;lS;Q zz&GKXvWV-qt3=+Po9Nz?mrPDin)_adxZFdkxrm(C>2$xgY6MXN4>F$OFZpLC! znMP@vUD`0+UF;*}TH7{~OrW!?f~C4-OCu~Y5K0^53iG9iK)*(2$Z70!@kQCYU)dUA9vf9h zmqtRem}1)ka3?YmIG_X(h59^3A?%b9=bq_=Ec{WQOKA*nI)>Sb$=-(gF%eoPg4pyE)-A=t8^hLJXe5Yb{Kz7KMCdL=bjKB^>ym=vt%Gd>1Al#+9LaK{IH|y*zAbyv) zn46GwW5Sj*YL5^kFyc@L&lrZBu%N)dpgkjCFYwt5mRh_O?W^_`U+}qjs(kVKQ`)< zW9OQI#iyj<*iK8IYx_lN7NAFF&ea`PcD!1%Z0cDD%He_jpK18IR_?`r1+aZq% z;7~ImkhUT~64Z}HlvAe$$fPz8B>;%%)Ocx5Jt<@}&Ji-B4Qk*>Cc^=az>lFb4>>Vz zkx6OUQTHZ?)Z<7)IyI}uih{%B$Pe9+@_*&%T^arHBpyb#gC zQny3A3Hw9(fKaJVfW`)a;)o=zIkqMVaz-RI$8F9;^h+ARu>9fU;L$li&XBp!NvvYr zWBzb7C3Ojp0Qw4#(&YwbWF3}EGgL$H1d|(18m1Gvuf}wf8o^c?_JUI*@@}EUz;H`O zFbe-4ii1;LEGY;Mg%kM0$t|i+;{EjDKD28iWRdHKDe^oO^6@;K7+8lQJGb?Fz02A+ zbN(ua*ty^9{la6yt8XP0Xn#MZK)bHjfStV8-^EQBLEQXhy=Hq5y;jHfof-z4z&%$W zDRQbn(#ZYCMzet^1aUMEileDxVg1_sgtGM{-NgL}Iwv!rYQ|0@&R=`C|T3%_BGC&+r3i{$Zzs%^3J zu&cYVnb{v#L7{`9NW=bEq8(@Zq$dpVVy#;jh*P~uu1OLVUZjxNlIYhLM zIkl^rwK^Fc%kjF|9Pt-fD8tjo#1#5W+W)+es-9W1h(W@&+Dsm1jeUnASYV)0J7RoNPxE2M2dqrdLP0OvjOK>-qiW)?C(@@Z)xe$0SRqY zxl-I?vGEih-Koc-CRFP&fMU_sAtbe;3)|`Bi(cV^^+feV_n3Q(Jto*7;=pVzz`=}K z&Lm9gQ7(8?kxA{4?@e&&YxPpQff4{yWhlW{E&(BrF_M%Rx6RPSFnPc?B3a7xU0tMM z+H|<9xxXJ#%Yfr(gfsYn?@T5TDx>|&u<@PrmSR$JJy<=cXW;UTz@~5-!oR~MWaNP_ z8Jc~Xo>B-*9`0Aa+!E3aO@k@86BI%_zdnfNUp_~WV*%|2M*a)f z#WoVAyzA<&wfeT(1wSi&x71(n^rh{9`GaN2xmvVit!O*M*a)2&NGtQDl?5Fo!L7St z7F(P$-}vvlz(UW;|1?2URd+MxR@&`t-tI@d6Hl&IKIKa)n{Qrf4ko2tZdqs{1C1r) ziqV_9{ez?(;F(o@Lwg7Gebhn}nRs|JYu>llGWs5zqYg9<;tDrh3QT@9gdb(ej4TrR zuu7P}I8GKZh6ZB{j?D4f;Rh z0wocO2n#h7a6S-KRc2!Gx%?*0KNIs&6`?p80&;>1+RhvfiS*S3)yXDVnR6DRRhe!^ zX|Xvkq*gL=3%gwN7}zl^?U$zi0%M>FTg&?;Ei}c477i^XevniIeoppg&|)Q< zu1{Q>cy($ub^CXxt_&@@U(#Nif|^R|c9>QpChCn|KLVrXw9XxdWjtmI$JdRWpRm8U zW*^Yab^cvu!6$lsN^B^zVBQTUfR~?s@##?7vVc|c~Q%;-krUxNymtMEiC2Q;_EoU zr~WK)<>;SguGqYL`n-kx;OqLVk9}sa+O~bFp-Yy=1`?8FUP~=2wLa_4fOW6Wx_8yO zpQude_7J5B9Ua5 zOQUO96t3&=BO{g-mX`LDAl3tU(`Wwl@h~!}QI!&YD*b|M-#}+U593gyfjPm!8)`pGoxo!W)E~AehUmQs z3f7Dks8I{?()Y?=sa0qJIKFiFojKa;CfLq!H!B^6YKM>F8XO0Y$nou5)@WchD(iWu z>2|=B6x){~lph8b&&@$m1I92isnRx4$O}9)GATWW#1KNGQ>debdr!5HBVl&zK(>-i z;E1#e;pBsvYnVwC47*DA*fa;QBr4%JXbmb6C|(n18#5@&VC!cLN;0Rc+(^Si-%R5o zs>QMRQS87I2zTJ%QV~$n+Tf5)6c*E`p{$Jn8o}XL2#EbHz5_Q|7jH`rrep*wHow+# zqa{$W$5*k(U$IX*ZBnzA%>LB!;O1?MhSju6oLZ!R^>S=5E$743tY736FE#{Asuvqx zZVi^zuDEX2+&r*yeC3e0q+zk)JFUMoYVs*)aC-T2(P~;9_Vo3RYaM~YZN9>79~ADm zVp=4&(jV*&0yI(twrTU_;W@Le2+nV!S8bdqk4zr%leKLq>iU(+ehg5RTE3pk*d0 z60ldw@}Pdw^5BOZ1@{i+Z?rZP1?ii#LU!X*oN}0(&MTeYIn2(h?1C4};c^5zpDLL4 zTfkZbH=nE45`wg0(*N*bl?PpiryB_u9t14`R~p2LNZ3fMEpxsA1Ca+kiA-?t|Cb^} zDg(5I?E&ErJgHBF5~>04sZpL%6-kmZWhm(Z0&WRK{w)FtWM{|J)Uo9iB#h1E7Bnkj zriBj7B0&k7SU#^6G_PeF&Fg)ty>HU=K7s=H^r~Pbec&2TZRh zwPgbK4=u5XBvB<3h8~03E`%n8ST~qWQ4iXO6ULrsHP$WYGX?{3O0#8$xt97 zIU$8|8kzT$2YROf{2)_VScldkN*0;3Oz6=PjAdl%LG-L1z0(8(G`0G`I%F(6bz4Xa zC_vFTY~2xP>za)tjsGFIogbToyKs;ZJ5V#^o}@GbH|y~9dX{{_LIqtYz*_vsOs{p! zV1QK6ht3a%w^W7$ckpztfk4)`5lY0>6m4C&6CtA%S0k z1VMUrbfEGCoJ*(RN+?lfZ?A&_pez`9Z2Zz)uIEx@hCpD0GcAm_Q7fl(tLTnxbwUT= z1RR4bOr1wYLYV{^l5@I;(NCp#5z=G8nIb%Z0Fg9Y9Y8lS6{^|@4iJ3jk}-u%I5OoU zU;t?WlPINTTz+EViRaIgFKK^j>AWGBn7UXS$gJ>XR`>x83^#RuYW_uGIXk+`t6q)^ z7FB|>HH(PBlJkSKGLW!En*bExcMGaxFWMLx8Zt8WlVCt6u`tjBjqq>jUMu_-{@gR| z=1yPtu0ydY8-o~-bci+>iiL0?KNFd{4HWcraMM4BqVBV`2-SX_0g;S*LNqJ{u|B_` zxiRpAAx=zN*!c^ahbhP+&%%D;pJ|eqr9+H!S^y1v6gCW|#;J*cDcCIVDK15;AT^vC zgiNpu_X3HBpp-+C30*+rDWAwO0O#xDxR;x zbGU?HBPkLo`ELtY>wVVxThU(n+bR|^Kl|#w_pLQtGl6DbO7huJfvwKYj+j1k zpQX>r&JHP8*k3iEL8>Kcpkf2|Gsc(_ltvI!N)A&-!V~^t-Bs<}*q~hTw3)DkX)UOi zse(YL_dq(qV6v<+$ezXE1{#1E;wi>xFiU5e&x~NKRQQiQ7DAgRtawWsJ0eMCxuKx1p5wu~yJ%w+1Ni&# zee4ECs}Xh5Pt>JCI69;2bQ5N1M`YgrNsm>Pd=%#w1ho_OgPe^_s44OheoJ%6YRkJn zgVTii^6!7HoYiP0j-|+i?`&#DpXMH$v?)au<}5_+q=lfZ3IXQ zY|CJI#{GNigiV;Hj|i-Uv<2*k&`#i&7By(8FYt$1K{9+M6g4_LHZnTB?rJ^KG;EJoWSMo4sH3A(hvhc>`y+&ByH-l$+MhfDCSjxBgB8kaVF`-F)YWF%Kr z&=3}k+^I+DG%hTmPY>YpI3U^ViBhH>?M{}yRQ~paIjP2kKrldKFI=JF{uag?8ue;O z5qc_mlcFWTairg-Z|>n6u6P$M{4PC!OkX49;|>Oq+%JZLHqjv~;z@)iiFgvUTPG&Q zrZ={Zkqs3UREdA`&vc>+C=_@BxF8g-`Xy^1t=yMZ?oX?jH-e?RnD_k7CF62Zpse0k zR*yi?!QxHxjV~S!B4TpG^Sysmv{qWN?E1bXP`cAs3Rg$u-zXbFNQksfxg5I?OVLP* z%H|EL@Zl~g?Y(c$W2d6<4EhO!&lW-_4$n;Qoi*)lh6(4H;SEryLbP>b^Hm9t7Lh5S zV>HCT(AdEK0q*a|r)oCBr&I<_LZ`G?QA`4ZVOu`?+S50lUaj88J9H2YEKB8TuO=Rz z^W=GD1BR^9vwnyGg|o;VijyD7=B0#fG1{4}4z^Af(0Y!65y8pKjY7s|ST~;+TIdxm zD@282g2#S=g71d(&0S3)BRq-?4hzpw9)_yLiKrSdK74(dp8p|T6d>{6QIv3n*5!BT z1+7Ja1}_YMe}R57^Hn-q9|PLXQaDz(9a%QE^N>e(jh5hLbV)?&jx8JuB$xP-;gxHp z=!Ye*m)zX;=8iXZ_>&u2%~DKU_@)t9$*)n0?IL>0M)CE@znYiC#ORdCV8yv=M` zj-P{-={E8Avt4jA#$42Vri-!Vf1<$!ZjWt=gw-yZms(b>nv7L~d;py_iaI6foUR`+GM$nr<~wlKP;UmS?MK+(GgJ$Hke_enR#G(gIEe|n zPqQSnp|yu-L$4ezyCv((7*yaPrU0|{$Qpld;I#MAzNOM@mc?%@H^b76j0;K8ut#I^p2eJj-K)joc3pp`xD3Kn$`d^ zYM(j3xb>N*0Z$gmX?N`_=T{Q0J+(Bs((FyE_u1>g$B=YOy^bv_wePE$Tg9Sz44?Y| zog{otiQj`wh-Z;$!%b*Y6AMIcLdBwioX3&WPTKx*xnXggm>#8C9(`Cjq(`Zi#}JkW z>_gQ&Mksp2w21-^;~L|F`!od6Ak}Y64FYO_gpd(@WgMJVSCq7AWiSN5HWF^=r8;&! zu_qu6(#Wqz+D)sJb4L?FkK=FnLMqF4MF-fvoO3J+7;9hiMjxFgYgE zG(rb)HaTXrU9rqQJb&`)zH4>vverw97Za~oJ`nyE;h$yQeu1e#k}{NT`bC`jh3}(9 zQwUuQED7DL!7C%s9T9u=v{--CU2*hM^~LHdBcDCKuYF>CQ&-`;;pBJtx$1>2ujs`+ zC)`yhF7;gO`40ctN#W|O|ka6|%*G{`D+b@|fny)w|bX_T3lfoGo zH6{JUm6B3pTpp=}+Tr8JVP1E_Sq>%{8NSChL%yN~V=Ys#b{EpKIvutztL&w~5HRfH9fNS=Ba%rK6is$_S1E>;tldsdFe5ZU z_0l9vWwM*du1q;eHiqo`G9xGzu2f0L6L&?J1rXCQvW>Fm(|-+&d4hh}|G$0dI4QcR zzus8WY42my-?JZLA4T4(L6&~x%Jy0N{JE&wh|CePVp`SK*u4 zf92pDM}tgkIOMK8bSd{@?v;ZP@TE;nLTo?mMfdxmCcJ|%asR$Rg(n7H4)-P&R@ zz=)!msIPJKk9%zWzxGX{DEqzT;fu0Z6!#e zhO~3_MN8I~q3BGqSLyKSLV|`4fu76QHi@DqGw0}4+kQD$%XFZ zLb0e}F=jcr&7IuF@CjY$`bn9)rp1%m3Zzof-RVazZ}+4h@uVD?X@*7#cV_?PgPzQO zPg?&>D|_#u_a0AL&rB;+Xx-WbIl+wC(mCgH(N1^K&cz~c#a3KLGbLT%zaGb=4DP)s z1}0YS+~&D-af@Z~&|>@b0rBudV!s{LIK~ydRo6nNftTy9)-4t7yxuGpT1D$&@yLTq z_A~AyXT-x3kSI!=;_gK82BUOpM}LJLbc}Q$psk*y^!n3ro8*l=E-}GIP4L=OE;ve> z$Q*!3$A&&lFDGx|`ZVnRcyI!sODwqNL|CqQNc|It!Wxn+2qOW9$gnsRgHLc41Aiil z;MDLs_(WoYLr@Xodck&rI80HH5gw7J5s5k8iz7J6GeCznE{8!8Et7x<-4w;eoZ@JS zWxi_N0-jQD(8Nd-6dO|$5@`~Zx86{@ge%;X5OmFl@FyhUg(-dv3eF_s{XPtHC~ywj zg$C6=*3bt_hs9~toO#ZE*+S|Gp6`^1nLo!+8Ae#>p>=?rD!0NxYO{OQkx=ec^@a#J zrJ>VMkSZ(WqPZb$QHwv}7bt;EZcGG7rf5rA1+OE*c0!NXYM4(JMqV9zWo$9}dh2iW zOWS)HifD=`$}UFIePW?J8GV$L7D_s6Zjq}-ibVADu!0$3oz1G6sY!=G^A@A7KlIQ; zH1|p3NR-q1B8U#OYBeDV$`O;JHaGt=^R>)n^C7nxg2zXe%mbd}qi>i8uH|3oeGbG; z;#$wu%tw1tD;JVIDcjuUffXABoK&-6pc z8MJd~bt8)UtYfZmUUMU%45IRR)g(cPgv)WH{$}?0jSA?%%XuTN?}HVsDrJykw@NQ> zX{_dpwGy*|E)h%AWp+D(ggqsC35_(V9q+)e3uNpAU^^+S0CCa=?{Fg0`+u#vtJZ6C-pk6+7Om83v}d^$ zU5qw+B}uK_wGzu~o75|DoVJTwiH_Cg-A&2R8vmpMxLdnJTY5(Yup)5DfF)5#WQ^8k zzn74#t$nX#v)0Vr>Eg87U3b%RDG|V(7A{JAP|}hm{07x8j`GDgPt(OCfsxV~J<6x< zJ?)F7?Zo)x

Ca3#iSB5i0eO(j@F&0Ba_8VL-W`O?gYA9PIvp(k_ftkFhO1CKIUj zQp@O+HC+&q`Y#|aIxHyjFCXC=2%qsbUv!su@KFc3yC9e;e}3uBe@d&;8=l_$)aI`i zO*j3PkD1YYsm;sBJlXj~=a++elaN{HAp$Z=;oiLf2N~P7aXs7w{z)YergKcfXAwS? zKf#CForc*~3^;ngsZ&GYK}0?0{rM0q(2mgQ2PE(Tfgu%ibjBZ4jkxJa-5oSo)fyHxSDQDFKjH29G^NbK!zWYUGeQ_>?cMY zCaA)(5MK`WYG$YN%kk?_V4ZT{kC3hfh0%|DaEl zsD7m9c=$AO^F>3QENp!G)RU+0vE`gNGCD=NXeWlx4+*1`ukaQ+p`PY8JIb6^t*B#- z(bFydBy{$4)6Kjs?%0hpQM1v0HLp#-6_=s;_ z&o9QM`MCJ$j`ssGssytBp-uNDrhTOm`xb5bhfMkK1;cjr58}*GQLphF;4lgfJ@i?# zVe=CZgh9{@1z1C!8uC&pl2z;m9J+e#LnKV56klT^4o0LHQ3}~j*?9~QIhsB4Ad9pw zu!S^V$gxQFV%?IUO5dil=}9&BI=I9khT*<3j03cev**5L#Ser?f%lDHjWK|=`!N|u^ zdkx5vM1o_FoC;GAW?gv-Qm%~m@u74Wakh;hR6_lGyvmjfeo-F&^NHMC6D9XJ_LGId z<1NV4k(6o5`Hf(2I*=>lVJnbx80D047dZzxrZrzqt^CXNi6loOhhZ9xvSPwr`^e!T z3^BICFx*VU^cuBXI`G6aD?O_)E|Gnee>si(4}CS9wM+?@UDit3rToiS)rYp1BOttE zBt==PlvuKhS*$F4pbfK_5+94;dhu;6E-dK_J8gbZFgE< zY^X%ce5hgV8iepVq3u?~g1Z)W3O{`x77?j_RUeWZG<*6FM(z{&qy+ot(Gl$mZm9}w z_alY(#VFwu`T-)$N#z~xe-R24ilNUaZUEYZg1$fR(}qekDBq#2WcBDXslviaSJ!G) zSXp08c#T(ul@IKZ%k7Jk`)}RY6$55DwyhEjnYeoyJFC<78EjRw#Dw}3Uq%?#)d=58 z;b45!z+D@3Z-aY#(7glhok905xF3+*V2W@OE|Aen3d?gh;?xD>*Vlw?iNfMEgw;Bf z&uYxWV($s7^obhp!`_nTiHJ* zugwE=@MKT_;r`drBK`8a@*lb72SS@rTRLJKRpQGoncCI=2h4BQT{_0Nv~{s#osZk& zDr|uzN3cN{$A!cg%1ombCn`*pwCS&`svR<08=Kq7W@prdU4YF9{^aQRlqL>;;u05f3AlPuy_!fV4hSi>8~JR zFcZ_T0reIpC$4P-O=6f%f@fqmjmWGe6PeHq;uyk?)LK+k54CqScK3sa@{R^4kADMj z8OqC@zf%*_9jB!wPCMH8!!jJ^4mW>WMg|nkBlV!|7 zH-?x76Ulz->O#Nv?%olN57XKL+hcL6DOdMw9b(BbN|LD5_g4X_(=20nX+xQe78VN^h;Jz^gl zXO%{)u|7J$PRpjziATnS$q5!27Cnev==7<(_xWP>x7%#(U9CQDhp-vA`qb6iUGXN< z6zs?Z52@gs)X-p&hY5O~>Nh5gj-ZdmVdT&hjwC7F!vG#}C3YbV4OFO{M7NyBmFD+R zf-6Jvmxf_@urD44bb#fFA^Z3kbvfe(xuUHo97q^?*}#Q-7JE%t{oNo!Obb3L2OPSf zHW|2A(RlFzNKCT^IKr4_$?K^cf-)h@?mquIwdhdgRI6M6)p5!+Iey3-1!^XRX-~F;9VYJU(C+oguh22uEN&w$z$km znJ)~fr}8Pg1Jw&QA`L!Az3@CmjxP(TDU^KwfqYZSLYfe1Xt0*77rsqFsbz|_V6e*| zhYY@1z3>8s8Ovz8IVs&qM+x)fnZGVNBhn`~V%j3$90$?&Vpp1(h$I*)Vb}YpkJ50T z30;legyvG<#yWgWe^DzSAMV|qaD??2PRr2AiLoR-io9U3r;_A#gk(k}69y@Wjfs#} z2jOo3?qweT7e>d^)Gu^8W&Kjv_QsCZgs$Y0y?h(9vAdq!RSFFiCEKFkDyR@cb zknF`5>Tso4U=PuBT$m(XMB~h`@tW%V{|-M|-$~s#9n>NZKY^-rVbxG8u)bcj*z>~R z<-vthH#YBmz32nskBH>jq#%!F4!tGK;=xb;Gp)y~R1X1!^ZV7}8L9SzE0BCZ7$Z(GTSnMgh;JpOCAemW`}$(#ukH8LFfQIy-eAJ?z0IXI4fwd7F7==Z z3(#s(ba5qE+`1z#MDuMHuT`3WGD;OZd&=~bc;4XWlfeEz-! zOLL34ud{au!t4jS+q-&w2H9tSx23t$mnOfqm^(Wg%}x7;n7<@juVZCXG2oN4rEQ3o zHb&ZnXguyXI;E|c&UW7696i%aE0s<%M1^HV5WWK(gb(o<3M&LYCh%thKO=+(32Y|t zA^{aeX170<==J;b`Y+@nZohQBSU)j2B##oQwTO=I)6zOUMC-Ib#|UYbFC`cI;EKpa zlzB-ZR3>Fir2pbXpV~g<QyMQ<-WcmRD1fvo% z|IUz61PT(7iD~U4F%5WHZ3G==O&g81kKh2$Uz@nBHNBxVd9-=cjb1+b$?hk*FSNh{ zP+9MEx0~;iy*ggfykz-a#*1CcWfpgtMQlFoDLdlXGC+}z%8~ZXC;2fa_o=75(SlJ2 z)p9`hycGdEtib?VD4=txw&1&%Qn*yrg0x*+XE5>pP~z_SPH~T& zq92o!cfFLnU>6%6BhSZW&;9ddV*Q$U^L5B{&;DSZU1~2FBK1$ze|68a<#vqz$;Y33 z{8nDgjl6A80YB3`+juK3@mtnsdgl($KP;wHFC;E(yOy+2a;^J%>-Ak?Qs0fZgP5Z7 zx8II6-qy!Iz3b^+w~QHLW|zmDE_z^wrZA zFH-D&YBv>6sj|!Fd*@rk%o>leR*bE^Wyttu3XEu+zIfV`S>iF2PPcfCNl%YIHNI># zxs4`|F>l#eDP2~WZuKT+%yzgF3zrkk^93&#UoBp6xl7E`9o`uIa!fjYx6-ocN_D9_ zyVR3bww$)Zowmc1wre@f>`pU#(wd&qzokuvPL}6RKXdvi{q3}@8NFAZwye)_>vNX% zo80}?$6%gr`5mvFX}M5)p>U>hiO;%) zxBauFvpJ%!Xo=r~R~V<7Jv7@c>I#?mA_{K(dedyo*E%mWh`PKbKA*fgzV2{mHU6;3 zlhx=+Zxqv-M4e@cZ}!F*X2zCdGH%5hpb5&b!Mh=EuED)wD^!*(>{;A?y~5peSZq83 z0geFE_Q?v5r( zs|y%aTBDdyv(R#Fr@Pi7)-*5nh{o2X*fxfF9#j}V>dCJa^R|h)?MwU)+1Fjzw(z-E zAA9Aor5&xF!ZuHSo0!*rJ!8q*CmtO1SRWL7hQy(hqHb)7KgH6Ro=Ux*>J{DH`Qj@!Kz->F?sT}Z>4Tz;jMcv>M{~!x2eBR+M>HF0oPf4Grs81|B zDC!O^@%^{6H(^-d_l$KRWx3kouC^>!cetxNJk_1kT`c7$sIJmo*3CXD>dKb*atV#O zjmx?93lE4UwEmtYelLAJk3IPq4aDTxysvGTF?(xjUu}4`VYXoQ;Y*KQd`#R};m)XB z6lU7-QC;YHuK$_-*<%;>&n3=PzfgC%ZmwcMy-@vX{VT9`FK%sG+`4H0<*C-@j!ex+OLB@JTYz|iu*DfhrB zPw#1b>R;gYky(Z@wL|?brY5Ikje!;~niioHkQef40;VNR=q7NA=IdDL8YbQQ5>66e zE>I$ZE{ge~p_`rj?BfGCpbX+(16yX;3OP8gr@W z$oVVb;aDlYzagyd$zKU4Z3|NPQpgpalhPK{e`|+RaFB(%N=Tf-7ZYY?yUunQ45?A$4tV2(i}I{N9MTT`jV5 zOO}%DHdq(uP(f_GQ*Iw_3=PFd*$xUDDY`P$Sr#U2Y>>NB1Gb@I6;;Y%Tbo35^qMR_ zjAK|^K>-p*Xb>|AicqCp!C{UqVHpmlPKt3I^$GiC2+?4LtE9G6I+BU0u*_CxD|JbD zTT-S%GU2Cu3Tj3Hl6pudNqQtvj7YgMnGe>EY`4vtUNTY41`^O_T18poh2sOF=JabBAU5-B3)W@hPS&Tj@tMpXpK4vB}AC`~@emeN=6h z?nJ#m2lXt0a6&rDG|q-l2{papRlJS=_WQh*^Zp83TieIT-mIjR!_ENtQ7|bi$`E( zZ3rZ|p&^p|IDXc77No}^0Urb;qXC7ts2ZJ_6 zNv_KT*opIpm=!*1|c6k2NYF~NhE-9 z4#%zVi5!2YP{rlHql&+&(%n=UZ>kJ8Rr;GME&CybZB?GA%Dbsby{SsNsY-rZ zwdHM9{+p_dH&xjyv6>XMs7mnbR8cWcc6_lzjNk0x3+YUSmzl@s)GzRtc@Mui=p)9P zJbbPkeBQ%nh5|NucvI-bG?({6!Q}!EUnIwU+{173$H!)%v=j&{C8s`D@@$Eiv(1yV zeL5aO*>n87+h;Jacxi=OYmI{Jp9ZfVBs`eGZ#qU zxfBRNSrB?xwVg`{2wXOo@LpXZfR)p6yhZE z0tqQxt-k^mV|x+KbI}1~ESDBY+sIW08aX{z7uZn4Wd)`edcTGox2v~FKDQh9-C;p~i&|B?(_32Q zkJ`Z{dy}gC8vNfb*uvg#Z!_N^fB!jdYa%~w2%O>!Tx!4+%_Rqt3b>>|7Z6MI7pT%R zyjdIk`V$w}=K}%GOaOmPL;2ZzmIqvg3aC%gm k#Ko_a?c+G7+J987;^Ssq9!=(N`PeVF_%$j%i+zy)0|%tSwEzGB literal 0 HcmV?d00001 diff --git a/__pycache__/2026-09-15_quick-sorter_destination-normalization_v18.cpython-313.pyc b/__pycache__/2026-09-15_quick-sorter_destination-normalization_v18.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7adfeea7219643b771ccb77d9fa6c583715026dc GIT binary patch literal 111557 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 H*sT0t+4I*Q literal 0 HcmV?d00001 diff --git a/__pycache__/2026-09-15_quick-sorter_destination-retry_v16.cpython-313.pyc b/__pycache__/2026-09-15_quick-sorter_destination-retry_v16.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ce160375b2a197c182ba411c84cbe94af1375f4b GIT binary patch literal 109563 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 RkNNn|b>LzGpUXbT{{^7QC0YOg literal 0 HcmV?d00001 diff --git a/__pycache__/2026-09-15_quick-sorter_full-inbox-candidates_v15.cpython-313.pyc b/__pycache__/2026-09-15_quick-sorter_full-inbox-candidates_v15.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..db54859956916b74d362fde8618a016d968577a5 GIT binary patch literal 108842 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

)ZyEWJGP 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 DCp-Ej literal 0 HcmV?d00001 diff --git a/__pycache__/2026-09-15_quick-sorter_inbox-review-only_v11.cpython-313.pyc b/__pycache__/2026-09-15_quick-sorter_inbox-review-only_v11.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2de3433846a05c3cb50d2033a043136ffbb1a1a9 GIT binary patch literal 107762 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

s!dc+bQ@{1VKZn^+_vbJJ{IU+uGC3 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 C3wNsk literal 0 HcmV?d00001 diff --git a/__pycache__/2026-09-15_quick-sorter_inbox-staging-context_v10.cpython-313.pyc b/__pycache__/2026-09-15_quick-sorter_inbox-staging-context_v10.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7ca59233bcfdfb731e154cb40058330d30b9fbde GIT binary patch literal 107641 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

-c3y_DLM1j%g3KS?oBRVoW5TB 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

  • =}#_LX!IqQ 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 SQuqrY{d3*mjS5BVi~L`TDQdU? literal 0 HcmV?d00001 diff --git a/__pycache__/2026-09-15_quick-sorter_semantic-naming_v13.cpython-313.pyc b/__pycache__/2026-09-15_quick-sorter_semantic-naming_v13.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5cfe63fbb611ade5db5662d2b61d5e435adf4c8 GIT binary patch literal 107483 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 TpS*D@Zmx{-mEbSw7dHPN56?SO literal 0 HcmV?d00001 diff --git a/__pycache__/quick_sorter.cpython-313.pyc b/__pycache__/quick_sorter.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2364e2491783085f7217b021421e5b0952a64727 GIT binary patch literal 107445 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஘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

    eyR+<=_mMs zNj{fAxzUXd+i850YB6-$A-EkA{@Naf2n;+xJ4KB*CKgD)9`lTjQj=6EB6`Qfgvak7 zl=k&hDI;l4hu^`_Euuk3k2i)Svsd)yUKQwqOQ_d5J1an&A_2E(-F_+XuHYGSyS zS{$wKudQ1@Z8&bn&^qB8taF|ypUyy*U%{X`HCR`-zN~yY<@m1*mLpd>o;%%wYuN7* zrYM`!D!suKEL-L*IQPqI9^# z99x3^qh;mX&UXCbzzDVAo!`d3Mr0|ail)>o zrNOh5Xeu?D(neEh(UdNl(nnK4r-?(J9{kz-T7M2-QN~rRi=^6A=*Bz_e?-R!7s&0fE5b!mFnoQU ziAbu~HQ@wNilq23WG8(Xfkvlm#4$OBuY(F<{Cu1S`s05F5FbM!Af#>r!52w&ogH=w zUjN5h40lX*4krK?>B*2-SVV&{=N^t|eUm4|RJu&vhx}7sS0u&h@&mUcsh}uC;v#8L zY8Np$CyC}#%{?QL46(*T>;q^0!-OUhs&F(?RfO<`NX&5k+;QdBFJKv)XN^PjPR85C4?G%>;ZD9V|Q{YK1UgMMbT5G5_0iUD5c1T=n4q3&owr}TBJf~ z!J|luh`^C_sea-ukse<;q8aXeqa|!CSu~c+mxtGESX{H=mT|*XdwA3C#Z9|!8FvS@ zyMWfqyULh7vL4D;s? zh*6s1^Lv)9IU#kt@qpDPQg^Oy4r#o`Lz*Q9@-B*cB7;!-liq*#5b`HJC4J-~*e z4T(n$M_$FN>%ml4X@srFCeYYm<5Ad#R78Ek;~8UZT%{1UH(FBTv2|_!VBIhtg*AiU@j$0=^9!2?u+&+xgVz#&wTNA+LPV zeCp{|8jhnAZl8+{8>3VTQIMZ=gpYd|jQbxzEruI-q$N8pLnu6+%mnfQzJr3zR@F0e(a#L$j zCR#`FfXLfphLe&9W5;XwlzKJrB9#rRh+-dpit&iT$%fX)=`w@OX7p9Zm=|C5 ziJ$(EQ!A|ZDViO=N0&MA5)d!*#-+iR;vUPNoRS*b}cZ){(tb?Suf~8X<+m z*mza_Dw$k5_fV$~O5$FQ8;mkK{i@@Nn*3Nn^7mKdP_CLol`Myho?dmoItmH8s*vl) zKwGsg%KsnLMfEA_qE6PPEBYmAtFOqBv~PdOeo=plx~vZA>Nxf(>e8=>;@t!r0csi3 zA2EJ3kEk1nL;U#RQ&w+fVxQ%(BKPVwP1;(&WUr-us+uK*+~r|3go3zD7Y=)ug_ z4p|QbZCItBhFI4j;HKGjYQl47f=Sqj9kQ_%HrO=>gr4Afd{aKZYuq-D)iNzjA>Q22 zCKKDU&khXK9yn0j(edoFkjRaL-6L77*Gn=Yh@BxYWpX-MzTiWd!;VSticGQ~7Orzo zK&(n4Op$T4(R;VQ&ejXrA|!vJ6oP?D;!CI7NAgVwk0HRaImS=8M<+d#KE`C~CMWQQ zK)_@RaWv(JK$%@D2^!Z~2MN4vyIojtPXK;V8f-g(&v$fCRR#v0eYQ$B?l{H106<7W z=|p8HAwWtgiWY6dV=jkKr<>kT+1gS%=&Y*T(7+^kRnOE8)g7xuy0K=%XAw zdE##bd1vqK|-XPhzWgvP=+vM?6o&w=+d+y^Gk zV473E+)SMDOveW)T$=uT+DmD{%-uJvA#Kl0+uc;{9aHv~e1XQmq2J#4+SD6m^Nt0@ z?>WC(_4U51=Btf=YW>mp&jgP-LfR9*Tp`^z z07&nrDC0vdr!Bf;$T;8qQg<-B^SVD|*gvbdt2f-S<_9*$+TvD0b*wqQqq^31&G8o< z-%blzcg=R*HCmT*3g--Q4YT8#KcwxD;hFQlK0IGK-}ZauUv}ThS@(6z!l8xd|Iq%m zyl=K&Q(PFxUljUy|hM12rD>UEN8)As(OtHnQ$u$AbQP@epklC38;aJ)!^A^ z4bq5-LP%BvEH<#**di^@yqbPZFNtXq!oONwQdnK=Qe9HJ4SUkPDG;}$l*X+I zlC*ze0>HTKC81G5_@C1LdRmG(zbV12B{LCk>Yhxm7I3Hb%4}U6Br*8CY5i%vRdKJR zq}=~r-5?1#S>V%hxPEPKJWC_J=_RRWLXb;{38nbhdQ0E0m%(yoGAz{flDsq_^heD< z4nVl>HS`;zzk*~ATqcQESx9c&5{GXFd8F_Ca#ZEM)d8 zd5#cYk`-o*0#j2fOvCf;Xmj-taEbbb(-1u9Kp{ku9 z`A97AIenqxRb8QC+Prq4dF^=fTBmL8p60a&n%DO21WD%5OwV~o5kvKlCVkH98pQ=S zBUOch9T^1F7*b$r^N6-XvIt;ej))QJHP?_Nqj!@YN`je*eS~59p%w!C3GdX|u}C^} zU(O-2X@EreR74l|JB@b=eI=Y_KahJqlFmwlE^3JQnTV>ld%rM65KS|PsNhH{)b}p* zC1jVRu2b9FcK1azZtzjQhzjonVvV5n@|@Zsj1IY-5tZOL<4eacD!_Y1gUE!LAL>crw~>?0qAxKX3`&Xf z)#s|ix`IVrK}c6L(|%8xqp>dQGv?O4vhyc~oaOBNi{lr@-)LV@f3-W5T^G*YxtP5( zoZYpU-8H-WZsXRuEf-rZw7j-6Y^h$fREI3JLBpjdu;z%htS$doSz__(Rt6*-o&I7k6CP@mlL_$DORa#r&fSx!)+d zQWUOgS*&VVs@fUKKN=i-Hk9RhG3V(}&e5Rh==~J1yubKB!x<9Agste8*V~GB zZ{yzCuAq2FTNy6zmeS3;Wo&@HcG zFv@bplnq`bi3xk7!oXM&^7O{0UMM39!3;e?PP83#F%X!>i8;VP@#-x}M3E9TJ_Ks4 znpBoR2F+9+8Z7ct9M58)9mW#cK}+Vi1vC*9(G=&*N!J8c*f#GN*5WX;nUWU#%(zLS zc4Wge;qdxSdSE3o>GzC`V1-TQ05M~$6|3hBn`#>yY8y905CAiPVQ4B{WGtnV7xgD- zJ?;>u$T-YzBeNrXRkpQ)*Z>wpY|(E&>2eDU9^3e&4`xUwVJd{ByGtk9DnWqYfrKI+ zRF=EWwu=m!{AWD1PE0;9ft&JDHL2DkA^ox@b z86*K*(MpUJSEq}li1mu7AUWuQh?}I}SksbG1sM~#oRJjqQxOfWejoI>6Ql)$X4o}^ z*x?%j4fjVhK3Ey}*-8~!A>wbzYD0j%L^Qit6pCaTB%-<^vCSn|#1*gjDal^LC;S15 zVT0#6B-pywoENZOF1S<>&Rf5jxBj-NdRBcWYyHB;P*%gN?w*!2Sm&n0IqMd4)`fDa zmh@E%uB+4GExQ-D><(?|UaISU&tUyP%Vn0&cU;#jnY(6F?&{6wHZNQ9UOI3;k2BZY z&j+1}4aWb0>$GX3ABGq%(iYX&$OvRrvhk0obW|;?RoJ1HrFagjHo69e#QPYu$j?HQ zgz4B+6;l%>H*}E6gjW$KA;r{-l@lzUn!xQ1(nQQc9A6D$5am_H+#n?^H?134=p`m5 zAwVh1^+q)gv6nKrNwyA>A^f!bEUzTEptCB#_sB|xnFDFYiL0neV@NAXXb9m%t|Of! zF!UZKIOASeaX?%~tjG|17~_#pC`0x%Cb1!DO++sWZ$u&ka!3q@i0Nr3P#j~3gg?b| z*1;lb|AT4G{Khx8ePvt7R6EmsH`TOk%nhuE#xZ{e^HJcaLY!2zSg!L_p`j(J>XIQ^yQNJgo-+NLOM1R z?`LQXw2@(!GSDT8Hq_7wL4JpsFFHFW6FbQHFi)Z{*?OU@Uy*E1JW?hvt-K0ZK|iRI zKbs77p@t(xoFXc)SFD;}$*Vq5e)Y=o0eO;k;-lsT7zmna)u69BqNttLKXcqsJKa#b zZLs#3uoe}WR@PQmrGlK30kpV$&>cRozwNMn=s;Vq@Fir4q{2*i%mJe{!rh1pR%syl z*C-8(Fdt0b@Jtwny&ke9(E1FYW5VsHxx?={<(d%w62D@|@heEoaLeiD^GDAeeR(jP zUb2{85=t+f>AI6u63$w?n6>tH*1B_Pv#Mot&c)&j#q+9=dHqW%_f=eW#~lb-oq3n`)W`q-1sK+`;)tefdt)|=0_oNEcFU|bMX7se1!ZoViT zwK3oxtxS%$z@c`Cjf`on&n4RYtxE3yZ~QPy?$wGekb8>fFiPke$QAEnU?M*YL~hwQ z`4|Z%rjsimzGtzF;}{DP9|rMFl&r@x%J~w;LX>liiZNsBQ zeP>YJ`O!^%C$oZhCpWWgtNNY#6vUr?tse?7&i%T7;gS0N?5syk^4B=YGwt+jEXMa#C4GSO%HT z^J`?5MZzfvko%QA%6%pgr@ShNrd7a9`8Vaj%y>hKD6N+v;W)q&XJ{dpFIvs+TO*ZU z5nsNXM}&d)#Kdr)S3v{`2%_~syHUXShzha$RdU;5e5~iV?J>nR>d0*8)W;QDzaIZ) z6f>LnCRsW0maM#*eiPcnysIC@${gxTC~w&^*}^3brV@)k(pOiQfEQ$mndSDlqxx2HclkaXH>S;UBIdtS8-$9mVeRvyDoDuEL z;$?LeFg_#eH~|K!vQ{^Vv@VMfAB$g(N$bozFC7jjma2CwR_wS~`fBNy4@+`7Ly_V& zU6Eq6iG{y!tx{Nz)E`fBu$;;!Vi!~))2D%L4sLB>Rt%tBp4H^ zktGTE!fpJJjnuc0nBnf|jWH$RKjh|L*8Y|@SlW8s`osKp^4~37%Iygm?2EZQbB!Us z@BE?JLrBkb1`YQ2a*INS1D7;&jc@R84*u?7aKoM(rQx;vDbFPhWjO%7;d@5Qoec9L zwModlGnBD&W;c{&2IK7H%gq7vxz@10I;gJ>oLMNn^kP_lAgDiZH5HW3_3iby8ai&M z7aR5m)dxQMxsgjhtoSHmIN-x9!2Ly9dye`YeG0`nCGCakcebY>{^hCO8z@}BSdpk6 zqkpS$tp(Ki`K&EJTKQ{M6%EApT_`|zO49U2~Udy@>aLTYKyNO721+pcKl8~?=orGcx@zkTMdGjC6QX<+Wq z#UmGvTpXAi_?qS;ACbb(rL}S3ju0*6BBCu)>gPP}iHQC{+tWjxPq%mSy>^g*H;|W2 z^YQRRGz=8seY|AdT!sW@bJ5~-kst_J+d}%bnGQUgGkmEas4ohti(>svbccc(|5<HFk9`G(CyIo5E}DDZSRj z=a_z!d>Y_QG z+ha=8Qo^U?ZOv!&*2bWbQ}(FZRK}?xGW31-t-Ub==*cJT=;|p*4Bk(B`<)`;vPKSXo*ED`~N@??`1xKknW z=Zx~?bQkU^@@8PHWWWUCakR$BXpOPAMTRGK^_uJ^dDJ^Wb^*^0w0k$&-E24Yl9nnV z*v-+pXdj{&p#I6R*ly-?<4u7dN6nMcmVFX!G4?){01$5Zy!f&u_%j~?NoH^R!zIYw z9b1!#qXT>YqkcG6jIjJwu{$=x(3;Wl@nkqr@K~HElyIU*!U?P{qO;K&uf=ZZ-TiP4 zlVJ#7jDICj6CW$X6Kn6Dhkwo65?c71z>uuuEzFnNvy#nTG>^)-1FX&JMV}^y<5obl zMxLS3lS4$&`Lbj*khbC4bq`ZHZ?--ANhrB!;F7!!_Uss>aa0i=Jf4`Y0`h3D$0#%W ze>`ysXya%JPlh+^9*Z}X65doXQ>wp@V$J&QiaqPRIp~v|RVYa=!;swGBn%o~oq!>) zpy%@JdA)pMAG4Wy5O4DbNvOkuJ)f_^NCOv0!K<{#lr)&9F*hoI=^l;4y|^BQhm|Mp z55OGlk64ewKg$!BfHsMi@MJw&|JWXFkb1O{_2_i8M>lj=@3DFdn8%@}?wUQd-a?k% z*j=}$-dlv8E?lLj)!@J!VsFI9_JZCM@%Lg{9xLFniX|RPk6tXc7nO4MV)-q&AMp3Y zXLvQ<;wPLPH$Argo2mYMD!;|9NJ6={%A7k)!VEIhFbPYcb^>MJKB+9iQcyMulVq}b zH}{DAEi3asBG=9*%GLTrxt>~?OM61dwe4OOfl3m^T*KG01L1*p>p9 z?Ql2zk=VxFJ|FD_@Gu)FxDA2(76EQWU>D#KbW}2&(D;1yzF}=quv1_fZv|RJqJ#l~XW*qVN+G^iY+Adw9AFFh8J`uV{eScxBVP z=&*kPTR`lTiH~@tQDE~3wb*um{yY?%S^QE+r;o|AJ(_fwZ zNceB~`M(ipyQ#OMEtgsKl}!Q1mzw6DzqomB^H(<|0gYy1)~)|b;Jxrq)bjrYQF6rS zrj{pR0YePivBk!{3vGWp@}rTz9sAMP-%h-dJ#T(9`}ea$je9>5?jzfZUikl3RrjK* z!pGDcRFP>78OSzI8`x#SQ_HLyPSfi%i2ht48>F6|=ANHXEqhkf`(JNh93z-NB%o*r zOsipZ+dOTdSc16LBh(V<7X-Yw3qHhu-oxxQdZtsEZ?k3_h}VBlcFlMF2^WmKnek>qLxUM{4PEA4rHK8HJws#C+2KgTi+*g zXVYo*`y}>E{*g)kk!;&9zexNN!y5^T=$Aas5${yhrnt=o;{FXrG4IlNAay1)$&cu} z$7%m8^A{ofJ9^BtZeOI-f1>FBr08c9{bxilMfH*wtw;(Ql&(5hgE~e9hxeq9oF>s; zS@HpP#uw2HyT-Hxg6#{#kVbKj-%1O3wgv|Xs*zqd7PkFFzIqsErmalt@8GSFkc3r_3c!AnS|M`rx4bIiqf=v`BG^6aJP6)qAG zYL=@D$tqji)(6JN{u*Uap>(F|LUrJG32g-LiLNnWj9zM3>tV-(=Zv5xj3<>BSa`9i z#H}0T$&8$&sc8#GLew*i91-n- zS&BxAST)52xnvt=yfVxo9~(q_8>8GE689}U1(=y;?4K}t;7i27L0Ol$1;eY*G-Cfm zFtd3{-@I(exw!Mf&V?=4w$AMgSvsx{e(zM!!q0Z#MPSn#mU;VE^XB!zyqch;CTOU6 zpZ0G2`c610NpJj#so-7;hu_y;sFk)%WUilWhZ895da&k%t?L%8>t;LOH|GY{Uar1W z9W+%e{wOmycgM9yHY~Okdmd-JRds8LZp;K%*+nxL}ymV1ueP z@4_b9OtJsM{`t=NEemTF4lnEumTtUl-E?1%Z1*y_^vv`7&+T8#-Me7?VP7z1#n-_6;^y;;H)`|#E6HoYq3q`n+3NmE$>$+AeS za454XWUTu7z9r+9<&rg*Uwr+=x!qC)7j4I`=Dc0}R&ls-@6E=&A=|Mf>#;|^yKOyo zU&GmIU`$}m3uL|4!uspa)CoPdCurDntuAPxEA?E(ukWCqtNy8}8k$YA~UfXnSU2yHL+qv!cQ@O%cn3?C5uqNcgxs}1($~pD2DeI!)f+1iJ7u78m z)rE>0!bO{dMVqhgS~6|9W64g|e5qVs6>QPt>i9=~>`rF;a{?~}^QwcEo}i&8xUEM_ z#Gz;1PfR61&&>Qs?hayjDsB;)-xbbY7tCHar<9>QGcUY(_A6)Sp9>Z>E}1qg7ZhD? zxzuu1d0ic7xn0nSrYR`Cy!F!7MPj}}W!<5I?zxWToPvwf7p5=1`09%?2HBT#*FPF@ zx)hJZ91E9I3CxMB`l{WFO;v1Q{?t^9?zPC>n^`3fN*WiJ^5=J5E)Eo9bQG<*Jox(H zT<4vFHS?PmDz2^x6>OU8xSLZjuYOba72TVL-!(i@HHz0ivKGMq$`3L>Q7y0y>z-0W zNZRmI(~h5-Hd0**u=@6Yd0QxRE$UL+5U$;|Si9@`)*m*%)4WvM6S5v$tnFD)TzF>Q z{F}#-ob3!+58kolMu)~jXwwxmbX_Y7TIl+|(L6UeU-oA0eC#s8ToJGM#Ruim%j)Hv^*2rH>1Fjle^4nsty|8i zy=khYr**NX@df?!2kVoR@Gn14;j(&ByiVH4)wm4!`d{yuw|u!fTv)eQSQjj)4_X?6 z#)ezkhJVHfG>sqoeifwu^@gJs?wxck#o6ko*K_aWw^ici-Bfn@UVZ7)JG9@=DyF!p zO^G)@Xf8zj!-8GaxcpTrJ^Cvh{r;~kDFaIGuku)~zqYki;Mw1#mQY;17V(eGl=sJ0 z%KPIY^*|Q)}dbbhDN+-E2sucx%c)C3kaY+Zx;~l~y6XWu&aP8Yt`S zY|45&kFwq_%^cXy-EOKwI$UZ#nxPJFC_{RgO^N3DCX+4!6vFM*ssv^aCM|Y?qTNISup+U@+3FrtGXxWQ(pyGiIiPfxLB+FCq zAFg51!z0<3h}>W^Qdk&n$0ez6!r#Idqc&I>immry;kI>t^F6vO470n3?O-J6G!Om- zznGFDN(k6nLil5xZ3fg;()otU*Y`D~PbI|>a2vmSzwGBH3xu1e-S0d-1Zky8ho zxSF^kvmPmaXl>l^ZR+Oh+e<&eSG zf(eF4A*am;H$d=SK1^Hf{JuJDnQ&GMpT*}QiXrSjCcRan8`Qs~XW8fj(IM({bbFJc z0*cro1&V!6C`5!oL6TU}JWb-#X-h;zMf>h(fynq|kr~cAL6v|5Rbg@%E_dOmSgwn( z8-=hz@-6%XJ`)WYGo8zsSr@A=R0aBo_giQRSGO!yw_F?k?sMOIE@bYW)!Z=^2PQ+N zwah!oxlMD2v9)5^U?z4mkagQo0yZaeuL8`8J}+RQ?RFvE+L`vdsk%G*^z$t*wFFY; zRbhSkO?~;YHEV8o?(iHu4*CRJykiSMvJ$dp=VX`i9 zb(D%92@pbk5+nCN#HX1NBAMN(701~IjN^(qnBCD`z$sqm{j?F|xIzRuo_s_>eg!2t z&>4M6jX$vkLaHQ)M)dcR^V2FCHc+W?Fc4KHBSSE~(dPGsz za|Mnix_9>+bKAP-~8-XK6}*{TDN^kzx}T>ByE(XLGhdU4T>InSMt_* z8YRa0t}S8eItg>}DjML=Hi{Y1hlwpxnoJ-JmmbUzo^8IwPRc&~T==gP(LAuinJrVv z35W3cx2U!#PWDs#)M9n(yyN_tb7$uK7pE>vg^i^_V`-?mRq|B3ZoT4X{|^E-HS6Ii zp81Qz?lWP4zVim6h*j#9uI~MvL%nTw96r#~$6$&W_8;6mM9*ngfwb3?(G_B5#u$Vn z719o&RH=uAqkxbRlP?gIuqxJNgmw9gIyj#S>1>RUe%`mJJ90zyw`o603-X79eTRRP z5z-y`qVJ2o&n0~mPCarn^$1MvB_Fs?-KuU4WjuAh>3q{m@O>-YhV)N`^~ZwxW7j)= z*!@oT(xGFE9mj&|V-HhTH6Z(>{2S|(=?vN3%QJM+#4*Xwv=iq_th&SPo78h;KT8Bw zMZ6rMxe4+zy>$T|adC-3ifk-IiR!<{EpeJr!UXcpI1H4U7{KzdAm+yD&NTUwq-h3l~50>SwS*noYxAj`PhgHP1OO zPF$E+7@eC4Wz$CAq%-HI>RUEfQq4uLw``}xYP|WM1L|mh>J=sTIYa^fJ-RHUvAf6g zrxipa%9~TeB!n1Ih4mVEC+0K^IGY#}``U?G6mmyw%#sKBp{ptdkF89$+c9oCyDDbW zD^10i5ZF{qdo@(arkR+<++!x;0%?+iMHt)lu}VoYcFCA4?IZgfv~AHfLUL&2Ut&)f zI!;<${0?weojiZs8sshH(yW^6du*iwzCJM|+fgP%gSE^1(%Qu=|8CGCr(&#-oS!D^ zh+=*68Oe#?{cFtE!am@maDXD(Oa1U5_tT=-WAccjrqx00Ni*)wDOr!Rl0~q z7#;JRfXR+;2nX{B{RCokA`^8lZ2ggkmV!Qlxi7^wGnXGbv}uS(QbeV%ILi(YctkE} zsUfa3tjTI7Odum0?cYFV+VpC~_Gxjic0l#-hh`PaM$`GT=gyv=erY;z==A}(*;vjn zo*zFq9_R>Vl+LQyw6&Pgf1~|xyZ@>?l+iEFT=7rB`u>~xe*E$u^cl0h^Hb-hUfvlv zbX#9M-*VOW_S9Qbx67Woel&RGX!yvpi$|Wl-TCaYA@lr!a|d283k-j`^0uK266uU0 zNaPHeUn-j$d96C6FAl1UVcblz7LvK(Qh;V$!X%rRrDIyw*%YvOfNVS_Yo44tt4WliZt0|-o$y@}dUJYHv%*0|y#xB|E-p(gYHjG#D ztTUH&=JU1ZYG1Ao>neh}ih%v{z@>qic5)h!e<456{#wb*-sM#D&D5f0V_s03cSm0` zuMX+g2GwgHMid$vtYvMi>_;LB^Nk_AjZCZZKtbwn5(0>eOgd{rjk7l9RkXP5R4k~( z`xq?9$^x}7gJl^BLRz9si9zn=|GB&Tv8ORAhl)W!vs-N8l}tK?8BiI&|6hhSj`phr zC91W8`qSD1;xEO6tw=VFlfzo$NFSfnf7An~(t+3Ma6?3|Lh46jkdxS#*Fg9^Jge5;3!Vl@`b&B3Ww30*% zzemq0Vz|_c1W{zkamEw5A%H50Br=PZj@O0DXMgMLLi*LyOC_Wtebnh0Q7*ikeeRJX z;h}RCn2ZZ{L!gk%Ta?Q;ZKcJ6=m#l!B9^$0cruZ2n3{eKgX2T4Sdnp`qQJ;Kx&-S% zEOEVoafFLFj0l6|pg{t|Nb_IeY+>rPZMKDW};}O|hduEU@~2!qP^T{w7O< zJmLADvNY@0eIOqFDargt^~Y^X0<#Jc1TpOUjwCXz{V8%w!?glLBTn}17%MwSNX|km zr~C`x6!5A_?I6LRWC`wADTTJdl6zwKWSn0+X}H+Pi`yi)P02qzY{uJ|zj=0)C=Nq` zI~@p7To7WD;Rrl#LXJ%i|yU7M;icLAqJ?`&-BJ@=0Mf%Dl_OxS{9cCsTe@2#xq6Z`u&VUuU z?N^Z{q9(JTDuXD{e*w87>9TxYw1&}(F2|%B=?p%yGa{=acKVxURZoZJ#*+MZ!{U?ZR-l?y1GPr$V`Hw=&zVx8KU# zJ+tp|uPoV1d36iEZ@h5jg=-sc)jt)=YrAEkA6j_5D2C<)TYPzf*E9 zeKzGM);#hdDtYqjx@*aM&y>5IofAlzI~#a@?f|TAjMgRN+J&s8svXy}mf8*n2aew! zm{>O1!lv>?Q~7-VlBp(Q%6eervhv{+D!&Q_23mbkUl2+yoL4NRmg3x@FLea-szTQF zA!Bt=Tm7CsJ;r9CJy@XD1jSH1Xh7mg)&_hUujBPNg)Idm+#s$IVw0X>1+Pj{6^Mo0 zTV0L@n9Gcr219o_mDF;M5R0lRQigNlTA|R0vVIRr$_e;7h(XXN*xM@!zt zN>*SqRf@Eksv2D5NWX^Y_h>15o04>qqv4aP9OaUwISIO&{uFG+sukx7Zz^WMR5_0* zxUr`))pL@0n`SaF@p84|uF{(%v$XU^7nfN|uAIN2u1)o7V@`$owen2lO@lIAm;Ad? z$jcwyBgtk^9?CGe{f~3;n-}5Job&4Y(F0w-iMieo2T^p}y*E9!q4vmc*8olBAj5oI zRTGD8-i$cU>JXP=p8spspU_VLj|@rsQ~&t>6pt$J&p58EiQ`}JrHt78ew@mV&havg zn9ifxJe-{s*E4d9-~{?;K#98ExO@4yOEu&e^#rBW1e zoF6DUo|xjwMKAW7RxP(u?gf;K$$(sYcrE0iUo1=V>Yu!vD0ZWNqGHc}lMHpgGR|!T z%9gjX#+{7T%Gx(6eTen}=+2`krvxLzg-6siY1uM_*w|{~(+0|;-vVai^o|u&k(?7@ zyvyi^D?O36R;IvNn8;p56!kKPH3zI+xqXJnI;6pIr!uY}AXOxsTX9Sj?Nhy?_!)ha z&3UlPzk@Wh9g;LF+LkeJ%TCA6NZIg7j|aXcu_;D6Z8>fwFJ&^JUwvexGQ-U)=1YH1 z9hel)%DFVoW;&>DNHDcRBthhei6n@8M@157Y{*^kL=4H4qNIqANkAk~1*Ac20AiXW zXG&E{IS?z_*1KYvAhcYjYLmkc0hwP7An%oi>LquxQy)+H!hYxuG;dE zpjlLJ&`eQ)VLkXx&9y^c;x9h^>eIpEjaMDvtgXSUt?$-|O4QGlSKzqMo~jIivBbni z3I{1-Y%)f>bU^TrDE0dk{Qyx!g{?*~)~8hkGmC*6T}Y&vz`DvRa?9jRUwD}!;)R4? zr-*IJc$NxCVce^eS^7s5&qJb3rxrLPdii8;9EUCZ5#`jv?wrhL$ReHcK#(YSd_M7* z@Dwx%_P#{Yke!{CjPnKPm$cW=XT{GeD98-7bQSq%^FwVZ{2qSA*r{Jd6M~(B)3e;= zb?d|Hw&5?hZrhwHY+AEuS`##u1-0$hmDl{Ze2~IrtbrZoXXZZxTh#PTGhKI6)88wq z3Ym{w8Vo3|R($=LYi-}%|E>KuvY`(fziT@V0&U3vSytR~F7JU(75IXRcp_qv58jicsc;tBtoaHw6#% z&FuS0X7Oyw9ZO!I;DL-4gAGvIAX9g^gGH!<)JmH+5ZqA$a&{ zd^fyl;O3?Q*c8nj`t`kF-nHppvdkR{q%gDTzEEz}Lffs}+K{>KmbPwIv#iZHpMEZV zZgS3ZTU&8I9rnGjf7j^0xbIi@En2H*_ARMvKGboUS+7|Z2~%Mcd(m>y@@n)hWIhP7 zE@>t1rkXETkimD_Ps~NLZOfUKi|a0|3zP+(f31GL^j0Pg9)M+1pz5}o^}QG?Ll?>bwxD!(a(=4 zxXh;&qK8mJ#ePox&XyF!j8XwbS$NXr46~x*Q%Kn%E@AoEty zS&~@^#Z)0jo|U=4Xb!ewp%p`%xoApKvr4STq_0RQSz^0eWM~7A0&KfG0rQ<{iL+-^AVCOzw z;!-tb9NcI`e+%avRm8D@|BUt@oYy@e#w2mGr!B(Q_7_H7TId8Pjr;u+fludz^GHF) zkAn|kJu6Uq3BYo0x~+{pYPLZ*>2(ZaimrhO@uXu65=1ZqZk&pNCnsEP+b$Q*)fln) z9Fr$-z{P}X5|+1=zse}ubTTr;C=e}qMAid57bo&0q*D`!pAl>nl~M#2o)fS}vFltZ?R|r8G;?hzqheNt-8X5Q?_}o%N-tMlsto7XE#}uP zyb#LY9?EW+-Hk68EOW!J?8Lc5xYKd@#h3Fg<;`Z?$u9~xE}y(~GF-f2v3SGPjiKVL zq5N&L#$|neV0cMix@;?dGwmyBffp7^7Y+uiTY^P9W_#{f@vGPN(-eCrvvj^Rgn2vl zPKI^aoVjeyTFxz7&dOcR%D&itp?}$AzNo#RT`pL&oRhnpTfCfKaJk}A#d2=RUE8_~ z+ZG1yR@PtIcJ=7pXHG0yj|5LXw_H&5L5azzn^k?VhRd&BHW=s6ELXICpi$-H*(({? z-j!xLpK&f@&KK4e-0xFx8Efy^6{z1#&o4fJlLS*fcFjE87XDEF#!&XAmv>{+O4{a+ z*_z^=wdI{n%I`PTcU5S9uvUS1vJqxH1D;7H)_0IxJa&jJp zcER?X=LC6)@p~{paNsQowNFmc*{^g+g?Jc^cz~BE{wLWtN$h>l7f2chQU>hpJJ@4u zj29xci1n{{qy4U_%j)HKpX)Bs?wdl(*XCQnEyc^m8yZE2;Ok(qIv#vJ2Xxo9qeRdEIu?i!A6-o($e4UR}Bl3dJ)by*Vx89XHenzX2d&m;}m`Yw?DPE4v($p zpxt&xKx?srN9c?@1_T{oQfKQF9pA*ZK#;K$aB{@$8gu%#+j`kvblVyC*qDv&Opr=} zXCXosLTWSu+MC=A)ru(4Oiqumha2h~m_z_i4F^!g+ z&|$)b8u*~YqSBP`d{4UPIsZA<;-IxUha zMggdZm5Row?sdCOa^!9UKAxKN+Su-VIx1W|qAOlaL_LP0fRAIRU7+8fR3q}wne<`@ zn$BfgaSr&JwkR&Hsj;o;m9bI0dH0Q|=MQ>-bs0m@HB~jXj)Sa!B#MM%!D%{j)syQ5 z?@7S{WT0Bfz{}lQXKQncd#d7xq0~wB;&7CEB-*;H@j-**=$3T&c&)7qT^QE}G@gid zvXA%j-3QwE0o%bNaPVIb&;I)`gLSqs90F|exCBhH1{}M$L>SnV!xZJ1C*bbD!;Wr~ zi4PGv@fdaV+QhIM$8$B?Fr$hX?GdKx<5c4EXV{eBq&`}yTz7ilhK-0j?d8BMDQj>O zi9JwA($kA%d)({5z5%h#q`vk6cmYUH*i*|i_p&Vj^zf57KFsN1UjmCTDIFm<230RK z?lzWDL~o|>7H2ED+lZh{vJGU=lFQv8k8j3XNGfxhFcPN`8>kBKU=scxFtp)NCF=$yoVcx~eN>c!4Ty|1x8~`4GBO@-Gmwc1x7mVLEP^gZ=&4wF? zlX^~}2`Bt(sOgFH5w_F#9&?ieJvx|f6lb(!&Xd|a%11bH$|_Cne({VyhUKRn$SgOk z)S0XdiJqWt?D#NnMQ9XhTERCMQ3Zn{oy|7__=rpZQTz`4-cd&_Ov6DMpG0|3f2nC} zrKu#^^iq2QIC0vR$>B-Yn3vI$b`O>u!+vmbr`?`O5Hv6Rsi4q$_`92cLl5vOjIt7u z#FRbm6UVF%^E1^7m?Lp$)LRX84MemX>l#EFxpPND-PTPawI{wPhBFKfj7FbG)kNm5 z8MDk;TjK`8b|+@m5sBgx3gScHV)4xtGOeh(nU;4mq#;ml{Q?9FDq8R!Qk=wrLcwdwJ}}|G;%3%r}stOXG5p;rKl}dO0q-IaHvfxn?Q{$vRNgH$|TsW9OzP%6~@)9_yOW=$NW1!4@3{crj4{ng)IetTiqE*|wYntHNmWv7?(3T1~i%ri-Na4fNSN z4-ED42M_exg&}G!jdOAw9==j01Rvk=mgorK73u2dtRgzS)x>#1vY*DF-l|oCVZ2D zgJ0mFUk#f%CHqLX^DGloVv`IOS6;y8bxhHYN7^_0Jb~i#3bZ>)bmDe}KGBL>A6ZL0 zq>L3sn|d6ZHN-yR@(B@SW>fEfM}lnd@0hdap2t?8(J$95=)&eEXcbL40oSeKhM=jD zZZhHbAh&dW?d{ykb9m$xxD|o} z2t_!n+nH7#;o%b=O)4#<%=2R%Mja|vo7YpnjhF5GM9(V)r;op z1td2vXO%(Y7_@H1Imh?XIOEy}oQC9|_Y7%im3PGBG(}HR3#zL;PM8myw=S8tUhB9+ zKA&Elj-4+{4RiHKXw8mL&dxdYhbk^}+xzgPmi8O1^NqJGYv`bK{3CC=eh2 z=MOSCQyxiKE0HH)g)gtBFP{x&t_vI1EgII5TcEVcfAMt#S3cLN?@m>GKci|-k@EXn zHtx}-{-C-o7dJOlsd#Zir{9yWzmcV-Cq?>hwdO{pX-})>##Z*WRf8u#Qmc@21&aw9 zD{2pE(ti|-Sr3B^Yzg0rEFu=u7+KX2^Xn+vN7_kJcIs*O0-``4yNIkI5IP!CkpiQq zctCqS2$0_Rw^BA)4e6!#5?dH80g0GmQ|1|*ZEPA+%ej(ywxRv1WTF^%b}n(=2?3`- zNu_w>bEYI@!!63gMg*|15`k0_prD2~Cb>f89P zXn*q@rTzn=h@zWK4kD>HlAOPT5qjo+Goprw*NLf#F2LDq^s^9930@f|oRXsj$%JST zj|GYtrv4#vVtWy7KR?&}atn?_OVxkzv%mV;xyf*95q8ntF`Cbxe(7{DyJ}&>g8w@k zzPs^T8<&hv;aGO@#oTlAYv!FWxSmztQ>JJ#V1>;#iI#_qB|&Y;FR-yatS?#A!xNcW zld+tYcX8mtK*0Cf@p(J*PpJCp(5!D+Z<=d+r8!Xcdi5RnWNd!5IpAEdEIj{*IltoG9=l%ME8dGVfC!KrNP)|M z)qxs-29QBB&L^#>GflT(;TONTC(fbFL#!vtz+QN;AaM&qWzwY|$LGkumT|XVc|y4l zY%48==# z$i8~OEh*4B+R8(eF(J^JKN0R^lpsnk$)F-i2_8|V=6KY&Ty6`m_Q+(iMI@P+Ixz{r zC{vT{oH%dlAhFIYzyKpvhLy^jh}Lb@T53O8Ezye6CHAUir+wnGqm*{lvUQ)hY?z9O zVb!v+0Yh$0xtAW-^JtT3zpq-h;S-l_jlvyW68A@EADFg~VFU}n6EaUo2yzYN(Xk#T z`-6>gCj6#cPRFkDZ}?mcQZl;NBso1f z0kjO4GhM>|IZjB1mNhcKynR3AJII>DCbM{XtSe`Y52*pg!ldOjug zb>57P9@5MqUxDdl6vM)*n4bACrmqx!#8YI{Zl_#w?8;ji-^At`J`1K#D`9bs;e>A>nogCSxNBu^>7&*^+WjA=t^e3M ziuVZjTA57|9O<9MUC-zU3;);}N%KvPk}AgK>=6zFzMZH5*4`wXhi`4SZy3vW$OGIF zt*lX^l&FQkkD)sEV@IYhqJz=2O91}|11Ott6D0{G6c&ZjzkxD^AJQ#Jo<-{>Og!S5 zHbNPFF^%LOqVIJlyx4 z`+E$a8ScHhO*jJk%IJdmJNeh!zUL2b+rN}v{z~7R`C`_EtT}&xf2|~xUOL}+Grj!E zC_56HY?fSMYxz0ttZLbueKGF>*6Xjqdit(0YdNoUw*8g8%X#o2{>ol5urg-OZNAui zp*b+Vz=tdiA!B3MxMk6}C1l)2oU$q{hg|$-GoQx$ua<`LHij&lW;@8v%5?EpF8s=p ze(ka)J8W6IXjzNJwj~=d^}$5DV>!S072_Q%c1~W_UDCm`_^wcHeaPAnwr*auZVp+u z!p12LdoTl=FE?Lmo_~&cMh@j{4(GHi=Coj4kN1W2IKkGbFG07j?_!>)!17O3zj2QhD=*XRZV9&g&jvos+fpqo1p|^d9(GwsPkD*PDaJ z3KFK|0ApV6AmK^Q$3Bv5{95jT3ia>W5dCFk!GUV!-&9+Al$swa6o{uwV!KiOlTeL6 z!;EoQgfV;USVTXxr>$?#PbwyNPG%}~DSv0^TaW?kf&{XWh_~h2G;VbN* z3>5u0j8z{o7__9yRAk<#XbyflaiIg+ae1UjMe`s7zjkO>6{;v=drxCa9V~#<_$~{{ zmLe-$04tK|(BLZ}DoT(`!mc86v#W}-7rBfDQepREl#RmTN6q{X;MZ7asIX@-?j3f@ zM#U1zge0Xg&5H)YqA2qogMg3jM0{9V(w8NBO7}jUZrUB<^z@PwDBVKq-g!Kg}m~mm|07DZ{!NcqcN(UHMfPJYYo3aVg z^26$o3f_IVTNgDj5nNsv55NHcYz-a0lVmCgL$lHUx4AbDkLtefh3Cv7&8mIh80`yM z0I?&$LJJaFz#}2CN*N@91VWO}NEjw5R&#q35xW;*;~-+U47F24oz(K%q{eUC$GBT? z+CDkMjG4)pUen&Z?VG3f4{S_`X?ySc`Tou_qtOv2UGDQ9#Ba{>+t=^@MfOx}F!=fZ zjaG-W{E>n1urd;YsA@-jQpZg%W1Tjv&kE`$Kr>A95#lmq9sUGs5L7AdC|iuv;$pmr zm!Vv^|3FtU0qUhHb~-?V>@5BzbnNo4m0T^E&-NFrV@s0-olu;A2elGc{l6kd(KAb| zYm#x|Ll)e~T?L*ET1|`(^0$Ig`B_SOmg*5RH9^2t$TU23dXT`V$#N4D9uH};XE6g) zF>FY{hj5J?3vt6xPaB>ZI{~%`i_U9lOCgF&iKGVhj0iGVB3j6k&LG=+K0rh?>b_ND zjys=uF7w6g=^b~?wwb{f%cmRe+A_}fpX;CN0-ykMX)v7(651?Jr#CE9kr`0iY{^{G zs}&xdKdCSnpE~RO%`<{AY7;E;_e>Y}6Mo6#u zjkZ~xSDyl{rRk1G7Q{cl;xW@8`qT9ChP8%2TWe|5n-*>6M1jHhaSw`cL!0F?(iYKn zm_wYCZRa8Gh-jf;H`0G8b=*u-C7+c<)bUgzi>J;ul1a(bIhaGkk7tm*TuLXS4H?M| z4{ova*+1Bbp@!)}zq3faxGD z;o@M(A}0mQ(YpV!o5cN?^dxOGN6t(RRgu&ovt2H&d3!x1HHp7xho>V%f?N(0F~X(* zfhR>)iS0`GhQCGbMgfW|V(1Wgl_fx@CdXk89WIJsMP_&iQr^!&7IOrGsgO2?^s<@B zW{p+bn`z$>CB7su4U<3vSNlNMnU16w8fuJxPSeoV@ox{jr0T_x$Igzc9ngg|LsdiCmEs<#* zRy=W(Qw=1pB9^u~A>AhDDgOdOngoOdDoTgVdXch)%+; zZpGeMw{c8qqazMaG_%>|h{u!CSEzeL>OFK$MP1zr^sKbP>`3S)oklHkQV7~;nc?YL z*7n8q4W7`t#ARD&QEi>FM&!IF%#I}0*2O$s>!87izV23? z2m4RaIztRlo(Nay!P>=l_xvtXzXyp${W+dvJV1VCy^%{ z$w!6&F(?wr_|8Orpk@VmX*-!nb3*aw43u>wbOpq;stJgdF98rBN(@0@2@4`9q{1~s zIs%hJ6_RcRvVukc!+p#EJ%EQm5d?4sQ{{l%^9VdSl15k!PJBpe1GQn^At71}*9X9S zpz;GGhCUF&1IQf(LMdiw(A={Is#-_JPop)%P7+w=2^vAOD5CfU<%}_!D_{$zDWYFD?W=F-w*UP% zUX~^18CuQeg*#Ki^3j8&wL$nc`>@$Qh2ttBsnQvUBWek$q!lX*H_OQ+mFF6V0#7Io zip7dnFEj(gD#sAF>vx?w3Ca5t{96D%nBxcr`0`|(1-?OsCI^R(I*FOV$YYG`PbUyh z!lokapWpz|4)`G5(Uu8pn#t*9AQK+o66o+)PVh?*Cyh0z@-&{J7IHilC4-x;AxH}n zrCymctY1R0(DMON;!McOW^ljQP|5b7j4I29nlr?a=~ud}AA?WZ3SWV;7y$YfZUCTX zXiQ17>w?C#IZg_)=bjB3S&%yWT+o;>TW~+#SP?VbbU#ItoEJzc_Cg;ysg&#w=0-f- zi%o)wKhMEH@pf|O)2&az-Hhb+e+w51DU8hS9gBfx3<9KW9wR;j@>6>XLUE;Wzv4AnxllYObz-P=wGXuta zFKmI7+%Z;unyiVdyq~5iDw%G(8=nS|ycj8Y^0kbs8NSpCwLI>n7YEYIz3JuiC2-b> zhFQ{YTMF;4DDc$zR;-$7@LO{vPD3zf6BrI1leQ2%LDbjaW)p@ zf}C{=y$h{E<~GKmu)^#}all$KpZ}3{rO#S|Cf!Rfxb(t>7d)qY>D5mzZ1cafun-E% z%O`E|8H*pf*Q9Knejhdltcla@zx@0lJ$|myno`NIOjW>YhZP!YvCnG%7#(lG`+xpC z<7uL##ndc>Et@|9Sic(%%ahD>JEmzTvak6kTTMSqrVX{puzd1`8FHdMN^cImV~ zD#+Sp*WRkNw`LeVOy?-fw(rsyKHSbB{IM==SFYj5IUI%dv|XDFKi>N*J#zhmElg3rbK) z0Sr^sFnN!T*%jfe!2AG*>*bn&zNvRV(@K;NQVc%@;b+oMKIMd>HqdHzq~homK`3^3 z6Yu~S3i9g&LIGeI8LMWpdYx`lzm%>DhVB15ngdVNQ%8@EoQACOUOYr*530$z6_8i6fN4WqnG42d0GZvjF-bes1=&A7TGl_Hij$1H=rGtg#tQ zml5?A?LM zvj_>F(6#L%Hqg_M5C%7}C8k$1477xV03c&gy%d?MCH2xTQ?GARy&N2Ug&n(#%@8-m z(qfRCysktT1hBYhmcj)^W}pl5@?lfG(%uQ>T4*N&$}6K=sJzNZP5=1Oa#BT&(0`Pr z1k7>~03xHPq+~XO*-)uAcfwI1EhWZc5IGJ+H{|yQPTm*?5M1hc?o24&w2S=j8kW?y^>IMN9j zFs9`+W(e&{wb|+_vPf7=ieT+wm3fzdW~h_Nzid0qFj-iND!AQ;)Nm1aq9bFvvU4aprGz{K^>*)o^=Ze`HND;C(m}zxZ(a{>7v(*v!8ol zXSH3H-mi}ojb-5GHR?ci1pOo3ZASVhF>im9mTDd=)^qSp+RFZ(4TCx??W-nk1q=pG0Gh`RRt zNziuSC;>zyP@7gsALWsGQdcCr{FS~Nnh z$q)hKj)D|sB3Z)`>c+<1t_L;++LIe3w*1bI|in2sh$N;R}GN>n&5qkF=ptr50~81xe|qW$7soTS&`Weu~ zzRH)qdZs-XpB;!V@WvMe;)`y_7kw{h;b@?|?RI%vFtdI(CYW71YYgUWykNSQ*{VEl znKcD-ieKsoS`*HFW)inAfBm<7*vI5hcmKYsar1f1?KA-dqW^I5Xou*BGD> zYSq|M@1b(=PzLQ!OQPSB9Za>)GzZgjXIg?8Su?Fr?hII0c&#hm)OniUu*~<|v91N} zCcE%T<>ksZUGwYTfGNJ(nYLhj*6sL`yU7sW3?#3;oxC=f+ceu8Tv0sR613M{X#H8q zGdI(OXZQO`4qVthyAuW6PA?DUx6Z``3(Mz>!J=m_o9-1>;4c3eyep|$FurSh$2R|h zP_%u{6kN0M&1BEMH*#-h)ZByKXzqpFODkSp0kzUg+b(PiBp2RJF8rPqwt&kTZN-_>Z!Sblu~a>>GyQ=&2L#2di*JMcmKmGhy1fjXmIjrtnx58 zWz`}|>`f@Xmy!RHEoii!x16&;%l38MT=Q$vP0MSxpf%_90Z++mC(z}yM=?mLW;o_E zSw0=m5-|#HqpS(jZDFVs^POFU5db21Dnd>SBjm&`u}CsHeoS2FY#}~9DE&DKR!BM< zxcE;s2uQIEKZNz-=f-jNQmi*ti*iv>tlr;13#KFR+^__T2wU$3>zC=AVKbP}MF>Tz zLd!C=)uV&n7>MvpZ%mJL)KBbE6N(IrC#vDAkzRO3(il1Nd=MGi0k z!9{+PYX@B%h4G8@det~)VA?9sbVLs*=em-w1^QiSG=NRqU>$?a%xc1xOJFk*?}x4f zVB2Tm073t>nOAVgQ zk2F@J^h!ieDS3qwF!wB?u!6$`4H7f(1B(KeU!J1X4oVoLAr|e=itk1_Hj?MLZAz019nSGE9!y~9g8E` zk+MM=yQSjGFx^5#33lzls*Jw!HNF3^SiE@8_UJemQR$TMI;9iRUOYSvvOlPV<&I6(b)l@F{oDV&qV4IqFacYp?^ver&mAtqxLaal+&CQMkjTVX6ZUc3t%lXjlnu5%$zYvX^{!vU9Kfd zBxJVD1p{EjnC~AyLAJjDlRi#hKO`_IL7@!3hPo1P5q=X&dz>!f0g$pJHgg!6a+idw z0e2TbCdd+CG}5ry-U$~J)S5|{9TX)R{|DMcl>*2QKJbv}NuCRtnWTIYi7Bej&@ zhM>HF!MqaHlDdP4)gj&3`1AY#Ju<+7f#(d2e}ldnPM(0TXws9T{77mVa^|B@MkAD| zhv0SEKX^iT_(2M20}+jC@1;9pq4RbMXyh5r072_9#x6Y9X_DJL(hwBYHlcGWf}I1*)sv|P>fiH3-UKn3Scq<5ihEMDR?1Ic9j?$^_#eX zIhnYm*$pqAoZk73*|yWnJ=Mkx;1FFPB(~HNMg$DzE^YMMgw`)&bnXS?u#p&(Jg)o#-*G;e(pGc z)UMYud~pRcI`ROK+W3*B@m_q=dG|RtX)gscTV9U2mymqPe8DXGu9#1`p%)VD{)DYi z?jeOTPx7^_t6Bc!mBEyB(bo5?yMLCFb*b&;HsB5oZy3KO=F!?{+L4 z@)y-#P8W9WpN;t>IW4%N;9?6H#?aloTMB<5qJ#4HH@>&gx3bY!+BBzo-3ryIimLgp zw+~!D5U6VKRyEvg_|fhUcKfQjedT*+8~iCH!NQVj8?SDhZMfJTEL-`u@w$;-7YB<< z@Tw!2Uy7eyzr1Hl7Sgx-ZQJkbV{Oe`P;A&mYcO+-SeHf7iJ5LaZ$4+9ZS!Ob$&K>^ z2o^Tp=z$;GfU)V*XNf)hsGEb^zaS8!?U%RvvMc@RmEc+1QqLbgcX+OLUc1mcb2wnz z>bGsZnF!sgOMNf*dFmIOKP-87hYsElyJwkRVw7MYX@fW?25KT{a<94x20)+ zv?{YbQG3fAhw#Ii`n2{M!(XMXZI3hjwT+`N(b8UN`0E@)d#T~COAT~iW9iT=mg*;% z1}6pgfR4fGFOlkx@C#zX8EYkjb`I?BH73xi5=AVAZ6R#;VY^3|ArST!@_*492J#SL zJ4$3*GyC{2;VTSKk)T&U>38zGDJGrQPhujh(gX7uW^H(+8P${T5DI63^w96TUi@A7 zn)oYp0V$#n!te>{X7YLQ=DtSLtZjuBAAQC{x0Vk-?9{cvp)h?B5h$Xm$d@Dq%{}-- zym4xroKx%6IrUD1GsYR~G&(I#tJ4NHBDm$PnsFKZrs+Q9k;B|q{1*x4}4XS zABY|Rx;=&-urhi$_!+a+TB9|rCQMycHGMPrOa^h=g!~jR8-RoPNd#nod_-_GFbi*R zF$KR!lP6$VO*D9hv7l2$x#+OL#XY!#u+BQIIjot4i@!;&gPYW$+Oa*xTAf2nN@0`m z4&P&`g&j`z1WTOhn#mZ40bwjTnVW?D3xsfsfza$QAp~0=X<#a{$5d-@m{~e2>k1v- z#^9ZWy|X#2h#lv!A&hs#Ax!8ov$EpZ_eAL_fjuSl7@(+@$nKLDm5{{Vr8tt2L#iWX zGR={Su1E`|k2;CM2E(r-VO=KBG)LBAvVjT^zVJ*cRi+0Sn7nnmX-#j!nDVs?;s|4S zFJ4_3)G*FcgB3T1^(S73jPjkDN?;f#Mj8csh95D_7_B#ULCan7iqZ8tR(GCWH^{Oj~7K^9u==V2Bd z#P5&|z!-!t`-cWcAYjM;BMJ){j}1Yta*{t2is@%I>O&@YO`Vt`flAEx2E^f?!(fW; zj99_6hq-8umy}FTi|TD5V}Jk1*vMpme+Z(7s?(xcj!lWn^%`QR?*|fA{y3}c%8tD4Ps7dN{ zsY>ef#W*+xwc#*Hq-8JV&VIh}1 z8m!QCDPATAR}qr4Y-vW6rlM2(E0(rgTTDH$(Xw9-6Ko!J{r|3>$*89a`R7-zr~2wh zLGCG0U4X}5Eq6!tgckiwdsozj+#?Sw=_@P_+a39if(>+d;p)e-)Toy-z`UW-(yFPqsWolx5ts^D0Zyq`-d6}#4JF5ZQc*!E3I>d>?Gz( z;T}RAqpsdYH4>eCWc283ag<@+me9N%g_o^}*`!{^il_qc*xS&vh?MInb(DqkX*(zq zpq8n#+HP0wsBo;5=RW4OdQI||*2GbXxCKk!hsmE9yW*wakxFIkB6nttn{vn_cFEE_ zRM+UDecMq5fT!l?1XO^~0>a4PAdXXJx&_Ze>%1r7 zAY?G4A00Y63D8kbninBGayjj`I)=y*m8hQKPG}I>uSy<^ph`Ll&=l?gwV{r~VWn#2 zGHk^XmFD=3=%4E#lt4xvuI^zP*v0Qa`X#FAtCl^s8B`F4@MN#CEp&_%y&uQ;&F&xd zf6y=N{ffWI$*>4#s4oa6rqaEzzfBMQv@F+I{Efb6T}G)80)7f}>i;!Hgu zWF%4zCK@BbL7EmC95Bd?`7}Cp5)NGTuHo_LX<%u-5h^%RiHt{vDbOQe^3%wSU9^j_ z$y0_KHhGqjNyhX+Yw4ot(|n!dzk|F8<3YI$926CcMYxZ@h>xtB_fj|08Gz|Qc%WJs(Nx4EuydJl}$#(8lOiQ|u5Kpgy#flfpDbj&}wcl&)7luvm4{ zspDM7T%qUqOoz|9I$&MzwXVO>d@EV#>K3f){Z`nKmMZ31aii${n)hlbt6c%>Zm)H> zu=k*FWI(X)_FD&km8^vPwKoReAAN6>@@flM_js*);K)`O7!<5~{MI3*Od)^GjmGzP zy|;@Z?hIJlz1DW@QbPY%1Z%tB>b$Slq_k;av??Q;c?Ryjw(sh`g~^*0!v29dt1n{^ zI8tjcEzhI%G|x9Iq}@omvF|2-Ym?BkUpP1{96u?HpAo)#7UY_G?M|}5)T(W#pHA%o z`Z=WSqo1#6Pteav?F9YsS~vZCP5T1b(KobgN*yB z9><}zX~3D*;cHG*ay;U#3ctA)jvUJMk9GDM`=S$!2DX;KpN+T?y}xv8$c*lDiE>qr z0YrkxGT`(cPI4NL4q^6_aGH^i2EHDN8MT!E72)fI(zr z@AL$}?J(`DgjSGseIb9$TQxk-`9@D(%m1BxU)Asz{25F>x2}54QN|_%Gl$e(mN%?O z2eim!qRtV`30BixJ_U9PiYQRv1b`|H^r9a<#Lh#}PPOdn{v?WD{%jDNMW&!!9%Mok znVd`R3vO@z*u3-YW7m%@Jom2q9rw-EKidAmcAENZcJsNw&R*}%USa=%!2YA&{YQf-IakIn zkKf{cV*QErmUSVYOuXH2c~>_G2gIyLynQ1$T>>Xu-V-j6`%|X4```miapD?BiqxC2lHnT4`v4Hd zHc3`GDZ==KA{j8D>$R(u-lHR|V3`&XRWa%!8KK@)YV^;r5}89QBfpSV?~tSsG-|X^ zxqm<=hw%aEY!Rx){|qBSfFtOWJk3Ia=Dv$bO1?G7SPnTu0T?tq0v0CJX?gl&V9u{Z zo1Kij6s^wWi6*t`#0&HN#9aA7JlNk}FD$)BN2^OsxH3Z>IY z3t$E*u>i!A2xrS}u(AfPj9eZGYfUk&5!O;DvxDr9hnCXClJadCV#dw*TpmS|8I4|>tIM0(%QXL1dPRxCF-QiS6)G%LlSNlLr4^1{lwfsc|3 z2wV+=47Jz3diARdwZ75~GobZue8M(yfNc{{gaQ3TZL7cnTmwoC9mj-lM_mx&r}-6i zg-xF?c4^R?q!ybNY8zGzv(=%hp{X_!&>@ZFrb#s*CQ`)K!tGJS^ukq?7iV~jiGJhI zMQk;yy&|V2#z$V?=!;Ho>NR$HQ`ySt4bbL!njT3#&^iUH zhV)L!vy%ZE4o-Ge?rnu@$mjN+=}x_5>yaR?={QKC27l0Ilg(CUUbs3 zf3qv3%0F%JuQQCn*o?ij!!yAH`{p&+trLB zKZc2u(CHllwg>F-n0U~G?X6+newb>jbXZdq+QZnPy|^J{U3d9KAvI)-YWGXxuz63j2z$>^Xaz_Hz zJg+s+({ydu)m`4|zCS;8>*!BLKOFt5G5^lPzUn?-QJ*ky(m(c`z@G~6XTAJc-`ETH zHDK1&6Gm`^+o64^(G>5{3f8<|Xf(D42;iVbi-ujzX!=ZPCoq8|X)ta?!vH`G0y~FHBK{90WwwKX5Wx-< zc8p*YaEypj^dQrSG`;1STXKv^8kluRi-Fm&<=r#yoC$2$<=wE$zoFfi+~M8OzMz{c zy0Y@}O3%PYSrxvNm3ZY%?hvdUU))aaV6*JEa<}Ribs{yG?fib0uX-n9-@+`$uBM90 z$Plt*+X`KY)y9Pxt!;L(yTZ!0>ZQp000V+l5FXSbh3%diHNoUCXo2+&^g~|X=y(Dc zNaK{9`P6@s<^$~x1Q_r)5y+4(Q<-3+L|Zh}9>ifvA&1B(;h3E)-O2hrp~~g<1hv@D z)9?Y*!SDf3&B(d*wToZFQJ$U^NH4|T?etP6n)B@rpt2c7kngc2&Zb<-xsXEy6L^98 z>cy`jIxu8tsm^uXNyvYo(_|DbT^9<}I*4Y~8J2@vm0?R|3_Y{jN+C#2CfNc0B0Xr; zH6pGgMKYa>7;07ARUYadEayNq2XFg_(ndO`>HnlN?;oo*c_ z$UaPv7#yTTJC3>{>J+1fPs??Ze@U8s2onyHq$K!jwgi0mov@A%$tBKC*Ie;P8yl`lOi< z-uKF5L;4+4t}~BwL_P&nzwswK9hL+Hsyq4L!)fJ?m$(wNng~#LTRNd>#_S+%w!3r9 z4O8%oOJ3{A4v?hJCXz*A(#OhAVNQdz4eB(rQLljUq6XChorg@?=~axrC=^NdZWyR8 zo{=_jA7%*c5z5RUf{JiH2%Ev6pF*rrJP1YMMkAxe!b305M5Gv@bd<8Bj$>k)i}a%) zv>+2sf17e&mT)Q(g@2v)1S4uq@(S_E(00O~5MKo4r9eWVH=)qeEL86DC$#-EAuYJF z=Iy%QtMesoyk6&VUaXyc?$W6Xr(W6O;a=W$y>6yKNZNQeB?BA8>K;c?%;o2L= zf)#5&H|SF1W}1Kiq-EeV&&Zr<{Wv`bTS<1_l?|6SyjkmUT|0gC^c!2Dc?DISh#SkZa4^X1l0ZJLbS`*E6_f|uIwT8lj0*A87hw6OL@ zw%-b)xCummBNI5*G*M+L2<7*r+_{mtgF;#JpSwWBulumhw{xG+ykAH-@C$>^7Wc(x zg_`6|O!ngsS2R{FKBknjo(`Eh14O)H8ILMzjniY)zGiv4eGUAsC*bC^zro)V_8Pzn zu?k>GumE;jI%?pLiGx9Md=oE{xuP9OhBf4M1cQ~~IFQFujl=db@-M~8Yh{O?R&CEx z+KOJ0^y#%~R6*!-JP*$sVl3z(L}=V-)o6fnZKLwviCvD`3@U;I|7gc!yJF>7$AI(D zZYe(xf*lP}5%m#>>47AaBNnnySSEBy=Sx`7L_C+&Q^TC;;&jWDVur)nMfp3dVA_fW zBE+bx&`{NOdxtH&FLxvU?qt|jJ8l$g3srh95#zt)k|%>S`2d;q$@Ei@ZDlBp(NF&diNRGS2n`JGF8qin z43#KT9D8sd+OizjB}%1*d2cE>nTK{<)IuVuwp`yg&%K}bCwW9teQVzrE&@Eio?OIz z$6O@(qr@}2kWOs(JZ_YBw|rTYuxW}Z*Ba7N&SC4xju>ePC0@P zjAn$aD(;M?#d;eG&)z*JsdUKZ02Tf0GFLgXu&96(k_I!g&Pb*gNz=ncI_p>8ju zV(vcq4TD@*?O+EQRRF^090l~ETBW^X#4DyMo)pbcGK-XBWCGJJ!9~oNX0_Ye#n3_# zFmmTfPo$6!dpsw}peSi?h?!J(yUh~h1=F2VG`U$i>NWsTlDl3=6)>8@06r^MEbf5* zaSzD0Qe6zr)EGd~-8gZUq4z4wxsXj*oG9Y;WayCyF_4axBh$T-GSZ7-mq~3u?$U!m zD8)xgWnr}Sh?6}`VCstLiUodPASq$G&=%D5d8Fl&CUnR>L3|$>s?n)?lX`Tu5i+=K zKp|roB*L(aAM;&$WPmJD zCz+>ZQSep=6clL~mg=WSf0I(lJ)w_Y9$}Ly6%x$^5x%F1zPs%RLwMq{9^!&}!O^!E9S`)3=z-3cOfPQm9!P4%W5X*V-&trHxF?o=K2rj*Y% zd76SL8JC(bG?Olk$FN`!R;<61vH|3#>OSrs5aO5%F{F(nWY^wFSj!M%VEv7#6u1FS z*vS|JV~h|7{x$_aroe}og) z{1#tc%gk>0r}|Vn7ap)*gthOtXTPW z+4Zu85pTtI_Tp|!udr`_U|*kiUtgf5{}Dx+?((dZI?O3eQwaFrhS^FNlbr~s7Wh~h+J`B%Vjo75#kWxzD|?6Ld8k0Dwc-nMubr{si_c=vHdiht*q_*% zOJhD*m(pCN|I@fQ3iC54+!ljy5y(Zm8Fp#Yga3(9oQ4iD^Q(cE(U%&oBV!=)E=b1n znLaW=HdugKq!WQ%oKA$OOU^;$z(u%5!rlsaAHHy#A!ItCcR=5Sh{xgu3WLrO6RFKl z1b)O&fSfk;0`00DmM}ijn-qplQ5z8Pqc1h+T&^K%0zit9{d;s=@5gyG2V$+c#I!p_est;nf;APxPfGg4=f*aaH6N!mVfA z&e(4L6D;=TPX6Z@dbfq_g|oeWvmI^^3Eu962^pr6VJ3zrlu(@`lj3Ga#C{RrD?vQw zT~8!;UZh8&dGZANg$zw6CnnE`iWvV$Z~tEelE7GmGr_;3Pi%K1>Zr(dqsby!h7nn! zlr)sH+|ERskal3|PZP-Zh%NOo@imsNYF80j^7XnMwVDq!u@oBX`e}9Ga;3-lEP^hk-@PRXni5(6IV696U{j4 zlFP@chk%R7+c1cSXBnc36Xr6*c<>(kYlhFAeI8IhD*W*{Kv}H1ON}4ur^EpRv(VA zZxn1)P0GMp!nw+K;ekU=*!ZHa@OMd|;-AnR$7Z+hkpk*6^lb=B-7sdJ3YVnM+-C-N1;a=e=vhy+2!ijVC<~3zTAco*E;6n|tyIog z;Bba6zymTKr^cain4xke^*$>Z6u{`qVFYHzk*?~f;%8ahp~@|t?syWSC(pJcW6aS7 zn0ib^%{mE_(afL(WWV`uqjf+KNb;VkN}L&pEQJOQ4d5R918(Z{gc3h#dsZ8IdAbl1 zjbT=q1`ISYGVlr8YuG$Y7csF4(_vGE7!K^Itij7yT9b?NyF&%bhp?BV;;%Vzb#s2gg6c&tqeqXd!vqB6u8M#3@1|v7*95SVLGkD@C<=ZAc4`9si63OEeoo zYk!qakTqleg}Q?FuU}4Ut{?f{pp5Olj1K=fVfc`PW7wy$dmQ|=Q;`2>8n*w4Pf$0Q zV|5qFW5H&e`;qWW{FaVL9Nw`^33X{qj zB!>j>IUy<%kalibsoz>gpDQm^zEX|%Ftx!>gZb`2)i!U{wp(4oz5}!EzLbOHrZJH; z1Lk&kpk_en@C*}yFLgZ9#@h1k1EI^ipFlp1nfibyeW8Ga<<|wQo4wY}H)93sX1}#X z%4F94=Jq?*T9NOLHY%5Ls*IvV>{Ky1&18CjohnMS@QvEmL823C8^$sOVV#&8M9QH5 zAK?cPiyEguxG3dL4bodCZ_FsV#w`(CdG$P1fDH9k$fuvWB{*=$X$DKzxTz~_ag1%1 z;QL!Qp;jVEop{j8v{B_;>#{%*+O$}Nj_^?#-y(a}rBcb#$&UPcbR@LFH}S_%k_zpx z%Myv-j)S+PCM7TDE4s>3kMm^7SJYgz$ks?lMpxW2s-|++Mf{>P6^s-rozl!5H$y@I z*n9K^6Bjwr@4}YUV1=#=wNO#}M0rb-DHhmRY57Yd^l4CD!P58g=l~WntEp%rolQ;c za#6bSrTO+Y^&SH(g0ZEGXo(k8NR!FYTIvY|n0vJ*zsu_`JH zJOW~gfK--=V|t1>u$c|WhuPlg4|g<%YOI{!cK&?gi#JT!7_cyiH^MjHu(dT5D~&`_7|-wgF|U0P|nG7?h{2M9{)YLuc}1OpAC zQ;CGsWe7t$9py+L%V45H@$2FR6u~f1MAuc>28J-Jz(FO@2F)(CO+{=yJlrtT5fdJ6 z)LsP;?TRqB&IUop6I#jWG$f3!218K=M1?{--7%2r3k(r|iUKBu7Rzfe6#OWCe-&?` zxhNXc2t!#7_{Knh5<4iuGFSzn&yy!dCPT|AnJ9XJVaQ5Mk`6M0i$|saDS{b!SIn2q zuUP{b72b>rUqGFY4bks|sXUA_&4S~pivjPmj!i)CE(OJEmjlIAI!%Ve3aQ%& z&qBA&pL+Z3^|St(?J_tcgq+P3HfIbpPZ)eYO7kus<$#n272`Afdo-Qw^uj7KEcgPU($x?{9c-gDTFKxQ8i3}=x3g;8&YoTbkVZPk+Yrf=lGmW1l zr&FBF+;h8T>SrO>v`BG)gt+qYC&cGtri%v50tpq~gbIH`WiUQz*7?%uU}nyhoXa`T z?HIW_azh{3*y`QbI+x?i+y#5dSTNva&DE0ew2u;2-b38X%bA|F*EU_<iwU{u+NmEwh!zohsF2iJYoKrv~W~jRGcd43SWqij0;v z4Dp6h>b|^<7ja-QK@C9LG3s$5Ay_8gq>FUj3o{|=^@sP3s59LZij_Ci6 zq=musC1RKzNVk5o8S>Gav9716bUcuKduAC_(xnLD+Y$kj(6Xu&lHPmxgsPJ@Ay?9F zORRCGN|7__J#s=g+~+C#u4V4m=9{2VvFnFb3oC9KZzbN^{5QF`;y=pW=R0yjQmt57 z#erLbk<)Ze5r$zlLl}YykGKs?n8F?3$e*c#q$)WQAVqsfi!(AOS+^JE#u@AQzaz0I zMk?utF;n|D=q)jAW&JRTP5V1|wY;{4R8lcD>>J2|PG4(+LcY8Us)nBU9lm6krNW*Q z771+?Y8w0rjp2>Tn=>jLIVv1Ge&@&uU(Tp6d32`nF1DJrFP@oO^U_)DLvv)He(f7) z7LvaEHP6^WlaR6bqlC?%oyY>3f_?SET2<{PV!;72G(8}MpCCgL6%j8iUc8IGp0Idr zYAlPTLgo=7k|ah62BW-nD0xSUTCzp05=R%24~*Wa<`XH736prLaRB0z_7IUC5t^9b zqM3;Z{aa`s5rZVk6oH z`%*ozD(yE$#DR%E)i|bzI56F(8pqsam0K`qrxrvA+{AUoV-~2Q#xS0oD2wSsY$tNo zU_M1(@(2UjQlBMBj^u75`ah-HgfNvnUOUnvY6h_<)J%cYpFB6AIG&F58O!xE_CB-R z*B~zwl9kE-D<(T%fH7Pw5Q!J!#@mP+W#dJ4qBmZV?=EqqK{`|fA%USYK}ZzCwpX2K zW_dfxd*VL2tX&;uHbi9?T}PV@Vf$Nnx6Ss&5|ICC4JEV6PiiP%j-uC7-E-u+XC1ye=t*2Ke*c89^^mvv(3RESS^dV~ z7kmX`E;<(f(<;5`X(~V>i&T7>P#ysM5~dGM1-QrZTKQP;i9wd@iE9~%9g~LESKGBCvQwuB+#k2Cug3QtKF%h ztSEFgnCBc(q;mVeah_?3@bQFQKvqf>cL5lL<=Zi8d6!pfvfB1 zob#u@e^98~@2%Q@h5HWo#`^!0(`|Qnx_ykAFM$MErJEL2I&V+Xaj?H2XUipyCqAvT{ToB%x0BOHfcZ#BDQ7FXH>T3P^@HUz7tHM-8j{r zFdog+w0zW$?YEiF%1sN@G9ZjJv0Zn?D(-j9tGI5aDizWMgbZ9_(<>%^HM7o5Z&qgl6)TDd?0qc@zPcYld^5UXs!4^L2VC`NQC536UQ z^IliTtO!85c-nYioNEf^>Oi0%EAnhsld3*z3%-fH2KJa;)7 z4m%82(MY_2dIHRT0%*NcsH*oT>;PyD!{lB%AblAvzSNeP=DX=xUOIGqS%-b;hi6*u zX6ATlKlkMf`!a`TcHPa$_R?1A%iiV7*frC7FR8?H$}D}?^E%$XATMF1vkakQi@g|oDrQ2@C29jHDC%3TrC`wj; zT=`LLvoCcgDwUG%rB&`rKj2F_Fw+DB5nftAzRZ2Tw0$!>*)wLHFY{Sn+Osn|VNC1p z3W!K%%&qiH21+-2OE=yu4OXv#ikLlVR)5jTL@1UjZ-Eb&?vjIpjklVF5{KZ}FC6IeJ5PRe;H0pB3=&j{6WlKhdRv@G zJfK5az6f(<_{C|Aj%d64S7VsTI02Nj;2w#oOyxryQK!y|P{TlabX|&?E5r*(#i>wS z`x9`N1jSCOW7I{aow~FTsKDH#3q%bC3kW;*1UJ7}>}=;AOisF)Df`jsPp8JLFcx; zw15d0oz{5jJ1(w zkp2d(ANJgc12E_E?3mZxNvMR_e!*&zrbNPkl`P%N9lcWx0qfj5*1cbRX40e_=3J

    vrC}^d?P07iUA(ICZ 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 Oexn%xmOhIKF#LZ@Z*4gM literal 0 HcmV?d00001 diff --git a/__pycache__/scope_triage.cpython-313.pyc b/__pycache__/scope_triage.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed51da08bec17d7eda8036063d7f905960356052 GIT binary patch literal 29278 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 literal 0 HcmV?d00001 diff --git a/nonwork_controlled_apply.py b/nonwork_controlled_apply.py new file mode 100644 index 0000000..426adfc --- /dev/null +++ b/nonwork_controlled_apply.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +""" +Controlled APPLY for the approved non-work recovery routing plan. + +This script intentionally: +- copies only rows marked COPY_NEW +- never moves or deletes source files +- never overwrites an existing destination +- verifies all source hashes immediately before applying +- re-verifies all copied files after writing +- re-verifies SKIP_EXACT_DUPLICATE targets +- only writes inside the two approved recovery folders +- writes an apply report back into the staging MANIFESTS directory + +Syncthing for 03_PRIVAT and 01_INBOX must already be paused before running. + +Rollback model: +The two recovery folders were confirmed absent before APPLY. Therefore every +file created there belongs to this import. Do NOT delete them automatically on +failure; inspect the apply report first. The original staging/source files are +never deleted. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import os +import shutil +import sys +from collections import Counter +from pathlib import Path + + +CHUNK = 1024 * 1024 + +PRIVATE_BASE = Path("/data/03_PRIVAT/01_INBOX/Recovered_Work-PC_2026-09-14") +INBOX_BASE = Path("/data/01_INBOX/Recovered_Work-PC_2026-09-14") +ALLOWED_COPY_BASES = (PRIVATE_BASE, INBOX_BASE) +ALLOWED_SKIP_ROOTS = (Path("/data/03_PRIVAT"), Path("/data/01_INBOX")) + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(CHUNK), b""): + h.update(chunk) + return h.hexdigest() + + +def is_under(path: Path, root: Path) -> bool: + try: + path.resolve(strict=False).relative_to(root.resolve(strict=False)) + return True + except ValueError: + return False + + +def read_manifest(path: Path) -> list[dict[str, str]]: + with path.open("r", encoding="utf-8", newline="") as f: + rows = list(csv.DictReader(f, delimiter="\t")) + + if not rows: + raise RuntimeError("Routing manifest is empty.") + + required = { + "effective_scope", + "relative_path", + "filename", + "source_path", + "sha256", + "audit_status", + "dry_run_action", + "proposed_destination", + "source_delete", + } + missing = required - set(rows[0]) + if missing: + raise RuntimeError( + "Routing manifest is missing columns: " + + ", ".join(sorted(missing)) + ) + return rows + + +def validate_before_mutation(rows: list[dict[str, str]]) -> list[str]: + problems: list[str] = [] + seen_destinations: set[Path] = set() + counts = Counter(row["dry_run_action"].strip() for row in rows) + + if len(rows) != 18: + problems.append(f"Expected 18 manifest rows, found {len(rows)}.") + if counts["COPY_NEW"] != 14: + problems.append(f"Expected 14 COPY_NEW rows, found {counts['COPY_NEW']}.") + if counts["SKIP_EXACT_DUPLICATE"] != 4: + problems.append( + "Expected 4 SKIP_EXACT_DUPLICATE rows, found " + f"{counts['SKIP_EXACT_DUPLICATE']}." + ) + unexpected = set(counts) - {"COPY_NEW", "SKIP_EXACT_DUPLICATE"} + if unexpected: + problems.append( + "Unexpected actions present: " + ", ".join(sorted(unexpected)) + ) + + # The approved recovery folders must still be absent at APPLY start. + for base in ALLOWED_COPY_BASES: + if os.path.lexists(base): + problems.append( + f"Approved recovery folder already exists before APPLY: {base}" + ) + + for row in rows: + rel = row["relative_path"] + action = row["dry_run_action"].strip() + source = Path(row["source_path"]) + expected = row["sha256"].strip().lower() + destination_text = row["proposed_destination"].strip() + + if row["source_delete"].strip().upper() != "NO": + problems.append(f"{rel}: source_delete is not NO.") + + if not source.is_file(): + problems.append(f"{rel}: source missing: {source}") + continue + + try: + actual = sha256_file(source) + except Exception as exc: + problems.append( + f"{rel}: source hash error: {type(exc).__name__}: {exc}" + ) + continue + + if actual.lower() != expected: + problems.append( + f"{rel}: source hash mismatch; expected {expected}, got {actual}" + ) + + if action == "COPY_NEW": + if not destination_text: + problems.append(f"{rel}: COPY_NEW has no destination.") + continue + + dest = Path(destination_text) + if not any(is_under(dest, base) for base in ALLOWED_COPY_BASES): + problems.append( + f"{rel}: destination outside approved recovery folders: {dest}" + ) + continue + + norm = dest.resolve(strict=False) + if norm in seen_destinations: + problems.append(f"{rel}: duplicate proposed destination: {dest}") + seen_destinations.add(norm) + + if os.path.lexists(dest): + problems.append(f"{rel}: destination already exists: {dest}") + + elif action == "SKIP_EXACT_DUPLICATE": + targets = [Path(p) for p in destination_text.split(" | ") if p] + if not targets: + problems.append(f"{rel}: duplicate target missing.") + continue + + for target in targets: + if not any(is_under(target, root) for root in ALLOWED_SKIP_ROOTS): + problems.append( + f"{rel}: duplicate target outside approved roots: {target}" + ) + continue + if not target.is_file(): + problems.append(f"{rel}: duplicate target missing: {target}") + continue + try: + target_hash = sha256_file(target) + except Exception as exc: + problems.append( + f"{rel}: duplicate target hash error {target}: " + f"{type(exc).__name__}: {exc}" + ) + continue + if target_hash.lower() != expected: + problems.append( + f"{rel}: duplicate target hash mismatch: {target}" + ) + + return problems + + +def copy_exclusive(src: Path, dest: Path) -> None: + """ + Copy bytes with exclusive creation so an existing file can never be + overwritten. Metadata is copied afterwards. + """ + dest.parent.mkdir(parents=True, exist_ok=True) + + # 'xb' maps to O_CREAT|O_EXCL semantics. + try: + with src.open("rb") as rf, dest.open("xb") as wf: + for chunk in iter(lambda: rf.read(CHUNK), b""): + wf.write(chunk) + wf.flush() + os.fsync(wf.fileno()) + shutil.copystat(src, dest, follow_symlinks=False) + except Exception: + # If this invocation created an incomplete destination, remove only + # that incomplete file. Never touch a pre-existing file because 'xb' + # would have failed before opening it. + try: + if dest.exists(): + dest.unlink() + except Exception: + pass + raise + + +def write_report(path: Path, rows: list[dict[str, str]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fields = [ + "effective_scope", + "relative_path", + "filename", + "source_path", + "destination_path", + "sha256", + "planned_action", + "apply_status", + "postcopy_sha256", + "error", + ] + with path.open("w", encoding="utf-8", newline="") as f: + w = csv.DictWriter(f, fieldnames=fields, delimiter="\t") + w.writeheader() + w.writerows(rows) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--manifest", required=True, type=Path) + ap.add_argument("--report", required=True, type=Path) + args = ap.parse_args() + + manifest = args.manifest.resolve() + report = args.report.resolve() + rows = read_manifest(manifest) + + print("=== NON-WORK CONTROLLED APPLY ===") + print(f"Manifest: {manifest}") + print(f"Report: {report}") + print() + + problems = validate_before_mutation(rows) + if problems: + print("PRE-APPLY VALIDATION FAILED — NOTHING WAS COPIED") + for item in problems: + print(" -", item) + return 2 + + print("PRE-APPLY VALIDATION PASS") + print("Creating only the two approved recovery folders and copying 14 files.") + print("No source file will be moved or deleted.") + print() + + results: list[dict[str, str]] = [] + fatal = False + + for i, row in enumerate(rows, 1): + action = row["dry_run_action"].strip() + src = Path(row["source_path"]) + dest_text = row["proposed_destination"].strip() + expected = row["sha256"].strip().lower() + + result = { + "effective_scope": row["effective_scope"], + "relative_path": row["relative_path"], + "filename": row["filename"], + "source_path": str(src), + "destination_path": dest_text, + "sha256": expected, + "planned_action": action, + "apply_status": "", + "postcopy_sha256": "", + "error": "", + } + + if action == "SKIP_EXACT_DUPLICATE": + result["apply_status"] = "SKIPPED_VERIFIED_DUPLICATE" + results.append(result) + print( + f"[{i:>2}/{len(rows)}] SKIP {row['filename']} " + "(verified exact duplicate)" + ) + continue + + dest = Path(dest_text) + + try: + copy_exclusive(src, dest) + post = sha256_file(dest) + result["postcopy_sha256"] = post + + if post.lower() != expected: + result["apply_status"] = "POSTCOPY_HASH_MISMATCH" + result["error"] = ( + f"Expected {expected}, got {post}; destination retained " + "for inspection." + ) + fatal = True + print( + f"[{i:>2}/{len(rows)}] FAIL {row['filename']} " + "(post-copy hash mismatch)" + ) + results.append(result) + break + + result["apply_status"] = "COPIED_AND_VERIFIED" + print( + f"[{i:>2}/{len(rows)}] COPY {row['filename']} VERIFIED" + ) + + except Exception as exc: + result["apply_status"] = "COPY_ERROR" + result["error"] = f"{type(exc).__name__}: {exc}" + fatal = True + print( + f"[{i:>2}/{len(rows)}] FAIL {row['filename']} " + f"({type(exc).__name__})" + ) + results.append(result) + break + + results.append(result) + + # If execution stopped early, record untouched remaining rows. + processed = len(results) + if processed < len(rows): + for row in rows[processed:]: + results.append({ + "effective_scope": row["effective_scope"], + "relative_path": row["relative_path"], + "filename": row["filename"], + "source_path": row["source_path"], + "destination_path": row["proposed_destination"], + "sha256": row["sha256"], + "planned_action": row["dry_run_action"], + "apply_status": "NOT_ATTEMPTED_AFTER_FAILURE", + "postcopy_sha256": "", + "error": "", + }) + + write_report(report, results) + + print() + print("=== APPLY SUMMARY ===") + counts = Counter(r["apply_status"] for r in results) + print(f"COPIED_AND_VERIFIED: {counts['COPIED_AND_VERIFIED']}") + print( + f"SKIPPED_VERIFIED_DUPLICATE: {counts['SKIPPED_VERIFIED_DUPLICATE']}" + ) + print(f"COPY_ERROR: {counts['COPY_ERROR']}") + print(f"POSTCOPY_HASH_MISMATCH: {counts['POSTCOPY_HASH_MISMATCH']}") + print( + f"NOT_ATTEMPTED_AFTER_FAILURE:{counts['NOT_ATTEMPTED_AFTER_FAILURE']}" + ) + print(f"Apply report: {report}") + + if fatal: + print() + print("APPLY INCOMPLETE — KEEP SYNCTHING PAUSED.") + print("Do not delete recovery folders automatically; inspect the report first.") + return 3 + + # Final independent pass over all 14 created files. + verify_problems = [] + for row in rows: + if row["dry_run_action"].strip() != "COPY_NEW": + continue + dest = Path(row["proposed_destination"]) + expected = row["sha256"].strip().lower() + if not dest.is_file(): + verify_problems.append(f"Missing after apply: {dest}") + continue + actual = sha256_file(dest) + if actual.lower() != expected: + verify_problems.append( + f"Hash mismatch after apply: {dest} expected {expected} got {actual}" + ) + + print() + if verify_problems: + print("FINAL VERIFY FAILED — KEEP SYNCTHING PAUSED.") + for item in verify_problems: + print(" -", item) + return 4 + + print("APPLY PASS") + print("14 new files were copied and SHA-256 verified.") + print("4 exact duplicates were skipped.") + print("0 source files were moved or deleted.") + print("KEEP SYNCTHING PAUSED until the separate post-apply verification step.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/nonwork_execution_preflight.py b/nonwork_execution_preflight.py new file mode 100644 index 0000000..ae95ac4 --- /dev/null +++ b/nonwork_execution_preflight.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +""" +Read-only execution preflight for the non-work recovery routing plan. + +Checks: +- routing manifest is structurally valid +- every source file still exists +- every source SHA-256 still matches the approved manifest +- every COPY_NEW destination is still absent +- COPY_NEW destinations remain inside the approved recovery folders +- no two COPY_NEW rows collide on the same destination +- every SKIP_EXACT_DUPLICATE target still exists and still has the same SHA-256 +- no HOLD/ERROR actions are present + +This script NEVER creates directories, copies files, moves files, renames files, +deletes files, or modifies Syncthing state. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import os +import sys +from collections import Counter +from pathlib import Path + + +CHUNK = 1024 * 1024 + +ALLOWED_COPY_BASES = ( + Path("/data/03_PRIVAT/01_INBOX/Recovered_Work-PC_2026-09-14"), + Path("/data/01_INBOX/Recovered_Work-PC_2026-09-14"), +) + +ALLOWED_SKIP_ROOTS = ( + Path("/data/03_PRIVAT"), + Path("/data/01_INBOX"), +) + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(CHUNK), b""): + h.update(chunk) + return h.hexdigest() + + +def is_under(path: Path, root: Path) -> bool: + try: + path.resolve(strict=False).relative_to(root.resolve(strict=False)) + return True + except ValueError: + return False + + +def read_manifest(path: Path) -> list[dict[str, str]]: + with path.open("r", encoding="utf-8", newline="") as f: + rows = list(csv.DictReader(f, delimiter="\t")) + + if not rows: + raise RuntimeError("Routing manifest is empty.") + + required = { + "effective_scope", + "relative_path", + "filename", + "source_path", + "sha256", + "audit_status", + "dry_run_action", + "proposed_destination", + "source_delete", + } + + missing = required - set(rows[0]) + if missing: + raise RuntimeError( + "Routing manifest is missing columns: " + + ", ".join(sorted(missing)) + ) + + return rows + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--manifest", required=True, type=Path) + args = ap.parse_args() + + manifest = args.manifest.resolve() + rows = read_manifest(manifest) + + print("=== NON-WORK ROUTING EXECUTION PREFLIGHT ===") + print(f"Manifest: {manifest}") + print(f"Rows: {len(rows)}") + print() + + problems: list[str] = [] + warnings: list[str] = [] + checked = Counter() + + copy_destinations: dict[Path, str] = {} + + for i, row in enumerate(rows, 1): + action = row["dry_run_action"].strip() + source = Path(row["source_path"]) + expected_hash = row["sha256"].strip().lower() + destination_text = row["proposed_destination"].strip() + + label = f"[{i:>2}/{len(rows)}] {action:<22} {row['filename']}" + + # Safety invariant from the approved dry-run manifest. + if row["source_delete"].strip().upper() != "NO": + problems.append( + f"{row['relative_path']}: source_delete is not NO." + ) + + # Every row's staging source must still exist and match. + if not source.is_file(): + problems.append( + f"{row['relative_path']}: source missing: {source}" + ) + print(label + " SOURCE_MISSING") + continue + + try: + actual_hash = sha256_file(source) + except Exception as exc: + problems.append( + f"{row['relative_path']}: cannot hash source: " + f"{type(exc).__name__}: {exc}" + ) + print(label + " SOURCE_HASH_ERROR") + continue + + if actual_hash.lower() != expected_hash: + problems.append( + f"{row['relative_path']}: source hash changed; " + f"expected {expected_hash}, got {actual_hash}" + ) + print(label + " SOURCE_HASH_MISMATCH") + continue + + checked["source_hash_ok"] += 1 + + if action == "COPY_NEW": + if not destination_text: + problems.append( + f"{row['relative_path']}: COPY_NEW has no destination." + ) + print(label + " DESTINATION_MISSING") + continue + + dest = Path(destination_text) + + if not any(is_under(dest, base) for base in ALLOWED_COPY_BASES): + problems.append( + f"{row['relative_path']}: COPY_NEW destination is outside " + f"approved recovery folders: {dest}" + ) + print(label + " DESTINATION_OUTSIDE_APPROVED_BASE") + continue + + normalized = dest.resolve(strict=False) + if normalized in copy_destinations: + problems.append( + f"Destination collision: {dest} is proposed for both " + f"{copy_destinations[normalized]!r} and " + f"{row['relative_path']!r}" + ) + print(label + " DESTINATION_COLLISION") + continue + + copy_destinations[normalized] = row["relative_path"] + + if os.path.lexists(dest): + problems.append( + f"{row['relative_path']}: proposed destination now exists: " + f"{dest}" + ) + print(label + " DESTINATION_ALREADY_EXISTS") + continue + + checked["copy_new_ready"] += 1 + print(label + " READY") + + elif action == "SKIP_EXACT_DUPLICATE": + if not destination_text: + problems.append( + f"{row['relative_path']}: duplicate row has no target path." + ) + print(label + " DUPLICATE_TARGET_MISSING") + continue + + # The dry-run format allows multiple exact target matches separated + # with " | ". Every listed target must still be identical. + targets = [Path(p) for p in destination_text.split(" | ") if p] + if not targets: + problems.append( + f"{row['relative_path']}: no duplicate targets parsed." + ) + print(label + " DUPLICATE_TARGET_MISSING") + continue + + row_ok = True + for target in targets: + if not any(is_under(target, root) for root in ALLOWED_SKIP_ROOTS): + problems.append( + f"{row['relative_path']}: duplicate target outside " + f"approved roots: {target}" + ) + row_ok = False + continue + + if not target.is_file(): + problems.append( + f"{row['relative_path']}: duplicate target missing: " + f"{target}" + ) + row_ok = False + continue + + try: + target_hash = sha256_file(target) + except Exception as exc: + problems.append( + f"{row['relative_path']}: cannot hash duplicate target " + f"{target}: {type(exc).__name__}: {exc}" + ) + row_ok = False + continue + + if target_hash.lower() != expected_hash: + problems.append( + f"{row['relative_path']}: duplicate target hash changed: " + f"{target}" + ) + row_ok = False + + if row_ok: + checked["skip_duplicate_verified"] += 1 + print(label + " VERIFIED") + else: + print(label + " DUPLICATE_VERIFICATION_FAILED") + + elif action.startswith("HOLD"): + problems.append( + f"{row['relative_path']}: unresolved HOLD action remains." + ) + print(label + " HOLD_PRESENT") + + else: + problems.append( + f"{row['relative_path']}: unexpected action {action!r}." + ) + print(label + " UNKNOWN_ACTION") + + # The recovery directories themselves should still be absent at this stage. + print() + print("=== RECOVERY FOLDER STATE ===") + for base in ALLOWED_COPY_BASES: + exists = os.path.lexists(base) + print(f"{base}: {'EXISTS' if exists else 'ABSENT'}") + if exists: + problems.append( + f"Recovery folder already exists before apply: {base}" + ) + + print() + print("=== SUMMARY ===") + print(f"Source hashes verified: {checked['source_hash_ok']}") + print(f"COPY_NEW ready: {checked['copy_new_ready']}") + print(f"SKIP duplicates re-verified: {checked['skip_duplicate_verified']}") + print(f"Problems: {len(problems)}") + print(f"Warnings: {len(warnings)}") + + if warnings: + print() + print("Warnings:") + for item in warnings: + print(" -", item) + + if problems: + print() + print("PREFLIGHT FAILED — DO NOT APPLY") + for item in problems: + print(" -", item) + return 2 + + print() + print("PREFLIGHT PASS") + print("No files or directories were created, copied, moved, renamed, or deleted.") + print("Syncthing state was not modified.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/nonwork_hash_audit.py b/nonwork_hash_audit.py new file mode 100644 index 0000000..fb61761 --- /dev/null +++ b/nonwork_hash_audit.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +""" +Read-only exact-hash audit for scope-triaged files. + +Purpose: +- Compare files routed to 03_PRIVAT / 01_INBOX against the existing + authoritative /data/03_PRIVAT and /data/01_INBOX trees. +- Detect exact duplicates using full-file SHA-256 only. +- Also report cross-scope exact matches. +- Never move, rename, delete, overwrite, or modify target files. + +The only output is a TSV report written to the staging MANIFESTS directory +(or another explicitly supplied path). +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import os +import sys +from collections import defaultdict +from pathlib import Path + + +CHUNK = 1024 * 1024 + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(CHUNK), b""): + h.update(chunk) + return h.hexdigest() + + +def index_tree(root: Path) -> tuple[dict[str, list[Path]], int, list[str]]: + by_hash: dict[str, list[Path]] = defaultdict(list) + count = 0 + errors: list[str] = [] + + if not root.is_dir(): + raise RuntimeError(f"Target root does not exist or is not a directory: {root}") + + for path in sorted(root.rglob("*")): + try: + if path.is_symlink(): + continue + if not path.is_file(): + continue + digest = sha256_file(path) + by_hash[digest].append(path) + count += 1 + except Exception as exc: + errors.append(f"{path}\t{type(exc).__name__}: {exc}") + + return dict(by_hash), count, errors + + +def read_manifest(path: Path) -> list[dict]: + with path.open("r", encoding="utf-8", newline="") as f: + rows = list(csv.DictReader(f, delimiter="\t")) + + required = {"origin", "relative_path", "effective_scope"} + missing = required - set(rows[0].keys() if rows else []) + if missing: + raise RuntimeError( + f"Manifest is missing required columns: {', '.join(sorted(missing))}" + ) + + return rows + + +def resolve_candidate(stage: Path, row: dict) -> Path: + origin = row["origin"] + rel = row["relative_path"] + + if origin == "SOURCE": + return stage / "SOURCE" / rel + + if origin == "ARCHIVE": + # Current non-work audit should not normally contain archive items, + # but resolve them defensively if that changes later. + return stage / "ARCHIVE_EXTRACTED" / "WORK" / rel + + raise RuntimeError(f"Unknown origin {origin!r} for {rel!r}") + + +def join_paths(paths: list[Path]) -> str: + return " | ".join(str(p) for p in paths) + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--stage", required=True, type=Path) + p.add_argument("--manifest", required=True, type=Path) + p.add_argument("--privat-root", type=Path, default=Path("/data/03_PRIVAT")) + p.add_argument("--inbox-root", type=Path, default=Path("/data/01_INBOX")) + p.add_argument("--report", required=True, type=Path) + args = p.parse_args() + + stage = args.stage.resolve() + manifest = args.manifest.resolve() + report = args.report.resolve() + + rows = read_manifest(manifest) + candidates = [ + row for row in rows + if row["effective_scope"] in {"03_PRIVAT", "01_INBOX"} + ] + + print("=== NON-WORK EXACT-HASH AUDIT ===") + print(f"Manifest rows: {len(rows)}") + print(f"Candidates: {len(candidates)}") + print(f" 03_PRIVAT: {sum(r['effective_scope'] == '03_PRIVAT' for r in candidates)}") + print(f" 01_INBOX: {sum(r['effective_scope'] == '01_INBOX' for r in candidates)}") + print() + print("Hashing authoritative target trees (read-only)...") + + privat_idx, privat_count, privat_errors = index_tree(args.privat_root) + inbox_idx, inbox_count, inbox_errors = index_tree(args.inbox_root) + + print(f"/data/03_PRIVAT files hashed: {privat_count}") + print(f"/data/01_INBOX files hashed: {inbox_count}") + print(f"Target hash errors: {len(privat_errors) + len(inbox_errors)}") + + results = [] + candidate_errors = 0 + + for n, row in enumerate(candidates, 1): + candidate = resolve_candidate(stage, row) + scope = row["effective_scope"] + + result = { + "effective_scope": scope, + "origin": row["origin"], + "relative_path": row["relative_path"], + "filename": row.get("filename", candidate.name), + "candidate_path": str(candidate), + "sha256": "", + "same_scope_status": "", + "same_scope_matches": "", + "cross_scope_status": "", + "cross_scope_matches": "", + "audit_status": "", + "error": "", + } + + try: + if not candidate.is_file(): + raise FileNotFoundError(candidate) + + digest = sha256_file(candidate) + result["sha256"] = digest + + same_idx = privat_idx if scope == "03_PRIVAT" else inbox_idx + cross_idx = inbox_idx if scope == "03_PRIVAT" else privat_idx + + same = same_idx.get(digest, []) + cross = cross_idx.get(digest, []) + + result["same_scope_status"] = ( + "EXACT_DUPLICATE" if same else "NOT_FOUND" + ) + result["same_scope_matches"] = join_paths(same) + + result["cross_scope_status"] = ( + "EXACT_MATCH_OTHER_SCOPE" if cross else "NOT_FOUND" + ) + result["cross_scope_matches"] = join_paths(cross) + + if same: + result["audit_status"] = "DUPLICATE_IN_DESTINATION" + elif cross: + result["audit_status"] = "MATCH_IN_OTHER_SCOPE_REVIEW" + else: + result["audit_status"] = "UNIQUE_VS_PRIVAT_AND_INBOX" + + except Exception as exc: + candidate_errors += 1 + result["audit_status"] = "ERROR" + result["error"] = f"{type(exc).__name__}: {exc}" + + results.append(result) + + print( + f"[{n:>2}/{len(candidates)}] " + f"{scope:<9} " + f"{result['audit_status']:<29} " + f"{result['filename']}" + ) + + report.parent.mkdir(parents=True, exist_ok=True) + with report.open("w", encoding="utf-8", newline="") as f: + fieldnames = list(results[0].keys()) if results else [] + writer = csv.DictWriter(f, fieldnames=fieldnames, delimiter="\t") + writer.writeheader() + writer.writerows(results) + + print() + print("=== SUMMARY ===") + statuses = defaultdict(int) + for r in results: + statuses[r["audit_status"]] += 1 + + for key in ( + "DUPLICATE_IN_DESTINATION", + "MATCH_IN_OTHER_SCOPE_REVIEW", + "UNIQUE_VS_PRIVAT_AND_INBOX", + "ERROR", + ): + print(f"{key}: {statuses[key]}") + + print(f"Target hash errors: {len(privat_errors) + len(inbox_errors)}") + print(f"Candidate errors: {candidate_errors}") + print(f"Report: {report}") + + if privat_errors or inbox_errors or candidate_errors: + print() + print("AUDIT INCOMPLETE: errors occurred; do not proceed to routing.") + return 2 + + print() + print("AUDIT COMPLETE: no files were moved, renamed, deleted, or overwritten.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/nonwork_post_apply_verify.py b/nonwork_post_apply_verify.py new file mode 100644 index 0000000..c1e36a0 --- /dev/null +++ b/nonwork_post_apply_verify.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +""" +Independent post-apply verification for the non-work recovery import. + +Read-only checks: +- all 18 staging sources still exist and retain approved SHA-256 hashes +- all 14 COPY_NEW destinations exist and match approved SHA-256 hashes +- all 4 SKIP_EXACT_DUPLICATE target files still exist and match +- recovery folders contain exactly the 14 planned files and no extras +- apply report contains 14 COPIED_AND_VERIFIED + 4 SKIPPED_VERIFIED_DUPLICATE +- no apply failures are recorded + +No files or Syncthing settings are modified. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +from collections import Counter +from pathlib import Path + + +CHUNK = 1024 * 1024 + +PRIVATE_BASE = Path("/data/03_PRIVAT/01_INBOX/Recovered_Work-PC_2026-09-14") +INBOX_BASE = Path("/data/01_INBOX/Recovered_Work-PC_2026-09-14") + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(CHUNK), b""): + h.update(chunk) + return h.hexdigest() + + +def read_tsv(path: Path) -> list[dict[str, str]]: + with path.open("r", encoding="utf-8", newline="") as f: + return list(csv.DictReader(f, delimiter="\t")) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--manifest", required=True, type=Path) + ap.add_argument("--apply-report", required=True, type=Path) + args = ap.parse_args() + + manifest = args.manifest.resolve() + apply_report = args.apply_report.resolve() + + rows = read_tsv(manifest) + report_rows = read_tsv(apply_report) + + problems: list[str] = [] + + print("=== INDEPENDENT POST-APPLY VERIFICATION ===") + print(f"Manifest rows: {len(rows)}") + print(f"Apply report rows: {len(report_rows)}") + print() + + if len(rows) != 18: + problems.append(f"Expected 18 manifest rows, found {len(rows)}.") + if len(report_rows) != 18: + problems.append(f"Expected 18 apply-report rows, found {len(report_rows)}.") + + # Verify apply report statuses independently. + report_counts = Counter(r.get("apply_status", "") for r in report_rows) + expected_report = { + "COPIED_AND_VERIFIED": 14, + "SKIPPED_VERIFIED_DUPLICATE": 4, + } + + for status, expected in expected_report.items(): + actual = report_counts.get(status, 0) + if actual != expected: + problems.append( + f"Apply report status {status}: expected {expected}, found {actual}." + ) + + unexpected_statuses = { + k: v for k, v in report_counts.items() + if k not in expected_report and v + } + if unexpected_statuses: + problems.append( + "Unexpected/non-success apply statuses: " + + ", ".join(f"{k}={v}" for k, v in sorted(unexpected_statuses.items())) + ) + + # Verify sources, destinations, and duplicate targets. + verified_sources = 0 + verified_destinations = 0 + verified_duplicates = 0 + planned_destinations: set[Path] = set() + + for i, row in enumerate(rows, 1): + action = row["dry_run_action"].strip() + expected = row["sha256"].strip().lower() + source = Path(row["source_path"]) + + # Source must still be intact. + if not source.is_file(): + problems.append(f"Source missing: {source}") + else: + actual = sha256_file(source) + if actual.lower() != expected: + problems.append( + f"Source hash mismatch: {source}\n" + f" expected {expected}\n" + f" actual {actual}" + ) + else: + verified_sources += 1 + + if action == "COPY_NEW": + dest = Path(row["proposed_destination"]) + planned_destinations.add(dest.resolve(strict=False)) + + if not dest.is_file(): + problems.append(f"Copied destination missing: {dest}") + print(f"[{i:>2}/18] COPY MISSING {row['filename']}") + continue + + actual = sha256_file(dest) + if actual.lower() != expected: + problems.append( + f"Destination hash mismatch: {dest}\n" + f" expected {expected}\n" + f" actual {actual}" + ) + print(f"[{i:>2}/18] COPY HASH_FAIL {row['filename']}") + else: + verified_destinations += 1 + print(f"[{i:>2}/18] COPY VERIFIED {row['filename']}") + + elif action == "SKIP_EXACT_DUPLICATE": + target_text = row["proposed_destination"].strip() + targets = [Path(p) for p in target_text.split(" | ") if p] + if not targets: + problems.append( + f"No duplicate target listed for {row['relative_path']}" + ) + print(f"[{i:>2}/18] SKIP NO_TARGET {row['filename']}") + continue + + row_ok = True + for target in targets: + if not target.is_file(): + problems.append(f"Duplicate target missing: {target}") + row_ok = False + continue + actual = sha256_file(target) + if actual.lower() != expected: + problems.append( + f"Duplicate target hash mismatch: {target}\n" + f" expected {expected}\n" + f" actual {actual}" + ) + row_ok = False + + if row_ok: + verified_duplicates += 1 + print(f"[{i:>2}/18] SKIP VERIFIED {row['filename']}") + else: + print(f"[{i:>2}/18] SKIP VERIFY_FAIL {row['filename']}") + + else: + problems.append(f"Unexpected action in manifest: {action!r}") + + # Recovery folder contents must equal exactly the 14 planned destinations. + print() + print("=== RECOVERY FOLDER INVENTORY ===") + + actual_files: set[Path] = set() + for base in (PRIVATE_BASE, INBOX_BASE): + if not base.is_dir(): + problems.append(f"Recovery folder missing: {base}") + print(f"{base}: MISSING") + continue + + files = sorted( + p for p in base.rglob("*") + if p.is_file() and not p.is_symlink() + ) + print(f"{base}: {len(files)} file(s)") + actual_files.update(p.resolve(strict=False) for p in files) + + extra = sorted(actual_files - planned_destinations) + missing = sorted(planned_destinations - actual_files) + + if extra: + problems.append( + "Unexpected extra files in recovery folders:\n " + + "\n ".join(str(p) for p in extra) + ) + if missing: + problems.append( + "Planned files missing from recovery folders:\n " + + "\n ".join(str(p) for p in missing) + ) + + print() + print("=== SUMMARY ===") + print(f"Sources verified: {verified_sources}/18") + print(f"Copied destinations verified: {verified_destinations}/14") + print(f"Skipped duplicates verified: {verified_duplicates}/4") + print(f"Recovery files found: {len(actual_files)}/14") + print(f"Extra recovery files: {len(extra)}") + print(f"Problems: {len(problems)}") + + if problems: + print() + print("POST-APPLY VERIFY FAILED — KEEP SYNCTHING PAUSED.") + for item in problems: + print(" -", item) + return 2 + + print() + print("POST-APPLY VERIFY PASS") + print("The 14 copied files and 4 skipped duplicates are independently verified.") + print("All 18 staging source files remain intact.") + print("No unexpected files exist in the two recovery folders.") + print("This script made no filesystem or Syncthing changes.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/quick_sorter.py b/quick_sorter.py new file mode 100644 index 0000000..fa924b8 --- /dev/null +++ b/quick_sorter.py @@ -0,0 +1,1863 @@ +#!/usr/bin/env python3 +""" +Quick Sorter v9.0 + +Purpose: fast human-reviewed file sorting/renaming with an optional local LLM. +One screen, one proposal per file, no spreadsheet round-trip, no per-file Q&A. + +V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +to sibling roots such as 03_PRIVAT, 04_ARBEIT and 05_VAULT. Archives are +identified before document analysis and left for the deliberate unpack/repack workflow. + +Safety: +- Scan and LLM analysis are read-only. +- Nothing moves until a row is explicitly confirmed. +- Preview is always available before Apply. +- Existing targets are never overwritten. +- Exact target collisions are full-SHA-256 compared: identical sources are removed only on Apply; different content is preserved as a visible _variant-N file. +- Every real Apply is logged. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import shutil +import json +import os +import re +import threading +import queue +import zipfile +import xml.etree.ElementTree as ET +import urllib.request +import unicodedata +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +# ---------------- configuration ---------------- + +IGNORE_DIR_NAMES = { + ".git", ".stfolder", ".stversions", "__pycache__", "node_modules", + ".venv", "venv", ".idea", ".vscode", +} +IGNORE_FILE_PATTERNS = [ + re.compile(r"^\.sync-conflict-.*"), + re.compile(r"^Thumbs\.db$", re.I), + re.compile(r"^\.~lock\..*#$"), + re.compile(r"^~\$.*"), + re.compile(r"^\.directory$"), + re.compile(r"^desktop\.ini$", re.I), + re.compile(r"^\.DS_Store$"), +] +STANDARD_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}_.+") +UMLAUT_MAP = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +DEFAULT_ENDPOINT = os.getenv( + "QUICK_SORTER_LLM_ENDPOINT", + "http://localhost:8080/v1/chat/completions", +) +DEFAULT_MODEL = os.getenv("QUICK_SORTER_LLM_MODEL", "daily") +DEFAULT_FALLBACK_MODEL = os.getenv("QUICK_SORTER_LLM_FALLBACK_MODEL", "architect") +LOG_FILENAME = ".quick_sorter_log.csv" +MAX_EXCERPT = 2400 +ARCHIVE_SUFFIXES = ( + ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", + ".tar.bz2", ".tbz2", ".gz", ".bz2", ".xz", +) + + +# ---------------- data model ---------------- + +@dataclass +class Record: + path: Path + root: Path + target_root: Path + rel_path: str + filename: str + ext: str + current_folder: str + date_guess: str + date_source: str + proposed_name: str + destination: str + confidence: float = 0.0 + reason: str = "" + status: str = "pending" # pending/suggested/review/confirmed/skip/done + candidates: list[str] = field(default_factory=list) + excerpt: str = "" + content_status: str = "not-read" + topic: str = "" + subtopic: str = "" + document_type: str = "" + detail: str = "" + version: str = "" + name_title: str = "" + name_subtitle: str = "" + collision: str = "" # exact duplicate / different-content variant handling + duplicate_of: str = "" # full-SHA-256 match found by the global pre-pass + source_context: str = "" # provenance: source-root name + relative source folder + + @property + def target(self) -> Path: + return self.target_root / self.destination / self.proposed_name + + +def is_archive_file(path: Path) -> bool: + """Recognise common archive/container formats before LLM analysis.""" + name = path.name.lower() + return any(name.endswith(suffix) for suffix in ARCHIVE_SUFFIXES) + + +# ---------------- date logic ---------------- + +def _valid(y: int, m: int, d: int) -> Optional[str]: + try: + return date(y, m, d).isoformat() + except ValueError: + return None + + +def date_from_filename(name: str, *, allow_legacy_yy_mm_dd: bool = True) -> Optional[str]: + """Conservative parser: known filename dates beat filesystem metadata. + + Legacy ``YY-MM-DD`` remains supported for real filenames because older files + in this archive use it. Parent-folder parsing disables that ambiguous form + unless surrounding wording (for example German ``Archiv bis DD-MM-YY``) + makes the intended interpretation clear. + """ + stem = Path(name).stem + + # ISO date already present + m = re.search(r"(? first day of range + m = re.search(r"(? 20YY-MM-DD. This form is ambiguous with DD-MM-YY, + # so parent-folder parsing deliberately disables it unless context resolves it. + if allow_legacy_yy_mm_dd: + m = re.search(r"(? Optional[str]: + """Optional embedded metadata; never required.""" + ext = path.suffix.lower() + try: + if ext == ".docx": + import docx + created = docx.Document(str(path)).core_properties.created + return created.date().isoformat() if created else None + if ext == ".xlsx": + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + created = wb.properties.created + return created.date().isoformat() if created else None + if ext == ".pdf": + from pypdf import PdfReader + created = PdfReader(str(path)).metadata.creation_date + return created.date().isoformat() if created else None + if ext in {".jpg", ".jpeg", ".tif", ".tiff"}: + from PIL import Image, ExifTags + image = Image.open(path) + exif = image.getexif() + tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None) + raw = exif.get(tag_id) if tag_id else None + if raw: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S").date().isoformat() + except Exception: + return None + return None + + +def date_from_parent_folders(path: Path, root: Optional[Path] = None) -> Optional[str]: + """Use a parent-folder date only when it plausibly dates the document. + + When ``root`` is supplied, only parent folders *inside* that scan root are + considered. The root directory itself and every ancestor above it are + deliberately excluded. This prevents staging/workspace names such as + ``FULL_STAGING_2026-09-14`` from leaking into document dates. + + Snapshot/cutoff folders such as ``Archiv bis 25-10-16`` describe the + archive boundary, not every document inside it. Their date must therefore + never become the document date. Bare two-digit hyphen dates in other parent + folders remain ambiguous and are not treated as verified dates. + """ + if root is not None: + try: + parts = path.parent.relative_to(root).parts + except ValueError: + # A caller supplied a boundary that does not contain this file. + # Be conservative: do not inspect arbitrary ancestors outside it. + return None + else: + # Backwards-compatible behaviour for direct helper calls that do not + # provide a scan root. Production scanning always passes the root. + parts = path.parent.parts + + for part in reversed(parts): + normalised = _normalise_alias_text(part) + + # Archive cutoff/snapshot metadata is provenance, not a document date. + # Example: "Archiv bis 25-10-16" can contain documents from 2023-2025. + if "archiv" in normalised.split() and "bis" in normalised.split(): + continue + + # Four-digit and dotted dates remain available, but ambiguous legacy + # YY-MM-DD is disabled for parent folders. + parsed = date_from_filename(part, allow_legacy_yy_mm_dd=False) + if parsed: + return parsed + return None + + +def best_date(path: Path, root: Optional[Path] = None) -> tuple[str, str]: + # Established priority for this archive: filename -> trustworthy date-folder -> mtime. + # Parent-folder lookup is root-bounded so workspace/staging names can never + # become document metadata. + from_name = date_from_filename(path.name) + if from_name: + return from_name, "filename" + from_folder = date_from_parent_folders(path, root=root) + if from_folder: + return from_folder, "folder" + return datetime.fromtimestamp(path.stat().st_mtime).date().isoformat(), "mtime" + + +# ---------------- content + token helpers ---------------- + +def sanitise_token(text: str) -> str: + result = text.strip().lower() + for src, repl in UMLAUT_MAP.items(): + result = result.replace(src, repl) + result = re.sub(r"\s+", "-", result) + result = re.sub(r"[^a-z0-9\-]", "", result) + result = re.sub(r"-+", "-", result) + return result.strip("-") + + +def tokens(text: str) -> set[str]: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return {t for t in re.split(r"[^a-z0-9]+", folded) if len(t) > 2} + + +# Generic document-type words are useful as a warning sign, but poor context. +# If a filename is mostly made of these words, the model must use the excerpt +# to recover the actual project/event/subject before proposing a new name. +GENERIC_NAME_WORDS = { + "ablaufplan", "plan", "planung", "protokoll", "handout", "praesentation", + "präsentation", "rechnung", "abrechnung", "liste", "teilnahmeliste", + "feedback", "infos", "info", "programm", "entwurf", "vorlage", "dokument", + "scan", "schicht", "treffen", "teamtreffen", "aufgabenliste", "todo", +} + +def is_generic_filename(name: str) -> bool: + stem = Path(name).stem + # Ignore date/version fragments; judge the semantic remainder. + words = [w for w in re.split(r"[^A-Za-zÄÖÜäöüß0-9]+", stem.lower()) if w] + words = [w for w in words if not re.fullmatch(r"(?:20)?\d{2,8}|v\d+", w)] + if not words: + return True + generic = sum(1 for w in words if w in GENERIC_NAME_WORDS) + # One lone generic title ("Ablaufplan") or a name dominated by generic + # document-type words needs content-derived context. + return len(words) <= 2 and generic >= 1 or (generic / len(words) >= 0.60) + + +def _clip(parts: list[str]) -> str: + text = "\n".join(x.strip() for x in parts if x and x.strip()) + return text[:MAX_EXCERPT] + + +def extract_excerpt(path: Path) -> tuple[str, str]: + """Return (text, status). + + Status is deliberately visible in the UI so missing optional libraries or + text-less/scanned documents never fail silently. + """ + ext = path.suffix.lower() + try: + if ext in {".md", ".txt", ".csv", ".html", ".htm"}: + text = path.read_text(encoding="utf-8", errors="ignore")[:MAX_EXCERPT] + return text, f"text:{len(text)}" + + if ext == ".docx": + try: + import docx + except ImportError: + return "", "missing:python-docx" + doc = docx.Document(str(path)) + parts = [] + props = doc.core_properties + for label, value in ( + ("title", props.title), ("subject", props.subject), + ("keywords", props.keywords), ("comments", props.comments), + ): + if value: + parts.append(f"{label}: {value}") + # Normal paragraphs + parts.extend(p.text for p in doc.paragraphs if p.text.strip()) + # Tables are crucial for schedules, plans and forms. + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + # Headers/footers can carry event/project names. + if sum(len(x) for x in parts) < MAX_EXCERPT: + for section in doc.sections: + parts.extend(p.text for p in section.header.paragraphs if p.text.strip()) + parts.extend(p.text for p in section.footer.paragraphs if p.text.strip()) + text = _clip(parts) + + # Some DOCX files keep visible text in drawing/text-box XML that + # python-docx does not expose through doc.paragraphs or tables. + # Only use this raw XML path as a fallback to avoid duplicating + # ordinary paragraph/table text. + if not text: + xml_parts = [] + try: + with zipfile.ZipFile(path) as zf: + for name in zf.namelist(): + if not (name.startswith("word/") and name.endswith(".xml")): + continue + try: + root = ET.fromstring(zf.read(name)) + except ET.ParseError: + continue + for node in root.iter(): + if node.tag.endswith("}t") and node.text and node.text.strip(): + xml_parts.append(node.text.strip()) + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in xml_parts) >= MAX_EXCERPT: + break + except (OSError, zipfile.BadZipFile): + xml_parts = [] + text = _clip(xml_parts) + + return text, f"docx:{len(text)}" if text else "docx:no-text" + + if ext == ".pdf": + try: + from pypdf import PdfReader + from pypdf.errors import DependencyError + except ImportError: + return "", "missing:pypdf" + try: + reader = PdfReader(str(path)) + parts = [] + meta = reader.metadata + if meta: + for key in ("/Title", "/Subject", "/Keywords"): + value = meta.get(key) + if value: + parts.append(f"{key[1:].lower()}: {value}") + for page in reader.pages[:3]: + parts.append(page.extract_text() or "") + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pdf:{len(text)}" if text else "pdf:no-text-or-scan" + except DependencyError as exc: + if "cryptography" in str(exc).lower(): + return "", "missing:cryptography" + raise + + if ext == ".xlsx": + try: + import openpyxl + except ImportError: + return "", "missing:openpyxl" + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + parts = [] + for ws in wb.worksheets[:2]: + parts.append(f"sheet: {ws.title}") + for row in ws.iter_rows(max_row=20, values_only=True): + line = " ".join(str(c) for c in row if c is not None) + if line.strip(): + parts.append(line) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"xlsx:{len(text)}" if text else "xlsx:no-text" + + if ext == ".pptx": + try: + from pptx import Presentation + except ImportError: + return "", "missing:python-pptx" + prs = Presentation(str(path)) + parts = [] + # python-pptx Slides supports integer indexing but slicing can + # raise AttributeError. Iterate normally and stop after 5 slides. + for slide_index, slide in enumerate(prs.slides): + if slide_index >= 5: + break + for shape in slide.shapes: + text = getattr(shape, "text", "") + if text and text.strip(): + parts.append(text) + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + if sum(len(x) for x in parts) >= MAX_EXCERPT: + break + text = _clip(parts) + return text, f"pptx:{len(text)}" if text else "pptx:no-text" + + if ext == ".odt": + try: + from odf import text as odf_text + from odf.opendocument import load as odf_load + from odf.teletype import extractText + except ImportError: + return "", "missing:odfpy" + doc = odf_load(str(path)) + parts = [extractText(n) for n in doc.getElementsByType(odf_text.P)] + text = _clip(parts) + return text, f"odt:{len(text)}" if text else "odt:no-text" + + return "", f"unsupported:{ext or 'none'}" + except Exception as exc: + return "", f"error:{type(exc).__name__}" + + +FILENAME_PREFIXES = { + "Allgemein": "Allgemein", + "Cafe_Bach": "CB", + "Checkpoint": "Checkpoint", + "CSD-Kampagnen": "CSD", + "Ehrenamt": "Ehrenamt", + "FAK": "FAK", + "SP5-Treffen": "SP5", + "Vor-Ort-Arbeit": "VOA", +} + + +def _filename_prefix(destination: str) -> str: + branch = destination.split("/", 1)[0].strip() + return FILENAME_PREFIXES.get(branch, branch.replace("_", "-") or "Datei") + + +GENERIC_FILENAME_LABELS = { + "dokument", "document", "datei", "file", "unterlagen", "sonstiges", + "misc", "info", "information", +} + +CATEGORY_NOISE_LABELS = { + "allgemein", "anleitungen", "kommunikation", "materialien", "planung", + "protokolle", "veranstaltung", "veranstaltungen", "verwaltung", "vorlagen", +} + +PREFIX_ALIAS_PHRASES = { + "Cafe_Bach": ("Café Bach", "Cafe Bach", "Cafe_Bach", "cafebach", "CB"), + "Checkpoint": ("Checkpoint", "CP"), + "CSD-Kampagnen": ("CSD-Kampagnen", "CSD Kampagnen", "CSD"), + "Ehrenamt": ("Ehrenamt",), + "FAK": ("FAK",), + "SP5-Treffen": ("SP5-Treffen", "SP5 Treffen", "SP5"), + "Vor-Ort-Arbeit": ("Vor-Ort-Arbeit", "Vor Ort Arbeit", "VOA"), + "Allgemein": ("Allgemein",), +} + + +def _filename_component(text: str, *, title_case_first: bool = False) -> str: + """Return one Windows/Linux-safe filename component while preserving Unicode. + + German spelling/capitalization is kept. Underscores remain reserved for the + schema separators. Cosmetic punctuation such as commas/semicolons is removed + because it adds noise without helping identification. + """ + value = unicodedata.normalize("NFC", str(text or "")).strip() + value = re.sub(r"^\d{4}[-_. ]\d{1,2}[-_. ]\d{1,2}[-_ ]*", "", value) + value = re.sub(r"\.[A-Za-z0-9]{1,6}$", "", value) + value = value.replace("_", " ") + value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", value) + value = re.sub(r"[,;]+", " ", value) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"-+", "-", value).strip("-. ") + if title_case_first and value and value[0].islower(): + value = value[0].upper() + value[1:] + return value + + +def _semantic_key(text: str) -> str: + folded = unicodedata.normalize("NFKD", str(text or "")).casefold() + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.replace("ß", "ss") + return re.sub(r"[^a-z0-9]+", "", folded) + + +def _branch_name(destination: str) -> str: + return destination.split("/", 1)[0].strip() + + +def _prefix_alias_keys(destination: str) -> set[str]: + branch = _branch_name(destination) + values = set(PREFIX_ALIAS_PHRASES.get(branch, ())) + values.add(_filename_prefix(destination)) + values.add(branch) + return {_semantic_key(x) for x in values if x} + + +def _strip_redundant_date_text(text: str, date_guess: str) -> str: + """Remove a full/date-fragment that merely repeats the leading date. + + We deliberately do not remove a bare year: `März 2024` and `Inventur 2023` + can carry useful semantic meaning even when mtime happens to be in that year. + """ + value = str(text or "") + m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", str(date_guess or "")) + if not m: + return value + y, mo, d = m.groups() + yy = y[-2:] + patterns = [ + rf"(? str: + return _filename_component( + _strip_redundant_date_text(text, rec.date_guess), + title_case_first=title_case_first, + ) + + +def _fallback_title(rec: Record) -> str: + for value in (rec.document_type, rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec, title_case_first=True) + if candidate and _semantic_key(candidate) not in GENERIC_FILENAME_LABELS: + return candidate + stem = _component_for_record(Path(rec.filename).stem, rec, title_case_first=True) + if stem and _semantic_key(stem) not in GENERIC_FILENAME_LABELS: + return stem + return "Dokument" + + +def _source_remainder(rec: Record, *blocked_values: str) -> str: + """Recover useful detail from the original filename when model fields are noisy. + + This is intentionally conservative: it is only used as a fallback subtitle. + Examples: `Getränkeliste Verzehrkarte` -> `Verzehrkarte`; + `Inventur Café Bach 2023` -> `2023`. + """ + raw = _strip_redundant_date_text(Path(rec.filename).stem, rec.date_guess) + + # Remove the branch/prefix phrase before token-level subtraction. + for phrase in sorted(PREFIX_ALIAS_PHRASES.get(_branch_name(rec.destination), ()), key=len, reverse=True): + raw = re.sub(rf"(?= 3): + continue + kept.append(candidate) + + return _filename_component(" ".join(kept)) + + +def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: + blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} + blocked |= _prefix_alias_keys(rec.destination) + blocked |= CATEGORY_NOISE_LABELS + + for value in (rec.subtopic, rec.detail): + candidate = _component_for_record(value, rec) + key = _semantic_key(candidate) + if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS: + return candidate + + return _source_remainder(rec, title, prefix, rec.topic) + + +def build_structured_filename(rec: Record) -> str: + """Build YYYY-MM-DD_PREFIX_Title_Subtitle[_vN].ext deterministically.""" + prefix = _filename_component(_filename_prefix(rec.destination), title_case_first=True) + prefix_aliases = _prefix_alias_keys(rec.destination) + + raw_title = _component_for_record(rec.name_title, rec, title_case_first=True) + raw_subtitle = _component_for_record(rec.name_subtitle, rec) + title_key = _semantic_key(raw_title) + subtitle_key = _semantic_key(raw_subtitle) + + # Promote a useful subtitle when the model supplied a generic label like + # `Info`; this avoids names such as Allgemein_Info_SurvStat... In that + # special case, do not immediately add another model-derived subtitle: only + # recover a genuinely distinct remainder from the original filename. + promoted_generic_title = False + if title_key in GENERIC_FILENAME_LABELS and raw_subtitle: + title = raw_subtitle + raw_subtitle = "" + promoted_generic_title = True + elif not raw_title or title_key in prefix_aliases: + title = _fallback_title(rec) + else: + title = raw_title + + title_key = _semantic_key(title) + blocked_subtitle = {title_key, _semantic_key(prefix)} | prefix_aliases | CATEGORY_NOISE_LABELS + subtitle_key = _semantic_key(raw_subtitle) + + if promoted_generic_title: + subtitle = _source_remainder(rec, title, prefix, rec.topic) + elif ( + not raw_subtitle + or subtitle_key in blocked_subtitle + or subtitle_key in GENERIC_FILENAME_LABELS + ): + subtitle = _fallback_subtitle(rec, title, prefix) + else: + subtitle = raw_subtitle + + # A subtitle can still repeat the branch semantically (e.g. `Café-Bach`). + if _semantic_key(subtitle) in prefix_aliases | {title_key, _semantic_key(prefix)}: + subtitle = _source_remainder(rec, title, prefix, rec.topic) + + parts = [rec.date_guess, prefix, title] + if subtitle and _semantic_key(subtitle) not in {_semantic_key(x) for x in parts}: + parts.append(subtitle) + + ver = _filename_component(rec.version) + if ver: + if re.fullmatch(r"\d+", ver): + ver = f"v{ver}" + elif not re.fullmatch(r"v\d+", ver, flags=re.I): + ver = f"v{ver}" + parts.append(ver) + + # Keep enough headroom for the destination path and collision suffixes. + stem = "_".join(x for x in parts if x) + if len(stem) > 180: + overflow = len(stem) - 180 + if subtitle and len(subtitle) > overflow + 12: + subtitle = subtitle[: len(subtitle) - overflow].rstrip("-_. ") + parts = [rec.date_guess, prefix, title] + ([subtitle] if subtitle else []) + if ver: + parts.append(ver) + stem = "_".join(x for x in parts if x) + stem = stem[:180].rstrip("-_. ") + return stem + rec.ext + + +# ---------------- policy + scan ---------------- + +def ignored(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts[:-1]): + return True + return any(p.match(path.name) for p in IGNORE_FILE_PATTERNS) or path.name == LOG_FILENAME + + +def load_policy(path: Optional[Path]) -> dict: + if not path or not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def dynamic_destinations(root: Path) -> list[str]: + values = [] + for p in root.rglob("*"): + if not p.is_dir() or p == root: + continue + rel = p.relative_to(root) + if any(part in IGNORE_DIR_NAMES or part.startswith(".") for part in rel.parts): + continue + # Avoid presenting the entire historical tree as a flat menu. + if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: + values.append(str(rel)) + return sorted(set(values)) + + +def allowed_destinations(root: Path, policy: dict, profile: str = "") -> list[str]: + key = profile.strip() or root.name + configured = policy.get(key) + if configured is not None: + return configured + return dynamic_destinations(root) + + +def _normalise_alias_text(text: str) -> str: + folded = text.lower() + for src, repl in UMLAUT_MAP.items(): + folded = folded.replace(src, repl) + return " ".join(t for t in re.split(r"[^a-z0-9]+", folded) if t) + + +def _strong_route_branch(rec: Record, aliases: dict[str, list[str]]) -> str: + """Return a deterministic branch only when aliases are unambiguous. + + A file can legitimately mention more than one work area (e.g. a Cafe Bach + refrigerator accounting sheet for a CSD occasion). In that situation, + hard-routing would destroy useful context. We therefore narrow the LLM + only when exactly ONE branch is matched; otherwise candidate scoring + the + LLM decide the primary owner/context. + """ + if not aliases: + return "" + + filename_text = " " + _normalise_alias_text(rec.filename) + " " + folder_text = " " + _normalise_alias_text(rec.current_folder) + " " + source_text = " " + _normalise_alias_text(rec.source_context) + " " + excerpt_text = " " + _normalise_alias_text(rec.excerpt[:700]) + " " + matched_branches = set() + + for branch, terms in aliases.items(): + for term in terms: + needle = _normalise_alias_text(term) + if not needle: + continue + token = f" {needle} " + if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: + matched_branches.add(branch) + break + + if len(matched_branches) == 1: + return next(iter(matched_branches)) + return "" + + +def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[str]: + if not destinations: + return [rec.current_folder] if rec.current_folder else [""] + + # User-defined strong aliases route obvious domain shorthand before the LLM. + branch = _strong_route_branch(rec, aliases or {}) + if branch: + routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] + if routed: + destinations = routed + + text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" + q = tokens(text) + scored = [] + current_top = rec.current_folder.split("/", 1)[0] if rec.current_folder else "" + for folder in destinations: + f_tokens = tokens(folder) + score = len(q & f_tokens) * 3 + if current_top and (folder == current_top or folder.startswith(current_top + "/")): + score += 2 + score -= max(0, folder.count("/") - 1) + scored.append((score, folder)) + scored.sort(key=lambda x: (-x[0], x[1])) + + # In Inbox mode destinations span several sibling roots. Always expose each + # top-level area to the LLM, otherwise an unrelated alphabetical slice could + # accidentally hide PRIVAT or VAULT from an ambiguous file. + cross_root = any(re.match(r"^\d{2}_[^/]+(?:/|$)", d) for d in destinations) + chosen = [] + if cross_root and not branch: + top_names = [] + for d in destinations: + top = d.split("/", 1)[0] + if top not in top_names: + top_names.append(top) + for top in top_names: + if top in destinations and top not in chosen: + chosen.append(top) + + for _, folder in scored: + if folder not in chosen: + chosen.append(folder) + if len(chosen) >= limit: + break + + # For a real 01_INBOX we must always allow "leave it where it is" when the + # model cannot know enough. This can be a nested unpack folder as well. + if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: + chosen.append(rec.current_folder) + + return chosen[:limit + (1 if allow_stay_outside_policy else 0)] + + +def scan(root: Path, target_root: Path, destinations: list[str], + aliases: Optional[dict[str, list[str]]] = None, + allow_stay_outside_policy: bool = False) -> list[Record]: + records = [] + for p in sorted(root.rglob("*")): + if not p.is_file() or ignored(p, root): + continue + rel = str(p.relative_to(root)) + try: + current = str(p.parent.relative_to(target_root)) + except ValueError: + current = str(p.parent.relative_to(root)) + if current == ".": + current = "" + # Keep provenance separate from destination logic. This matters when an + # incoming backup root itself carries useful ownership context (for + # example a Cafe-Bach backup) that would otherwise disappear when paths + # are made relative to the source root. + source_context = root.name + relative_parent = str(p.parent.relative_to(root)) + if relative_parent != ".": + source_context = f"{root.name}/{relative_parent}" + d, source = best_date(p, root=root) + archive = is_archive_file(p) + rec = Record( + path=p, + root=root, + target_root=target_root, + rel_path=rel, + filename=p.name, + ext=p.suffix, + current_folder=current, + date_guess=d, + date_source=source, + proposed_name=p.name, + destination=current, + status="archive" if archive else "pending", + content_status="archive" if archive else "not-read", + reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), + source_context=source_context, + ) + rec.candidates = choose_candidates( + rec, destinations, aliases=aliases, + allow_stay_outside_policy=allow_stay_outside_policy, + ) + records.append(rec) + return records + + +# ---------------- LLM batching ---------------- + +SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +Your job is to save the human time, not create more decisions. + +Return one JSON ARRAY only, one object per input item, in the same order. +Each object must contain exactly: +id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason + +Rules: +- destination MUST be one of that item's candidates. Never invent a folder. +- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +- If uncertain, keep the current folder when it is a candidate, lower confidence, and say why. +- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +- detail: optional useful distinguishing detail; empty string if unnecessary. +- version: explicit version only if supported by filename/content; otherwise empty string. +- Use only as many semantic parts as useful; never invent filler. +- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +- For destination, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch branches merely because it mentions a campaign/event. +- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +- Use the supplied date_guess; do not invent a different date from vague content. +- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +- Correct obvious spelling/transliteration problems when safe. +- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +- reason: max 12 words, factual and concise. +""" + + +def _model_generation_limits(model: str) -> tuple[int, int]: + """Return a generous output-token ceiling and HTTP timeout for one file. + + Reasoning models spend part of the completion budget before emitting final + JSON. These are ceilings, not targets: models can stop much earlier. + """ + name = model.strip().lower() + if "debug" in name: + return 6000, 300 + if "architect" in name: + return 2500, 180 + if "daily" in name: + return 1000, 120 + return 2000, 180 + + +def _result_confidence(result: Optional[dict]) -> float: + if not isinstance(result, dict): + return 0.0 + try: + return max(0.0, min(1.0, float(result.get("confidence", 0)))) + except (TypeError, ValueError): + return 0.0 + + +def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: + item = { + "id": 0, + "filename": rec.filename, + "current_folder": rec.current_folder, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + "generic_name": is_generic_filename(rec.filename), + "content_status": rec.content_status, + "candidates": rec.candidates, + "excerpt": rec.excerpt[:MAX_EXCERPT], + } + prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) + max_tokens, timeout = _model_generation_limits(model) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + choice = data["choices"][0] + text = choice.get("message", {}).get("content") or "" + parsed = extract_json_array(text) + if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): + raise ValueError("model did not return a non-empty JSON array") + result = dict(parsed[0]) + result["id"] = 0 + return result + + +def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: + """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" + if not isinstance(result, dict): + return True + dest = str(result.get("destination", "")).strip() + if dest not in rec.candidates: + return True + if _result_confidence(result) < 0.85: + return True + if is_generic_filename(rec.filename): + return True + return False + + +def llm_batches( + records: list[Record], + endpoint: str, + model: str, + batch_size: int = 1, + fallback_model: str = "", +): + """Analyse exactly one file per LLM request. + + batch_size is retained for CLI/backward compatibility but intentionally + ignored: one-file requests prevent cross-file influence and isolate failures. + + When a fallback model is configured, it is used only for a primary error, + an invalid destination, confidence below 0.85, or a generic filename. + If primary and fallback disagree on destination, the primary proposal is + kept but confidence is capped at 0.69 so the GUI forces human review. + """ + del batch_size + fallback_model = fallback_model.strip() + + for rec in records: + batch = [rec] + primary = None + primary_error = None + try: + primary = _call_llm_one(rec, endpoint, model) + except Exception as exc: + primary_error = str(exc) + + use_fallback = ( + bool(fallback_model) + and fallback_model != model + and (primary_error is not None or _needs_fallback(rec, primary)) + ) + + if not use_fallback: + if primary_error is not None: + yield batch, None, primary_error + else: + yield batch, [primary], None + continue + + try: + fallback = _call_llm_one(rec, endpoint, fallback_model) + except Exception as exc: + fallback_error = str(exc) + if primary is not None: + # A usable primary answer is better than throwing it away merely + # because the optional second opinion failed. + yield batch, [primary], None + else: + yield batch, None, f"primary={primary_error}; fallback={fallback_error}" + continue + + if primary is None: + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + continue + + primary_dest = str(primary.get("destination", "")).strip() + fallback_dest = str(fallback.get("destination", "")).strip() + + if primary_dest == fallback_dest: + # For the cases deliberately escalated, prefer the second opinion + # when both models agree on the actual destination. + chosen = dict(fallback) + chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() + yield batch, [chosen], None + else: + # Do not silently choose between disagreeing models. Keep the fast + # primary proposal visible, but force explicit human review. + chosen = dict(primary) + chosen["confidence"] = min(_result_confidence(primary), 0.69) + chosen["reason"] = ( + f"Model disagreement: {model}={primary_dest}; " + f"{fallback_model}={fallback_dest}" + ) + yield batch, [chosen], None + + +def extract_json_array(text: str): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start, end = text.find("["), text.rfind("]") + if start >= 0 and end > start: + return json.loads(text[start:end + 1]) + raise + + +def apply_llm_results(batch: list[Record], results: list[dict]): + by_id = {int(x.get("id", -1)): x for x in results if isinstance(x, dict)} + for idx, rec in enumerate(batch): + result = by_id.get(idx) + if not result: + rec.status = "review" + rec.reason = "no model result" + continue + dest = str(result.get("destination", "")).strip() + try: + conf = float(result.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0.0 + if dest not in rec.candidates: + dest = rec.current_folder + conf = min(conf, 0.4) + rec.destination = dest + rec.topic = str(result.get("topic", "")).strip() + rec.subtopic = str(result.get("subtopic", "")).strip() + rec.document_type = str(result.get("document_type", "")).strip() + rec.detail = str(result.get("detail", "")).strip() + rec.version = str(result.get("version", "")).strip() + rec.name_title = str(result.get("title", "")).strip() + rec.name_subtitle = str(result.get("subtitle", "")).strip() + rec.proposed_name = build_structured_filename(rec) + # If no usable content was extracted from a generic filename, the model + # cannot reliably recover a hidden event/project name. Force human review. + if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): + conf = min(conf, 0.69) + + # Every V7 proposal follows the structured naming scheme. If its date + # comes only from filesystem mtime, the date is useful but historically + # unverified, so require explicit human review before apply. + if rec.date_source == "mtime": + conf = min(conf, 0.69) + rec.confidence = max(0.0, min(1.0, conf)) + rec.reason = str(result.get("reason", ""))[:120] + rec.status = "suggested" if rec.confidence >= 0.70 else "review" + + +# ---------------- planning + apply ---------------- + +_HASH_CACHE: dict[str, tuple[int, int, str]] = {} + + +def sha256_file(path: Path) -> str: + """Full-file SHA-256. Used only when two files compete for the same target. + + The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI + does not repeatedly hash unchanged files. + """ + st = path.stat() + key = str(path) + cached = _HASH_CACHE.get(key) + sig = (st.st_size, st.st_mtime_ns) + if cached and cached[:2] == sig: + return cached[2] + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + digest = h.hexdigest() + _HASH_CACHE[key] = (sig[0], sig[1], digest) + return digest + + +def same_content(a: Path, b: Path) -> bool: + """True only for byte-identical files. Any read error is treated as different. + + This is deliberately conservative: we only auto-delete when equality can be + proven. Files that merely *look* equivalent but differ in metadata stay kept. + """ + try: + if a.stat().st_size != b.stat().st_size: + return False + return sha256_file(a) == sha256_file(b) + except OSError: + return False + + +def global_duplicate_prepass(records: list[Record], source_root: Path, target_root: Path) -> dict[str, int | bool | str]: + """Mark exact duplicates before content extraction or LLM analysis. + + The pre-pass is deliberately enabled only for *separate* source and target + trees. This covers migration/staging imports without changing the established + V2 Inbox behaviour where the Inbox is nested inside a broader target root. + + Full SHA-256 is used for every exact-duplicate decision. Existing target files + are authoritative. If no target copy exists, the first source copy becomes the + representative and later byte-identical source copies are marked duplicates. + """ + stats: dict[str, int | bool | str] = { + "enabled": False, + "target_files": 0, + "source_files": 0, + "target_duplicates": 0, + "source_duplicates": 0, + "hash_errors": 0, + "reason": "", + } + + source_root = source_root.resolve() + target_root = target_root.resolve() + + if source_root == target_root or source_root.is_relative_to(target_root): + stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" + return stats + if target_root.is_relative_to(source_root): + stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" + return stats + + stats["enabled"] = True + target_by_hash: dict[str, Path] = {} + + for p in sorted(target_root.rglob("*")): + if p.is_symlink() or not p.is_file() or ignored(p, target_root): + continue + try: + digest = sha256_file(p) + except OSError: + stats["hash_errors"] += 1 + continue + stats["target_files"] += 1 + target_by_hash.setdefault(digest, p) + + source_seen: dict[str, Path] = {} + for rec in records: + # scan() has already applied the normal ignore rules. Archives are hashed + # too: an archive that is already byte-identical in TARGET needs no LLM. + try: + digest = sha256_file(rec.path) + except OSError: + stats["hash_errors"] += 1 + continue + stats["source_files"] += 1 + + target_match = target_by_hash.get(digest) + if target_match is not None: + rec.duplicate_of = str(target_match) + rec.status = "exact-duplicate" + rec.collision = "EXACT → already in target" + try: + display = str(target_match.relative_to(target_root)) + except ValueError: + display = str(target_match) + rec.reason = f"SHA-256 match in target: {display}" + stats["target_duplicates"] += 1 + continue + + first_source = source_seen.get(digest) + if first_source is not None: + rec.duplicate_of = str(first_source) + rec.status = "exact-duplicate" + rec.collision = "EXACT → duplicate incoming" + try: + display = str(first_source.relative_to(source_root)) + except ValueError: + display = str(first_source) + rec.reason = f"SHA-256 match in source: {display}" + stats["source_duplicates"] += 1 + continue + + source_seen[digest] = rec.path + + return stats + + +def variant_path(target: Path, n: int) -> Path: + """Insert a visible variant marker before the full extension chain.""" + suffix = "".join(target.suffixes) + stem = target.name[:-len(suffix)] if suffix else target.name + return target.with_name(f"{stem}_variant-{n}{suffix}") + + +def _find_variant_or_duplicate(source: Path, target: Path, reserved: dict[str, Path]) -> tuple[Optional[Path], Optional[Path]]: + """Find a safe variant target. + + Returns (free_variant_path, None), or (None, duplicate_of) if an already-existing + variant is byte-identical to source. + """ + n = 2 + while True: + cand = variant_path(target, n) + other = reserved.get(str(cand)) + if other is not None: + if same_content(source, other): + return None, other + n += 1 + continue + if cand.exists(): + if cand.is_file() and same_content(source, cand): + return None, cand + n += 1 + continue + return cand, None + + +def plan(records: list[Record]) -> list[dict]: + """Plan confirmed rows, automatically resolving target collisions. + + Rules: + - exact byte duplicate (full SHA-256) -> keep the already-present/planned copy, + remove the redundant source on Apply; + - same target but different bytes -> keep both, route the new one to + *_variant-2, *_variant-3, ...; + - never overwrite an existing file. + """ + rows = [] + reserved: dict[str, Path] = {} # future/existing target path -> content source + + for rec in records: + if rec.status != "confirmed": + continue + source = rec.path + + # A global pre-pass duplicate bypasses semantic destination/name planning. + # Re-verify the bytes immediately before planning deletion; if the other + # copy changed or vanished, fail closed and touch nothing. + if rec.duplicate_of: + duplicate_of = Path(rec.duplicate_of) + if duplicate_of.exists() and same_content(source, duplicate_of): + result = "EXACT duplicate -> would remove source" + resolved = duplicate_of + collision = "EXACT → remove duplicate" + else: + result = "SKIPPED - pre-pass duplicate no longer matches" + resolved = duplicate_of + collision = "RECHECK → duplicate changed" + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(rec.target), + "result": result, + }) + continue + + target = rec.target + + if target == source: + result = "unchanged" + resolved = target + collision = "" + reserved.setdefault(str(target), source) + + else: + occupied_by = reserved.get(str(target)) + if occupied_by is not None: + if same_content(source, occupied_by): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + else: + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + elif target.exists(): + if target.is_file() and same_content(source, target): + result = "EXACT duplicate -> would remove source" + resolved = target + collision = "EXACT → remove duplicate" + reserved[str(target)] = target + else: + # Base name is occupied by different content. Preserve both. + reserved.setdefault(str(target), target) + variant, dup_of = _find_variant_or_duplicate(source, target, reserved) + if dup_of is not None: + result = "EXACT duplicate -> would remove source" + resolved = dup_of + collision = "EXACT → remove duplicate" + else: + resolved = variant + result = "DIFF -> would move as variant" + collision = f"DIFF → {resolved.name}" + reserved[str(resolved)] = source + + else: + result = "would move" + resolved = target + collision = "" + reserved[str(target)] = source + + rec.collision = collision + rows.append({ + "old_path": str(source), + "new_path": str(resolved), + "requested_path": str(target), + "result": result, + }) + return rows + + +def apply_confirmed(records: list[Record], log_path: Path) -> list[dict]: + planned = plan(records) + by_old = {str(r.path): r for r in records} + timestamp = datetime.now().isoformat(timespec="seconds") + out = [] + + for row in planned: + result = row["result"] + old = Path(row["old_path"]) + new = Path(row["new_path"]) + rec = by_old.get(str(old)) + + if result in ("would move", "DIFF -> would move as variant"): + # Plan already proved 'new' is unoccupied. Re-check before touching disk. + if new.exists(): + result = "SKIPPED - resolved target unexpectedly exists" + else: + new.parent.mkdir(parents=True, exist_ok=True) + if old.parent == new.parent: + old.rename(new) + else: + shutil.move(str(old), str(new)) + result = "moved" if row["result"] == "would move" else "moved as variant" + if rec: + rec.path = new + try: + rec.rel_path = str(new.relative_to(rec.root)) + except ValueError: + rec.rel_path = str(new.relative_to(rec.target_root)) + rec.current_folder = str(new.parent.relative_to(rec.target_root)) + rec.filename = new.name + rec.proposed_name = new.name + rec.collision = "" + rec.status = "done" + + elif result == "EXACT duplicate -> would remove source": + # Only reached after a full SHA-256 equality check in plan(). + try: + old.unlink() + result = "removed exact duplicate" + if rec: + rec.collision = "" + rec.status = "done" + except OSError as e: + result = f"SKIPPED - could not remove duplicate: {e}" + + out.append({"timestamp": timestamp, **row, "result": result}) + + if out: + write_header = not log_path.exists() + with log_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=out[0].keys()) + if write_header: + writer.writeheader() + writer.writerows(out) + return out + + +# ---------------- GUI ---------------- + +def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): + import tkinter as tk + from tkinter import ttk, messagebox + + class App: + def __init__(self, win): + self.win = win + self.records = records + self.destinations = destinations + self.by_iid = {} + self.q = queue.Queue() + self.analysis_running = False + self.active_editor = None + win.title(f"Quick Sorter — {root}") + win.geometry("1500x760") + self._build() + self._populate() + + def _build(self): + bar = ttk.Frame(self.win, padding=6) + bar.pack(fill="x") + ttk.Button(bar, text="Analyse selected", command=self.analyse_selected).pack(side="left", padx=2) + if inbox_mode: + ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm selected", command=lambda: self.set_status("confirmed")).pack(side="left", padx=2) + ttk.Button(bar, text="Confirm ≥ 0.85", command=self.confirm_confident).pack(side="left", padx=2) + ttk.Button(bar, text="Skip selected", command=lambda: self.set_status("skip")).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Set folder…", command=self.set_folder).pack(side="left", padx=2) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) + ttk.Button(bar, text="Preview confirmed", command=self.preview).pack(side="left", padx=2) + ttk.Button(bar, text="Apply confirmed", command=self.apply).pack(side="left", padx=2) + + cols = ("status", "file", "date", "source", "content", "destination", "semantic", "name", "confidence", "collision", "reason") + self.tree = ttk.Treeview(self.win, columns=cols, show="headings", selectmode="extended") + widths = {"status": 85, "file": 300, "date": 95, "source": 70, "content": 120, "destination": 200, "semantic": 250, "name": 360, "confidence": 80, "collision": 260, "reason": 230} + for c in cols: + self.tree.heading(c, text=c.title(), command=lambda col=c: self.sort(col, False)) + self.tree.column(c, width=widths[c], stretch=False) + self.tree.pack(fill="both", expand=True) + self.tree.bind("", self.edit_cell) + self.status = ttk.Label(self.win, padding=6) + self.status.pack(fill="x") + + def values(self, r): + semantic = " / ".join(x for x in (r.topic, r.subtopic, r.document_type, r.detail, r.version) if x) + return (r.status, r.rel_path, r.date_guess, r.date_source, r.content_status, r.destination, + semantic, r.proposed_name, f"{r.confidence:.2f}" if r.confidence else "", r.collision, r.reason) + + def _populate(self): + self.tree.delete(*self.tree.get_children()) + self.by_iid.clear() + for idx, rec in enumerate(self.records): + iid = str(idx) + self.by_iid[iid] = rec + self.tree.insert("", "end", iid=iid, values=self.values(rec)) + self._counts() + + def refresh(self, iid): + rec = self.by_iid[iid] + self.tree.item(iid, values=self.values(rec)) + + def _counts(self): + counts = {} + for r in self.records: + counts[r.status] = counts.get(r.status, 0) + 1 + self.status.config(text=" / ".join(f"{k}: {v}" for k, v in sorted(counts.items())) + f" / total: {len(self.records)}") + + def selected(self): + return [(iid, self.by_iid[iid]) for iid in self.tree.selection()] + + def recompute_collisions(self): + # Preserve global pre-pass markers on rows that have not yet been + # confirmed. plan() still handles ordinary same-target collisions. + for rec in self.records: + if not rec.duplicate_of: + rec.collision = "" + plan(self.records) + for iid in self.by_iid: + self.refresh(iid) + + def rescan(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + old = {str(r.path): r for r in self.records} + fresh = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + merged = [] + new_count = 0 + for rec in fresh: + previous = old.get(str(rec.path)) + if previous is not None: + merged.append(previous) + else: + merged.append(rec) + new_count += 1 + self.records = merged + global_duplicate_prepass(self.records, root, target_root) + self._populate() + messagebox.showinfo( + "Rescan complete", + f"New files: {new_count}\nCurrently in source folder: {len(self.records)}", + ) + + def analyse_selected(self): + if self.analysis_running: + messagebox.showinfo("Busy", "Analysis is already running.") + return + selected = [r for _, r in self.selected()] + duplicate_count = sum(1 for r in selected if r.duplicate_of) + archive_count = sum(1 for r in selected if not r.duplicate_of and (r.status == "archive" or is_archive_file(r.path))) + chosen = [ + r for r in selected + if not r.duplicate_of and r.status != "archive" and not is_archive_file(r.path) + ] + if not chosen: + if duplicate_count: + messagebox.showinfo( + "Exact duplicate selected", + f"{duplicate_count} exact SHA-256 duplicate(s) are already resolved and are not sent to the LLM.\n" + "Confirm them only when you want Apply to remove the redundant SOURCE copy.", + ) + elif archive_count: + messagebox.showinfo( + "Archive selected", + "Archive files are intentionally not sent to the LLM.\n" + "Unpack them first, then use Rescan Inbox.", + ) + else: + messagebox.showinfo("Select files", "Select one or more rows first.") + return + self.analysis_running = True + self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") + + def worker(): + for rec in chosen: + if not rec.excerpt: + rec.excerpt, rec.content_status = extract_excerpt(rec.path) + rec.candidates = choose_candidates(rec, self.destinations, aliases=aliases) + for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): + self.q.put((batch, results, error)) + self.q.put((None, None, "__DONE__")) + + threading.Thread(target=worker, daemon=True).start() + self.win.after(100, self.poll) + + def poll(self): + try: + while True: + batch, results, error = self.q.get_nowait() + if error == "__DONE__": + self.analysis_running = False + self._counts() + return + if error: + for r in batch: + r.status = "review" + r.reason = f"LLM error: {error[:80]}" + else: + apply_llm_results(batch, results) + for iid, rec in self.by_iid.items(): + if rec in batch: + self.refresh(iid) + except queue.Empty: + pass + self.win.after(120, self.poll) + + def set_status(self, status): + for iid, rec in self.selected(): + rec.status = status + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def confirm_confident(self): + for iid, rec in self.by_iid.items(): + if rec.status == "suggested" and rec.confidence >= 0.85: + rec.status = "confirmed" + self.refresh(iid) + self.recompute_collisions() + self._counts() + + def set_folder(self): + chosen = self.selected() + if not chosen: + return + top = tk.Toplevel(self.win) + top.title("Set destination") + var = tk.StringVar() + combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) + combo.pack(padx=10, pady=10) + combo.focus() + def go(): + dest = var.get().strip() + if dest in self.destinations or dest == "": + for iid, rec in chosen: + rec.destination = dest + rec.status = "review" + self.refresh(iid) + top.destroy() + ttk.Button(top, text="Apply", command=go).pack(pady=(0, 10)) + combo.bind("", lambda _e: go()) + + def edit_cell(self, event): + region = self.tree.identify("region", event.x, event.y) + if region != "cell": + return + iid = self.tree.identify_row(event.y) + col = int(self.tree.identify_column(event.x)[1:]) - 1 + if not iid or col not in (5, 7): # destination/name only + return + bbox = self.tree.bbox(iid, f"#{col + 1}") + if not bbox: + return + x, y, w, h = bbox + rec = self.by_iid[iid] + if self.active_editor: + self.active_editor.destroy() + if col == 5: + editor = ttk.Combobox(self.tree, values=self.destinations) + editor.set(rec.destination) + else: + editor = ttk.Entry(self.tree) + editor.insert(0, rec.proposed_name) + editor.select_range(0, tk.END) + editor.place(x=x, y=y, width=w, height=h) + editor.focus() + self.active_editor = editor + def commit(_e=None): + value = editor.get().strip() + if col == 5 and value in self.destinations: + rec.destination = value + elif col == 7 and value: + rec.proposed_name = Path(value).name + rec.status = "review" + editor.destroy() + self.active_editor = None + self.refresh(iid) + editor.bind("", commit) + editor.bind("", commit) + editor.bind("", lambda _e: editor.destroy()) + + def preview(self): + self.recompute_collisions() + rows = plan(self.records) + if not rows: + messagebox.showinfo("Preview", "No confirmed rows.") + return + text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30]) + if len(rows) > 30: + text += f"\n\n… and {len(rows)-30} more" + messagebox.showinfo("Dry-run preview", text) + + def apply(self): + rows = plan(self.records) + if not rows: + messagebox.showinfo("Apply", "No confirmed rows.") + return + normal = sum(1 for r in rows if r["result"] == "would move") + variants = sum(1 for r in rows if r["result"] == "DIFF -> would move as variant") + duplicates = sum(1 for r in rows if r["result"] == "EXACT duplicate -> would remove source") + unchanged = sum(1 for r in rows if r["result"] == "unchanged") + summary = ( + f"Normal move/rename: {normal}\n" + f"Different-content collisions kept as variant: {variants}\n" + f"Exact SHA-256 duplicates to remove: {duplicates}\n" + f"Unchanged: {unchanged}\n\n" + "No existing file will be overwritten. Exact duplicates are only " + "removed after a full-file SHA-256 match." + ) + if not messagebox.askyesno("Apply resolved batch?", summary): + return + result = apply_confirmed(self.records, root / LOG_FILENAME) + moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) + removed = sum(1 for x in result if x["result"] == "removed exact duplicate") + skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) + if inbox_mode: + self.records = scan( + root, target_root, self.destinations, aliases=aliases, + allow_stay_outside_policy=True, + ) + self._populate() + messagebox.showinfo( + "Done", + f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" + f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" + ) + + def sort(self, column, reverse): + rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] + rows.sort(key=lambda x: x[0].lower(), reverse=reverse) + for pos, (_, iid) in enumerate(rows): + self.tree.move(iid, "", pos) + self.tree.heading(column, command=lambda: self.sort(column, not reverse)) + + win = tk.Tk() + App(win) + win.mainloop() + + +def dependency_report() -> list[tuple[str, str, bool]]: + checks = [ + ("DOCX", "python-docx", "docx"), + ("PDF", "pypdf", "pypdf"), + ("PDF AES", "cryptography", "cryptography"), + ("XLSX", "openpyxl", "openpyxl"), + ("PPTX", "python-pptx", "pptx"), + ("ODT", "odfpy", "odf"), + ("Images metadata", "Pillow", "PIL"), + ] + out = [] + for label, package, module in checks: + try: + __import__(module) + ok = True + except ImportError: + ok = False + out.append((label, package, ok)) + return out + + +# ---------------- CLI ---------------- +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", nargs="?", help="Folder to review, e.g. /data/04_ARBEIT") + ap.add_argument("--policy", default=str(Path(__file__).with_name("sorter_policy.json"))) + ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") + ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=DEFAULT_FALLBACK_MODEL, help="Second-opinion model for uncertain cases; default: architect") + ap.add_argument("--no-fallback", action="store_true", help="Disable the second-opinion model") + ap.add_argument("--batch-size", type=int, default=1, help="Compatibility option; V7 always sends one file per LLM request") + ap.add_argument("--scan-only", action="store_true", help="Read-only summary; do not open GUI") + ap.add_argument("--check-deps", action="store_true", help="Show optional document readers available in this Python environment and exit") + args = ap.parse_args() + + if args.check_deps: + for label, package, ok in dependency_report(): + print(f"{'OK' if ok else 'MISSING':7} {label:16} pip package: {package}") + return + + if not args.root: + raise SystemExit("root folder is required unless --check-deps is used") + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Not a directory: {root}") + policy = load_policy(Path(args.policy)) + profile = args.profile.strip() or root.name + inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" + if args.target_root: + target_root = Path(args.target_root).expanduser().resolve() + elif inbox_mode: + target_root = root.parent + else: + target_root = root + if not target_root.is_dir(): + raise SystemExit(f"Not a target directory: {target_root}") + + destinations = allowed_destinations(root, policy, args.profile) + aliases = policy.get("_strong_aliases", {}).get(profile, {}) + records = scan( + root, target_root, destinations, aliases=aliases, + allow_stay_outside_policy=inbox_mode, + ) + dedupe = global_duplicate_prepass(records, root, target_root) + + print(f"Root: {root}") + print(f"Target root: {target_root}") + print(f"Policy profile: {profile}") + print(f"Files: {len(records)}") + print(f"Archives: {sum(r.status == 'archive' for r in records)}") + if dedupe["enabled"]: + print( + "Global SHA-256 pre-pass: " + f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " + f"already in target={dedupe['target_duplicates']}, " + f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" + ) + else: + print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") + print(f"Allowed destinations: {len(destinations)}") + print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") + print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") + print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") + print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") + + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print(f"Primary LLM model: {args.model}") + print(f"Fallback LLM model: {fallback_model or 'disabled'}") + + if args.scan_only: + for r in records[:25]: + duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" + print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") + return + + launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..85856db --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +python-docx +pypdf +openpyxl +python-pptx +odfpy +Pillow +fonttools +cryptography diff --git a/result1.md b/result1.md new file mode 100644 index 0000000..5c74ad3 --- /dev/null +++ b/result1.md @@ -0,0 +1,268 @@ +.d..t...... | ./ +.d..t...... | Allgemein/ +>f+++++++++ | Allgemein/2022-08-29_Allgemein_SurvStat@RKI-2.pdf +cd+++++++++ | Allgemein/Anleitungen/ +>f+++++++++ | Allgemein/Anleitungen/2016-03-05_Allgemein_Hygiene_Betrieb.pdf +.d..t...... | Allgemein/Verwaltung/ +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Bekanntmachung_TRBA-310.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Biologische-Einstufung_Pilze-in-Risikogruppen.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Biostoffverordnung_BioStoffV.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Gefährdungsbeurteilung_Biostoffverordnung.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Handlungsanleitung_Biologische-Arbeitsstoffe.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Handlungshilfe_Biostoffverordnung.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Infektionsschutzgesetz_IfSG.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Protokoll_TRBA-405.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Schutzmaßnahmen_Abfallsammlung.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Schutzmaßnahmen_Archivgut.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Schutzmaßnahmen_TRBA-212.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Schutzmaßnahmen_biologische-Arbeitsstoffe.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Schutzmaßnahmen_biologische-Arbeitsstoffe_variant-2.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_TRBA-220_Biologische-Arbeitsstoffe.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_TRBA-250_Biologische-Arbeitsstoffe.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_TRBA-464_Einstufung-parasiten-risikogruppen.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_TRBA-TRGS-406_sensibilisierende-stoffe-für-die-atemwege.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Technische-Regel-TRBA-001_Biostoffverordnung.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Technische-Regel_Abfallbehandlungsanlagen.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Technische-Regel_Biologische-Arbeitsstoffe.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Technische-Regeln_Biologische-Arbeitsstoffe.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Technische-Regeln_Biologische-Arbeitsstoffe_v2.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Technische-Regeln_Biologische-Arbeitsstoffe_variant-2.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Technische-Regeln_Biologische-Arbeitsstoffe_variant-3.pdf +>f+++++++++ | Allgemein/Verwaltung/2009-07-23_Allgemein_Technische-Regeln_TRBA-466-Einstufung-von-Bakterien.pdf +>f+++++++++ | Allgemein/Verwaltung/2022-01-03_Allgemein_Preisvergleich_Angebote.docx +>f+++++++++ | Allgemein/Verwaltung/2022-08-29_Allgemein_Statistik_Wochennotifiziation.csv +>f+++++++++ | Allgemein/Verwaltung/2022-10-24_Allgemein_SurvStat-Info_RKI.pdf +.d..t...... | CSD-Kampagnen/ +>f+++++++++ | CSD-Kampagnen/2022-01-28_CSD_Protokoll_CSD-Kampagne-2022.docx +>f+++++++++ | CSD-Kampagnen/2022-03-04_CSD_Anschreiben_CSD-2022.docx +>f+++++++++ | CSD-Kampagnen/2026-04-29_CSD_Angebot_Faecher-Costa-schwarz-2026.pdf +>f+++++++++ | CSD-Kampagnen/2026-05-04_CSD_Angebot_Herzundlust-2026.pdf +>f+++++++++ | CSD-Kampagnen/2026-05-04_CSD_Umhaengetasche_2026.pdf +>f+++++++++ | CSD-Kampagnen/2026-05-04_CSD_Umhängetasche_2026-Absatzplus.pdf +>f+++++++++ | CSD-Kampagnen/2026-05-15_CSD_Angebot_2944277.pdf +>f+++++++++ | CSD-Kampagnen/2026-05-15_CSD_Angebot_Cool-Concept-Einkaufschips.pdf +>f+++++++++ | CSD-Kampagnen/2026-05-15_CSD_Angebot_Herzenslust-Einkaufchips.pdf +>f+++++++++ | CSD-Kampagnen/2026-05-18_CSD_Rechnung_Shirts-Angebot-Herzundlust.pdf +>f+++++++++ | CSD-Kampagnen/2026-05-18_CSD_Shirts_Herzundlust.pdf +>f+++++++++ | CSD-Kampagnen/2026-05-20_CSD_Angebot_Cool-Concept-Herz-und-Lust.pdf +>f+++++++++ | CSD-Kampagnen/2026-05-20_CSD_Angebot_herzundlust-postkarten.pdf +>f+++++++++ | CSD-Kampagnen/2026-05-20_CSD_Angebot_herzundlust-postkarten_variant-2.pdf +>f+++++++++ | CSD-Kampagnen/2026-05-20_CSD_Angebot_herzundlust-postkarten_variant-3.pdf +.d..t...... | Cafe_Bach/ +>f+++++++++ | Cafe_Bach/2022-01-04_CB_Anschreiben_Kleine-Show-Preis.docx +>f+++++++++ | Cafe_Bach/2022-01-07_CB_Anschreiben_Checkpoint.docx +>f+++++++++ | Cafe_Bach/2022-10-24_CB_Statistik_2022.csv +>f+++++++++ | Cafe_Bach/2025-01-17_CB_Ehrenamtsbescheinigung_Aidshilfe-Köln.docx +.d..t...... | Cafe_Bach/Anleitungen/ +>f+++++++++ | Cafe_Bach/Anleitungen/2009-07-23_CB_Betriebsanweisung_Desinfektionsmittel.pdf +>f+++++++++ | Cafe_Bach/Anleitungen/2016-08-03_CB_Leitfaden_HACCP_v3.-Auflage.pdf +>f+++++++++ | Cafe_Bach/Anleitungen/2024-01-10_CB_Reinigungsliste_Getränkeschankanlage.pdf +>f+++++++++ | Cafe_Bach/Anleitungen/2025-03-28_CB_Bedienungsanleitung_Spülmaschine-Caféküche.docx +.d..t...... | Cafe_Bach/Kommunikation/ +>f+++++++++ | Cafe_Bach/Kommunikation/2022-01-07_CB_Anschreiben_Patsy.docx +>f+++++++++ | Cafe_Bach/Kommunikation/2022-01-07_CB_Anschreiben_Patsy_variant-2.docx +>f+++++++++ | Cafe_Bach/Kommunikation/2022-01-10_CB_Anschreiben_Phönix-Sauna.docx +.d..t...... | Cafe_Bach/Materialien/ +>f+++++++++ | Cafe_Bach/Materialien/2022-07-19_CB_Preise-und-Links_Equipment.xlsx +>f+++++++++ | Cafe_Bach/Materialien/2022-09-19_CB_Allergen-Dokumentation_Paprika-Rahmgeschnetzeltes-Hähnchen-Reis.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-09-19_CB_Allergeninformation_allergene.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-09-19_CB_Kenntlichmachung-Zusatzstoffe_Speise-und-Getränkekarten.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-09-23_CB_Allergen-Dokumentation_Geflügel-Gyros.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-09-23_CB_Allergen-Dokumentation_Kartoffel-Eintopf.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-09-23_CB_Allergen-Dokumentation_Kohlroulade.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-09-23_CB_Allergen-Dokumentation_Lachsfilet.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-09-23_CB_Allergen-Dokumentation_Layout-1.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-09-23_CB_Allergen-Dokumentation_Paprika-Tomatenpfanne.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-09-23_CB_Allergen-Dokumentation_Pasta-Rote-Linsen.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-09-23_CB_Allergen-Dokumentation_Veggi-tortellini.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-10-24_CB_Allergen-Dokumentation_Gnocchi-in-Feta-Tomatensoße.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-10-24_CB_Allergen-Dokumentation_Grillhaxe.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-10-24_CB_Allergen-Dokumentation_Layout-1.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-10-24_CB_Allergen-Dokumentation_Marinierte-Hähnchenkeule.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-10-24_CB_Allergen-Dokumentation_Paprikaschote.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-10-24_CB_Allergen-Dokumentation_Veganer-Kichererbsen-Eintopf.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-10-24_CB_Allergen-Dokumentation_Veganes-Soja-Schnitzel-mit-Ebly.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-10-24_CB_Allergen-Dokumentation_allergene.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-11-07_CB_Allergen-Dokumentation_Fischeintopf.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-11-07_CB_Allergen-Dokumentation_Fusilli-Gorgonzolasoße.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-11-07_CB_Allergen-Dokumentation_Kartoffel-Zucchini-Puffer.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-11-07_CB_Allergen-Dokumentation_Speckpfannekuchen.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-11-07_CB_Allergen-Dokumentation_Veganer-Bauerneintopf.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-11-28_CB_Allergen-Dokumentation_Backfisch-vom-Seelachs.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-11-28_CB_Allergen-Dokumentation_Gemüse-Quiche.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-11-28_CB_Allergen-Dokumentation_Layout-1.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-11-28_CB_Allergen-Dokumentation_Maccheroni-Auflauf.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-11-28_CB_Allergen-Dokumentation_Pasta-Thunfischsoße.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-11-28_CB_Allergen-Dokumentation_Quinoa-Suppe.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-11-28_CB_Allergen-Dokumentation_Weizen-Wrap.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-11-28_CB_Allergen-Dokumentation_Zusatzstoffe.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-12-02_CB_Allergen-Dokumentation_Cannelloni.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-12-02_CB_Allergen-Dokumentation_Hühnerfrikassee.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-12-02_CB_Allergen-Dokumentation_Pasta-Arrabiata.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-12-02_CB_Allergen-Dokumentation_Veganes-Curry.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-12-02_CB_Allergen-Dokumentation_Vegetarische-Maultaschen.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-12-02_CB_Allergen-Dokumentation_Zanderfilet-in-Nuss-Kruste.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-12-02_CB_Allergen-Dokumentation_allergene.pdf +>f+++++++++ | Cafe_Bach/Materialien/2022-12-13_CB_Allergen-Dokumentation_Layout-1.pdf +>f+++++++++ | Cafe_Bach/Materialien/2023-05-16_CB_Getränkeliste_Notfälle.docx +>f+++++++++ | Cafe_Bach/Materialien/2024-01-10_CB_Allergene-Übersicht_Buffet-mit-Zusatzstoffen.docx +>f+++++++++ | Cafe_Bach/Materialien/2024-01-10_CB_Checkliste_Fritteuse.pdf +>f+++++++++ | Cafe_Bach/Materialien/2024-01-10_CB_Checkliste_Wareneingang.pdf +>f+++++++++ | Cafe_Bach/Materialien/2024-01-10_CB_HACCP-Checklisten_Hygiene.pdf +>f+++++++++ | Cafe_Bach/Materialien/2024-06-04_CB_Namensliste_Johannes-Eddie.docx +>f+++++++++ | Cafe_Bach/Materialien/2024-11-08_CB_Auffsteller_koreanischer-Gurkensalat.docx +>f+++++++++ | Cafe_Bach/Materialien/2025-10-15_CB_Getränkeliste_ohne-kaffee.docx +>f+++++++++ | Cafe_Bach/Materialien/2026-09-03_CB_Getränkeliste_Verzehrkarte.pdf +.d..t...... | Cafe_Bach/Planung/ +>f+++++++++ | Cafe_Bach/Planung/2016-08-03_CB_HACCP-Anleitung_EU-Lebensmittelhygiene.pdf +>f+++++++++ | Cafe_Bach/Planung/2016-08-03_CB_HACCP-Anleitung_Lebensmittelhygiene.pdf +>f+++++++++ | Cafe_Bach/Planung/2016-08-03_CB_Hygienekonzept_Weichkäseherstellung.pdf +>f+++++++++ | Cafe_Bach/Planung/2022-08-12_CB_Kalkulation_Mittagessen.xlsx +>f+++++++++ | Cafe_Bach/Planung/2022-11-17_CB_Kalkulation_Rinderroulade-Rotkohl-Kartoffeln.xlsx +>f+++++++++ | Cafe_Bach/Planung/2023-03-17_CB_Speisenkalkulation_2022.xlsx +>f+++++++++ | Cafe_Bach/Planung/2023-04-06_CB_Speisenkalkulation_2023.xlsx +>f+++++++++ | Cafe_Bach/Planung/2023-08-18_CB_Inventur_2022.xlsx +>f+++++++++ | Cafe_Bach/Planung/2023-08-18_CB_Inventur_August-2022.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-01-10_CB_Checkliste_Kueche-2018.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-01-18_CB_Dienstplan_Januar-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-01-26_CB_Dienstplan_Januar-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-01-30_CB_Dienstplan_Februar-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-02-15_CB_Dienstplan_Februar-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-02-29_CB_Dienstplan_Februar-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-02-29_CB_Dienstplan_März-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-03-12_CB_Dienstplan_März-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-03-12_CB_Dienstplan_März-2024_variant-2.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-03-12_CB_Dienstplan_März-2024_variant-3.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-03-22_CB_Dienstplan_April-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-03-22_CB_Dienstplan_April-2024_variant-2.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-04-11_CB_Dienstplan_April-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-04-25_CB_Dienstplan_April-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-04-26_CB_Dienstplan_April-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-04-29_CB_Inventur_2023.xlsx +>f+++++++++ | Cafe_Bach/Planung/2024-04-30_CB_Dienstplan_Mai-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-05-03_CB_Dienstplan_Mai-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-05-21_CB_Dienstplan_Mai-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-05-21_CB_Dienstplan_Mai-2024_variant-2.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-05-28_CB_Dienstplan_Juni-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-05-28_CB_Dienstplan_Juni-2024_vvariant-2.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-06-13_CB_Dienstplan_Juni-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-06-13_CB_Dienstplan_Juni-2024_variant-2.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-06-19_CB_Kalkulation_Longdrinks.xlsx +>f+++++++++ | Cafe_Bach/Planung/2024-07-05_CB_Dienstplan_Juli-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-07-24_CB_Dienstplan_2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-08-23_CB_Dienstplan_September-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-10-02_CB_Dienstplan_Oktober-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-10-31_CB_Dienstplan_November-2024.pdf +>f+++++++++ | Cafe_Bach/Planung/2024-11-27_CB_Personalplanung_Schichtplan-und-Verfügbarkeit.xlsx +>f+++++++++ | Cafe_Bach/Planung/2024-12-20_CB_Inventur_2024.xlsx +>f+++++++++ | Cafe_Bach/Planung/2025-01-06_CB_Jourfixe_2022-07.xlsx +>f+++++++++ | Cafe_Bach/Planung/2025-04-08_CB_Jourfixe_Patrick.xlsx +>f+++++++++ | Cafe_Bach/Planung/2025-05-21_CB_Warenbestand_bestand.xlsx +>f+++++++++ | Cafe_Bach/Planung/2026-09-09_CB_Treffen_SP5.pdf +.d..t...... | Cafe_Bach/Veranstaltungen/ +>f+++++++++ | Cafe_Bach/Veranstaltungen/2022-08-31_CB_Auswertung_Soft-Opening-Café-Bach.xlsx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2023-05-17_CB_Gästeliste_IDAHOBITA-2023.xlsx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2023-11-30_CB_Getränkeliste_Kleine-Show-2023.xlsx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2024-07-16_CB_Cateringrechner_Abschied-Ute-Hummler.xlsx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2024-07-23_CB_Getränkeliste_CSD-Ausschank.xlsx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2024-08-22_CB_Dienstplan_Apple-Event.pdf +>f+++++++++ | Cafe_Bach/Veranstaltungen/2024-11-25_CB_Getränkeliste_Kleine-Show-2024.xlsx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2024-12-17_CB_Getränkeliste_Weihnachtsfeier-AHK-2024.xlsx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2025-01-22_CB_Gruppen-und-Veranstaltungen_im.xlsx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2025-02-05_CB_Gruppen-und-Veranstaltungen_im-(version-1)_v1.xlsx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2025-03-17_CB_Ablaufplan_Nubbelverbrennung-2025.docx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2025-03-28_CB_Getränkeliste_Kaffeeklatsch-Bitte-mit-Sahne.docx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2025-03-28_CB_Getränkeliste_verkauf-preisliste-strichliste.xlsx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2025-04-24_CB_Karte_Kaffeeklatsch-Bitte-mit-Sahne.docx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2025-05-19_CB_Veranstaltungsplan_2025.xlsx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2025-06-06_CB_Ablaufplan_Rainbow-Day.docx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2025-07-24_CB_Terminvorschau_shalk-sahne-kleine-show.docx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2025-08-19_CB_Getränkeliste_Run-of-Colors-Bierwagen.xlsx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2025-11-04_CB_Handout-Nutzung_Cafe-Bach-Veranstaltung.docx +>f+++++++++ | Cafe_Bach/Veranstaltungen/2026-09-09_CB_Inventur_Kühlschrank-07-08-13-08.pdf +.d..t...... | Cafe_Bach/Verwaltung/ +>f+++++++++ | Cafe_Bach/Verwaltung/2014-06-11_CB_HACCP-Handbuch_Stand-06-2014.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2016-11-02_CB_Infektionsschutzgesetz_IfSG.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2022-06-13_CB_Konzessionsantrag_Café-Bach-–-2022.docx +>f+++++++++ | Cafe_Bach/Verwaltung/2022-07-13_CB_Antrag_Unbedenklichkeitsbescheinigung.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2022-07-18_CB_Schuldnerverzeichnisabfrage_Café-Bach-2022.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2022-07-21_CB_Auszug_Liegenschaftskataster.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2022-09-27_CB_Konzessionsantrag_C2522092712570.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2022-10-05_CB_Antrag_Gewerbezentralregister.docx +>f+++++++++ | Cafe_Bach/Verwaltung/2022-10-26_CB_Teilnahmebescheinigung_S4OG-KM-C2522102612490.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2022-11-22_CB_Bescheinigung_S4OG-KM-C2522112209340.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2022-12-31_CB_Inventur.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-01-10_CB_Checkliste_Temperaturkontrolle-Kühlungen-2018.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-01-10_CB_Hygieneanweisung_2018.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-01-10_CB_Reinigungsliste_Küchenbereich.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-01-19_CB_Kasse_Bereinigung.xlsx +>f+++++++++ | Cafe_Bach/Verwaltung/2024-02-15_CB_Rechnung_2024.docx +>f+++++++++ | Cafe_Bach/Verwaltung/2024-02-15_CB_Rechnung_Austausch-ECDC-und-Aidshilfe-Köln.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-02-15_CB_Rechnung_CB2401.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-02-15_CB_Rechnung_DAH-JES-2024.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-02-15_CB_Rechnung_Stammtisch-Freizeit-Tandems.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-03-05_CB_Rechnung_Fraktion-DIE-LINKE.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-03-05_CB_Rechnung_Junge-Stadt-Köln.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-03-08_CB_Rechnung_Aidshilfe-Köln.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-03-08_CB_Rechnung_Karl-Lauterbach-2024.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-03-08_CB_Rechnung_Trauerfeier-BeWo.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-04-11_CB_Rechnung_Abschied-Kira-Split-2024.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-04-11_CB_Rechnung_Aidshilfe-Köln.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-04-11_CB_Rechnung_Lebenshaus-Stiftung.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-05-03_CB_Rechnung_LHS-19.04.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-05-03_CB_Rechnung_Stammtisch-Freizeit-Tandems.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-05-03_CB_Rechnung_Torben-Beimann-2024.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-05-24_CB_Rechnung_Jana-Kühnel.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-06-04_CB_Rechnung_Junge-Stadt-Köln.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-06-05_CB_Rechnung_Lebenshaus-Stiftung.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-06-05_CB_Rechnung_Workshop-Lust-&-Rausch-2024.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-06-20_CB_Rechnung_Catering-extern-2024.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-06-27_CB_Rechnung_Junge-Stadt-Köln.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-07-10_CB_Partnervereinbarung_Vytal-Mehrwegsystem.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-07-24_CB_Rechnung_Austausch-Partnerstädte.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-08-12_CB_Rechnung_Junge-Stadt-Köln.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-08-16_CB_Dienstplan_Abschied-Ute-Hummler.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-08-19_CB_Rechnung_CB2424-Abschiedsfeier-Ute-Hummler.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-08-20_CB_Rechnung_Abschied-Ute-Hummler.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-08-21_CB_Rechnung_Benjamin-Kinkel-2024.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-08-22_CB_Rechnung_CB2426-Apple-DNA-Event.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-09-02_CB_Rechnung_Junge-Stadt-Köln.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-09-04_CB_Rechnung_Rotonda-2024.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-10-01_CB_Rechnung_Aidshilfe-Köln.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-10-01_CB_Rechnung_CB2431.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-10-01_CB_Rechnung_Run-of-Colors-2024.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-10-01_CB_Rechnung_Run-of-Colors-2024_variant-2.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-10-02_CB_Rechnung_Nachtreffen-CTC-2024.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-10-15_CB_Rechnung_Aidshilfe-NRW.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-10-15_CB_Rechnung_CB2434-SHALK-NRW.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-10-28_CB_Rechnung_Talk-Gästebewirtung-Doxy-Pep-Veranstaltung.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-10-29_CB_Rechnung_Aidshilfe-Köln.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-10-31_CB_Rechnung_Junge-Stadt-Köln.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-11-04_CB_Personalessen_März-April-24.xlsx +>f+++++++++ | Cafe_Bach/Verwaltung/2024-11-05_CB_Rechnung_Aidshilfe-NRW.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-11-14_CB_Rechnung_Aidshilfe-Köln.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-11-14_CB_Rechnung_Cafe-Bach-2024.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-11-14_CB_Rechnung_DAH-Fachtag-2024.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-11-28_CB_Rechnung_Lebenshaus-Stiftung.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-11-28_CB_Rechnung_Stammtisch-Freizeit-Tandems-27.11.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-12-03_CB_Rechnung_Bettina-Baum-29.11.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-12-17_CB_Rechnung_CB2446.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2024-12-17_CB_Rechnung_CB2447-Weihnachtsfeier-AHK.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2025-01-08_CB_Inventarliste_2022-2023.xlsx +>f+++++++++ | Cafe_Bach/Verwaltung/2025-01-20_CB_Rechnung_Aidshilfe-Köln-2023-2024.xlsx +>f+++++++++ | Cafe_Bach/Verwaltung/2025-01-22_CB_Rechnung_Aidshilfe-Köln.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2025-01-29_CB_Preisliste_preise.xlsx +>f+++++++++ | Cafe_Bach/Verwaltung/2025-02-19_CB_Kassenbestand_C2525021911090.pdf +>f+++++++++ | Cafe_Bach/Verwaltung/2025-04-28_CB_Zählprotokoll_Kasse-Café-Bach.xlsx +>f+++++++++ | Cafe_Bach/Verwaltung/2025-06-03_CB_Lieferantenliste_lieferanten.xlsx +>f+++++++++ | Cafe_Bach/Verwaltung/2025-07-04_CB_Kassenbericht_Kasse.xlsx +>f+++++++++ | Cafe_Bach/Verwaltung/2026-09-09_CB_Überlassungsdaten_Cafe-Bach-Vermietung.xlsx +.d..t...... | Cafe_Bach/Vorlagen/ +>f+++++++++ | Cafe_Bach/Vorlagen/2026-08-21_CB_Getränkeliste_A4.docx +.d..t...... | Checkpoint/ +>f+++++++++ | Checkpoint/2022-01-10_Checkpoint_Anschreiben_Phönix-Sauna.pdf +>f+++++++++ | Checkpoint/2022-01-10_Checkpoint_Anschreiben_babylon-sauna.docx +>f+++++++++ | Checkpoint/2022-03-31_Checkpoint_Anschreiben_Patsy-Prävention.docx +>f+++++++++ | Checkpoint/2026-09-01_Checkpoint_Protokoll_Checkpoint-2026.docx +.d..t...... | FAK/ +>f+++++++++ | FAK/2022-02-01_FAK_Rechnung_Facharbeitskreis-Schwule-Prävention.pdf diff --git a/result2.md b/result2.md new file mode 100644 index 0000000..c9b57cd --- /dev/null +++ b/result2.md @@ -0,0 +1,445 @@ +/data/02_DEV/13_document_sorter/scope_triage.py:7:- Exactly three outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage.py:12:- If Daily and Architect disagree, route conservatively to 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage.py:52:- 01_INBOX = ownership/purpose is genuinely unclear, mixed, generic, or unsafe to decide. +/data/02_DEV/13_document_sorter/scope_triage.py:73: use/ownership is unclear, choose 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage.py:75: personal patient information and no clear work use is present, choose 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage.py:77: project or professional purchase context; otherwise choose 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage.py:79:- If work/private ownership cannot be determined safely, choose 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage.py:85:- A blank/sample contact-import CSV -> 01_INBOX unless actual work records are present. +/data/02_DEV/13_document_sorter/scope_triage.py:86:- A generic Roboto/font LICENSE -> 01_INBOX unless tied to a concrete work deliverable. +/data/02_DEV/13_document_sorter/scope_triage.py:91:- <=0.70 when evidence is thin or mixed; prefer 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage.py:259: if scope not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: +/data/02_DEV/13_document_sorter/scope_triage.py:322: if result.get("scope") not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: +/data/02_DEV/13_document_sorter/scope_triage.py:390: "scope": "01_INBOX", +/data/02_DEV/13_document_sorter/scope_triage.py:418: "scope": "01_INBOX", +/data/02_DEV/13_document_sorter/scope_triage.py:536: print("Allowed outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX") +/data/02_DEV/13_document_sorter/scope_triage.py:617: print("01_INBOX:", counts["01_INBOX"]) +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:8:V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:452: if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:531: # In Inbox mode destinations span several sibling roots. Always expose each +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:552: # For a real 01_INBOX we must always allow "leave it where it is" when the +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:620:- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:798: V2 Inbox behaviour where the Inbox is nested inside a broader target root. +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:1073:def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:1095: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:1096: ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:1161: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:1202: "Unpack them first, then use Rescan Inbox.", +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:1352: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:1361: f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:1401: ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:1402: ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:1422: inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:1425: elif inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:1436: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-date_v3.py:1466: launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), aliases=aliases, inbox_mode=inbox_mode) +/data/02_DEV/13_document_sorter/2026-09-14_nonwork-routing_post-apply-verify_v1.py:27:PRIVATE_BASE = Path("/data/03_PRIVAT/01_INBOX/Recovered_Work-PC_2026-09-14") +/data/02_DEV/13_document_sorter/2026-09-14_nonwork-routing_post-apply-verify_v1.py:28:INBOX_BASE = Path("/data/01_INBOX/Recovered_Work-PC_2026-09-14") +/data/02_DEV/13_document_sorter/2026-09-14_nonwork-routing_post-apply-verify_v1.py:177: for base in (PRIVATE_BASE, INBOX_BASE): +/data/02_DEV/13_document_sorter/nonwork_hash_audit.py:6:- Compare files routed to 03_PRIVAT / 01_INBOX against the existing +/data/02_DEV/13_document_sorter/nonwork_hash_audit.py:7: authoritative /data/03_PRIVAT and /data/01_INBOX trees. +/data/02_DEV/13_document_sorter/nonwork_hash_audit.py:99: p.add_argument("--inbox-root", type=Path, default=Path("/data/01_INBOX")) +/data/02_DEV/13_document_sorter/nonwork_hash_audit.py:110: if row["effective_scope"] in {"03_PRIVAT", "01_INBOX"} +/data/02_DEV/13_document_sorter/nonwork_hash_audit.py:117: print(f" 01_INBOX: {sum(r['effective_scope'] == '01_INBOX' for r in candidates)}") +/data/02_DEV/13_document_sorter/nonwork_hash_audit.py:122: inbox_idx, inbox_count, inbox_errors = index_tree(args.inbox_root) +/data/02_DEV/13_document_sorter/nonwork_hash_audit.py:125: print(f"/data/01_INBOX files hashed: {inbox_count}") +/data/02_DEV/13_document_sorter/nonwork_hash_audit.py:126: print(f"Target hash errors: {len(privat_errors) + len(inbox_errors)}") +/data/02_DEV/13_document_sorter/nonwork_hash_audit.py:157: same_idx = privat_idx if scope == "03_PRIVAT" else inbox_idx +/data/02_DEV/13_document_sorter/nonwork_hash_audit.py:158: cross_idx = inbox_idx if scope == "03_PRIVAT" else privat_idx +/data/02_DEV/13_document_sorter/nonwork_hash_audit.py:178: result["audit_status"] = "UNIQUE_VS_PRIVAT_AND_INBOX" +/data/02_DEV/13_document_sorter/nonwork_hash_audit.py:210: "UNIQUE_VS_PRIVAT_AND_INBOX", +/data/02_DEV/13_document_sorter/nonwork_hash_audit.py:215: print(f"Target hash errors: {len(privat_errors) + len(inbox_errors)}") +/data/02_DEV/13_document_sorter/nonwork_hash_audit.py:219: if privat_errors or inbox_errors or candidate_errors: +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:7:- Exactly three outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:11:- If Daily and Architect disagree, route conservatively to 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:51:- 01_INBOX = ownership/purpose is genuinely unclear, mixed, generic, or unsafe to decide. +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:72: use/ownership is unclear, choose 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:74: personal patient information and no clear work use is present, choose 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:76: project or professional purchase context; otherwise choose 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:78:- If work/private ownership cannot be determined safely, choose 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:84:- A blank/sample contact-import CSV -> 01_INBOX unless actual work records are present. +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:85:- A generic Roboto/font LICENSE -> 01_INBOX unless tied to a concrete work deliverable. +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:90:- <=0.70 when evidence is thin or mixed; prefer 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:258: if scope not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:321: if result.get("scope") not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:371: "scope": "01_INBOX", +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:399: "scope": "01_INBOX", +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:517: print("Allowed outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX") +/data/02_DEV/13_document_sorter/scope_triage_v2_working.py:598: print("01_INBOX:", counts["01_INBOX"]) +/data/02_DEV/13_document_sorter/2026-09-10_sorter-policy_cafebach-source-alias_v2.json:2: "01_INBOX": [ +/data/02_DEV/13_document_sorter/2026-09-10_sorter-policy_cafebach-source-alias_v2.json:3: "01_INBOX", +/data/02_DEV/13_document_sorter/2026-09-10_sorter-policy_cafebach-source-alias_v2.json:128: "01_INBOX": { +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_work-private-inbox_v1.py:7:- Exactly three outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_work-private-inbox_v1.py:10:- If Daily and Architect disagree, route conservatively to 01_INBOX. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_work-private-inbox_v1.py:48:- 01_INBOX = genuinely unclear, mixed, or not safe to decide +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_work-private-inbox_v1.py:61:- If work/private ownership cannot be determined safely, choose 01_INBOX. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_work-private-inbox_v1.py:231: if scope not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_work-private-inbox_v1.py:247: if result.get("scope") not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_work-private-inbox_v1.py:288: "scope": "01_INBOX", +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_work-private-inbox_v1.py:315: "scope": "01_INBOX", +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_work-private-inbox_v1.py:431: print("Allowed outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX") +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_work-private-inbox_v1.py:501: print("01_INBOX:", counts["01_INBOX"]) +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:7:- Exactly three outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:12:- If Daily and Architect disagree, route conservatively to 01_INBOX. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:52:- 01_INBOX = ownership/purpose is genuinely unclear, mixed, generic, or unsafe to decide. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:73: use/ownership is unclear, choose 01_INBOX. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:75: personal patient information and no clear work use is present, choose 01_INBOX. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:77: project or professional purchase context; otherwise choose 01_INBOX. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:79:- If work/private ownership cannot be determined safely, choose 01_INBOX. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:85:- A blank/sample contact-import CSV -> 01_INBOX unless actual work records are present. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:86:- A generic Roboto/font LICENSE -> 01_INBOX unless tied to a concrete work deliverable. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:91:- <=0.70 when evidence is thin or mixed; prefer 01_INBOX. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:259: if scope not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:322: if result.get("scope") not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:390: "scope": "01_INBOX", +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:418: "scope": "01_INBOX", +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:536: print("Allowed outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX") +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_curated-archive-provenance_v3.py:617: print("01_INBOX:", counts["01_INBOX"]) +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:7:- Exactly three outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:11:- If Daily and Architect disagree, route conservatively to 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:51:- 01_INBOX = ownership/purpose is genuinely unclear, mixed, generic, or unsafe to decide. +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:72: use/ownership is unclear, choose 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:74: personal patient information and no clear work use is present, choose 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:76: project or professional purchase context; otherwise choose 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:78:- If work/private ownership cannot be determined safely, choose 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:84:- A blank/sample contact-import CSV -> 01_INBOX unless actual work records are present. +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:85:- A generic Roboto/font LICENSE -> 01_INBOX unless tied to a concrete work deliverable. +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:90:- <=0.70 when evidence is thin or mixed; prefer 01_INBOX. +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:258: if scope not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:321: if result.get("scope") not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:371: "scope": "01_INBOX", +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:399: "scope": "01_INBOX", +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:517: print("Allowed outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX") +/data/02_DEV/13_document_sorter/scope_triage_v1_working.py:598: print("01_INBOX:", counts["01_INBOX"]) +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:2:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/06AnOverviewofCellBiology.pdf 06AnOverviewofCellBiology.pdf 01_INBOX 03_PRIVAT 0.50 models-disagree reviewed-content Generic academic/reference material with no Aidshilfe or Café-Bach work context. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:3:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf 01_INBOX 03_PRIVAT 0.50 models-disagree reviewed-content Generic academic textbook/reference material with no work-specific context. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:4:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Gastro Frohes Neues Jahr.docx Anschreiben - Gastro Frohes Neues Jahr.docx 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Sparse extract, but it belongs to the same Checkpoint/Gastro professional correspondence set as neighboring confirmed work letters. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:5:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben.docx Anschreiben.docx 01_INBOX 04_ARBEIT 0.50 models-disagree user-confirmed User confirmed this is Aidshilfe/Checkpoint work correspondence. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:6:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Bolsover2004CellBiology.pdf Bolsover2004CellBiology.pdf 01_INBOX 03_PRIVAT 0.75 models-agree reviewed-content Generic academic textbook/reference material with no work-specific context. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:7:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Covid IMpfungen 2022.docx Covid IMpfungen 2022.docx 01_INBOX 03_PRIVAT 0.50 models-disagree content-context Personal vaccination record; not an organizational work record. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:8:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Preisvergleich Angebote.docx Preisvergleich Angebote.docx 01_INBOX 04_ARBEIT 0.50 models-disagree reviewed-content Contains a Queeres Netzwerk project-application link alongside projector price comparisons; fits work procurement/project context. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:9:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Quiz Terasse 21-07.pptx Quiz Terasse 21-07.pptx 01_INBOX 03_PRIVAT 0.50 models-disagree user-confirmed User confirmed this belongs to private/Chrystal activities. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:10:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen (1).pdf QuiztalMath Antwortbogen (1).pdf 01_INBOX 03_PRIVAT 0.50 models-disagree user-confirmed User confirmed this belongs to private/Chrystal activities. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:11:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen.pdf QuiztalMath Antwortbogen.pdf 01_INBOX 03_PRIVAT 0.65 models-agree user-confirmed User confirmed this belongs to private/Chrystal activities. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:12:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/SENIOR TICKET NL 20220301.pdf SENIOR TICKET NL 20220301.pdf 01_INBOX 03_PRIVAT 0.50 models-disagree content-context Personal travel ticket rather than work documentation. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:13:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Tagesplan.xlsx Tagesplan.xlsx 01_INBOX 03_PRIVAT 0.50 models-disagree reviewed-content Personal daily planning sheet containing private routine items such as waking, showering and going to work. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:14:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf 01_INBOX 03_PRIVAT 0.50 models-disagree reviewed-content Generic educational material for children with no work-specific context. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:15:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Wichtige_Kundeninformation_zu_Konto_0125273623.PDF Wichtige_Kundeninformation_zu_Konto_0125273623.PDF 01_INBOX 03_PRIVAT 0.50 models-disagree content-context Personal bank-account/customer information rather than work documentation. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:16:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Data.csv Data.csv 01_INBOX 04_ARBEIT 0.50 models-disagree paired-document-context Paired with SurvStat Info.pdf in the same folder; the companion file is confirmed work health-surveillance material. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:17:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_1.pdf ticket_patrick-ja_queerfeld-festiv_1.pdf 01_INBOX 03_PRIVAT 0.50 models-disagree paired-document-context Personal festival ticket; matching companion ticket is already classified private. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:18:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Kühlschrank 07-08-13,08.pdf Kühlschrank 07-08-13,08.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Café-Bach operational file in the CB inbox; refrigerator-related document belongs to work context. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:19:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/SP5 treffen Samstag 15-08.pdf SP5 treffen Samstag 15-08.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context SP5 meeting document in Café-Bach work backup; SP5 is a work project. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:20:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/A5 Aufsteller.docx A5 Aufsteller.docx 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Display/signage document inside Café-Bach archive; work-material context is stronger than sparse extracted text. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:21:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Aushang für Notfälle.docx Getränkeliste Aushang für Notfälle.docx 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Emergency beverage-list signage for Café-Bach operations. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:22:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Auszug_aus_Liegenschaftskataster.pdf Auszug_aus_Liegenschaftskataster.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Property extract stored in the Café-Bach concession application package; work licensing record. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:23:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Certificate is part of the Café-Bach concession application package; retained as work licensing documentation. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:25:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2414 Junge Stadt Köln.pdf CB2414 Junge Stadt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:26:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2419 Lebenshaus-Stiftung.pdf CB2419 Lebenshaus-Stiftung.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:27:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2432 Aidshilfe Köln.pdf CB2432 Aidshilfe Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:28:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2438 Junge Stadt Köln.pdf CB2438 Junge Stadt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:29:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2443 Lebenshaus-Stiftung.pdf CB2443 Lebenshaus-Stiftung.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v2.tsv:30:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2444 Junge Stadt Köln.pdf CB2444 Junge Stadt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.patch:20:+ V2 Inbox behaviour where the Inbox is nested inside a broader target root. +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.patch:184: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.patch:214: launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), aliases=aliases, inbox_mode=inbox_mode) +/data/02_DEV/13_document_sorter/sorter_policy.json:2: "01_INBOX": [ +/data/02_DEV/13_document_sorter/sorter_policy.json:3: "01_INBOX", +/data/02_DEV/13_document_sorter/sorter_policy.json:128: "01_INBOX": { +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:8:V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:452: if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:531: # In Inbox mode destinations span several sibling roots. Always expose each +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:552: # For a real 01_INBOX we must always allow "leave it where it is" when the +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:620:- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:915: V2 Inbox behaviour where the Inbox is nested inside a broader target root. +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:1190:def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:1212: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:1213: ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:1278: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:1319: "Unpack them first, then use Rescan Inbox.", +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:1469: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:1478: f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:1518: ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:1519: ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:1541: inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:1544: elif inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:1555: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_single-request-fallback_v5.py:1589: launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:8:V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:673: if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:752: # In Inbox mode destinations span several sibling roots. Always expose each +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:773: # For a real 01_INBOX we must always allow "leave it where it is" when the +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:841:- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:1126: V2 Inbox behaviour where the Inbox is nested inside a broader target root. +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:1401:def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:1423: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:1424: ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:1489: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:1530: "Unpack them first, then use Rescan Inbox.", +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:1680: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:1689: f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:1729: ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:1730: ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:1752: inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:1755: elif inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:1766: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_naming-cleanup_v7.py:1800: launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:8:V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:427: if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:505: # In Inbox mode destinations span several sibling roots. Always expose each +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:526: # For a real 01_INBOX we must always allow "leave it where it is" when the +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:585:- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:747: V2 Inbox behaviour where the Inbox is nested inside a broader target root. +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:1022:def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:1044: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:1045: ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:1110: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:1151: "Unpack them first, then use Rescan Inbox.", +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:1301: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:1310: f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:1350: ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:1351: ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:1371: inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:1374: elif inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:1385: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_global-dedupe.py:1415: launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), aliases=aliases, inbox_mode=inbox_mode) +/data/02_DEV/13_document_sorter/nonwork_controlled_apply.py:15:Syncthing for 03_PRIVAT and 01_INBOX must already be paused before running. +/data/02_DEV/13_document_sorter/nonwork_controlled_apply.py:38:PRIVATE_BASE = Path("/data/03_PRIVAT/01_INBOX/Recovered_Work-PC_2026-09-14") +/data/02_DEV/13_document_sorter/nonwork_controlled_apply.py:39:INBOX_BASE = Path("/data/01_INBOX/Recovered_Work-PC_2026-09-14") +/data/02_DEV/13_document_sorter/nonwork_controlled_apply.py:40:ALLOWED_COPY_BASES = (PRIVATE_BASE, INBOX_BASE) +/data/02_DEV/13_document_sorter/nonwork_controlled_apply.py:41:ALLOWED_SKIP_ROOTS = (Path("/data/03_PRIVAT"), Path("/data/01_INBOX")) +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:3:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/06AnOverviewofCellBiology.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/06AnOverviewofCellBiology.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert 06AnOverviewofCellBiology.pdf .pdf 2022-05-30 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:947 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.80 03_PRIVAT yes reviewed-content Generic academic/reference material with no Aidshilfe or Café-Bach work context. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:4:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf .pdf 2022-05-30 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:436 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.78 03_PRIVAT yes reviewed-content Generic academic textbook/reference material with no work-specific context. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:7:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Gastro Frohes Neues Jahr.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Gastro Frohes Neues Jahr.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben - Gastro Frohes Neues Jahr.docx .docx 2022-01-07 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT docx:61 models-disagree ownership-fragile-filename 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes structural-context Sparse extract, but it belongs to the same Checkpoint/Gastro professional correspondence set as neighboring confirmed work letters. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:12:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben.docx .docx 2022-01-07 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT docx:61 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.85 04_ARBEIT yes user-confirmed User confirmed this is Aidshilfe/Checkpoint work correspondence. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:16:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Bolsover2004CellBiology.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Bolsover2004CellBiology.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Bolsover2004CellBiology.pdf .pdf 2022-05-30 mtime 01_INBOX 0.75 Generic textbook, no evidence of work use or ownership. pdf:2400 models-agree primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.80 03_PRIVAT yes reviewed-content Generic academic textbook/reference material with no work-specific context. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:17:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Covid IMpfungen 2022.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Covid IMpfungen 2022.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Covid IMpfungen 2022.docx .docx 2022-01-18 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT docx:318 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 03_PRIVAT yes content-context Personal vaccination record; not an organizational work record. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:19:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Preisvergleich Angebote.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Preisvergleich Angebote.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Preisvergleich Angebote.docx .docx 2022-01-03 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT docx:476 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.92 04_ARBEIT yes reviewed-content Contains a Queeres Netzwerk project-application link alongside projector price comparisons; fits work procurement/project context. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:20:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Quiz Terasse 21-07.pptx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Quiz Terasse 21-07.pptx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Quiz Terasse 21-07.pptx .pptx 2022-08-10 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pptx:462 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.80 03_PRIVAT yes user-confirmed User confirmed this belongs to private/Chrystal activities. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:21:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen (1).pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen (1).pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert QuiztalMath Antwortbogen (1).pdf .pdf 2022-11-02 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:173 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.75 03_PRIVAT yes user-confirmed User confirmed this belongs to private/Chrystal activities. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:22:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert QuiztalMath Antwortbogen.pdf .pdf 2022-11-02 mtime 01_INBOX 0.65 Generic quiz answer sheet, no clear work context or ownership. pdf:141 models-agree primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.65 03_PRIVAT yes user-confirmed User confirmed this belongs to private/Chrystal activities. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:23:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Roboto/LICENSE.txt" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Roboto/LICENSE.txt SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Roboto LICENSE.txt .txt 2022-01-17 mtime 01_INBOX 0.75 Generic font license, no work context. text:2400 models-agree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.95 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:24:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/S2OGKM_C25021111716590.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/S2OGKM_C25021111716590.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert S2OGKM_C25021111716590.pdf .pdf 2021-11-17 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT pdf:29 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:25:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/SENIOR TICKET NL 20220301.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/SENIOR TICKET NL 20220301.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert SENIOR TICKET NL 20220301.pdf .pdf 2022-03-01 filename 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:2400 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 03_PRIVAT yes content-context Personal travel ticket rather than work documentation. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:26:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Sample CSV file for importing contacts (1).csv" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Sample CSV file for importing contacts (1).csv SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Sample CSV file for importing contacts (1).csv .csv 2022-01-07 mtime 01_INBOX 0.65 Generic sample CSV for contact import, no work context. text:1994 models-agree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.65 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:27:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Tagesplan.xlsx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Tagesplan.xlsx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Tagesplan.xlsx .xlsx 2022-01-11 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT xlsx:951 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 03_PRIVAT yes reviewed-content Personal daily planning sheet containing private routine items such as waking, showering and going to work. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:28:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Testsample.csv" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Testsample.csv SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Testsample.csv .csv 2022-01-07 mtime 01_INBOX 0.75 Sample contact list, no real personal or work data. text:1996 models-agree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.85 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:29:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf .pdf 2022-08-10 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT pdf:2400 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 03_PRIVAT yes reviewed-content Generic educational material for children with no work-specific context. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:30:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Wichtige_Kundeninformation_zu_Konto_0125273623.PDF" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Wichtige_Kundeninformation_zu_Konto_0125273623.PDF SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Wichtige_Kundeninformation_zu_Konto_0125273623.PDF .pdf 2022-08-15 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:2400 models-disagree ownership-fragile-filename,work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.95 03_PRIVAT yes content-context Personal bank-account/customer information rather than work documentation. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:31:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Data.csv" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Data.csv SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat Data.csv .csv 2022-08-29 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT text:362 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 04_ARBEIT yes paired-document-context Paired with SurvStat Info.pdf in the same folder; the companion file is confirmed work health-surveillance material. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:33:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_1.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_1.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert ticket_patrick-ja_queerfeld-festiv_1.pdf .pdf 2022-08-29 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:381 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 03_PRIVAT yes paired-document-context Personal festival ticket; matching companion ticket is already classified private. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:35:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Getränkeliste Verzehrkarte.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Getränkeliste Verzehrkarte.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB Getränkeliste Verzehrkarte.pdf .pdf 2026-09-03 mtime 04_ARBEIT 0.95 Document lists beverages for a team event with payment instructions, indicating work use. pdf:787 primary 04_ARBEIT 0.95 04_ARBEIT no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:36:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Kühlschrank 07-08-13,08.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Kühlschrank 07-08-13,08.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB Kühlschrank 07-08-13,08.pdf .pdf 2026-09-09 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:27 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.80 04_ARBEIT yes structural-context Café-Bach operational file in the CB inbox; refrigerator-related document belongs to work context. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:37:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/SP5 treffen Samstag 15-08.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/SP5 treffen Samstag 15-08.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB SP5 treffen Samstag 15-08.pdf .pdf 2026-09-09 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT pdf:27 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 04_ARBEIT yes structural-context SP5 meeting document in Café-Bach work backup; SP5 is a work project. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:38:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/A5 Aufsteller.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/A5 Aufsteller.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 A5 Aufsteller.docx .docx 2024-11-08 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT docx:53 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.90 04_ARBEIT yes structural-context Display/signage document inside Café-Bach archive; work-material context is stronger than sparse extracted text. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:84:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Aushang für Notfälle.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Aushang für Notfälle.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Getränkeliste Aushang für Notfälle.docx .docx 2023-05-16 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT docx:216 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.90 04_ARBEIT yes structural-context Emergency beverage-list signage for Café-Bach operations. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:115:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Auszug_aus_Liegenschaftskataster.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Auszug_aus_Liegenschaftskataster.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Auszug_aus_Liegenschaftskataster.pdf .pdf 2022-07-21 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:no-text-or-scan models-disagree primary-confidence-below-0.85,limited-content 01_INBOX 0.75 03_PRIVAT 0.75 04_ARBEIT yes structural-context Property extract stored in the Café-Bach concession application package; work licensing record. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:116:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf .pdf 2022-11-22 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:29 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.92 04_ARBEIT yes structural-context Certificate is part of the Café-Bach concession application package; retained as work licensing documentation. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:171:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2414 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2414 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2414 Junge Stadt Köln.pdf .pdf 2024-05-03 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1062 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:176:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2419 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2419 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2419 Lebenshaus-Stiftung.pdf .pdf 2024-06-05 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1027 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.92 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:190:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2432 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2432 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2432 Aidshilfe Köln.pdf .pdf 2024-10-01 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1038 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:196:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2438 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2438 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2438 Junge Stadt Köln.pdf .pdf 2024-10-31 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1137 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:201:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2443 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2443 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2443 Lebenshaus-Stiftung.pdf .pdf 2024-11-28 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1086 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.90 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v2.tsv:202:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2444 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2444 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2444 Junge Stadt Köln.pdf .pdf 2024-11-28 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1139 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:8:V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:540: if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:619: # In Inbox mode destinations span several sibling roots. Always expose each +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:640: # For a real 01_INBOX we must always allow "leave it where it is" when the +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:708:- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:993: V2 Inbox behaviour where the Inbox is nested inside a broader target root. +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:1268:def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:1290: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:1291: ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:1356: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:1397: "Unpack them first, then use Rescan Inbox.", +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:1547: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:1556: f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:1596: ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:1597: ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:1619: inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:1622: elif inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:1633: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-13_quick-sorter_deterministic-naming_v6.py:1667: launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) +/data/02_DEV/13_document_sorter/README.md:3:Siehe **README_V2.md** für den neuen Top-Level-`01_INBOX`-Workflow. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:3:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/06AnOverviewofCellBiology.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/06AnOverviewofCellBiology.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert 06AnOverviewofCellBiology.pdf .pdf 2022-05-30 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:947 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.80 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:4:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert ALBERTS-BRAY-HOPKIN-JOHNSON-LEWIS-RAFF-ROBERTS-WALTER_-_Essential_Cell_Biology_1_parte.pdf .pdf 2022-05-30 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:436 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.78 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:7:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Gastro Frohes Neues Jahr.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben - Gastro Frohes Neues Jahr.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben - Gastro Frohes Neues Jahr.docx .docx 2022-01-07 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT docx:61 models-disagree ownership-fragile-filename 04_ARBEIT 0.95 03_PRIVAT 0.80 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:12:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben/Anschreiben.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Anschreiben Anschreiben.docx .docx 2022-01-07 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT docx:61 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.85 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:16:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Bolsover2004CellBiology.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Bolsover2004CellBiology.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Bolsover2004CellBiology.pdf .pdf 2022-05-30 mtime 01_INBOX 0.75 Generic textbook, no evidence of work use or ownership. pdf:2400 models-agree primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.80 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:17:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Covid IMpfungen 2022.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Covid IMpfungen 2022.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Covid IMpfungen 2022.docx .docx 2022-01-18 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT docx:318 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 03_PRIVAT yes content-context Personal vaccination record; not an organizational work record. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:19:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Preisvergleich Angebote.docx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Preisvergleich Angebote.docx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Preisvergleich Angebote.docx .docx 2022-01-03 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT docx:476 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.92 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:20:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Quiz Terasse 21-07.pptx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Quiz Terasse 21-07.pptx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Quiz Terasse 21-07.pptx .pptx 2022-08-10 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pptx:462 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.80 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:21:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen (1).pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen (1).pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert QuiztalMath Antwortbogen (1).pdf .pdf 2022-11-02 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:173 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.75 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:22:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/QuiztalMath Antwortbogen.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert QuiztalMath Antwortbogen.pdf .pdf 2022-11-02 mtime 01_INBOX 0.65 Generic quiz answer sheet, no clear work context or ownership. pdf:141 models-agree primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.65 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:23:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Roboto/LICENSE.txt" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Roboto/LICENSE.txt SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Roboto LICENSE.txt .txt 2022-01-17 mtime 01_INBOX 0.75 Generic font license, no work context. text:2400 models-agree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.95 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:24:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/S2OGKM_C25021111716590.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/S2OGKM_C25021111716590.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert S2OGKM_C25021111716590.pdf .pdf 2021-11-17 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT pdf:29 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:25:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/SENIOR TICKET NL 20220301.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/SENIOR TICKET NL 20220301.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert SENIOR TICKET NL 20220301.pdf .pdf 2022-03-01 filename 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:2400 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 03_PRIVAT yes content-context Personal travel ticket rather than work documentation. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:26:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Sample CSV file for importing contacts (1).csv" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Sample CSV file for importing contacts (1).csv SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Sample CSV file for importing contacts (1).csv .csv 2022-01-07 mtime 01_INBOX 0.65 Generic sample CSV for contact import, no work context. text:1994 models-agree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.65 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:27:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Tagesplan.xlsx" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Tagesplan.xlsx SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Tagesplan.xlsx .xlsx 2022-01-11 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT xlsx:951 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:28:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Testsample.csv" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Testsample.csv SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Testsample.csv .csv 2022-01-07 mtime 01_INBOX 0.75 Sample contact list, no real personal or work data. text:1996 models-agree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 01_INBOX 0.85 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:29:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Vorschau_48591_Mathe_zum_Schmunzeln_-_111_listig-lustige_Rechengeschichten_fuer_die_Grundschule.pdf .pdf 2022-08-10 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT pdf:2400 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 01_INBOX no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:30:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Wichtige_Kundeninformation_zu_Konto_0125273623.PDF" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Wichtige_Kundeninformation_zu_Konto_0125273623.PDF SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert Wichtige_Kundeninformation_zu_Konto_0125273623.PDF .pdf 2022-08-15 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:2400 models-disagree ownership-fragile-filename,work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.95 03_PRIVAT yes content-context Personal bank-account/customer information rather than work documentation. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:31:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Data.csv" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Data.csv SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat Data.csv .csv 2022-08-29 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT text:362 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 04_ARBEIT yes paired-document-context Paired with SurvStat Info.pdf in the same folder; the companion file is confirmed work health-surveillance material. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:33:"SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_1.pdf" SOURCE Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_1.pdf SOURCE/Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert ticket_patrick-ja_queerfeld-festiv_1.pdf .pdf 2022-08-29 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:381 models-disagree ownership-fragile-filename,primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.92 03_PRIVAT yes paired-document-context Personal festival ticket; matching companion ticket is already classified private. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:35:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Getränkeliste Verzehrkarte.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Getränkeliste Verzehrkarte.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB Getränkeliste Verzehrkarte.pdf .pdf 2026-09-03 mtime 04_ARBEIT 0.95 Document lists beverages for a team event with payment instructions, indicating work use. pdf:787 primary 04_ARBEIT 0.95 04_ARBEIT no +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:36:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Kühlschrank 07-08-13,08.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Kühlschrank 07-08-13,08.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB Kühlschrank 07-08-13,08.pdf .pdf 2026-09-09 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:27 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 03_PRIVAT 0.80 04_ARBEIT yes structural-context Café-Bach operational file in the CB inbox; refrigerator-related document belongs to work context. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:37:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/SP5 treffen Samstag 15-08.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/SP5 treffen Samstag 15-08.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB SP5 treffen Samstag 15-08.pdf .pdf 2026-09-09 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT pdf:27 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.80 04_ARBEIT yes structural-context SP5 meeting document in Café-Bach work backup; SP5 is a work project. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:38:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/A5 Aufsteller.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/A5 Aufsteller.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 A5 Aufsteller.docx .docx 2024-11-08 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT docx:53 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.90 04_ARBEIT yes structural-context Display/signage document inside Café-Bach archive; work-material context is stronger than sparse extracted text. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:84:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Aushang für Notfälle.docx" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Aushang für Notfälle.docx SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16 Getränkeliste Aushang für Notfälle.docx .docx 2023-05-16 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=04_ARBEIT docx:216 models-disagree primary-confidence-below-0.85 01_INBOX 0.75 04_ARBEIT 0.90 04_ARBEIT yes structural-context Emergency beverage-list signage for Café-Bach operations. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:115:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Auszug_aus_Liegenschaftskataster.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Auszug_aus_Liegenschaftskataster.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Auszug_aus_Liegenschaftskataster.pdf .pdf 2022-07-21 mtime 01_INBOX 0.50 model disagreement: daily=01_INBOX; architect=03_PRIVAT pdf:no-text-or-scan models-disagree primary-confidence-below-0.85,limited-content 01_INBOX 0.75 03_PRIVAT 0.75 04_ARBEIT yes structural-context Property extract stored in the Café-Bach concession application package; work licensing record. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:116:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf .pdf 2022-11-22 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:29 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.92 04_ARBEIT yes structural-context Certificate is part of the Café-Bach concession application package; retained as work licensing documentation. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:171:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2414 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2414 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2414 Junge Stadt Köln.pdf .pdf 2024-05-03 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1062 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:176:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2419 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2419 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2419 Lebenshaus-Stiftung.pdf .pdf 2024-06-05 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1027 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.92 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:190:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2432 Aidshilfe Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2432 Aidshilfe Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2432 Aidshilfe Köln.pdf .pdf 2024-10-01 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1038 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:196:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2438 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2438 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2438 Junge Stadt Köln.pdf .pdf 2024-10-31 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1137 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:201:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2443 Lebenshaus-Stiftung.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2443 Lebenshaus-Stiftung.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2443 Lebenshaus-Stiftung.pdf .pdf 2024-11-28 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1086 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.90 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_effective-routing_v1.tsv:202:"SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2444 Junge Stadt Köln.pdf" SOURCE cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2444 Junge Stadt Köln.pdf SOURCE/cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024 CB2444 Junge Stadt Köln.pdf .pdf 2024-11-28 mtime 01_INBOX 0.50 model disagreement: daily=04_ARBEIT; architect=03_PRIVAT pdf:1139 models-disagree work-vs-personal-record-class 04_ARBEIT 0.95 03_PRIVAT 0.80 04_ARBEIT yes series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:8:V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:692: if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:771: # In Inbox mode destinations span several sibling roots. Always expose each +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:792: # For a real 01_INBOX we must always allow "leave it where it is" when the +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:860:- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:1145: V2 Inbox behaviour where the Inbox is nested inside a broader target root. +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:1420:def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:1442: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:1443: ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:1508: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:1549: "Unpack them first, then use Rescan Inbox.", +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:1699: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:1708: f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:1748: ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:1749: ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:1771: inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:1774: elif inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:1785: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_root-bounded-dates_v8.py:1819: launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) +/data/02_DEV/13_document_sorter/nonwork_post_apply_verify.py:27:PRIVATE_BASE = Path("/data/03_PRIVAT/01_INBOX/Recovered_Work-PC_2026-09-14") +/data/02_DEV/13_document_sorter/nonwork_post_apply_verify.py:28:INBOX_BASE = Path("/data/01_INBOX/Recovered_Work-PC_2026-09-14") +/data/02_DEV/13_document_sorter/nonwork_post_apply_verify.py:177: for base in (PRIVATE_BASE, INBOX_BASE): +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:8:V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:731: if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:810: # In Inbox mode destinations span several sibling roots. Always expose each +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:831: # For a real 01_INBOX we must always allow "leave it where it is" when the +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:899:- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:1184: V2 Inbox behaviour where the Inbox is nested inside a broader target root. +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:1459:def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:1481: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:1482: ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:1547: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:1588: "Unpack them first, then use Rescan Inbox.", +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:1738: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:1747: f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:1788: ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:1789: ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:1811: inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:1814: elif inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:1825: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-14_quick-sorter_extractor-hardening_v9.py:1859: launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:8:V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:428: if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:507: # In Inbox mode destinations span several sibling roots. Always expose each +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:528: # For a real 01_INBOX we must always allow "leave it where it is" when the +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:596:- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:774: V2 Inbox behaviour where the Inbox is nested inside a broader target root. +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:1049:def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:1071: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:1072: ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:1137: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:1178: "Unpack them first, then use Rescan Inbox.", +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:1328: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:1337: f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:1377: ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:1378: ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:1398: inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:1401: elif inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:1412: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_context-aware-mtime_v2.py:1442: launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), aliases=aliases, inbox_mode=inbox_mode) +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:3:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/Kühlschrank 07-08-13,08.pdf Kühlschrank 07-08-13,08.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Café-Bach operational file in the CB inbox; refrigerator-related document belongs to work context. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:4:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/01 Inbox - CB/SP5 treffen Samstag 15-08.pdf SP5 treffen Samstag 15-08.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context SP5 meeting document in Café-Bach work backup; SP5 is a work project. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:5:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/A5 Aufsteller.docx A5 Aufsteller.docx 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Display/signage document inside Café-Bach archive; work-material context is stronger than sparse extracted text. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:6:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Getränkeliste Aushang für Notfälle.docx Getränkeliste Aushang für Notfälle.docx 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Emergency beverage-list signage for Café-Bach operations. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:7:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Auszug_aus_Liegenschaftskataster.pdf Auszug_aus_Liegenschaftskataster.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Property extract stored in the Café-Bach concession application package; work licensing record. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:8:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Konzession/Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf Bescheinigung in Steuersachen Schubert, Oliver Finanzamt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree structural-context Certificate is part of the Café-Bach concession application package; retained as work licensing documentation. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:9:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2414 Junge Stadt Köln.pdf CB2414 Junge Stadt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:10:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2419 Lebenshaus-Stiftung.pdf CB2419 Lebenshaus-Stiftung.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:11:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2432 Aidshilfe Köln.pdf CB2432 Aidshilfe Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:12:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2438 Junge Stadt Köln.pdf CB2438 Junge Stadt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:13:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2443 Lebenshaus-Stiftung.pdf CB2443 Lebenshaus-Stiftung.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:14:cafebach_Ordner_arbeitsrechner_duplikate_Vorhanden/Archiv bis 25-10-16/Rechnungen/Rechnungen 2024/CB2444 Junge Stadt Köln.pdf CB2444 Junge Stadt Köln.pdf 01_INBOX 04_ARBEIT 0.50 models-disagree series-consistency Café-Bach CB24xx invoice series; neighboring invoices are consistently work records. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:15:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/survstat/Data.csv Data.csv 01_INBOX 04_ARBEIT 0.50 models-disagree paired-document-context Paired with SurvStat Info.pdf in the same folder; the companion file is confirmed work health-surveillance material. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:16:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Covid IMpfungen 2022.docx Covid IMpfungen 2022.docx 01_INBOX 03_PRIVAT 0.50 models-disagree content-context Personal vaccination record; not an organizational work record. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:17:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/SENIOR TICKET NL 20220301.pdf SENIOR TICKET NL 20220301.pdf 01_INBOX 03_PRIVAT 0.50 models-disagree content-context Personal travel ticket rather than work documentation. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:18:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/Wichtige_Kundeninformation_zu_Konto_0125273623.PDF Wichtige_Kundeninformation_zu_Konto_0125273623.PDF 01_INBOX 03_PRIVAT 0.50 models-disagree content-context Personal bank-account/customer information rather than work documentation. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_manual-overrides_v1.tsv:19:Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden/Unsortiert/ticket_patrick-ja_queerfeld-festiv_1.pdf ticket_patrick-ja_queerfeld-festiv_1.pdf 01_INBOX 03_PRIVAT 0.50 models-disagree paired-document-context Personal festival ticket; matching companion ticket is already classified private. approved +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_nonwork-hash-audit_v1.py:6:- Compare files routed to 03_PRIVAT / 01_INBOX against the existing +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_nonwork-hash-audit_v1.py:7: authoritative /data/03_PRIVAT and /data/01_INBOX trees. +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_nonwork-hash-audit_v1.py:99: p.add_argument("--inbox-root", type=Path, default=Path("/data/01_INBOX")) +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_nonwork-hash-audit_v1.py:110: if row["effective_scope"] in {"03_PRIVAT", "01_INBOX"} +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_nonwork-hash-audit_v1.py:117: print(f" 01_INBOX: {sum(r['effective_scope'] == '01_INBOX' for r in candidates)}") +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_nonwork-hash-audit_v1.py:122: inbox_idx, inbox_count, inbox_errors = index_tree(args.inbox_root) +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_nonwork-hash-audit_v1.py:125: print(f"/data/01_INBOX files hashed: {inbox_count}") +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_nonwork-hash-audit_v1.py:126: print(f"Target hash errors: {len(privat_errors) + len(inbox_errors)}") +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_nonwork-hash-audit_v1.py:157: same_idx = privat_idx if scope == "03_PRIVAT" else inbox_idx +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_nonwork-hash-audit_v1.py:158: cross_idx = inbox_idx if scope == "03_PRIVAT" else privat_idx +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_nonwork-hash-audit_v1.py:178: result["audit_status"] = "UNIQUE_VS_PRIVAT_AND_INBOX" +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_nonwork-hash-audit_v1.py:210: "UNIQUE_VS_PRIVAT_AND_INBOX", +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_nonwork-hash-audit_v1.py:215: print(f"Target hash errors: {len(privat_errors) + len(inbox_errors)}") +/data/02_DEV/13_document_sorter/2026-09-14_scope-triage_nonwork-hash-audit_v1.py:219: if privat_errors or inbox_errors or candidate_errors: +/data/02_DEV/13_document_sorter/nonwork_execution_preflight.py:33: Path("/data/03_PRIVAT/01_INBOX/Recovered_Work-PC_2026-09-14"), +/data/02_DEV/13_document_sorter/nonwork_execution_preflight.py:34: Path("/data/01_INBOX/Recovered_Work-PC_2026-09-14"), +/data/02_DEV/13_document_sorter/nonwork_execution_preflight.py:39: Path("/data/01_INBOX"), +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:8:V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:451: if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:530: # In Inbox mode destinations span several sibling roots. Always expose each +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:551: # For a real 01_INBOX we must always allow "leave it where it is" when the +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:619:- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:797: V2 Inbox behaviour where the Inbox is nested inside a broader target root. +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:1072:def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:1094: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:1095: ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:1160: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:1201: "Unpack them first, then use Rescan Inbox.", +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:1351: if inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:1360: f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:1400: ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:1401: ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:1421: inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:1424: elif inbox_mode: +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:1435: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/2026-09-10_quick-sorter_archive-cutoff-safe_v4.py:1465: launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), aliases=aliases, inbox_mode=inbox_mode) +/data/02_DEV/13_document_sorter/quick_sorter.py:8:V2 adds a first-class top-level 01_INBOX workflow: source files can be routed +/data/02_DEV/13_document_sorter/quick_sorter.py:731: if len(rel.parts) <= 2 and "Altbestand" not in rel.parts and "01_INBOX" not in rel.parts: +/data/02_DEV/13_document_sorter/quick_sorter.py:810: # In Inbox mode destinations span several sibling roots. Always expose each +/data/02_DEV/13_document_sorter/quick_sorter.py:831: # For a real 01_INBOX we must always allow "leave it where it is" when the +/data/02_DEV/13_document_sorter/quick_sorter.py:899:- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +/data/02_DEV/13_document_sorter/quick_sorter.py:1184: V2 Inbox behaviour where the Inbox is nested inside a broader target root. +/data/02_DEV/13_document_sorter/quick_sorter.py:1459:def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): +/data/02_DEV/13_document_sorter/quick_sorter.py:1481: if inbox_mode: +/data/02_DEV/13_document_sorter/quick_sorter.py:1482: ttk.Button(bar, text="Rescan Inbox", command=self.rescan).pack(side="left", padx=2) +/data/02_DEV/13_document_sorter/quick_sorter.py:1547: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/quick_sorter.py:1588: "Unpack them first, then use Rescan Inbox.", +/data/02_DEV/13_document_sorter/quick_sorter.py:1738: if inbox_mode: +/data/02_DEV/13_document_sorter/quick_sorter.py:1747: f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" +/data/02_DEV/13_document_sorter/quick_sorter.py:1788: ap.add_argument("--profile", default="", help="Policy profile to use even when the test folder has a different name, e.g. 04_ARBEIT or 01_INBOX") +/data/02_DEV/13_document_sorter/quick_sorter.py:1789: ap.add_argument("--target-root", default="", help="Base folder destinations are relative to. For 01_INBOX this defaults to the Inbox parent.") +/data/02_DEV/13_document_sorter/quick_sorter.py:1811: inbox_mode = profile == "01_INBOX" or root.name == "01_INBOX" +/data/02_DEV/13_document_sorter/quick_sorter.py:1814: elif inbox_mode: +/data/02_DEV/13_document_sorter/quick_sorter.py:1825: allow_stay_outside_policy=inbox_mode, +/data/02_DEV/13_document_sorter/quick_sorter.py:1859: launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) +/data/02_DEV/13_document_sorter/README_V2.md:1:# Fast Sorter V2.0 — Inbox Mode +/data/02_DEV/13_document_sorter/README_V2.md:3:V2.0 baut auf V1.5 auf und ergänzt einen echten Top-Level-Inbox-Workflow. +/data/02_DEV/13_document_sorter/README_V2.md:9:├── 01_INBOX/ +/data/02_DEV/13_document_sorter/README_V2.md:17:`01_INBOX` ist damit **kein Unterordner von 03_PRIVAT**, sondern die gemeinsame Eingangsstelle. +/data/02_DEV/13_document_sorter/README_V2.md:21:Wenn der Ordner wirklich `01_INBOX` heißt, erkennt V2 den Modus automatisch: +/data/02_DEV/13_document_sorter/README_V2.md:24:python quick_sorter.py "/pfad/zum/HAUPTORDNER/01_INBOX" +/data/02_DEV/13_document_sorter/README_V2.md:30:python quick_sorter.py "/pfad/zum/HAUPTORDNER/01_INBOX" --profile 01_INBOX +/data/02_DEV/13_document_sorter/README_V2.md:36:python quick_sorter.py "/pfad/zu/INBOX_TEST" \ +/data/02_DEV/13_document_sorter/README_V2.md:37: --profile 01_INBOX \ +/data/02_DEV/13_document_sorter/README_V2.md:45:Eine Datei aus `01_INBOX` kann z. B. nach +/data/02_DEV/13_document_sorter/README_V2.md:53:verschoben werden. Ziele liegen nicht mehr innerhalb der Inbox. +/data/02_DEV/13_document_sorter/README_V2.md:78:### Rescan Inbox +/data/02_DEV/13_document_sorter/README_V2.md:82:1. Dateien in `01_INBOX` entpacken. +/data/02_DEV/13_document_sorter/README_V2.md:83:2. In der App `Rescan Inbox` klicken. +/data/02_DEV/13_document_sorter/README_V2.md:88:### Apply räumt die Inbox wirklich auf +/data/02_DEV/13_document_sorter/README_V2.md:90:Nach `Apply confirmed` werden bestätigte Dateien in ihre Zielbereiche verschoben. Danach scannt die App die Inbox neu. Erfolgreich verschobene Dateien verschwinden aus der Ansicht; übrig bleiben nur noch nicht bearbeitete, Review- oder Archivfälle. +/data/02_DEV/13_document_sorter/README_V2.md:104:## Wichtig vor dem echten Inbox-Durchlauf +/data/02_DEV/13_document_sorter/README_V2.md:106:Die Technik für die Inbox steht. Die Zielstruktur für `04_ARBEIT` ist bereits gut getestet. Die aktuell eingetragene Struktur für `03_PRIVAT` und `05_VAULT` ist noch die bisherige Arbeitsfassung. Diese Ordner sollten vor einem großen echten Inbox-Apply gemeinsam überprüft und anschließend nur in `sorter_policy.json` angepasst werden — dafür muss der Python-Code nicht erneut umgebaut werden. diff --git a/result3.md b/result3.md new file mode 100644 index 0000000..124c579 --- /dev/null +++ b/result3.md @@ -0,0 +1,399 @@ +774- token = f" {needle} " +775- if token in filename_text or token in folder_text or token in source_text or token in excerpt_text: +776- matched_branches.add(branch) +777- break +778- +779- if len(matched_branches) == 1: +780- return next(iter(matched_branches)) +781- return "" +782- +783- +784-def choose_candidates(rec: Record, destinations: list[str], limit: int = 14, +785- aliases: Optional[dict[str, list[str]]] = None, +786: allow_stay_outside_policy: bool = False) -> list[str]: +787- if not destinations: +788- return [rec.current_folder] if rec.current_folder else [""] +789- +790- # User-defined strong aliases route obvious domain shorthand before the LLM. +791- branch = _strong_route_branch(rec, aliases or {}) +792- if branch: +793- routed = [d for d in destinations if d == branch or d.startswith(branch + "/")] +794- if routed: +795- destinations = routed +796- +797- text = f"{rec.filename} {rec.current_folder} {rec.source_context} {rec.excerpt[:400]}" +798- q = tokens(text) +-- +821- for top in top_names: +822- if top in destinations and top not in chosen: +823- chosen.append(top) +824- +825- for _, folder in scored: +826- if folder not in chosen: +827- chosen.append(folder) +828- if len(chosen) >= limit: +829- break +830- +831- # For a real 01_INBOX we must always allow "leave it where it is" when the +832- # model cannot know enough. This can be a nested unpack folder as well. +833: if allow_stay_outside_policy and rec.current_folder and rec.current_folder not in chosen: +834- chosen.append(rec.current_folder) +835- +836: return chosen[:limit + (1 if allow_stay_outside_policy else 0)] +837- +838- +839-def scan(root: Path, target_root: Path, destinations: list[str], +840- aliases: Optional[dict[str, list[str]]] = None, +841: allow_stay_outside_policy: bool = False) -> list[Record]: +842- records = [] +843- for p in sorted(root.rglob("*")): +844- if not p.is_file() or ignored(p, root): +845- continue +846- rel = str(p.relative_to(root)) +847- try: +848: current = str(p.parent.relative_to(target_root)) +849- except ValueError: +850- current = str(p.parent.relative_to(root)) +851- if current == ".": +852- current = "" +853- # Keep provenance separate from destination logic. This matters when an +854- # incoming backup root itself carries useful ownership context (for +855- # example a Cafe-Bach backup) that would otherwise disappear when paths +856- # are made relative to the source root. +857- source_context = root.name +858- relative_parent = str(p.parent.relative_to(root)) +859- if relative_parent != ".": +860- source_context = f"{root.name}/{relative_parent}" +-- +870- current_folder=current, +871- date_guess=d, +872- date_source=source, +873- proposed_name=p.name, +874- destination=current, +875- status="archive" if archive else "pending", +876- content_status="archive" if archive else "not-read", +877- reason=("Archiv: entpacken, Inhalt sortieren, bei Bedarf sauber neu packen" if archive else ""), +878- source_context=source_context, +879- ) +880- rec.candidates = choose_candidates( +881- rec, destinations, aliases=aliases, +882: allow_stay_outside_policy=allow_stay_outside_policy, +883- ) +884- records.append(rec) +885- return records +886- +887- +888-# ---------------- LLM batching ---------------- +889- +890-SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +891-Your job is to save the human time, not create more decisions. +892- +893-Return one JSON ARRAY only, one object per input item, in the same order. +894-Each object must contain exactly: +-- +997- if _result_confidence(result) < 0.85: +998- return True +999- if is_generic_filename(rec.filename): +1000- return True +1001- return False +1002- +1003- +1004-def llm_batches( +1005- records: list[Record], +1006- endpoint: str, +1007- model: str, +1008- batch_size: int = 1, +1009: fallback_model: str = "", +1010-): +1011- """Analyse exactly one file per LLM request. +1012- +1013- batch_size is retained for CLI/backward compatibility but intentionally +1014- ignored: one-file requests prevent cross-file influence and isolate failures. +1015- +1016- When a fallback model is configured, it is used only for a primary error, +1017- an invalid destination, confidence below 0.85, or a generic filename. +1018- If primary and fallback disagree on destination, the primary proposal is +1019- kept but confidence is capped at 0.69 so the GUI forces human review. +1020- """ +1021- del batch_size +1022: fallback_model = fallback_model.strip() +1023- +1024- for rec in records: +1025- batch = [rec] +1026- primary = None +1027- primary_error = None +1028- try: +1029- primary = _call_llm_one(rec, endpoint, model) +1030- except Exception as exc: +1031- primary_error = str(exc) +1032- +1033- use_fallback = ( +1034: bool(fallback_model) +1035: and fallback_model != model +1036- and (primary_error is not None or _needs_fallback(rec, primary)) +1037- ) +1038- +1039- if not use_fallback: +1040- if primary_error is not None: +1041- yield batch, None, primary_error +1042- else: +1043- yield batch, [primary], None +1044- continue +1045- +1046- try: +1047: fallback = _call_llm_one(rec, endpoint, fallback_model) +1048- except Exception as exc: +1049- fallback_error = str(exc) +1050- if primary is not None: +1051- # A usable primary answer is better than throwing it away merely +1052- # because the optional second opinion failed. +1053- yield batch, [primary], None +1054- else: +1055- yield batch, None, f"primary={primary_error}; fallback={fallback_error}" +1056- continue +1057- +1058- if primary is None: +1059- chosen = dict(fallback) +1060: chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() +1061- yield batch, [chosen], None +1062- continue +1063- +1064- primary_dest = str(primary.get("destination", "")).strip() +1065- fallback_dest = str(fallback.get("destination", "")).strip() +1066- +1067- if primary_dest == fallback_dest: +1068- # For the cases deliberately escalated, prefer the second opinion +1069- # when both models agree on the actual destination. +1070- chosen = dict(fallback) +1071: chosen["reason"] = f"{fallback_model} fallback: {chosen.get('reason', '')}".strip() +1072- yield batch, [chosen], None +1073- else: +1074- # Do not silently choose between disagreeing models. Keep the fast +1075- # primary proposal visible, but force explicit human review. +1076- chosen = dict(primary) +1077- chosen["confidence"] = min(_result_confidence(primary), 0.69) +1078- chosen["reason"] = ( +1079- f"Model disagreement: {model}={primary_dest}; " +1080: f"{fallback_model}={fallback_dest}" +1081- ) +1082- yield batch, [chosen], None +1083- +1084- +1085-def extract_json_array(text: str): +1086- text = text.strip() +1087- if text.startswith("```"): +1088- text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) +1089- text = re.sub(r"\s*```$", "", text) +1090- try: +1091- return json.loads(text) +1092- except json.JSONDecodeError: +-- +1191- "enabled": False, +1192- "target_files": 0, +1193- "source_files": 0, +1194- "target_duplicates": 0, +1195- "source_duplicates": 0, +1196- "hash_errors": 0, +1197- "reason": "", +1198- } +1199- +1200- source_root = source_root.resolve() +1201- target_root = target_root.resolve() +1202- +1203: if source_root == target_root or source_root.is_relative_to(target_root): +1204- stats["reason"] = "source is the target or nested inside it; preserving normal V2 behaviour" +1205- return stats +1206- if target_root.is_relative_to(source_root): +1207- stats["reason"] = "target is nested inside source; unsafe layout for global dedupe" +1208- return stats +1209- +1210- stats["enabled"] = True +1211- target_by_hash: dict[str, Path] = {} +1212- +1213- for p in sorted(target_root.rglob("*")): +1214- if p.is_symlink() or not p.is_file() or ignored(p, target_root): +1215- continue +-- +1227- # too: an archive that is already byte-identical in TARGET needs no LLM. +1228- try: +1229- digest = sha256_file(rec.path) +1230- except OSError: +1231- stats["hash_errors"] += 1 +1232- continue +1233- stats["source_files"] += 1 +1234- +1235- target_match = target_by_hash.get(digest) +1236- if target_match is not None: +1237- rec.duplicate_of = str(target_match) +1238- rec.status = "exact-duplicate" +1239: rec.collision = "EXACT → already in target" +1240- try: +1241: display = str(target_match.relative_to(target_root)) +1242- except ValueError: +1243- display = str(target_match) +1244- rec.reason = f"SHA-256 match in target: {display}" +1245- stats["target_duplicates"] += 1 +1246- continue +1247- +1248- first_source = source_seen.get(digest) +1249- if first_source is not None: +1250- rec.duplicate_of = str(first_source) +1251- rec.status = "exact-duplicate" +1252- rec.collision = "EXACT → duplicate incoming" +1253- try: +-- +1447- if out: +1448- write_header = not log_path.exists() +1449- with log_path.open("a", newline="", encoding="utf-8") as f: +1450- writer = csv.DictWriter(f, fieldnames=out[0].keys()) +1451- if write_header: +1452- writer.writeheader() +1453- writer.writerows(out) +1454- return out +1455- +1456- +1457-# ---------------- GUI ---------------- +1458- +1459:def launch_gui(root: Path, target_root: Path, records: list[Record], destinations: list[str], endpoint: str, model: str, batch_size: int, fallback_model: str = "", aliases: Optional[dict[str, list[str]]] = None, inbox_mode: bool = False): +1460- import tkinter as tk +1461- from tkinter import ttk, messagebox +1462- +1463- class App: +1464- def __init__(self, win): +1465- self.win = win +1466- self.records = records +1467- self.destinations = destinations +1468- self.by_iid = {} +1469- self.q = queue.Queue() +1470- self.analysis_running = False +1471- self.active_editor = None +-- +1535- rec.collision = "" +1536- plan(self.records) +1537- for iid in self.by_iid: +1538- self.refresh(iid) +1539- +1540- def rescan(self): +1541- if self.analysis_running: +1542- messagebox.showinfo("Busy", "Analysis is already running.") +1543- return +1544- old = {str(r.path): r for r in self.records} +1545- fresh = scan( +1546- root, target_root, self.destinations, aliases=aliases, +1547: allow_stay_outside_policy=inbox_mode, +1548- ) +1549- merged = [] +1550- new_count = 0 +1551- for rec in fresh: +1552- previous = old.get(str(rec.path)) +1553- if previous is not None: +1554- merged.append(previous) +1555- else: +1556- merged.append(rec) +1557- new_count += 1 +1558- self.records = merged +1559- global_duplicate_prepass(self.records, root, target_root) +-- +1589- ) +1590- else: +1591- messagebox.showinfo("Select files", "Select one or more rows first.") +1592- return +1593- self.analysis_running = True +1594- self.status.config(text=f"Analysing {len(chosen)} file(s), one request per file…") +1595- +1596- def worker(): +1597- for rec in chosen: +1598- if not rec.excerpt: +1599- rec.excerpt, rec.content_status = extract_excerpt(rec.path) +1600- rec.candidates = choose_candidates(rec, self.destinations, aliases=aliases) +1601: for batch, results, error in llm_batches(chosen, endpoint, model, batch_size, fallback_model=fallback_model): +1602- self.q.put((batch, results, error)) +1603- self.q.put((None, None, "__DONE__")) +1604- +1605- threading.Thread(target=worker, daemon=True).start() +1606- self.win.after(100, self.poll) +1607- +1608- def poll(self): +1609- try: +1610- while True: +1611- batch, results, error = self.q.get_nowait() +1612- if error == "__DONE__": +1613- self.analysis_running = False +-- +1729- "No existing file will be overwritten. Exact duplicates are only " +1730- "removed after a full-file SHA-256 match." +1731- ) +1732- if not messagebox.askyesno("Apply resolved batch?", summary): +1733- return +1734- result = apply_confirmed(self.records, root / LOG_FILENAME) +1735- moved = sum(1 for x in result if x["result"] in ("moved", "moved as variant")) +1736- removed = sum(1 for x in result if x["result"] == "removed exact duplicate") +1737- skipped = sum(1 for x in result if x["result"].startswith("SKIPPED")) +1738- if inbox_mode: +1739- self.records = scan( +1740- root, target_root, self.destinations, aliases=aliases, +1741: allow_stay_outside_policy=True, +1742- ) +1743- self._populate() +1744- messagebox.showinfo( +1745- "Done", +1746- f"Moved/renamed: {moved}\nRemoved exact duplicates: {removed}\nSkipped: {skipped}\n" +1747- f"Remaining in Inbox: {len(self.records) if inbox_mode else 'n/a'}\nLog: {root / LOG_FILENAME}" +1748- ) +1749- +1750- def sort(self, column, reverse): +1751- rows = [(self.tree.set(iid, column), iid) for iid in self.tree.get_children("")] +1752- rows.sort(key=lambda x: x[0].lower(), reverse=reverse) +1753- for pos, (_, iid) in enumerate(rows): +-- +1813- target_root = Path(args.target_root).expanduser().resolve() +1814- elif inbox_mode: +1815- target_root = root.parent +1816- else: +1817- target_root = root +1818- if not target_root.is_dir(): +1819- raise SystemExit(f"Not a target directory: {target_root}") +1820- +1821- destinations = allowed_destinations(root, policy, args.profile) +1822- aliases = policy.get("_strong_aliases", {}).get(profile, {}) +1823- records = scan( +1824- root, target_root, destinations, aliases=aliases, +1825: allow_stay_outside_policy=inbox_mode, +1826- ) +1827- dedupe = global_duplicate_prepass(records, root, target_root) +1828- +1829- print(f"Root: {root}") +1830- print(f"Target root: {target_root}") +1831- print(f"Policy profile: {profile}") +1832- print(f"Files: {len(records)}") +1833- print(f"Archives: {sum(r.status == 'archive' for r in records)}") +1834- if dedupe["enabled"]: +1835- print( +1836: "Global SHA-256 pre-pass: " +1837- f"target files={dedupe['target_files']}, source files={dedupe['source_files']}, " +1838: f"already in target={dedupe['target_duplicates']}, " +1839- f"duplicate incoming={dedupe['source_duplicates']}, hash errors={dedupe['hash_errors']}" +1840- ) +1841- else: +1842: print(f"Global SHA-256 pre-pass: disabled ({dedupe['reason']})") +1843- print(f"Allowed destinations: {len(destinations)}") +1844- print(f"Strong route aliases: {sum(len(v) for v in aliases.values())} across {len(aliases)} branches") +1845- print(f"Date from filename: {sum(r.date_source == 'filename' for r in records)}") +1846- print(f"Date from dated parent folder: {sum(r.date_source == 'folder' for r in records)}") +1847- print(f"Date from mtime fallback: {sum(r.date_source == 'mtime' for r in records)}") +1848- +1849: fallback_model = "" if args.no_fallback else args.fallback_model.strip() +1850- print(f"Primary LLM model: {args.model}") +1851: print(f"Fallback LLM model: {fallback_model or 'disabled'}") +1852- +1853- if args.scan_only: +1854- for r in records[:25]: +1855- duplicate = f" -> {r.duplicate_of}" if r.duplicate_of else "" +1856- print(f"{r.status:15} {r.date_guess} [{r.date_source:8}] {r.rel_path}{duplicate}") +1857- return +1858- +1859: launch_gui(root, target_root, records, destinations, args.endpoint, args.model, max(1, args.batch_size), fallback_model=fallback_model, aliases=aliases, inbox_mode=inbox_mode) +1860- +1861- +1862-if __name__ == "__main__": +1863- main() diff --git a/results4.md b/results4.md new file mode 100644 index 0000000..3678899 --- /dev/null +++ b/results4.md @@ -0,0 +1,182 @@ +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]) diff --git a/results5.md b/results5.md new file mode 100644 index 0000000..3ad8173 --- /dev/null +++ b/results5.md @@ -0,0 +1,222 @@ +899:SYSTEM_PROMPT = """You are a conservative file-sorting assistant. +900-Your job is to save the human time, not create more decisions. +901- +902-Return one JSON ARRAY only, one object per input item, in the same order. +903-Each object must contain exactly: +904-id, destination, topic, subtopic, document_type, detail, title, subtitle, version, confidence, reason +905- +906-Rules: +907-- destination MUST be one of that item's candidates. Never invent a folder. +908-- In 01_INBOX mode, choose the stable owner/domain first; use 99_ARCHIV only for clearly historical/reference material, not merely because a file is old. +909-- If uncertain, keep the current folder when it is a candidate, lower confidence, and say why. +910-- Do NOT write the final filename. Extract semantic naming fields; Python will build the filename consistently. +911-- title: concise German document label for the filename, e.g. Anschreiben, Inventur, Rechnung, Getränkeliste, Checkliste. +912-- subtitle: concise distinguishing subject/entity/event/year, e.g. Phönix-Sauna, 2023, CB2428-Aidshilfe-Köln; empty if unnecessary. +913-- title/subtitle MUST NOT contain the date, destination/prefix, extension, or version and MUST NOT repeat each other. +914-- Prefer normal readable German spelling and capitalization in title/subtitle; do not slugify or translate into English. +915-- topic: stable broad context/owner, e.g. cafe-bach, checkpoint, csd, sp5. +916-- subtopic: distinctive event/project/subject, e.g. bach-voices, golden-gays-versammlung, chemsex. +917-- document_type: what the document is, e.g. ablaufplan, protokoll, rechnung, handout. +918-- detail: optional useful distinguishing detail; empty string if unnecessary. +919-- version: explicit version only if supported by filename/content; otherwise empty string. +920-- Use only as many semantic parts as useful; never invent filler. +921-- The filename is NOT the only source of truth. Use the excerpt to recover the actual project, event, organisation, or subject when clearly stated. +922-- source_context is provenance from the incoming source root/folder. Use it as supporting evidence for the primary owner/work area; generic backup labels are not destinations by themselves. +923-- If generic_name=true (for example "Ablaufplan", "Protokoll", "Präsentation", "Rechnung"), DO NOT merely preserve/paraphrase the generic filename. Add the distinctive project/event/subject from the content when it is clear. +924-- For destination, prefer the PRIMARY OWNER/WORK AREA of the document. A mentioned event/topic can be secondary context. Example principle: an inventory/accounting document owned by one work area does not switch branches merely because it mentions a campaign/event. +925-- If several work-area aliases occur, do not assume the longest alias is primary; use filename structure + content semantics to decide and lower confidence if genuinely ambiguous. +926-- Use an informative original filename as evidence for title/subtitle, but do not preserve its whole formatting; Python will normalize the final name. +927-- Use the supplied date_guess; do not invent a different date from vague content. +928-- If date_source is mtime, treat the date as unverified fallback metadata. Do not invent a different date. +929-- Correct obvious spelling/transliteration problems when safe. +930-- confidence is 0.0 to 1.0. Use >=0.85 only for genuinely clear cases. +931-- reason: max 12 words, factual and concise. +932-""" +933- +934- +935-def _model_generation_limits(model: str) -> tuple[int, int]: +936- """Return a generous output-token ceiling and HTTP timeout for one file. +937- +938- Reasoning models spend part of the completion budget before emitting final +939- JSON. These are ceilings, not targets: models can stop much earlier. +940- """ +941- name = model.strip().lower() +942- if "debug" in name: +943- return 6000, 300 +944- if "architect" in name: +945- return 2500, 180 +946- if "daily" in name: +947- return 1000, 120 +948- return 2000, 180 +949- +950- +951-def _result_confidence(result: Optional[dict]) -> float: +952- if not isinstance(result, dict): +953- return 0.0 +954- try: +955- return max(0.0, min(1.0, float(result.get("confidence", 0)))) +956- except (TypeError, ValueError): +957- return 0.0 +958- +959- +960-def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: +961- item = { +962- "id": 0, +963- "filename": rec.filename, +964- "current_folder": rec.current_folder, +965- "source_context": rec.source_context, +966- "date_guess": rec.date_guess, +967- "date_source": rec.date_source, +968- "generic_name": is_generic_filename(rec.filename), +969- "content_status": rec.content_status, +970- "candidates": rec.candidates, +971- "excerpt": rec.excerpt[:MAX_EXCERPT], +972- } +973- prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) +974- max_tokens, timeout = _model_generation_limits(model) +975- payload = { +976- "model": model, +977- "messages": [{"role": "user", "content": prompt}], +978- "temperature": 0.0, +979- "max_tokens": max_tokens, +980- } +981- req = urllib.request.Request( +982- endpoint, +983- data=json.dumps(payload).encode("utf-8"), +984- headers={"Content-Type": "application/json"}, +985- method="POST", +986- ) +987- with urllib.request.urlopen(req, timeout=timeout) as resp: +988- data = json.loads(resp.read().decode("utf-8")) +989- choice = data["choices"][0] +990- text = choice.get("message", {}).get("content") or "" +991- parsed = extract_json_array(text) +992- if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): +993- raise ValueError("model did not return a non-empty JSON array") +994- result = dict(parsed[0]) +995- result["id"] = 0 +996- return result +997- +998- +999-def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: +1000- """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" +1001- if not isinstance(result, dict): +1002- return True +1003- dest = str(result.get("destination", "")).strip() +1004- if dest not in rec.candidates: +1005- return True +1006- if _result_confidence(result) < 0.85: +1007- return True +1008- if is_generic_filename(rec.filename): +1009- return True +1010- return False +1011- +1012- +1013-def llm_batches( +1014- records: list[Record], +1015- endpoint: str, +1016- model: str, +1017- batch_size: int = 1, +1018- fallback_model: str = "", +1019-): +960:def _call_llm_one(rec: Record, endpoint: str, model: str) -> dict: +961- item = { +962- "id": 0, +963- "filename": rec.filename, +964- "current_folder": rec.current_folder, +965- "source_context": rec.source_context, +966- "date_guess": rec.date_guess, +967- "date_source": rec.date_source, +968- "generic_name": is_generic_filename(rec.filename), +969- "content_status": rec.content_status, +970- "candidates": rec.candidates, +971- "excerpt": rec.excerpt[:MAX_EXCERPT], +972- } +973- prompt = SYSTEM_PROMPT + "\nINPUT:\n" + json.dumps([item], ensure_ascii=False) +974- max_tokens, timeout = _model_generation_limits(model) +975- payload = { +976- "model": model, +977- "messages": [{"role": "user", "content": prompt}], +978- "temperature": 0.0, +979- "max_tokens": max_tokens, +980- } +981- req = urllib.request.Request( +982- endpoint, +983- data=json.dumps(payload).encode("utf-8"), +984- headers={"Content-Type": "application/json"}, +985- method="POST", +986- ) +987- with urllib.request.urlopen(req, timeout=timeout) as resp: +988- data = json.loads(resp.read().decode("utf-8")) +989- choice = data["choices"][0] +990- text = choice.get("message", {}).get("content") or "" +991- parsed = extract_json_array(text) +992- if not isinstance(parsed, list) or not parsed or not isinstance(parsed[0], dict): +993- raise ValueError("model did not return a non-empty JSON array") +994- result = dict(parsed[0]) +995- result["id"] = 0 +996- return result +997- +998- +999-def _needs_fallback(rec: Record, result: Optional[dict]) -> bool: +1000- """Escalate only uncertain/fragile cases; do not pay Architect cost always.""" +1001- if not isinstance(result, dict): +1002- return True +1003- dest = str(result.get("destination", "")).strip() +1004- if dest not in rec.candidates: +1005- return True +1006- if _result_confidence(result) < 0.85: +1007- return True +1008- if is_generic_filename(rec.filename): +1009- return True +1010- return False +1011- +1012- +1013-def llm_batches( +1014- records: list[Record], +1015- endpoint: str, +1016- model: str, +1017- batch_size: int = 1, +1018- fallback_model: str = "", +1019-): +1020- """Analyse exactly one file per LLM request. +1021- +1022- batch_size is retained for CLI/backward compatibility but intentionally +1023- ignored: one-file requests prevent cross-file influence and isolate failures. +1024- +1025- When a fallback model is configured, it is used only for a primary error, +1026- an invalid destination, confidence below 0.85, or a generic filename. +1027- If primary and fallback disagree on destination, the primary proposal is +1028- kept but confidence is capped at 0.69 so the GUI forces human review. +1029- """ +1030- del batch_size +1031- fallback_model = fallback_model.strip() +1032- +1033- for rec in records: +1034- batch = [rec] +1035- primary = None +1036- primary_error = None +1037- try: +1038- primary = _call_llm_one(rec, endpoint, model) +1039- except Exception as exc: +1040- primary_error = str(exc) +1041- +1042- use_fallback = ( +1043- bool(fallback_model) +1044- and fallback_model != model +1045- and (primary_error is not None or _needs_fallback(rec, primary)) +1046- ) +1047- +1048- if not use_fallback: +1049- if primary_error is not None: +1050- yield batch, None, primary_error +1051- else: +1052- yield batch, [primary], None +1053- continue +1054- +1055- try: +1056- fallback = _call_llm_one(rec, endpoint, fallback_model) +1057- except Exception as exc: +1058- fallback_error = str(exc) +1059- if primary is not None: +1060- # A usable primary answer is better than throwing it away merely diff --git a/scope_triage.py b/scope_triage.py new file mode 100644 index 0000000..2f51c1e --- /dev/null +++ b/scope_triage.py @@ -0,0 +1,624 @@ +#!/usr/bin/env python3 +""" +Scope triage v2 for the staged document-recovery project. + +Purpose: +- Read-only top-level classification before detailed sorting. +- Exactly three outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX. +- One file per LLM request. +- Curated files under ARCHIVE_EXTRACTED/WORK are deterministically 04_ARBEIT. +- Daily model first for ordinary SOURCE files; Architect verifies uncertain or policy-fragile cases. +- Architect receives a larger reasoning/output budget. +- If Daily and Architect disagree, route conservatively to 01_INBOX. +- Report keeps both model votes and review flags for auditability. +- Resume-safe TSV report written after every completed file. + +This script does NOT move, rename, delete, or modify source/target files. +It only reads files and writes a TSV report under the staging MANIFESTS folder. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import os +import tempfile +import urllib.request +from collections import Counter +from pathlib import Path +from typing import Optional + +import quick_sorter as q + +READABLE = { + ".pdf", ".docx", ".xlsx", ".pptx", + ".odt", ".txt", ".md", ".csv", +} + +MIXED_SOURCE_NAME = "Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden" + +SCOPE_PROMPT = """You are a conservative top-level file-ownership classifier. +Your only job is to decide the PRIMARY ownership/life area of ONE file. + +Return one JSON OBJECT only with exactly these keys: +scope, confidence, reason + +Allowed scope values: +- 04_ARBEIT = primarily an operational/professional record used for the user's employer, + job role, work projects, work teams, professional events, or work administration. +- 03_PRIVAT = primarily the user's own personal/private record as an individual. +- 01_INBOX = ownership/purpose is genuinely unclear, mixed, generic, or unsafe to decide. + +CRITICAL OWNERSHIP RULE: +Do NOT ask merely whether the subject is 'related to work'. Ask who primarily owns/uses +the record and why it exists. A document can mention work but still be a private record. + +Decision rules: +- Source folder is provenance/evidence, NOT authority. +- A file found on a work computer can still be private. +- A file found in a private folder can still be work material. +- Choose 04_ARBEIT for correspondence written/sent in a professional role, employer + projects, Checkpoint/Aidshilfe/Cafe-Bach operations, work-team records, professional + prevention work, work-event planning, employer administration, and work expenses or + reimbursements created for professional activity. +- Friendly or personal wording inside professional correspondence does NOT make it private. +- Choose 03_PRIVAT for the user's own employment contract, payslip, personal bank/account + documents, personal insurance/tax records, personal travel/event tickets, personal daily + planning, and personal health/vaccination records. The user's own employment contract is + PRIVATE documentation even though employment is work-related. +- Generic sample files, blank import templates, font/software licences, generic reference + material, or context-free technical assets are NOT automatically work. If their concrete + use/ownership is unclear, choose 01_INBOX. +- Generic medical/scientific guidance is not automatically a personal health record. If no + personal patient information and no clear work use is present, choose 01_INBOX. +- Product comparisons/offers are 04_ARBEIT only when the excerpt shows a concrete employer + project or professional purchase context; otherwise choose 01_INBOX. +- Do NOT route to DEV, VAULT, ARCHIV, or any other area in this recovery pass. +- If work/private ownership cannot be determined safely, choose 01_INBOX. + +Anchor examples: +- A New-Year greeting to a venue that says the sender works for Checkpoint/Aidshilfe and + discusses prevention materials/collaboration -> 04_ARBEIT. +- The user's own Arbeitsvertrag -> 03_PRIVAT. +- A blank/sample contact-import CSV -> 01_INBOX unless actual work records are present. +- A generic Roboto/font LICENSE -> 01_INBOX unless tied to a concrete work deliverable. + +Confidence calibration: +- 0.90-0.95 only with direct ownership/purpose evidence. +- 0.75-0.89 when likely but some interpretation is required. +- <=0.70 when evidence is thin or mixed; prefer 01_INBOX. +- Never use high confidence merely because of the source folder. +- reason: maximum 14 words, factual and concise. +""" + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def build_queue(stage: Path, policy_path: Path) -> list[dict]: + source = stage / "SOURCE" + target = stage / "TARGET" + extracted = stage / "ARCHIVE_EXTRACTED" / "WORK" + + policy = q.load_policy(policy_path) + destinations = q.allowed_destinations(source, policy, "04_ARBEIT") + aliases = policy.get("_strong_aliases", {}).get("04_ARBEIT", {}) + + records = q.scan(source, target, destinations, aliases=aliases) + q.global_duplicate_prepass(records, source, target) + + items: list[dict] = [] + + for rec in records: + if rec.status != "pending": + continue + if q.is_archive_file(rec.path): + continue + if rec.ext.lower() not in READABLE: + continue + + items.append({ + "key": "SOURCE\t" + rec.rel_path, + "origin": "SOURCE", + "path": rec.path, + "rel_path": rec.rel_path, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + }) + + source_hashes = { + sha256(p) + for p in source.rglob("*") + if p.is_file() + } + target_hashes = { + sha256(p) + for p in target.rglob("*") + if p.is_file() + } + + seen: set[str] = set() + + for p in sorted(extracted.rglob("*")): + if not p.is_file() or p.suffix.lower() not in READABLE: + continue + + digest = sha256(p) + if digest in source_hashes or digest in target_hashes or digest in seen: + continue + seen.add(digest) + + rel = str(p.relative_to(extracted)) + # Preserve meaningful archive provenance at least at recovery-group level. + group = Path(rel).parts[0] if Path(rel).parts else "ARCHIVE" + source_context = f"archive-recovered/{group}/{Path(rel).parent}" + + date_guess, date_source = q.best_date(p, root=extracted) + + items.append({ + "key": "ARCHIVE\t" + rel, + "origin": "ARCHIVE", + "path": p, + "rel_path": rel, + "source_context": source_context, + "date_guess": date_guess, + "date_source": date_source, + }) + + return items + + +def model_limits(model: str) -> tuple[int, int]: + # Reasoning models may spend substantial tokens before emitting their tiny JSON answer. + name = model.lower() + if "gpt-oss" in name or "architect" in name: + return 2500, 240 + if "35b" in name or "debug" in name: + return 6000, 300 + return 500, 120 + + +def extract_json_object(text: str) -> dict: + text = text.strip() + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:].lstrip() + + try: + value = json.loads(text) + except json.JSONDecodeError: + start = text.find("{") + end = text.rfind("}") + if start < 0 or end <= start: + raise + value = json.loads(text[start:end + 1]) + + if not isinstance(value, dict): + raise ValueError("model did not return a JSON object") + return value + + +def call_scope_model(item: dict, endpoint: str, model: str) -> dict: + text, content_status = q.extract_excerpt(item["path"]) + + payload_item = { + "filename": item["path"].name, + "relative_path": item["rel_path"], + "source_context": item["source_context"], + "date_guess": item["date_guess"], + "date_source": item["date_source"], + "content_status": content_status, + "excerpt": text[:q.MAX_EXCERPT], + } + + prompt = ( + SCOPE_PROMPT + + "\nINPUT:\n" + + json.dumps(payload_item, ensure_ascii=False) + ) + + max_tokens, timeout = model_limits(model) + request_payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + + req = urllib.request.Request( + endpoint, + data=json.dumps(request_payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + + choice = data["choices"][0] + answer = choice.get("message", {}).get("content") or "" + result = extract_json_object(answer) + + scope = str(result.get("scope", "")).strip() + try: + confidence = float(result.get("confidence", 0)) + except (TypeError, ValueError): + confidence = 0.0 + + reason = str(result.get("reason", "")).strip()[:160] + + if scope not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: + raise ValueError(f"invalid scope: {scope!r}") + + confidence = max(0.0, min(1.0, confidence)) + + return { + "scope": scope, + "confidence": confidence, + "reason": reason, + "content_status": content_status, + } + + +def scope_review_flags(item: dict, result: Optional[dict]) -> list[str]: + flags: list[str] = [] + if not isinstance(result, dict): + return ["no-primary-result"] + + scope = str(result.get("scope", "")) + filename = item["path"].name.lower() + rel = str(item.get("rel_path", "")).lower() + + try: + excerpt, _ = q.extract_excerpt(item["path"]) + except Exception: + excerpt = "" + content = excerpt.lower() + combined = " ".join((filename, rel, content)) + + # Classes where broad 'work-related' reasoning often confuses ownership. + always_fragile = ( + "anschreiben", "arbeitsvertrag", "ticket", "konto", + "kundeninformation", "sample", "testsample", "license", "lizenz", + "preisvergleich", "impf", "tagesplan", + ) + if any(term in filename for term in always_fragile): + flags.append("ownership-fragile-filename") + + strong_work = ( + "aidshilfe", "checkpoint", "cafe bach", "café bach", "herzenslust", + "prävention", "praevention", "vor-ort", "fak", "dienstplan", + ) + if scope == "03_PRIVAT" and any(term in combined for term in strong_work): + flags.append("private-vs-strong-work-evidence") + + personal_record = ( + "arbeitsvertrag", "konto", "ticket", "versicherung", "steuer", + "gehalts", "lohnabrechnung", "impfpass", + ) + if scope == "04_ARBEIT" and any(term in combined for term in personal_record): + flags.append("work-vs-personal-record-class") + + generic_asset = ("sample", "testsample", "license", "lizenz") + if scope == "04_ARBEIT" and any(term in filename for term in generic_asset): + flags.append("work-vs-generic-asset") + + return flags + + +def needs_fallback(item: dict, result: Optional[dict]) -> tuple[bool, list[str]]: + flags = scope_review_flags(item, result) + if not isinstance(result, dict): + return True, flags + if result.get("scope") not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: + flags.append("invalid-primary-scope") + return True, flags + try: + if float(result.get("confidence", 0)) < 0.85: + flags.append("primary-confidence-below-0.85") + except (TypeError, ValueError): + flags.append("invalid-primary-confidence") + + status = str(result.get("content_status", "")) + if "no-text" in status or status.startswith("error:") or status.startswith("missing:"): + flags.append("limited-content") + + return bool(flags), flags + + +def classify(item: dict, endpoint: str, model: str, fallback_model: str) -> dict: + # ARCHIVE items in this queue come only from ARCHIVE_EXTRACTED/WORK. + # That directory was already curated and verified as work material during + # the archive-recovery phase; calendar/private archive payload is held + # separately under HOLD_CALENDAR and never enters build_queue(). + if item.get("origin") == "ARCHIVE": + return { + "scope": "04_ARBEIT", + "confidence": 0.99, + "reason": "Curated work archive provenance verified before scope triage.", + "content_status": "provenance:curated-work-archive", + "model_path": "deterministic-provenance", + "review_flags": "", + "primary_scope": "", + "primary_confidence": "", + "fallback_scope": "", + "fallback_confidence": "", + } + + primary = None + primary_error = "" + + try: + primary = call_scope_model(item, endpoint, model) + except Exception as exc: + primary_error = f"{type(exc).__name__}: {exc}" + + use_fallback, review_flags = needs_fallback(item, primary) + use_fallback = bool(fallback_model) and use_fallback + + def decorate(result: dict, model_path: str, fallback: Optional[dict] = None) -> dict: + out = dict(result) + out["model_path"] = model_path + out["review_flags"] = ",".join(review_flags) + out["primary_scope"] = primary.get("scope", "") if primary else "" + out["primary_confidence"] = primary.get("confidence", "") if primary else "" + out["fallback_scope"] = fallback.get("scope", "") if fallback else "" + out["fallback_confidence"] = fallback.get("confidence", "") if fallback else "" + return out + + if not use_fallback: + return decorate(primary, "primary") + + fallback = None + fallback_error = "" + try: + fallback = call_scope_model(item, endpoint, fallback_model) + except Exception as exc: + fallback_error = f"{type(exc).__name__}: {exc}" + + if primary is None and fallback is None: + result = { + "scope": "01_INBOX", + "confidence": 0.0, + "reason": f"both models failed: {primary_error}; {fallback_error}"[:160], + "content_status": "model-error", + } + return decorate(result, "failure") + + if primary is None: + return decorate(fallback, "fallback-only", fallback) + + if fallback is None: + result = dict(primary) + result["confidence"] = min(float(result.get("confidence", 0)), 0.69) + result["reason"] = ( + f"fallback failed; {result.get('reason', '')}" + )[:160] + return decorate(result, "primary-fallback-failed") + + if primary["scope"] == fallback["scope"]: + # Agreement is useful, but keep confidence conservative: never higher than the weaker vote. + result = dict(fallback) + result["confidence"] = min( + float(primary.get("confidence", 0)), + float(fallback.get("confidence", 0)), + ) + return decorate(result, "models-agree", fallback) + + result = { + "scope": "01_INBOX", + "confidence": 0.50, + "reason": ( + f"model disagreement: {model}={primary['scope']}; " + f"{fallback_model}={fallback['scope']}" + )[:160], + "content_status": primary.get("content_status", ""), + } + return decorate(result, "models-disagree", fallback) + + +FIELDS = [ + "key", "origin", "relative_path", "source_context", + "filename", "extension", "date_guess", "date_source", + "scope", "confidence", "reason", "content_status", "model_path", + "review_flags", "primary_scope", "primary_confidence", + "fallback_scope", "fallback_confidence", +] + + +def load_existing(report: Path) -> dict[str, dict]: + if not report.is_file(): + return {} + rows = {} + with report.open(newline="", encoding="utf-8") as f: + reader = csv.DictReader(f, delimiter="\t") + for row in reader: + key = row.get("key", "") + if key: + rows[key] = row + return rows + + +def write_report_atomic(report: Path, rows: dict[str, dict], order: list[str]) -> None: + report.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp( + prefix=report.name + ".", + suffix=".tmp", + dir=report.parent, + text=True, + ) + os.close(fd) + temp = Path(temp_name) + try: + with temp.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=FIELDS, delimiter="\t") + writer.writeheader() + for key in order: + if key in rows: + writer.writerow({name: rows[key].get(name, "") for name in FIELDS}) + temp.replace(report) + finally: + if temp.exists(): + temp.unlink() + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument( + "--stage", + default="/data/90_SORT_TEST_ARBEIT_IMPORT/FULL_STAGING_2026-09-14", + ) + ap.add_argument( + "--policy", + default=str(Path(__file__).with_name("sorter_policy.json")), + ) + ap.add_argument("--endpoint", default=q.DEFAULT_ENDPOINT) + ap.add_argument("--model", default=q.DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=q.DEFAULT_FALLBACK_MODEL) + ap.add_argument("--no-fallback", action="store_true") + ap.add_argument( + "--report", + default="", + help="TSV report path; defaults under STAGE/MANIFESTS", + ) + ap.add_argument( + "--pilot-mixed", + action="store_true", + help="Only the original readable files from the known mixed backup", + ) + ap.add_argument("--limit", type=int, default=0) + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + stage = Path(args.stage).expanduser().resolve() + policy_path = Path(args.policy).expanduser().resolve() + + if not stage.is_dir(): + raise SystemExit(f"Not a staging directory: {stage}") + if not policy_path.is_file(): + raise SystemExit(f"Policy not found: {policy_path}") + + items = build_queue(stage, policy_path) + + if args.pilot_mixed: + prefix = MIXED_SOURCE_NAME + "/" + items = [ + item for item in items + if item["origin"] == "SOURCE" + and item["rel_path"].startswith(prefix) + ] + + if args.limit > 0: + items = items[:args.limit] + + if args.report: + report = Path(args.report).expanduser().resolve() + else: + name = ( + "scope_triage_pilot_mixed_v3.tsv" + if args.pilot_mixed + else "scope_triage_readable_v3.tsv" + ) + report = stage / "MANIFESTS" / name + + print("=== SCOPE TRIAGE ===") + print("Queue:", len(items)) + print("Mode:", "pilot-mixed" if args.pilot_mixed else "full-readable") + print("Allowed outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX") + print("Primary model:", args.model) + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print("Fallback model:", fallback_model or "disabled") + print("Report:", report) + + if args.dry_run: + print("DRY RUN: no LLM requests made") + for item in items[:20]: + print(item["origin"], item["rel_path"]) + return + + existing = load_existing(report) + order = [item["key"] for item in items] + rows = {k: v for k, v in existing.items() if k in set(order)} + + completed = len(rows) + if completed: + print(f"Resume: {completed} already completed") + + for index, item in enumerate(items, 1): + key = item["key"] + if key in rows: + continue + + result = classify( + item, + args.endpoint, + args.model, + fallback_model, + ) + + row = { + "key": key, + "origin": item["origin"], + "relative_path": item["rel_path"], + "source_context": item["source_context"], + "filename": item["path"].name, + "extension": item["path"].suffix.lower(), + "date_guess": item["date_guess"], + "date_source": item["date_source"], + "scope": result["scope"], + "confidence": f"{float(result['confidence']):.2f}", + "reason": result["reason"], + "content_status": result["content_status"], + "model_path": result["model_path"], + "review_flags": result.get("review_flags", ""), + "primary_scope": result.get("primary_scope", ""), + "primary_confidence": ( + f"{float(result['primary_confidence']):.2f}" + if result.get("primary_confidence", "") != "" else "" + ), + "fallback_scope": result.get("fallback_scope", ""), + "fallback_confidence": ( + f"{float(result['fallback_confidence']):.2f}" + if result.get("fallback_confidence", "") != "" else "" + ), + } + + rows[key] = row + write_report_atomic(report, rows, order) + + print( + f"[{index:3}/{len(items)}] " + f"{result['scope']:9} " + f"{float(result['confidence']):.2f} " + f"{item['path'].name}" + ) + + counts = Counter(row["scope"] for row in rows.values()) + low = sum(float(row["confidence"]) < 0.70 for row in rows.values()) + disagreements = sum( + row.get("model_path") == "models-disagree" + for row in rows.values() + ) + + print() + print("=== RESULT ===") + print("Completed:", len(rows)) + print("04_ARBEIT:", counts["04_ARBEIT"]) + print("03_PRIVAT:", counts["03_PRIVAT"]) + print("01_INBOX:", counts["01_INBOX"]) + print("Confidence < 0.70:", low) + print("Model disagreements:", disagreements) + print("Report:", report) + + +if __name__ == "__main__": + main() diff --git a/scope_triage_v1_working.py b/scope_triage_v1_working.py new file mode 100644 index 0000000..920db7d --- /dev/null +++ b/scope_triage_v1_working.py @@ -0,0 +1,605 @@ +#!/usr/bin/env python3 +""" +Scope triage v2 for the staged document-recovery project. + +Purpose: +- Read-only top-level classification before detailed sorting. +- Exactly three outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX. +- One file per LLM request. +- Daily model first; Architect verifies uncertain or policy-fragile cases. +- Architect receives a larger reasoning/output budget. +- If Daily and Architect disagree, route conservatively to 01_INBOX. +- Report keeps both model votes and review flags for auditability. +- Resume-safe TSV report written after every completed file. + +This script does NOT move, rename, delete, or modify source/target files. +It only reads files and writes a TSV report under the staging MANIFESTS folder. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import os +import tempfile +import urllib.request +from collections import Counter +from pathlib import Path +from typing import Optional + +import quick_sorter as q + +READABLE = { + ".pdf", ".docx", ".xlsx", ".pptx", + ".odt", ".txt", ".md", ".csv", +} + +MIXED_SOURCE_NAME = "Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden" + +SCOPE_PROMPT = """You are a conservative top-level file-ownership classifier. +Your only job is to decide the PRIMARY ownership/life area of ONE file. + +Return one JSON OBJECT only with exactly these keys: +scope, confidence, reason + +Allowed scope values: +- 04_ARBEIT = primarily an operational/professional record used for the user's employer, + job role, work projects, work teams, professional events, or work administration. +- 03_PRIVAT = primarily the user's own personal/private record as an individual. +- 01_INBOX = ownership/purpose is genuinely unclear, mixed, generic, or unsafe to decide. + +CRITICAL OWNERSHIP RULE: +Do NOT ask merely whether the subject is 'related to work'. Ask who primarily owns/uses +the record and why it exists. A document can mention work but still be a private record. + +Decision rules: +- Source folder is provenance/evidence, NOT authority. +- A file found on a work computer can still be private. +- A file found in a private folder can still be work material. +- Choose 04_ARBEIT for correspondence written/sent in a professional role, employer + projects, Checkpoint/Aidshilfe/Cafe-Bach operations, work-team records, professional + prevention work, work-event planning, employer administration, and work expenses or + reimbursements created for professional activity. +- Friendly or personal wording inside professional correspondence does NOT make it private. +- Choose 03_PRIVAT for the user's own employment contract, payslip, personal bank/account + documents, personal insurance/tax records, personal travel/event tickets, personal daily + planning, and personal health/vaccination records. The user's own employment contract is + PRIVATE documentation even though employment is work-related. +- Generic sample files, blank import templates, font/software licences, generic reference + material, or context-free technical assets are NOT automatically work. If their concrete + use/ownership is unclear, choose 01_INBOX. +- Generic medical/scientific guidance is not automatically a personal health record. If no + personal patient information and no clear work use is present, choose 01_INBOX. +- Product comparisons/offers are 04_ARBEIT only when the excerpt shows a concrete employer + project or professional purchase context; otherwise choose 01_INBOX. +- Do NOT route to DEV, VAULT, ARCHIV, or any other area in this recovery pass. +- If work/private ownership cannot be determined safely, choose 01_INBOX. + +Anchor examples: +- A New-Year greeting to a venue that says the sender works for Checkpoint/Aidshilfe and + discusses prevention materials/collaboration -> 04_ARBEIT. +- The user's own Arbeitsvertrag -> 03_PRIVAT. +- A blank/sample contact-import CSV -> 01_INBOX unless actual work records are present. +- A generic Roboto/font LICENSE -> 01_INBOX unless tied to a concrete work deliverable. + +Confidence calibration: +- 0.90-0.95 only with direct ownership/purpose evidence. +- 0.75-0.89 when likely but some interpretation is required. +- <=0.70 when evidence is thin or mixed; prefer 01_INBOX. +- Never use high confidence merely because of the source folder. +- reason: maximum 14 words, factual and concise. +""" + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def build_queue(stage: Path, policy_path: Path) -> list[dict]: + source = stage / "SOURCE" + target = stage / "TARGET" + extracted = stage / "ARCHIVE_EXTRACTED" / "WORK" + + policy = q.load_policy(policy_path) + destinations = q.allowed_destinations(source, policy, "04_ARBEIT") + aliases = policy.get("_strong_aliases", {}).get("04_ARBEIT", {}) + + records = q.scan(source, target, destinations, aliases=aliases) + q.global_duplicate_prepass(records, source, target) + + items: list[dict] = [] + + for rec in records: + if rec.status != "pending": + continue + if q.is_archive_file(rec.path): + continue + if rec.ext.lower() not in READABLE: + continue + + items.append({ + "key": "SOURCE\t" + rec.rel_path, + "origin": "SOURCE", + "path": rec.path, + "rel_path": rec.rel_path, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + }) + + source_hashes = { + sha256(p) + for p in source.rglob("*") + if p.is_file() + } + target_hashes = { + sha256(p) + for p in target.rglob("*") + if p.is_file() + } + + seen: set[str] = set() + + for p in sorted(extracted.rglob("*")): + if not p.is_file() or p.suffix.lower() not in READABLE: + continue + + digest = sha256(p) + if digest in source_hashes or digest in target_hashes or digest in seen: + continue + seen.add(digest) + + rel = str(p.relative_to(extracted)) + # Preserve meaningful archive provenance at least at recovery-group level. + group = Path(rel).parts[0] if Path(rel).parts else "ARCHIVE" + source_context = f"archive-recovered/{group}/{Path(rel).parent}" + + date_guess, date_source = q.best_date(p, root=extracted) + + items.append({ + "key": "ARCHIVE\t" + rel, + "origin": "ARCHIVE", + "path": p, + "rel_path": rel, + "source_context": source_context, + "date_guess": date_guess, + "date_source": date_source, + }) + + return items + + +def model_limits(model: str) -> tuple[int, int]: + # Reasoning models may spend substantial tokens before emitting their tiny JSON answer. + name = model.lower() + if "gpt-oss" in name or "architect" in name: + return 2500, 240 + if "35b" in name or "debug" in name: + return 6000, 300 + return 500, 120 + + +def extract_json_object(text: str) -> dict: + text = text.strip() + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:].lstrip() + + try: + value = json.loads(text) + except json.JSONDecodeError: + start = text.find("{") + end = text.rfind("}") + if start < 0 or end <= start: + raise + value = json.loads(text[start:end + 1]) + + if not isinstance(value, dict): + raise ValueError("model did not return a JSON object") + return value + + +def call_scope_model(item: dict, endpoint: str, model: str) -> dict: + text, content_status = q.extract_excerpt(item["path"]) + + payload_item = { + "filename": item["path"].name, + "relative_path": item["rel_path"], + "source_context": item["source_context"], + "date_guess": item["date_guess"], + "date_source": item["date_source"], + "content_status": content_status, + "excerpt": text[:q.MAX_EXCERPT], + } + + prompt = ( + SCOPE_PROMPT + + "\nINPUT:\n" + + json.dumps(payload_item, ensure_ascii=False) + ) + + max_tokens, timeout = model_limits(model) + request_payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + + req = urllib.request.Request( + endpoint, + data=json.dumps(request_payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + + choice = data["choices"][0] + answer = choice.get("message", {}).get("content") or "" + result = extract_json_object(answer) + + scope = str(result.get("scope", "")).strip() + try: + confidence = float(result.get("confidence", 0)) + except (TypeError, ValueError): + confidence = 0.0 + + reason = str(result.get("reason", "")).strip()[:160] + + if scope not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: + raise ValueError(f"invalid scope: {scope!r}") + + confidence = max(0.0, min(1.0, confidence)) + + return { + "scope": scope, + "confidence": confidence, + "reason": reason, + "content_status": content_status, + } + + +def scope_review_flags(item: dict, result: Optional[dict]) -> list[str]: + flags: list[str] = [] + if not isinstance(result, dict): + return ["no-primary-result"] + + scope = str(result.get("scope", "")) + filename = item["path"].name.lower() + rel = str(item.get("rel_path", "")).lower() + + try: + excerpt, _ = q.extract_excerpt(item["path"]) + except Exception: + excerpt = "" + content = excerpt.lower() + combined = " ".join((filename, rel, content)) + + # Classes where broad 'work-related' reasoning often confuses ownership. + always_fragile = ( + "anschreiben", "arbeitsvertrag", "ticket", "konto", + "kundeninformation", "sample", "testsample", "license", "lizenz", + "preisvergleich", "impf", "tagesplan", + ) + if any(term in filename for term in always_fragile): + flags.append("ownership-fragile-filename") + + strong_work = ( + "aidshilfe", "checkpoint", "cafe bach", "café bach", "herzenslust", + "prävention", "praevention", "vor-ort", "fak", "dienstplan", + ) + if scope == "03_PRIVAT" and any(term in combined for term in strong_work): + flags.append("private-vs-strong-work-evidence") + + personal_record = ( + "arbeitsvertrag", "konto", "ticket", "versicherung", "steuer", + "gehalts", "lohnabrechnung", "impfpass", + ) + if scope == "04_ARBEIT" and any(term in combined for term in personal_record): + flags.append("work-vs-personal-record-class") + + generic_asset = ("sample", "testsample", "license", "lizenz") + if scope == "04_ARBEIT" and any(term in filename for term in generic_asset): + flags.append("work-vs-generic-asset") + + return flags + + +def needs_fallback(item: dict, result: Optional[dict]) -> tuple[bool, list[str]]: + flags = scope_review_flags(item, result) + if not isinstance(result, dict): + return True, flags + if result.get("scope") not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: + flags.append("invalid-primary-scope") + return True, flags + try: + if float(result.get("confidence", 0)) < 0.85: + flags.append("primary-confidence-below-0.85") + except (TypeError, ValueError): + flags.append("invalid-primary-confidence") + + status = str(result.get("content_status", "")) + if "no-text" in status or status.startswith("error:") or status.startswith("missing:"): + flags.append("limited-content") + + return bool(flags), flags + + +def classify(item: dict, endpoint: str, model: str, fallback_model: str) -> dict: + primary = None + primary_error = "" + + try: + primary = call_scope_model(item, endpoint, model) + except Exception as exc: + primary_error = f"{type(exc).__name__}: {exc}" + + use_fallback, review_flags = needs_fallback(item, primary) + use_fallback = bool(fallback_model) and use_fallback + + def decorate(result: dict, model_path: str, fallback: Optional[dict] = None) -> dict: + out = dict(result) + out["model_path"] = model_path + out["review_flags"] = ",".join(review_flags) + out["primary_scope"] = primary.get("scope", "") if primary else "" + out["primary_confidence"] = primary.get("confidence", "") if primary else "" + out["fallback_scope"] = fallback.get("scope", "") if fallback else "" + out["fallback_confidence"] = fallback.get("confidence", "") if fallback else "" + return out + + if not use_fallback: + return decorate(primary, "primary") + + fallback = None + fallback_error = "" + try: + fallback = call_scope_model(item, endpoint, fallback_model) + except Exception as exc: + fallback_error = f"{type(exc).__name__}: {exc}" + + if primary is None and fallback is None: + result = { + "scope": "01_INBOX", + "confidence": 0.0, + "reason": f"both models failed: {primary_error}; {fallback_error}"[:160], + "content_status": "model-error", + } + return decorate(result, "failure") + + if primary is None: + return decorate(fallback, "fallback-only", fallback) + + if fallback is None: + result = dict(primary) + result["confidence"] = min(float(result.get("confidence", 0)), 0.69) + result["reason"] = ( + f"fallback failed; {result.get('reason', '')}" + )[:160] + return decorate(result, "primary-fallback-failed") + + if primary["scope"] == fallback["scope"]: + # Agreement is useful, but keep confidence conservative: never higher than the weaker vote. + result = dict(fallback) + result["confidence"] = min( + float(primary.get("confidence", 0)), + float(fallback.get("confidence", 0)), + ) + return decorate(result, "models-agree", fallback) + + result = { + "scope": "01_INBOX", + "confidence": 0.50, + "reason": ( + f"model disagreement: {model}={primary['scope']}; " + f"{fallback_model}={fallback['scope']}" + )[:160], + "content_status": primary.get("content_status", ""), + } + return decorate(result, "models-disagree", fallback) + + +FIELDS = [ + "key", "origin", "relative_path", "source_context", + "filename", "extension", "date_guess", "date_source", + "scope", "confidence", "reason", "content_status", "model_path", + "review_flags", "primary_scope", "primary_confidence", + "fallback_scope", "fallback_confidence", +] + + +def load_existing(report: Path) -> dict[str, dict]: + if not report.is_file(): + return {} + rows = {} + with report.open(newline="", encoding="utf-8") as f: + reader = csv.DictReader(f, delimiter="\t") + for row in reader: + key = row.get("key", "") + if key: + rows[key] = row + return rows + + +def write_report_atomic(report: Path, rows: dict[str, dict], order: list[str]) -> None: + report.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp( + prefix=report.name + ".", + suffix=".tmp", + dir=report.parent, + text=True, + ) + os.close(fd) + temp = Path(temp_name) + try: + with temp.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=FIELDS, delimiter="\t") + writer.writeheader() + for key in order: + if key in rows: + writer.writerow({name: rows[key].get(name, "") for name in FIELDS}) + temp.replace(report) + finally: + if temp.exists(): + temp.unlink() + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument( + "--stage", + default="/data/90_SORT_TEST_ARBEIT_IMPORT/FULL_STAGING_2026-09-14", + ) + ap.add_argument( + "--policy", + default=str(Path(__file__).with_name("sorter_policy.json")), + ) + ap.add_argument("--endpoint", default=q.DEFAULT_ENDPOINT) + ap.add_argument("--model", default=q.DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=q.DEFAULT_FALLBACK_MODEL) + ap.add_argument("--no-fallback", action="store_true") + ap.add_argument( + "--report", + default="", + help="TSV report path; defaults under STAGE/MANIFESTS", + ) + ap.add_argument( + "--pilot-mixed", + action="store_true", + help="Only the original readable files from the known mixed backup", + ) + ap.add_argument("--limit", type=int, default=0) + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + stage = Path(args.stage).expanduser().resolve() + policy_path = Path(args.policy).expanduser().resolve() + + if not stage.is_dir(): + raise SystemExit(f"Not a staging directory: {stage}") + if not policy_path.is_file(): + raise SystemExit(f"Policy not found: {policy_path}") + + items = build_queue(stage, policy_path) + + if args.pilot_mixed: + prefix = MIXED_SOURCE_NAME + "/" + items = [ + item for item in items + if item["origin"] == "SOURCE" + and item["rel_path"].startswith(prefix) + ] + + if args.limit > 0: + items = items[:args.limit] + + if args.report: + report = Path(args.report).expanduser().resolve() + else: + name = ( + "scope_triage_pilot_mixed_v2.tsv" + if args.pilot_mixed + else "scope_triage_readable_v2.tsv" + ) + report = stage / "MANIFESTS" / name + + print("=== SCOPE TRIAGE ===") + print("Queue:", len(items)) + print("Mode:", "pilot-mixed" if args.pilot_mixed else "full-readable") + print("Allowed outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX") + print("Primary model:", args.model) + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print("Fallback model:", fallback_model or "disabled") + print("Report:", report) + + if args.dry_run: + print("DRY RUN: no LLM requests made") + for item in items[:20]: + print(item["origin"], item["rel_path"]) + return + + existing = load_existing(report) + order = [item["key"] for item in items] + rows = {k: v for k, v in existing.items() if k in set(order)} + + completed = len(rows) + if completed: + print(f"Resume: {completed} already completed") + + for index, item in enumerate(items, 1): + key = item["key"] + if key in rows: + continue + + result = classify( + item, + args.endpoint, + args.model, + fallback_model, + ) + + row = { + "key": key, + "origin": item["origin"], + "relative_path": item["rel_path"], + "source_context": item["source_context"], + "filename": item["path"].name, + "extension": item["path"].suffix.lower(), + "date_guess": item["date_guess"], + "date_source": item["date_source"], + "scope": result["scope"], + "confidence": f"{float(result['confidence']):.2f}", + "reason": result["reason"], + "content_status": result["content_status"], + "model_path": result["model_path"], + "review_flags": result.get("review_flags", ""), + "primary_scope": result.get("primary_scope", ""), + "primary_confidence": ( + f"{float(result['primary_confidence']):.2f}" + if result.get("primary_confidence", "") != "" else "" + ), + "fallback_scope": result.get("fallback_scope", ""), + "fallback_confidence": ( + f"{float(result['fallback_confidence']):.2f}" + if result.get("fallback_confidence", "") != "" else "" + ), + } + + rows[key] = row + write_report_atomic(report, rows, order) + + print( + f"[{index:3}/{len(items)}] " + f"{result['scope']:9} " + f"{float(result['confidence']):.2f} " + f"{item['path'].name}" + ) + + counts = Counter(row["scope"] for row in rows.values()) + low = sum(float(row["confidence"]) < 0.70 for row in rows.values()) + disagreements = sum( + row.get("model_path") == "models-disagree" + for row in rows.values() + ) + + print() + print("=== RESULT ===") + print("Completed:", len(rows)) + print("04_ARBEIT:", counts["04_ARBEIT"]) + print("03_PRIVAT:", counts["03_PRIVAT"]) + print("01_INBOX:", counts["01_INBOX"]) + print("Confidence < 0.70:", low) + print("Model disagreements:", disagreements) + print("Report:", report) + + +if __name__ == "__main__": + main() diff --git a/scope_triage_v2_working.py b/scope_triage_v2_working.py new file mode 100644 index 0000000..920db7d --- /dev/null +++ b/scope_triage_v2_working.py @@ -0,0 +1,605 @@ +#!/usr/bin/env python3 +""" +Scope triage v2 for the staged document-recovery project. + +Purpose: +- Read-only top-level classification before detailed sorting. +- Exactly three outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX. +- One file per LLM request. +- Daily model first; Architect verifies uncertain or policy-fragile cases. +- Architect receives a larger reasoning/output budget. +- If Daily and Architect disagree, route conservatively to 01_INBOX. +- Report keeps both model votes and review flags for auditability. +- Resume-safe TSV report written after every completed file. + +This script does NOT move, rename, delete, or modify source/target files. +It only reads files and writes a TSV report under the staging MANIFESTS folder. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import os +import tempfile +import urllib.request +from collections import Counter +from pathlib import Path +from typing import Optional + +import quick_sorter as q + +READABLE = { + ".pdf", ".docx", ".xlsx", ".pptx", + ".odt", ".txt", ".md", ".csv", +} + +MIXED_SOURCE_NAME = "Arbeits_Rechner_BackUP_Dokumente_Duplikate_vorhanden" + +SCOPE_PROMPT = """You are a conservative top-level file-ownership classifier. +Your only job is to decide the PRIMARY ownership/life area of ONE file. + +Return one JSON OBJECT only with exactly these keys: +scope, confidence, reason + +Allowed scope values: +- 04_ARBEIT = primarily an operational/professional record used for the user's employer, + job role, work projects, work teams, professional events, or work administration. +- 03_PRIVAT = primarily the user's own personal/private record as an individual. +- 01_INBOX = ownership/purpose is genuinely unclear, mixed, generic, or unsafe to decide. + +CRITICAL OWNERSHIP RULE: +Do NOT ask merely whether the subject is 'related to work'. Ask who primarily owns/uses +the record and why it exists. A document can mention work but still be a private record. + +Decision rules: +- Source folder is provenance/evidence, NOT authority. +- A file found on a work computer can still be private. +- A file found in a private folder can still be work material. +- Choose 04_ARBEIT for correspondence written/sent in a professional role, employer + projects, Checkpoint/Aidshilfe/Cafe-Bach operations, work-team records, professional + prevention work, work-event planning, employer administration, and work expenses or + reimbursements created for professional activity. +- Friendly or personal wording inside professional correspondence does NOT make it private. +- Choose 03_PRIVAT for the user's own employment contract, payslip, personal bank/account + documents, personal insurance/tax records, personal travel/event tickets, personal daily + planning, and personal health/vaccination records. The user's own employment contract is + PRIVATE documentation even though employment is work-related. +- Generic sample files, blank import templates, font/software licences, generic reference + material, or context-free technical assets are NOT automatically work. If their concrete + use/ownership is unclear, choose 01_INBOX. +- Generic medical/scientific guidance is not automatically a personal health record. If no + personal patient information and no clear work use is present, choose 01_INBOX. +- Product comparisons/offers are 04_ARBEIT only when the excerpt shows a concrete employer + project or professional purchase context; otherwise choose 01_INBOX. +- Do NOT route to DEV, VAULT, ARCHIV, or any other area in this recovery pass. +- If work/private ownership cannot be determined safely, choose 01_INBOX. + +Anchor examples: +- A New-Year greeting to a venue that says the sender works for Checkpoint/Aidshilfe and + discusses prevention materials/collaboration -> 04_ARBEIT. +- The user's own Arbeitsvertrag -> 03_PRIVAT. +- A blank/sample contact-import CSV -> 01_INBOX unless actual work records are present. +- A generic Roboto/font LICENSE -> 01_INBOX unless tied to a concrete work deliverable. + +Confidence calibration: +- 0.90-0.95 only with direct ownership/purpose evidence. +- 0.75-0.89 when likely but some interpretation is required. +- <=0.70 when evidence is thin or mixed; prefer 01_INBOX. +- Never use high confidence merely because of the source folder. +- reason: maximum 14 words, factual and concise. +""" + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def build_queue(stage: Path, policy_path: Path) -> list[dict]: + source = stage / "SOURCE" + target = stage / "TARGET" + extracted = stage / "ARCHIVE_EXTRACTED" / "WORK" + + policy = q.load_policy(policy_path) + destinations = q.allowed_destinations(source, policy, "04_ARBEIT") + aliases = policy.get("_strong_aliases", {}).get("04_ARBEIT", {}) + + records = q.scan(source, target, destinations, aliases=aliases) + q.global_duplicate_prepass(records, source, target) + + items: list[dict] = [] + + for rec in records: + if rec.status != "pending": + continue + if q.is_archive_file(rec.path): + continue + if rec.ext.lower() not in READABLE: + continue + + items.append({ + "key": "SOURCE\t" + rec.rel_path, + "origin": "SOURCE", + "path": rec.path, + "rel_path": rec.rel_path, + "source_context": rec.source_context, + "date_guess": rec.date_guess, + "date_source": rec.date_source, + }) + + source_hashes = { + sha256(p) + for p in source.rglob("*") + if p.is_file() + } + target_hashes = { + sha256(p) + for p in target.rglob("*") + if p.is_file() + } + + seen: set[str] = set() + + for p in sorted(extracted.rglob("*")): + if not p.is_file() or p.suffix.lower() not in READABLE: + continue + + digest = sha256(p) + if digest in source_hashes or digest in target_hashes or digest in seen: + continue + seen.add(digest) + + rel = str(p.relative_to(extracted)) + # Preserve meaningful archive provenance at least at recovery-group level. + group = Path(rel).parts[0] if Path(rel).parts else "ARCHIVE" + source_context = f"archive-recovered/{group}/{Path(rel).parent}" + + date_guess, date_source = q.best_date(p, root=extracted) + + items.append({ + "key": "ARCHIVE\t" + rel, + "origin": "ARCHIVE", + "path": p, + "rel_path": rel, + "source_context": source_context, + "date_guess": date_guess, + "date_source": date_source, + }) + + return items + + +def model_limits(model: str) -> tuple[int, int]: + # Reasoning models may spend substantial tokens before emitting their tiny JSON answer. + name = model.lower() + if "gpt-oss" in name or "architect" in name: + return 2500, 240 + if "35b" in name or "debug" in name: + return 6000, 300 + return 500, 120 + + +def extract_json_object(text: str) -> dict: + text = text.strip() + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:].lstrip() + + try: + value = json.loads(text) + except json.JSONDecodeError: + start = text.find("{") + end = text.rfind("}") + if start < 0 or end <= start: + raise + value = json.loads(text[start:end + 1]) + + if not isinstance(value, dict): + raise ValueError("model did not return a JSON object") + return value + + +def call_scope_model(item: dict, endpoint: str, model: str) -> dict: + text, content_status = q.extract_excerpt(item["path"]) + + payload_item = { + "filename": item["path"].name, + "relative_path": item["rel_path"], + "source_context": item["source_context"], + "date_guess": item["date_guess"], + "date_source": item["date_source"], + "content_status": content_status, + "excerpt": text[:q.MAX_EXCERPT], + } + + prompt = ( + SCOPE_PROMPT + + "\nINPUT:\n" + + json.dumps(payload_item, ensure_ascii=False) + ) + + max_tokens, timeout = model_limits(model) + request_payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": max_tokens, + } + + req = urllib.request.Request( + endpoint, + data=json.dumps(request_payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + + choice = data["choices"][0] + answer = choice.get("message", {}).get("content") or "" + result = extract_json_object(answer) + + scope = str(result.get("scope", "")).strip() + try: + confidence = float(result.get("confidence", 0)) + except (TypeError, ValueError): + confidence = 0.0 + + reason = str(result.get("reason", "")).strip()[:160] + + if scope not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: + raise ValueError(f"invalid scope: {scope!r}") + + confidence = max(0.0, min(1.0, confidence)) + + return { + "scope": scope, + "confidence": confidence, + "reason": reason, + "content_status": content_status, + } + + +def scope_review_flags(item: dict, result: Optional[dict]) -> list[str]: + flags: list[str] = [] + if not isinstance(result, dict): + return ["no-primary-result"] + + scope = str(result.get("scope", "")) + filename = item["path"].name.lower() + rel = str(item.get("rel_path", "")).lower() + + try: + excerpt, _ = q.extract_excerpt(item["path"]) + except Exception: + excerpt = "" + content = excerpt.lower() + combined = " ".join((filename, rel, content)) + + # Classes where broad 'work-related' reasoning often confuses ownership. + always_fragile = ( + "anschreiben", "arbeitsvertrag", "ticket", "konto", + "kundeninformation", "sample", "testsample", "license", "lizenz", + "preisvergleich", "impf", "tagesplan", + ) + if any(term in filename for term in always_fragile): + flags.append("ownership-fragile-filename") + + strong_work = ( + "aidshilfe", "checkpoint", "cafe bach", "café bach", "herzenslust", + "prävention", "praevention", "vor-ort", "fak", "dienstplan", + ) + if scope == "03_PRIVAT" and any(term in combined for term in strong_work): + flags.append("private-vs-strong-work-evidence") + + personal_record = ( + "arbeitsvertrag", "konto", "ticket", "versicherung", "steuer", + "gehalts", "lohnabrechnung", "impfpass", + ) + if scope == "04_ARBEIT" and any(term in combined for term in personal_record): + flags.append("work-vs-personal-record-class") + + generic_asset = ("sample", "testsample", "license", "lizenz") + if scope == "04_ARBEIT" and any(term in filename for term in generic_asset): + flags.append("work-vs-generic-asset") + + return flags + + +def needs_fallback(item: dict, result: Optional[dict]) -> tuple[bool, list[str]]: + flags = scope_review_flags(item, result) + if not isinstance(result, dict): + return True, flags + if result.get("scope") not in {"04_ARBEIT", "03_PRIVAT", "01_INBOX"}: + flags.append("invalid-primary-scope") + return True, flags + try: + if float(result.get("confidence", 0)) < 0.85: + flags.append("primary-confidence-below-0.85") + except (TypeError, ValueError): + flags.append("invalid-primary-confidence") + + status = str(result.get("content_status", "")) + if "no-text" in status or status.startswith("error:") or status.startswith("missing:"): + flags.append("limited-content") + + return bool(flags), flags + + +def classify(item: dict, endpoint: str, model: str, fallback_model: str) -> dict: + primary = None + primary_error = "" + + try: + primary = call_scope_model(item, endpoint, model) + except Exception as exc: + primary_error = f"{type(exc).__name__}: {exc}" + + use_fallback, review_flags = needs_fallback(item, primary) + use_fallback = bool(fallback_model) and use_fallback + + def decorate(result: dict, model_path: str, fallback: Optional[dict] = None) -> dict: + out = dict(result) + out["model_path"] = model_path + out["review_flags"] = ",".join(review_flags) + out["primary_scope"] = primary.get("scope", "") if primary else "" + out["primary_confidence"] = primary.get("confidence", "") if primary else "" + out["fallback_scope"] = fallback.get("scope", "") if fallback else "" + out["fallback_confidence"] = fallback.get("confidence", "") if fallback else "" + return out + + if not use_fallback: + return decorate(primary, "primary") + + fallback = None + fallback_error = "" + try: + fallback = call_scope_model(item, endpoint, fallback_model) + except Exception as exc: + fallback_error = f"{type(exc).__name__}: {exc}" + + if primary is None and fallback is None: + result = { + "scope": "01_INBOX", + "confidence": 0.0, + "reason": f"both models failed: {primary_error}; {fallback_error}"[:160], + "content_status": "model-error", + } + return decorate(result, "failure") + + if primary is None: + return decorate(fallback, "fallback-only", fallback) + + if fallback is None: + result = dict(primary) + result["confidence"] = min(float(result.get("confidence", 0)), 0.69) + result["reason"] = ( + f"fallback failed; {result.get('reason', '')}" + )[:160] + return decorate(result, "primary-fallback-failed") + + if primary["scope"] == fallback["scope"]: + # Agreement is useful, but keep confidence conservative: never higher than the weaker vote. + result = dict(fallback) + result["confidence"] = min( + float(primary.get("confidence", 0)), + float(fallback.get("confidence", 0)), + ) + return decorate(result, "models-agree", fallback) + + result = { + "scope": "01_INBOX", + "confidence": 0.50, + "reason": ( + f"model disagreement: {model}={primary['scope']}; " + f"{fallback_model}={fallback['scope']}" + )[:160], + "content_status": primary.get("content_status", ""), + } + return decorate(result, "models-disagree", fallback) + + +FIELDS = [ + "key", "origin", "relative_path", "source_context", + "filename", "extension", "date_guess", "date_source", + "scope", "confidence", "reason", "content_status", "model_path", + "review_flags", "primary_scope", "primary_confidence", + "fallback_scope", "fallback_confidence", +] + + +def load_existing(report: Path) -> dict[str, dict]: + if not report.is_file(): + return {} + rows = {} + with report.open(newline="", encoding="utf-8") as f: + reader = csv.DictReader(f, delimiter="\t") + for row in reader: + key = row.get("key", "") + if key: + rows[key] = row + return rows + + +def write_report_atomic(report: Path, rows: dict[str, dict], order: list[str]) -> None: + report.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp( + prefix=report.name + ".", + suffix=".tmp", + dir=report.parent, + text=True, + ) + os.close(fd) + temp = Path(temp_name) + try: + with temp.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=FIELDS, delimiter="\t") + writer.writeheader() + for key in order: + if key in rows: + writer.writerow({name: rows[key].get(name, "") for name in FIELDS}) + temp.replace(report) + finally: + if temp.exists(): + temp.unlink() + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument( + "--stage", + default="/data/90_SORT_TEST_ARBEIT_IMPORT/FULL_STAGING_2026-09-14", + ) + ap.add_argument( + "--policy", + default=str(Path(__file__).with_name("sorter_policy.json")), + ) + ap.add_argument("--endpoint", default=q.DEFAULT_ENDPOINT) + ap.add_argument("--model", default=q.DEFAULT_MODEL) + ap.add_argument("--fallback-model", default=q.DEFAULT_FALLBACK_MODEL) + ap.add_argument("--no-fallback", action="store_true") + ap.add_argument( + "--report", + default="", + help="TSV report path; defaults under STAGE/MANIFESTS", + ) + ap.add_argument( + "--pilot-mixed", + action="store_true", + help="Only the original readable files from the known mixed backup", + ) + ap.add_argument("--limit", type=int, default=0) + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + stage = Path(args.stage).expanduser().resolve() + policy_path = Path(args.policy).expanduser().resolve() + + if not stage.is_dir(): + raise SystemExit(f"Not a staging directory: {stage}") + if not policy_path.is_file(): + raise SystemExit(f"Policy not found: {policy_path}") + + items = build_queue(stage, policy_path) + + if args.pilot_mixed: + prefix = MIXED_SOURCE_NAME + "/" + items = [ + item for item in items + if item["origin"] == "SOURCE" + and item["rel_path"].startswith(prefix) + ] + + if args.limit > 0: + items = items[:args.limit] + + if args.report: + report = Path(args.report).expanduser().resolve() + else: + name = ( + "scope_triage_pilot_mixed_v2.tsv" + if args.pilot_mixed + else "scope_triage_readable_v2.tsv" + ) + report = stage / "MANIFESTS" / name + + print("=== SCOPE TRIAGE ===") + print("Queue:", len(items)) + print("Mode:", "pilot-mixed" if args.pilot_mixed else "full-readable") + print("Allowed outcomes: 04_ARBEIT, 03_PRIVAT, 01_INBOX") + print("Primary model:", args.model) + fallback_model = "" if args.no_fallback else args.fallback_model.strip() + print("Fallback model:", fallback_model or "disabled") + print("Report:", report) + + if args.dry_run: + print("DRY RUN: no LLM requests made") + for item in items[:20]: + print(item["origin"], item["rel_path"]) + return + + existing = load_existing(report) + order = [item["key"] for item in items] + rows = {k: v for k, v in existing.items() if k in set(order)} + + completed = len(rows) + if completed: + print(f"Resume: {completed} already completed") + + for index, item in enumerate(items, 1): + key = item["key"] + if key in rows: + continue + + result = classify( + item, + args.endpoint, + args.model, + fallback_model, + ) + + row = { + "key": key, + "origin": item["origin"], + "relative_path": item["rel_path"], + "source_context": item["source_context"], + "filename": item["path"].name, + "extension": item["path"].suffix.lower(), + "date_guess": item["date_guess"], + "date_source": item["date_source"], + "scope": result["scope"], + "confidence": f"{float(result['confidence']):.2f}", + "reason": result["reason"], + "content_status": result["content_status"], + "model_path": result["model_path"], + "review_flags": result.get("review_flags", ""), + "primary_scope": result.get("primary_scope", ""), + "primary_confidence": ( + f"{float(result['primary_confidence']):.2f}" + if result.get("primary_confidence", "") != "" else "" + ), + "fallback_scope": result.get("fallback_scope", ""), + "fallback_confidence": ( + f"{float(result['fallback_confidence']):.2f}" + if result.get("fallback_confidence", "") != "" else "" + ), + } + + rows[key] = row + write_report_atomic(report, rows, order) + + print( + f"[{index:3}/{len(items)}] " + f"{result['scope']:9} " + f"{float(result['confidence']):.2f} " + f"{item['path'].name}" + ) + + counts = Counter(row["scope"] for row in rows.values()) + low = sum(float(row["confidence"]) < 0.70 for row in rows.values()) + disagreements = sum( + row.get("model_path") == "models-disagree" + for row in rows.values() + ) + + print() + print("=== RESULT ===") + print("Completed:", len(rows)) + print("04_ARBEIT:", counts["04_ARBEIT"]) + print("03_PRIVAT:", counts["03_PRIVAT"]) + print("01_INBOX:", counts["01_INBOX"]) + print("Confidence < 0.70:", low) + print("Model disagreements:", disagreements) + print("Report:", report) + + +if __name__ == "__main__": + main() diff --git a/sorter_policy.json b/sorter_policy.json new file mode 100644 index 0000000..8eedb9b --- /dev/null +++ b/sorter_policy.json @@ -0,0 +1,159 @@ +{ + "01_INBOX": [ + "01_INBOX", + "03_PRIVAT", + "03_PRIVAT/Gesundheit", + "03_PRIVAT/Dokumente", + "03_PRIVAT/Chrystal", + "04_ARBEIT", + "04_ARBEIT/Allgemein", + "04_ARBEIT/Allgemein/Kommunikation", + "04_ARBEIT/Allgemein/Kontakte", + "04_ARBEIT/Allgemein/Verwaltung", + "04_ARBEIT/Allgemein/Vorlagen", + "04_ARBEIT/Allgemein/Anleitungen", + "04_ARBEIT/Cafe_Bach", + "04_ARBEIT/Cafe_Bach/Planung", + "04_ARBEIT/Cafe_Bach/Kommunikation", + "04_ARBEIT/Cafe_Bach/Verwaltung", + "04_ARBEIT/Cafe_Bach/Veranstaltungen", + "04_ARBEIT/Cafe_Bach/Materialien", + "04_ARBEIT/Cafe_Bach/Vorlagen", + "04_ARBEIT/Cafe_Bach/Anleitungen", + "04_ARBEIT/Checkpoint", + "04_ARBEIT/CSD-Kampagnen", + "04_ARBEIT/Ehrenamt", + "04_ARBEIT/FAK", + "04_ARBEIT/SP5-Treffen", + "04_ARBEIT/Vor-Ort-Arbeit", + "05_VAULT", + "05_VAULT/Wissen", + "05_VAULT/Wissen/IT", + "05_VAULT/Wissen/Design", + "05_VAULT/Wissen/Biologie", + "05_VAULT/Wissen/Mathematik", + "05_VAULT/Wissen/HIV_Praevention", + "05_VAULT/Wissen/Organisation", + "05_VAULT/Prompts", + "05_VAULT/Tools", + "03_PRIVAT/Anleitungen", + "04_ARBEIT/Allgemein/Antraege", + "04_ARBEIT/Allgemein/Praesentationen", + "99_ARCHIV" + ], + "02_DEV": [ + "Projekte", + "Experimente" + ], + "03_PRIVAT": [ + "Gesundheit", + "Dokumente", + "Organisation", + "Anleitungen", + "Chrystal" + ], + "04_ARBEIT": [ + "Allgemein", + "Allgemein/Kommunikation", + "Allgemein/Kontakte", + "Allgemein/Protokolle", + "Allgemein/Verwaltung", + "Allgemein/Vorlagen", + "Allgemein/Anleitungen", + "Cafe_Bach", + "Cafe_Bach/Planung", + "Cafe_Bach/Kommunikation", + "Cafe_Bach/Verwaltung", + "Cafe_Bach/Veranstaltungen", + "Cafe_Bach/Materialien", + "Cafe_Bach/Vorlagen", + "Cafe_Bach/Anleitungen", + "Checkpoint", + "CSD-Kampagnen", + "Ehrenamt", + "FAK", + "SP5-Treffen", + "Vor-Ort-Arbeit" + ], + "05_VAULT": [ + "Wissen", + "Wissen/IT", + "Wissen/Design", + "Wissen/Biologie", + "Wissen/Mathematik", + "Wissen/HIV_Praevention", + "Wissen/Organisation", + "Wissen/Checkpoint", + "Wissen/KI", + "Prompts", + "Tools" + ], + "_strong_aliases": { + "04_ARBEIT": { + "Cafe_Bach": [ + "cb", + "cafe bach", + "café bach", + "cafe_bach", + "caba", + "cafebach" + ], + "Checkpoint": [ + "checkpoint", + "check point" + ], + "CSD-Kampagnen": [ + "csd", + "cologne pride" + ], + "SP5-Treffen": [ + "sp5", + "4s treffen" + ], + "Vor-Ort-Arbeit": [ + "voa", + "vor ort arbeit", + "vor-ort-arbeit" + ], + "FAK": [ + "fak", + "facharbeitskreis" + ] + }, + "01_INBOX": { + "04_ARBEIT/Cafe_Bach": [ + "cb", + "cafe bach", + "café bach", + "cafe_bach", + "caba", + "cafebach" + ], + "04_ARBEIT/Checkpoint": [ + "checkpoint", + "check point" + ], + "04_ARBEIT/CSD-Kampagnen": [ + "csd", + "cologne pride" + ], + "04_ARBEIT/SP5-Treffen": [ + "sp5", + "4s treffen" + ], + "04_ARBEIT/Vor-Ort-Arbeit": [ + "voa", + "vor ort arbeit", + "vor-ort-arbeit" + ], + "04_ARBEIT/FAK": [ + "fak", + "facharbeitskreis" + ], + "03_PRIVAT/Chrystal": [ + "chrystal math", + "chrystal-math" + ] + } + } +}