Files
planer2/backend/chat.py
2026-07-29 15:19:41 +02:00

293 lines
12 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.
"""
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'<!-- 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.
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)}