update
@@ -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
@@ -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
|
||||
101
backend/chat.py
@@ -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
|
After Width: | Height: | Size: 29 KiB |
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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":
|
||||
|
||||
418
backend/tests/test_auftrag.py
Normal 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
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
193
backend/tests/test_recherche.py
Normal 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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||