This commit is contained in:
Team3
2026-07-22 16:12:23 +02:00
commit f07ef9653d
851 changed files with 501480 additions and 0 deletions

8
backend/__main__.py Normal file
View File

@@ -0,0 +1,8 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) # `python -m backend` von der Wurzel
from cli import main # noqa: E402
main()

564
backend/agents.py Normal file
View File

@@ -0,0 +1,564 @@
"""Provider-Layer: LLM-Calls über Claude-CLI, OpenCode-CLI oder direkte MiniMax-API.
Port aus creator/backend/agents.py mit zwei bewussten Abweichungen:
- KEIN Provider-Fallback: resolve_role(role) kommt vollständig aus der .env; ist eine
Rolle unkonfiguriert oder der Provider nicht nutzbar, wird der Lauf pausiert
(AgentInfraError) — nie still gewechselt (Leitprinzip 2).
- Leere-Ausgabe-Triage (Phase-0-Erkenntnis 13): leerer Text bei rc=0 ist ein Fehler.
OpenCode-Pfad: keine Session in der OpenCode-DB → Netz/Provider → AgentInfraError
(Pause); Session vorhanden → Retry-Fehler (Thinking-Overrun/Stall).
Der Event-Sink `on_event` wird von außen injiziert (CLI setzt database.add_event) —
dieses Modul bleibt DB-frei.
"""
import asyncio
import heapq
import logging
import os
import re
import shutil
import signal
import sqlite3
import tempfile
import time
from contextlib import asynccontextmanager
from pathlib import Path
import httpx
from config import (MAX_CONCURRENT_AGENTS, MAX_CONCURRENT_AGENTS_PER_TOPIC,
MAX_CONCURRENT_API_AGENTS, OPENCODE_CONFIG, PROVIDERS,
RAM_MIN_FREE_PCT, resolve_role)
log = logging.getLogger("planer.agents")
class AgentInfraError(Exception):
"""Infrastruktur nicht nutzbar (Netz/Provider down, Rolle unkonfiguriert). KEIN
inhaltliches Urteil — der Aufrufer pausiert den Lauf, nie fail-open."""
_active_processes: dict[str, asyncio.subprocess.Process] = {}
_active_api: set[str] = set() # laufende direkte API-Calls (kein Prozess zu killen)
_active_started: dict[str, float] = {} # agent_key → Wandzeit-Start (Live-Anzeige)
_active_labels: dict[str, str] = {}
def active_agents(scope_prefix: str | None = None) -> list[dict]:
"""Laufende Agenten mit Laufzeit, längste zuerst. → [{key, label, runtime}]"""
now = time.time()
out = [{"key": k, "label": _active_labels.get(k, ""), "runtime": round(now - t, 1)}
for k, t in list(_active_started.items())
if (k in _active_processes or k in _active_api)
and (not scope_prefix or k.startswith(scope_prefix))]
return sorted(out, key=lambda a: -a["runtime"])
# --- Abbruch (Prefix-basiert) --------------------------------------------------------
_cancelled_prefixes: set[str] = set()
def cancel_scope(prefix: str) -> None:
_cancelled_prefixes.add(prefix)
def clear_scope(prefix: str) -> None:
_cancelled_prefixes.discard(prefix)
def _scope_cancelled(agent_key: str) -> bool:
return any(agent_key.startswith(p) for p in _cancelled_prefixes)
class _PrioritySemaphore:
"""Semaphore-Variante: bei knappen Slots gewinnt die NIEDRIGSTE Prioritätszahl
(FIFO innerhalb gleicher Priorität) — frühe Board-Spalten vor späten."""
def __init__(self, value: int):
self._value = value
self._waiters: list = [] # Heap aus [priority, seq, future]
self._seq = 0
async def acquire(self, priority: int = 100):
if self._value > 0:
self._value -= 1
return
fut = asyncio.get_event_loop().create_future()
entry = [priority, self._seq, fut]
self._seq += 1
heapq.heappush(self._waiters, entry)
try:
await fut # release() reicht den Slot direkt weiter (kein value-Increment)
except BaseException:
entry[2] = None # Tombstone: release() überspringt tote Waiter
if fut.done() and not fut.cancelled():
self.release() # Slot kam exakt vor dem Cancel → weitergeben
raise
def release(self):
while self._waiters:
entry = heapq.heappop(self._waiters)
if entry[2] is not None and not entry[2].done():
entry[2].set_result(None)
return
self._value += 1
_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS) # Prozess-Tier (~310 MB RSS)
_batch_sem_api = _PrioritySemaphore(MAX_CONCURRENT_API_AGENTS) # API-Tier (~0 RAM)
_topic_sems: dict[str, _PrioritySemaphore] = {}
# Kleinerer Index = höhere Priorität: Karten fertig machen statt neues WIP öffnen.
_STAGE_PRIORITY = ("sichten", "judge", "nachfass", "schneiden")
def _agent_priority(key: str) -> int:
for i, tag in enumerate(_STAGE_PRIORITY):
if f"-{tag}-" in key or key.endswith(f"-{tag}"):
return i
return len(_STAGE_PRIORITY)
@asynccontextmanager
async def _batch_gate(scope: str | None, priority: int, api: bool = False):
"""Erst der Per-Topic-Slot (fair), dann der GLOBALE Slot nach Priorität. Ein Waiter
hält beim globalen Anstehen nur seinen Topic-Slot. Topic-Cap gilt über beide Tiers."""
topic_sem = _topic_sems.setdefault(scope, _PrioritySemaphore(MAX_CONCURRENT_AGENTS_PER_TOPIC)) if scope else None
global_sem = _batch_sem_api if api else _batch_sem
if topic_sem is not None:
await topic_sem.acquire(priority)
await global_sem.acquire(priority)
try:
yield
finally:
global_sem.release()
if topic_sem is not None:
topic_sem.release()
# Stagger für OpenCode-Starts: gleichzeitige Kaltstarts kollidieren auf der internen
# Session-DB (Phase-0-Erkenntnis 13a: alle hängen, keine Session entsteht). Token-Bucket:
# der Lock vergibt nur den Start-Slot, geschlafen wird außerhalb.
_opencode_start_lock = asyncio.Lock()
_OPENCODE_START_DELAY = float(os.getenv("OPENCODE_START_DELAY", "0.5"))
_opencode_next_start = 0.0
async def _opencode_slot() -> None:
global _opencode_next_start
loop = asyncio.get_running_loop()
async with _opencode_start_lock:
now = loop.time()
start_at = max(now, _opencode_next_start)
_opencode_next_start = start_at + _OPENCODE_START_DELAY
await asyncio.sleep(max(0.0, start_at - now))
# --- RAM-Gate: unter RAM_MIN_FREE_PCT freiem Speicher warten neue CLI-Prozesse ---------
_RAM_GATE_FLOOR = 2 # unter so vielen Läufen: immer zulassen (Deadlock-Schutz)
_RAM_PER_PROC_KB = 350 * 1024
_RAM_COMMIT_WINDOW_S = 10.0 # frische Zulassungen zählen als schon verbrauchtes RAM
_RAM_POLL_S = 2.0
_cli_running = 0
_cli_recent_starts: list[tuple[float, int]] = []
def _meminfo() -> tuple[int, int] | None:
try:
text = Path("/proc/meminfo").read_text()
except OSError:
return None
m = {k: v for k, v in re.findall(r"^(MemTotal|MemAvailable):\s+(\d+)", text, re.MULTILINE)}
if "MemTotal" not in m or "MemAvailable" not in m:
return None
return int(m["MemAvailable"]), int(m["MemTotal"])
async def _ram_gate(agent_key: str, est_kb: int) -> bool:
"""True = Start zugelassen (Commit registriert), False = Scope während des Wartens
abgebrochen. Check und Commit-Append im selben synchronen Block (kein await dazwischen)."""
if RAM_MIN_FREE_PCT <= 0:
return True
waited = False
while True:
if _scope_cancelled(agent_key):
return False
mem = _meminfo()
if mem is None or _cli_running < _RAM_GATE_FLOOR:
break
avail_kb, total_kb = mem
now = time.monotonic()
_cli_recent_starts[:] = [(t, kb) for t, kb in _cli_recent_starts
if now - t < _RAM_COMMIT_WINDOW_S]
committed_kb = sum(kb for _, kb in _cli_recent_starts)
if avail_kb - committed_kb >= total_kb * RAM_MIN_FREE_PCT / 100:
break
if not waited:
log.info("agent %s: RAM-Gate wartet (%.0f%% frei)", agent_key, avail_kb * 100 / total_kb)
waited = True
await asyncio.sleep(_RAM_POLL_S)
_cli_recent_starts.append((time.monotonic(), est_kb))
return True
# Capability → Claude --allowedTools / OpenCode-Agent
_CLAUDE_TOOLS = {"full": "Write,Bash,Read,WebSearch,WebFetch", "files": "Read,Bash,Write",
"read": "Read", "none": None}
_OPENCODE_AGENTS = {"full": "full", "files": "files", "read": "readonly", "none": "text"}
def _use_text_api(provider: str, model: str, capabilities: str, on_line) -> bool:
"""Direkter API-Pfad nur für tool-lose, nicht-streamende MiniMax-Calls — spart den
OpenCode-Prozess (und dessen Stall-/Kaltstart-Probleme). PLANER_TEXT_API=0 = aus."""
return (capabilities == "none" and on_line is None
and PROVIDERS[provider]["cli"] == "opencode"
and model.split("/", 1)[0] in ("minimax", "minimax-kalt")
and bool(os.environ.get("MINIMAX_API_KEY"))
and os.getenv("PLANER_TEXT_API", "1") != "0")
def provider_available(provider: str) -> bool:
cfg = PROVIDERS.get(provider)
if not cfg:
return False
if shutil.which(cfg["cli"]) is None:
return False
env_key = cfg.get("env_key")
if env_key and not os.environ.get(env_key):
return False
return True
def _kill(process) -> None:
"""Agent + Kindprozesse über die Prozessgruppe killen (Kinder halten sonst die
Pipes offen und blockieren communicate())."""
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
try:
process.kill()
except ProcessLookupError:
pass
def kill_process(agent_key_prefix: str) -> None:
for key, process in list(_active_processes.items()):
if process.returncode is not None:
_active_processes.pop(key, None)
_active_started.pop(key, None)
continue
if key.startswith(agent_key_prefix):
log.debug("kill agent %s", key)
_kill(process)
# Event-Sink (CLI injiziert database.add_event). Fire-and-forget je fertigem Agenten.
on_event = None
async def run_agent(
agent_key: str,
prompt: str,
timeout: int,
role: str,
capabilities: str = "none",
scope: str | None = None,
on_line=None,
label: str = "",
) -> tuple[int, str, str]:
"""Ein LLM-Call der Rolle `role`. → (rc, stdout, stderr). Wirft AgentInfraError,
wenn die Infrastruktur nicht nutzbar ist (Rolle unkonfiguriert, Provider down,
leere Antwort ohne Session) — der Lauf soll dann pausieren, nicht raten."""
if os.getenv("PLANER_FAKE_AGENTS"): # Sekunden-Smoke: deterministisch statt LLM
import fake_agents
start = time.monotonic()
res = await fake_agents.respond(agent_key, prompt, capabilities)
if on_event is not None and scope is not None: # Telemetrie auch im Fake-Pfad testbar
await on_event(topic=scope, kind="agent", key=agent_key, label=label,
status="ok" if res[0] == 0 else "error",
dur_ms=int((time.monotonic() - start) * 1000), wait_ms=0,
meta={"provider": "fake", "model": "fake", "role": role, "rc": res[0]})
return res
if _scope_cancelled(agent_key):
return 1, "", "cancelled"
try:
provider, model = resolve_role(role) # NUR aus .env — kein Default, kein Fallback
except RuntimeError as e:
raise AgentInfraError(str(e)) from e
use_api = _use_text_api(provider, model, capabilities, on_line)
if not use_api and shutil.which(PROVIDERS[provider]["cli"]) is None:
raise AgentInfraError(f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider {provider})")
queued = time.monotonic()
async with _batch_gate(scope, _agent_priority(agent_key), api=use_api):
if _scope_cancelled(agent_key):
return 1, "", "cancelled"
wait_ms = int((time.monotonic() - queued) * 1000)
start = time.monotonic()
status = "error"
rc = None
err_tail = ""
api_tokens = None
try:
log.info("agent %s: %s %s (Rolle %s)", agent_key, provider, model, role)
if use_api:
rc_, out_, err_, api_tokens = await _run_text_api(agent_key, prompt, timeout, model, label=label)
res = (rc_, out_, err_)
elif PROVIDERS[provider]["cli"] == "opencode":
res = await _run_opencode(agent_key, prompt, timeout, model, capabilities,
on_line=on_line, label=label)
res = await _leere_ausgabe_triage(agent_key, res, on_line)
else:
res = await _run_claude_cli(agent_key, prompt, timeout, model, capabilities, label=label)
rc = res[0]
status = "ok" if rc == 0 else ("killed" if rc is not None and rc < 0 else "error")
if rc not in (0, None) and rc >= 0:
err_tail = (res[2] or res[1] or "").strip()[-300:]
return res
except asyncio.TimeoutError:
status = "timeout"
raise
except asyncio.CancelledError:
status = "cancelled"
raise
except AgentInfraError:
status = "infra"
raise
finally:
if on_event is not None and scope is not None: # nie fatal
try:
meta = {"provider": provider, "model": model, "role": role, "rc": rc}
if err_tail:
meta["stderr"] = err_tail
if api_tokens:
meta["tokens"] = api_tokens
elif PROVIDERS[provider]["cli"] == "opencode":
if (tok := await asyncio.to_thread(_session_tokens, agent_key)):
meta["tokens"] = tok
await on_event(topic=scope, kind="agent", key=agent_key, label=label,
status=status, dur_ms=int((time.monotonic() - start) * 1000),
wait_ms=wait_ms, meta=meta)
except Exception:
log.debug("on_event failed", exc_info=True)
async def _leere_ausgabe_triage(agent_key: str, res: tuple[int, str, str], on_line) -> tuple[int, str, str]:
"""Erkenntnis 13: leerer Text bei rc=0 ist nie ok. Keine OpenCode-Session zum Key →
der Call kam nie beim Provider an (Netz) → AgentInfraError (Lauf pausiert).
Session vorhanden → Thinking-Overrun/Stall → normaler Fehler (Engine-Retry)."""
rc, out, err = res
if rc != 0 or out.strip() or on_line is not None:
return res
tok = await asyncio.to_thread(_session_tokens, agent_key)
if tok is None:
raise AgentInfraError(f"agent {agent_key}: leere Ausgabe ohne OpenCode-Session — Provider/Netz nicht erreichbar")
return 1, "", f"leere Ausgabe (Session vorhanden, output-tokens={tok.get('output')}) — Overrun/Stall"
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int,
stagger: bool = False, on_line=None, label: str = "",
env: dict | None = None, est_kb: int = _RAM_PER_PROC_KB) -> tuple[int, str, str]:
start = time.monotonic()
async def spawn():
return await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True, # eigene Prozessgruppe → killpg trifft auch Kinder
env=env,
)
global _cli_running
if stagger:
if not await _ram_gate(agent_key, est_kb):
return 1, "", "cancelled"
await _opencode_slot() # Gate VOR dem Start-Slot: Zulassungswelle wird trotzdem gespaced
process = await spawn()
if stagger:
_cli_running += 1
# Kollisionssicheres Tracking: identische Keys bekommen ~n-Suffix (Prefix-Kill matcht weiter)
track_key = agent_key
n = 2
while track_key in _active_processes:
track_key = f"{agent_key}~{n}"
n += 1
_active_processes[track_key] = process
_active_started[track_key] = time.time()
_active_labels[track_key] = label
try:
try:
if on_line is not None:
out_chunks: list[str] = []
async def _pump():
async for raw in process.stdout:
s = raw.decode("utf-8", errors="replace")
out_chunks.append(s)
try:
on_line(s)
except Exception:
log.debug("on_line callback failed", exc_info=True)
await asyncio.wait_for(_pump(), timeout=timeout)
await process.wait()
stderr_b = await process.stderr.read()
stdout, stderr = "".join(out_chunks).encode("utf-8"), stderr_b
else:
stdout, stderr = await asyncio.wait_for(
process.communicate(input=stdin_data),
timeout=timeout,
)
except asyncio.TimeoutError:
_kill(process)
try:
await asyncio.wait_for(process.wait(), timeout=5)
except asyncio.TimeoutError:
pass
log.info("agent %s: timeout after %ds", agent_key, timeout)
raise
log.info("agent %s: exit %s after %.1fs (%d bytes stdout)",
agent_key, process.returncode, time.monotonic() - start, len(stdout))
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
finally:
if stagger:
_cli_running -= 1
if _active_processes.get(track_key) is process:
del _active_processes[track_key]
_active_started.pop(track_key, None)
_active_labels.pop(track_key, None)
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, model: str,
capabilities: str, label: str = "") -> tuple[int, str, str]:
cmd = ["claude", "-p", "--model", model]
tools = _CLAUDE_TOOLS.get(capabilities)
if tools:
cmd += ["--allowedTools", tools]
cmd += ["--dangerously-skip-permissions"]
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, stagger=True, label=label)
_OPENCODE_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db"
def _session_tokens(agent_key: str) -> dict | None:
"""Token-Zähler der neuesten OpenCode-Session mit title=agent_key (via run --title).
Best-effort, read-only, nie fatal — None wenn DB/Zeile fehlt. Auch Timeouts haben
Tokens verbraucht; die Verschwendung soll sichtbar bleiben."""
try:
con = sqlite3.connect(f"file:{_OPENCODE_DB}?mode=ro", uri=True, timeout=1)
try:
row = con.execute(
"SELECT tokens_input, tokens_output, tokens_reasoning,"
" tokens_cache_read, tokens_cache_write"
" FROM session WHERE title=? ORDER BY time_created DESC LIMIT 1",
(agent_key,)).fetchone()
finally:
con.close()
except Exception:
return None
if row is None:
return None
keys = ("input", "output", "reasoning", "cache_read", "cache_write")
return {k: int(v or 0) for k, v in zip(keys, row)}
async def _run_opencode(agent_key: str, prompt: str, timeout: int, model: str, capabilities: str,
on_line=None, label: str = "") -> tuple[int, str, str]:
# Prompt per Temp-Datei statt argv (ARG_MAX-Schutz bei großen Chunks)
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as f:
f.write(prompt)
prompt_path = Path(f.name)
# Die positionale Message MUSS vor -f stehen (-f ist ein Array-Flag).
cmd = [
"opencode", "run",
"Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.",
"-m", model,
"--agent", _OPENCODE_AGENTS.get(capabilities, "text"),
"--title", agent_key, # Token-Accounting: verbindet die OpenCode-Session mit unserem Event
"-f", str(prompt_path),
]
if on_line is not None:
cmd += ["--format", "json"]
env = {**os.environ, "OPENCODE_CONFIG": str(OPENCODE_CONFIG)}
try:
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True,
on_line=on_line, label=label, env=env)
return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
finally:
prompt_path.unlink(missing_ok=True)
# --- Direkter API-Pfad (MiniMax, Anthropic-Messages-Format) ---------------------------
_API_URL = "https://api.minimax.io/anthropic/v1/messages"
_API_VERSION = "2023-06-01"
_API_MAX_TOKENS = 32_000
def _api_model_opts(model: str) -> dict:
"""kalt-Route = niedrige Temperatur ohne Thinking; nativ = Endpoint-Defaults.
Bewusst prefix-basiert statt Modell-Tabelle — Modell-IDs gehören nicht in den Code."""
if model.startswith("minimax-kalt/"):
return {"temperature": 0.3, "thinking": {"type": "disabled"}}
return {}
async def _run_text_api(agent_key: str, prompt: str, timeout: int, model: str,
label: str = "") -> tuple[int, str, str, dict | None]:
"""→ (rc, text, err, tokens). Tokens direkt aus der Response-usage, auch bei rc!=0 —
Verschwendung bleibt sichtbar. Leere Antwort und max_tokens-Abbruch sind Fehler."""
body = {
"model": model.split("/", 1)[1], # ohne OpenCode-Provider-Prefix
"max_tokens": _API_MAX_TOKENS,
"messages": [{"role": "user", "content": prompt}],
**_api_model_opts(model),
}
headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": _API_VERSION}
track_key = agent_key
n = 2
while track_key in _active_api:
track_key = f"{agent_key}~{n}"
n += 1
_active_api.add(track_key)
_active_started[track_key] = time.time()
_active_labels[track_key] = label
start = time.monotonic()
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=30)) as client:
resp = await asyncio.wait_for(
client.post(_API_URL, json=body, headers=headers), timeout=timeout)
except httpx.TimeoutException:
raise asyncio.TimeoutError
except httpx.HTTPError as e:
return 1, "", f"{type(e).__name__}: {e}", None
finally:
_active_api.discard(track_key)
_active_started.pop(track_key, None)
_active_labels.pop(track_key, None)
log.info("agent %s: api done after %.1fs", agent_key, time.monotonic() - start)
if resp.status_code != 200:
return 1, "", f"HTTP {resp.status_code}: {resp.text[:300]}", None
data = resp.json()
# Nur Text-Blöcke zählen — Thinking-Blöcke (native Route) werden übersprungen.
text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
u = data.get("usage") or {}
tokens = {"input": int(u.get("input_tokens") or 0), "output": int(u.get("output_tokens") or 0),
"reasoning": 0, "cache_read": int(u.get("cache_read_input_tokens") or 0),
"cache_write": int(u.get("cache_creation_input_tokens") or 0)}
if not text.strip():
return 1, "", f"leere Antwort (stop_reason={data.get('stop_reason')})", tokens
err = "stop_reason=max_tokens (abgeschnitten)" if data.get("stop_reason") == "max_tokens" else ""
return 0, text, err, tokens
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
def _clean_opencode_output(text: str) -> str:
"""ANSI-Codes und den führenden Banner ("> agent · model") entfernen."""
text = _ANSI_RE.sub("", text)
lines = text.splitlines()
while lines and (not lines[0].strip() or lines[0].lstrip().startswith(">")):
lines.pop(0)
return "\n".join(lines).strip()

101
backend/artefakte.py Normal file
View File

@@ -0,0 +1,101 @@
"""Artefakt-Schicht: Scan-Fragmente je Quelldatei zusammensetzen und als
`.planer/`-Dateien ins Ziel-Repo schreiben. Merge arbeitet auf den ROHEN
Markdown-Blöcken (kein Grammatik-Roundtrip — nichts geht verloren).
"""
import re
from pathlib import Path
_ERGAENZE_RE = re.compile(r"^ergaenze-anker:\s*(\S+)\s+zu\s+(\S+)\s*$", re.M)
def datei_pfad(datei_rel: str) -> str:
"""Quelldatei → Pfad der Einheiten-Datei unter .planer/ (`/` → `__`).
Bekannte v1-Schwäche: Quellnamen mit `__` können kollidieren."""
return "einheiten/" + datei_rel.replace("/", "__") + ".md"
def _bloecke(text: str) -> dict[str, str]:
"""Fragment → {einheiten_id: roher_block}. Blöcke beginnen bei `## <id>`."""
out: dict[str, str] = {}
aktuelle_id = None
zeilen: list[str] = []
for zeile in text.splitlines():
if zeile.startswith("## ") and ("::" in zeile or re.fullmatch(r"## \S+\.\w+", zeile.strip())):
if aktuelle_id is not None:
out[aktuelle_id] = "\n".join(zeilen).rstrip()
aktuelle_id = zeile[3:].strip()
zeilen = [zeile]
elif aktuelle_id is not None and not _ERGAENZE_RE.match(zeile):
zeilen.append(zeile)
if aktuelle_id is not None:
out[aktuelle_id] = "\n".join(zeilen).rstrip()
return out
def merge_fragmente(fragmente: list[str]) -> str:
"""Fragmente EINER Quelldatei → Rumpf der Einheiten-Datei. Einheiten in Reihenfolge
des ersten Auftretens, Dubletten (gleiche ID) fallen weg; `ergaenze-anker: X zu Y`
aus Nachfass-Antworten wird als `anker: X` in den Block von Y eingefügt."""
bloecke: dict[str, str] = {}
ergaenzungen: list[tuple[str, str]] = []
for frag in fragmente:
ergaenzungen += _ERGAENZE_RE.findall(frag)
for eid, block in _bloecke(frag).items():
bloecke.setdefault(eid, block)
for anker_id, ziel_id in ergaenzungen:
block = bloecke.get(ziel_id)
if block is None:
continue # Ziel existiert nicht — Gate hat das bereits als Fehler gemeldet
kopf, _, rest = block.partition("\n")
if f"anker: {anker_id}" not in block:
bloecke[ziel_id] = f"{kopf}\nanker: {anker_id}\n{rest}"
return "\n\n".join(bloecke.values())
def einheiten_datei(datei_rel: str, rumpf: str, stand: str) -> str:
return f"# Einheiten: {datei_rel}\nstand: {stand}\n\n{rumpf}\n"
def lese_stand(text: str) -> str | None:
m = re.search(r"^stand:\s*(\S+)", text, re.M)
return m.group(1) if m else None
def alle_ids(text: str) -> set[str]:
"""Alle Einheiten-IDs + Zusatz-Anker einer Einheiten-Datei (für Referenz-Gates)."""
ids = {m.group(1) for m in re.finditer(r"^## (\S+)$", text, re.M)}
ids |= {m.group(1) for m in re.finditer(r"^anker:\s*(\S+)\s*$", text, re.M)}
return ids
def schreibe_planer(ziel_repo: Path, dateien: dict[str, str]) -> list[Path]:
"""Schreibt {relativer_pfad_unter_.planer: inhalt} ins Ziel-Repo. → geschriebene Pfade."""
geschrieben = []
for rel, inhalt in sorted(dateien.items()):
pfad = ziel_repo / ".planer" / rel
pfad.parent.mkdir(parents=True, exist_ok=True)
pfad.write_text(inhalt, encoding="utf-8")
geschrieben.append(pfad)
return geschrieben
def pruefung_geruest(wurzel: Path, projekt: str) -> str:
"""pruefung.md-Gerüst mit einfacher Erkennung (Makefile-Targets, pytest).
Fehlt etwas, steht dort explizit '(keins vorhanden)' — Gates degradieren sichtbar."""
tests = lint = start = "(keins vorhanden)"
makefile = wurzel / "Makefile"
if makefile.is_file():
targets = set(re.findall(r"^([\w-]+):", makefile.read_text(encoding="utf-8"), re.M))
if "test" in targets:
tests = "make test"
if "lint" in targets:
lint = "make lint"
for kandidat in ("dev", "run", "start"):
if kandidat in targets:
start = f"make {kandidat}"
break
elif list(wurzel.rglob("pytest.ini")) or list(wurzel.rglob("conftest.py")):
tests = "pytest"
return (f"# Prüfung: {projekt}\n"
f"tests: {tests}\nlint: {lint}\nstart: {start}\n"
f"hinweise:\n- (vom Scan erzeugtes Gerüst — bitte prüfen und ergänzen)\n")

96
backend/chunker.py Normal file
View File

@@ -0,0 +1,96 @@
"""Chunker: zerlegt ein Repo mechanisch (kein LLM) in kartengroße Scan-Aufträge.
Phase-0-Befund: dateigroße LLM-Calls dauern Minuten und große Dateien stallen —
deshalb Chunks von ~CHUNK_ZEILEN Zeilen, geschnitten ENTLANG von Top-Level-Symbolen
(nie mitten durch eine Funktion). v1 scannt nur Python-Dateien.
"""
import re
from pathlib import Path
CHUNK_ZEILEN = 250 # Zielgröße; ein einzelnes Riesen-Symbol darf größer sein
ENDUNGEN = {".py"} # v1: nur Python (Creator/Planer-Backends); Rest später
AUSSCHLUSS_DIRS = {".git", ".planer", "node_modules", "__pycache__", ".pytest_cache",
"dist", "build", "storage", "venv", ".venv", ".idea", ".vscode",
"tests"} # v1: Test-Code erzeugt in den Sichten nur Rauschen
AUSSCHLUSS_DATEIEN = re.compile(r"^\.env") # Secrets: nie lesen (Leitprinzip 8)
_SYMBOL_RE = re.compile(r"^(?:async\s+)?(?:def|class)\s+(\w+)")
def dateien(wurzel: Path) -> list[Path]:
"""Scanbare Quelldateien unter `wurzel`, Ausschlussliste angewandt, sortiert."""
out = []
for p in sorted(wurzel.rglob("*")):
if not p.is_file() or p.suffix not in ENDUNGEN:
continue
rel = p.relative_to(wurzel)
if any(teil in AUSSCHLUSS_DIRS for teil in rel.parts[:-1]):
continue
if AUSSCHLUSS_DATEIEN.match(p.name):
continue
out.append(p)
return out
def symbol_zeilen(zeilen: list[str]) -> list[tuple[int, str]]:
"""(zeilennummer_0basiert, symbolname) aller Top-Level-def/class, in Dateireihenfolge."""
return [(i, m.group(1)) for i, z in enumerate(zeilen) if (m := _SYMBOL_RE.match(z))]
def top_level_symbole(text: str) -> set[str]:
"""Alle Top-Level-Symbole (def/class/Konstanten) — Abdeckungs-Referenz für Gates."""
out = {name for _, name in symbol_zeilen(text.splitlines())}
out |= {m.group(1) for m in re.finditer(r"^(\w+)\s*[:=]", text, re.M)}
return out
def _block_start(zeilen: list[str], symbol_zeile: int) -> int:
"""Dekoratoren/Kommentare direkt über dem Symbol gehören zum Block."""
i = symbol_zeile
while i > 0 and (zeilen[i - 1].startswith("@") or zeilen[i - 1].lstrip().startswith("#")):
i -= 1
return i
def schneide(datei_rel: str, text: str) -> list[dict]:
"""Datei → Chunks entlang Top-Level-Symbolen, je ~CHUNK_ZEILEN Zeilen.
Chunk: {datei, start, ende (exklusiv, 0-basiert), symbole, text, groesse}.
Der Kopf vor dem ersten Symbol (Docstring/Imports/Konstanten) gehört zum ersten Chunk."""
zeilen = text.splitlines()
symbole = symbol_zeilen(zeilen)
if not symbole: # Datei ohne def/class (Config, __init__): ein Chunk, Datei-Anker
return [{"datei": datei_rel, "start": 0, "ende": len(zeilen),
"symbole": [], "text": text, "groesse": len(zeilen)}] if zeilen else []
# Schnittpunkte: Blockanfänge der Symbole; Kopf hängt am ersten Block.
grenzen = [( _block_start(zeilen, z), name) for z, name in symbole]
chunks: list[dict] = []
start = 0
aktuelle_symbole: list[str] = []
for idx, (block_start, name) in enumerate(grenzen):
naechster = grenzen[idx + 1][0] if idx + 1 < len(grenzen) else len(zeilen)
if aktuelle_symbole and (naechster - start) > CHUNK_ZEILEN:
# Symbol beginnt neuen Chunk, sonst würde der aktuelle zu groß
chunks.append(_chunk(datei_rel, zeilen, start, block_start, aktuelle_symbole))
start = block_start
aktuelle_symbole = []
aktuelle_symbole.append(name)
chunks.append(_chunk(datei_rel, zeilen, start, len(zeilen), aktuelle_symbole))
return chunks
def _chunk(datei: str, zeilen: list[str], start: int, ende: int, symbole: list[str]) -> dict:
return {"datei": datei, "start": start, "ende": ende, "symbole": list(symbole),
"text": "\n".join(zeilen[start:ende]), "groesse": ende - start}
def chunks_fuer_repo(wurzel: Path, nur_dateien: set[str] | None = None) -> list[dict]:
"""Alle Chunks des Repos; `nur_dateien` (relative Pfade) schränkt ein (Re-Scan)."""
out = []
for p in dateien(wurzel):
rel = str(p.relative_to(wurzel))
if nur_dateien is not None and rel not in nur_dateien:
continue
out.extend(schneide(rel, p.read_text(encoding="utf-8", errors="replace")))
return out

119
backend/cli.py Normal file
View File

@@ -0,0 +1,119 @@
"""CLI des Planers (Phase 1, kein Server):
python -m backend scan <pfad> Erst-Scan eines Projekt-Repos
python -m backend rescan <pfad> Re-Scan über git-Diff gegen die Anker-Stände
python -m backend status Karten-Stände aller bekannten Läufe
"""
import argparse
import asyncio
import logging
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) # `python -m backend` von der Wurzel
import agents # noqa: E402
import database as db
import scan_dienst
log = logging.getLogger("planer")
def _abbruch(meldung: str) -> "NoReturn": # noqa: F821
print(f"ABBRUCH: {meldung}", file=sys.stderr)
sys.exit(1)
def _vorflug(wurzel: Path) -> str:
try:
return scan_dienst.vorflug(wurzel)
except scan_dienst.VorflugFehler as e:
_abbruch(str(e))
def _fortschritt(text: str) -> None:
print(f" {text}", flush=True)
def _bericht(ergebnis: dict) -> None:
print(f"Status: {ergebnis['status']}")
if ergebnis.get("infra_error"):
print(f" Infra: {ergebnis['infra_error']}")
for pfad in ergebnis.get("geschrieben", []):
print(f" geschrieben: .planer/{pfad}")
for card in ergebnis.get("dead", []):
print(f" DEAD: {card} (requeue über erneuten Lauf oder DB)")
if ergebnis["status"] == "ok":
print("Artefakte aktualisiert — jetzt committen (Code + .planer/ atomar).")
async def _scan(wurzel: Path, fortsetzen: bool = False) -> int:
stand = _vorflug(wurzel)
await db.init_db()
agents.on_event = db.add_event
import scan_board
ergebnis = await scan_board.run_scan(wurzel, wurzel.name, stand, set_p=_fortschritt,
fortsetzen=fortsetzen)
_bericht(ergebnis)
return 0 if ergebnis["status"] == "ok" else 1
async def _rescan(wurzel: Path) -> int:
stand_neu = _vorflug(wurzel)
await db.init_db()
agents.on_event = db.add_event
try:
ergebnis = await scan_dienst.rescan_lauf(wurzel, wurzel.name, stand_neu,
set_p=_fortschritt)
except scan_dienst.VorflugFehler as e:
_abbruch(str(e))
if ergebnis["status"] == "unveraendert":
print("Keine Änderungen seit dem letzten Scan — nichts zu tun.")
return 0
_bericht(ergebnis)
return 0 if ergebnis["status"] == "ok" else 1
async def _status() -> None:
await db.init_db()
verbindung = await db.get_db()
cursor = await verbindung.execute("SELECT DISTINCT topic FROM kanban_cards ORDER BY topic")
topics = [r[0] for r in await cursor.fetchall()]
if not topics:
print("Keine Läufe in der Planer-DB.")
return
for topic in topics:
counts = await db.kanban_stage_counts(topic)
dead = await db.kanban_dead(topic)
print(f"{topic}: " + ", ".join(f"{s}={n}" for b in counts.values() for s, n in b.items())
+ (f" — DEAD: {[c['card_id'] for c in dead]}" if dead else ""))
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
# Hänger-Diagnose (offener Befund: Prozess lebt nach Board-Ende weiter):
# `kill -USR1 <pid>` schreibt alle Python-Stacks nach stderr.
import faulthandler
import signal as _signal
faulthandler.register(_signal.SIGUSR1)
parser = argparse.ArgumentParser(prog="planer")
sub = parser.add_subparsers(dest="kommando", required=True)
for name in ("scan", "rescan"):
p = sub.add_parser(name)
p.add_argument("pfad", type=Path)
if name == "scan":
p.add_argument("--fortsetzen", action="store_true",
help="abgebrochenen Lauf fortsetzen (nur tote Chunks neu)")
sub.add_parser("status")
args = parser.parse_args()
if args.kommando == "scan":
sys.exit(asyncio.run(_scan(args.pfad.resolve(), fortsetzen=args.fortsetzen)))
elif args.kommando == "rescan":
sys.exit(asyncio.run(_rescan(args.pfad.resolve())))
else:
asyncio.run(_status())
if __name__ == "__main__":
main()

72
backend/config.py Normal file
View File

@@ -0,0 +1,72 @@
"""Konfiguration: Provider-Stacks, Rollen-Auflösung, Engine-Konstanten.
Rollen-Modelle stehen AUSSCHLIESSLICH in der .env (ROLLE_<NAME>=provider:modell) —
nirgendwo im Code ein Default oder Fallback. Unkonfigurierte Rolle = harter Stopp.
"""
import os
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
STORAGE_DIR = PROJECT_ROOT / "storage"
DB_PATH = STORAGE_DIR / "planer.db"
LAEUFE_DIR = STORAGE_DIR / "laeufe"
OPENCODE_CONFIG = PROJECT_ROOT / "dev-ops" / "opencode-slim.json"
PROJEKTE_DATEI = PROJECT_ROOT / "projekte.txt"
def _load_env(path: Path) -> None:
"""Mini .env-Loader (keine Dependency): KEY=VALUE-Zeilen. Die DATEI gewinnt über
vererbte Env (Creator-Lehre: vererbte Werte pinnen sonst still veraltete Stände)."""
try:
text = path.read_text(encoding="utf-8")
except OSError:
return
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip('"').strip("'")
if key:
os.environ[key] = value
_load_env(PROJECT_ROOT / ".env")
# --- Provider-Stacks: nur Aufrufweg + Auth, KEINE Modelle -------------------------
PROVIDERS = {
"claude": {"cli": "claude", "env_key": None}, # Auth via OAuth/~/.claude (Abo)
"minimax": {"cli": "opencode", "env_key": "MINIMAX_API_KEY"},
}
# LLM-Rollen des Scan-Boards. Jede braucht ROLLE_<NAME> in der .env.
ROLLEN = ("schneiden", "nachfass", "sichten", "judge")
def resolve_role(role: str) -> tuple[str, str]:
"""→ (provider, modell) aus ROLLE_<NAME>=provider:modell. Fehlt die Konfiguration
oder ist sie unbrauchbar: RuntimeError — NIE ein stiller Default (Leitprinzip 2)."""
env_name = f"ROLLE_{role.upper()}"
wert = os.getenv(env_name, "").strip()
if not wert:
raise RuntimeError(
f"Rolle '{role}' hat kein Modell konfiguriert — {env_name}=provider:modell in .env setzen")
provider, _, modell = wert.partition(":")
if provider not in PROVIDERS:
raise RuntimeError(
f"{env_name}: unbekannter Provider '{provider}' (bekannt: {', '.join(PROVIDERS)})")
if not modell:
raise RuntimeError(f"{env_name}: Modell fehlt (Format provider:modell)")
return provider, modell
# --- Engine-Konstanten (Kanban + Agenten) -----------------------------------------
KANBAN_BATCH = int(os.getenv("KANBAN_BATCH", "4")) # Karten je Paket
MAX_CARD_RETRIES = int(os.getenv("MAX_CARD_RETRIES", "3")) # dann dead-letter
RETRY_BACKOFF = float(os.getenv("RETRY_BACKOFF", "30")) # Basis-Sekunden, exponentiell
MAX_CONCURRENT_AGENTS = int(os.getenv("MAX_CONCURRENT_AGENTS", "6"))
MAX_CONCURRENT_AGENTS_PER_TOPIC = int(os.getenv("MAX_CONCURRENT_AGENTS_PER_TOPIC", "6"))
MAX_CONCURRENT_API_AGENTS = int(os.getenv("MAX_CONCURRENT_API_AGENTS", "12"))
RAM_MIN_FREE_PCT = int(os.getenv("RAM_MIN_FREE_PCT", "20")) # 0 = RAM-Gate aus
AGENT_TIMEOUT = int(os.getenv("AGENT_TIMEOUT", "360")) # Sekunden je LLM-Call

327
backend/database.py Normal file
View File

@@ -0,0 +1,327 @@
"""Planer-DB: NUR Lauf-Zustand (Kanban-Karten) + Telemetrie (Events) — wegwerfbar.
Die Wahrheit über Projekte liegt als Dateien in deren Repos (.planer/), nie hier.
Gekürzte Übernahme aus creator/backend/database.py: eine geteilte aiosqlite-Connection,
Schreib-Serialisierung über einen loop-gebundenen Lock (_tx), WAL + busy_timeout.
"""
import asyncio
import json
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
import aiosqlite
import config
CREATE_KANBAN_CARDS = """
CREATE TABLE IF NOT EXISTS kanban_cards (
topic TEXT NOT NULL,
board TEXT NOT NULL,
card_id TEXT NOT NULL,
kind TEXT NOT NULL,
stage TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '{}',
retries INTEGER NOT NULL DEFAULT 0,
not_before TEXT NOT NULL DEFAULT '',
last_error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (topic, board, card_id)
)
"""
CREATE_KANBAN_PULL_INDEX = """
CREATE INDEX IF NOT EXISTS idx_kanban_pull ON kanban_cards(topic, board, stage, not_before, updated_at)
"""
CREATE_EVENTS = """
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
topic TEXT NOT NULL,
ts TEXT NOT NULL,
kind TEXT NOT NULL,
key TEXT NOT NULL DEFAULT '',
label TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
dur_ms INTEGER,
wait_ms INTEGER,
meta TEXT NOT NULL DEFAULT '{}',
run_id TEXT NOT NULL DEFAULT ''
)
"""
CREATE_EVENTS_INDEX = "CREATE INDEX IF NOT EXISTS idx_events ON events(topic, ts)"
CREATE_EVENTS_RUN_INDEX = "CREATE INDEX IF NOT EXISTS idx_events_run ON events(topic, run_id)"
_db: aiosqlite.Connection | None = None
async def get_db() -> aiosqlite.Connection:
global _db
if _db is None:
config.DB_PATH.parent.mkdir(parents=True, exist_ok=True)
_db = await aiosqlite.connect(config.DB_PATH)
_db.row_factory = None
return _db
async def close_db() -> None:
global _db
if _db is not None:
await _db.close()
_db = None
_write_lock: asyncio.Lock | None = None
_write_lock_loop: asyncio.AbstractEventLoop | None = None
def _get_write_lock() -> asyncio.Lock:
"""Lock lazy an den AKTUELLEN Loop gebunden — ein modul-globaler Lock bricht,
wenn Tests jeden Fall in einem frischen Loop fahren."""
global _write_lock, _write_lock_loop
loop = asyncio.get_running_loop()
if _write_lock is None or _write_lock_loop is not loop:
_write_lock = asyncio.Lock()
_write_lock_loop = loop
return _write_lock
@asynccontextmanager
async def _tx():
"""Atomarer Schreibblock: Lock + commit. Nur in Blatt-Funktionen verwenden,
die selbst committen — nie in Funktionen, die andere schreibende rufen."""
async with _get_write_lock():
db = await get_db()
try:
yield db
await db.commit()
except BaseException:
try:
await db.rollback()
except Exception:
pass
raise
async def init_db() -> None:
db = await get_db()
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA synchronous=NORMAL")
await db.execute("PRAGMA busy_timeout=5000")
await db.execute(CREATE_KANBAN_CARDS)
await db.execute(CREATE_KANBAN_PULL_INDEX)
await db.execute(CREATE_EVENTS)
await db.execute(CREATE_EVENTS_INDEX)
await db.execute(CREATE_EVENTS_RUN_INDEX)
await db.commit()
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _now_plus(seconds: float) -> str:
return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat()
def _row_to_dict(row, cursor) -> dict:
return {d[0]: row[i] for i, d in enumerate(cursor.description)}
def _card(row, cursor) -> dict:
"""Row → dict mit dekodiertem Payload-JSON (bleibt unter 'payload')."""
d = _row_to_dict(row, cursor)
try:
d["payload"] = json.loads(d.get("payload") or "{}")
except (TypeError, ValueError):
d["payload"] = {}
return d
# --- Karten ------------------------------------------------------------------------
async def kanban_pull(topic: str, board: str, stage: str, limit: int) -> list[dict]:
"""`limit` bereite Karten der Stufe (Backoff abgelaufen). LPT: Karten mit
`groesse`-Payload zuerst (größter Chunk startet früh), sonst FIFO."""
db = await get_db()
cursor = await db.execute(
"""SELECT * FROM kanban_cards WHERE topic = ? AND board = ? AND stage = ? AND not_before <= ?
ORDER BY COALESCE(json_extract(payload, '$.groesse'), 0) DESC, updated_at LIMIT ?""",
(topic, board, stage, _now(), limit))
return [_card(row, cursor) for row in await cursor.fetchall()]
async def kanban_count(topic: str, stages, board: str | None = None) -> int:
"""Karten in einer der `stages` (str oder Liste) — Queue-Länge/Quieszenz.
Karten im Backoff zählen mit: ihre Arbeit ist nicht getan."""
if isinstance(stages, str):
stages = [stages]
if not stages:
return 0
db = await get_db()
ph = ",".join("?" * len(stages))
sql = f"SELECT count(*) FROM kanban_cards WHERE topic = ? AND stage IN ({ph})"
args: tuple = (topic, *stages)
if board:
sql += " AND board = ?"
args += (board,)
cursor = await db.execute(sql, args)
return (await cursor.fetchone())[0]
async def kanban_advance(topic: str, board: str, card_id: str, stage: str) -> None:
await kanban_advance_many(topic, board, [(card_id, stage)])
async def kanban_advance_many(topic: str, board: str, moves: list[tuple[str, str]]) -> None:
"""Batch-Stufenwechsel in EINEM Commit (der Flow advanced ganze Pakete)."""
if not moves:
return
async with _tx() as db:
now = _now()
await db.executemany(
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
WHERE topic = ? AND board = ? AND card_id = ?""",
[(stage, now, topic, board, cid) for cid, stage in moves])
await _add_events_many(db, topic, [("stage", f"{board}:{cid}", "", stage) for cid, stage in moves])
async def kanban_upsert_card(topic: str, board: str, card_id: str, kind: str, stage: str,
payload: dict | None = None) -> None:
"""Insert oder Overwrite (stabile IDs upserted, nie dupliziert).
payload=None behält bei Konflikt das bestehende Payload."""
async with _tx() as db:
await db.execute(
"""INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, updated_at)
VALUES (?, ?, ?, ?, ?, COALESCE(?, '{}'), ?)
ON CONFLICT(topic, board, card_id) DO UPDATE SET
kind = excluded.kind, stage = excluded.stage,
payload = COALESCE(?, kanban_cards.payload),
retries = 0, not_before = '', last_error = NULL, updated_at = excluded.updated_at""",
(topic, board, card_id, kind, stage,
json.dumps(payload, ensure_ascii=False) if payload is not None else None, _now(),
json.dumps(payload, ensure_ascii=False) if payload is not None else None))
async def kanban_set_payload(topic: str, board: str, card_id: str, payload: dict) -> None:
async with _tx() as db:
await db.execute(
"UPDATE kanban_cards SET payload = ?, updated_at = ? WHERE topic = ? AND board = ? AND card_id = ?",
(json.dumps(payload, ensure_ascii=False), _now(), topic, board, card_id))
async def kanban_get_card(topic: str, board: str, card_id: str) -> dict | None:
db = await get_db()
cursor = await db.execute(
"SELECT * FROM kanban_cards WHERE topic = ? AND board = ? AND card_id = ?", (topic, board, card_id))
row = await cursor.fetchone()
return _card(row, cursor) if row else None
async def kanban_cards(topic: str, board: str | None = None, stage: str | None = None,
kind: str | None = None) -> list[dict]:
db = await get_db()
sql, args = "SELECT * FROM kanban_cards WHERE topic = ?", [topic]
for col, val in (("board", board), ("stage", stage), ("kind", kind)):
if val is not None:
sql += f" AND {col} = ?"
args.append(val)
cursor = await db.execute(sql, args)
return [_card(row, cursor) for row in await cursor.fetchall()]
async def kanban_fail_card(topic: str, board: str, card_id: str, error: str,
max_retries: int, backoff_base: float = 30.0) -> bool:
"""Verarbeitungsfehler: retries++, exponentieller Backoff (not_before), nach
`max_retries` → stage 'dead' (Dead-Letter, requeue-bar). → True wenn dead."""
async with _tx() as db:
cursor = await db.execute(
"SELECT retries FROM kanban_cards WHERE topic = ? AND board = ? AND card_id = ?",
(topic, board, card_id))
row = await cursor.fetchone()
if row is None:
return False
retries = (row[0] or 0) + 1
dead = retries >= max_retries
if dead:
await db.execute(
"""UPDATE kanban_cards SET stage = 'dead', retries = ?, not_before = '',
last_error = ?, updated_at = ? WHERE topic = ? AND board = ? AND card_id = ?""",
(retries, error[:500], _now(), topic, board, card_id))
else:
await db.execute(
"""UPDATE kanban_cards SET retries = ?, not_before = ?, last_error = ?, updated_at = ?
WHERE topic = ? AND board = ? AND card_id = ?""",
(retries, _now_plus(backoff_base * (2 ** (retries - 1))), error[:500], _now(),
topic, board, card_id))
await db.execute(
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta, run_id) VALUES (?,?,?,?,?,?,?,?,?,?)",
(topic, _now(), "fail", f"{board}:{card_id}", "", "dead" if dead else f"retry{retries}",
None, None, json.dumps({"error": error[:200]}, ensure_ascii=False), _current_run.get(topic, "")))
return dead
async def kanban_dead(topic: str) -> list[dict]:
return await kanban_cards(topic, stage="dead")
async def kanban_requeue_dead(topic: str, board: str, stage: str) -> int:
"""dead → `stage` (frische Retries). → Anzahl requeueter Karten."""
async with _tx() as db:
cursor = await db.execute(
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
WHERE topic = ? AND board = ? AND stage = 'dead'""",
(stage, _now(), topic, board))
await _add_events_many(db, topic, [("reset", f"{board}:requeue-dead", "", stage)])
return cursor.rowcount
async def kanban_stage_counts(topic: str) -> dict[str, dict[str, int]]:
"""{board: {stage: count}} — das Live-Board."""
db = await get_db()
cursor = await db.execute(
"SELECT board, stage, count(*) FROM kanban_cards WHERE topic = ? GROUP BY board, stage", (topic,))
out: dict[str, dict[str, int]] = {}
for board, stage, n in await cursor.fetchall():
out.setdefault(board, {})[stage] = n
return out
async def letzter_agent_ts(topic: str) -> str | None:
"""Zeitstempel des letzten Agent-Events (auch aus CLI-Läufen — gleiche DB)."""
db = await get_db()
cursor = await db.execute(
"SELECT MAX(ts) FROM events WHERE topic = ? AND kind = 'agent'", (topic,))
return (await cursor.fetchone())[0]
# --- Telemetrie ---------------------------------------------------------------------
_current_run: dict[str, str] = {}
def set_current_run(topic: str, run_id: str | None) -> None:
if run_id:
_current_run[topic] = run_id
else:
_current_run.pop(topic, None)
async def add_event(topic: str, kind: str, key: str = "", label: str = "", status: str = "",
dur_ms: int | None = None, wait_ms: int | None = None, meta: dict | None = None) -> None:
"""Eine Telemetrie-Zeile, eigener Commit. Aufrufer behandeln das fire-and-forget."""
async with _tx() as db:
await db.execute(
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta, run_id) VALUES (?,?,?,?,?,?,?,?,?,?)",
(topic, _now(), kind, key, label, status, dur_ms, wait_ms,
json.dumps(meta or {}, ensure_ascii=False), _current_run.get(topic, "")))
async def _add_events_many(db, topic: str, rows: list[tuple]) -> None:
"""Batch-Insert OHNE Commit — muss in der Transaktion des Aufrufers laufen."""
now = _now()
rid = _current_run.get(topic, "")
await db.executemany(
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta, run_id) VALUES (?,?,?,?,?,?,?,?,?,?)",
[(topic, now, kind, key, label, status, None, None, "{}", rid)
for kind, key, label, status in rows])

155
backend/fake_agents.py Normal file
View File

@@ -0,0 +1,155 @@
"""Fake-Welt: deterministische Agenten-Antworten ohne LLM (PLANER_FAKE_AGENTS=1).
Creator-Muster: eine Welt beantwortet Calls anhand von agent_key-Mustern und kann
Störungen einspielen ({muster, modus, mal}) — damit testet die Fake-E2E den kompletten
Pfad inkl. Retry/Dead-Letter/Pause in Sekunden.
"""
import re
# Modul-globale Welt für den ENV-Weg; Tests setzen ihre eigene via set_welt().
_WELT = None
def set_welt(welt) -> None:
global _WELT
_WELT = welt
class Welt:
def __init__(self):
self.calls: list[str] = [] # Auditspur aller agent_keys
self.stoerungen: list[dict] = [] # {muster, modus: fehler|garbage|infra, mal, [antwort]}
self.antworten: list[tuple[str, object]] = [] # (regex auf agent_key, fn(prompt) -> str)
def antwort_auf(self, muster: str, fn) -> None:
self.antworten.append((muster, fn))
def _stoerung(self, agent_key: str) -> tuple[int, str, str] | None:
for s in self.stoerungen:
if s.get("mal", 0) > 0 and re.search(s["muster"], agent_key):
s["mal"] -= 1
modus = s.get("modus", "fehler")
if modus == "fehler":
return 1, "", "fake-stoerung"
if modus == "garbage":
return 0, "### kaputtes {fragment", ""
if modus == "infra":
from agents import AgentInfraError
raise AgentInfraError("fake-infra-stoerung")
if modus == "antwort":
return 0, s.get("antwort", ""), ""
return None
async def respond(self, agent_key: str, prompt: str, capabilities: str) -> tuple[int, str, str]:
self.calls.append(agent_key)
gestoert = self._stoerung(agent_key)
if gestoert is not None:
return gestoert
for muster, fn in self.antworten:
if re.search(muster, agent_key):
return 0, fn(prompt), ""
return 1, "", f"fake: keine Antwort für {agent_key}"
async def respond(agent_key: str, prompt: str, capabilities: str) -> tuple[int, str, str]:
global _WELT
if _WELT is None:
_WELT = Welt()
standard_antworten(_WELT)
return await _WELT.respond(agent_key, prompt, capabilities)
# --- Standard-Antworten: mechanisch erzeugte Ausgaben, die die ECHTEN Gates bestehen ---
def _quelle_aus(prompt: str) -> tuple[str, str]:
"""(datei, quelltext) aus einem Scan-Prompt (Template-Struktur)."""
m = re.search(r"Ausschnitt aus `(.+?)`\n\n```python\n(.*?)\n?```", prompt, re.S)
return (m.group(1), m.group(2)) if m else ("", "")
def _symbol_liste(prompt: str, kopf: str) -> list[str]:
"""`- symbol`-Liste direkt nach der Zeile mit `kopf` (Leerzeilen dazwischen erlaubt)."""
idx = prompt.find(kopf)
if idx < 0:
return []
out: list[str] = []
for zeile in prompt[idx:].splitlines()[1:]:
z = zeile.strip()
if z.startswith("- "):
if "(keine)" not in z:
out.append(z[2:].strip())
elif z:
break # erste andere Zeile beendet die Liste
return out
def _einheit_fuer(datei: str, symbol: str, quelle: str) -> str:
"""Eine gültige Einheit: Beleg = die wörtliche Definitionszeile aus der Quelle."""
beleg = next((z.strip() for z in quelle.splitlines()
if re.match(rf"\s*(?:async\s+)?(?:def|class)\s+{re.escape(symbol)}\b", z)
or re.match(rf"{re.escape(symbol)}\s*[:=]", z)), "")
beleg = beleg[:60].replace('"', "'")
return (f"## {datei}::{symbol}\n"
f"beschreibung: Fake-Verantwortung von {symbol}.\n"
f"input: Fake-Input\noutput: Fake-Output\n"
f"entscheidungen:\n- Fake-Entscheidung zu {symbol}.\n beleg: \"{beleg}\"\n"
f"kanten:\n")
def _fake_schneiden(prompt: str, lasse_aus: set[str]) -> str:
datei, quelle = _quelle_aus(prompt)
symbole = [s for s in _symbol_liste(prompt, "MÜSSEN alle abgedeckt sein") if s not in lasse_aus]
if not symbole: # Datei ohne def/class: erste Konstante als Einheit
m = re.search(r"^(\w+)\s*[:=]", quelle, re.M)
symbole = [m.group(1)] if m else []
return "\n".join(_einheit_fuer(datei, s, quelle) for s in symbole)
def _fake_nachfass(prompt: str) -> str:
datei, quelle = _quelle_aus(prompt)
fehlend = _symbol_liste(prompt, "zugeordnet sind:")
return "\n".join(_einheit_fuer(datei, s, quelle) for s in fehlend)
def _ids_aus_uebersicht(prompt: str) -> list[str]:
return re.findall(r"^- (\S+::\S+):", prompt, re.M)
def _projekt_aus(prompt: str) -> str:
m = re.search(r"Einheiten von `(.+?)`", prompt)
return m.group(1) if m else "projekt"
def _fake_features(prompt: str) -> str:
ids = _ids_aus_uebersicht(prompt)
p = _projekt_aus(prompt)
return (f"# Kern: {p}\nFake-Kern in zwei Sätzen. Mehr Fake.\n\n"
f"tragende-einheiten: {ids[0]}\n\n"
f"# Bereich: fake-bereich\nbeschreibung: Ein Fake-Bereich.\n\n"
f"## Feature: Fake-Feature [kern]\nbeschreibung: Kann alles Fake.\n\n"
f"### Kann fake arbeiten\neinheiten: {', '.join(ids)}\n")
def _fake_architektur(prompt: str) -> str:
ids = _ids_aus_uebersicht(prompt)
p = _projekt_aus(prompt)
return (f"# Architektur: {p}\n\n## Komponente: fake-komponente\n"
f"beschreibung: Trägt alles.\neinheiten: {', '.join(ids)}\nbeziehungen:\n")
def _fake_flows(prompt: str) -> str:
ids = _ids_aus_uebersicht(prompt)
p = _projekt_aus(prompt)
schritte = "\n".join(f"{i+1}. Fake-Schritt {i+1}{eid}" for i, eid in enumerate(ids[:3]))
return f"# Flows: {p}\n\n## Flow: fake-ablauf\n{schritte}\n"
def standard_antworten(welt: Welt, lasse_aus: set[str] | None = None) -> None:
"""Registriert gültige Fake-Antworten für alle Scan-Rollen. `lasse_aus`: Symbole,
die der Erst-Scan absichtlich auslässt (übt den Nachfass-Pfad)."""
aus = lasse_aus or set()
welt.antwort_auf(r"-schneiden-", lambda pr: _fake_schneiden(pr, aus))
welt.antwort_auf(r"-nachfass-", _fake_nachfass)
welt.antwort_auf(r"-sichten-sicht-features", _fake_features)
welt.antwort_auf(r"-sichten-sicht-architektur", _fake_architektur)
welt.antwort_auf(r"-sichten-sicht-flows", _fake_flows)

310
backend/gates.py Normal file
View File

@@ -0,0 +1,310 @@
"""Mechanische Gates (tokenfrei): prüfen LLM-Scan-Ausgaben gegen den echten Code.
Port von phase0/pruefe.py als Bibliothek. Phase-0-validiert: fängt erfundene Anker,
umgeschriebene Zitate (auch Minimal-Umformungen) und Format-Drift; Escaping-Rauschen
der Modelle wird vor dem Vergleich normalisiert.
"""
import difflib
import re
from pathlib import Path
KANTEN_TYPEN = {"ruft-auf", "nutzt", "wird-genutzt-von", "löst-aus", "wird-ausgelöst-von"}
SCHLUESSEL = ("beschreibung", "input", "output") # Pflichtfelder je Einheit
def entschaerfe(s: str) -> str:
"""Escaping-Rauschen normalisieren: \\"\" und \\\\\\ (Erkenntnis 11)."""
return s.replace('\\"', '"').replace("\\\\", "\\")
def _beleg_text(rest: str) -> str:
"""Beleg-Inhalt aus dem Zeilenrest: äußere Anführungszeichen abstreifen, Rest wörtlich.
Kein Quote-Delimiter-Parsing — Belege enthalten selbst Anführungszeichen/Regex
(Abnahme-Befund 17), die Zeile gehört ab `beleg:` komplett dem Zitat."""
rest = rest.strip()
if len(rest) >= 2 and rest.startswith('"') and rest.endswith('"'):
rest = rest[1:-1]
elif rest.startswith('"'):
rest = rest[1:]
return entschaerfe(rest)
def parse_einheiten(text: str) -> tuple[list[dict], list[str], list[str]]:
"""Einheiten-Markdown → (einheiten, zusatz_anker, struktur_fehler).
Einheit: {id, zeile, felder: {beschreibung/input/output}, belege: [(nr, zitat)],
kanten: [(nr, typ, ziel)], anker: [ids]}. Streng schreiben, tolerant parsen."""
einheiten: list[dict] = []
zusatz_anker: list[str] = []
fehler: list[str] = []
aktuelle = None
modus = None
for nr, zeile in enumerate(text.splitlines(), 1):
# Einheiten-Kopf: `## datei::symbol` oder Datei-Einheit `## pfad.py` (Skripte
# ohne def/class — Befund 18)
if zeile.startswith("## ") and ("::" in zeile or re.fullmatch(r"## \S+\.\w+", zeile.strip())):
aktuelle = {"id": zeile[3:].strip(), "zeile": nr, "felder": {}, "belege": [],
"kanten": [], "anker": []}
einheiten.append(aktuelle)
modus = None
elif zeile.startswith(("anker:", "ergaenze-anker:")):
aid = zeile.split(":", 1)[1].strip().split(" zu ")[0].strip()
zusatz_anker.append(aid)
if aktuelle is not None and zeile.startswith("anker:"):
aktuelle["anker"].append(aid)
elif aktuelle is None:
continue
elif m := re.match(r"(beschreibung|input|output):(.*)$", zeile):
aktuelle["felder"][m.group(1)] = m.group(2).strip()
modus = m.group(1)
elif zeile.strip() in ("entscheidungen:", "fakten:"):
modus = "punkte"
elif zeile.strip() == "kanten:":
modus = "kanten"
elif m := re.match(r"\s+beleg:\s*(.+)$", zeile):
aktuelle["belege"].append((nr, _beleg_text(m.group(1))))
elif modus == "punkte" and (m := re.search(r"\bbeleg:\s*(.+)$", zeile)):
aktuelle["belege"].append((nr, _beleg_text(m.group(1)))) # tolerant: beleg inline
elif modus == "kanten" and (m := re.match(r"- ([\wäöü-]+):\s*(\S+)\s*$", zeile)):
aktuelle["kanten"].append((nr, m.group(1), m.group(2)))
elif modus == "kanten" and zeile.startswith("- "):
fehler.append(f"Z{nr}: Kante unlesbar (Zusatztext?): {zeile.strip()!r}")
return einheiten, zusatz_anker, fehler
def _symbol_in_datei(symbol: str, quelle: str, top_level_nur: bool) -> bool:
if "." in symbol: # qualifizierte Methode `Klasse.methode` (Abnahme-Befund)
klasse, _, methode = symbol.partition(".")
return (_symbol_in_datei(klasse, quelle, top_level_nur)
and re.search(rf"def\s+{re.escape(methode)}\b", quelle) is not None)
if top_level_nur:
muster = rf"^(?:async\s+)?(?:def|class)\s+{re.escape(symbol)}\b|^{re.escape(symbol)}\s*[:=]"
else: # Zusatz-Anker dürfen verschachtelt sein (Innenfunktionen, Erkenntnis M3-Lauf)
muster = rf"(?:def|class)\s+{re.escape(symbol)}\b|^{re.escape(symbol)}\s*[:=]"
return re.search(muster, quelle, re.M) is not None
def pruefe_fragment(text: str, wurzel: Path, chunk_datei: str,
quelle_cache: dict | None = None) -> tuple[list[str], list[str], list[dict]]:
"""Gates für EIN Scan-Fragment: Struktur, Anker-Existenz, Zitat-Treue, Pflichtfelder,
Kanten-Grammatik. → (fehler, warnungen, einheiten). Kanten-ZIELE in fremden Dateien
werden nur auf Datei-Existenz geprüft (das Symbol prüft der Scan der Zieldatei)."""
cache = quelle_cache if quelle_cache is not None else {}
def quelle(datei: str) -> str | None:
if datei not in cache:
p = wurzel / datei
cache[datei] = p.read_text(encoding="utf-8", errors="replace") if p.is_file() else None
return cache[datei]
einheiten, zusatz_anker, fehler = parse_einheiten(text)
warnungen: list[str] = []
if not einheiten:
fehler.append("keine Einheit geparst (Format-Drift oder leere Antwort)")
def pruefe_id(eid: str, kontext: str, top_level: bool):
datei, _, symbol = eid.partition("::")
q = quelle(datei)
if q is None:
fehler.append(f"{kontext}: Datei fehlt: {datei}")
elif symbol and not _symbol_in_datei(symbol, q, top_level):
fehler.append(f"{kontext}: Symbol '{symbol}' nicht in {datei}")
for e in einheiten:
kontext = e["id"]
if not e["id"].startswith(chunk_datei + "::") and e["id"] != chunk_datei:
fehler.append(f"{kontext}: Einheit gehört nicht zur gescannten Datei {chunk_datei}")
continue
pruefe_id(e["id"], f"Z{e['zeile']} einheit", top_level=True)
for feld in SCHLUESSEL:
if not e["felder"].get(feld):
(fehler if feld == "beschreibung" else warnungen).append(f"{kontext}: {feld}: fehlt")
q = quelle(chunk_datei) or ""
for nr, beleg in e["belege"]:
if beleg not in q:
fehler.append(f"{kontext} Z{nr}: Zitat nicht wörtlich in {chunk_datei}: \"{beleg[:50]}\"")
if not e["belege"]:
warnungen.append(f"{kontext}: keine Belege")
for aid in e["anker"]:
pruefe_id(aid, f"{kontext} anker", top_level=False)
for nr, typ, ziel in e["kanten"]:
if typ not in KANTEN_TYPEN:
fehler.append(f"{kontext} Z{nr}: unbekannter Kanten-Typ '{typ}'")
ziel_datei = ziel.partition("::")[0]
if ziel_datei == chunk_datei:
pruefe_id(ziel, f"{kontext} Z{nr} kante", top_level=False)
elif quelle(ziel_datei) is None:
fehler.append(f"{kontext} Z{nr} kante: Datei fehlt: {ziel_datei}")
for aid in zusatz_anker:
if not aid.startswith(chunk_datei + "::"):
fehler.append(f"ergaenze-anker außerhalb der gescannten Datei: {aid}")
return fehler, warnungen, einheiten
def fehlende_symbole(chunk_symbole: list[str], einheiten: list[dict],
zusatz_anker: list[str]) -> list[str]:
"""Abdeckung eines Chunks: welche seiner Top-Level-Symbole sind keiner Einheit
zugeordnet (weder als ID noch als Anker)?"""
zugeordnet = {e["id"].partition("::")[2] for e in einheiten}
zugeordnet |= {a.partition("::")[2] for a in zusatz_anker}
zugeordnet |= {a.partition("::")[2] for e in einheiten for a in e["anker"]}
return [s for s in chunk_symbole if s not in zugeordnet]
def repariere_kanten(fragment: str, wurzel: Path, chunk_datei: str,
quelle_cache: dict | None = None) -> tuple[str, list[str]]:
"""Mechanische Reparatur VOR den Gates: Kanten mit ungültigem Ziel (erfundene Datei,
fehlendes Symbol, fehlender datei::-Prefix) werden GESTRICHEN statt das ganze
Fragment scheitern zu lassen — Kanten sind Zusatz-Metadaten, keine Belege.
→ (bereinigtes_fragment, gestrichene_zeilen)."""
cache = quelle_cache if quelle_cache is not None else {}
def quelle(datei: str) -> str | None:
if datei not in cache:
p = wurzel / datei
cache[datei] = p.read_text(encoding="utf-8", errors="replace") if p.is_file() else None
return cache[datei]
def ziel_ok(ziel: str) -> bool:
if "::" not in ziel:
return False
datei, _, symbol = ziel.partition("::")
q = quelle(datei)
if q is None:
return False
return datei != chunk_datei or _symbol_in_datei(symbol, q, top_level_nur=False)
gestrichen: list[str] = []
zeilen_neu: list[str] = []
for zeile in fragment.splitlines():
m = re.match(r"- ([\wäöü-]+):\s*(\S+)\s*$", zeile)
if m and m.group(1) in KANTEN_TYPEN and not ziel_ok(m.group(2)):
gestrichen.append(zeile.strip())
continue
# Freitext hinter einem Kanten-Typ ("- löst-aus: SystemExit bei …") → streichen
m_frei = re.match(r"- ([\wäöü-]+):\s+\S+\s+\S", zeile)
if m_frei and m_frei.group(1) in KANTEN_TYPEN:
gestrichen.append(zeile.strip())
continue
if zeile.startswith(("anker:", "ergaenze-anker:")): # kaputte Anker-Zeilen ebenso
aid = zeile.split(":", 1)[1].strip().split(" zu ")[0].strip()
datei, _, symbol = aid.partition("::")
q = quelle(datei) if datei else None
if datei != chunk_datei or not symbol or q is None \
or not _symbol_in_datei(symbol, q, top_level_nur=False):
gestrichen.append(zeile.strip())
continue
zeilen_neu.append(zeile)
return "\n".join(zeilen_neu), gestrichen
_BELEG_RE = re.compile(r"(\bbeleg:\s*)(.+)$")
def repariere_belege(fragment: str, wurzel: Path, chunk_datei: str,
quelle_cache: dict | None = None) -> tuple[str, list[str]]:
"""Mechanische Zitat-Reparatur: ein Beleg, der nicht wörtlich in der Quelle steht,
wird per Fuzzy-Match auf die ähnlichste Quellzeile GESCHNAPPT und durch deren
wörtlichen Text ersetzt (Abnahme-Befund: Modelle schreiben z. B. `:=` zu `=` um —
ein Zeichen daneben). Unter der Ähnlichkeitsschwelle bleibt der Beleg unverändert
und fällt im Gate durch → Befund-Feedback. → (fragment, korrigierte_belege)."""
cache = quelle_cache if quelle_cache is not None else {}
if chunk_datei not in cache:
p = wurzel / chunk_datei
cache[chunk_datei] = p.read_text(encoding="utf-8", errors="replace") if p.is_file() else None
quelle = cache[chunk_datei]
if quelle is None:
return fragment, []
zeilen = [z.strip() for z in quelle.splitlines() if z.strip()]
korrigiert: list[str] = []
def snap(m: re.Match) -> str:
zitat = _beleg_text(m.group(2))
if not zitat or zitat in quelle:
return m.group(0)
# Fenster-Matching: Abdeckung des Zitats durch gemeinsame Blöcke mit einer
# Quellzeile — funktioniert auch für kurze Zitate aus langen Zeilen.
beste, beste_deckung = None, 0.0
for z in zeilen:
sm = difflib.SequenceMatcher(None, zitat.lower(), z.lower(), autojunk=False)
if sm.real_quick_ratio() * max(len(zitat), len(z)) < len(zitat) * 0.8:
continue
bloecke = [b for b in sm.get_matching_blocks() if b.size > 0]
deckung = sum(b.size for b in bloecke) / len(zitat)
if deckung > beste_deckung:
beste_deckung = deckung
start = min(b.b for b in bloecke)
ende = max(b.b + b.size for b in bloecke)
beste = z[start:ende] # wörtliches Quell-Fenster
if beste is None or beste_deckung < 0.8 or beste not in quelle:
return m.group(0)
korrigiert.append(f"{zitat!r}{beste[:60]!r}")
return m.group(1) + '"' + beste[:78] + '"'
neu = "\n".join(_BELEG_RE.sub(snap, zeile) for zeile in fragment.splitlines())
return neu, korrigiert
def repariere_referenzen(sichten_text: str, bekannte_ids: set[str]) -> tuple[str, list[str]]:
"""Mechanische Reparatur einer Sicht: unbekannte Einheiten-Referenzen werden aus
`einheiten:`-/`tragende-einheiten:`-Zeilen und Flow-Schritten GESTRICHEN; verliert
ein Teilfeature/Schritt alle Referenzen, fällt die Zeile bzw. der `###`-Block weg.
(Abnahme-Befund: Sichten referenzieren Einheiten toter Chunks — per Feedback nicht
heilbar, weil die IDs schlicht nicht existieren.) → (text, gestrichene_refs)."""
gestrichen: list[str] = []
# Symbol → IDs: falscher Datei-Prefix bei eindeutigem Symbol wird KORRIGIERT statt
# gestrichen (Befund 19: repair.py-Einheit als board_inventory.py referenziert)
je_symbol: dict[str, list[str]] = {}
for eid in bekannte_ids:
je_symbol.setdefault(eid.partition("::")[2], []).append(eid)
def filtere(refs: str) -> list[str]:
gueltig = []
for ref in refs.split(","):
ref = ref.strip()
if not ref:
continue
if ref in bekannte_ids:
gueltig.append(ref)
continue
kandidaten = je_symbol.get(ref.partition("::")[2], [])
if len(kandidaten) == 1:
gueltig.append(kandidaten[0])
else:
gestrichen.append(ref)
return gueltig
zeilen_neu: list[str] = []
for zeile in sichten_text.splitlines():
if m := re.match(r"^(einheiten|tragende-einheiten):\s*(.+)$", zeile):
gueltig = filtere(m.group(2))
if gueltig:
zeilen_neu.append(f"{m.group(1)}: {', '.join(gueltig)}")
elif m.group(1) == "einheiten" and zeilen_neu and zeilen_neu[-1].startswith("### "):
zeilen_neu.pop() # Teilfeature ohne einzige gültige Referenz fällt weg
else:
zeilen_neu.append(f"{m.group(1)}:")
elif m := re.match(r"^(\d+\.\s.+\s—\s)(.+)$", zeile):
gueltig = filtere(m.group(2))
if gueltig:
zeilen_neu.append(m.group(1) + ", ".join(gueltig))
# Schritt ohne gültige Referenz fällt weg
else:
zeilen_neu.append(zeile)
return "\n".join(zeilen_neu), gestrichen
def pruefe_referenzen(sichten_text: str, bekannte_ids: set[str]) -> list[str]:
"""Referenz-Integrität einer Sicht: `einheiten:`-/`tragende-einheiten:`-Zeilen und
Flow-Schritte (`1. … — id, id`) dürfen nur bekannte Einheiten-IDs nennen."""
fehler = []
referenz_zeilen = [m.group(1) for m in
re.finditer(r"^(?:einheiten|tragende-einheiten):\s*(.+)$", sichten_text, re.M)]
referenz_zeilen += [m.group(1) for m in
re.finditer(r"^\d+\.\s.+\s—\s(.+)$", sichten_text, re.M)]
for zeile in referenz_zeilen:
for ref in zeile.split(","):
ref = ref.strip()
if ref and ref not in bekannte_ids:
fehler.append(f"unbekannte Einheiten-Referenz: {ref}")
return fehler

269
backend/kanban.py Normal file
View File

@@ -0,0 +1,269 @@
"""Generic streaming kanban engine (no concrete stages — boards define those).
Each column is a worker that pulls cards from its input `stage` (the queue = kanban_cards rows
WHERE stage = <input>), processes up to KANBAN_BATCH at a time, and advances them. Streaming
columns run continuously; BARRIER columns start only at QUIESCENCE of every stage before them
(no active worker + no queued card). SERIAL columns process one package at a time (their
processor mutates shared cross-card state).
Failure handling: a processor exception (including parse-fails it raises) sends the package's
unadvanced cards into exponential backoff (retries++, not_before); after MAX_CARD_RETRIES the
card goes to stage 'dead' (dead-letter — visible on the board, requeue-able via API). No card
is ever deleted by the engine.
Board definitions live in board_inventory.py / board_artefacts.py; run via run_flow().
"""
import asyncio
import logging
import database as db
from config import KANBAN_BATCH, MAX_CARD_RETRIES, MAX_CONCURRENT_AGENTS_PER_TOPIC, RETRY_BACKOFF
from agents import AgentInfraError
log = logging.getLogger("creator.kanban")
# How many packages ONE worker keeps in flight at once. A worker no longer blocks on a single
# package — it keeps pulling and dispatching until this many run concurrently, so a busy column
# fills the agent slots (the per-topic semaphore is the real cap; over-dispatch just queues cheaply).
WORKER_INFLIGHT = MAX_CONCURRENT_AGENTS_PER_TOPIC
_POLL = 0.3 # seconds between empty-queue polls
# Live registry of running flows (topic → Flow), so routes can attach research agents,
# report `generating`, and cancel.
active_flows: dict[str, "Flow"] = {}
class Flow:
"""Shared runtime state of one topic run: active-task counters per stage + a wakeup event.
`producers` counts running research agents (initial + any added live); research counts as done
only when ALL producers have finished, so the flow stays awake while extras still search."""
def __init__(self, topic: str, work_dir=None):
self.topic = topic
self.work_dir = work_dir
self.active: dict[str, int] = {}
self.producers = 0
self.producer_tag = 0
self.stop = False
self.wake = asyncio.Event()
self.spawn_research = None # set by the board: () → coroutine adding one more research agent
self.state: dict = {} # board-private shared state (embedding caches, one-shot flags …)
self.active_cards: set[str] = set() # "board:card_id" currently inside a processor (live display)
@property
def research_done(self) -> bool:
return self.producers <= 0
def add_producer(self):
"""MUST be called synchronously BEFORE create_task of the producer — otherwise workers
can pass their exit check in the gap and never see the new producer (quiescence race)."""
self.producers += 1
self.wake.set()
def done_producer(self):
self.producers -= 1
self.wake.set()
def next_tag(self) -> int:
self.producer_tag += 1
return self.producer_tag
def enter(self, stage: str):
self.active[stage] = self.active.get(stage, 0) + 1
def leave(self, stage: str):
self.active[stage] = max(0, self.active.get(stage, 0) - 1)
self.wake.set()
def active_in(self, stages) -> bool:
return any(self.active.get(s, 0) > 0 for s in stages)
class Stage:
"""One column: board + stage name + processor. `upstream` (all stages before it, across
boards) is filled by chain_stages(). process(cards) gets the pulled package (list of card
dicts with decoded payload).
barrier: pull only when every upstream stage is quiescent (relational judgements need the
full set). gate: extra callable that must be truthy before the stage pulls (works without
barrier too — e.g. the consensus gate holds cards until research is done so late reader
votes still count). drain: pull the WHOLE queue as one package (global passes like the
fragment filter); implies serial."""
def __init__(self, board: str, stage: str, process, *, barrier: bool = False,
serial: bool = False, gate=None, drain: bool = False):
self.board = board
self.stage = stage
self.process = process
self.barrier = barrier
self.serial = serial or drain
self.gate = gate
self.drain = drain
self.upstream: list[str] = []
def chain_stages(stages: list[Stage]) -> list[Stage]:
"""Fill each stage's upstream = every stage listed before it (list order = flow order).
Producers are upstream of everything implicitly via flow.research_done."""
seen: list[str] = []
for s in stages:
s.upstream = list(seen)
seen.append(s.stage)
return stages
async def quiescent(flow: Flow, stages) -> bool:
"""True iff no worker is active in `stages` AND no card is queued in any of them.
The barrier/exit condition — must include QUEUED cards, not just active workers, or a worker
could exit in a momentary lull while an upstream worker still has work to push down."""
if not stages:
return True
if flow.active_in(stages):
return False
return await db.kanban_count(flow.topic, list(stages)) == 0
async def _sleep_wake(flow: Flow):
try:
await asyncio.wait_for(flow.wake.wait(), timeout=_POLL)
except asyncio.TimeoutError:
pass
flow.wake.clear()
async def _fail_package(flow: Flow, spec: Stage, cards: list[dict], error: str):
"""Backoff/dead-letter for the cards the processor did NOT advance (their stage is unchanged —
advanced cards must not be punished for a failure after their move)."""
for c in cards:
cur = await db.kanban_get_card(flow.topic, spec.board, c["card_id"])
if cur is None or cur["stage"] != spec.stage:
continue
dead = await db.kanban_fail_card(flow.topic, spec.board, c["card_id"], error,
MAX_CARD_RETRIES, RETRY_BACKOFF)
if dead:
log.warning("kanban %s/%s: card %s → dead (%s)", flow.topic, spec.stage, c["card_id"], error)
async def _worker(flow: Flow, spec: Stage, inflight: int, all_stages: list[str]):
"""Pull cards from spec.stage, run spec.process — keeping up to `inflight` packages running
CONCURRENTLY so a busy column fills the agent slots. A barrier worker only pulls when upstream
is fully quiescent (and its gate, if any, is open). ANY worker exits only when research is
done and the WHOLE flow is quiescent — global instead of per-stage, so a downstream stage
that feeds cards back upstream (gap-check → ingest) never strands work. Double-checked over
one grace sleep (a producer attached in the lull keeps the flow alive).
Double-pull safety: each stage has exactly ONE worker, so an in-memory `claimed` set of
card-ids (held while a package runs) keeps concurrent pulls from grabbing the same cards."""
topic = flow.topic
claimed: set[str] = set()
tasks: set[asyncio.Task] = set()
batch = 100_000 if spec.drain else KANBAN_BATCH
async def _run(cards):
ids = [c["card_id"] for c in cards]
flow.enter(spec.stage)
flow.active_cards.update(f"{spec.board}:{i}" for i in ids)
try:
await spec.process(cards)
except AgentInfraError as e: # 429/Timeout erschöpft → Lauf pausieren, NICHT dead-letter
log.warning("kanban %s/%s: infra-pause: %s", topic, spec.stage, e)
flow.state["infra_paused"] = True
flow.state["infra_error"] = str(e)
flow.stop = True
flow.wake.set()
except Exception as e: # one bad package must not kill the worker → backoff/dead-letter
log.info("kanban %s/%s: %s: %s", topic, spec.stage, type(e).__name__, e)
try:
await _fail_package(flow, spec, cards, f"{type(e).__name__}: {e}")
except Exception:
log.exception("kanban %s/%s: fail-handling broke", topic, spec.stage)
finally:
flow.leave(spec.stage)
for i in ids:
claimed.discard(i)
flow.active_cards.discard(f"{spec.board}:{i}")
flow.wake.set()
async def _idle_exit() -> bool:
return (flow.research_done and not flow.active_in(all_stages)
and await db.kanban_count(topic, all_stages) == 0)
async def _may_pull() -> bool:
if spec.gate is not None and not spec.gate():
return False
if not spec.barrier:
return True
return await quiescent(flow, spec.upstream)
try:
while not flow.stop:
tasks = {t for t in tasks if not t.done()}
# Fill the pipeline: pull fresh cards and dispatch until `inflight` packages run.
if await _may_pull():
while len(tasks) < inflight:
rows = await db.kanban_pull(topic, spec.board, spec.stage, batch + len(claimed))
fresh = [r for r in rows if r["card_id"] not in claimed][:batch]
if not fresh:
break
for r in fresh:
claimed.add(r["card_id"])
tasks.add(asyncio.create_task(_run(list(fresh))))
if tasks: # busy → wait for a package to finish, then refill
await asyncio.wait(tasks, timeout=_POLL, return_when=asyncio.FIRST_COMPLETED)
continue
# idle: nothing in flight and nothing pulled
if await _idle_exit():
# Real grace sleep (NOT _sleep_wake — the wake event is usually already set
# by the last package and would collapse the window to 0ms). A producer
# attached during the lull flips research_done and keeps us alive.
await asyncio.sleep(_POLL)
if await _idle_exit():
return # nothing left and nothing upstream can produce
continue
await _sleep_wake(flow)
finally:
for t in tasks:
t.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def run_flow(flow: Flow, stages: list[Stage], producers=(), set_p=None) -> None:
"""Run producers + one worker per stage until global quiescence. `producers` are coroutines
already counted via flow.add_producer() BEFORE this call (quiescence race). Registers the
flow in active_flows for live attach/cancel."""
active_flows[flow.topic] = flow
names = [s.stage for s in stages]
def _spawn_workers():
return [asyncio.ensure_future(_worker(flow, s, 1 if s.serial else WORKER_INFLIGHT, names))
for s in stages]
workers = [asyncio.ensure_future(p) for p in producers] + _spawn_workers()
progress = asyncio.create_task(_progress(flow, set_p)) if set_p else None
try:
while True:
await asyncio.gather(*workers, return_exceptions=True)
# Restart round: a producer attached exactly as the workers exited (missed even the
# grace sleep) leaves live producers or queued cards behind → run the workers again.
if flow.stop or (flow.research_done and await quiescent(flow, names)):
break
workers = _spawn_workers()
finally:
flow.stop = True
if progress:
progress.cancel()
if active_flows.get(flow.topic) is flow:
active_flows.pop(flow.topic, None)
async def _progress(flow: Flow, set_p):
while not flow.stop:
try:
counts = await db.kanban_stage_counts(flow.topic)
total = sum(n for stages in counts.values() for n in stages.values())
set_p(f"Kanban: {total} Karten im Fluss")
except Exception:
pass
await asyncio.sleep(1.0)

36
backend/projekte.py Normal file
View File

@@ -0,0 +1,36 @@
"""Projektliste: konfigurierte Pfade aus projekte.txt (eine Zeile = absoluter Pfad,
#-Kommentare). Von Hand editierbar — UI-Verwaltung kommt später (YAGNI)."""
from pathlib import Path
import config
def lade() -> list[dict]:
"""→ [{name, pfad, fehler?}]. Toter Pfad oder Basename-Kollision wird als Eintrag
mit `fehler` gemeldet, nie still verschluckt."""
if not config.PROJEKTE_DATEI.is_file():
return []
out: list[dict] = []
gesehen: dict[str, str] = {}
for zeile in config.PROJEKTE_DATEI.read_text(encoding="utf-8").splitlines():
zeile = zeile.strip()
if not zeile or zeile.startswith("#"):
continue
pfad = Path(zeile).expanduser()
name = pfad.name
eintrag = {"name": name, "pfad": str(pfad)}
if not pfad.is_dir():
eintrag["fehler"] = "Pfad existiert nicht"
elif name in gesehen:
eintrag["fehler"] = f"Namenskollision mit {gesehen[name]}"
else:
gesehen[name] = str(pfad)
out.append(eintrag)
return out
def finde(name: str) -> Path | None:
for p in lade():
if p["name"] == name and "fehler" not in p:
return Path(p["pfad"])
return None

3
backend/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests

53
backend/repo.py Normal file
View File

@@ -0,0 +1,53 @@
"""git-Helfer (nackte CLI, keine Bibliothek): Anker-Stände und Diff fürs Re-Scannen."""
import subprocess
from pathlib import Path
def _git(wurzel: Path, *args: str) -> str:
res = subprocess.run(["git", "-C", str(wurzel), *args],
capture_output=True, text=True, timeout=30)
if res.returncode != 0:
raise RuntimeError(f"git {' '.join(args)}: {res.stderr.strip() or res.stdout.strip()}")
return res.stdout
def ist_repo(wurzel: Path) -> bool:
try:
_git(wurzel, "rev-parse", "--git-dir")
return True
except (RuntimeError, FileNotFoundError):
return False
def head_hash(wurzel: Path) -> str:
return _git(wurzel, "rev-parse", "--short=12", "HEAD").strip()
def working_tree_sauber(wurzel: Path) -> bool:
"""False bei uncommitteten Änderungen (inkl. untracked) — Scan/Board arbeiten nur
auf sauberem Tree, sonst vermischen sich Stände. AUSNAHME `.planer/`: die eigenen
Artefakte (z. B. aus einem Teillauf) dürfen den Folgelauf nicht blockieren."""
for zeile in _git(wurzel, "status", "--porcelain").splitlines():
pfad = zeile[3:].split(" -> ")[-1].strip().strip('"')
if not pfad.startswith(".planer/"):
return False
return True
def geaenderte_dateien(wurzel: Path, alt: str, neu: str = "HEAD") -> tuple[set[str], set[str]]:
"""(geändert_oder_neu, gelöscht) zwischen `alt` und `neu`, `.planer/` ausgefiltert.
Renames (R) = alt gelöscht + neu geändert (v1: kein Anker-Matching über Renames)."""
geaendert: set[str] = set()
geloescht: set[str] = set()
for zeile in _git(wurzel, "diff", "--name-status", f"{alt}..{neu}").splitlines():
teile = zeile.split("\t")
status = teile[0]
if status.startswith("R") and len(teile) == 3:
geloescht.add(teile[1])
geaendert.add(teile[2])
elif status == "D":
geloescht.add(teile[1])
elif len(teile) >= 2:
geaendert.add(teile[1])
kein_planer = lambda s: not s.startswith(".planer/") # noqa: E731
return set(filter(kein_planer, geaendert)), set(filter(kein_planer, geloescht))

108
backend/routes.py Normal file
View File

@@ -0,0 +1,108 @@
"""API des Viewers (Phase 2). Lese-Routen über viewer.py, Lauf-Routen über scan_dienst."""
from pathlib import Path
from fastapi import APIRouter, HTTPException
import agents
import database as db
import projekte
import scan_dienst
import viewer
router = APIRouter(prefix="/api")
def _wurzel(name: str) -> Path:
wurzel = projekte.finde(name)
if wurzel is None:
raise HTTPException(404, f"Projekt '{name}' nicht in projekte.txt")
return wurzel
@router.get("/health")
async def health():
return {"ok": True}
@router.get("/projekte")
async def liste():
out = []
for p in projekte.lade():
eintrag = dict(p)
if "fehler" not in p:
wurzel = Path(p["pfad"])
eintrag["hat_planer"] = (wurzel / ".planer" / "kern.md").is_file()
staende = {d: s for d, (s, _) in scan_dienst.einheiten_bestand(wurzel).items()}
eintrag["drift"] = viewer.drift(wurzel, staende)["veraltet"]
eintrag["letzter_scan_ts"] = await db.letzter_agent_ts(p["name"])
lauf = scan_dienst.status(p["name"])
eintrag["scan_laeuft"] = bool(lauf and lauf["laeuft"])
out.append(eintrag)
return out
@router.get("/projekte/{name}")
async def projekt(name: str):
wurzel = _wurzel(name)
daten = viewer.lade_projekt(wurzel)
einheiten_kurz = {eid: {"beschreibung": e["beschreibung"]}
for eid, e in daten["einheiten"].items()}
return {
"name": name,
"kern": daten["kern"],
"bereiche": daten["bereiche"],
"architektur": daten["architektur"],
"flows": daten["flows"],
"pruefung": daten["pruefung"],
"einheiten": einheiten_kurz,
"doku_ordnung": viewer.doku_ordnung(daten),
"drift": viewer.drift(wurzel, daten["staende"]),
}
@router.get("/projekte/{name}/einheit")
async def einheit(name: str, id: str):
wurzel = _wurzel(name)
daten = viewer.lade_projekt(wurzel)
e = daten["einheiten"].get(id)
if e is None:
raise HTTPException(404, f"Einheit '{id}' unbekannt")
quelldatei = id.partition("::")[0]
veraltet = quelldatei in viewer.drift(wurzel, daten["staende"])["veraltet"]
code = viewer.code_extrahieren(wurzel, id)
for anker in e["anker"]:
zusatz = viewer.code_extrahieren(wurzel, anker)
if zusatz and zusatz not in code:
code += ("\n\n" if code else "") + zusatz
return {**e, "code": code, "veraltet": veraltet}
@router.post("/projekte/{name}/scan")
async def scan_starten(name: str, body: dict):
wurzel = _wurzel(name)
art = body.get("art", "scan")
if art not in ("scan", "rescan"):
raise HTTPException(400, f"unbekannte Art '{art}'")
try:
status = scan_dienst.starte(name, wurzel, art)
except scan_dienst.VorflugFehler as e:
raise HTTPException(409, str(e))
return {"status": status}
@router.get("/projekte/{name}/scan-status")
async def scan_status(name: str):
_wurzel(name)
lauf = scan_dienst.status(name)
if lauf is None:
return {"laeuft": False, "art": None, "ergebnis": None, "stufen": {}, "agenten": [], "dead": []}
out = dict(lauf)
if lauf["laeuft"]:
out["stufen"] = (await db.kanban_stage_counts(name)).get("scan", {})
out["agenten"] = agents.active_agents(f"scan-{name}-")
out["dead"] = [c["card_id"] for c in await db.kanban_dead(name)]
else:
out["stufen"] = {}
out["agenten"] = []
out["dead"] = (lauf["ergebnis"] or {}).get("dead", [])
return out

319
backend/scan_board.py Normal file
View File

@@ -0,0 +1,319 @@
"""Scan-Board: zerlegt ein Projekt-Repo in Einheiten und Sichten (.planer/-Dateien).
Stages (Engine: kanban.py):
Producer (Chunker, mechanisch) → schneiden (LLM je Chunk) → nachfass (LLM, nur bei
Abdeckungslücken) → sammeln (mechanisch, drain: Fragmente je Quelldatei mergen,
Sicht-Karten anlegen) → sichten (LLM je Sicht-Art) → schreiben (mechanisch, drain:
.planer/ ins Ziel-Repo).
Payload-Disziplin: Chunk-Text und Fragmente bleiben in der Karte (~15 KB), ab `sammeln`
tragen Karten nur noch Staging-Pfade (storage/laeufe/<run>/).
"""
import asyncio
import logging
import time
from pathlib import Path
import artefakte
import chunker
import config
import database as db
import gates
from agents import AgentInfraError, run_agent
from kanban import Flow, Stage, chain_stages, run_flow
log = logging.getLogger("planer.scan")
BOARD = "scan"
SICHT_ARTEN = ("features", "architektur", "flows")
MAX_NACHFASS_SYMBOLE = 40 # Notbremse gegen endlos wachsende Nachfass-Prompts
def _template(name: str) -> str:
return (config.TEMPLATES_DIR / f"{name}.md").read_text(encoding="utf-8")
def _prompt(name: str, **ersetzungen: str) -> str:
text = _template(name)
for k, v in ersetzungen.items():
text = text.replace(f"<{k}>", v)
return text
async def _llm(flow: Flow, stufe: str, card_id: str, rolle: str, prompt: str, label: str) -> str:
"""Ein Gate-gesicherter LLM-Call: rc!=0 → ValueError (Engine-Backoff);
AgentInfraError propagiert (Lauf pausiert)."""
rc, out, err = await run_agent(
f"scan-{flow.topic}-{stufe}-{card_id}", prompt, config.AGENT_TIMEOUT,
role=rolle, scope=flow.topic, label=label)
if rc != 0:
raise ValueError(f"{stufe} rc={rc}: {(err or out)[:200]}")
return out
class ScanKontext:
def __init__(self, wurzel: Path, stand: str, lauf_dir: Path):
self.wurzel = wurzel
self.stand = stand # HEAD-Hash des Ziel-Repos beim Scan-Start
self.lauf_dir = lauf_dir # Staging: storage/laeufe/<run>/
self.quelle_cache: dict = {} # Datei → Text (Gates lesen viel)
self.bestand: dict[str, str] = {} # unveränderte Einheiten-Dateien (Re-Scan)
# --- Prozessoren ---------------------------------------------------------------------
async def _je_karte(flow: Flow, cards: list[dict], verarbeite) -> None:
"""Karten eines Pakets parallel verarbeiten; Erfolge advancen, dann ersten Fehler
werfen (nur unadvancte Karten gehen in den Backoff). AgentInfraError hat Vorrang."""
ergebnisse = await asyncio.gather(*(verarbeite(c) for c in cards), return_exceptions=True)
moves = [(c["card_id"], ziel) for c, ziel in zip(cards, ergebnisse) if isinstance(ziel, str)]
if moves:
await db.kanban_advance_many(flow.topic, BOARD, moves)
flow.wake.set()
fehler = [e for e in ergebnisse if isinstance(e, BaseException)]
for e in fehler:
if isinstance(e, AgentInfraError):
raise e
if fehler:
raise fehler[0]
def _mit_befunden(prompt: str, payload: dict) -> str:
"""Repair-Muster (Creator-Lehre): der Retry bekommt die Gate-Befunde des letzten
Versuchs — blinde Wiederholung heilt systematische Fehler nicht."""
befunde = payload.get("befunde")
if not befunde:
return prompt
liste = "\n".join(f"- {b}" for b in befunde[:6])
return (prompt + "\n\n## WICHTIG: Korrektur\n\nDein vorheriger Versuch scheiterte an "
"diesen Prüf-Befunden. Behebe GENAU diese Punkte (Zitate müssen wörtlich aus "
"der Quelle stammen — kopiere sie zeichengenau):\n" + liste + "\n")
async def _befunde_merken(flow: Flow, card: dict, fehler: list[str]) -> None:
card["payload"]["befunde"] = fehler[:6]
await db.kanban_set_payload(flow.topic, BOARD, card["card_id"], card["payload"])
async def _proc_schneiden(ctx: ScanKontext, flow: Flow, cards: list[dict]) -> None:
async def verarbeite(c: dict) -> str:
p = c["payload"]
prompt = _mit_befunden(
_prompt("einheiten", DATEI=p["datei"], QUELLE=p["text"],
SYMBOLE="\n".join(f"- {s}" for s in p["symbole"]) or "- (keine)"), p)
out = await _llm(flow, "schneiden", c["card_id"], "schneiden", prompt, p["datei"])
out, gestrichen = gates.repariere_kanten(out, ctx.wurzel, p["datei"], ctx.quelle_cache)
out, korrigiert = gates.repariere_belege(out, ctx.wurzel, p["datei"], ctx.quelle_cache)
fehler, warnungen, einheiten = gates.pruefe_fragment(out, ctx.wurzel, p["datei"],
ctx.quelle_cache)
if fehler:
await _befunde_merken(flow, c, fehler)
raise ValueError(f"Gates: {'; '.join(fehler[:3])}")
warnungen += [f"Kante gestrichen: {z}" for z in gestrichen]
warnungen += [f"Beleg geschnappt: {z}" for z in korrigiert]
_, zusatz, _ = gates.parse_einheiten(out)
fehlend = gates.fehlende_symbole(p["symbole"], einheiten, zusatz)
p["fragmente"] = [out]
p["fehlend"] = fehlend
p["warnungen"] = warnungen
p.pop("befunde", None)
await db.kanban_set_payload(flow.topic, BOARD, c["card_id"], p)
return "nachfass" if fehlend else "sammeln"
await _je_karte(flow, cards, verarbeite)
async def _proc_nachfass(ctx: ScanKontext, flow: Flow, cards: list[dict]) -> None:
async def verarbeite(c: dict) -> str:
p = c["payload"]
fehlend = p["fehlend"][:MAX_NACHFASS_SYMBOLE]
prompt = _mit_befunden(
_prompt("nachfass", DATEI=p["datei"], QUELLE=p["text"],
FEHLEND="\n".join(f"- {s}" for s in fehlend),
VORHANDEN="\n\n".join(p["fragmente"])), p)
out = await _llm(flow, "nachfass", c["card_id"], "nachfass", prompt, p["datei"])
out, _ = gates.repariere_kanten(out, ctx.wurzel, p["datei"], ctx.quelle_cache)
out, _ = gates.repariere_belege(out, ctx.wurzel, p["datei"], ctx.quelle_cache)
fehler, _, _ = gates.pruefe_fragment(out, ctx.wurzel, p["datei"], ctx.quelle_cache) \
if out.strip().startswith("## ") else ([], [], [])
if fehler:
await _befunde_merken(flow, c, fehler)
raise ValueError(f"Gates (Nachfass): {'; '.join(fehler[:3])}")
# Abdeckung über ALLE Fragmente des Chunks prüfen
gesamt = "\n\n".join(p["fragmente"] + [out])
einheiten, zusatz, _ = gates.parse_einheiten(gesamt)
immer_noch = gates.fehlende_symbole(p["symbole"], einheiten, zusatz)
if immer_noch:
raise ValueError(f"Nachfass ließ Symbole offen: {', '.join(immer_noch[:5])}")
p["fragmente"].append(out)
p["fehlend"] = []
p.pop("befunde", None)
await db.kanban_set_payload(flow.topic, BOARD, c["card_id"], p)
return "sammeln"
await _je_karte(flow, cards, verarbeite)
async def _proc_sammeln(ctx: ScanKontext, flow: Flow, cards: list[dict]) -> None:
"""Drain: alle Chunk-Karten liegen vor. Fragmente je Quelldatei mergen, Staging
schreiben, Übersichten bauen, eine Karte je Sicht-Art anlegen."""
je_datei: dict[str, list[dict]] = {}
for c in cards:
je_datei.setdefault(c["payload"]["datei"], []).append(c)
(ctx.lauf_dir / "einheiten").mkdir(parents=True, exist_ok=True)
ids: set[str] = set()
kurz_zeilen: list[str] = [] # ID + beschreibung (features)
kanten_zeilen: list[str] = [] # ID + beschreibung + kanten (architektur/flows)
for datei, karten in sorted(je_datei.items()):
karten.sort(key=lambda c: c["payload"]["start"])
rumpf = artefakte.merge_fragmente([f for c in karten for f in c["payload"]["fragmente"]])
text = artefakte.einheiten_datei(datei, rumpf, ctx.stand)
(ctx.lauf_dir / artefakte.datei_pfad(datei)).write_text(text, encoding="utf-8")
ids |= artefakte.alle_ids(text)
einheiten, _, _ = gates.parse_einheiten(text)
for e in einheiten:
kurz_zeilen.append(f"- {e['id']}: {e['felder'].get('beschreibung', '')}")
kanten = "; ".join(f"{typ} {ziel}" for _, typ, ziel in e["kanten"])
kanten_zeilen.append(kurz_zeilen[-1] + (f"\n kanten: {kanten}" if kanten else ""))
for datei, text in ctx.bestand.items(): # Re-Scan: unveränderte Dateien zählen mit
ids |= artefakte.alle_ids(text)
einheiten, _, _ = gates.parse_einheiten(text)
for e in einheiten:
kurz_zeilen.append(f"- {e['id']}: {e['felder'].get('beschreibung', '')}")
kanten = "; ".join(f"{typ} {ziel}" for _, typ, ziel in e["kanten"])
kanten_zeilen.append(kurz_zeilen[-1] + (f"\n kanten: {kanten}" if kanten else ""))
(ctx.lauf_dir / "uebersicht-kurz.md").write_text("\n".join(sorted(kurz_zeilen)), encoding="utf-8")
(ctx.lauf_dir / "uebersicht-kanten.md").write_text("\n".join(sorted(kanten_zeilen)), encoding="utf-8")
(ctx.lauf_dir / "ids.txt").write_text("\n".join(sorted(ids)), encoding="utf-8")
for art in SICHT_ARTEN:
await db.kanban_upsert_card(flow.topic, BOARD, f"sicht-{art}", "sicht", "sichten",
{"art": art})
await db.kanban_advance_many(flow.topic, BOARD,
[(c["card_id"], "chunk-fertig") for c in cards])
flow.wake.set()
async def _proc_sichten(ctx: ScanKontext, flow: Flow, cards: list[dict]) -> None:
ids = set((ctx.lauf_dir / "ids.txt").read_text(encoding="utf-8").splitlines())
async def verarbeite(c: dict) -> str:
art = c["payload"]["art"]
uebersicht = "uebersicht-kurz.md" if art == "features" else "uebersicht-kanten.md"
prompt = _mit_befunden(
_prompt(f"sichten-{art}", PROJEKT=flow.topic,
EINHEITEN=(ctx.lauf_dir / uebersicht).read_text(encoding="utf-8")), c["payload"])
out = await _llm(flow, "sichten", c["card_id"], "sichten", prompt, art)
out, gestrichen = gates.repariere_referenzen(out, ids)
if gestrichen:
log.info("scan %s: %s%d unbekannte Referenzen gestrichen", flow.topic, art,
len(gestrichen))
fehler = gates.pruefe_referenzen(out, ids)
if fehler:
await _befunde_merken(flow, c, fehler)
raise ValueError(f"Referenzen ({art}): {'; '.join(fehler[:3])}")
(ctx.lauf_dir / f"sicht-{art}.md").write_text(out, encoding="utf-8")
return "schreiben"
await _je_karte(flow, cards, verarbeite)
async def _proc_schreiben(ctx: ScanKontext, flow: Flow, cards: list[dict]) -> None:
"""Drain nach allen Sichten: .planer/ im Ziel-Repo schreiben."""
dateien: dict[str, str] = {}
for p in (ctx.lauf_dir / "einheiten").glob("*.md"):
dateien[f"einheiten/{p.name}"] = p.read_text(encoding="utf-8")
for datei, text in ctx.bestand.items():
dateien.setdefault(artefakte.datei_pfad(datei), text)
fehlend: list[str] = [] # gescheiterte Sichten: auslassen statt Kaskade (sichtbar degradieren)
if (ctx.lauf_dir / "sicht-features.md").is_file():
features = (ctx.lauf_dir / "sicht-features.md").read_text(encoding="utf-8")
kern, _, bereiche = features.partition("# Bereich:")
dateien["kern.md"] = kern.strip() + "\n"
for i, block in enumerate(("# Bereich:" + bereiche).split("# Bereich:")):
if not block.strip():
continue
name = block.splitlines()[0].strip().lower().replace(" ", "-").replace("/", "-")
dateien[f"features/{name or f'bereich-{i}'}.md"] = "# Bereich:" + block.rstrip() + "\n"
else:
fehlend.append("features")
for art, zieldatei in (("architektur", "architektur.md"), ("flows", "flows.md")):
if (ctx.lauf_dir / f"sicht-{art}.md").is_file():
dateien[zieldatei] = (ctx.lauf_dir / f"sicht-{art}.md").read_text(encoding="utf-8")
else:
fehlend.append(art)
if fehlend:
flow.state["sichten_fehlend"] = fehlend
log.warning("scan %s: Sichten fehlen (Karten dead): %s", flow.topic, ", ".join(fehlend))
if not (ctx.wurzel / ".planer" / "pruefung.md").is_file(): # nie Nutzer-Edits überschreiben
dateien["pruefung.md"] = artefakte.pruefung_geruest(ctx.wurzel, flow.topic)
artefakte.schreibe_planer(ctx.wurzel, dateien)
flow.state["geschrieben"] = sorted(dateien)
await db.kanban_advance_many(flow.topic, BOARD, [(c["card_id"], "fertig") for c in cards])
flow.wake.set()
# --- Flow-Start ------------------------------------------------------------------------
def scan_stages(ctx: ScanKontext, flow: Flow) -> list[Stage]:
# gate=research_done an den Barriere-Stufen ist PFLICHT (Abnahme-Befund): die Barriere
# prüft nur die Upstream-Stufen — ohne Gate drained `sammeln`, während der Producer
# noch Karten einreiht, und die Sichten sehen eine Teil-Übersicht.
fertig_produziert = lambda: flow.research_done # noqa: E731
return chain_stages([
Stage(BOARD, "schneiden", lambda cs: _proc_schneiden(ctx, flow, cs)),
Stage(BOARD, "nachfass", lambda cs: _proc_nachfass(ctx, flow, cs)),
Stage(BOARD, "sammeln", lambda cs: _proc_sammeln(ctx, flow, cs),
barrier=True, drain=True, gate=fertig_produziert),
Stage(BOARD, "sichten", lambda cs: _proc_sichten(ctx, flow, cs)),
Stage(BOARD, "schreiben", lambda cs: _proc_schreiben(ctx, flow, cs),
barrier=True, drain=True, gate=fertig_produziert),
])
async def run_scan(wurzel: Path, projekt: str, stand: str, set_p=None,
nur_dateien: set[str] | None = None,
bestand: dict[str, str] | None = None,
fortsetzen: bool = False) -> dict:
"""Scan eines Projekt-Repos. `nur_dateien` beschränkt auf geänderte Dateien (Re-Scan);
`bestand` = unveränderte Einheiten-Dateien {quelldatei: text}. `fortsetzen=True`
setzt einen abgebrochenen/fehlgeschlagenen Lauf fort: tote Chunks laufen neu durchs
LLM, fertige nur durchs mechanische Sammeln (Fragmente stecken im Payload) — setzt
unveränderten HEAD voraus. → {status, geschrieben, dead}."""
run_id = f"scan-{time.time_ns()}" # eindeutig auch bei zwei Läufen in derselben Sekunde
lauf_dir = config.LAEUFE_DIR / projekt / run_id
lauf_dir.mkdir(parents=True, exist_ok=True)
ctx = ScanKontext(wurzel, stand, lauf_dir)
ctx.bestand = bestand or {}
flow = Flow(projekt)
db.set_current_run(projekt, run_id)
async def producer():
try:
if fortsetzen:
n_neu = n_fertig = 0
for c in await db.kanban_cards(flow.topic, BOARD, kind="chunk"):
if c["stage"] == "dead":
await db.kanban_upsert_card(flow.topic, BOARD, c["card_id"],
"chunk", "schneiden", None)
n_neu += 1
elif c["stage"] == "chunk-fertig":
await db.kanban_upsert_card(flow.topic, BOARD, c["card_id"],
"chunk", "sammeln", None)
n_fertig += 1
log.info("scan %s: Fortsetzung — %d neu, %d nur sammeln", projekt, n_neu, n_fertig)
return
chunks = await asyncio.to_thread(chunker.chunks_fuer_repo, wurzel, nur_dateien)
for i, chunk in enumerate(chunks):
await db.kanban_upsert_card(flow.topic, BOARD, f"chunk-{i:04d}", "chunk",
"schneiden", chunk)
log.info("scan %s: %d Chunks angelegt", projekt, len(chunks))
finally:
flow.done_producer()
flow.add_producer() # SYNCHRON vor create_task (Quieszenz-Race)
try:
await run_flow(flow, scan_stages(ctx, flow), [producer()], set_p)
finally:
db.set_current_run(projekt, None)
dead = await db.kanban_dead(projekt)
status = "pausiert" if flow.state.get("infra_paused") else ("fehler" if dead else "ok")
return {"status": status, "infra_error": flow.state.get("infra_error", ""),
"geschrieben": flow.state.get("geschrieben", []),
"dead": [c["card_id"] for c in dead]}

147
backend/scan_dienst.py Normal file
View File

@@ -0,0 +1,147 @@
"""Scan-Dienst: Vorflug-Checks, Re-Scan-Planung und Hintergrund-Läufe.
Eine Wahrheit für CLI und Server: cli.py und routes.py delegieren beide hierher.
Doppelstart-Guard nach Creator-Muster: prozess-lokales Dict + idempotente Antwort +
Re-Entry-Check in der Coroutine (gilt nur im eigenen Prozess — bei laufendem Server
Scans über die UI starten, nicht parallel per CLI).
"""
import asyncio
import logging
import os
import time
from pathlib import Path
import agents
import artefakte
import chunker
import config
import repo
import scan_board
log = logging.getLogger("planer.dienst")
class VorflugFehler(Exception):
"""Lauf darf nicht starten (kein Repo, dirty tree, Rolle/Provider unbrauchbar)."""
def vorflug(wurzel: Path) -> str:
"""Checks vor jedem Lauf → HEAD-Hash. Wirft VorflugFehler (Erkenntnis 13:
lieber vorher stoppen als leer verbrennen)."""
if not wurzel.is_dir():
raise VorflugFehler(f"{wurzel} ist kein Verzeichnis")
if not repo.ist_repo(wurzel):
raise VorflugFehler(f"{wurzel} ist kein git-Repo — Anker brauchen Commit-Hashes (git init && commit)")
if not repo.working_tree_sauber(wurzel):
raise VorflugFehler(f"{wurzel} hat uncommittete Änderungen — erst committen, dann scannen")
if not os.getenv("PLANER_FAKE_AGENTS"):
for rolle in config.ROLLEN:
try:
provider, _ = config.resolve_role(rolle)
except RuntimeError as e:
raise VorflugFehler(str(e)) from e
if not agents.provider_available(provider):
raise VorflugFehler(f"Provider '{provider}' (Rolle {rolle}) nicht nutzbar — CLI/Key prüfen")
return repo.head_hash(wurzel)
def einheiten_bestand(wurzel: Path) -> dict[str, tuple[str, str]]:
"""{quelldatei: (stand, text)} aus den vorhandenen .planer/einheiten/-Dateien."""
out: dict[str, tuple[str, str]] = {}
verzeichnis = wurzel / ".planer" / "einheiten"
for p in verzeichnis.glob("*.md") if verzeichnis.is_dir() else []:
text = p.read_text(encoding="utf-8")
kopf = text.splitlines()[0] if text else ""
datei = kopf.removeprefix("# Einheiten:").strip()
stand = artefakte.lese_stand(text)
if datei and stand:
out[datei] = (stand, text)
return out
def rescan_plan(wurzel: Path) -> tuple[set[str], dict[str, str], list[str]]:
"""→ (zu_scannen, unveraendert {datei: text}, geloeschte_quelldateien).
Wirft VorflugFehler, wenn noch kein Scan existiert."""
bestand = einheiten_bestand(wurzel)
if not bestand:
raise VorflugFehler("keine .planer/einheiten/ gefunden — erst `scan` ausführen")
scanbar = {str(p.relative_to(wurzel)) for p in chunker.dateien(wurzel)}
diff_cache: dict[str, set[str]] = {}
def geaendert_seit(stand: str) -> set[str]:
if stand not in diff_cache:
diff_cache[stand] = repo.geaenderte_dateien(wurzel, stand)[0]
return diff_cache[stand]
zu_scannen: set[str] = scanbar - set(bestand)
unveraendert: dict[str, str] = {}
for datei, (stand_alt, text) in bestand.items():
if datei not in scanbar:
continue
if stand_alt == "UNBEKANNT" or datei in geaendert_seit(stand_alt):
zu_scannen.add(datei)
else:
unveraendert[datei] = text
geloescht = [datei for datei in bestand if datei not in scanbar]
return zu_scannen, unveraendert, geloescht
async def rescan_lauf(wurzel: Path, projekt: str, stand: str, set_p=None) -> dict:
"""Kompletter Re-Scan: Plan, Aufräumen gelöschter Artefakte, Lauf. Keine Änderungen →
{status: 'unveraendert'}."""
zu_scannen, unveraendert, geloescht = rescan_plan(wurzel)
for datei in geloescht:
(wurzel / ".planer" / artefakte.datei_pfad(datei)).unlink(missing_ok=True)
if not zu_scannen and not geloescht:
return {"status": "unveraendert", "geschrieben": [], "dead": []}
if not zu_scannen and unveraendert:
# Nur Löschungen: Sichten müssen neu — kleinste verbliebene Datei mit-rescannen
zu_scannen = {min(unveraendert, key=lambda d: len(unveraendert[d]))}
unveraendert.pop(next(iter(zu_scannen)))
log.info("rescan %s: %d neu, %d unverändert, %d gelöscht",
projekt, len(zu_scannen), len(unveraendert), len(geloescht))
return await scan_board.run_scan(wurzel, projekt, stand, set_p=set_p,
nur_dateien=zu_scannen, bestand=unveraendert)
# --- Hintergrund-Läufe (Server) --------------------------------------------------------
_laeufe: dict[str, dict] = {} # projekt → {task, art, gestartet, ergebnis}
def starte(name: str, wurzel: Path, art: str, fortsetzen: bool = False) -> str:
"""Startet scan|rescan als Hintergrund-Task. → 'gestartet' | 'laeuft'.
Wirft VorflugFehler synchron (Route antwortet 409 mit Meldung)."""
eintrag = _laeufe.get(name)
if eintrag and not eintrag["task"].done():
return "laeuft"
stand = vorflug(wurzel)
if art == "rescan":
rescan_plan(wurzel) # wirft synchron, wenn es keinen Erst-Scan gibt
async def lauf():
if (e := _laeufe.get(name)) and e.get("laeuft_intern"): # Re-Entry-Schutz
return
_laeufe[name]["laeuft_intern"] = True
try:
if art == "rescan":
ergebnis = await rescan_lauf(wurzel, name, stand)
else:
ergebnis = await scan_board.run_scan(wurzel, name, stand, fortsetzen=fortsetzen)
_laeufe[name]["ergebnis"] = ergebnis
except Exception as e: # Status muss den Fehler zeigen, nie stiller Tod
log.exception("lauf %s (%s) abgebrochen", name, art)
_laeufe[name]["ergebnis"] = {"status": "abbruch", "infra_error": str(e),
"geschrieben": [], "dead": []}
_laeufe[name] = {"task": asyncio.create_task(lauf()), "art": art,
"gestartet": time.time(), "ergebnis": None}
return "gestartet"
def status(name: str) -> dict | None:
eintrag = _laeufe.get(name)
if eintrag is None:
return None
return {"laeuft": not eintrag["task"].done(), "art": eintrag["art"],
"gestartet": eintrag["gestartet"], "ergebnis": eintrag["ergebnis"]}

48
backend/server.py Normal file
View File

@@ -0,0 +1,48 @@
"""FastAPI-Server des Planers (Phase 2).
Start: `uvicorn server:app --port 8100` aus backend/ — OHNE --reload: der Reload killt
laufende Scans im eigenen Prozess (Creator-Lehre; siehe README, Selbst-Änderungs-Regel).
"""
import sys
from contextlib import asynccontextmanager
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from fastapi import FastAPI # noqa: E402
from fastapi.staticfiles import StaticFiles # noqa: E402
from starlette.responses import Response # noqa: E402
import agents # noqa: E402
import config # noqa: E402
import database as db # noqa: E402
from routes import router # noqa: E402
FRONTEND_DIST = config.PROJECT_ROOT / "frontend" / "dist"
@asynccontextmanager
async def lifespan(app: FastAPI):
await db.init_db()
agents.on_event = db.add_event
yield
await db.close_db()
class CachedStatic(StaticFiles):
"""assets/ = unveränderlich (gehashte Namen), index.html = nie cachen."""
def file_response(self, full_path, stat_result, scope, status_code=200) -> Response:
resp = super().file_response(full_path, stat_result, scope, status_code)
pfad = str(full_path)
if "/assets/" in pfad:
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
elif pfad.endswith("index.html"):
resp.headers["Cache-Control"] = "no-cache"
return resp
app = FastAPI(title="Planer", lifespan=lifespan)
app.include_router(router)
if FRONTEND_DIST.exists():
app.mount("/", CachedStatic(directory=FRONTEND_DIST, html=True), name="frontend")

View File

@@ -0,0 +1,53 @@
# Auftrag: Code-Ausschnitt in Einheiten zerlegen
Du bekommst unten einen Ausschnitt der Datei `<DATEI>` aus einem Software-Projekt.
Zerlege ihn in **Einheiten** und gib NUR die Einheiten-Abschnitte im unten definierten
Format zurück — nichts davor, nichts danach, keine Code-Fences um das Gesamtergebnis.
Diese Top-Level-Symbole des Ausschnitts MÜSSEN alle abgedeckt sein:
<SYMBOLE>
## Was ist eine Einheit
Das Kleinste mit **eigener Verantwortung**, das sich in einem Satz beschreiben lässt —
meist eine Funktion oder Klasse; mehrere kleine, zusammengehörige Symbole dürfen eine
Einheit bilden (Hauptsymbol als ID, weitere je als `anker:`-Zeile). Jedes gelistete
Symbol gehört zu genau einer Einheit (MECE).
Hat der Ausschnitt KEINE def/class-Symbole (reines Skript, Konfiguration), erzeuge
GENAU EINE Datei-Einheit mit der ID `<DATEI>` (ohne `::`) — erfinde NIE Pseudo-Symbole.
## Ausgabeformat (exakt einhalten)
```
## <DATEI>::<hauptsymbol>
beschreibung: <genau ein Satz: die Verantwortung — EINE Aufgabe, kein „und"-Katalog>
input: <was die Einheit entgegennimmt — eine knappe Zeile>
output: <was sie liefert bzw. bewirkt — eine knappe Zeile>
entscheidungen:
- <kurzer Satz: eine im Code getroffene Design-Entscheidung oder Garantie>
beleg: "<kurzes WÖRTLICHES Zitat aus dem Ausschnitt, das sie stützt>"
kanten:
- ruft-auf: <einheiten-id>
- nutzt: <einheiten-id>
- wird-genutzt-von: <einheiten-id>
```
Regeln:
- `beschreibung:` genau EIN Satz, EINE Aufgabe. `input:`/`output:` sind Pflicht.
- 25 Entscheidungen je Einheit: Design-Entscheidungen und Garantien, KEIN
Implementierungs-Nacherzählen. Jede hat GENAU EINE `beleg:`-Zeile auf eigener Zeile.
- Der Beleg muss WÖRTLICH (Zeichen für Zeichen) im Ausschnitt vorkommen und ein
zusammenhängender Ausschnitt aus GENAU EINER Quellzeile sein — NIEMALS mehrere
Zeilen zu einer zusammenziehen. Max. ~60 Zeichen.
- Kanten-Typen nur: `ruft-auf`, `nutzt`, `wird-genutzt-von`, `löst-aus`,
`wird-ausgelöst-von`. Hinter dem Ziel steht NICHTS mehr. Ziele in anderen Modulen
(aus Imports ableitbar) als `pfad/zur/datei.py::symbol`. Keine Kante erfinden —
lieber weniger als falsche.
- Schreibe auf Deutsch.
## Ausschnitt aus `<DATEI>`
```python
<QUELLE>
```

View File

@@ -0,0 +1,33 @@
# Auftrag: Fehlende Einheiten ergänzen
Ein Ausschnitt der Datei `<DATEI>` wurde bereits in Einheiten zerlegt (Bestand unten).
Die Abdeckungsprüfung meldet, dass folgende Top-Level-Symbole noch KEINER Einheit
zugeordnet sind:
<FEHLEND>
Erzeuge NUR die fehlenden Einheiten-Abschnitte im selben Format wie der Bestand —
nichts davor, nichts danach, keine Wiederholung bestehender Einheiten, keine
Code-Fences um das Gesamtergebnis.
NUR WENN ein fehlendes Symbol ein unselbstständiger Hilfssatellit einer bestehenden
Einheit ist — es wird AUSSCHLIESSLICH vom Hauptsymbol dieser Einheit verwendet und hat
keine eigene Verantwortung — darfst du statt einer neuen Einheit eine Zeile
`ergaenze-anker: <DATEI>::<symbol> zu <bestehende-einheiten-id>` ausgeben.
Im Zweifel IMMER eine eigene Einheit. Symbole mit benennbarer eigener Aufgabe
(Exception-Klasse, Schema-Bauer, Konstante mit Bedeutung) sind IMMER eigene Einheiten.
Format-Regeln wie beim Erst-Scan: `beschreibung:` (ein Satz), `input:`, `output:`,
25 `entscheidungen:` mit je GENAU EINER `beleg:`-Zeile (WÖRTLICHES Zitat aus GENAU
EINER Quellzeile, max. ~60 Zeichen), `kanten:` nur mit den bekannten Typen.
Schreibe auf Deutsch.
## Bestand (bereits zerlegte Einheiten dieses Ausschnitts)
<VORHANDEN>
## Ausschnitt aus `<DATEI>`
```python
<QUELLE>
```

View File

@@ -0,0 +1,29 @@
# Auftrag: Architektur-Sicht aggregieren
Unten stehen alle Einheiten des Projekts `<PROJEKT>` (ID + Beschreibung + Kanten).
Aggregiere daraus die **Architektur-Sicht**: die Komponenten des Systems und ihre
Beziehungen. Beschreibe NUR, was Einheiten und Kanten belegen.
## Ausgabeformat (exakt einhalten)
```
# Architektur: <PROJEKT>
## Komponente: <kurzer Name, OHNE Klammern, OHNE „und">
beschreibung: <EIN kurzer Satz: die Verantwortung der Komponente>
einheiten: <einheiten-id>, <einheiten-id>, …
beziehungen:
- nutzt: <andere Komponente (exakter Name)>
- wird-genutzt-von: <andere Komponente (exakter Name)>
```
Regeln:
- 38 Komponenten; jede Einheit gehört zu GENAU EINER Komponente (MECE).
- Eine Beziehung nur, wenn mindestens eine Kante zwischen Einheiten der beiden
Komponenten sie trägt — keine Beziehung erfinden.
- `einheiten:` nur IDs, die unten wirklich vorkommen (exakte Schreibweise).
- Schreibe auf Deutsch. Gib NUR das Markdown aus, ohne Code-Fences drumherum.
## Einheiten von `<PROJEKT>` (mit Kanten)
<EINHEITEN>

View File

@@ -0,0 +1,51 @@
# Auftrag: Feature-Sicht und Kern aggregieren
Unten stehen alle Einheiten des Projekts `<PROJEKT>` (ID + Ein-Satz-Beschreibung).
Aggregiere daraus die **Feature-Sicht** (was kann das Projekt aus Betreibersicht?)
und den **Kern** (was trägt das Projekt?). Beschreibe NUR, was die Einheiten belegen —
erfinde nichts aus Weltwissen.
WICHTIG — Zielgruppe ist die GESCHÄFTSFÜHRUNG, nicht Entwickler:
- Beschreibe, WAS das Produkt kann und WOZU es gut ist — NIE, wie es im Code
umgesetzt ist.
- Verboten: Code-Begriffe und Jargon (Funktions-/Modul-/Dateinamen, Backticks,
Wörter wie Präfix, Spawn, Semaphore, CLI, Cache, Payload, Thread, Lock, Registry,
Callback, Endpoint). Wenn du so ein Wort brauchst, beschreibe stattdessen die
Wirkung in Alltagssprache.
- Titel und Sätze so, dass sie jemand ohne Programmierkenntnisse sofort versteht.
Beispiel: statt „Markiert einen Schlüssel-Präfix global als abgebrochen" →
„Laufende Arbeiten lassen sich gezielt stoppen".
- Die `einheiten:`-Referenzzeilen bleiben unverändert technisch (exakte IDs) —
sie sind Maschinen-Verweise, keine Leser-Texte.
## Ausgabeformat (exakt einhalten; zuerst der Kern, dann die Bereiche)
```
# Kern: <PROJEKT>
<2-4 Sätze: wofür das Projekt existiert und was es im Kern tut>
tragende-einheiten: <einheiten-id>, <einheiten-id>, …
# Bereich: <kurzer Name, OHNE Klammern, OHNE „und">
beschreibung: <EIN kurzer Satz, der den Kern des Bereichs trifft — keine Aufzählung>
## Feature: <Titel OHNE „und" — genau EINE Aufgabe> [kern|rand]
beschreibung: <EIN kurzer Satz ohne „und"-Aufzählung>
### <Teilfeature: Fähigkeits-Satz aus Betreibersicht, z. B. „Kann nach Abbruch fortsetzen">
einheiten: <einheiten-id>, <einheiten-id>
```
Regeln:
- 310 Bereiche; MECE je Ebene: Bereiche zerlegen das Projekt, Features ihren Bereich,
Teilfeatures ihr Feature — vollständig, überschneidungsfrei.
- Braucht ein Titel „und", mach zwei Bereiche/Features daraus.
- `[kern]` = trägt das Projekt, `[rand]` = Komfort/Beiwerk.
- `einheiten:` listet NUR IDs, die unten wirklich vorkommen (exakte Schreibweise);
jedes Teilfeature hat mindestens eine. Jede Einheit sollte in mindestens einem
Teilfeature auftauchen; reine interne Helfer dürfen unreferenziert bleiben.
- Schreibe auf Deutsch. Gib NUR das Markdown aus, ohne Code-Fences drumherum.
## Einheiten von `<PROJEKT>`
<EINHEITEN>

View File

@@ -0,0 +1,26 @@
# Auftrag: Flow-Sicht aggregieren
Unten stehen alle Einheiten des Projekts `<PROJEKT>` (ID + Beschreibung + Kanten).
Aggregiere daraus die **Flow-Sicht**: die wichtigsten Abläufe des Systems als
Schrittfolgen. Beschreibe NUR Abläufe, die die Einheiten und ihre Kanten belegen.
## Ausgabeformat (exakt einhalten)
```
# Flows: <PROJEKT>
## Flow: <kurzer Titel des Ablaufs, OHNE „und">
1. <Schritt in einem Satz> — <einheiten-id>
2. <Schritt in einem Satz> — <einheiten-id>, <einheiten-id>
```
Regeln:
- 26 Flows, je 310 Schritte; nur die tragenden Abläufe, keine Randfälle.
- Jeder Schritt endet mit ` — ` und mindestens einer Einheiten-ID, die unten wirklich
vorkommt (exakte Schreibweise).
- Die Schrittfolge muss zu den Kanten passen (wer ruft wen) — nichts erfinden.
- Schreibe auf Deutsch. Gib NUR das Markdown aus, ohne Code-Fences drumherum.
## Einheiten von `<PROJEKT>` (mit Kanten)
<EINHEITEN>

66
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,66 @@
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import config # noqa: E402
import database # noqa: E402
import fake_agents # noqa: E402
@pytest.fixture
async def testdb(tmp_path, monkeypatch):
"""Frische SQLite-Datei je Test; setzt die modul-globale Connection zurück."""
monkeypatch.setattr(config, "DB_PATH", tmp_path / "test.db")
database._db = None
await database.init_db()
yield database
await database.close_db()
@pytest.fixture
async def gescanntes_repo(tmp_path, monkeypatch, testdb):
"""mini_repo als echtes git-Repo mit echtem .planer/ (Fake-Scan, echter HEAD-Hash) —
Grundlage für Viewer-/Drift-/Routen-Tests."""
import shutil
import subprocess
import fake_agents
import kanban
import repo as repo_mod
import scan_board
wurzel = tmp_path / "mini"
shutil.copytree(Path(__file__).parent / "fixtures" / "mini_repo", wurzel)
env = {"PATH": "/usr/bin:/bin", "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", "HOME": str(wurzel)}
def git(*args):
subprocess.run(["git", "-C", str(wurzel), *args], check=True, capture_output=True, env=env)
git("init", "-b", "main")
git("add", "-A")
git("commit", "-m", "start")
welt = fake_agents.Welt()
fake_agents.set_welt(welt)
fake_agents.standard_antworten(welt)
monkeypatch.setenv("PLANER_FAKE_AGENTS", "1")
monkeypatch.setattr(kanban, "RETRY_BACKOFF", 0.05)
ergebnis = await scan_board.run_scan(wurzel, "mini", stand=repo_mod.head_hash(wurzel))
assert ergebnis["status"] == "ok", ergebnis
git("add", "-A")
git("commit", "-m", "artefakte")
yield wurzel, git
fake_agents.set_welt(None)
@pytest.fixture
def fake_welt(monkeypatch):
"""Frische Fake-Welt + PLANER_FAKE_AGENTS aktiv; Tests hängen Antworten/Störungen an."""
welt = fake_agents.Welt()
fake_agents.set_welt(welt)
monkeypatch.setenv("PLANER_FAKE_AGENTS", "1")
yield welt
fake_agents.set_welt(None)

View File

@@ -0,0 +1,7 @@
"""Mini-Fixture: Ausgabe."""
from rechner import addiere
def zeige(a, b):
"""Formatiert die Summe."""
return f"Ergebnis: {addiere(a, b)}"

View File

@@ -0,0 +1,23 @@
"""Mini-Fixture: kleiner Rechner."""
BASIS = 10
def addiere(a, b):
"""Summe zweier Zahlen."""
return a + b
def skaliere(wert):
"""Skaliert mit der Modul-Basis."""
return wert * BASIS
class Speicher:
"""Merkt sich das letzte Ergebnis."""
def __init__(self):
self.letztes = None
def merke(self, wert):
self.letztes = wert

View File

@@ -0,0 +1,50 @@
"""Provider-Layer: Fake-Umschaltung, harter Stopp ohne Rollen-Config, kein Fallback."""
import re
from pathlib import Path
import pytest
import agents
async def test_fake_umschaltung(fake_welt):
fake_welt.antwort_auf(r"-schneiden-", lambda prompt: "ANTWORT")
rc, out, err = await agents.run_agent("scan-x-schneiden-1", "egal", 5, role="schneiden")
assert (rc, out) == (0, "ANTWORT")
assert fake_welt.calls == ["scan-x-schneiden-1"]
async def test_stoerung_dann_fehler(fake_welt):
fake_welt.antwort_auf(r".", lambda prompt: "ok")
fake_welt.stoerungen.append({"muster": r"-schneiden-", "modus": "fehler", "mal": 1})
rc1, _, err1 = await agents.run_agent("scan-x-schneiden-1", "egal", 5, role="schneiden")
rc2, out2, _ = await agents.run_agent("scan-x-schneiden-1", "egal", 5, role="schneiden")
assert (rc1, err1) == (1, "fake-stoerung")
assert (rc2, out2) == (0, "ok")
async def test_infra_stoerung_wirft(fake_welt):
fake_welt.stoerungen.append({"muster": r".", "modus": "infra", "mal": 1})
with pytest.raises(agents.AgentInfraError):
await agents.run_agent("scan-x-schneiden-1", "egal", 5, role="schneiden")
async def test_unkonfigurierte_rolle_pausiert(monkeypatch):
"""Ohne Fake-Modus und ohne ROLLE_-Env: AgentInfraError (Lauf pausiert), kein Default."""
monkeypatch.delenv("PLANER_FAKE_AGENTS", raising=False)
monkeypatch.delenv("ROLLE_JUDGE", raising=False)
with pytest.raises(agents.AgentInfraError, match="kein Modell konfiguriert"):
await agents.run_agent("scan-x-judge-1", "egal", 5, role="judge")
def test_kein_fallback_codepfad():
"""Der Creator-Fallback (_available_cached / 'routed provider unavailable') darf
nicht mitportiert worden sein."""
quelle = Path(agents.__file__).read_text(encoding="utf-8")
assert "_available_cached" not in quelle
assert not re.search(r"run_provider", quelle)
def test_prioritaet_der_stufen():
assert agents._agent_priority("scan-p-sichten-1") < agents._agent_priority("scan-p-schneiden-9")
assert agents._agent_priority("irgendwas") == len(agents._STAGE_PRIORITY)

View File

@@ -0,0 +1,65 @@
"""Artefakt-Schicht: Merge, ergaenze-anker, Writer, Prüfungs-Gerüst."""
from pathlib import Path
import artefakte
FRAG_A = """## a.py::eins
beschreibung: Erste.
input: x
output: y
## a.py::zwei
beschreibung: Zweite.
"""
FRAG_NACHFASS = """ergaenze-anker: a.py::_helfer zu a.py::eins
## a.py::drei
beschreibung: Dritte.
"""
def test_merge_mit_nachfass():
rumpf = artefakte.merge_fragmente([FRAG_A, FRAG_NACHFASS])
assert rumpf.index("## a.py::eins") < rumpf.index("## a.py::zwei") < rumpf.index("## a.py::drei")
assert "anker: a.py::_helfer" in rumpf.split("## a.py::zwei")[0] # im eins-Block
assert "ergaenze-anker" not in rumpf
def test_merge_dubletten_fallen_weg():
rumpf = artefakte.merge_fragmente([FRAG_A, FRAG_A])
assert rumpf.count("## a.py::eins") == 1
def test_einheiten_datei_und_stand():
text = artefakte.einheiten_datei("a.py", "## a.py::eins\nbeschreibung: X.", "abc123")
assert text.startswith("# Einheiten: a.py\nstand: abc123\n")
assert artefakte.lese_stand(text) == "abc123"
def test_alle_ids():
text = "## a.py::eins\nanker: a.py::_helfer\n\n## a.py::zwei\n"
assert artefakte.alle_ids(text) == {"a.py::eins", "a.py::_helfer", "a.py::zwei"}
def test_datei_pfad():
assert artefakte.datei_pfad("backend/kanban.py") == "einheiten/backend__kanban.py.md"
def test_schreibe_planer(tmp_path):
pfade = artefakte.schreibe_planer(tmp_path, {"kern.md": "# Kern\n",
"einheiten/a.py.md": "# Einheiten: a.py\n"})
assert (tmp_path / ".planer" / "kern.md").read_text() == "# Kern\n"
assert len(pfade) == 2
def test_pruefung_geruest_mit_makefile(tmp_path):
(tmp_path / "Makefile").write_text("test:\n\tpytest\ndev:\n\tuvicorn\n")
text = artefakte.pruefung_geruest(tmp_path, "demo")
assert "tests: make test" in text and "start: make dev" in text
assert "lint: (keins vorhanden)" in text
def test_pruefung_geruest_ohne_alles(tmp_path):
text = artefakte.pruefung_geruest(tmp_path, "demo")
assert "tests: (keins vorhanden)" in text

View File

@@ -0,0 +1,57 @@
"""Chunker: Ausschlussliste, Symbol-Schnitt, Chunk-Größen."""
from pathlib import Path
import chunker
MINI = Path(__file__).parent / "fixtures" / "mini_repo"
def test_mini_repo_dateien():
rel = [str(p.relative_to(MINI)) for p in chunker.dateien(MINI)]
assert rel == ["anzeige.py", "rechner.py"]
def test_ausschluss(tmp_path):
(tmp_path / "ok.py").write_text("X = 1\n")
(tmp_path / ".env").write_text("GEHEIM=1\n")
(tmp_path / ".env.local").write_text("GEHEIM=2\n")
(tmp_path / "node_modules").mkdir()
(tmp_path / "node_modules" / "paket.py").write_text("Y = 1\n")
(tmp_path / ".planer").mkdir()
(tmp_path / ".planer" / "alt.py").write_text("Z = 1\n")
rel = [str(p.relative_to(tmp_path)) for p in chunker.dateien(tmp_path)]
assert rel == ["ok.py"]
def test_schnitt_mini():
text = (MINI / "rechner.py").read_text()
chunks = chunker.schneide("rechner.py", text)
assert len(chunks) == 1 # klein → ein Chunk
assert chunks[0]["symbole"] == ["addiere", "skaliere", "Speicher"]
assert chunks[0]["text"] == text.rstrip("\n") # Kopf (Docstring, BASIS) im ersten Chunk
def test_schnitt_grosse_datei():
"""Symbole à 60 Zeilen → Chunks bleiben um CHUNK_ZEILEN, Schnitt nie mitten im Symbol."""
bloecke = [f"def f{i}():\n" + "".join(f" x{j} = {j}\n" for j in range(60)) for i in range(20)]
text = "\n".join(bloecke)
chunks = chunker.schneide("gross.py", text)
assert len(chunks) > 1
assert all(c["groesse"] <= chunker.CHUNK_ZEILEN + 61 for c in chunks)
alle = [s for c in chunks for s in c["symbole"]]
assert alle == [f"f{i}" for i in range(20)] # jedes Symbol genau einmal, in Reihenfolge
for c in chunks:
assert c["text"].splitlines()[0].startswith(("def ", "\n", '"')) # Schnitt an Blockgrenze
def test_creator_smoke():
"""Read-only gegen das echte Creator-Backend: board_inventory.py (2344 Z.) wird zerlegt."""
wurzel = Path("/home/arbeit/projects/creator")
chunks = [c for c in chunker.chunks_fuer_repo(wurzel, {"backend/board_inventory.py"})]
assert len(chunks) >= 6
assert all(c["groesse"] <= 700 for c in chunks) # kein Riesen-Chunk
def test_top_level_symbole():
text = (MINI / "rechner.py").read_text()
assert chunker.top_level_symbole(text) == {"BASIS", "addiere", "skaliere", "Speicher"}

View File

@@ -0,0 +1,31 @@
"""Rollen-Auflösung: explizit konfiguriert oder harter Stopp — nie ein Default."""
import pytest
import config
def test_konfigurierte_rolle(monkeypatch):
monkeypatch.setenv("ROLLE_SCHNEIDEN", "minimax:minimax/MiniMax-M3")
assert config.resolve_role("schneiden") == ("minimax", "minimax/MiniMax-M3")
def test_unkonfigurierte_rolle_stoppt(monkeypatch):
monkeypatch.delenv("ROLLE_JUDGE", raising=False)
with pytest.raises(RuntimeError, match="kein Modell konfiguriert"):
config.resolve_role("judge")
def test_unbekannter_provider_stoppt(monkeypatch):
monkeypatch.setenv("ROLLE_SICHTEN", "openai:gpt-4")
with pytest.raises(RuntimeError, match="unbekannter Provider"):
config.resolve_role("sichten")
def test_fehlendes_modell_stoppt(monkeypatch):
monkeypatch.setenv("ROLLE_NACHFASS", "minimax")
with pytest.raises(RuntimeError, match="Modell fehlt"):
config.resolve_role("nachfass")
def test_alle_rollen_deklariert():
assert set(config.ROLLEN) == {"schneiden", "nachfass", "sichten", "judge"}

View File

@@ -0,0 +1,33 @@
"""DB-Roundtrip: Karten + Telemetrie."""
import json
async def test_karten_roundtrip(testdb):
await testdb.kanban_upsert_card("p", "scan", "c1", "chunk", "schneiden", {"groesse": 10})
await testdb.kanban_upsert_card("p", "scan", "c2", "chunk", "schneiden", {"groesse": 99})
karten = await testdb.kanban_pull("p", "scan", "schneiden", 10)
assert [k["card_id"] for k in karten] == ["c2", "c1"] # LPT: größte zuerst
assert karten[0]["payload"]["groesse"] == 99
await testdb.kanban_advance_many("p", "scan", [("c1", "sammeln")])
assert await testdb.kanban_count("p", "schneiden") == 1
assert (await testdb.kanban_stage_counts("p"))["scan"] == {"schneiden": 1, "sammeln": 1}
async def test_payload_erhalt_bei_upsert_ohne_payload(testdb):
await testdb.kanban_upsert_card("p", "scan", "c1", "chunk", "schneiden", {"datei": "a.py"})
await testdb.kanban_upsert_card("p", "scan", "c1", "chunk", "nachfass", None)
karte = await testdb.kanban_get_card("p", "scan", "c1")
assert karte["stage"] == "nachfass" and karte["payload"]["datei"] == "a.py"
async def test_events_mit_run_id(testdb):
testdb.set_current_run("p", "lauf-1")
await testdb.add_event("p", "agent", key="scan:c1", status="ok", dur_ms=1200,
meta={"tokens": {"output": 500}})
db = await testdb.get_db()
cursor = await db.execute("SELECT kind, status, run_id, meta FROM events WHERE topic='p'")
kind, status, run_id, meta = await cursor.fetchone()
assert (kind, status, run_id) == ("agent", "ok", "lauf-1")
assert json.loads(meta)["tokens"]["output"] == 500
testdb.set_current_run("p", None)

159
backend/tests/test_gates.py Normal file
View File

@@ -0,0 +1,159 @@
"""Gates gegen das mini_repo-Fixture: gültiges Fragment passiert, jede Manipulation
wird mit genau dem erwarteten Fehler gefangen."""
from pathlib import Path
import gates
MINI = Path(__file__).parent / "fixtures" / "mini_repo"
GUT = '''# Einheiten: rechner.py
stand: UNBEKANNT
## rechner.py::addiere
beschreibung: Addiert zwei Zahlen.
input: zwei Zahlen a, b
output: die Summe
entscheidungen:
- Reine Funktion ohne Zustand.
beleg: "return a + b"
kanten:
- wird-genutzt-von: anzeige.py::zeige
## rechner.py::Speicher
beschreibung: Merkt sich das letzte Ergebnis.
input: Werte über merke()
output: letzter Wert in self.letztes
entscheidungen:
- Startet leer.
beleg: "self.letztes = None"
kanten:
- nutzt: rechner.py::addiere
'''
def test_gutes_fragment_passiert():
fehler, warnungen, einheiten = gates.pruefe_fragment(GUT, MINI, "rechner.py")
assert fehler == []
assert [e["id"] for e in einheiten] == ["rechner.py::addiere", "rechner.py::Speicher"]
def test_falsches_zitat():
kaputt = GUT.replace('beleg: "return a + b"', 'beleg: "return a - b"')
fehler, _, _ = gates.pruefe_fragment(kaputt, MINI, "rechner.py")
assert any("Zitat nicht wörtlich" in f for f in fehler)
def test_erfundener_anker():
kaputt = GUT.replace("## rechner.py::addiere", "## rechner.py::subtrahiere")
fehler, _, _ = gates.pruefe_fragment(kaputt, MINI, "rechner.py")
assert any("Symbol 'subtrahiere' nicht in" in f for f in fehler)
def test_kaputte_kante():
kaputt = GUT.replace("- nutzt: rechner.py::addiere",
"- nutzt: rechner.py::addiere (wichtig!)")
fehler, _, _ = gates.pruefe_fragment(kaputt, MINI, "rechner.py")
assert any("Kante unlesbar" in f for f in fehler)
def test_unbekannter_kanten_typ():
kaputt = GUT.replace("- nutzt:", "- verwendet:")
fehler, _, _ = gates.pruefe_fragment(kaputt, MINI, "rechner.py")
assert any("unbekannter Kanten-Typ" in f for f in fehler)
def test_fremde_datei_abgelehnt():
fehler, _, _ = gates.pruefe_fragment(GUT, MINI, "anzeige.py")
assert any("gehört nicht zur gescannten Datei" in f for f in fehler)
def test_leere_antwort():
fehler, _, _ = gates.pruefe_fragment("", MINI, "rechner.py")
assert any("keine Einheit geparst" in f for f in fehler)
def test_escaping_normalisierung():
frag = GUT.replace('beleg: "return a + b"', 'beleg: "return f\\"Ergebnis: {addiere(a, b)}\\""')
frag = frag.replace("## rechner.py::addiere", "## anzeige.py::zeige").replace(
"# Einheiten: rechner.py", "# Einheiten: anzeige.py")
# nur den umgebauten zeige-Block prüfen
block = frag.split("\n\n")[1]
fehler, _, _ = gates.pruefe_fragment(block, MINI, "anzeige.py")
assert not any("Zitat" in f for f in fehler)
def test_fehlende_symbole():
_, _, einheiten = gates.pruefe_fragment(GUT, MINI, "rechner.py")
fehlt = gates.fehlende_symbole(["addiere", "skaliere", "Speicher", "BASIS"], einheiten, [])
assert fehlt == ["skaliere", "BASIS"]
fehlt2 = gates.fehlende_symbole(["skaliere"], einheiten, ["rechner.py::skaliere"])
assert fehlt2 == []
def test_repariere_kanten():
"""Erfundene Kanten-Ziele werden gestrichen (Datei erfunden, Symbol erfunden,
Prefix fehlt) — gültige Kanten und alle anderen Zeilen bleiben unangetastet."""
kaputt = GUT.replace(
"- nutzt: rechner.py::addiere",
"- nutzt: rechner.py::addiere\n- ruft-auf: erfunden.py::x\n"
"- nutzt: rechner.py::gibtsnicht\n- löst-aus: nur_symbol")
bereinigt, gestrichen = gates.repariere_kanten(kaputt, MINI, "rechner.py")
assert len(gestrichen) == 3
assert "- nutzt: rechner.py::addiere" in bereinigt
assert "erfunden.py" not in bereinigt and "gibtsnicht" not in bereinigt
fehler, _, _ = gates.pruefe_fragment(bereinigt, MINI, "rechner.py")
assert fehler == []
def test_repariere_belege_schnappt_fast_richtige_zitate():
"""Ein-Zeichen-Abweichung (`=` statt Original) wird auf die echte Quellzeile
geschnappt; frei erfundene Zitate bleiben stehen und fallen im Gate durch."""
kaputt = GUT.replace('beleg: "return a + b"', 'beleg: "return a+b"')
repariert, korrigiert = gates.repariere_belege(kaputt, MINI, "rechner.py")
assert len(korrigiert) == 1
fehler, _, _ = gates.pruefe_fragment(repariert, MINI, "rechner.py")
assert fehler == []
erfunden = GUT.replace('beleg: "return a + b"', 'beleg: "raise TotalErfunden(xyz)"')
repariert2, korrigiert2 = gates.repariere_belege(erfunden, MINI, "rechner.py")
assert korrigiert2 == []
fehler2, _, _ = gates.pruefe_fragment(repariert2, MINI, "rechner.py")
assert any("Zitat nicht wörtlich" in f for f in fehler2)
def test_qualifizierte_methode_als_symbol():
frag = GUT.replace("## rechner.py::Speicher", "## rechner.py::Speicher.merke")
fehler, _, _ = gates.pruefe_fragment(frag, MINI, "rechner.py")
assert not any("Speicher.merke" in f for f in fehler)
def test_beleg_mit_anfuehrungszeichen():
"""Belege mit eingebetteten Quotes/Regex (Befund 17) parsen und passieren."""
frag = ('## anzeige.py::zeige\nbeschreibung: Formatiert.\ninput: a, b\noutput: Text\n'
'entscheidungen:\n- Nutzt einen f-String.\n'
' beleg: "return f"Ergebnis: {addiere(a, b)}""\nkanten:\n')
fehler, _, _ = gates.pruefe_fragment(frag, MINI, "anzeige.py")
assert fehler == []
def test_datei_einheit_ohne_symbole(tmp_path):
"""Skript ohne def/class: Datei-Einheit `## pfad` (Befund 18)."""
(tmp_path / "skript.py").write_text('import sys\nprint(sys.argv)\n')
frag = ('## skript.py\nbeschreibung: Gibt Argumente aus.\ninput: argv\noutput: stdout\n'
'entscheidungen:\n- Direkter Druck ohne Formatierung.\n beleg: "print(sys.argv)"\nkanten:\n')
fehler, _, einheiten = gates.pruefe_fragment(frag, tmp_path, "skript.py")
assert fehler == [] and einheiten[0]["id"] == "skript.py"
def test_referenz_korrektur_falscher_prefix():
"""Symbol existiert eindeutig unter anderer Datei → ID wird korrigiert (Befund 19)."""
sicht = "### Kann reparieren\neinheiten: falsch.py::_fix, kaputt.py::gibtsnirgends\n"
text, gestrichen = gates.repariere_referenzen(sicht, {"richtig.py::_fix", "a.py::x"})
assert "richtig.py::_fix" in text
assert gestrichen == ["kaputt.py::gibtsnirgends"]
def test_referenz_integritaet():
sicht = "### Kann rechnen\neinheiten: rechner.py::addiere, rechner.py::gibtsnicht\n"
fehler = gates.pruefe_referenzen(sicht, {"rechner.py::addiere"})
assert fehler == ["unbekannte Einheiten-Referenz: rechner.py::gibtsnicht"]

View File

@@ -0,0 +1,128 @@
"""Engine tests with fake processors (no LLM): flow, barrier, retry/dead-letter, producer race."""
import asyncio
import pytest
import kanban
from kanban import Flow, Stage, chain_stages, run_flow
TOPIC = "t"
BOARD = "inventory"
def _advance_proc(db, to_stage):
async def proc(cards):
await db.kanban_advance_many(TOPIC, BOARD, [(c["card_id"], to_stage) for c in cards])
return proc
async def _seed(db, n, stage="s1"):
for i in range(n):
await db.kanban_upsert_card(TOPIC, BOARD, f"card-{i}", "title", stage, {"title": f"T{i}"})
async def test_cards_flow_through_stages(testdb):
db = testdb
await _seed(db, 7)
flow = Flow(TOPIC)
stages = chain_stages([
Stage(BOARD, "s1", _advance_proc(db, "s2")),
Stage(BOARD, "s2", _advance_proc(db, "done")),
])
await asyncio.wait_for(run_flow(flow, stages), timeout=10)
assert await db.kanban_count(TOPIC, "done", board=BOARD) == 7
assert await db.kanban_count(TOPIC, ["s1", "s2"], board=BOARD) == 0
async def test_barrier_waits_for_upstream(testdb):
db = testdb
await _seed(db, 6)
upstream_left: list[int] = []
async def slow_s1(cards):
await asyncio.sleep(0.05) # keep upstream busy so an eager barrier would see queued cards
await db.kanban_advance_many(TOPIC, BOARD, [(c["card_id"], "gate") for c in cards])
async def barrier_proc(cards):
upstream_left.append(await db.kanban_count(TOPIC, ["s1"], board=BOARD))
await db.kanban_advance_many(TOPIC, BOARD, [(c["card_id"], "done") for c in cards])
flow = Flow(TOPIC)
stages = chain_stages([
Stage(BOARD, "s1", slow_s1),
Stage(BOARD, "gate", barrier_proc, barrier=True),
])
await asyncio.wait_for(run_flow(flow, stages), timeout=10)
assert await db.kanban_count(TOPIC, "done", board=BOARD) == 6
assert upstream_left and all(n == 0 for n in upstream_left) # barrier never ran with s1 queued
async def test_retry_backoff_then_dead(testdb, monkeypatch):
db = testdb
monkeypatch.setattr(kanban, "RETRY_BACKOFF", 0.02)
await _seed(db, 1)
attempts = []
async def failing(cards):
attempts.append(cards[0]["retries"])
raise RuntimeError("kaputt")
flow = Flow(TOPIC)
stages = chain_stages([Stage(BOARD, "s1", failing)])
await asyncio.wait_for(run_flow(flow, stages), timeout=10)
card = await db.kanban_get_card(TOPIC, BOARD, "card-0")
assert card["stage"] == "dead"
assert card["retries"] == kanban.MAX_CARD_RETRIES
assert "kaputt" in card["last_error"]
assert attempts == [0, 1, 2] # backoff between attempts, then dead-letter
async def test_requeue_dead(testdb):
db = testdb
await db.kanban_upsert_card(TOPIC, BOARD, "card-0", "title", "s1")
for _ in range(kanban.MAX_CARD_RETRIES):
await db.kanban_fail_card(TOPIC, BOARD, "card-0", "x", kanban.MAX_CARD_RETRIES, 0.0)
assert (await db.kanban_get_card(TOPIC, BOARD, "card-0"))["stage"] == "dead"
assert await db.kanban_requeue_dead(TOPIC, BOARD, "s1") == 1
card = await db.kanban_get_card(TOPIC, BOARD, "card-0")
assert card["stage"] == "s1" and card["retries"] == 0
async def test_producer_attach_in_idle_lull(testdb):
"""Fix-6 regression: a producer attached while workers sit in the exit grace poll
must keep the flow alive and its cards must still be processed."""
db = testdb
flow = Flow(TOPIC)
stages = chain_stages([Stage(BOARD, "s1", _advance_proc(db, "done"))])
async def producer_a():
await db.kanban_upsert_card(TOPIC, BOARD, "card-a", "title", "s1")
flow.wake.set()
flow.done_producer()
async def attacher():
while await db.kanban_count(TOPIC, "done", board=BOARD) == 0: # wait for card-a done
await asyncio.sleep(0.01)
flow.add_producer() # synchronous BEFORE the work — the grace poll must see it
async def producer_b():
await db.kanban_upsert_card(TOPIC, BOARD, "card-b", "title", "s1")
flow.wake.set()
flow.done_producer()
await producer_b()
flow.add_producer() # producer_a, counted before run_flow (sync add)
asyncio.get_event_loop().create_task(attacher())
await asyncio.wait_for(run_flow(flow, stages, producers=[producer_a()]), timeout=10)
assert await db.kanban_count(TOPIC, "done", board=BOARD) == 2
async def test_backoff_delays_pull(testdb, monkeypatch):
db = testdb
await db.kanban_upsert_card(TOPIC, BOARD, "card-0", "title", "s1")
await db.kanban_fail_card(TOPIC, BOARD, "card-0", "x", 5, 0.2)
assert await db.kanban_pull(TOPIC, BOARD, "s1", 10) == [] # in backoff → not pullable
assert await db.kanban_count(TOPIC, "s1", board=BOARD) == 1 # but still counts as queued
await asyncio.sleep(0.25)
assert len(await db.kanban_pull(TOPIC, BOARD, "s1", 10)) == 1

View File

@@ -0,0 +1,32 @@
"""Wächter für Leitprinzip 2: Modell-IDs stehen NUR in der .env, nie im Code.
Greppt den gesamten backend-Quellcode (ohne tests/) nach Modell-ID-Mustern.
Schlägt dieser Test an, hat jemand ein Modell hart verdrahtet.
"""
import re
from pathlib import Path
BACKEND = Path(__file__).resolve().parent.parent
MUSTER = re.compile(r"claude-\w+-\d|MiniMax-M\d|\bopus\b|\bsonnet\b|\bhaiku\b|gpt-\d",
re.IGNORECASE)
def test_keine_modell_ids_im_code():
treffer = []
for pfad in BACKEND.rglob("*.py"):
if "tests" in pfad.parts:
continue
for nr, zeile in enumerate(pfad.read_text(encoding="utf-8").splitlines(), 1):
if MUSTER.search(zeile):
treffer.append(f"{pfad.relative_to(BACKEND)}:{nr}: {zeile.strip()}")
assert not treffer, "Modell-IDs im Code gefunden (gehören in die .env):\n" + "\n".join(treffer)
def test_keine_modell_ids_in_templates():
templates = BACKEND / "templates"
if not templates.is_dir():
return
treffer = [f"{p.name}:{nr}" for p in templates.rglob("*.md")
for nr, zeile in enumerate(p.read_text(encoding="utf-8").splitlines(), 1)
if MUSTER.search(zeile)]
assert not treffer, f"Modell-IDs in Templates: {treffer}"

View File

@@ -0,0 +1,102 @@
"""Re-Scan über git-Diff: nur berührte Dateien werden neu gescannt, Löschungen räumen
auf, sauberer Tree ist Pflicht. Läuft komplett in der Fake-Welt."""
import shutil
import subprocess
from pathlib import Path
import pytest
import fake_agents
import kanban
import repo as repo_mod
MINI = Path(__file__).parent / "fixtures" / "mini_repo"
def _git(wurzel, *args):
subprocess.run(["git", "-C", str(wurzel), *args], check=True, capture_output=True,
env={"PATH": "/usr/bin:/bin", "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
"HOME": str(wurzel)})
def _repo(tmp_path: Path) -> Path:
ziel = tmp_path / "repo"
shutil.copytree(MINI, ziel)
_git(ziel, "init", "-b", "main")
_git(ziel, "add", "-A")
_git(ziel, "commit", "-m", "start")
return ziel
@pytest.fixture
def cli(monkeypatch):
import cli as cli_mod
monkeypatch.setattr(kanban, "RETRY_BACKOFF", 0.05)
return cli_mod
async def test_scan_dann_rescan_nur_geaenderte(cli, fake_welt, testdb, tmp_path):
fake_agents.standard_antworten(fake_welt)
wurzel = _repo(tmp_path)
assert await cli._scan(wurzel) == 0
stand1 = repo_mod.head_hash(wurzel)
assert f"stand: {stand1}" in (wurzel / ".planer" / "einheiten" / "rechner.py.md").read_text()
_git(wurzel, "add", "-A")
_git(wurzel, "commit", "-m", "scan-artefakte")
(wurzel / "rechner.py").write_text((wurzel / "rechner.py").read_text() + "\n\ndef neu():\n return 1\n")
_git(wurzel, "add", "-A")
_git(wurzel, "commit", "-m", "neue funktion")
stand2 = repo_mod.head_hash(wurzel)
fake_welt.calls.clear()
assert await cli._rescan(wurzel) == 0
# Nur rechner.py wurde neu gescannt …
assert all("anzeige" not in k for k in fake_welt.calls if "-schneiden-" in k)
rechner = (wurzel / ".planer" / "einheiten" / "rechner.py.md").read_text()
anzeige = (wurzel / ".planer" / "einheiten" / "anzeige.py.md").read_text()
assert f"stand: {stand2}" in rechner and "## rechner.py::neu" in rechner
assert f"stand: {stand1}" in anzeige # unverändert übernommen
# … aber die Sichten wurden neu aggregiert und referenzieren auch den Bestand
assert any("-sichten-" in k for k in fake_welt.calls)
kern = (wurzel / ".planer" / "kern.md").read_text()
assert kern.startswith("# Kern:")
async def test_rescan_ohne_aenderung_ruft_kein_llm(cli, fake_welt, testdb, tmp_path):
fake_agents.standard_antworten(fake_welt)
wurzel = _repo(tmp_path)
assert await cli._scan(wurzel) == 0
_git(wurzel, "add", "-A")
_git(wurzel, "commit", "-m", "scan-artefakte")
fake_welt.calls.clear()
assert await cli._rescan(wurzel) == 0
assert fake_welt.calls == []
async def test_rescan_geloeschte_datei_raeumt_auf(cli, fake_welt, testdb, tmp_path):
fake_agents.standard_antworten(fake_welt)
wurzel = _repo(tmp_path)
assert await cli._scan(wurzel) == 0
_git(wurzel, "add", "-A")
_git(wurzel, "commit", "-m", "scan-artefakte")
(wurzel / "anzeige.py").unlink()
_git(wurzel, "add", "-A")
_git(wurzel, "commit", "-m", "anzeige weg")
assert await cli._rescan(wurzel) == 0
assert not (wurzel / ".planer" / "einheiten" / "anzeige.py.md").exists()
async def test_unsauberer_tree_bricht_ab(cli, fake_welt, testdb, tmp_path):
wurzel = _repo(tmp_path)
(wurzel / "schmutz.py").write_text("X = 1\n")
with pytest.raises(SystemExit):
await cli._scan(wurzel)
async def test_kein_git_repo_bricht_ab(cli, fake_welt, testdb, tmp_path):
kein_repo = tmp_path / "kein-repo"
kein_repo.mkdir()
with pytest.raises(SystemExit):
await cli._scan(kein_repo)

View File

@@ -0,0 +1,87 @@
"""API-Routen: Lesen + Scan-Start über httpx/ASGITransport (gleicher Event-Loop wie
create_task und der DB-Write-Lock — fastapi.TestClient wäre die Loop-Falle)."""
import asyncio
import httpx
import pytest
import config
import scan_dienst
@pytest.fixture
async def client(gescanntes_repo, monkeypatch, tmp_path):
wurzel, git = gescanntes_repo
datei = tmp_path / "projekte.txt"
datei.write_text(f"{wurzel}\n{tmp_path}/tot\n")
monkeypatch.setattr(config, "PROJEKTE_DATEI", datei)
scan_dienst._laeufe.clear()
from server import app
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c, wurzel, git
async def test_projektliste(client):
c, wurzel, _ = client
r = await c.get("/api/projekte")
assert r.status_code == 200
eintraege = {p["name"]: p for p in r.json()}
assert eintraege["mini"]["hat_planer"] is True
assert eintraege["mini"]["drift"] == []
assert eintraege["mini"]["scan_laeuft"] is False
assert "fehler" in eintraege["tot"]
async def test_projekt_detail(client):
c, _, _ = client
r = await c.get("/api/projekte/mini")
daten = r.json()
assert daten["kern"]["titel"] == "mini"
assert daten["bereiche"] and daten["bereiche"][0]["features"]
assert "rechner.py::addiere" in daten["einheiten"]
assert daten["doku_ordnung"]
assert (await c.get("/api/projekte/gibtsnicht")).status_code == 404
async def test_einheit_mit_code(client):
c, _, _ = client
r = await c.get("/api/projekte/mini/einheit", params={"id": "rechner.py::addiere"})
e = r.json()
assert "return a + b" in e["code"]
assert e["veraltet"] is False
assert (await c.get("/api/projekte/mini/einheit", params={"id": "x.py::y"})).status_code == 404
async def test_drift_nach_commit(client):
c, wurzel, git = client
(wurzel / "rechner.py").write_text((wurzel / "rechner.py").read_text() + "\nX9 = 9\n")
git("add", "-A")
git("commit", "-m", "drift")
r = await c.get("/api/projekte")
mini = next(p for p in r.json() if p["name"] == "mini")
assert mini["drift"] == ["rechner.py"]
e = (await c.get("/api/projekte/mini/einheit", params={"id": "rechner.py::addiere"})).json()
assert e["veraltet"] is True
async def test_scan_starten_und_pollen(client):
c, _, _ = client
r = await c.post("/api/projekte/mini/scan", json={"art": "rescan"})
assert r.json()["status"] == "gestartet"
doppel = await c.post("/api/projekte/mini/scan", json={"art": "rescan"})
assert doppel.json()["status"] in ("laeuft", "gestartet") # idempotent, nie Doppellauf
for _ in range(100):
s = (await c.get("/api/projekte/mini/scan-status")).json()
if not s["laeuft"]:
break
await asyncio.sleep(0.1)
assert s["ergebnis"]["status"] in ("ok", "unveraendert")
async def test_scan_409_bei_dirty_tree(client):
c, wurzel, _ = client
(wurzel / "schmutz.py").write_text("X = 1\n")
r = await c.post("/api/projekte/mini/scan", json={"art": "scan"})
assert r.status_code == 409
assert "uncommittete" in r.json()["detail"]

View File

@@ -0,0 +1,119 @@
"""Fake-E2E: kompletter Scan-Lauf (Chunker → Board → .planer/) in Sekunden, ohne LLM.
Die Fake-Antworten durchlaufen die ECHTEN Gates — getestet wird der volle Pfad
inkl. Nachfass, Retry, Dead-Letter und Infra-Pause."""
import asyncio
import shutil
from pathlib import Path
import fake_agents
import kanban
import scan_board
MINI = Path(__file__).parent / "fixtures" / "mini_repo"
def _repo(tmp_path: Path) -> Path:
ziel = tmp_path / "repo"
shutil.copytree(MINI, ziel)
return ziel
async def _scan(repo: Path, monkeypatch) -> dict:
monkeypatch.setattr(kanban, "RETRY_BACKOFF", 0.05)
return await asyncio.wait_for(
scan_board.run_scan(repo, "mini", stand="deadbeef"), timeout=30)
async def test_e2e_voller_scan(fake_welt, testdb, tmp_path, monkeypatch):
fake_agents.standard_antworten(fake_welt)
repo = _repo(tmp_path)
ergebnis = await _scan(repo, monkeypatch)
assert ergebnis["status"] == "ok", ergebnis
planer = repo / ".planer"
assert (planer / "einheiten" / "rechner.py.md").is_file()
assert (planer / "einheiten" / "anzeige.py.md").is_file()
assert (planer / "kern.md").read_text().startswith("# Kern: mini")
assert (planer / "architektur.md").is_file()
assert (planer / "flows.md").is_file()
assert (planer / "pruefung.md").is_file()
assert list((planer / "features").glob("*.md"))
einheiten = (planer / "einheiten" / "rechner.py.md").read_text()
assert "stand: deadbeef" in einheiten
assert "## rechner.py::addiere" in einheiten and "## rechner.py::Speicher" in einheiten
async def test_e2e_nachfass_pfad(fake_welt, testdb, tmp_path, monkeypatch):
"""Erst-Scan lässt `skaliere` aus → Abdeckungs-Gate schickt den Chunk in den Nachfass."""
fake_agents.standard_antworten(fake_welt, lasse_aus={"skaliere"})
repo = _repo(tmp_path)
ergebnis = await _scan(repo, monkeypatch)
assert ergebnis["status"] == "ok", ergebnis
assert any("-nachfass-" in k for k in fake_welt.calls)
assert "## rechner.py::skaliere" in (repo / ".planer" / "einheiten" / "rechner.py.md").read_text()
async def test_e2e_stoerung_retry(fake_welt, testdb, tmp_path, monkeypatch):
"""Einmal Garbage → Gates lehnen ab → Engine-Backoff → zweiter Versuch gelingt."""
fake_agents.standard_antworten(fake_welt)
fake_welt.stoerungen.append({"muster": r"-schneiden-chunk-0000", "modus": "garbage", "mal": 1})
repo = _repo(tmp_path)
ergebnis = await _scan(repo, monkeypatch)
assert ergebnis["status"] == "ok", ergebnis
async def test_e2e_dauerfehler_dead(fake_welt, testdb, tmp_path, monkeypatch):
"""Dauerhafter Fehler auf einem Chunk → Karte landet im Dead-Letter, Lauf endet."""
fake_agents.standard_antworten(fake_welt)
fake_welt.stoerungen.append({"muster": r"-schneiden-chunk-0000", "modus": "fehler", "mal": 99})
repo = _repo(tmp_path)
ergebnis = await _scan(repo, monkeypatch)
assert ergebnis["status"] == "fehler"
assert ergebnis["dead"] == ["chunk-0000"]
async def test_e2e_infra_pause(fake_welt, testdb, tmp_path, monkeypatch):
"""AgentInfraError → Lauf pausiert sofort (kein Dead-Letter, kein Raten)."""
fake_agents.standard_antworten(fake_welt)
fake_welt.stoerungen.append({"muster": r"-schneiden-", "modus": "infra", "mal": 1})
repo = _repo(tmp_path)
ergebnis = await _scan(repo, monkeypatch)
assert ergebnis["status"] == "pausiert"
assert "fake-infra" in ergebnis["infra_error"]
async def test_e2e_fortsetzen_eine_sichten_runde(fake_welt, testdb, tmp_path, monkeypatch):
"""Fortsetzung mit Mix aus toten und fertigen Chunks: die Sammeln-Barriere darf erst
öffnen, wenn der Producer ALLE Karten requeued hat — genau eine Sichten-Runde, und
die Sichten sehen die volle Übersicht (Abnahme-Befund: Teil-Übersicht durch frühes
Drainen)."""
fake_agents.standard_antworten(fake_welt)
repo = _repo(tmp_path)
ergebnis = await _scan(repo, monkeypatch)
assert ergebnis["status"] == "ok"
# Zustand eines abgebrochenen Laufs nachstellen: ein Chunk tot, Rest fertig
karten = await testdb.kanban_cards("mini", scan_board.BOARD, kind="chunk")
tot = karten[0]["card_id"]
await testdb.kanban_upsert_card("mini", scan_board.BOARD, tot, "chunk", "dead", None)
fake_welt.calls.clear()
ergebnis2 = await asyncio.wait_for(
scan_board.run_scan(repo, "mini", stand="deadbeef", fortsetzen=True), timeout=30)
assert ergebnis2["status"] == "ok", ergebnis2
sichten_calls = [k for k in fake_welt.calls if "-sichten-" in k]
assert len(sichten_calls) == 3 # genau EINE Runde, kein Doppel-Drain
features = (repo / ".planer" / "features" / "fake-bereich.md").read_text()
assert "rechner.py::addiere" in features and "anzeige.py::zeige" in features # volle Übersicht
async def test_e2e_events_geschrieben(fake_welt, testdb, tmp_path, monkeypatch):
"""Telemetrie: Agent-Events mit run_id landen in der DB."""
import agents
import database
monkeypatch.setattr(agents, "on_event", database.add_event)
fake_agents.standard_antworten(fake_welt)
repo = _repo(tmp_path)
ergebnis = await _scan(repo, monkeypatch)
assert ergebnis["status"] == "ok"
db = await testdb.get_db()
cursor = await db.execute(
"SELECT count(*) FROM events WHERE topic='mini' AND kind='agent' AND run_id LIKE 'scan-%'")
assert (await cursor.fetchone())[0] >= 5 # 2 Chunks + 3 Sichten

View File

@@ -0,0 +1,114 @@
"""Viewer-Service: Parser, Drift, Doku-Ordnung, Projektliste."""
from pathlib import Path
import config
import projekte
import viewer
MINI = Path(__file__).parent / "fixtures" / "mini_repo"
def test_lese_einheiten_mit_entscheidungen():
text = """# Einheiten: a.py
stand: abc
## a.py::eins
beschreibung: Macht eins.
input: x
output: y
entscheidungen:
- Erste Entscheidung.
beleg: "code hier"
- Zweite mit Umbruch
und Fortsetzung.
kanten:
- ruft-auf: a.py::zwei
## b.py
beschreibung: Datei-Einheit.
"""
e = viewer.lese_einheiten(text)
eins = e["a.py::eins"]
assert eins["entscheidungen"] == ["Erste Entscheidung.", "Zweite mit Umbruch und Fortsetzung."]
assert eins["kanten"] == [{"typ": "ruft-auf", "ziel": "a.py::zwei"}]
assert (eins["input"], eins["output"]) == ("x", "y")
assert e["b.py"]["beschreibung"] == "Datei-Einheit."
def test_lese_kern_und_pruefung():
kern = viewer.lese_kern("# Kern: demo\nZwei Sätze.\n\ntragende-einheiten: a.py::x, b.py::y\n")
assert kern["titel"] == "demo" and kern["tragende_einheiten"] == ["a.py::x", "b.py::y"]
pr = viewer.lese_pruefung("# Prüfung: demo\ntests: make test\nlint: (keins vorhanden)\n"
"start: make dev\nhinweise:\n- Vorsicht.\n")
assert pr["tests"] == "make test" and pr["hinweise"] == ["Vorsicht."]
def test_lese_features_architektur_flows():
b = viewer.lese_features("# Bereich: rechnen\nbeschreibung: Rechnet.\n\n"
"## Feature: Addieren [kern]\nbeschreibung: Summen.\n\n"
"### Kann addieren\neinheiten: a.py::eins\n")
assert b["features"][0]["flag"] == "kern"
assert b["features"][0]["teilfeatures"][0]["einheiten"] == ["a.py::eins"]
a = viewer.lese_architektur("# Architektur: demo\n\n## Komponente: kernstück\n"
"beschreibung: Trägt.\neinheiten: a.py::eins\n"
"beziehungen:\n- nutzt: anzeige\n")
assert a[0]["beziehungen"] == [{"typ": "nutzt", "ziel": "anzeige"}]
f = viewer.lese_flows("# Flows: demo\n\n## Flow: ablauf\n1. Schritt eins — a.py::eins\n"
"2. Schritt ohne Referenz\n")
assert f[0]["schritte"][0]["einheiten"] == ["a.py::eins"]
assert f[0]["schritte"][1]["einheiten"] == []
def test_code_extrahieren():
assert "return a + b" in viewer.code_extrahieren(MINI, "rechner.py::addiere")
assert viewer.code_extrahieren(MINI, "rechner.py::BASIS").startswith("BASIS = 10")
assert viewer.code_extrahieren(MINI, "rechner.py::gibtsnicht") == ""
assert viewer.code_extrahieren(MINI, "wegdatei.py::x") == ""
assert "def merke" in viewer.code_extrahieren(MINI, "rechner.py::Speicher.merke")
async def test_lade_projekt_und_drift(gescanntes_repo):
wurzel, git = gescanntes_repo
projekt = viewer.lade_projekt(wurzel)
assert "rechner.py::addiere" in projekt["einheiten"]
assert projekt["kern"]["titel"] == "mini"
assert projekt["staende"]["rechner.py"]
assert viewer.drift(wurzel, projekt["staende"])["veraltet"] == []
(wurzel / "rechner.py").write_text((wurzel / "rechner.py").read_text() + "\nX2 = 2\n")
git("add", "-A")
git("commit", "-m", "aenderung")
d = viewer.drift(wurzel, projekt["staende"])
assert d["veraltet"] == ["rechner.py"] # anzeige.py unverändert
def test_doku_ordnung_mit_zyklus():
projekt = {
"bereiche": [
{"name": "oben", "features": [{"teilfeatures": [{"einheiten": ["a.py::x"]}]}]},
{"name": "unten", "features": [{"teilfeatures": [{"einheiten": ["b.py::y"]}]}]},
],
"einheiten": {
"a.py::x": {"kanten": [{"typ": "ruft-auf", "ziel": "b.py::y"}]},
"b.py::y": {"kanten": []},
},
}
assert viewer.doku_ordnung(projekt) == ["unten", "oben"] # Fundament zuerst
# Zyklus: beide nutzen einander → deterministisch, beide enthalten
projekt["einheiten"]["b.py::y"]["kanten"] = [{"typ": "nutzt", "ziel": "a.py::x"}]
ordnung = viewer.doku_ordnung(projekt)
assert sorted(ordnung) == ["oben", "unten"] and len(ordnung) == 2
def test_projektliste(tmp_path, monkeypatch):
echt = tmp_path / "echt"
echt.mkdir()
(tmp_path / "kopie").mkdir()
datei = tmp_path / "projekte.txt"
datei.write_text(f"# Kommentar\n{echt}\n{tmp_path}/weg\n{tmp_path}/kopie\n{echt}\n")
monkeypatch.setattr(config, "PROJEKTE_DATEI", datei)
liste = projekte.lade()
assert [p.get("fehler") is None for p in liste] == [True, False, True, False]
assert "existiert nicht" in liste[1]["fehler"]
assert "Namenskollision" in liste[3]["fehler"]
assert projekte.finde("echt") == echt and projekte.finde("weg") is None

279
backend/viewer.py Normal file
View File

@@ -0,0 +1,279 @@
"""Viewer-Service: liest .planer/-Dateien eines Projekts in Strukturen für die API.
Reine Bibliothek (kein FastAPI-Import). Eigener toleranter Lese-Parser — gates.py bleibt
unangetastet (scan-kritisch) und verwirft z. B. die Entscheidungs-SÄTZE, die die UI zeigt.
Code je Einheit wird LIVE aus dem Anker extrahiert, nie gespeichert (Single Source).
"""
import re
from pathlib import Path
import artefakte
import repo
_EINHEIT_KOPF = re.compile(r"^## (\S+::\S+|\S+\.\w+)$")
def lese_einheiten(text: str) -> dict[str, dict]:
"""Einheiten-Datei → {id: {beschreibung, input, output, entscheidungen, kanten, anker}}."""
out: dict[str, dict] = {}
aktuelle = None
modus = None
for zeile in text.splitlines():
if (m := _EINHEIT_KOPF.match(zeile.strip())) and zeile.startswith("## "):
aktuelle = {"id": m.group(1), "beschreibung": "", "input": "", "output": "",
"entscheidungen": [], "kanten": [], "anker": []}
out[aktuelle["id"]] = aktuelle
modus = None
elif aktuelle is None:
continue
elif m := re.match(r"(beschreibung|input|output):(.*)$", zeile):
aktuelle[m.group(1)] = m.group(2).strip()
modus = m.group(1)
elif zeile.startswith("anker:"):
aktuelle["anker"].append(zeile.split(":", 1)[1].strip())
elif zeile.strip() in ("entscheidungen:", "fakten:"):
modus = "punkte"
elif zeile.strip() == "kanten:":
modus = "kanten"
elif modus == "punkte" and re.match(r"\s+beleg:", zeile):
continue # UI zeigt echten Code statt Zitaten
elif modus == "punkte" and zeile.startswith("- "):
aktuelle["entscheidungen"].append(re.sub(r"\s*\bbeleg:.*$", "", zeile[2:]).strip())
elif modus == "punkte" and zeile.startswith(" ") and aktuelle["entscheidungen"]:
aktuelle["entscheidungen"][-1] += " " + zeile.strip()
elif modus == "kanten" and (m := re.match(r"- ([\wäöü-]+):\s*(\S+)", zeile)):
aktuelle["kanten"].append({"typ": m.group(1), "ziel": m.group(2)})
elif modus in ("beschreibung", "input", "output") and zeile.startswith(" "):
aktuelle[modus] += " " + zeile.strip()
return out
def lese_kern(text: str) -> dict:
zeilen = text.splitlines()
titel = zeilen[0].removeprefix("# Kern:").strip() if zeilen else ""
tragend: list[str] = []
rumpf: list[str] = []
for zeile in zeilen[1:]:
if zeile.startswith("tragende-einheiten:"):
tragend = [t.strip() for t in zeile.split(":", 1)[1].split(",") if t.strip()]
else:
rumpf.append(zeile)
return {"titel": titel, "text": "\n".join(rumpf).strip(), "tragende_einheiten": tragend}
def _einheiten_refs(zeile: str) -> list[str]:
return [t.strip() for t in zeile.split(":", 1)[1].split(",") if t.strip()]
def lese_features(text: str) -> dict:
"""Eine Bereichs-Datei → {name, beschreibung, features: [{titel, flag, beschreibung,
teilfeatures: [{titel, einheiten}]}]}."""
bereich = {"name": "", "beschreibung": "", "features": []}
feature = None
for zeile in text.splitlines():
if zeile.startswith("# Bereich:"):
bereich["name"] = zeile.split(":", 1)[1].strip()
feature = None
elif zeile.startswith("## Feature:"):
titel = zeile[11:].strip()
flag = "kern" if "[kern]" in titel else "rand"
feature = {"titel": re.sub(r"\s*\[(kern|rand)\]", "", titel).strip(),
"flag": flag, "beschreibung": "", "teilfeatures": []}
bereich["features"].append(feature)
elif zeile.startswith("### ") and feature is not None:
feature["teilfeatures"].append({"titel": zeile[4:].strip(), "einheiten": []})
elif zeile.startswith("einheiten:") and feature and feature["teilfeatures"]:
feature["teilfeatures"][-1]["einheiten"] = _einheiten_refs(zeile)
elif zeile.startswith("beschreibung:"):
ziel = feature if feature is not None else bereich
ziel["beschreibung"] = zeile.split(":", 1)[1].strip()
return bereich
def lese_architektur(text: str) -> list[dict]:
komponenten: list[dict] = []
k = None
modus = None
for zeile in text.splitlines():
if zeile.startswith("## Komponente:"):
k = {"name": zeile.split(":", 1)[1].strip(), "beschreibung": "",
"einheiten": [], "beziehungen": []}
komponenten.append(k)
modus = None
elif k is None:
continue
elif zeile.startswith("beschreibung:"):
k["beschreibung"] = zeile.split(":", 1)[1].strip()
elif zeile.startswith("einheiten:"):
k["einheiten"] = _einheiten_refs(zeile)
elif zeile.strip() == "beziehungen:":
modus = "beziehungen"
elif modus == "beziehungen" and (m := re.match(r"- ([\wäöü-]+):\s*(.+)$", zeile)):
k["beziehungen"].append({"typ": m.group(1), "ziel": m.group(2).strip()})
return komponenten
def lese_flows(text: str) -> list[dict]:
flows: list[dict] = []
f = None
for zeile in text.splitlines():
if zeile.startswith("## Flow:"):
f = {"titel": zeile.split(":", 1)[1].strip(), "schritte": []}
flows.append(f)
elif f is not None and (m := re.match(r"^\d+\.\s(.+?)(?:\s—\s(.+))?$", zeile)):
refs = [t.strip() for t in (m.group(2) or "").split(",") if t.strip()]
f["schritte"].append({"text": m.group(1).strip(), "einheiten": refs})
return flows
def lese_pruefung(text: str) -> dict:
out = {"tests": "", "lint": "", "start": "", "hinweise": []}
modus = None
for zeile in text.splitlines():
if m := re.match(r"(tests|lint|start):(.*)$", zeile):
out[m.group(1)] = m.group(2).strip()
modus = None
elif zeile.strip() == "hinweise:":
modus = "hinweise"
elif modus == "hinweise" and zeile.startswith("- "):
out["hinweise"].append(zeile[2:].strip())
return out
def code_extrahieren(wurzel: Path, eid: str) -> str:
"""Quellcode eines Ankers (pfad::symbol) LIVE aus der Quelle — nie Exception:
fehlende Datei/Symbol → '' (UI zeigt Hinweis)."""
try:
datei, _, symbol = eid.partition("::")
p = wurzel / datei
if not p.is_file():
return ""
text = p.read_text(encoding="utf-8", errors="replace")
if not symbol:
return text[:8000] # Datei-Einheit: ganze (gekappte) Datei
if "." in symbol:
symbol = symbol.split(".", 1)[1] # qualifizierte Methode → Methode suchen
zeilen = text.splitlines()
start = None
for i, z in enumerate(zeilen):
if re.match(rf"(\s*)(?:async\s+)?(?:def|class)\s+{re.escape(symbol)}\b", z):
start = i
break
if re.match(rf"{re.escape(symbol)}\s*[:=]", z):
start = i
ende = i + 1
while ende < len(zeilen) and (zeilen[ende].startswith((" ", "\t", ")", "]", "}"))
or not zeilen[ende].strip()):
ende += 1
return "\n".join(zeilen[start:ende]).rstrip()
if start is None:
return ""
einzug = len(zeilen[start]) - len(zeilen[start].lstrip())
ende = start + 1
while ende < len(zeilen):
z = zeilen[ende]
if z.strip() and (len(z) - len(z.lstrip())) <= einzug:
break
ende += 1
return "\n".join(zeilen[start:ende]).rstrip()
except Exception:
return ""
def lade_projekt(wurzel: Path) -> dict:
"""Alle .planer/-Dateien eines Projekts → Struktur für die API. Fehlende Dateien
ergeben leere Teile (degradieren, nie werfen)."""
planer = wurzel / ".planer"
def text(rel: str) -> str:
p = planer / rel
return p.read_text(encoding="utf-8") if p.is_file() else ""
einheiten: dict[str, dict] = {}
staende: dict[str, str] = {} # quelldatei → stand
for p in sorted((planer / "einheiten").glob("*.md")) if (planer / "einheiten").is_dir() else []:
inhalt = p.read_text(encoding="utf-8")
einheiten.update(lese_einheiten(inhalt))
kopf = inhalt.splitlines()[0] if inhalt else ""
datei = kopf.removeprefix("# Einheiten:").strip()
if datei and (stand := artefakte.lese_stand(inhalt)):
staende[datei] = stand
bereiche = [lese_features((planer / "features" / p.name).read_text(encoding="utf-8"))
for p in sorted((planer / "features").glob("*.md"))] \
if (planer / "features").is_dir() else []
return {
"kern": lese_kern(text("kern.md")),
"bereiche": bereiche,
"architektur": lese_architektur(text("architektur.md")),
"flows": lese_flows(text("flows.md")),
"pruefung": lese_pruefung(text("pruefung.md")),
"einheiten": einheiten,
"staende": staende,
}
# --- Drift ---------------------------------------------------------------------------
_diff_cache: dict[tuple[str, str, str], set[str]] = {} # (pfad, stand, head) → geändert
def drift(wurzel: Path, staende: dict[str, str]) -> dict:
"""Welche gescannten Quelldateien sind seit ihrem stand: berührt?
{head, veraltet: [quelldatei]}. Cache-Schlüssel enthält HEAD → selbst-invalidierend."""
try:
head = repo.head_hash(wurzel)
except (RuntimeError, FileNotFoundError):
return {"head": "", "veraltet": []}
veraltet = []
for datei, stand in sorted(staende.items()):
if stand == head:
continue
schluessel = (str(wurzel), stand, head)
if schluessel not in _diff_cache:
try:
_diff_cache[schluessel] = repo.geaenderte_dateien(wurzel, stand)[0]
except RuntimeError:
_diff_cache[schluessel] = set()
if datei in _diff_cache[schluessel]:
veraltet.append(datei)
return {"head": head, "veraltet": veraltet}
# --- Doku-Leseordnung ------------------------------------------------------------------
def doku_ordnung(projekt: dict) -> list[str]:
"""Bereichs-Namen in Abhängigkeitsordnung: Kanten der Einheiten (ruft-auf/nutzt) werden
zu Bereichs-Kanten aggregiert; viel Genutztes kommt früher (Kahn-Toposort, zyklenfest:
bei Zyklus gewinnt der Bereich mit kleinstem In-Degree, dann alphabetisch)."""
einheit_zu_bereich: dict[str, str] = {}
for bereich in projekt["bereiche"]:
for f in bereich["features"]:
for tf in f["teilfeatures"]:
for eid in tf["einheiten"]:
einheit_zu_bereich.setdefault(eid, bereich["name"])
namen = [b["name"] for b in projekt["bereiche"]]
eingehend: dict[str, set[str]] = {n: set() for n in namen}
for eid, e in projekt["einheiten"].items():
von = einheit_zu_bereich.get(eid)
for kante in e["kanten"]:
if kante["typ"] not in ("ruft-auf", "nutzt"):
continue
nach = einheit_zu_bereich.get(kante["ziel"])
if von and nach and von != nach:
eingehend[nach].add(von) # "von nutzt nach" → nach ist Fundament → früher
# Kahn: Bereiche ohne offene Nutzer-Abhängigkeit zuerst — Fundament vor Nutzern:
# ein Bereich kommt, sobald alles, was ER nutzt, schon gelesen wurde.
nutzt: dict[str, set[str]] = {n: set() for n in namen}
for nach, nutzer in eingehend.items():
for von in nutzer:
nutzt[von].add(nach)
ordnung: list[str] = []
offen = set(namen)
while offen:
bereit = sorted(n for n in offen if not (nutzt[n] & offen))
if not bereit: # Zyklus: kleinste offene Abhängigkeitszahl, dann Name
bereit = [min(offen, key=lambda n: (len(nutzt[n] & offen), n))]
for n in bereit:
ordnung.append(n)
offen.discard(n)
return ordnung