update
This commit is contained in:
@@ -39,7 +39,7 @@ import kanban
|
||||
from kanban import Flow, Stage, chain_stages
|
||||
import blocks
|
||||
from blocks import (
|
||||
_FILTER_NOTATION, _GROUP_STANDALONE,
|
||||
_FILTER_NOTATION,
|
||||
_build_research_prompt, _canonical, _canonical_key, _chunk_nums, _cliques,
|
||||
_completion_schema, _containment_parent, _crawl_index, _file_payload,
|
||||
_filter_schema, _filter_suspect, _is_artifact, _is_named_statement,
|
||||
@@ -47,13 +47,13 @@ from blocks import (
|
||||
_direction_conflict, _relation_conflict, _root, _supplement_schema, _text_sections, _umbrella_schema,
|
||||
_aspect_marker, _title_variants, _corpus_files, _evidence_pack, _sink_json, source_folder,
|
||||
)
|
||||
from config import (QA_GATE_NOTE, QA_GATE_LLM,
|
||||
from config import (QA_GATE_NOTE, QA_GATE_LLM, LUECKEN_RESEARCH_MAX,
|
||||
DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK,
|
||||
FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS,
|
||||
RESEARCH_THEMA_AGENTS,
|
||||
BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
|
||||
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS,
|
||||
GROUP_MIN_COS_FLOOR, GROUP_RECONCILE_FLOOR,
|
||||
GROUP_RECONCILE_FLOOR, GROUP_THEMES_PER_SQRT,
|
||||
)
|
||||
from fsutil import atomic_write_json, atomic_write_text
|
||||
from jsonio import read_json_file as _json_file
|
||||
@@ -1272,6 +1272,15 @@ async def _proc_dedup(ctx: GenContext, flow: Flow, cards):
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
def _themen_zielband(n: int) -> tuple[int, int]:
|
||||
"""Weiches Prompt-Band der Themenzahl: k = GROUP_THEMES_PER_SQRT·√n, ±30 %, min 2.
|
||||
n ist die Item-Zahl der GROUPING-WELLE — die Supplement-Welle bekommt bewusst ein
|
||||
kleines Band aus ihrem kleinen n (sie sortiert nur Neuzugänge nach)."""
|
||||
k = GROUP_THEMES_PER_SQRT * math.sqrt(max(n, 1))
|
||||
lo = max(2, math.floor(0.7 * k))
|
||||
return lo, max(lo + 1, math.ceil(1.3 * k))
|
||||
|
||||
|
||||
async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
"""BARRIER/drain — umbrella grouping over the filter survivors: embedding sibling clusters
|
||||
(low floor, high recall) + top-down pass, one judge per cluster, type gate + min-cos backstop,
|
||||
@@ -1300,11 +1309,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
all_ids = set(range(1, n + 1))
|
||||
full_list = "\n".join(f"{i}. {texts[i - 1]}" for i in range(1, n + 1))
|
||||
h = _h(*[r["card_id"] for r in rows])
|
||||
|
||||
def _min_cos(idxs):
|
||||
if len(idxs) < 2:
|
||||
return 1.0
|
||||
return round(min(float(sims[i][j]) for a, i in enumerate(idxs) for j in idxs[a + 1:]), 3)
|
||||
theme_lo, theme_hi = _themen_zielband(n)
|
||||
|
||||
async def _assess(tag, cand_text, count):
|
||||
path = work_dir / f"gruppierung-{h}-c{tag}.json"
|
||||
@@ -1312,7 +1317,8 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
status, _v = await run_single_slot(
|
||||
ctx, f"Gruppierung {tag}", key=f"blocks-{topic}-gruppierung-{h}-c{tag}",
|
||||
prompt=_prompt("Blocks-Gruppierung", topic=topic, candidates=cand_text,
|
||||
list=full_list, out_path=path),
|
||||
list=full_list, out_path=path,
|
||||
theme_lo=theme_lo, theme_hi=theme_hi, n_items=n),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: _umbrella_schema(_json_file(p), all_ids),
|
||||
timeout=_timeout("research_mapping", count))
|
||||
@@ -1349,7 +1355,6 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
# Bottom-up card-sorting: keine type-gate/min-cos-Vetos mehr — jedes Item soll in
|
||||
# ein Thema; ein „Fehl-Merge" ist billig (Item bleibt als Sub erhalten, keine Lücke).
|
||||
mrows = [rows[m - 1] for m in members]
|
||||
mc = _min_cos([m - 1 for m in members])
|
||||
unorm = _norm_title(title)
|
||||
member_norms = {r["title_norm"] for r in mrows}
|
||||
if unorm not in member_norms and unorm in seen_norm:
|
||||
@@ -1358,7 +1363,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
continue
|
||||
used.update(members)
|
||||
seen_norm.add(unorm)
|
||||
chosen.append({"umbrella": title, "description": desc, "min_cos": mc, "members": members})
|
||||
chosen.append({"umbrella": title, "description": desc, "members": members})
|
||||
# reconcile: same parent proposed twice under different titles → union
|
||||
if len(chosen) >= 2:
|
||||
uv = await _vec_rows(flow, [f"{c['umbrella']} — {c['description']}" for c in chosen])
|
||||
@@ -1462,24 +1467,25 @@ async def _proc_gap_check(ctx: GenContext, flow: Flow, cards):
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _supplement_beleg(ctx: GenContext, flow: Flow, supplements: list) -> list:
|
||||
async def _supplement_beleg(ctx: GenContext, flow: Flow, supplements: list, tag: str = "") -> list:
|
||||
"""Evidence gate for supplement proposals: keyword excerpts per proposal, ONE no-tool
|
||||
judge marks material coverage (ja/nein). Proposals without any matching excerpt drop
|
||||
immediately; a failed gate keeps nothing (creep is costlier than a lost bonus round)."""
|
||||
immediately; a failed gate keeps nothing (creep is costlier than a lost bonus round).
|
||||
`tag` trennt Resume-Datei/Key je Aufrufer (Lücken-Recherche läuft mehrere Runden)."""
|
||||
topic = flow.topic
|
||||
folder = source_folder(topic)
|
||||
packs = [(t, d, _evidence_pack(folder, None, [t], budget=6000)) for t, d in supplements]
|
||||
cands = [(t, d, ev) for t, d, ev in packs if ev]
|
||||
kept: list = []
|
||||
if cands:
|
||||
path = flow.work_dir / "supplement-beleg.json"
|
||||
path = flow.work_dir / f"supplement-beleg{tag}.json"
|
||||
ids = set(range(1, len(cands) + 1))
|
||||
verdict = _yesno_schema(_json_file(path), ids)
|
||||
if verdict is None:
|
||||
lines = "\n\n".join(f"{k}. {t} — {d}\nAUSZÜGE:\n{ev}"
|
||||
for k, (t, d, ev) in enumerate(cands, 1))
|
||||
status, verdict = await run_single_slot(
|
||||
ctx, "Supplement-Beleg", key=f"blocks-{topic}-supplement-beleg",
|
||||
ctx, "Supplement-Beleg", key=f"blocks-{topic}-supplement-beleg{tag}",
|
||||
prompt=_prompt("Blocks-Supplement-Beleg", topic=topic, proposals=lines,
|
||||
extra=_extra(flow.state.get("instructions", ""))),
|
||||
role="judge", capabilities="none",
|
||||
@@ -1525,11 +1531,21 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
||||
# treats). Only proposals the material itself covers may enter the inventory.
|
||||
if supplements and source_folder(topic):
|
||||
supplements = await _supplement_beleg(ctx, flow, supplements)
|
||||
# Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
|
||||
# must NOT dedup a supplement proposal — their content is gone. A hit on a dead title
|
||||
# REOPENS the lineage instead: the title card rejoins its cluster (live re-cluster) and
|
||||
# the respawned block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay
|
||||
# in the dedup: those were rejected as non-blocks, not lost as content.
|
||||
new, reopened = await _ingest_proposals(topic, supplements or [])
|
||||
if new or reopened:
|
||||
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _ingest_proposals(topic: str, supplements: list) -> tuple[int, int]:
|
||||
"""Vorschläge (title, description) als Titel-Karten einspeisen — gemeinsamer Ingest-
|
||||
Schwanz von Supplement- und Lücken-Recherche. → (neu, wiedereröffnet).
|
||||
|
||||
Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
|
||||
must NOT dedup a proposal — their content is gone. A hit on a dead title REOPENS the
|
||||
lineage instead: the title card rejoins its cluster (live re-cluster) and the respawned
|
||||
block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay in the dedup:
|
||||
those were rejected as non-blocks, not lost as content."""
|
||||
dead_reasons = {"fragment", "drop-collateral", "drop"}
|
||||
cards = await db.kanban_cards(topic, board=BOARD)
|
||||
dead_clusters = {c["payload"].get("cluster") for c in cards
|
||||
@@ -1563,7 +1579,7 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
||||
if (k := _canonical_key(tt)):
|
||||
known_keys.add(k)
|
||||
new = reopened = 0
|
||||
for t, d in (supplements or []):
|
||||
for t, d in supplements:
|
||||
t, d = clean_title(t), clean_title(d)
|
||||
norm = _norm_title(t)
|
||||
key = _canonical_key(t)
|
||||
@@ -1587,9 +1603,57 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
||||
card["payload"]["supplement"] = True
|
||||
await db.kanban_set_payload(topic, BOARD, norm, card["payload"])
|
||||
new += 1
|
||||
if new or reopened:
|
||||
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
|
||||
flow.wake.set()
|
||||
return new, reopened
|
||||
|
||||
|
||||
async def _luecken_producer(ctx: GenContext, flow: Flow, report: dict) -> int:
|
||||
"""Gezielte Nach-Recherche für offene QA-Lücken (Auto-Loop): EIN Agent sieht Bestand
|
||||
plus Lücken-Fundstellen (Abschnittstexte deterministisch re-extrahiert, gleicher
|
||||
Splitter wie die QA) und schlägt NUR dafür Blöcke vor — leere Liste erlaubt.
|
||||
Danach Evidence-Gate + Ingest wie beim Supplement; neue Titel durchlaufen alle
|
||||
Inventar-Gates bis done_block. → Zahl neuer/wiedereröffneter Karten."""
|
||||
import qa as _qa
|
||||
topic = flow.topic
|
||||
lk, kl = _qa.offene_luecken(report)
|
||||
if not (lk or kl):
|
||||
return 0
|
||||
flow.add_producer() # Engine am Leben halten, solange der Research-Call läuft
|
||||
try:
|
||||
texts = _qa._corpus_texts(topic)
|
||||
sections = {f: _qa._sections(t) for f, t in texts.items()}
|
||||
gaps = []
|
||||
for x in lk[:20]:
|
||||
sec = sections.get(x.get("datei"), [])
|
||||
k = int(x.get("abschnitt") or 0)
|
||||
volltext = sec[k - 1][:1500] if 0 < k <= len(sec) else x.get("vorschau", "")
|
||||
gaps.append(f"[{x.get('datei')} #{k}]\n{volltext}")
|
||||
gaps += [f"NAMED RESULT (no covering block): {n}" for n in kl[:20]]
|
||||
done = await db.kanban_cards(topic, board=BOARD, kind="block", stage="done_block")
|
||||
runde = flow.state.get("luecken_runde", 0)
|
||||
path = flow.work_dir / f"luecken-research-r{runde}.json"
|
||||
supplements = _supplement_schema(_json_file(path))
|
||||
if supplements is None:
|
||||
status, supplements = await run_single_slot(
|
||||
ctx, f"Lücken-Recherche r{runde}", key=f"blocks-{topic}-luecken-nachfass-r{runde}",
|
||||
prompt=_prompt("Blocks-Luecken-Research", topic=topic,
|
||||
blocks="\n".join(f"- {c['payload'].get('title', '')}" for c in done),
|
||||
gaps="\n\n".join(gaps), out_path=path,
|
||||
extra=_extra(flow.state.get("instructions", ""))),
|
||||
role="quick", capabilities="files",
|
||||
payload=lambda result, p=path: _supplement_schema(_json_file(p)),
|
||||
timeout=_timeout("ergaenzung"))
|
||||
if status != OK or supplements is None:
|
||||
_log(topic, "Lücken-Recherche ohne Ergebnis — übersprungen")
|
||||
return 0
|
||||
if supplements and source_folder(topic):
|
||||
supplements = await _supplement_beleg(ctx, flow, supplements, tag=f"-l{runde}")
|
||||
new, reopened = await _ingest_proposals(topic, supplements or [])
|
||||
if new or reopened:
|
||||
_log(topic, f"Lücken-Recherche: {new} Kandidat(en) → ingest, {reopened} wiedereröffnet")
|
||||
flow.wake.set()
|
||||
return new + reopened
|
||||
finally:
|
||||
flow.done_producer()
|
||||
|
||||
|
||||
async def _proc_done(ctx: GenContext, flow: Flow, cards):
|
||||
@@ -1731,11 +1795,11 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
||||
stages = chain_stages(stages)
|
||||
if artefacts:
|
||||
# Outline needs every block's TITLE + FACTS (aus generate), nothing later: cut the
|
||||
# post-generate stages from its barrier so it runs parallel to verify…finalize AND
|
||||
# zur Cross-Dedup-Barriere des langsamsten Blocks (makespan tail).
|
||||
# post-generate stages from its barrier so it runs parallel to verify…finalize
|
||||
# des langsamsten Blocks (makespan tail).
|
||||
outline = next(s for s in stages if s.stage == "outline")
|
||||
outline.upstream = [u for u in outline.upstream if u not in
|
||||
("verify", "artefakte", "finalize", "konsolidierung")]
|
||||
("verify", "artefakte", "finalize")]
|
||||
producers = _build_producers(ctx, flow, q, folder, instructions) if (research and inventory) else []
|
||||
|
||||
async def _as_producer(coro):
|
||||
@@ -1792,17 +1856,24 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
||||
_QA_GATE_POLL = 2.0 # Sekunden zwischen Quiescence-Checks des QA-Wächters
|
||||
|
||||
|
||||
async def _warte_inventar_ruhe(flow: Flow, inv_names: list[str]) -> None:
|
||||
"""Bis das Inventar quiescent ist (Research fertig, keine aktiven Karten in den
|
||||
Inventar-Stages) oder der Flow stoppt — Poll-Kopf des QA-Wächters, auch nach
|
||||
Lücken-Recherche-Runden gebraucht (neue Karten laufen erst durch alle Gates)."""
|
||||
while not flow.stop:
|
||||
if (flow.research_done and not flow.active_in(inv_names)
|
||||
and await db.kanban_count(flow.topic, inv_names) == 0):
|
||||
return
|
||||
await asyncio.sleep(_QA_GATE_POLL)
|
||||
|
||||
|
||||
async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_p):
|
||||
"""Companion task: once the inventory is quiescent, run the QA once and decide —
|
||||
open the board-2 gate or pause the flow. Fail-OPEN on errors (QA is a helper,
|
||||
not a jailer); qa_force short-circuits to open."""
|
||||
topic = flow.topic
|
||||
try:
|
||||
while not flow.stop:
|
||||
if (flow.research_done and not flow.active_in(inv_names)
|
||||
and await db.kanban_count(topic, inv_names) == 0):
|
||||
break
|
||||
await asyncio.sleep(_QA_GATE_POLL)
|
||||
await _warte_inventar_ruhe(flow, inv_names)
|
||||
if flow.stop or ctx.is_cancelled():
|
||||
return
|
||||
if flow.state.get("qa_force"):
|
||||
@@ -1832,16 +1903,33 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_
|
||||
flow.wake.set()
|
||||
return
|
||||
|
||||
# Auto an: „Befunde beheben"-Loop bis 100 % / Stillstand / 10×.
|
||||
# Auto an: „Befunde beheben"-Loop bis 100 % / Stillstand / 10×. Offene Lücken
|
||||
# bekommen erst bis zu LUECKEN_RESEARCH_MAX gezielte Nach-Recherche-Runden;
|
||||
# das Lücken-Urteil (Freispruch/bestätigt) fällt erst, wenn die Recherche
|
||||
# nichts mehr bewegt — sonst würde eine füllbare Lücke vorschnell verurteilt.
|
||||
from auto_loop import auto_repair_loop
|
||||
import repair as _repair
|
||||
|
||||
async def _reparieren():
|
||||
rep = qa.latest_report(topic) or {}
|
||||
research_bewegt = False
|
||||
runde = flow.state.get("luecken_runde", 0)
|
||||
lk, kl = qa.offene_luecken(rep)
|
||||
if (lk or kl) and runde < LUECKEN_RESEARCH_MAX:
|
||||
flow.state["luecken_runde"] = runde + 1
|
||||
set_p(f"Lücken-Recherche {runde + 1}/{LUECKEN_RESEARCH_MAX}…")
|
||||
neue = await _luecken_producer(ctx, flow, rep)
|
||||
research_bewegt = neue > 0
|
||||
if neue: # neue Titel durchlaufen alle Gates — erst Ruhe, dann messen
|
||||
await _warte_inventar_ruhe(flow, inv_names)
|
||||
if flow.stop or ctx.is_cancelled():
|
||||
return float(flow.state.get("qa_note") or 0.0), False
|
||||
set_p("Befunde beheben (Inventar)…")
|
||||
r = await _repair.repair_befunde(topic, "inventory") # behebt + misst neu
|
||||
r = await _repair.repair_befunde(topic, "inventory",
|
||||
luecken_urteil=not research_bewegt)
|
||||
n = float(r.get("note", 10.0))
|
||||
flow.state["qa_note"] = n
|
||||
return n
|
||||
return n, bool(r.get("aktionen", 0)) or research_bewegt
|
||||
|
||||
res = await auto_repair_loop("Inventar", note, _reparieren)
|
||||
end_note = res["note"]
|
||||
@@ -1899,7 +1987,7 @@ async def _artefakt_auto_loop(ctx: GenContext, flow: Flow, set_p):
|
||||
async def _reparieren():
|
||||
set_p("Befunde beheben (Artefakte)…")
|
||||
r = await _repair.repair_befunde(topic, "artefacts")
|
||||
return float(r.get("note_artefakte") or 10.0)
|
||||
return float(r.get("note_artefakte") or 10.0), bool(r.get("aktionen", 0))
|
||||
|
||||
res = await auto_repair_loop("Artefakte", na, _reparieren)
|
||||
flow.state["note_artefakte"] = res["note"]
|
||||
@@ -1986,7 +2074,6 @@ COLUMNS = [
|
||||
("artefacts", "verify", "Prüfen", "ablock"),
|
||||
("artefacts", "artefakte", "Lernmittel", "ablock"),
|
||||
("artefacts", "finalize", "Zusammenführen", "ablock"),
|
||||
("artefacts", "konsolidierung", "Konsolidierung", "ablock"),
|
||||
("artefacts", "outline", "Gliederung", "outline"),
|
||||
("artefacts", "done_artefact", "Fertig", "ablock"),
|
||||
]
|
||||
@@ -2053,13 +2140,15 @@ async def board_snapshot(topic: str, limit: int = 20) -> dict:
|
||||
|
||||
def _qa_view(topic: str, counts: dict, flow) -> dict | None:
|
||||
"""Latest QA report digest for the board header. `pausiert` = the gate stopped the
|
||||
flow (score below threshold, board-2 cards waiting, no flow running)."""
|
||||
flow (score below 100 %, board-2 cards waiting, no flow running) — das Auto-Gate
|
||||
öffnet nur bei VOLL; eine 9.6 pausierte den Loop, galt hier aber nicht als pausiert."""
|
||||
# no inventory (deleted/never built) → no badge; the report files stay on purpose,
|
||||
# so the first run after a rebuild diffs against the old state
|
||||
if not counts.get("inventory", {}).get("done_block", 0) and not (
|
||||
flow and flow.state.get("qa_note") is not None):
|
||||
return None
|
||||
import qa
|
||||
from auto_loop import VOLL
|
||||
r = qa.latest_report(topic)
|
||||
if r is None:
|
||||
return None
|
||||
@@ -2067,9 +2156,9 @@ def _qa_view(topic: str, counts: dict, flow) -> dict | None:
|
||||
if note is None:
|
||||
return None
|
||||
wartend = counts.get("artefacts", {}).get("generate", 0)
|
||||
pausiert = bool(note < QA_GATE_NOTE and wartend and flow is None)
|
||||
pausiert = bool(note < VOLL and wartend and flow is None)
|
||||
return {"note": note, "note_artefakte": r.get("note_artefakte"),
|
||||
"schwelle": QA_GATE_NOTE, "pausiert": pausiert,
|
||||
"schwelle": VOLL, "pausiert": pausiert,
|
||||
"quoten": r.get("quoten", {}),
|
||||
"befunde": (r.get("fremd", []) + r.get("unecht", []))[:6]}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user