"""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. """ import json import agents import features import parser import projekte import pruefung 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 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. {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 keinen Sinn hätte, sonst „optional" - ziel, regeln und testen dürfen leer sein oder fehlen 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.""" # 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 _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. raus.append({"feld": "neue_karte", "original": "", "vorschlag": titel, "roh": text, "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") 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." # 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( stil="\n\n".join(pruefung._stil_lesen(b) for b in pruefung.BEREICHE), karten=_kartenstand(projekt)) nachricht = NACHRICHT.format(verlauf=gespraech, text=text) # Ein Ausrutscher beim JSON kostet sonst die ganze Wortmeldung. Zweiter Versuch # mit etwas mehr Streuung; 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) 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)}