Files
planer2/backend/recherche.py
2026-09-07 07:44:00 +02:00

209 lines
8.1 KiB
Python

"""Herausfinden, was gebaut werden soll — in mehreren Fäden nebeneinander.
Recherche ist kein Nachschlagewerk. Sie klärt, was draußen gilt, damit die Planung
daneben Karten schneiden kann: Zahlen, Grenzen, Namen, Belege. Was keine Entscheidung
trägt, gehört nicht in die Antwort.
Die Karten liegen dabei, aber nur zum Lesen. Ohne sie fragt man ins Leere — „welche
Funktionen fehlen noch" ist ohne den Bestand nicht zu beantworten, und das Modell
riete am Projekt vorbei. Vorschläge macht die Recherche trotzdem keine: sie schreibt
Prosa, keine Kartenfelder. Was aus einem Fund folgt, entscheidet die Person nebenan.
Mehrere Fäden, weil Themen sich sonst gegenseitig verdrecken. „Wie geht Upload zu S3"
und „welche Diagrammbibliothek" haben nichts miteinander zu tun; in einem Verlauf
zieht das Modell den einen Kontext in die andere Antwort. Ein Faden ist ein Thema,
und er bleibt stehen, bis ihn jemand schließt.
Die Antwort ist Markdown und wird als solches angezeigt. Kein JSON: hier wird nichts
in Felder zerlegt und nichts gegengerechnet, also gäbe es auch nichts zu prüfen — ein
Formatzwang kostete nur einen Aufruf mehr, wenn das Modell ihn verfehlt.
Die Fäden liegen im Projektstand, nicht in der Planungskopie: Nachgeschlagenes gilt
unabhängig davon, ob gerade geplant wird, und überlebt einen Planungsabbruch.
"""
import json
import uuid
import agents
import features
import parser
import projekte
import verzeichnis
DATEI = ".recherche.json"
# So viele Wortmeldungen behält ein Faden. Recherche geht in die Tiefe, deshalb mehr
# als im Planungsgespräch — dort steht das Ergebnis auf den Karten, hier nur im Faden.
TIEFE = 40
# Länger wird kein Reitertext lesbar; die ganze Frage steht ohnehin im Faden.
TITELLAENGE = 40
SYSTEM = """Du recherchierst für jemanden, der Software plant.
Was du herausfindest, wird eine Aufgabe für die Implementierung. Schreib deshalb so,
dass jemand danach loslegen kann, ohne deine Suche zu wiederholen: Zahlen, Grenzen,
Namen, Belege. Was keine Entscheidung trägt, lässt du weg — kein Hintergrund, keine
Geschichte, keine Alternative, die du schon verworfen hast.
Schlag im Web nach. Antworte nicht aus dem Gedächtnis, wenn es um Versionen, Grenzen,
Preise, Schnittstellen oder aktuelle Empfehlungen geht — da veraltet Wissen still.
Belege mit Links. Widersprechen sich zwei Quellen, sag es, statt dich zu entscheiden.
Findest du nichts, sag auch das — eine erfundene Zahl ist hier teurer als keine.
Antworte auf Deutsch, als Markdown. So ist eine Antwort gebaut:
Ein bis drei Sätze Prosa: was du gefunden hast und was daraus folgt.
## Festgelegt
Je Zeile eine Aussage mit ihrer Zahl, ihrem Namen, ihrer Grenze. Fett, was eine
Entscheidung ist. Keine Begründung — die steht oben in der Prosa.
## Offen
Was noch jemand entscheiden muss, als Frage. Weglassen, wenn nichts offen ist.
## Quellen
Je Zeile ein Link mit einem Halbsatz, wofür er steht.
Die Überschriften stehen nur da, wo sie etwas tragen. Auf eine Rückfrage antwortest du
in zwei Sätzen ohne Struktur; hast du nichts gefunden, sagst du das in einem.
Die Karten des Projekts stehen unten. Sie sagen, was gebaut werden soll — lies sie,
bevor du fragst, wovon die Rede ist. Wer wissen will, was noch fehlt, bekommt eine
Antwort, die sich auf diese Karten bezieht, nicht auf ein Projekt im Allgemeinen.
Du änderst die Karten nicht und schlägst auch keine Formulierung dafür vor. Was aus
deinem Fund folgt, entscheidet die Person nebenan. Den Quelltext siehst du nicht:
Was heute schon gebaut ist, steht als „gebaut" oder „offen" an den Funktionen.
Die Karten:
{karten}"""
def _punkte(text: str) -> list:
return [z.strip("- ").strip() for z in (text or "").splitlines() if z.strip()]
def _karten(projekt: str) -> str:
"""Der Kartenstand für den Prompt — mit „gebaut"/„offen", ohne Testabläufe.
Das Planungsgespräch blendet den Stand aus: dort entscheidet ihn der Code, und
ein Feld weniger ist ein Feld weniger zum Vergessen. Hier ist er das Wichtigste
am Bestand — „was fehlt noch" hängt an nichts anderem.
Die Testabläufe bleiben draußen. Sie sagen, wie man etwas prüft, und das trägt
keine Recherche; sie würden nur den Prompt füllen.
"""
vision, karten = "", []
for f in features.liste(projekt):
s = f["sektionen"]
if s["art"] == parser.VISION:
vision = s["ziel"]
continue
karten.append({
"titel": s["titel"], "art": s["art"], "ziel": s["ziel"],
"regeln": _punkte(s["regeln"]),
"funktionen": [{"titel": x["text"], "stand": x["stand"],
"ziel": x["ziel"], "regeln": _punkte(x["regeln"])}
for x in s["funktionen"]],
})
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 _pfad(projekt: str):
return projekte.stand(projekt) / DATEI
def _lade(projekt: str) -> list:
# Der Pfad wird vor dem try geholt: „Projekt gibt es nicht" ist ein
# FileNotFoundError und damit ein OSError. Im try gefangen, würde daraus ein
# leeres Ergebnis statt einer 404.
datei = _pfad(projekt)
try:
faeden = json.loads(datei.read_text())
except (OSError, ValueError):
return []
return faeden if isinstance(faeden, list) else []
def _sichere(projekt: str, faeden: list):
ziel = _pfad(projekt)
neben = ziel.with_suffix(".json.neu")
neben.write_text(json.dumps(faeden, indent=1, ensure_ascii=False) + "\n")
neben.replace(ziel)
def _finde(faeden: list, id: str) -> dict:
for f in faeden:
if f.get("id") == id:
return f
raise FileNotFoundError("Diesen Faden gibt es nicht mehr")
def liste(projekt: str) -> list:
"""Alle Fäden mit ihrem Verlauf — das Panel zeigt Reiter und Inhalt zugleich."""
return _lade(projekt)
def anlegen(projekt: str) -> dict:
"""Ein leerer Faden. Seinen Namen bekommt er von der ersten Frage."""
faeden = _lade(projekt)
faden = {"id": uuid.uuid4().hex[:8], "titel": "Neu", "verlauf": []}
faeden.append(faden)
_sichere(projekt, faeden)
return faden
def loeschen(projekt: str, id: str):
faeden = _lade(projekt)
_finde(faeden, id)
_sichere(projekt, [f for f in faeden if f.get("id") != id])
def _titel(text: str) -> str:
text = " ".join(text.split())
return text if len(text) <= TITELLAENGE else f"{text[:TITELLAENGE].rstrip()}…"
async def frage(projekt: str, id: str, text: str) -> dict:
"""Eine Runde in einem Faden — der Faden mit der neuen Antwort."""
text = (text or "").strip()
if not text:
raise ValueError("Nachricht ist leer")
faden = _finde(_lade(projekt), id)
gespraech = "\n\n".join(
f'{"Person" if e["rolle"] == "person" else "Du"}: {e["text"]}'
for e in faden["verlauf"]) or "Noch nichts besprochen."
nachricht = f"Bisher:\n{gespraech}\n\nNeue Frage:\n{text}"
# Ohne eingestelltes Arbeitsverzeichnis läuft der Prozess dort, wo der Server
# steht. Lesen ist ohnehin gesperrt; der Ordner sorgt nur dafür, dass ein
# gefundener Umweg im Projekt landet und nicht in planer2.
ordner = None
try:
ordner = str(verzeichnis.pruefe(verzeichnis.lies(projekt)))
except (ValueError, FileNotFoundError, OSError):
pass
antwort = (await agents.frage_lokal(
SYSTEM.format(karten=_karten(projekt)), nachricht,
agents.RECHERCHE_KOMMANDO, ordner)).strip()
# Erst jetzt neu laden: der Aufruf dauert Minuten, in denen ein anderer Faden
# angelegt oder geschlossen worden sein kann. Der Faden selbst kann weg sein —
# dann ist die Antwort verfallen, und das ist richtig so.
faeden = _lade(projekt)
faden = _finde(faeden, id)
if not faden["verlauf"]:
faden["titel"] = _titel(text)
faden["verlauf"] += [{"rolle": "person", "text": text},
{"rolle": "ki", "text": antwort}]
faden["verlauf"] = faden["verlauf"][-TIEFE:]
_sichere(projekt, faeden)
return faden