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 0000000..a147ffc Binary files /dev/null and b/__pycache__/2026-09-15_quick-sorter_dedupe-hardening_v17.cpython-313.pyc differ 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 0000000..7adfeea Binary files /dev/null and b/__pycache__/2026-09-15_quick-sorter_destination-normalization_v18.cpython-313.pyc differ 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 0000000..ce16037 Binary files /dev/null and b/__pycache__/2026-09-15_quick-sorter_destination-retry_v16.cpython-313.pyc differ 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 0000000..db54859 Binary files /dev/null and b/__pycache__/2026-09-15_quick-sorter_full-inbox-candidates_v15.cpython-313.pyc differ diff --git a/__pycache__/2026-09-15_quick-sorter_inbox-purpose-routing_v12.cpython-313.pyc b/__pycache__/2026-09-15_quick-sorter_inbox-purpose-routing_v12.cpython-313.pyc new file mode 100644 index 0000000..e78ede9 Binary files /dev/null and b/__pycache__/2026-09-15_quick-sorter_inbox-purpose-routing_v12.cpython-313.pyc differ 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 0000000..2de3433 Binary files /dev/null and b/__pycache__/2026-09-15_quick-sorter_inbox-review-only_v11.cpython-313.pyc differ 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 0000000..7ca5923 Binary files /dev/null and b/__pycache__/2026-09-15_quick-sorter_inbox-staging-context_v10.cpython-313.pyc differ 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 0000000..e5cfe63 Binary files /dev/null and b/__pycache__/2026-09-15_quick-sorter_semantic-naming_v13.cpython-313.pyc differ diff --git a/__pycache__/2026-09-15_quick-sorter_vault-specific-routing_v14.cpython-313.pyc b/__pycache__/2026-09-15_quick-sorter_vault-specific-routing_v14.cpython-313.pyc new file mode 100644 index 0000000..5b0acf2 Binary files /dev/null and b/__pycache__/2026-09-15_quick-sorter_vault-specific-routing_v14.cpython-313.pyc differ diff --git a/__pycache__/2026-09-16_inbox-sort_daily-wrapper.cpython-313.pyc b/__pycache__/2026-09-16_inbox-sort_daily-wrapper.cpython-313.pyc new file mode 100644 index 0000000..7bdf5a7 Binary files /dev/null and b/__pycache__/2026-09-16_inbox-sort_daily-wrapper.cpython-313.pyc differ diff --git a/__pycache__/quick_sorter.cpython-313.pyc b/__pycache__/quick_sorter.cpython-313.pyc new file mode 100644 index 0000000..2364e24 Binary files /dev/null and b/__pycache__/quick_sorter.cpython-313.pyc differ diff --git a/__pycache__/quick_sorter.cpython-314.pyc b/__pycache__/quick_sorter.cpython-314.pyc new file mode 100644 index 0000000..ea59735 Binary files /dev/null and b/__pycache__/quick_sorter.cpython-314.pyc differ diff --git a/__pycache__/scope_triage.cpython-313.pyc b/__pycache__/scope_triage.cpython-313.pyc new file mode 100644 index 0000000..ed51da0 Binary files /dev/null and b/__pycache__/scope_triage.cpython-313.pyc differ 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" + ] + } + } +}