document_sorter/2026-09-14_scope-triage_work-private-inbox_v1.py
2026-09-16 13:10:15 +02:00

508 lines
16 KiB
Python

#!/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()