293 lines
12 KiB
Python
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)}
|