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

222 lines
10 KiB
Markdown

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