This commit is contained in:
team3
2026-09-07 07:44:00 +02:00
parent 5d42b4cfc5
commit c6bbed4049
250 changed files with 13372 additions and 6203 deletions

View File

@@ -1,8 +1,23 @@
"""Anbindung an MiniMax über das Anthropic-Messages-Format. Kein Fallback: fehlt die
Konfiguration oder antwortet der Provider nicht, bricht der Aufruf mit sichtbarer
Meldung ab."""
"""Zwei Wege zum Modell, je nach Aufgabe.
`frage` geht über MiniMax im Anthropic-Messages-Format: die Prüfungen sind hundert
Anfragen je Lauf und leben von der Temperaturstreuung — das muss billig und
parallelisierbar sein.
`frage_lokal` geht über die angemeldete Claude-CLI, dasselbe Abo wie die
Agenten-Übergabe. Die Gespräche sind einzelne Aufrufe und leben von Urteilskraft;
dort zählt Qualität, nicht Durchsatz.
Welche Werkzeuge dabei offenstehen, entscheidet die Betriebsart, nicht das Modell:
Planung liest den Projektcode, Recherche sucht im Web, und keine der beiden kann,
was die andere darf. Die Prüfungen bekommen gar nichts — dort ist der Katalog die
einzige zulässige Quelle.
Kein Fallback zwischen beiden: fehlt ein Weg, bricht der Aufruf mit sichtbarer
Meldung ab, statt still auf das schwächere Modell auszuweichen."""
import asyncio
import json
import httpx
@@ -12,6 +27,73 @@ ZEITLIMIT = 180.0
MAX_TOKENS = 8000
API_VERSION = "2023-06-01"
GRUNDLAGE = (
"claude", "-p", "--model", "opus", "--output-format", "json",
# Spart ein Fünftel der Grundlast; was hier gilt, steht ohnehin im eigenen Prompt.
"--exclude-dynamic-system-prompt-sections",
# Ohne das erbt der Aufruf, was am Rechner sonst eingerichtet ist: MCP-Server,
# Workflows, Hooks, ein fremder Antwortstil. Ein Gespräch über Feature-Karten hat
# in keinem Postfach etwas zu suchen.
"--safe-mode",
)
# Was in keiner Betriebsart erlaubt ist. Beide Gespräche holen fremden Text in den
# Kontext — das eine Suchtreffer, das andere Quelltext — und fremder Text darf keinen
# Terminplan anlegen, keine Nachricht verschicken und keine Agenten starten. Was
# künftig an Werkzeugen dazukommt, steht hier nicht: beim Nachziehen der CLI gehört
# diese Liste geprüft.
GESPERRT = (
"Write", "Edit", "NotebookEdit", "Bash", "Task", "TodoWrite",
"Workflow", "Skill", "ReportFindings", "ScheduleWakeup", "DesignSync",
"CronCreate", "CronDelete", "CronList", "RemoteTrigger", "PushNotification",
"SendMessage", "Monitor", "EnterWorktree", "ExitWorktree",
"TaskCreate", "TaskGet", "TaskList", "TaskOutput", "TaskStop", "TaskUpdate",
)
def _kommando(erlaubt: tuple, gesperrt: tuple, zuege: int) -> tuple:
"""Ein Aufruf mit genau den Werkzeugen einer Betriebsart.
`--allowed-tools` ist nicht bloß die Umkehrung von `--disallowed-tools`: ohne die
Vorabfreigabe fragt die CLI je Suche und je Datei nach Erlaubnis und bekommt im
Stapelbetrieb keine Antwort. Der Versuch endet dann als Absage, nicht als Fehler.
`ToolSearch` steht nirgends: Suche, Abruf und Lesen sind nachladbar, ohne das
Werkzeug käme das Modell gar nicht an sie heran. Was es nachlädt, ist trotzdem
gesperrt — die Sperre wirkt beim Aufruf, nicht beim Suchen.
"""
return (*GRUNDLAGE,
"--allowed-tools", *erlaubt,
"--disallowed-tools", *GESPERRT, *gesperrt,
"--max-turns", str(zuege))
# Planen heißt: an den Karten arbeiten, mit dem Code als Beleg. Das Web bleibt zu —
# was das Projekt können soll, entscheidet die Person, nicht eine fremde Seite.
PLANUNG_KOMMANDO = _kommando(
("Read", "Glob", "Grep"), ("WebSearch", "WebFetch"), 20)
# Ohne eingestelltes Arbeitsverzeichnis gibt es nichts zu lesen. Lesen bliebe hier
# nicht folgenlos: ohne gesetzten Ordner liefe die Suche im Serververzeichnis, und
# das Modell beschriebe planer2 statt des Projekts.
PLANUNG_OHNE_CODE = _kommando(
(), ("Read", "Glob", "Grep", "WebSearch", "WebFetch"), 1)
# Recherche geht nach draußen und nirgendwo sonst: kein Blick in Dateien, damit
# nichts aus dem Projekt in einer Suchanfrage landet.
RECHERCHE_KOMMANDO = _kommando(
("WebSearch", "WebFetch"), ("Read", "Glob", "Grep"), 24)
# Opus denkt länger als MiniMax, der Prozessstart kostet extra, und Suchen wie
# Codelesen legen mehrere Abrufe obendrauf.
CHAT_ZEITLIMIT = 600.0
# Nur fürs Log: welcher Weg gerade lief. Ohne den Namen sagt eine Zeitangabe nichts,
# denn dieselbe Sekundenzahl ist bei einem Zug teuer und bei zwanzig billig.
WEGE = {
PLANUNG_KOMMANDO: "planung+code",
PLANUNG_OHNE_CODE: "planung",
RECHERCHE_KOMMANDO: "recherche",
}
# Der Provider drosselt bei vielen gleichzeitigen Anfragen
WIEDERHOLBAR = (429, 500, 502, 503, 504)
WIEDERHOLUNGEN = 6
@@ -39,6 +121,67 @@ def pruefe_konfiguration():
raise NichtKonfiguriert(f"Nicht konfiguriert: {', '.join(fehlend)} in .env fehlt")
async def frage_lokal(system: str, inhalt: str,
kommando: tuple = PLANUNG_OHNE_CODE,
ordner: str | None = None) -> str:
"""Eine Runde über die Claude-CLI — der Text der Antwort.
Der Prompt geht über die Standardeingabe: Katalog und Kartenstand sind zu lang,
um sie als Kommandozeilenargument zu übergeben. Keine Temperatur — die CLI kennt
keine; für einen einzelnen Zug braucht es auch keine Streuung.
`ordner` ist das Verzeichnis, in dem der Aufruf läuft. Es entscheidet, was ein
erlaubtes Lesen erreicht; ohne Angabe erbt der Prozess das Serververzeichnis, und
das ist nur zulässig, solange gar nicht gelesen werden darf.
"""
try:
prozess = await asyncio.create_subprocess_exec(
*kommando, "--system-prompt", system,
cwd=ordner,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError:
raise NichtKonfiguriert(
"Claude-CLI nicht gefunden — „claude“ muss im Pfad liegen und angemeldet sein"
)
try:
aus, err = await asyncio.wait_for(
prozess.communicate(inhalt.encode()), CHAT_ZEITLIMIT)
except asyncio.TimeoutError:
prozess.kill()
raise RuntimeError(f"Claude antwortet nicht innerhalb von {CHAT_ZEITLIMIT:.0f} s")
if prozess.returncode != 0:
raise RuntimeError(f"Claude bricht ab: {err.decode()[-300:].strip() or 'ohne Meldung'}")
try:
daten = json.loads(aus)
except ValueError:
raise RuntimeError("Antwort der Claude-CLI ist kein JSON")
if daten.get("is_error"):
raise RuntimeError(f"Claude meldet einen Fehler: {daten.get('result')}")
_notiere(kommando, daten)
text = str(daten.get("result") or "")
if not text.strip():
raise RuntimeError("Leere Antwort von Claude")
return text
def _notiere(kommando: tuple, daten: dict):
"""Wohin die Wartezeit ging — ins Serverlog, eine Zeile je Aufruf.
Die CLI legt es ohnehin bei; ungelesen bleibt nur die Frage „wieso dauert das so
lange" offen. Ein Zug ist ein Modellaufruf: jede Suche, jeder Abruf, jedes Lesen
zählt einzeln. Sind die Züge hoch, hat das Modell gearbeitet; ist einer teuer, war
der Prompt lang.
"""
zuege = daten.get("num_turns") or 0
api = (daten.get("duration_api_ms") or 0) / 1000
print(f"[claude] {WEGE.get(kommando, 'aufruf')}: {api:.0f}s, {zuege} Züge, "
f"{(api / zuege if zuege else api):.0f}s/Zug, "
f"${daten.get('total_cost_usd') or 0:.3f}", flush=True)
def _pause(res, versuch: int) -> float:
"""Wie lange bis zum nächsten Versuch — der Provider darf es selbst sagen."""
gesagt = res.headers.get("retry-after")

319
backend/auftrag.py Normal file
View File

@@ -0,0 +1,319 @@
"""Die Planung an einen Agenten übergeben.
Claude implementiert die geplanten Änderungen im Arbeitsverzeichnis — lokal über
die Claude-CLI mit dem angemeldeten Konto. Solange er arbeitet, bleibt die
Planung stehen: die Übersicht sagt, was das Projekt kann, und das stimmt erst,
wenn der Code da ist. Erst nach Erfolg wird die Planung übernommen. Ein
Fehlschlag übernimmt nichts; die Planung steht noch und kann erneut los.
Der Agent meldet jeden erledigten Schritt als eigene Zeile; der Server liest den
Stream mit und hakt die Schritte ab. Zustand nur im Speicher — er überlebt keinen
Serverneustart, der laufende Prozess arbeitet trotzdem zu Ende, übernimmt dann
aber nichts mehr sichtbar.
"""
import asyncio
import difflib
import json
import re
import parser
import planung
import projekte
import verzeichnis
# stream-json liefert je Zeile ein Ereignis: sichtbar wird, woran der Agent
# gerade arbeitet, und die ERLEDIGT-Zeilen kommen an, sobald sie fallen.
KOMMANDO = ("claude", "-p", "--model", "opus", "--permission-mode", "acceptEdits",
"--output-format", "stream-json", "--verbose")
MARKE = "ERLEDIGT:"
# Einzelne Stream-Zeilen tragen ganze Dateien — die Standardgrenze ist zu klein
PUFFER = 10 * 1024 * 1024
# projektname -> {"laeuft", "fehler", "text", "schritte", "aktivitaet", "uebernommen"}
_laeufe = {}
_aufgaben = set()
PRUEFEN = "PRÜFEN"
# Eine neue Funktion ist immer unbewiesen — es gibt noch keinen Code, der sie leistet.
ART = {"weg": "GESTRICHEN", "geaendert": "GEÄNDERT", "neu": PRUEFEN}
AUFTRAG = """Du bekommst die Feature-Karten eines Projekts und die geplanten Änderungen.
Eine Karte ist ein Auftrag: Regeln sind Zusicherungen, eine Funktion ist etwas,
das man für sich testen kann. Setze den Code in diesem Verzeichnis so um, dass er
den geänderten Karten entspricht.
- Lies zuerst den bestehenden Code und folge seinen Konventionen.
- Ändere nur, was die Arbeitsschritte verlangen.
- {pruefen} heißt: die Funktion gilt als unbewiesen. Prüfe, ob der Code genau das
leistet, was ihre Testschritte beschreiben. Fehlt sie, baue sie. Weicht sie ab,
bring sie in Ordnung. Sie kann schon einmal gebaut worden sein und trotzdem falsch
arbeiten — glaube dem Code nicht, weil er existiert.
- GEÄNDERT heißt: gebaut, aber die Karte sagt jetzt etwas anderes.
- GESTRICHEN heißt: die Fähigkeit wird entfernt.
- Ein Kommando in Anführungszeichen muss danach genau so aufrufbar sein.
- Führe vorhandene Tests aus, wenn es welche gibt.
Die Arbeitsschritte:
{schritte}
Gib nach jedem abgeschlossenen Schritt sofort eine eigene Textzeile aus,
mit dem Schritt wörtlich wie oben:
{marke} <Schritt>
Hake nur ab, was du geprüft hast. Konntest du einen Ablauf nicht ausführen — weil
kein Fenster, kein Gerät oder kein Dienst da ist — dann melde ihn NICHT als erledigt.
Schreib am Ende in einer eigenen Zeile, was du nicht prüfen konntest und warum.
Die betroffenen Karten, wie sie danach gelten:
{karten}"""
def _kartentexte(projekt: str, titel: set) -> str:
"""Der volle Text der geplanten Karten — der Sollzustand, nicht der Diff."""
texte = []
for datei in sorted(projekte.wurzel(projekt).glob("*.md")):
roh = datei.read_text()
if parser.zerlege(roh)["titel"] in titel:
texte.append(roh.strip())
return "\n\n=====\n\n".join(texte) or "(keine — nur Streichungen)"
def _offene(projekt: str) -> set:
"""(Karte, Funktion) für alles, was noch nicht bewiesen ist.
Auch in Karten, die sich gegenüber dem Stand nicht geändert haben: eine Funktion
zurück auf „offen“ zu setzen ist die Art, eine kaputte Umsetzung zu melden. Sie
muss dann in die Arbeitsliste, sonst hätte der Klick keine Folge.
"""
raus = set()
for datei in projekte.wurzel(projekt).glob("*.md"):
s = parser.zerlege(datei.read_text())
if s["art"] == parser.VISION:
continue
for f in s["funktionen"]:
if f["stand"] == parser.OFFEN:
raus.add((s["titel"], f["text"]))
return raus
def _schritte(projekt: str, aenderungen: dict) -> list[dict]:
"""Die Arbeitsliste: ein Eintrag je Streichung, Kopfänderung und Funktion.
Karte und Funktion stehen daneben, nicht nur im Text. Beim Abhaken wird die
Funktion auf „gebaut“ gesetzt — den Namen aus dem Satz zurückzuparsen wäre
eine zweite Wahrheit über dieselbe Sache.
"""
offen = _offene(projekt)
raus = []
erledigt = set()
def funktionsschritt(titel: str, name: str, art: str):
erledigt.add((titel, name))
# Offen schlägt geändert: „prüfen und herstellen“ deckt beides ab, und der
# Agent soll dem vorhandenen Code nicht glauben.
marke = PRUEFEN if (titel, name) in offen else ART[art]
raus.append({"text": f"{titel} / {name}: {marke}", "karte": titel,
# Eine gestrichene Funktion gibt es hinterher nicht mehr
"funktion": "" if art == "weg" else name})
for titel in sorted(aenderungen):
e = aenderungen[titel]
if e["art"] == "weg":
raus.append({"text": f"Karte „{titel}“ streichen",
"karte": titel, "funktion": ""})
erledigt |= {(titel, n) for n in e.get("funktionen", {})}
continue
if e.get("kopf"):
raus.append({"text": f"Karte „{titel}“: Kartenkopf anpassen",
"karte": titel, "funktion": ""})
for name, art in sorted(e.get("funktionen", {}).items()):
funktionsschritt(titel, name, art)
for titel, name in sorted(offen - erledigt):
funktionsschritt(titel, name, "geaendert")
return raus
def _visionen(projekt: str) -> set:
"""Titel der Vision-Karten, in Stand und Arbeitskopie."""
titel = set()
for ordner in (projekte.stand(projekt), projekte.wurzel(projekt)):
for datei in ordner.glob("*.md"):
s = parser.zerlege(datei.read_text())
if s["art"] == parser.VISION:
titel.add(s["titel"])
return titel
def bau_auftrag(projekt: str) -> tuple[str, list[dict]]:
"""Prompt und Arbeitsliste, aus dem Stand der laufenden Planung.
Die Vision bleibt außen vor: sie beschreibt Absicht, keinen Auftrag —
eine geänderte Vision ist nichts, was ein Agent bauen soll.
"""
if not planung.laeuft(projekt):
return "", []
visionen = _visionen(projekt)
aenderungen = {t: e for t, e in planung.aenderungen(projekt).items()
if t not in visionen}
schritte = _schritte(projekt, aenderungen)
if not schritte:
return "", []
# Der Sollzustand jeder Karte, die Arbeit trägt — auch der unveränderten mit
# einer offenen Funktion. Ohne ihren Text wüsste der Agent nicht, wogegen er prüft.
bleibend = {s["karte"] for s in schritte if s["karte"] not in {
t for t, e in aenderungen.items() if e["art"] == "weg"}}
text = AUFTRAG.format(
schritte="\n".join(f'- {s["text"]}' for s in schritte),
marke=MARKE,
pruefen=PRUEFEN,
karten=_kartentexte(projekt, bleibend))
return text, schritte
def status(projekt: str) -> dict:
return _laeufe.get(projekt) or {
"laeuft": False, "fehler": "", "text": "",
"schritte": [], "aktivitaet": "", "uebernommen": False,
}
# „offen“ steht als eigener Block in der Datei; fehlt er, gilt die Funktion als
# gebaut. Umstellen heißt darum: den Block streichen. Ein Umbau über den Parser
# würde die Datei neu schreiben und dabei unbekannte Blöcke verlieren.
_FUNKTIONSMARKE = re.compile(r"^<!--\s*funktion\s*-->[ \t]*$", re.M | re.I)
_OFFENBLOCK = re.compile(
r"^<!--\s*stand\s*-->[ \t]*\n[ \t]*" + parser.OFFEN + r"[ \t]*\n", re.M | re.I)
def _als_gebaut(roh: str, funktion: str) -> str:
"""Streicht den offen-Vermerk genau dieser Funktion."""
marken = [m.start() for m in _FUNKTIONSMARKE.finditer(roh)]
for i, start in enumerate(marken):
ende = marken[i + 1] if i + 1 < len(marken) else len(roh)
block = roh[start:ende]
zeilen = [z.strip() for z in block.splitlines()[1:] if z.strip()]
titel = next((z for z in zeilen
if not z.startswith("<!--") and not z.startswith("---")), "")
if titel != funktion:
continue
return roh[:start] + _OFFENBLOCK.sub("", block, count=1) + roh[ende:]
return roh
def _setze_gebaut(projekt: str, schritt: dict):
"""Ein abgehakter Schritt heißt: der Code ist da, die Funktion ist gebaut.
Geschrieben wird in die Arbeitskopie; `planung.setze_um` trägt sie danach in den
Projektstand. Scheitert der Agent später, bleibt der Haken stehen — was gebaut
ist, ist gebaut, und die Planung läuft von dort weiter.
"""
if not schritt.get("funktion"):
return
for datei in projekte.wurzel(projekt).glob("*.md"):
roh = datei.read_text()
if parser.zerlege(roh)["titel"] != schritt.get("karte"):
continue
neu = _als_gebaut(roh, schritt["funktion"])
if neu != roh:
datei.write_text(neu)
return
def _hake_ab(projekt: str, lauf: dict, zeile: str):
"""Eine ERLEDIGT-Zeile gegen die offenen Schritte halten.
Wörtlich wäre schön, aber ein Modell zitiert nicht immer wörtlich —
deshalb genügt große Ähnlichkeit mit genau einem offenen Schritt.
"""
gemeldet = zeile.split(MARKE, 1)[1].strip()
offen = [s for s in lauf["schritte"] if not s["fertig"]]
for s in offen:
if s["text"] == gemeldet:
s["fertig"] = True
_setze_gebaut(projekt, s)
return
naechster = max(
offen,
key=lambda s: difflib.SequenceMatcher(None, s["text"], gemeldet).ratio(),
default=None)
if naechster and difflib.SequenceMatcher(
None, naechster["text"], gemeldet).ratio() >= 0.6:
naechster["fertig"] = True
_setze_gebaut(projekt, naechster)
def _verarbeite(projekt: str, lauf: dict, roh: bytes):
"""Ein Stream-Ereignis: Haken setzen, Aktivität zeigen, Ergebnis merken."""
try:
ereignis = json.loads(roh)
except ValueError:
return
if ereignis.get("type") == "result":
lauf["text"] = str(ereignis.get("result") or "").strip()
return
if ereignis.get("type") != "assistant":
return
for block in (ereignis.get("message") or {}).get("content") or []:
if block.get("type") == "text":
for zeile in str(block.get("text") or "").splitlines():
if MARKE in zeile:
_hake_ab(projekt, lauf, zeile)
elif block.get("type") == "tool_use":
eingabe = block.get("input") or {}
ziel = (eingabe.get("file_path") or eingabe.get("path")
or eingabe.get("command") or eingabe.get("pattern") or "")
lauf["aktivitaet"] = f'{block.get("name", "")} {str(ziel)[:80]}'.strip()
async def _fuehre_aus(projekt: str, auftrag: str, ordner: str):
lauf = _laeufe[projekt]
try:
prozess = await asyncio.create_subprocess_exec(
*KOMMANDO, cwd=ordner, limit=PUFFER,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE)
prozess.stdin.write(auftrag.encode())
await prozess.stdin.drain()
prozess.stdin.close()
while zeile := await prozess.stdout.readline():
_verarbeite(projekt, lauf, zeile)
fehler = (await prozess.stderr.read()).decode(errors="replace").strip()
if await prozess.wait() != 0:
lauf["fehler"] = fehler or f"Agent endete mit Code {prozess.returncode}"
return
# Erst jetzt wird die Planung wahr: der Code entspricht den Karten.
planung.setze_um(projekt)
lauf["uebernommen"] = True
except FileNotFoundError:
lauf["fehler"] = "claude-CLI nicht gefunden — ist Claude Code installiert?"
except Exception as e: # sichtbar statt still verschluckt
lauf["fehler"] = str(e)
finally:
lauf["aktivitaet"] = ""
lauf["laeuft"] = False
def starte(projekt: str, auftrag: str, schritte: list[dict]) -> bool:
"""Startet den Agenten im Hintergrund. False, wenn nichts zu tun ist."""
if not auftrag:
return False
ordner = verzeichnis.lies(projekt)
if not ordner:
return False
if status(projekt)["laeuft"]:
raise ValueError("Für dieses Projekt arbeitet schon ein Agent")
_laeufe[projekt] = {
"laeuft": True, "fehler": "", "text": "",
"schritte": [{**s, "fertig": False} for s in schritte],
"aktivitaet": "", "uebernommen": False,
}
aufgabe = asyncio.get_event_loop().create_task(
_fuehre_aus(projekt, auftrag, ordner))
_aufgaben.add(aufgabe)
aufgabe.add_done_callback(_aufgaben.discard)
return True

View File

@@ -11,6 +11,15 @@ funktioniert, Annehmen schreibt die Datei.
Das Modell schreibt nie selbst in eine Datei. Es beschreibt den Sollzustand einer
Karte; was sich dadurch ändert, rechnet der Code aus. Das ist dieselbe Beweislast wie
bei der Prüfung und der Grund, warum ein Missverständnis hier nichts kaputt macht.
Gefragt wird über die Claude-CLI, nicht über MiniMax: das Gespräch soll planen helfen.
Ein Modell, das nur Kataloge abarbeitet, gibt hier die Frage zurück statt Vorschläge.
Zum Planen gehört der Quelltext, nicht das Web. Wer eine Karte entwirft, muss wissen,
was heute schon dasteht — das steht im Arbeitsverzeichnis und nirgends sonst. Fremdes
Wissen kommt aus der Recherche und ist ein eigener Raum (`recherche.py`): was dort
gefunden wird, bringt die Person selbst mit, statt dass es unbemerkt in einen Entwurf
sickert.
"""
import json
@@ -20,20 +29,28 @@ import features
import parser
import projekte
import pruefung
import verzeichnis
DATEI = ".chat.json"
# So viele Wortmeldungen bleiben stehen. Mehr wäre teuer je Anfrage und hilft wenig:
# was vor zwanzig Schritten besprochen wurde, steht längst auf den Karten.
TIEFE = 30
# Antworten in einem Rutsch. Ein Gespräch verträgt keine sechs Stimmen — es soll
# schnell sein, und was falsch ist, sieht man am Vorschlag.
TEMPERATUR = 0.3
# schnell sein, und was falsch ist, sieht man am Vorschlag. Dafür das starke Modell:
# hier wird geplant, nicht ein Katalog abgearbeitet.
SYSTEM = """Du entwickelst mit einer Person zusammen die Feature-Karten eines Projekts.
Eine Karte ist ein Auftrag: die Person will ein Problem gelöst haben. Die Lösung
besteht aus Regeln und Funktionen. Eine Funktion ist etwas, das man für sich testen
kann. Was nur die Umsetzung betrifft, gehört auf keine Karte.
kann. Wie eine Sache gebaut ist, gehört auf keine Karte — wie man sie bedient, immer.
Bedienung ist nicht nur Maus und Menü. Einrichten, Installieren und Starten sind
Funktionen wie jede andere; ihr Auslöser ist ein Kommando. Setze es wörtlich in
Anführungszeichen, damit die Person es abtippen kann: `Befehl "make install"
ausführen`. Was das Kommando innen tut, bleibt draußen.
{code}
{stil}
@@ -53,16 +70,55 @@ Antworte ausschließlich mit JSON:
VOLLSTÄNDIG, so wie sie danach aussehen sollen. Der Stand oben hat dieselbe Form:
kopiere die Karte von dort und ändere nur, was sich ändern soll. Jede Zeile, die
du nicht wörtlich mit abschreibst, gilt als gestrichen.
- art: „kern", wenn die Anwendung ohne die Karte keinen Sinn hätte, sonst „optional"
- ziel, regeln und testen dürfen leer sein oder fehlen
- art: „kern", wenn die Anwendung ohne die Karte nicht laufen kann, sonst „optional"
- ziel und regeln dürfen leer sein oder fehlen
- testen ist Pflicht bei jeder Funktion: der Ablauf, an dem man sie prüft. Weißt du
ihn nicht, gehört die Funktion nicht auf die Karte — dann frag danach
Ist nichts zu ändern, ist karten leer. Ändere nur, wonach gefragt wurde; Karten,
die niemand erwähnt hat, lässt du weg.
Frag nach, was du nicht weißt — aber leg trotzdem einen Entwurf daneben. Eine Frage
ohne Vorschlag hilft niemandem: an einem Entwurf sieht die Person sofort, was du
falsch verstanden hast. Erfinde dabei nichts, was nicht gesagt wurde; lass eine
Funktion lieber knapp als ausgedacht."""
Zwei Arten von Wortmeldung, und sie werden verschieden behandelt.
Eine Frage will eine Antwort, keinen Entwurf. „Wie ist das heute?", „Muss ich noch
etwas tun?", „Wozu dient das?" — antworte in Prosa und lass karten leer. Ungefragt
eine Karte anzufassen ist ein Fehler, auch wenn dir dabei etwas auffällt. Sag es in
einem Satz und frag, ob du es aufnehmen sollst.
Ein Wunsch will einen Entwurf. Sobald etwas anders werden soll oder dazukommen soll,
leg die Karten daneben, wie sie danach aussehen. Fehlt dir dafür eine Angabe, frag
danach — aber leg trotzdem einen Entwurf daneben. An einem Entwurf sieht die Person
sofort, was du falsch verstanden hast, und er gilt erst, wenn sie ihn annimmt.
Bei einem Wunsch gibst du die Frage nie unbeantwortet zurück. Wer nach Namen fragt
— für Kommandos, Funktionen oder Karten — bekommt konkrete Namen, keine Gegenfrage.
Nenne das Übliche für diese Art von Anwendung, sag in einem Satz, worauf du dich
stützt, und frag danach, was daran nicht passt. „Das kann ich nicht vorschlagen" ist
keine zulässige Antwort.
Gehört etwas nicht auf eine Karte, sag in einem Satz, wohin es stattdessen gehört.
Beim Bestand bleibst du bei der Wahrheit: über das, was das Projekt heute kann,
erfindest du nichts. Ein Entwurf für etwas Neues kommt von dir."""
# Zwei Fassungen desselben Absatzes. Ohne eingestelltes Arbeitsverzeichnis gibt es
# nichts zu lesen, und ein Angebot, das ins Leere greift, wäre schlimmer als keines:
# das Modell behauptete sonst, nachgesehen zu haben.
CODE_MIT = """Der Quelltext des Projekts liegt in deinem Arbeitsverzeichnis. Du darfst
darin lesen und suchen, und du sollst es tun, sobald dein Entwurf davon abhängt, wie
etwas heute gebaut ist: ob es eine Funktion schon gibt, wie eine Bedienstelle heißt,
was ein Ablauf wirklich auslöst. Rate nicht, sieh nach.
Was du dort findest, ist ein Beleg, kein Kartentext. Wie eine Sache gebaut ist, gehört
auf keine Karte — dorthin kommt nur, was die Person davon merkt. Stützt du dich auf
eine Stelle, nenne im Antwortsatz die Datei.
Widerspricht der Code den Karten, schreib die Karte nicht still um. Sag in einem Satz,
was du gefunden hast, und frag, welche Seite gilt."""
CODE_OHNE = """Für dieses Projekt ist kein Arbeitsverzeichnis eingestellt. Du kannst
den Quelltext also nicht ansehen. Was das Projekt heute kann, weißt du nur aus den
Karten unten — behaupte nichts darüber hinaus."""
# Die Formatregel steht auch hier, nicht nur im System-Prompt. Bei langen Antworten
# vergaß das Modell sie sonst und schrieb reinen Fließtext — die ganze Wortmeldung
@@ -80,6 +136,14 @@ Schreibe sie noch einmal, ausschließlich als das JSON-Objekt aus der Vorgabe
keine Einleitung, kein Nachsatz, keine Code-Zäune."""
def _arbeitsordner(projekt: str) -> str | None:
"""Wo der Quelltext liegt — oder nichts, wenn dort nichts zu holen ist."""
try:
return str(verzeichnis.pruefe(verzeichnis.lies(projekt)))
except (ValueError, FileNotFoundError, OSError):
return None
def _pfad(projekt: str):
return projekte.wurzel(projekt) / DATEI
@@ -239,9 +303,11 @@ def vorschlaege(projekt: str, karten: list) -> list:
if bestand is None:
# Neue Karte: es gibt keine Stelle, an der man ansetzen könnte —
# deshalb ein eigener Vorschlag, der die ganze Datei mitbringt.
# `sektionen` liegt daneben: nur am Titel lässt sich nicht entscheiden,
# ob die Karte taugt. Zerlegt wird hier, damit es keinen zweiten Parser gibt.
raus.append({"feld": "neue_karte", "original": "", "vorschlag": titel,
"roh": text, "ganzes_feld": True, "feature": "",
"funktion": "", "regel": "Aus dem Gespräch",
"roh": text, "sektionen": neu, "ganzes_feld": True,
"feature": "", "funktion": "", "regel": "Aus dem Gespräch",
"problem": "Neue Karte"})
continue
grund = f"Aus dem Gespräch: {titel}"
@@ -256,24 +322,29 @@ async def frage(projekt: str, text: str) -> dict:
text = (text or "").strip()
if not text:
raise ValueError("Nachricht ist leer")
agents.pruefe_konfiguration()
bisher = verlauf(projekt)
gespraech = "\n\n".join(f'{"Person" if e["rolle"] == "person" else "Du"}: {e["text"]}'
for e in bisher) or "Noch nichts besprochen."
# Der Ordner entscheidet über beides zugleich: was im Prompt angeboten wird und
# was die CLI überhaupt zulässt. Ein toter Pfad zählt wie keiner — der Prozess
# ließe sich darin nicht einmal starten.
ordner = _arbeitsordner(projekt)
# Beide Kataloge: der Chat schreibt Karten UND Funktionen. Vorher kannte er nur
# die Kartenregeln und erfand Funktionstitel wie „Galerie im Modal" — ein
# Bereich statt einer Handlung, und die Prüfung merkte es erst hinterher.
system = SYSTEM.format(
code=CODE_MIT if ordner else CODE_OHNE,
stil="\n\n".join(pruefung._stil_lesen(b) for b in pruefung.BEREICHE),
karten=_kartenstand(projekt))
nachricht = NACHRICHT.format(verlauf=gespraech, text=text)
kommando = agents.PLANUNG_KOMMANDO if ordner else agents.PLANUNG_OHNE_CODE
# Ein Ausrutscher beim JSON kostet sonst die ganze Wortmeldung. Zweiter Versuch
# mit etwas mehr Streuung; danach fliegt der Fehler nach oben und ist sichtbar.
# Ein Ausrutscher beim JSON kostet sonst die ganze Wortmeldung. Danach fliegt der
# Fehler nach oben und ist sichtbar.
daten = None
for versuch in range(3):
antwort = await agents.frage(system, nachricht, TEMPERATUR + 0.1 * versuch)
antwort = await agents.frage_lokal(system, nachricht, kommando, ordner)
try:
daten = pruefung._json_lesen(antwort)
break

BIN
backend/chatui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -27,12 +27,8 @@ MINIMAX_API_KEY = os.environ.get("MINIMAX_API_KEY", "")
MINIMAX_MODELL = os.environ.get("MINIMAX_MODELL", "")
MINIMAX_URL = os.environ.get("MINIMAX_URL", "")
# Das Gespräch darf woanders laufen als die Prüfungen. Es ist ein einzelner Aufruf
# und lebt von Sprachgefühl; die Prüfungen sind hundert Stimmen und leben von der
# Temperaturstreuung. Nicht gesetzt heißt: alles über MiniMax.
CHAT_API_KEY = os.environ.get("CHAT_API_KEY", "")
CHAT_MODELL = os.environ.get("CHAT_MODELL", "")
CHAT_URL = os.environ.get("CHAT_URL", "https://api.anthropic.com/v1/messages")
# Das Gespräch läuft nicht hierüber, sondern über die angemeldete Claude-CLI
# (`agents.frage_lokal`) — dasselbe Abo wie die Agenten-Übergabe, kein zweiter Zugang.
def _zahl(name: str, standard: int) -> int:

View File

@@ -81,35 +81,9 @@ def speichern(projekt: str, id: str, roh: str) -> dict:
titel = parser.zerlege(roh)["titel"]
if not titel:
raise ValueError("Titel fehlt")
vorher = datei.read_text()
datei.write_text(roh)
neu = _folge_titel(datei, titel)
# Ein neuer Titel benennt die Datei um. Der Rückschritt muss beides kennen:
# die neue Datei fällt weg, die alte kommt mit ihrem alten Inhalt zurück.
schritt = {datei.stem: vorher}
if neu.stem != datei.stem:
schritt[neu.stem] = None
return _eintrag(neu)
return _eintrag(_folge_titel(datei, titel))
def loeschen(projekt: str, id: str):
datei = _datei(projekt, id)
titel = parser.zerlege(datei.read_text())["titel"] or id
datei.unlink()
def verschieben(projekt: str, id: str, ziel: str) -> dict:
"""Ein Feature in ein anderes Projekt umhängen.
Kein Kopieren: die Datei wandert, damit es sie nur einmal gibt. Der Verlauf
hält beide Seiten fest — sonst ließe sich der Schritt nicht zurücknehmen.
"""
if ziel == projekt:
raise ValueError("Feature liegt schon in diesem Projekt")
datei = _datei(projekt, id)
roh = datei.read_text()
titel = parser.zerlege(roh)["titel"] or id
# Erst das Ziel auflösen: gibt es das Projekt nicht, ist noch nichts passiert
neu = _frei(projekte.wurzel(ziel), _slug(titel))
datei.rename(neu)
return _eintrag(neu)
_datei(projekt, id).unlink()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -20,12 +20,18 @@ import shutil
import parser
import projekte
import verzeichnis
# Was beim Vergleich zählt: der sichtbare Inhalt einer Funktion, nicht ihre Datei.
# Wird eine Karte umbenannt, wandert ihre Datei — der Vergleich hinge sonst an
# einem Dateinamen statt an dem, was dasteht.
FELDER = ("ziel", "regeln", "testen", "stand")
# Dateien, die dem Projekt gehören und nicht der Planung. Sie überleben das
# Umsetzen, auch wenn die Kopie sie nicht kennt. Das Gespräch und das
# Urteilsgedächtnis stehen bewusst nicht hier: die gehören zum geplanten Stand.
NUR_PROJEKT = (verzeichnis.DATEI,)
def laeuft(projekt: str) -> bool:
return (projekte.stand(projekt) / projekte.PLANUNG).is_dir()
@@ -55,6 +61,11 @@ def setze_um(projekt: str):
Erst die Kopie herausheben, dann den alten Stand räumen, dann die Kopie an
seine Stelle. Ein Absturz mitten im Räumen ließe sonst ein Projekt ohne Karten
zurück — und die Planung gleich mit.
Was dem Projekt gehört und nicht der Planung, bleibt stehen: das
Arbeitsverzeichnis wurde vielleicht erst nach dem Planungsstart festgelegt und
fehlt dann in der Kopie. Das Räumen hat es mitgenommen, und der nächste Lauf
fand kein Verzeichnis mehr.
"""
kopie = _kopie(projekt)
if not kopie.is_dir():
@@ -64,6 +75,9 @@ def setze_um(projekt: str):
shutil.rmtree(daneben, ignore_errors=True)
kopie.rename(daneben)
for eintrag in ordner.iterdir():
if eintrag.name in NUR_PROJEKT and not (daneben / eintrag.name).exists():
eintrag.rename(daneben / eintrag.name)
continue
shutil.rmtree(eintrag) if eintrag.is_dir() else eintrag.unlink()
for eintrag in daneben.iterdir():
eintrag.rename(ordner / eintrag.name)

View File

@@ -510,7 +510,7 @@ async def _stil_aufgaben(projekt: str):
stand = ledger.lade(projekt)
stand["stil"] = ledger.behalte(stand["stil"], {_stilschluessel(a) for a in liste})
return (liste,
lambda a, _zaehl: _pruefe(stil, a, stand),
lambda a: _pruefe(stil, a, stand),
lambda: ledger.sichere(projekt, stand))
@@ -523,17 +523,12 @@ async def _lauf(projekt: str, liste: list[dict], arbeite, abschluss=None):
lauf = _laeufe[projekt]
schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL)
def zaehl():
lauf["fertig"] += 1
async def eine(aufgabe):
if aufgabe is None:
return # Platzhalter: zählt der Sammellauf selbst hoch
async with schranke:
if _laeufe.get(projekt) is not lauf:
return # verworfen (Panel geschlossen) — keine weitere Anfrage
try:
lauf["probleme"].extend(await arbeite(aufgabe, zaehl))
lauf["probleme"].extend(await arbeite(aufgabe))
except Exception as e:
# Zählt trotzdem als erledigt, sonst hängt der Fortschritt.
# Die Meldung nennt die Zahl, damit „100 %" nicht vollständig vortäuscht.
@@ -541,8 +536,7 @@ async def _lauf(projekt: str, liste: list[dict], arbeite, abschluss=None):
lauf["meldung"] = (
f"{lauf['fehler']} von {lauf['gesamt']} Prüfungen fehlgeschlagen: {e}"
)
if aufgabe is not True:
lauf["fertig"] += 1
lauf["fertig"] += 1
try:
await asyncio.gather(*(eine(a) for a in liste))

208
backend/recherche.py Normal file
View File

@@ -0,0 +1,208 @@
"""Herausfinden, was gebaut werden soll — in mehreren Fäden nebeneinander.
Recherche ist kein Nachschlagewerk. Sie klärt, was draußen gilt, damit die Planung
daneben Karten schneiden kann: Zahlen, Grenzen, Namen, Belege. Was keine Entscheidung
trägt, gehört nicht in die Antwort.
Die Karten liegen dabei, aber nur zum Lesen. Ohne sie fragt man ins Leere — „welche
Funktionen fehlen noch" ist ohne den Bestand nicht zu beantworten, und das Modell
riete am Projekt vorbei. Vorschläge macht die Recherche trotzdem keine: sie schreibt
Prosa, keine Kartenfelder. Was aus einem Fund folgt, entscheidet die Person nebenan.
Mehrere Fäden, weil Themen sich sonst gegenseitig verdrecken. „Wie geht Upload zu S3"
und „welche Diagrammbibliothek" haben nichts miteinander zu tun; in einem Verlauf
zieht das Modell den einen Kontext in die andere Antwort. Ein Faden ist ein Thema,
und er bleibt stehen, bis ihn jemand schließt.
Die Antwort ist Markdown und wird als solches angezeigt. Kein JSON: hier wird nichts
in Felder zerlegt und nichts gegengerechnet, also gäbe es auch nichts zu prüfen — ein
Formatzwang kostete nur einen Aufruf mehr, wenn das Modell ihn verfehlt.
Die Fäden liegen im Projektstand, nicht in der Planungskopie: Nachgeschlagenes gilt
unabhängig davon, ob gerade geplant wird, und überlebt einen Planungsabbruch.
"""
import json
import uuid
import agents
import features
import parser
import projekte
import verzeichnis
DATEI = ".recherche.json"
# So viele Wortmeldungen behält ein Faden. Recherche geht in die Tiefe, deshalb mehr
# als im Planungsgespräch — dort steht das Ergebnis auf den Karten, hier nur im Faden.
TIEFE = 40
# Länger wird kein Reitertext lesbar; die ganze Frage steht ohnehin im Faden.
TITELLAENGE = 40
SYSTEM = """Du recherchierst für jemanden, der Software plant.
Was du herausfindest, wird eine Aufgabe für die Implementierung. Schreib deshalb so,
dass jemand danach loslegen kann, ohne deine Suche zu wiederholen: Zahlen, Grenzen,
Namen, Belege. Was keine Entscheidung trägt, lässt du weg — kein Hintergrund, keine
Geschichte, keine Alternative, die du schon verworfen hast.
Schlag im Web nach. Antworte nicht aus dem Gedächtnis, wenn es um Versionen, Grenzen,
Preise, Schnittstellen oder aktuelle Empfehlungen geht — da veraltet Wissen still.
Belege mit Links. Widersprechen sich zwei Quellen, sag es, statt dich zu entscheiden.
Findest du nichts, sag auch das — eine erfundene Zahl ist hier teurer als keine.
Antworte auf Deutsch, als Markdown. So ist eine Antwort gebaut:
Ein bis drei Sätze Prosa: was du gefunden hast und was daraus folgt.
## Festgelegt
Je Zeile eine Aussage mit ihrer Zahl, ihrem Namen, ihrer Grenze. Fett, was eine
Entscheidung ist. Keine Begründung — die steht oben in der Prosa.
## Offen
Was noch jemand entscheiden muss, als Frage. Weglassen, wenn nichts offen ist.
## Quellen
Je Zeile ein Link mit einem Halbsatz, wofür er steht.
Die Überschriften stehen nur da, wo sie etwas tragen. Auf eine Rückfrage antwortest du
in zwei Sätzen ohne Struktur; hast du nichts gefunden, sagst du das in einem.
Die Karten des Projekts stehen unten. Sie sagen, was gebaut werden soll — lies sie,
bevor du fragst, wovon die Rede ist. Wer wissen will, was noch fehlt, bekommt eine
Antwort, die sich auf diese Karten bezieht, nicht auf ein Projekt im Allgemeinen.
Du änderst die Karten nicht und schlägst auch keine Formulierung dafür vor. Was aus
deinem Fund folgt, entscheidet die Person nebenan. Den Quelltext siehst du nicht:
Was heute schon gebaut ist, steht als „gebaut" oder „offen" an den Funktionen.
Die Karten:
{karten}"""
def _punkte(text: str) -> list:
return [z.strip("- ").strip() for z in (text or "").splitlines() if z.strip()]
def _karten(projekt: str) -> str:
"""Der Kartenstand für den Prompt — mit „gebaut"/„offen", ohne Testabläufe.
Das Planungsgespräch blendet den Stand aus: dort entscheidet ihn der Code, und
ein Feld weniger ist ein Feld weniger zum Vergessen. Hier ist er das Wichtigste
am Bestand — „was fehlt noch" hängt an nichts anderem.
Die Testabläufe bleiben draußen. Sie sagen, wie man etwas prüft, und das trägt
keine Recherche; sie würden nur den Prompt füllen.
"""
vision, karten = "", []
for f in features.liste(projekt):
s = f["sektionen"]
if s["art"] == parser.VISION:
vision = s["ziel"]
continue
karten.append({
"titel": s["titel"], "art": s["art"], "ziel": s["ziel"],
"regeln": _punkte(s["regeln"]),
"funktionen": [{"titel": x["text"], "stand": x["stand"],
"ziel": x["ziel"], "regeln": _punkte(x["regeln"])}
for x in s["funktionen"]],
})
text = json.dumps(karten, indent=1, ensure_ascii=False) if karten \
else "Noch keine Karten."
if vision:
text = f"So ist das Projekt gedacht:\n\n{vision}\n\nDie Karten:\n\n{text}"
return text
def _pfad(projekt: str):
return projekte.stand(projekt) / DATEI
def _lade(projekt: str) -> list:
# Der Pfad wird vor dem try geholt: „Projekt gibt es nicht" ist ein
# FileNotFoundError und damit ein OSError. Im try gefangen, würde daraus ein
# leeres Ergebnis statt einer 404.
datei = _pfad(projekt)
try:
faeden = json.loads(datei.read_text())
except (OSError, ValueError):
return []
return faeden if isinstance(faeden, list) else []
def _sichere(projekt: str, faeden: list):
ziel = _pfad(projekt)
neben = ziel.with_suffix(".json.neu")
neben.write_text(json.dumps(faeden, indent=1, ensure_ascii=False) + "\n")
neben.replace(ziel)
def _finde(faeden: list, id: str) -> dict:
for f in faeden:
if f.get("id") == id:
return f
raise FileNotFoundError("Diesen Faden gibt es nicht mehr")
def liste(projekt: str) -> list:
"""Alle Fäden mit ihrem Verlauf — das Panel zeigt Reiter und Inhalt zugleich."""
return _lade(projekt)
def anlegen(projekt: str) -> dict:
"""Ein leerer Faden. Seinen Namen bekommt er von der ersten Frage."""
faeden = _lade(projekt)
faden = {"id": uuid.uuid4().hex[:8], "titel": "Neu", "verlauf": []}
faeden.append(faden)
_sichere(projekt, faeden)
return faden
def loeschen(projekt: str, id: str):
faeden = _lade(projekt)
_finde(faeden, id)
_sichere(projekt, [f for f in faeden if f.get("id") != id])
def _titel(text: str) -> str:
text = " ".join(text.split())
return text if len(text) <= TITELLAENGE else f"{text[:TITELLAENGE].rstrip()}…"
async def frage(projekt: str, id: str, text: str) -> dict:
"""Eine Runde in einem Faden — der Faden mit der neuen Antwort."""
text = (text or "").strip()
if not text:
raise ValueError("Nachricht ist leer")
faden = _finde(_lade(projekt), id)
gespraech = "\n\n".join(
f'{"Person" if e["rolle"] == "person" else "Du"}: {e["text"]}'
for e in faden["verlauf"]) or "Noch nichts besprochen."
nachricht = f"Bisher:\n{gespraech}\n\nNeue Frage:\n{text}"
# Ohne eingestelltes Arbeitsverzeichnis läuft der Prozess dort, wo der Server
# steht. Lesen ist ohnehin gesperrt; der Ordner sorgt nur dafür, dass ein
# gefundener Umweg im Projekt landet und nicht in planer2.
ordner = None
try:
ordner = str(verzeichnis.pruefe(verzeichnis.lies(projekt)))
except (ValueError, FileNotFoundError, OSError):
pass
antwort = (await agents.frage_lokal(
SYSTEM.format(karten=_karten(projekt)), nachricht,
agents.RECHERCHE_KOMMANDO, ordner)).strip()
# Erst jetzt neu laden: der Aufruf dauert Minuten, in denen ein anderer Faden
# angelegt oder geschlossen worden sein kann. Der Faden selbst kann weg sein —
# dann ist die Antwort verfallen, und das ist richtig so.
faeden = _lade(projekt)
faden = _finde(faeden, id)
if not faden["verlauf"]:
faden["titel"] = _titel(text)
faden["verlauf"] += [{"rolle": "person", "text": text},
{"rolle": "ki", "text": antwort}]
faden["verlauf"] = faden["verlauf"][-TIEFE:]
_sichere(projekt, faeden)
return faden

View File

@@ -4,11 +4,14 @@ hier wird auf 400/404/409 gemappt."""
from fastapi import APIRouter, HTTPException
import agents
import auftrag
import chat
import features
import projekte
import planung
import pruefung
import recherche
import verzeichnis
router = APIRouter(prefix="/api")
@@ -127,17 +130,64 @@ async def planung_starten(name: str):
@router.post("/projekte/{name}/planung/umsetzen")
async def planung_umsetzen(name: str):
"""Mit Arbeitsverzeichnis implementiert ein Agent die Änderungen; die Planung
bleibt stehen und wird erst nach seinem Erfolg übernommen. Ohne Verzeichnis
oder ohne Änderungen wird sofort übernommen."""
try:
planung.setze_um(name)
anweisung, schritte = auftrag.bau_auftrag(name)
agent = auftrag.starte(name, anweisung, schritte)
if not agent:
planung.setze_um(name)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(409, str(e))
return {"ok": True}
return {"ok": True, "agent": agent}
@router.get("/projekte/{name}/auftrag")
async def auftrag_status(name: str):
return auftrag.status(name)
@router.get("/system/ordner")
async def ordner_liste(pfad: str = ""):
"""Zum Durchblättern im Verzeichnis-Dialog — listet nur, schreibt nie."""
try:
return verzeichnis.ordner(pfad)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(400, str(e))
@router.get("/projekte/{name}/verzeichnis")
async def verzeichnis_lesen(name: str):
try:
return {"pfad": verzeichnis.lies(name)}
except FileNotFoundError as e:
raise HTTPException(404, str(e))
@router.put("/projekte/{name}/verzeichnis")
async def verzeichnis_setzen(name: str, body: dict):
pfad = (body.get("pfad") or "").strip()
try:
if not pfad:
verzeichnis.loesche(name)
return {"pfad": ""}
return {"pfad": verzeichnis.setze(name, pfad)}
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(400, str(e))
@router.delete("/projekte/{name}/planung")
async def planung_abbrechen(name: str):
# Solange der Agent auf der Planung arbeitet, darf sie ihm niemand wegziehen
if auftrag.status(name)["laeuft"]:
raise HTTPException(409, "Der Agent arbeitet noch an dieser Planung")
try:
planung.brich_ab(name)
except FileNotFoundError as e:
@@ -147,6 +197,8 @@ async def planung_abbrechen(name: str):
@router.post("/projekte/{name}/planung/zuruecksetzen")
async def planung_zuruecksetzen(name: str, body: dict):
if auftrag.status(name)["laeuft"]:
raise HTTPException(409, "Der Agent arbeitet noch an dieser Planung")
try:
planung.zuruecksetzen(name, (body or {}).get("titel") or "")
except FileNotFoundError as e:
@@ -171,16 +223,6 @@ async def feature_loeschen(name: str, id: str):
return {"ok": True}
@router.put("/projekte/{name}/features/{id}/projekt")
async def feature_verschieben(name: str, id: str, body: dict):
try:
return features.verschieben(name, id, (body.get("ziel") or "").strip())
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(400, str(e))
@router.get("/projekte/{name}/chat")
async def chat_verlauf(name: str):
try:
@@ -208,3 +250,40 @@ async def chat_leeren(name: str):
except FileNotFoundError as e:
raise HTTPException(404, str(e))
return {"ok": True}
@router.get("/projekte/{name}/recherche")
async def recherche_faeden(name: str):
try:
return {"faeden": recherche.liste(name)}
except FileNotFoundError as e:
raise HTTPException(404, str(e))
@router.post("/projekte/{name}/recherche")
async def recherche_anlegen(name: str):
try:
return recherche.anlegen(name)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
@router.post("/projekte/{name}/recherche/{id}")
async def recherche_frage(name: str, id: str, body: dict):
try:
return await recherche.frage(name, id, body.get("text") or "")
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except agents.NichtKonfiguriert as e:
raise HTTPException(400, str(e))
except ValueError as e:
raise HTTPException(400, str(e))
@router.delete("/projekte/{name}/recherche/{id}")
async def recherche_loeschen(name: str, id: str):
try:
recherche.loeschen(name, id)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
return {"ok": True}

View File

@@ -8,22 +8,42 @@ Abweichung als Problem und schlägt einen korrigierten Text vor.
Gilt für jedes Feld.
- Sprache: Deutsch, Präsens, keine Anrede
- Icons heißen `<Name>-Icon`. Der Name darf ein Zeichen sein: `x-Icon`, `+-Icon`,
`Stift-Icon`. Ein Doppelpunkt mit dem tatsächlichen Zeichen dahinter ist erlaubt
(`Nacht-Icon: ☾`)
- Maustasten als `LMB`, `RMB`, `MMB`
- Ein Menüeintrag wird wörtlich zitiert: `Klick auf "Feature entfernen"`
- Ein Kommando wird wörtlich zitiert: `Befehl "make install" ausführen`
- Ein Zitat zählt als ein Wort und darf Sonderzeichen tragen
- Jeder Gegenstand heißt überall gleich
## Bedienung, nicht Bauweise
Karten beschreiben, was gilt und wie man es auslöst. Wie es aussieht und wie es
innen gebaut ist, gehört nicht dazu.
- Keine Icons, keine Zeichen als Namen
- Keine Farben
- Keine Positionen wie `rechts oben` oder `unter dem Titel`
- Kein Layout: Rahmen, Spalten, Abstände, Breiten
- Erlaubt ist der Bereich: `Kopfzeile`, `Hauptbereich`, `Projektliste`
- Kein Aufbau eines Skripts, nur sein Aufruf
- Keine Dateinamen, Klassen oder Bibliotheken
Richtig: `LMB auf die Einstufung` · `Rückfrage vor dem Entfernen`
Richtig: `Befehl "make install" ausführen` · `Ein Aufruf genügt`
Falsch: `Augen-Icon rechts oben` · `Kern blau umrandet` · `Marke rechts vom Titel`
Falsch: `Das Skript legt einen Symlink in autostart` (Bauweise)
## Vom System gemessen
Diese Vorgaben prüft der Code selbst, exakt und für jede Zeile. Halte dich beim
Schreiben daran — aber berufe dich nie darauf. Als `regel` sind sie nicht zulässig.
- Jede Funktion hat Ziel, Regeln oder Testschritte — nie nur einen Titel
- Jede Funktion hat Testschritte — ohne sie ist sie keine Funktion
- Titel einer Funktion endet auf ein Verb im Infinitiv
- Titel einer Funktion beginnt nicht mit einem Auslöser
- Testschritte beschreiben genau einen Ablauf
- Kein Satzzeichen am Zeilenende, kein Punkt und kein Semikolon
- Keine Füllwörter. Die Liste ist abschließend: auch, noch, einfach, jeweils, ggf.
- Keine Klammern und keine Schrägstriche
- Keine Klammern und keine Schrägstriche außerhalb eines Zitats
- Titel der Karte: höchstens 3 Wörter
- Titel einer Funktion: höchstens 5 Wörter
- Ziel: höchstens 2 Sätze, je höchstens 12 Wörter

View File

@@ -2,10 +2,11 @@
Feld `badge`.
- `kern`: ohne die Karte ist das Projekt nicht benutzbar
- `optional`: nützlich, aber verzichtbar
- `kern`: fehlt die Karte, kann die Anwendung nicht laufen
- `optional`: fehlt die Karte, läuft die Anwendung weiter — mit weniger Funktionen
- Die Prüffrage ist immer dieselbe: Streiche die Karte. Läuft der Rest noch?
- Die Einstufung muss zum Ziel der Karte passen
- Im Zweifel `kern`
Richtig: `Projekte` ist kern · `Darkmode` ist optional
Falsch: `Darkmode` als kern (das Projekt läuft ohne)
Richtig: `Projekt` ist kern · `Prüfung` ist optional
Falsch: `Prüfung` als kern (ohne Prüfung läuft der Planer weiter)
Falsch: `Sicherung` als kern, weil sie wichtig ist (wichtig heißt nicht tragend)

View File

@@ -4,10 +4,13 @@ Feld `regeln`, wenn `funktion` leer ist. Gilt für die ganze Karte.
- Eine Zeile je Regel
- Genau eine Aussage je Zeile; zwei Aussagen werden zu zwei Regeln
- Sichert etwas zu, das man sonst nicht sieht
- Beschreibt eine Eigenschaft oder Bedingung, keinen Ablaufschritt
- Steht nicht schon in den Testschritten
- Steht nicht schon als Regel der Karte
- Keine Aufzählung mit Komma oder „und" zweier gleichrangiger Aussagen
- Ein Icon-Name allein ist eine gültige Regel
Richtig: `Alphabetisch sortiert` · `Verlassen speichert` · `Escape verwirft`
Falsch: `Kern blau umrandet, Optional grün umrandet` (zwei Aussagen)
Falsch: `Klick auf Badge` (Ablaufschritt, gehört zu Testen)
Falsch: `Titel eingeben, dann bestätigen` (zwei Aussagen)
Falsch: `LMB auf Badge` (Ablaufschritt, gehört zu Testen)
Falsch: `Entsteht leer`, wenn die Testschritte keine Eingabe zeigen (schon sichtbar)

View File

@@ -2,8 +2,12 @@
Feld `titel`, wenn `funktion` leer ist.
- Substantiv, oder Objekt mit Verb im Infinitiv
- Benennt die Sache, kein ganzer Satz
- Benennt einen Gegenstand, keine Tätigkeit
- Sammelbegriff für die Funktionen der Karte
- Jede Funktion der Karte handelt von diesem Gegenstand
- Ein Wort, wenn es reicht
Richtig: `Features` · `Karten prüfen` · `Darkmode`
Falsch: `Features verwalten und anzeigen` (zu lang, Tätigkeit)
Richtig: `Projekt` · `Feature` · `Vorschlag` · `Planung`
Falsch: `Karten prüfen` (Tätigkeit, das ist ein Funktionstitel)
Falsch: `Verwaltung und Anzeige` (zwei Gegenstände)
Falsch: `Gespräch` für eine Karte über Anweisungen (führt in die Irre)

View File

@@ -2,8 +2,13 @@
Feld `ziel`, wenn `funktion` leer ist.
- Beschreibt den Zweck der Karte, nicht die Bedienung
- Entfällt, wenn der Titel selbsterklärend ist — dann bleibt das Feld leer
- Ergänzt den Titel, bis er eindeutig ist
- Lässt erwarten, welche Funktionen auf der Karte stehen
- Beginnt mit einem Verb
- Erklärt den Titel nicht mit sich selbst
- Beschreibt den Zweck, nie die Bedienung
Richtig: `Gruppiert Informationen zu ähnlichen Funktionen`
Falsch: `Klick auf die Karte öffnet den Vollmodus` (Bedienung gehört zu Testen)
Richtig: `Bündelt die Features einer Anwendung samt Verzeichnis`
Falsch: `Gruppiert Features für ein Projekt` auf der Karte `Projekt` (zirkulär)
Falsch: `Eine Änderung, die auf Zustimmung wartet` (Definition ohne Verb)
Falsch: `LMB auf die Karte öffnet sie` (Bedienung gehört zu Testen)

View File

@@ -4,10 +4,13 @@ Feld `regeln`, wenn `funktion` gesetzt ist. Gilt nur für diese Funktion.
- Eine Zeile je Regel
- Genau eine Aussage je Zeile; zwei Aussagen werden zu zwei Regeln
- Sichert etwas zu, das man sonst nicht sieht
- Beschreibt eine Eigenschaft oder Bedingung, keinen Ablaufschritt
- Steht nicht schon in den Testschritten
- Steht nicht schon als Regel der Karte
- Keine Aufzählung mit Komma oder „und" zweier gleichrangiger Aussagen
- Ein Icon-Name allein ist eine gültige Regel
Richtig: `x-Icon` · `Kern blau umrandet` · `Aktualisiert den Dateinamen`
Falsch: `Kern blau umrandet, Optional grün umrandet` (zwei Aussagen)
Falsch: `Klick auf Badge` (Ablaufschritt, gehört zu Testen)
Richtig: `Titel ist Pflicht` · `Rückfrage vor dem Entfernen`
Falsch: `Titel eingeben, dann bestätigen` (zwei Aussagen)
Falsch: `LMB auf Badge` (Ablaufschritt, gehört zu Testen)
Falsch: `Entsteht leer`, wenn die Testschritte keine Eingabe zeigen (schon sichtbar)

View File

@@ -2,22 +2,40 @@
Feld `testen`. Gibt es nur bei Funktionen, nie auf Kartenebene.
Pflicht bei jeder Funktion. Eine Funktion ist etwas, das man für sich testen kann —
ohne Ablauf ist sie nur eine Behauptung. Ziel und Regeln dürfen fehlen, Testen nie.
Weiß man den Ablauf nicht, ist es keine Funktion, sondern eine offene Frage.
- Eine Zeile je Schritt
- Genau ein Ablauf: handeln, handeln, Ergebnis
- Nach dem Ergebnis folgt keine Handlung mehr
- Schritte in Reihenfolge der Bedienung
- Beginnt mit der Vorbedingung, wenn eine nötig ist
- Kein Schritt der Bedienung fehlt
- Jeder Schritt ist ohne Vorwissen ausführbar
- Letzte Zeile beschreibt den Endzustand, keine Handlung
- Handlungen als Nominalstil mit Maustaste oder Verb im Infinitiv
- Handlungen als Nominalstil mit Maustaste, Kommando oder Verb im Infinitiv
- Ein Kommando steht wörtlich im Zitat und ist so aufrufbar
Richtig:
```
- Vollmodus öffnen
- RMB auf Funktion
- Feature öffnen
- RMB auf die Funktion
- Klick auf "Funktion entfernen"
- Bestätigen
- Funktion entfernt
```
Falsch: letzte Zeile `Klick zum entfernen` (Handlung statt Endzustand)
Auf der Kommandozeile gilt dasselbe — der Aufruf ist die Handlung:
```
- Befehl "make install" ausführen
- Abmelden und wieder anmelden
- Anwendung läuft automatisch
```
Falsch: `Installationsskript starten` (sagt nicht, wie)
Falsch: `Der Prüfschritt kopiert die Desktop-Datei` (Bauweise statt Bedienung)
Falsch: letzte Zeile `Klick zum Entfernen` (Handlung statt Endzustand)
Falsch: `Funktion entfernen wählen` (sagt nicht, wo)
Falsch, weil zwei Abläufe in einer Funktion stehen:
```

View File

@@ -3,9 +3,18 @@
Feld `titel`, wenn `funktion` gesetzt ist.
- Kurzsatz aus Objekt und Verb im Infinitiv
- Beschreibt etwas, das man für sich testen kann
- Nennt den Gegenstand der Karte, wenn das nicht lügt
- Genau eine Tätigkeit
- Endet auf ein Verb im Infinitiv
- Nennt eine Handlung, keinen Bereich, keinen Zustand
- Die Handlung löst ein Mensch aus, nicht das System
- Nennt die Wirkung, nie den Auslöser — der steht in den Testschritten
Richtig: `Feature erstellen` · `Anzeigemodus umschalten` · `Feature als Datei speichern`
Falsch: `Erstellung von Features` (Substantivierung) · `Feature erstellen und öffnen` (zwei Tätigkeiten) · `Galerie im Modal` (ein Bereich) · `Preis aktualisiert` (ein Zustand) · `In den Warenkorb` (kein Verb)
Richtig: `Feature anlegen` · `Vorschlag annehmen` · `Sicht wechseln`
Richtig: `Anwendung installieren` · `Autostart aktivieren` · `Tests ausführen`
Falsch: `Erstellung von Features` (Substantivierung)
Falsch: `Feature anlegen und öffnen` (zwei Tätigkeiten)
Falsch: `Galerie im Modal` (ein Bereich) · `Preis aktualisiert` (ein Zustand)
Falsch: `Änderung markieren` (das System markiert, gehört in die Regeln)
Falsch: `Befehl "make install" ausführen` (der Auslöser, gehört zu Testen)
Falsch: `LMB auf Speichern` (der Auslöser, gehört zu Testen)

View File

@@ -2,8 +2,12 @@
Feld `ziel`, wenn `funktion` gesetzt ist.
- Beschreibt den Zweck der Funktion, nicht die Bedienung
- Entfällt, wenn der Funktionstitel selbsterklärend ist — dann bleibt das Feld leer
- Ergänzt den Titel um das Warum
- Beginnt mit einem Verb
- Entfällt, wenn der Titel selbsterklärend ist — dann bleibt das Feld leer
- Wiederholt den Titel nicht
- Beschreibt den Zweck, nie die Bedienung
Richtig: `Beschreibt kurz das Kernziel der Funktion`
Falsch: `Klick auf Regeln der Funktion` (Bedienung gehört zu Testen)
Richtig: `Trennt tragende von verzichtbaren Features`
Falsch: `Legt die Vision an` auf `Vision anlegen` (wiederholt den Titel)
Falsch: `LMB auf Regeln der Funktion` (Bedienung gehört zu Testen)

View File

@@ -37,10 +37,17 @@ _INFINITIV = re.compile(r"(?:e[lr]?n|ln|rn)$", re.I)
# Eine Handlung steht als Infinitiv am Anfang oder am Ende der Zeile („Bestätigen im
# Modal", „Text ändern") oder beginnt mit einer Taste. Alles andere ist ein Ergebnis.
# Nur Anfang und Ende, nicht irgendwo: deutsche Mehrzahl endet auch auf -en.
_TASTE = re.compile(r"^(?:LMB|RMB|MMB|Klick|Doppelklick|Enter|Escape|Strg|Tab)\b", re.I)
# „Befehl" und „Kommando" stehen dabei wie eine Taste: auf der Kommandozeile ist der
# Aufruf die Handlung, und ohne sie wäre ein Setup nicht bedienbar zu beschreiben.
_TASTE = re.compile(
r"^(?:LMB|RMB|MMB|Klick|Doppelklick|Enter|Escape|Strg|Tab|Befehl|Kommando)\b", re.I)
SATZENDE = ".;"
_KLAMMER = re.compile(r"[()/]")
# Ein Zitat ist eine Einheit: ein Menüeintrag oder ein Kommando. „Befehl "make install"
# ausführen" sind drei Wörter, nicht vier, und der Schrägstrich in „npm run build/dev"
# ist keiner von uns. Darum wird außerhalb der Zitate gemessen.
_ZITAT = re.compile(r'"[^"]*"|„[^“]*“')
# Eine Vorlage ist kein Inhalt. Das Modell schlug `<testschritt>` als Testschritt
# vor — formal richtig, gemeint war „hier fehlt noch etwas".
_LUECKE = re.compile(r"<[^<>]*>|\.\.\.|\bTODO\b|\bXXX\b", re.I)
@@ -58,9 +65,30 @@ def _problem(feld: str, original: str, problem: str, vorschlag: str = "") -> dic
return {"feld": feld, "original": original, "problem": problem, "vorschlag": vorschlag}
def _messbar(zeile: str) -> str:
"""Die Zeile mit jedem Zitat als einem Wort — so wird gezählt und gesucht."""
return _ZITAT.sub("Zitat", zeile)
def _ausserhalb(zeile: str, wandel) -> str:
"""Wendet `wandel` nur auf die Teile außerhalb der Zitate an.
Ein Füllwort im Menüeintrag ist keins — dort steht der Text, den die Anwendung
zeigt. Würde er mitgestrichen, verwiese der Testschritt auf einen Knopf, den es
nicht gibt.
"""
teile, letzt = [], 0
for m in _ZITAT.finditer(zeile):
teile.append(wandel(zeile[letzt:m.start()]))
teile.append(m.group(0))
letzt = m.end()
teile.append(wandel(zeile[letzt:]))
return "".join(teile)
def _ohne_wort(zeile: str, wort: str) -> str:
neu = re.sub(rf"\s*\b{re.escape(wort)}\b\.?", "", zeile, flags=re.I).strip()
return re.sub(r"\s{2,}", " ", neu)
weg = lambda t: re.sub(rf"\s*\b{re.escape(wort)}\b\.?", "", t, flags=re.I)
return re.sub(r"\s{2,}", " ", _ausserhalb(zeile, weg)).strip()
def _reparierbar(zeile: str) -> tuple[list[str], str]:
@@ -70,7 +98,7 @@ def _reparierbar(zeile: str) -> tuple[list[str], str]:
gruende.append("Satzzeichen am Zeilenende")
neu = neu[:-1].rstrip()
for wort in FUELLWOERTER:
if re.search(rf"\b{wort}\b", neu, re.I):
if re.search(rf"\b{wort}\b", _messbar(neu), re.I):
gruende.append(f"Füllwort „{wort}“")
neu = _ohne_wort(neu, wort)
return gruende, neu
@@ -79,19 +107,24 @@ def _reparierbar(zeile: str) -> tuple[list[str], str]:
def _umschreiben(bereich: str, feld: str, zeile: str) -> list[str]:
"""Verstöße, die der Code sieht, aber nicht beheben kann."""
gruende = []
if _KLAMMER.search(zeile):
gemessen = _messbar(zeile)
if _KLAMMER.search(gemessen):
gruende.append("Klammer oder Schrägstrich")
if _LUECKE.search(zeile):
if _LUECKE.search(gemessen):
gruende.append("Platzhalter statt Inhalt")
grenze = WORTGRENZEN.get((bereich, feld))
if grenze and len(zeile.split()) > grenze:
if grenze and len(gemessen.split()) > grenze:
gruende.append(f"Mehr als {grenze} Wörter")
if bereich == "funktion" and feld == "titel":
woerter = zeile.split()
woerter = gemessen.split()
if woerter and not _INFINITIV.search(woerter[-1]):
gruende.append("Kein Verb im Infinitiv am Ende")
# Der Titel nennt die Wirkung, nicht den Griff. „Befehl "make install"
# ausführen" ist ein Testschritt — als Titel sagt er nicht, was danach gilt.
if _TASTE.match(zeile):
gruende.append("Auslöser statt Wirkung im Titel")
if feld == "ziel":
saetze = [s.strip() for s in re.split(r"[.!?]+", zeile) if s.strip()]
saetze = [s.strip() for s in re.split(r"[.!?]+", gemessen) if s.strip()]
if len(saetze) > ZIEL_SAETZE:
gruende.append(f"Mehr als {ZIEL_SAETZE} Sätze")
if any(len(s.split()) > ZIEL_WOERTER for s in saetze):
@@ -100,7 +133,7 @@ def _umschreiben(bereich: str, feld: str, zeile: str) -> list[str]:
def _handlung(zeile: str) -> bool:
woerter = zeile.split()
woerter = _messbar(zeile).split()
if not woerter:
return True
return bool(_TASTE.match(zeile) or _INFINITIV.search(woerter[-1])
@@ -115,10 +148,12 @@ def _zweiter_ablauf(zeilen: list[str]) -> str:
„Bildergalerie bedienen" mit blättern, Miniatur wählen und Großansicht in einem.
"""
ergebnis = False
for zeile in zeilen:
for i, zeile in enumerate(zeilen):
if ergebnis and _handlung(zeile):
return zeile
if not _handlung(zeile):
# Die erste Zeile darf ein Zustand sein: das ist die Vorbedingung, nicht das
# Ergebnis. „Badge ist sichtbar" vor „RMB auf Badge" ist ein Ablauf, nicht zwei.
if i and not _handlung(zeile):
ergebnis = True
return ""
@@ -137,14 +172,15 @@ def _zeile(bereich: str, feld: str, zeile: str) -> list[dict]:
def befunde(bereich: str, felder: dict) -> list[dict]:
"""Alle messbaren Verstöße einer Prüfstelle, in Reihenfolge der Felder."""
raus = []
# Ein bloßer Titel beschreibt nichts — und lässt sich über alles stülpen. Der
# Prüfer hielt damit einen Druckknopf für „Modal mit Tastatur steuern".
# Testschritte sind Pflicht. Eine Funktion ist definiert als etwas, das man für
# sich testen kann — ohne Ablauf ist die Behauptung unbelegt und der Titel lässt
# sich über alles stülpen. Ziel und Regeln bleiben freiwillig.
# Nur wenn die ganze Funktion vorliegt: bei einem einzelnen Feld sagt das Fehlen
# der anderen nichts.
inhaltsfelder = ("ziel", "regeln", "testen")
if bereich == "funktion" and all(f in felder for f in inhaltsfelder) \
and not any((felder.get(f) or "").strip() for f in inhaltsfelder):
raus.append(_problem("testen", "", "Funktion ohne Ziel, Regeln und Testen"))
and not (felder.get("testen") or "").strip():
raus.append(_problem("testen", "", "Funktion ohne Testschritte"))
return raus
for feld, text in felder.items():
if feld == "badge":

View File

@@ -0,0 +1,418 @@
"""Agenten-Übergabe: der Auftrag beschreibt den Diff, der Start braucht ein Verzeichnis."""
import asyncio
import pytest
import auftrag
import features
import planung
import projekte
import verzeichnis
T = "-" * 40
@pytest.fixture
def projekt(datenordner):
projekte.anlegen("A")
features.anlegen("A", f"<!-- titel -->\nGalerie\n{T}\n<!-- funktion -->\nBild wählen\n")
features.anlegen("A", f"<!-- titel -->\nShop\n{T}\n<!-- funktion -->\nKarte kaufen\n")
yield "A"
auftrag._laeufe.clear()
def test_ohne_planung_kein_auftrag(projekt):
assert auftrag.bau_auftrag(projekt) == ("", [])
def test_leere_planung_kein_auftrag(projekt):
planung.starte(projekt)
assert auftrag.bau_auftrag(projekt) == ("", [])
def test_auftrag_nennt_diff_und_sollzustand(projekt):
planung.starte(projekt)
features.anlegen(projekt, f"<!-- titel -->\nMerkliste\n{T}\n<!-- funktion -->\nProdukt merken\n")
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Shop")
features.loeschen(projekt, f["id"])
text, schritte = auftrag.bau_auftrag(projekt)
texte = [s["text"] for s in schritte]
assert "Merkliste / Produkt merken: PRÜFEN" in texte
assert "Karte „Shop“ streichen" in texte
assert all(s in text for s in texte)
# Die Zuordnung steht daneben, nicht nur im Satz
assert {"text": "Merkliste / Produkt merken: PRÜFEN", "karte": "Merkliste",
"funktion": "Produkt merken"} in schritte
assert {"text": "Karte „Shop“ streichen", "karte": "Shop",
"funktion": ""} in schritte
# Sollzustand: die neue Karte steht voll drin, die gestrichene nicht
assert "<!-- funktion -->\nProdukt merken" in text
assert "Karte kaufen" not in text.split("Die betroffenen Karten")[1]
def test_start_braucht_verzeichnis(projekt):
assert auftrag.starte(projekt, "tu was", ["x"]) is False
def test_start_braucht_auftrag(projekt, tmp_path):
verzeichnis.setze(projekt, str(tmp_path))
assert auftrag.starte(projekt, "", []) is False
class Strom:
"""Attrappe des Claude-Prozesses: gibt vorbereitete Stream-Zeilen aus."""
def __init__(self, zeilen, rc=0, gesehen=None):
import json as _json
self._zeilen = [(_json.dumps(z) + "\n").encode() for z in zeilen]
self.returncode = rc
self._gesehen = gesehen if gesehen is not None else {}
class Stdin:
def write(s, daten): self._gesehen["eingabe"] = daten.decode()
async def drain(s): pass
def close(s): pass
self.stdin = Stdin()
class Stdout:
async def readline(s):
return self._zeilen.pop(0) if self._zeilen else b""
self.stdout = Stdout()
class Stderr:
async def read(s): return b""
self.stderr = Stderr()
async def wait(self):
return self.returncode
def text_ereignis(text):
return {"type": "assistant", "message": {"content": [{"type": "text", "text": text}]}}
def test_start_fuehrt_claude_im_verzeichnis_aus(projekt, tmp_path, monkeypatch):
verzeichnis.setze(projekt, str(tmp_path))
planung.starte(projekt)
gesehen = {}
async def unecht(*kommando, cwd, **k):
gesehen["kommando"] = kommando
gesehen["cwd"] = cwd
return Strom([{"type": "result", "result": "fertig"}], gesehen=gesehen)
monkeypatch.setattr(asyncio, "create_subprocess_exec", unecht)
async def lauf():
assert auftrag.starte(projekt, "Setze um: X",
[{"text": "S1", "karte": "", "funktion": ""}]) is True
assert auftrag.status(projekt)["laeuft"] is True
await asyncio.gather(*auftrag._aufgaben)
asyncio.run(lauf())
assert gesehen["kommando"][0] == "claude"
assert "--output-format" in gesehen["kommando"]
assert gesehen["cwd"] == str(tmp_path)
assert gesehen["eingabe"] == "Setze um: X"
stand = auftrag.status(projekt)
assert stand["text"] == "fertig"
assert stand["laeuft"] is False
def test_schritte_werden_abgehakt_und_planung_uebernommen(projekt, tmp_path, monkeypatch):
verzeichnis.setze(projekt, str(tmp_path))
planung.starte(projekt)
features.anlegen(projekt, f"<!-- titel -->\nMerkliste\n{T}\n<!-- funktion -->\nProdukt merken\n")
anweisung, schritte = auftrag.bau_auftrag(projekt)
async def unecht(*a, **k):
return Strom([
{"type": "assistant", "message": {"content": [
{"type": "tool_use", "name": "Edit", "input": {"file_path": "x.py"}}]}},
text_ereignis(f'ERLEDIGT: {schritte[0]["text"]}'),
{"type": "result", "result": "alles gebaut"},
])
monkeypatch.setattr(asyncio, "create_subprocess_exec", unecht)
async def lauf():
auftrag.starte(projekt, anweisung, schritte)
await asyncio.gather(*auftrag._aufgaben)
asyncio.run(lauf())
stand = auftrag.status(projekt)
assert stand["schritte"] == [{**schritte[0], "fertig": True}]
assert stand["uebernommen"] is True
assert planung.laeuft(projekt) is False
assert "Merkliste" in [f["sektionen"]["titel"] for f in features.liste(projekt)]
def test_fehlschlag_laesst_die_planung_stehen(projekt, tmp_path, monkeypatch):
verzeichnis.setze(projekt, str(tmp_path))
planung.starte(projekt)
features.anlegen(projekt, f"<!-- titel -->\nMerkliste\n{T}\n<!-- funktion -->\nProdukt merken\n")
anweisung, schritte = auftrag.bau_auftrag(projekt)
async def unecht(*a, **k):
return Strom([], rc=1)
monkeypatch.setattr(asyncio, "create_subprocess_exec", unecht)
async def lauf():
auftrag.starte(projekt, anweisung, schritte)
await asyncio.gather(*auftrag._aufgaben)
asyncio.run(lauf())
stand = auftrag.status(projekt)
assert stand["uebernommen"] is False
assert stand["fehler"]
assert planung.laeuft(projekt) is True
assert "Merkliste" not in [f["sektionen"]["titel"] for f in features.liste(projekt, nur_stand=True)]
def test_ungefaehre_meldung_hakt_den_naechsten_schritt_ab():
lauf = {"schritte": [{"text": "Galerie / Bild wählen: NEU", "fertig": False},
{"text": "Karte „Shop“ streichen", "fertig": False}]}
auftrag._hake_ab("Test", lauf, "ERLEDIGT: Galerie / Bild wählen (NEU)")
assert lauf["schritte"][0]["fertig"] is True
auftrag._hake_ab("Test", lauf, "ERLEDIGT: völlig anderes Zeug")
assert lauf["schritte"][1]["fertig"] is False
def test_fehlender_claude_wird_gemeldet(projekt, tmp_path, monkeypatch):
verzeichnis.setze(projekt, str(tmp_path))
async def kaputt(*a, **k):
raise FileNotFoundError("claude")
monkeypatch.setattr(asyncio, "create_subprocess_exec", kaputt)
async def lauf():
auftrag.starte(projekt, "X", [{"text": "S", "karte": "", "funktion": ""}])
await asyncio.gather(*auftrag._aufgaben)
asyncio.run(lauf())
assert "claude-CLI nicht gefunden" in auftrag.status(projekt)["fehler"]
assert auftrag.status(projekt)["laeuft"] is False
def test_zweiter_start_wird_abgelehnt(projekt, tmp_path):
verzeichnis.setze(projekt, str(tmp_path))
auftrag._laeufe[projekt] = {"laeuft": True, "fehler": "", "text": "",
"schritte": [], "aktivitaet": "", "uebernommen": False}
with pytest.raises(ValueError):
auftrag.starte(projekt, "X", [{"text": "S", "karte": "", "funktion": ""}])
# ------------------------------------------------------------- Verzeichnis
def test_verzeichnis_wird_gemerkt(projekt, tmp_path):
verzeichnis.setze(projekt, str(tmp_path))
assert verzeichnis.lies(projekt) == str(tmp_path)
verzeichnis.loesche(projekt)
assert verzeichnis.lies(projekt) == ""
def test_verzeichnis_lehnt_wurzel_und_relatives_ab(projekt):
with pytest.raises(ValueError):
verzeichnis.pruefe("/")
with pytest.raises(ValueError):
verzeichnis.pruefe("relativ/pfad")
with pytest.raises(FileNotFoundError):
verzeichnis.pruefe("/gibt/es/nicht")
def test_verzeichnis_liegt_im_stand_nicht_in_der_kopie(projekt, tmp_path):
planung.starte(projekt)
verzeichnis.setze(projekt, str(tmp_path))
planung.brich_ab(projekt)
assert verzeichnis.lies(projekt) == str(tmp_path)
# ------------------------------------------------------------- Durchblättern
def test_ordner_listet_nur_sichtbare_verzeichnisse(tmp_path):
(tmp_path / "b").mkdir()
(tmp_path / "A").mkdir()
(tmp_path / ".versteckt").mkdir()
(tmp_path / "datei.txt").write_text("x")
antwort = verzeichnis.ordner(str(tmp_path))
assert antwort["ordner"] == ["A", "b"]
assert antwort["pfad"] == str(tmp_path)
assert antwort["eltern"] == str(tmp_path.parent)
def test_ordner_ohne_pfad_beginnt_daheim():
from pathlib import Path
assert verzeichnis.ordner("")["pfad"] == str(Path.home())
def test_ordner_lehnt_datei_und_relatives_ab(tmp_path):
(tmp_path / "d.txt").write_text("x")
with pytest.raises(FileNotFoundError):
verzeichnis.ordner(str(tmp_path / "d.txt"))
with pytest.raises(ValueError):
verzeichnis.ordner("relativ")
def test_vision_gehoert_nie_zum_auftrag(projekt):
features.anlegen(projekt, "<!-- titel -->\nVision\n<!-- art -->\nvision\n<!-- ziel -->\nAlt\n")
planung.starte(projekt)
v = next(f for f in features.liste(projekt) if f["sektionen"]["art"] == "vision")
features.speichern(projekt, v["id"], v["roh"].replace("Alt", "Neu"))
assert auftrag.bau_auftrag(projekt) == ("", [])
features.anlegen(projekt, f"<!-- titel -->\nMerkliste\n{T}\n<!-- funktion -->\nProdukt merken\n")
text, schritte = auftrag.bau_auftrag(projekt)
assert [s["text"] for s in schritte] == ["Merkliste / Produkt merken: PRÜFEN"]
assert "Vision" not in text
# ------------------------------------------------- Stand: bestellt wird gebaut
def _stand(projekt, karte, funktion):
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == karte)
return next(x["stand"] for x in f["sektionen"]["funktionen"] if x["text"] == funktion)
def test_abgehakte_funktion_gilt_als_gebaut(projekt, tmp_path, monkeypatch):
"""Der Agent hat es gebaut — dann darf die Karte nicht „bestellt“ behaupten."""
verzeichnis.setze(projekt, str(tmp_path))
planung.starte(projekt)
features.anlegen(projekt, f"<!-- titel -->\nMerkliste\n{T}\n<!-- funktion -->\n"
f"Produkt merken\n<!-- stand -->\noffen\n{T}\n"
f"<!-- funktion -->\nProdukt entfernen\n<!-- stand -->\noffen\n")
anweisung, schritte = auftrag.bau_auftrag(projekt)
merken = next(s for s in schritte if s["funktion"] == "Produkt merken")
async def unecht(*a, **k):
return Strom([text_ereignis(f'ERLEDIGT: {merken["text"]}'),
{"type": "result", "result": "eins gebaut"}])
monkeypatch.setattr(asyncio, "create_subprocess_exec", unecht)
async def lauf():
auftrag.starte(projekt, anweisung, schritte)
await asyncio.gather(*auftrag._aufgaben)
asyncio.run(lauf())
# Nur der abgehakte Schritt wandert um; der andere bleibt bestellt
assert _stand(projekt, "Merkliste", "Produkt merken") == "gebaut"
assert _stand(projekt, "Merkliste", "Produkt entfernen") == "offen"
def test_gestrichene_funktion_wird_nicht_umgestellt(projekt, tmp_path):
"""Was weg ist, ist nicht gebaut — der Schritt trägt darum keinen Namen."""
planung.starte(projekt)
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Galerie")
features.speichern(projekt, f["id"], "<!-- titel -->\nGalerie\n")
_, schritte = auftrag.bau_auftrag(projekt)
weg = next(s for s in schritte if "Bild wählen" in s["text"])
assert weg["funktion"] == ""
def test_offen_vermerk_bleibt_bei_anderer_funktion(projekt):
roh = ("<!-- titel -->\nGalerie\n"
"<!-- funktion -->\nBild wählen\n<!-- stand -->\noffen\n"
"<!-- funktion -->\nBild schließen\n<!-- stand -->\noffen\n")
neu = auftrag._als_gebaut(roh, "Bild schließen")
assert neu.count("offen") == 1
assert "Bild wählen\n<!-- stand -->\noffen" in neu
def test_unbekannte_bloecke_bleiben_erhalten(projekt):
"""Textoperation statt Neuschreiben: der Rest der Datei bleibt Zeichen für Zeichen."""
roh = ("<!-- titel -->\nGalerie\n<!-- eigenes -->\nnicht anfassen\n"
"<!-- funktion -->\nBild wählen\n<!-- stand -->\noffen\n"
"<!-- regeln -->\n- Eine Regel\n")
neu = auftrag._als_gebaut(roh, "Bild wählen")
assert "<!-- eigenes -->\nnicht anfassen" in neu
assert "<!-- regeln -->\n- Eine Regel" in neu
assert "offen" not in neu
# ---------------------------------------- offen heißt: noch nicht bewiesen
def _auf_offen(projekt, karte, funktion):
"""Wie ein Klick auf den Stand: gebaut zurück auf offen."""
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == karte)
roh = f["roh"].replace(f"<!-- funktion -->\n{funktion}\n",
f"<!-- funktion -->\n{funktion}\n<!-- stand -->\noffen\n")
features.speichern(projekt, f["id"], roh)
def test_zurueckgesetzte_funktion_kommt_in_die_arbeitsliste(projekt):
"""Eine kaputte Umsetzung meldet man, indem man die Funktion auf offen setzt.
Die Karte ändert sich dadurch, aber sonst nichts — vorher blieb der Klick folgenlos.
"""
planung.starte(projekt)
_auf_offen(projekt, "Galerie", "Bild wählen")
text, schritte = auftrag.bau_auftrag(projekt)
assert [s["text"] for s in schritte] == ["Galerie / Bild wählen: PRÜFEN"]
# Der Sollzustand der Karte gehört dazu, sonst weiß der Agent nicht, wogegen er prüft
assert "<!-- funktion -->\nBild wählen" in text
assert auftrag.PRUEFEN in text
def test_offene_funktion_ohne_kartenaenderung(projekt, tmp_path):
"""Offen in der Kopie und im Stand: kein Diff, trotzdem Arbeit."""
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Shop")
features.speichern(projekt, f["id"], f["roh"].replace(
"<!-- funktion -->\nKarte kaufen\n",
"<!-- funktion -->\nKarte kaufen\n<!-- stand -->\noffen\n"))
planung.starte(projekt)
assert planung.aenderungen(projekt) == {}
_, schritte = auftrag.bau_auftrag(projekt)
assert [s["text"] for s in schritte] == ["Shop / Karte kaufen: PRÜFEN"]
def test_offen_schlaegt_geaendert(projekt):
"""Eine offene Funktion wird geprüft, nicht bloß angepasst."""
planung.starte(projekt)
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Galerie")
features.speichern(projekt, f["id"], f["roh"].replace(
"<!-- funktion -->\nBild wählen\n",
"<!-- funktion -->\nBild wählen\n<!-- stand -->\noffen\n"
"<!-- regeln -->\n- Eine neue Regel\n"))
_, schritte = auftrag.bau_auftrag(projekt)
assert [s["text"] for s in schritte] == ["Galerie / Bild wählen: PRÜFEN"]
def test_gebaut_und_geaendert_bleibt_geaendert(projekt):
planung.starte(projekt)
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Galerie")
features.speichern(projekt, f["id"], f["roh"].replace(
"<!-- funktion -->\nBild wählen\n",
"<!-- funktion -->\nBild wählen\n<!-- regeln -->\n- Eine neue Regel\n"))
_, schritte = auftrag.bau_auftrag(projekt)
assert [s["text"] for s in schritte] == ["Galerie / Bild wählen: GEÄNDERT"]
def test_gestrichene_karte_zieht_ihre_offenen_nicht_nach(projekt):
"""Eine gestrichene Karte darf nicht zusätzlich als „prüfen“ auftauchen."""
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Shop")
features.speichern(projekt, f["id"], f["roh"].replace(
"<!-- funktion -->\nKarte kaufen\n",
"<!-- funktion -->\nKarte kaufen\n<!-- stand -->\noffen\n"))
planung.starte(projekt)
g = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Shop")
features.loeschen(projekt, g["id"])
_, schritte = auftrag.bau_auftrag(projekt)
assert [s["text"] for s in schritte] == ["Karte „Shop“ streichen"]
def test_ohne_planung_kein_lauf(projekt):
"""Der Agent arbeitet auf der Planung; ohne sie gibt es keinen Auftrag."""
_auf_offen(projekt, "Galerie", "Bild wählen")
assert auftrag.bau_auftrag(projekt) == ("", [])
def test_auftrag_verlangt_offenlegung_des_ungeprueften(projekt):
planung.starte(projekt)
_auf_offen(projekt, "Galerie", "Bild wählen")
text, _ = auftrag.bau_auftrag(projekt)
assert "nicht prüfen konntest" in text
assert "Hake nur ab, was du geprüft hast" in text

111
backend/tests/test_chat.py Normal file
View File

@@ -0,0 +1,111 @@
"""Was das Planungsgespräch darf — und was es nicht sieht.
Geprüft wird nicht die Antwort des Modells, sondern womit es losgeschickt wird:
Werkzeugsatz, Arbeitsverzeichnis und der Absatz im Systemprompt gehören zusammen.
Fällt eines auseinander, behauptet das Modell nachgesehen zu haben, ohne zu können.
"""
import json
import pytest
import agents
import chat
import projekte
import verzeichnis
KARTE = ("<!-- titel -->\nUpload\n<!-- art -->\nkern\n"
"<!-- ziel -->\nDateien landen im Speicher.\n"
"<!-- funktion -->\nDatei wählen\n<!-- testen -->\n- Dialog öffnen\n")
@pytest.fixture
def projekt(datenordner):
projekte.anlegen("P")
(datenordner / "P" / "upload.md").write_text(KARTE)
return "P"
@pytest.fixture
def mitschrift(monkeypatch):
"""Fängt den Aufruf ab und gibt eine gültige Antwort zurück."""
gesehen = {}
async def antworte(system, inhalt, kommando=None, ordner=None):
gesehen.update(system=system, inhalt=inhalt, kommando=kommando, ordner=ordner)
return json.dumps({"antwort": "Verstanden.", "karten": []})
monkeypatch.setattr(agents, "frage_lokal", antworte)
return gesehen
async def test_ohne_verzeichnis_kein_lesen(projekt, mitschrift):
await chat.frage(projekt, "Was kann das Projekt?")
assert mitschrift["kommando"] == agents.PLANUNG_OHNE_CODE
assert mitschrift["ordner"] is None
assert chat.CODE_OHNE in mitschrift["system"]
async def test_mit_verzeichnis_darf_lesen(projekt, mitschrift, tmp_path):
code = tmp_path / "quelltext"
code.mkdir()
verzeichnis.setze(projekt, str(code))
await chat.frage(projekt, "Gibt es den Upload schon?")
assert mitschrift["kommando"] == agents.PLANUNG_KOMMANDO
assert mitschrift["ordner"] == str(code.resolve())
assert chat.CODE_MIT in mitschrift["system"]
async def test_toter_pfad_zaehlt_wie_keiner(projekt, mitschrift, tmp_path):
"""Sonst startet der Prozess nicht, und die Meldung zeigt auf die CLI."""
code = tmp_path / "weg"
code.mkdir()
verzeichnis.setze(projekt, str(code))
code.rmdir()
await chat.frage(projekt, "Und jetzt?")
assert mitschrift["kommando"] == agents.PLANUNG_OHNE_CODE
assert mitschrift["ordner"] is None
async def test_planung_sucht_nicht_im_web(projekt, mitschrift, tmp_path):
"""Fremdes Wissen kommt aus der Recherche, nicht nebenbei in einen Entwurf."""
code = tmp_path / "quelltext"
code.mkdir()
verzeichnis.setze(projekt, str(code))
await chat.frage(projekt, "Wie macht man das üblicherweise?")
for satz in (agents.PLANUNG_KOMMANDO, agents.PLANUNG_OHNE_CODE):
assert "WebSearch" not in _erlaubt(satz)
assert "WebFetch" not in _erlaubt(satz)
def test_recherche_sieht_keinen_quelltext():
"""Umgekehrt: aus dem Projekt darf nichts in eine Suchanfrage geraten."""
erlaubt = _erlaubt(agents.RECHERCHE_KOMMANDO)
assert "WebSearch" in erlaubt and "WebFetch" in erlaubt
for werkzeug in ("Read", "Glob", "Grep"):
assert werkzeug not in erlaubt
@pytest.mark.parametrize("kommando", [
agents.PLANUNG_KOMMANDO, agents.PLANUNG_OHNE_CODE, agents.RECHERCHE_KOMMANDO])
def test_kein_satz_kann_etwas_anstossen(kommando):
"""Fremder Text im Kontext darf nichts auslösen, egal woher er kommt."""
gesperrt = _gesperrt(kommando)
for werkzeug in ("Write", "Edit", "Bash", "CronCreate", "PushNotification",
"SendMessage", "Workflow", "Task"):
assert werkzeug in gesperrt
assert "--safe-mode" in kommando
def _abschnitt(kommando: tuple, flagge: str) -> list:
rest = list(kommando[kommando.index(flagge) + 1:])
ende = next((i for i, w in enumerate(rest) if w.startswith("--")), len(rest))
return rest[:ende]
def _erlaubt(kommando: tuple) -> list:
return _abschnitt(kommando, "--allowed-tools")
def _gesperrt(kommando: tuple) -> list:
return _abschnitt(kommando, "--disallowed-tools")

View File

@@ -22,13 +22,16 @@ def projekt(datenordner):
def karte(projekt, titel, *funktionen):
"""funktionen sind (name, regel) oder (name, regel, [testschritte])."""
"""funktionen sind (name, regel) oder (name, regel, [testschritte]).
Ohne eigene Schritte bekommt die Funktion einen Platzhalter-Ablauf: Testschritte
sind Pflicht, und ohne sie bricht die Messung vor allem anderen ab.
"""
text = f"<!-- titel -->\n{titel}\n"
for eintrag in funktionen:
name, regel, schritte = (*eintrag, [])[:3]
name, regel, schritte = (*eintrag, ["Karte öffnen", "Karte offen"])[:3]
text += f"<!-- funktion -->\n{name}\n<!-- regeln -->\n- {regel}\n"
if schritte:
text += "<!-- testen -->\n" + "".join(f"- {s}\n" for s in schritte)
text += "<!-- testen -->\n" + "".join(f"- {s}\n" for s in schritte)
return features.anlegen(projekt, text)
@@ -116,14 +119,14 @@ async def test_stilurteil_wird_gemerkt_und_nicht_neu_gefragt(projekt, monkeypatc
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
for a in liste:
assert await arbeite(a, None) == []
assert await arbeite(a) == []
abschluss()
erste = modell.rufe
assert erste >= len(pruefung.TEMPERATUREN)
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
for a in liste:
assert await arbeite(a, None) == []
assert await arbeite(a) == []
assert modell.rufe == erste # keine einzige neue Anfrage
@@ -136,7 +139,7 @@ async def test_geaenderter_text_wird_neu_gefragt(projekt, monkeypatch):
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
for a in liste:
await arbeite(a, None)
await arbeite(a)
abschluss()
vorher = modell.rufe
@@ -144,7 +147,7 @@ async def test_geaenderter_text_wird_neu_gefragt(projekt, monkeypatch):
datei.write_text(datei.read_text().replace("- Knopf", "- Ganz anderer Text"))
liste, arbeite, _ = await pruefung._stil_aufgaben(projekt)
for a in liste:
await arbeite(a, None)
await arbeite(a)
assert modell.rufe > vorher
@@ -159,7 +162,7 @@ async def test_gemessener_verstoss_kommt_nie_aus_dem_gedaechtnis(projekt, monkey
for lauf in range(2):
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
gefunden = [p for a in liste for p in await arbeite(a, None)]
gefunden = [p for a in liste for p in await arbeite(a)]
abschluss()
assert [p["vorschlag"] for p in gefunden] == ["Passwort prüfen"], lauf
assert modell.rufe > len(pruefung.TEMPERATUREN) # zweimal wirklich gefragt

View File

@@ -199,3 +199,33 @@ def test_gespraech_und_gedaechtnis_wandern_mit(projekt):
def test_die_kopie_kopiert_sich_nicht_selbst(projekt):
planung.starte(projekt)
assert not (projekte.wurzel(projekt) / projekte.PLANUNG).exists()
def test_arbeitsverzeichnis_ueberlebt_das_umsetzen(projekt, tmp_path):
"""Erst nach dem Planungsstart festgelegt — dann fehlt es in der Kopie.
Vorher räumte das Umsetzen es mit dem alten Stand weg, und der nächste Lauf
fand kein Verzeichnis mehr.
"""
import verzeichnis
planung.starte(projekt)
verzeichnis.setze(projekt, str(tmp_path))
assert not (planung._kopie(projekt) / verzeichnis.DATEI).exists()
planung.setze_um(projekt)
assert verzeichnis.lies(projekt) == str(tmp_path)
def test_geplantes_verzeichnis_gewinnt(projekt, tmp_path):
"""Steht es in beiden, gilt die Kopie — sie ist der neuere Stand."""
import verzeichnis
alt = tmp_path / "alt"
alt.mkdir()
verzeichnis.setze(projekt, str(alt))
planung.starte(projekt)
neu = tmp_path / "neu"
neu.mkdir()
(planung._kopie(projekt) / verzeichnis.DATEI).write_text(str(neu))
planung.setze_um(projekt)
assert verzeichnis.lies(projekt) == str(neu)

View File

@@ -0,0 +1,193 @@
import pytest
import agents
import projekte
import recherche
@pytest.fixture
def projekt(datenordner):
projekte.anlegen("P")
return "P"
def test_leeres_projekt_hat_keine_faeden(projekt):
assert recherche.liste(projekt) == []
def test_anlegen_gibt_faden_mit_id(projekt):
faden = recherche.anlegen(projekt)
assert faden["id"] and faden["verlauf"] == []
assert [f["id"] for f in recherche.liste(projekt)] == [faden["id"]]
def test_faeden_haben_eigene_ids(projekt):
a, b = recherche.anlegen(projekt), recherche.anlegen(projekt)
assert a["id"] != b["id"]
assert len(recherche.liste(projekt)) == 2
def test_loeschen_trifft_nur_den_einen(projekt):
a, b = recherche.anlegen(projekt), recherche.anlegen(projekt)
recherche.loeschen(projekt, a["id"])
assert [f["id"] for f in recherche.liste(projekt)] == [b["id"]]
def test_loeschen_ohne_faden_ist_404(projekt):
with pytest.raises(FileNotFoundError):
recherche.loeschen(projekt, "gibtsnicht")
def test_faeden_liegen_im_projektstand_nicht_in_der_planung(projekt):
"""Nachgeschlagenes überlebt einen Planungsabbruch — es gehört zum Projekt."""
recherche.anlegen(projekt)
(projekte.stand(projekt) / projekte.PLANUNG).mkdir()
assert len(recherche.liste(projekt)) == 1
assert (projekte.stand(projekt) / recherche.DATEI).is_file()
def _antwort(satz="Laut Quelle X: ja."):
"""Eine Modellantwort — Markdown, nicht JSON."""
async def antworte(system, inhalt, kommando=None, ordner=None):
return satz
return antworte
async def test_frage_haengt_beide_zeilen_an(projekt, monkeypatch):
faden = recherche.anlegen(projekt)
monkeypatch.setattr(agents, "frage_lokal", _antwort(" Laut Quelle X: ja. "))
erg = await recherche.frage(projekt, faden["id"], "Wie geht das?")
assert [(e["rolle"], e["text"]) for e in erg["verlauf"]] == [
("person", "Wie geht das?"), ("ki", "Laut Quelle X: ja.")]
async def test_erste_frage_benennt_den_faden(projekt, monkeypatch):
faden = recherche.anlegen(projekt)
monkeypatch.setattr(agents, "frage_lokal", _antwort("ok"))
erg = await recherche.frage(projekt, faden["id"], "Wie lädt man\nzu S3 hoch?")
assert erg["titel"] == "Wie lädt man zu S3 hoch?"
# Die zweite Frage lässt den Namen stehen: der Reiter soll nicht wandern
erg = await recherche.frage(projekt, faden["id"], "Und die Grenzen?")
assert erg["titel"] == "Wie lädt man zu S3 hoch?"
async def test_langer_titel_wird_gekuerzt(projekt, monkeypatch):
faden = recherche.anlegen(projekt)
monkeypatch.setattr(agents, "frage_lokal", _antwort("ok"))
erg = await recherche.frage(projekt, faden["id"], "w" * 80)
assert len(erg["titel"]) == recherche.TITELLAENGE + 1
assert erg["titel"].endswith("…")
async def test_recherche_bekommt_die_websuche(projekt, monkeypatch):
"""Der Werkzeugsatz ist der ganze Unterschied zum Planungsgespräch."""
faden = recherche.anlegen(projekt)
gesehen = {}
async def antworte(system, inhalt, kommando=None, ordner=None):
gesehen.update(kommando=kommando, inhalt=inhalt)
return "ok"
monkeypatch.setattr(agents, "frage_lokal", antworte)
await recherche.frage(projekt, faden["id"], "Was gilt draußen?")
assert gesehen["kommando"] == agents.RECHERCHE_KOMMANDO
async def test_die_karten_liegen_im_prompt(projekt, monkeypatch):
"""Ohne Bestand ist „was fehlt noch" nicht zu beantworten — dann rät das Modell."""
(projekte.stand(projekt) / "gold.md").write_text(
"<!-- titel -->\nGold\n<!-- art -->\nkern\n"
"<!-- ziel -->\nSpieler verdient Gold pro Runde.\n"
"<!-- funktion -->\nZinsen erhalten\n<!-- stand -->\noffen\n"
"<!-- testen -->\n- Runde spielen\n")
faden = recherche.anlegen(projekt)
gesehen = {}
async def antworte(system, inhalt, kommando=None, ordner=None):
gesehen["system"] = system
return "ok"
monkeypatch.setattr(agents, "frage_lokal", antworte)
await recherche.frage(projekt, faden["id"], "Was fehlt noch?")
assert "Zinsen erhalten" in gesehen["system"]
# Der Stand ist der Punkt: er unterscheidet Gebautes von Bestelltem
assert '"stand": "offen"' in gesehen["system"]
# Testabläufe blähen den Prompt und tragen keine Recherche
assert "Runde spielen" not in gesehen["system"]
async def test_vision_kommt_mit(projekt, monkeypatch):
(projekte.stand(projekt) / "vision.md").write_text(
"<!-- titel -->\nVision\n<!-- art -->\nvision\n"
"<!-- ziel -->\nTFT im Browser nachbauen.\n")
faden = recherche.anlegen(projekt)
gesehen = {}
async def antworte(system, inhalt, kommando=None, ordner=None):
gesehen["system"] = system
return "ok"
monkeypatch.setattr(agents, "frage_lokal", antworte)
await recherche.frage(projekt, faden["id"], "Und nun?")
assert "TFT im Browser nachbauen." in gesehen["system"]
async def test_markdown_bleibt_wie_es_kam(projekt, monkeypatch):
"""Die Antwort wird als Markdown angezeigt — der Server fasst sie nicht an."""
roh = "Kurz gesagt.\n\n## Festgelegt\n- **5 Gold** pro Runde\n\n## Quellen\n- [Wiki](http://x)"
faden = recherche.anlegen(projekt)
monkeypatch.setattr(agents, "frage_lokal", _antwort(f"\n{roh}\n"))
erg = await recherche.frage(projekt, faden["id"], "Wie geht Gold?")
assert erg["verlauf"][-1]["text"] == roh
async def test_leere_frage_wird_abgelehnt(projekt):
faden = recherche.anlegen(projekt)
with pytest.raises(ValueError):
await recherche.frage(projekt, faden["id"], " ")
async def test_geschlossener_faden_verwirft_die_antwort(projekt, monkeypatch):
"""Antworten dauern Minuten. Wer inzwischen schließt, will das Ergebnis nicht."""
faden = recherche.anlegen(projekt)
async def antworte(system, inhalt, kommando=None, ordner=None):
recherche.loeschen(projekt, faden["id"])
return "zu spät"
monkeypatch.setattr(agents, "frage_lokal", antworte)
with pytest.raises(FileNotFoundError):
await recherche.frage(projekt, faden["id"], "Was gilt?")
assert recherche.liste(projekt) == []
async def test_paralleler_faden_bleibt_erhalten(projekt, monkeypatch):
"""Der zweite Faden entsteht, während der erste noch wartet."""
a = recherche.anlegen(projekt)
neu = {}
async def antworte(system, inhalt, kommando=None, ordner=None):
neu["b"] = recherche.anlegen(projekt)
return "fertig"
monkeypatch.setattr(agents, "frage_lokal", antworte)
await recherche.frage(projekt, a["id"], "Frage")
faeden = recherche.liste(projekt)
assert [f["id"] for f in faeden] == [a["id"], neu["b"]["id"]]
# Was der eine Faden erfährt, bleibt beim einen
assert len(faeden[0]["verlauf"]) == 2 and faeden[1]["verlauf"] == []
def test_kaputte_datei_gilt_als_leer(projekt):
(projekte.stand(projekt) / recherche.DATEI).write_text("{kein json")
assert recherche.liste(projekt) == []
async def test_tiefe_haelt_den_faden_kurz(projekt, monkeypatch):
faden = recherche.anlegen(projekt)
monkeypatch.setattr(agents, "frage_lokal", _antwort("ok"))
for i in range(recherche.TIEFE):
await recherche.frage(projekt, faden["id"], f"Frage {i}")
verlauf = recherche.liste(projekt)[0]["verlauf"]
assert len(verlauf) == recherche.TIEFE
assert verlauf[-2]["text"] == f"Frage {recherche.TIEFE - 1}"

View File

@@ -72,3 +72,41 @@ async def test_pruefung_ohne_konfiguration(client, monkeypatch):
async def test_unbekanntes_projekt(client):
assert (await client.get("/api/projekte/Fremd/features")).status_code == 404
assert (await client.post("/api/projekte/Fremd/features", json={"roh": ROH})).status_code == 404
assert (await client.get("/api/projekte/Fremd/recherche")).status_code == 404
async def test_recherche_faeden(client, monkeypatch):
import agents
await client.post("/api/projekte", json={"name": "Alpha"})
assert (await client.get("/api/projekte/Alpha/recherche")).json()["faeden"] == []
a = (await client.post("/api/projekte/Alpha/recherche")).json()
b = (await client.post("/api/projekte/Alpha/recherche")).json()
assert a["id"] != b["id"]
async def antworte(system, inhalt, kommando=None, ordner=None):
return "## Festgelegt\n- **5 GB** je Sendung"
monkeypatch.setattr(agents, "frage_lokal", antworte)
res = await client.post(f"/api/projekte/Alpha/recherche/{a['id']}",
json={"text": "Was gilt?"})
assert res.json()["titel"] == "Was gilt?"
assert len(res.json()["verlauf"]) == 2
assert res.json()["verlauf"][-1]["text"] == "## Festgelegt\n- **5 GB** je Sendung"
# Der zweite Faden bleibt leer — Fäden teilen sich nichts
faeden = (await client.get("/api/projekte/Alpha/recherche")).json()["faeden"]
assert [len(f["verlauf"]) for f in faeden] == [2, 0]
assert (await client.delete(f"/api/projekte/Alpha/recherche/{b['id']}")).status_code == 200
assert len((await client.get("/api/projekte/Alpha/recherche")).json()["faeden"]) == 1
assert (await client.delete(f"/api/projekte/Alpha/recherche/{b['id']}")).status_code == 404
async def test_recherche_leere_frage(client):
await client.post("/api/projekte", json={"name": "Alpha"})
faden = (await client.post("/api/projekte/Alpha/recherche")).json()
res = await client.post(f"/api/projekte/Alpha/recherche/{faden['id']}",
json={"text": " "})
assert res.status_code == 400

View File

@@ -103,7 +103,8 @@ async def test_messung_ist_auftrag_und_tuersteher(datenordner, monkeypatch):
f = features.anlegen(
"Test",
"<!-- titel -->\nLogin\n<!-- funktion -->\nAnmelden\n"
"<!-- regeln -->\n- Auch Passwort prüfen.\n",
"<!-- regeln -->\n- Auch Passwort prüfen.\n"
"<!-- testen -->\n- Anmelden\n- Angemeldet\n",
)
gesehen = []
# Jede Stimme liefert erst einen halbherzigen Vorschlag: Punkt weg, Füllwort bleibt
@@ -141,7 +142,8 @@ async def test_gemessener_verstoss_wird_notfalls_selbst_behoben(datenordner, mon
f = features.anlegen(
"Test",
"<!-- titel -->\nLogin\n<!-- funktion -->\nAnmelden\n"
"<!-- regeln -->\n- Auch Passwort prüfen.\n",
"<!-- regeln -->\n- Auch Passwort prüfen.\n"
"<!-- testen -->\n- Anmelden\n- Angemeldet\n",
)
async def sturr(system, inhalt, temperatur=0.0):
@@ -226,18 +228,24 @@ def test_zeilenfehler_haben_vorrang():
== ["Satzzeichen am Zeilenende, Füllwort „auch“"]
def test_funktion_ohne_jedes_feld_ist_leer():
"""Ein bloßer Titel beschreibt nichts — der Prüfer stülpt ihn über alles."""
def test_funktion_ohne_testschritte():
"""Ohne Ablauf ist der Titel eine Behauptung — der Prüfer stülpt ihn über alles."""
b = struktur.befunde("funktion", {"titel": "Modal steuern", "ziel": "",
"regeln": "", "testen": ""})
assert _probleme(b) == ["Funktion ohne Ziel, Regeln und Testen"]
assert _probleme(b) == ["Funktion ohne Testschritte"]
def test_ein_einziges_feld_genuegt():
for feld in ("ziel", "regeln", "testen"):
def test_ziel_und_regeln_ersetzen_die_testschritte_nicht():
for feld in ("ziel", "regeln"):
felder = {"titel": "Modal steuern", "ziel": "", "regeln": "", "testen": ""}
felder[feld] = "Etwas tun" if feld == "ziel" else "- Etwas tun"
assert struktur.befunde("funktion", felder) == [], feld
assert _probleme(struktur.befunde("funktion", felder)) \
== ["Funktion ohne Testschritte"], feld
def test_testschritte_genuegen():
assert struktur.befunde("funktion", {"titel": "Modal steuern", "ziel": "",
"regeln": "", "testen": "- Etwas tun"}) == []
def test_einzelnes_feld_loest_nichts_aus():
@@ -259,3 +267,59 @@ def test_platzhalter_ist_kein_inhalt():
def test_kleiner_als_ist_kein_platzhalter():
assert not struktur._umschreiben("funktion", "regeln", "Wert 3 < 5")
def test_kommando_als_testschritt():
"""Ein Setup wird über die Kommandozeile bedient — das muss zulässig sein."""
assert _pruefe("funktion", "testen", '- Befehl "make install" ausführen') == []
assert _pruefe("funktion", "titel", "Anwendung installieren") == []
def test_zitat_zaehlt_als_ein_wort():
"""Sonst sprengt jedes Kommando die Wortgrenze."""
lang = '- Befehl "uv run python -m tft.cli refresh" ausführen'
assert _pruefe("funktion", "testen", lang) == []
# Ohne Zitat greift die Grenze weiter
assert _probleme(_pruefe("funktion", "testen",
"- Ein sehr langer Schritt mit deutlich zu vielen Wörtern")) \
== ["Mehr als 8 Wörter"]
def test_schraegstrich_im_zitat_ist_erlaubt():
assert _pruefe("funktion", "testen", '- Befehl "npm run build/dev" ausführen') == []
assert _probleme(_pruefe("funktion", "regeln", "- Gilt für Bank und/oder Brett")) \
== ["Klammer oder Schrägstrich"]
def test_fuellwort_im_zitat_bleibt_stehen():
"""Der Menüeintrag heißt so — gestrichen zeigte der Schritt auf nichts."""
assert _pruefe("funktion", "testen", '- Klick auf "Auch Vorlagen zeigen"') == []
def test_kommandoschritt_ist_eine_handlung():
"""Nach dem Ergebnis darf keine Handlung mehr stehen — auch keine mit Kommando."""
zwei = ('- Befehl "make install" ausführen\n- Anwendung installiert\n'
'- Befehl "make test" ausführen')
assert _probleme(_pruefe("funktion", "testen", zwei)) == \
["Zweiter Ablauf in einer Funktion"]
def test_ausloeser_ist_kein_funktionstitel():
"""Der Titel nennt die Wirkung; der Griff steht in den Testschritten."""
for titel in ('Befehl "make install" ausführen', "LMB auf Speichern drücken",
"Klick auf Anlegen ausführen"):
assert "Auslöser statt Wirkung im Titel" in _probleme(
_pruefe("funktion", "titel", titel)), titel
assert _pruefe("funktion", "titel", "Anwendung installieren") == []
def test_vorbedingung_ist_kein_zweiter_ablauf():
"""Der Katalog erlaubt eine Vorbedingung zuerst — sie ist kein Endzustand."""
text = "- Badge ist sichtbar\n- RMB auf Badge\n- Text ändern\n- Neuer Text sichtbar"
assert _pruefe("funktion", "testen", text) == []
def test_zweiter_ablauf_faellt_weiter_auf():
text = "- Karte öffnen\n- Karte sichtbar\n- Karte schließen"
assert _probleme(_pruefe("funktion", "testen", text)) \
== ["Zweiter Ablauf in einer Funktion"]

75
backend/verzeichnis.py Normal file
View File

@@ -0,0 +1,75 @@
"""Das Arbeitsverzeichnis eines Projekts — dort implementiert der Agent.
Der Pfad liegt als eine Zeile im Projektordner und wandert beim Umbenennen mit.
Er ist eine Eigenschaft des Projekts, nicht der Planung: gelesen und geschrieben
wird immer im Projektstand, damit ein Planungsabbruch ihn nicht mitnimmt.
"""
import os
from pathlib import Path
import projekte
DATEI = ".verzeichnis"
def _pfad(projekt: str):
return projekte.stand(projekt) / DATEI
def lies(projekt: str) -> str:
"""Der eingestellte Pfad, oder leer."""
try:
return _pfad(projekt).read_text().strip()
except OSError:
return ""
def pruefe(pfad: str) -> Path:
"""Taugt der Pfad als Arbeitsverzeichnis? Gibt ihn aufgelöst zurück oder wirft."""
pfad = (pfad or "").strip()
if not pfad:
raise ValueError("Kein Pfad angegeben")
ziel = Path(os.path.expanduser(pfad))
if not ziel.is_absolute():
raise ValueError("Der Pfad muss absolut sein")
ziel = ziel.resolve()
if not ziel.is_dir():
raise FileNotFoundError(f"„{ziel}“ ist kein Verzeichnis")
if ziel == Path(ziel.anchor) or ziel == Path.home():
# Die Wurzel oder das ganze Heimatverzeichnis ist immer ein Fehlgriff
raise ValueError("Bitte ein Projektverzeichnis wählen, nicht die Wurzel")
return ziel
def setze(projekt: str, pfad: str) -> str:
ziel = pruefe(pfad)
_pfad(projekt).write_text(f"{ziel}\n")
return str(ziel)
def loesche(projekt: str):
_pfad(projekt).unlink(missing_ok=True)
def ordner(pfad: str = "") -> dict:
"""Ein Verzeichnis zum Durchblättern: Unterordner, ohne Verstecktes.
Kein Pfad heißt Heimatverzeichnis — dort beginnt die Suche. Die Auswahl
trifft der Mensch; hier wird nur gelistet, nie geschrieben.
"""
ziel = Path(os.path.expanduser(pfad.strip())) if pfad.strip() else Path.home()
if not ziel.is_absolute():
raise ValueError("Der Pfad muss absolut sein")
ziel = ziel.resolve()
if not ziel.is_dir():
raise FileNotFoundError(f"„{ziel}“ ist kein Verzeichnis")
try:
unter = sorted(
(e.name for e in ziel.iterdir()
if e.is_dir() and not e.name.startswith(".")),
key=str.lower)
except PermissionError:
raise ValueError(f"„{ziel}“ ist nicht lesbar")
eltern = str(ziel.parent) if ziel.parent != ziel else ""
return {"pfad": str(ziel), "eltern": eltern, "ordner": unter}