364 lines
16 KiB
Python
364 lines
16 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.
|
|
|
|
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'<!-- 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.
|
|
# `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)}
|