This commit is contained in:
team3
2026-07-04 12:21:45 +02:00
parent 2f5d5b9ca1
commit 8d8f6c8e51
43 changed files with 1920 additions and 236 deletions

View File

@@ -28,6 +28,7 @@ import json
import logging
import math
import re
import shutil
import unicodedata
import uuid
from datetime import datetime, timezone
@@ -63,7 +64,7 @@ from textkit import _norm_title, _parse_selection, _title, clean_title
log = logging.getLogger("creator.board_inventory")
BOARD = "inventory"
RESEARCH_RUNTIME = 900 # one research agent, one round — the tail ingests live while it writes
from config import RESEARCH_RUNTIME # noqa: E402 — zentral tunebar
_POLL_RESEARCH = 3 # seconds between live reads of a running research file
_ingest_lock = asyncio.Lock() # serializes the read-modify-write title upserts
@@ -84,15 +85,37 @@ def _line(i: int, p: dict, mark: str = "") -> str:
return f"{i}. {mark}{p['title']}{d}" if d else f"{i}. {mark}{p['title']}"
def _naming_schema(data, count: int) -> int | None:
"""{"best": N} → 1-based member index in [1, count] · otherwise None."""
def _naming_schema(data, count: int) -> tuple[int | None, str | None, bool] | None:
"""{"ok":true} → (None,None,True) · {"best":N[,"name":…]} → (N, name|None, False).
name über 80 Zeichen wird verworfen (Kürze ist der Zweck der Abstraktion)."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return None, None, True
try:
n = int(data.get("best"))
except (ValueError, TypeError):
return None
return n if 1 <= n <= count else None
if not 1 <= n <= count:
return None
name = str(data.get("name", "")).strip() or None
if name and len(name) > 80:
name = None
return n, name, False
def _name_verankert(name: str, rows: list[dict], ctoks: set[str] | None) -> bool:
"""Abstraction guard: a free-formed title must be anchored — in the corpus (uni/projekt)
or in the members' own words (thema). Unanchored names drift to textbook canon
(measured: 'Königsberger Brückenproblem', 0 corpus hits) → fall back to best-of-members."""
if ctoks is not None:
return _hat_anker(name, ctoks)
from qa import _STOP
basis = " ".join(f"{r.get('title', '')} {r.get('description') or ''}" for r in rows)
fold = lambda s: unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode().casefold() # noqa: E731
btoks = set(re.findall(r"\w{3,}", fold(basis)))
ntoks = {t for t in re.findall(r"\w{3,}", fold(name)) if t not in _STOP}
return bool(ntoks) and ntoks <= btoks
# ── Embedding cache (per flow) ─────────────────────────────────────────────────────
@@ -450,15 +473,25 @@ def _hat_anker(title: str, ctoks: set[str]) -> bool:
async def _anker_beleg(ctx: GenContext, flow: Flow, kandidaten: list[tuple[str, dict]]) -> set[str]:
"""Evidence judge for quorum titles WITHOUT any corpus anchor — two readers naming the
same famous canon independently beat the quorum although the material never mentions it
(measured: 'Königsberger Brückenproblem', 0 corpus hits). FAIL-OPEN: the titles carry
2-reader backing, only an explicit 'nein' rejects. → card_ids to reject."""
(measured: 'Königsberger Brückenproblem', 0 corpus hits). FAIL-OPEN for judge errors —
but an EMPTY evidence pack (no distinctive token anywhere in the corpus) rejects
deterministically: the judge approved 3 canon titles on sham excerpts. → card_ids to reject."""
from qa import _distinctive
topic = flow.topic
folder = source_folder(topic)
lines = []
hart_nein: set[str] = set()
for k, (cid, p) in enumerate(kandidaten, 1):
ev = _evidence_pack(folder, None, [p.get("title", ""), p.get("description") or ""], budget=4000)
# distinctive tokens only: title+description as raw queries pulled generic sections
# („Beispiel", „klassisch") — the judge saw sham excerpts and waved canon through
toks = _distinctive(p.get("title", "")) | _distinctive(p.get("description") or "")
ev = _evidence_pack(folder, None, [" ".join(sorted(toks))], budget=4000) if toks else ""
if not ev: # nothing to attest — deterministic reject, no judge to sweet-talk
hart_nein.add(cid)
lines.append(f"{k}. {p.get('title', '')}{p.get('description') or ''}\nAUSZÜGE:\n"
f"{ev or '(keine passenden Auszüge im Material gefunden)'}")
if len(hart_nein) == len(kandidaten):
return hart_nein
h = _h(*[cid for cid, _ in kandidaten])
path = flow.work_dir / f"anker-beleg-{h}.json"
ids = set(range(1, len(kandidaten) + 1))
@@ -473,7 +506,8 @@ async def _anker_beleg(ctx: GenContext, flow: Flow, kandidaten: list[tuple[str,
timeout=_timeout("selection_mapping", len(kandidaten)))
if status != OK or not isinstance(verdict, dict):
verdict = {} # fail-open
return {cid for k, (cid, _p) in enumerate(kandidaten, 1) if str(verdict.get(k, "ja")) == "nein"}
return hart_nein | {cid for k, (cid, _p) in enumerate(kandidaten, 1)
if str(verdict.get(k, "ja")) == "nein"}
async def _proc_consensus_gate(ctx: GenContext, flow: Flow, cards):
@@ -608,32 +642,42 @@ async def _proc_clarify(ctx: GenContext, flow: Flow, cards):
async def _choose_title(ctx: GenContext, flow: Flow, cid: str, rows: list[dict],
template: str, current: int | None = None) -> str:
"""Judge picks the best member title (index). Parse-fail → survivorship fallback."""
template: str, current_title: str | None = None) -> tuple[str, str | None]:
"""Judge picks the best member title and MAY propose a short abstracted name — kept
only when anchored (_name_verankert). → (member_norm, custom_title|None);
("", None) on cancel; {"ok":true} in the check keeps the current title."""
topic = flow.topic
h = _h(*[r["norm"] for r in rows], template)
path = flow.work_dir / f"naming-{cid}-{h}.json"
best = _naming_schema(_json_file(path), len(rows))
if best is None:
verdict = _naming_schema(_json_file(path), len(rows))
if verdict is None:
lines = "\n".join(f"{k + 1}. {_t_text(r)}" for k, r in enumerate(rows))
kw = dict(topic=topic, members=lines, out_path=path)
if current is not None:
kw["current"] = current
status, best = await run_single_slot(
kw = dict(topic=topic, members=lines)
if current_title is not None:
kw["current_title"] = current_title
status, verdict = await run_single_slot(
ctx, f"Naming {cid}", key=f"blocks-{topic}-naming-{cid}-{h}",
prompt=_prompt(template, **kw), role="judge", capabilities="files",
payload=lambda result, p=path, n=len(rows): _naming_schema(_json_file(p), n),
prompt=_prompt(template, **kw), role="judge", capabilities="none",
payload=lambda result, p=path, n=len(rows): _sink_json(result, p, lambda d: _naming_schema(d, n)),
timeout=_timeout("selection_mapping", len(rows)))
if status == CANCELLED:
return ""
return "", None
if status == FAILED:
best = None
if best is None:
verdict = None
if verdict is None:
rep = _rep(rows)
w = _norm_title(rep["title"])
norms = [r["norm"] for r in rows]
return w if w in norms else norms[0]
return rows[best - 1]["norm"]
return (w if w in norms else norms[0]), None
best, name, ok = verdict
if ok: # check judge confirms the current (possibly custom) title: change nothing
return "", (current_title or "")
custom = None
if name:
ctoks = flow.state.get("korpus_tokens")
if _name_verankert(name, rows, ctoks):
custom = clean_title(name)
return rows[best - 1]["norm"], custom
async def _proc_naming(ctx: GenContext, flow: Flow, cards):
@@ -650,13 +694,15 @@ async def _name_one(ctx: GenContext, flow: Flow, c):
p = c["payload"]
rows = await _member_rows(topic, cid)
if len(rows) > 1 and not p.get("renamed"):
winner = await _choose_title(ctx, flow, cid, rows, "Blocks-Naming")
if not winner: # cancelled
winner, custom = await _choose_title(ctx, flow, cid, rows, "Blocks-Naming")
if not winner and custom is None: # cancelled
return
p["main_norm"] = winner
w = next(r for r in rows if r["norm"] == winner)
p["title"], p["description"] = w["title"], w.get("description") or p.get("description", "")
await db.kanban_set_payload(topic, BOARD, cid, p)
if winner:
p["main_norm"] = winner
w = next(r for r in rows if r["norm"] == winner)
p["title"] = custom or w["title"]
p["description"] = w.get("description") or p.get("description", "")
await db.kanban_set_payload(topic, BOARD, cid, p)
await db.kanban_advance(topic, BOARD, cid, "naming_check")
@@ -675,14 +721,14 @@ async def _namecheck_one(ctx: GenContext, flow: Flow, c):
p = c["payload"]
rows = await _member_rows(topic, cid)
if len(rows) > 1 and not p.get("renamed"):
norms = [r["norm"] for r in rows]
cur = p.get("main_norm")
current = norms.index(cur) + 1 if cur in norms else 1
winner = await _choose_title(ctx, flow, cid, rows, "Blocks-Naming-Check", current=current)
if not winner:
winner, custom = await _choose_title(ctx, flow, cid, rows, "Blocks-Naming-Check",
current_title=p.get("title", ""))
if not winner and custom is None: # cancelled
return
w = next(r for r in rows if r["norm"] == winner)
p["title"], p["description"] = w["title"], w.get("description") or p.get("description", "")
if winner: # ok-verdict ("" + custom) keeps title AND description untouched
w = next(r for r in rows if r["norm"] == winner)
p["title"] = custom or w["title"]
p["description"] = w.get("description") or p.get("description", "")
readers = sorted(set().union(*[set(r.get("readers") or []) for r in rows])) if rows else []
sources = sorted(set().union(*[set(r.get("sources") or []) for r in rows])) if rows else []
await db.kanban_upsert_card(topic, BOARD, f"b-{cid}", "block", "fragment_filter", {
@@ -1872,6 +1918,13 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
await db.delete_subblocks(topic)
await db.delete_question_pattern(topic)
await db.delete_sub_artefakte(topic)
# global sidecar files and per-block resume slots: leftovers of the previous
# derive would re-merge/resume into the fresh run — nothing here is reused
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
files[k].unlink(missing_ok=True)
for d in files["arbeit"].glob("ab-*"):
if d.is_dir():
shutil.rmtree(d, ignore_errors=True)
await db.add_event(topic, "reset", key=f"{board}:from-{stage}", status=str(moved))
return moved