init
This commit is contained in:
8
backend/__main__.py
Normal file
8
backend/__main__.py
Normal 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
564
backend/agents.py
Normal 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
101
backend/artefakte.py
Normal 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
96
backend/chunker.py
Normal 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
119
backend/cli.py
Normal 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
72
backend/config.py
Normal 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
327
backend/database.py
Normal 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
155
backend/fake_agents.py
Normal 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
310
backend/gates.py
Normal 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
269
backend/kanban.py
Normal 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
36
backend/projekte.py
Normal 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
3
backend/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
53
backend/repo.py
Normal file
53
backend/repo.py
Normal 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
108
backend/routes.py
Normal 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
319
backend/scan_board.py
Normal 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
147
backend/scan_dienst.py
Normal 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
48
backend/server.py
Normal 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")
|
||||
53
backend/templates/einheiten.md
Normal file
53
backend/templates/einheiten.md
Normal 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.
|
||||
- 2–5 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>
|
||||
```
|
||||
33
backend/templates/nachfass.md
Normal file
33
backend/templates/nachfass.md
Normal 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:`,
|
||||
2–5 `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>
|
||||
```
|
||||
29
backend/templates/sichten-architektur.md
Normal file
29
backend/templates/sichten-architektur.md
Normal 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:
|
||||
- 3–8 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>
|
||||
51
backend/templates/sichten-features.md
Normal file
51
backend/templates/sichten-features.md
Normal 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:
|
||||
- 3–10 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>
|
||||
26
backend/templates/sichten-flows.md
Normal file
26
backend/templates/sichten-flows.md
Normal 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:
|
||||
- 2–6 Flows, je 3–10 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
66
backend/tests/conftest.py
Normal 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)
|
||||
7
backend/tests/fixtures/mini_repo/anzeige.py
vendored
Normal file
7
backend/tests/fixtures/mini_repo/anzeige.py
vendored
Normal 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)}"
|
||||
23
backend/tests/fixtures/mini_repo/rechner.py
vendored
Normal file
23
backend/tests/fixtures/mini_repo/rechner.py
vendored
Normal 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
|
||||
50
backend/tests/test_agents.py
Normal file
50
backend/tests/test_agents.py
Normal 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)
|
||||
65
backend/tests/test_artefakte.py
Normal file
65
backend/tests/test_artefakte.py
Normal 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
|
||||
57
backend/tests/test_chunker.py
Normal file
57
backend/tests/test_chunker.py
Normal 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"}
|
||||
31
backend/tests/test_config_rollen.py
Normal file
31
backend/tests/test_config_rollen.py
Normal 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"}
|
||||
33
backend/tests/test_database.py
Normal file
33
backend/tests/test_database.py
Normal 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
159
backend/tests/test_gates.py
Normal 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"]
|
||||
128
backend/tests/test_kanban.py
Normal file
128
backend/tests/test_kanban.py
Normal 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
|
||||
32
backend/tests/test_modell_ids.py
Normal file
32
backend/tests/test_modell_ids.py
Normal 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}"
|
||||
102
backend/tests/test_rescan.py
Normal file
102
backend/tests/test_rescan.py
Normal 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)
|
||||
87
backend/tests/test_routes.py
Normal file
87
backend/tests/test_routes.py
Normal 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"]
|
||||
119
backend/tests/test_scan_fake.py
Normal file
119
backend/tests/test_scan_fake.py
Normal 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
|
||||
114
backend/tests/test_viewer.py
Normal file
114
backend/tests/test_viewer.py
Normal 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
279
backend/viewer.py
Normal 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
|
||||
Reference in New Issue
Block a user