update
This commit is contained in:
292
backend/chat.py
Normal file
292
backend/chat.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""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)}
|
||||
Reference in New Issue
Block a user