"""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'\n{str(karte.get("titel") or "").strip()}', f'\n{art if art in parser.ARTEN else "kern"}'] if str(karte.get("ziel") or "").strip(): teile.append(f'\n{str(karte["ziel"]).strip()}') if _zeilen(karte.get("regeln")): teile.append(f'\n{_zeilen(karte.get("regeln"))}') for f in karte.get("funktionen") or []: block = [f'\n{str(f.get("titel") or "").strip()}'] if f.get("stand") == parser.OFFEN: block.append(f'\n{parser.OFFEN}') if str(f.get("ziel") or "").strip(): block.append(f'\n{str(f["ziel"]).strip()}') for feld in ("regeln", "testen"): if _zeilen(f.get(feld)): block.append(f'\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)}