209 lines
8.1 KiB
Python
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
|