Files
planer2/backend/chat.py
2026-09-07 07:44:00 +02:00

364 lines
16 KiB
Python

"""Karten im Gespräch entwickeln.
Man schreibt, was man vorhat. Die KI antwortet und legt daneben, wie die Karten
danach aussähen. Übernommen wird einzeln — dasselbe ✓ und ✕ wie bei der Prüfung.
Warum kein eigener Anzeigeweg: Ein Vorschlag ist ein Vorschlag, egal ob ihn die
Prüfung oder das Gespräch gemacht hat. Der Chat liefert deshalb genau die Form, die
`pruefung._stellen` erzeugt — Karten zeigen ihn an derselben Stelle an, der Vergleich
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
import agents
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. 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. 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}
Der aktuelle Stand:
{karten}
Antworte ausschließlich mit JSON:
{{"antwort": "...", "karten": [
{{"titel": "...", "art": "kern", "ziel": "...", "regeln": ["..."],
"funktionen": [{{"titel": "...", "ziel": "...", "regeln": ["..."],
"testen": ["..."]}}]}}
]}}
- antwort: was du der Person sagst, auf Deutsch, höchstens vier Sätze
- karten: nur die Karten, die sich ändern oder dazukommen sollen — jeweils
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 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.
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
# war verloren.
NACHRICHT = """{verlauf}
Neue Nachricht der Person:
{text}
Antworte ausschließlich mit dem JSON-Objekt, ohne Text davor oder danach."""
NACHBESSERN = """Deine Antwort war kein JSON.
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
def verlauf(projekt: str) -> list:
try:
eintraege = json.loads(_pfad(projekt).read_text())
except (OSError, ValueError):
return []
return eintraege if isinstance(eintraege, list) else []
def _sichere(projekt: str, eintraege: list):
ziel = _pfad(projekt)
neben = ziel.with_suffix(".json.neu")
neben.write_text(json.dumps(eintraege[-TIEFE:], indent=1, ensure_ascii=False) + "\n")
neben.replace(ziel)
def leeren(projekt: str):
_pfad(projekt).unlink(missing_ok=True)
def _punkte(text: str) -> list:
return [z.strip("- ").strip() for z in (text or "").splitlines() if z.strip()]
def _als_spec(f: dict) -> dict:
"""Eine Karte in genau der Form, in der das Modell antworten soll."""
s = f["sektionen"]
return {"titel": s["titel"], "art": s["art"], "ziel": s["ziel"],
"regeln": _punkte(s["regeln"]),
"funktionen": [{"titel": x["text"], "ziel": x["ziel"],
"regeln": _punkte(x["regeln"]),
"testen": _punkte(x["testen"])}
for x in s["funktionen"]]}
def _kartenstand(projekt: str) -> str:
"""Der Stand als JSON — dieselbe Form wie die Antwort.
Vorher stand er als Fließtext da. Das Modell musste ihn beim Zurückschreiben
übersetzen und schob dabei Regeln zwischen Karten- und Funktionsebene hin und
her: aus „eine Funktion ergänzen" wurden vier Änderungen. Gleiche Form heißt,
dass Abschreiben nichts kostet.
"""
vision, karten = "", []
for f in features.liste(projekt):
if f["sektionen"]["art"] == parser.VISION:
vision = f["sektionen"]["ziel"]
else:
karten.append(_als_spec(f))
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 _zeilen(wert) -> str:
"""regeln und testen kommen als Liste oder als Block — hier wird eins daraus."""
if isinstance(wert, list):
eintraege = [str(z).strip("- ").strip() for z in wert]
else:
eintraege = [z.strip("- ").strip() for z in str(wert or "").splitlines()]
return "\n".join(f"- {z}" for z in eintraege if z)
TRENNER = "-" * 40
def roh(karte: dict) -> str:
"""Aus dem Sollzustand die Markdown-Datei — dieselbe Form wie im Frontend."""
art = str(karte.get("art") or "kern").strip().lower()
teile = [f'<!-- titel -->\n{str(karte.get("titel") or "").strip()}',
f'<!-- art -->\n{art if art in parser.ARTEN else "kern"}']
if str(karte.get("ziel") or "").strip():
teile.append(f'<!-- ziel -->\n{str(karte["ziel"]).strip()}')
if _zeilen(karte.get("regeln")):
teile.append(f'<!-- regeln -->\n{_zeilen(karte.get("regeln"))}')
for f in karte.get("funktionen") or []:
block = [f'<!-- funktion -->\n{str(f.get("titel") or "").strip()}']
if f.get("stand") == parser.OFFEN:
block.append(f'<!-- stand -->\n{parser.OFFEN}')
if str(f.get("ziel") or "").strip():
block.append(f'<!-- ziel -->\n{str(f["ziel"]).strip()}')
for feld in ("regeln", "testen"):
if _zeilen(f.get(feld)):
block.append(f'<!-- {feld} -->\n{_zeilen(f.get(feld))}')
teile.append("\n".join(block))
return f"\n{TRENNER}\n".join(teile) + "\n"
def _felder(sektionen: dict) -> dict:
return {"titel": sektionen["titel"], "badge": sektionen["art"],
"ziel": sektionen["ziel"], "regeln": sektionen["regeln"],
"funktionen": "\n".join(f["text"] for f in sektionen["funktionen"])}
def _vergleiche(alt: dict, neu: dict, feature_id: str, grund: str) -> list:
"""Was sich an einer bestehenden Karte ändert, Feld für Feld.
Auf Funktionsebene wird nur die Liste der Titel verglichen. Wer den Inhalt einer
Funktion ändern will, bekommt dafür eigene Stellen — sonst wäre ein Vorschlag ein
Klotz, den man nur ganz oder gar nicht nehmen kann.
"""
raus = []
for feld in ("titel", "badge", "ziel", "regeln", "funktionen"):
for p in pruefung._stellen(feld, alt.get(feld, ""), neu.get(feld, ""),
grund, "Aus dem Gespräch"):
p["feature"] = feature_id
p["funktion"] = ""
# Was das Gespräch an Funktionen erfindet, ist bestellt, nicht gebaut.
# Dieselbe Stelle aus Prüfung 3 hätte belegten Code — dort wäre „offen" falsch.
if feld == "funktionen":
p["neu_offen"] = True
raus.append(p)
return raus
def _funktionsstellen(alt: dict, neu: dict, feature_id: str, grund: str) -> list:
"""Änderungen innerhalb einer Funktion, je Feld eine eigene Stelle."""
vorher = {f["text"]: f for f in alt["funktionen"]}
raus = []
for f in neu["funktionen"]:
bestand = vorher.get(f["text"])
if not bestand:
continue # neue Funktion: steckt schon im Listenvergleich
for feld in ("ziel", "regeln", "testen"):
for p in pruefung._stellen(feld, bestand[feld], f[feld], grund,
"Aus dem Gespräch"):
p["feature"] = feature_id
p["funktion"] = f["text"]
raus.append(p)
return raus
def vorschlaege(projekt: str, karten: list) -> list:
"""Aus den Sollzuständen die einzelnen Stellen — bestehende Karten wie neue."""
nach_titel = {f["sektionen"]["titel"].lower(): f for f in features.liste(projekt)}
raus = []
for karte in karten:
if not isinstance(karte, dict) or not str(karte.get("titel") or "").strip():
continue
titel = str(karte["titel"]).strip()
# Den Stand entscheidet der Code, nicht das Modell: was schon gebaut war,
# bleibt gebaut; was das Gespräch neu erfindet, ist bestellt und noch offen.
# Das Modell sieht das Feld gar nicht — ein Feld weniger, das es vergessen kann.
bestand = nach_titel.get(titel.lower())
gebaut = {x["text"] for x in bestand["sektionen"]["funktionen"]
if x["stand"] == parser.GEBAUT} if bestand else set()
for f in karte.get("funktionen") or []:
f["stand"] = parser.GEBAUT if str(f.get("titel") or "").strip() in gebaut \
else parser.OFFEN
text = roh(karte)
neu = parser.zerlege(text)
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, "sektionen": neu, "ganzes_feld": True,
"feature": "", "funktion": "", "regel": "Aus dem Gespräch",
"problem": "Neue Karte"})
continue
grund = f"Aus dem Gespräch: {titel}"
raus += _vergleiche(_felder(bestand["sektionen"]), _felder(neu),
bestand["id"], grund)
raus += _funktionsstellen(bestand["sektionen"], neu, bestand["id"], grund)
return raus
async def frage(projekt: str, text: str) -> dict:
"""Eine Runde: Frage rein, Antwort und Vorschläge raus."""
text = (text or "").strip()
if not text:
raise ValueError("Nachricht ist leer")
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. Danach fliegt der
# Fehler nach oben und ist sichtbar.
daten = None
for versuch in range(3):
antwort = await agents.frage_lokal(system, nachricht, kommando, ordner)
try:
daten = pruefung._json_lesen(antwort)
break
except ValueError:
if versuch == 2:
raise ValueError("Antwort war nicht lesbar — bitte noch einmal senden")
# Nachbessern statt blind wiederholen: das Modell sieht seinen eigenen
# Fehlversuch und weiß, was daran fehlte.
nachricht = (f"{nachricht}\n\nDein letzter Versuch war:\n"
f"{antwort[:2000]}\n\n{NACHBESSERN}")
satz = str(daten.get("antwort") or "").strip() or "Verstanden."
probleme = vorschlaege(projekt, daten.get("karten") or [])
bisher += [{"rolle": "person", "text": text}, {"rolle": "ki", "text": satz}]
_sichere(projekt, bisher)
return {"antwort": satz, "probleme": probleme, "verlauf": verlauf(projekt)}