diff --git a/README.md b/README.md index e3f06c6..e251082 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Get ready in just a few steps: * **19 AI providers supported:** Use local models (via Ollama), Claude, ChatGPT, Gemini, DeepSeek, Groq, and many more (with over 40 presets). * **Run directly in FreeCAD:** F5 runs the entire macro, F9 runs only the selection. Pressing again aborts execution. * **Generate macros from plain language (FC11–FC14):** Describe your macro in natural language and see the result immediately in an embedded **πŸ‘ 3D preview**. +* **Whole project as context:** Pick a project folder and the AI gets a compact map of all folders and files β€” and pulls in individual files by itself when it needs them. * **Unified error panel:** Translate errors to your preferred language in place, have the AI explain them, or auto-fix them. * **🎀 Voice control (offline):** Navigate panels/actions and dictate text, fully **offline** (via Vosk), without sending data to external providers. * **Flexible UI:** 10 freely arrangeable dock panels, interactive assistant, and a built-in searchable help. diff --git a/core/params.py b/core/params.py index 55720cb..0fac443 100644 --- a/core/params.py +++ b/core/params.py @@ -21,6 +21,21 @@ def lade_kontext() -> str: def speichere_kontext(text: str): App.ParamGet(PREF_KEY).SetString("ProjektKontext", text) +# ── Projektordner: ganzer Ordnerbaum als KI-Kontext ──────────────────────── + +def lade_projektordner() -> str: + return App.ParamGet(PREF_KEY).GetString("ProjektOrdner", "") + +def speichere_projektordner(pfad: str) -> None: + App.ParamGet(PREF_KEY).SetString("ProjektOrdner", pfad or "") + +def lade_projektkarte_aktiv() -> bool: + """Ob die Projektkarte bei jedem KI-Aufruf mitgeschickt wird.""" + return App.ParamGet(PREF_KEY).GetBool("ProjektKarteAktiv", True) + +def speichere_projektkarte_aktiv(v: bool) -> None: + App.ParamGet(PREF_KEY).SetBool("ProjektKarteAktiv", bool(v)) + _KEY_NAMES = { "anthropic": "ApiKeyAnthropic", "openai": "ApiKeyOpenAI", diff --git a/data/hilfe_texte.py b/data/hilfe_texte.py index 0922dc2..08de8fc 100644 --- a/data/hilfe_texte.py +++ b/data/hilfe_texte.py @@ -124,6 +124,32 @@ mit dem Fehler β€” dort πŸ” Übersetzen, πŸ› KI erklΓ€rt oder πŸ”§ KI korrigieren. +Projektordner (ganzer Ordnerbaum als Kontext): + πŸ€– KI-Panel β†’ β–Ά πŸ“Œ Projekt-Kontext aufklappen + β†’ πŸ“ WΓ€hlen … β†’ Ordner deines Projekts auswΓ€hlen + + Mitgeschickt wird nur eine ÜBERSICHT, kein Quelltext: + der Ordnerbaum plus alle Klassen und Funktionen der + Python-Dateien. Das kostet meist nur ein paar hundert + Token, egal wie groß das Projekt ist. + Bei Ollama wird automatisch die sparsame Variante + (nur Ordnerbaum) verwendet. + + Braucht die KI den Inhalt einer Datei, fordert sie ihn + selbst an β€” die Datei wird automatisch nachgeladen und + die Anfrage lΓ€uft weiter (max. 2 Runden pro Frage). + Im Statusfeld steht dann β€žπŸ“‚ Lade nach …". + + Der Haken β€žProjektΓΌbersicht bei jeder Anfrage + mitschicken" schaltet das Ganze ab, ohne dass du den + Ordner verlierst. βœ• entfernt den Ordner. + + Ausgelassen werden: __pycache__, .git, node_modules, + venv, build, dist sowie Dateien ΓΌber 200 KB und alles, + was kein Textformat ist. + Es wird ausschließlich gelesen β€” nie geschrieben, und + nie außerhalb des gewΓ€hlten Ordners. + Chat-Verlauf: Bleibt zwischen Fragen erhalten (Folgefragen mΓΆglich). Ab 5.000 Zeichen wird der Γ€lteste Teil automatisch diff --git a/docs/ki-workflow.md b/docs/ki-workflow.md index b6d2ff1..0b4b89e 100644 --- a/docs/ki-workflow.md +++ b/docs/ki-workflow.md @@ -77,6 +77,42 @@ can πŸ” Translate, πŸ› have the AI explain it, or πŸ”§ AI-fix it. ``` On the next FreeCAD start, simply load the `.json` file and continue seamlessly. +## Project folder as context +By default the AI only sees the file you have open. To give it a whole project β€” several +folders and files β€” pick a project folder: + +``` +πŸ€– AI panel β†’ β–Ά πŸ“Œ Project context β†’ πŸ“ Choose … +``` + +What is sent is a **map, not source code**: the folder tree plus every class and function of +the Python files in it. This usually costs only a few hundred tokens no matter how large the +project is. For Ollama the frugal variant (folder tree only) is used automatically, so the +small context window of local models is not blown. + +When the AI needs the actual content of a file, it asks for it itself: + +``` +#DATEI: core/params.py β†’ the file is loaded and the request continues +#SUCHE: Placement β†’ matches across the project are supplied +``` + +The file is fetched automatically and the request re-runs with that content (max. 2 rounds per +question, max. 3 files per round). The status bar shows `πŸ“‚ Lade nach …` while this happens. +Because this uses plain text markers instead of a provider-specific tool API, it works with all +19 providers β€” including local Ollama models without tool support. + +The same three operations are also available as manual buttons in the **πŸ”§ Tools panel** +(`projekt_dateien_auflisten`, `projekt_datei_lesen`, `projekt_suchen`). + +| Detail | Behaviour | +|--------|-----------| +| Checkbox "ProjektΓΌbersicht … mitschicken" | Turns the map off without losing the folder | +| βœ• button | Clears the project folder | +| Skipped | `__pycache__`, `.git`, `node_modules`, `venv`, `build`, `dist`, files > 200 KB, non-text files | +| Limits | 400 files, 12,000 characters of map | +| Access | Read-only, and never outside the chosen folder | + ## Using the chat history The chat history is kept between questions. Follow-up questions build on previous answers. After 5,000 characters the oldest part is automatically compressed (summarised). diff --git a/editor/builders/dock_builder.py b/editor/builders/dock_builder.py index 31026f2..9c91fc9 100644 --- a/editor/builders/dock_builder.py +++ b/editor/builders/dock_builder.py @@ -17,6 +17,8 @@ lade_auto_einfuegen, speichere_auto_einfuegen, lade_thinking_modus, speichere_thinking_modus, lade_plan_modus, speichere_plan_modus, + lade_projektordner, speichere_projektordner, + lade_projektkarte_aktiv, speichere_projektkarte_aktiv, lade_api_key, SYSTEM_PROMPT_VORLAGEN) from ui.barrierefreiheit import BarrierefreiheitPanel from editor.panel import FreecadHelferPanel @@ -608,6 +610,80 @@ def _mindesthoehe(widget, zeilen: int) -> None: lambda: speichere_kontext(editor._kontext.toPlainText())) _kontext_sek.addWidget(editor._kontext) + # ── Projektordner: ganzer Ordnerbaum als KI-Kontext ──────────────────── + _ordner_zeile = QtWidgets.QHBoxLayout() + _ordner_zeile.setSpacing(theme.DOCK_KI_RAHMEN_ABST) + + editor._projekt_pfad_feld = QtWidgets.QLineEdit(lade_projektordner()) + editor._projekt_pfad_feld.setReadOnly(True) + editor._projekt_pfad_feld.setPlaceholderText( + "Kein Projektordner gewΓ€hlt – nur die offene Datei geht an die KI") + editor._projekt_pfad_feld.setToolTip( + "GewΓ€hlter Projektordner. Die KI bekommt eine Übersicht aller\n" + "Dateien darin und kann einzelne Dateien gezielt nachfordern.") + _ordner_zeile.addWidget(editor._projekt_pfad_feld, stretch=1) + + _btn_ordner = QtWidgets.QPushButton("πŸ“ WΓ€hlen …") + _btn_ordner.setToolTip("Projektordner auswΓ€hlen") + _ordner_zeile.addWidget(_btn_ordner) + + _btn_ordner_weg = QtWidgets.QPushButton("βœ•") + _btn_ordner_weg.setFixedWidth(theme.DOCK_ICON_BTN_BREITE) + _btn_ordner_weg.setToolTip("Projektordner nicht mehr mitschicken") + _ordner_zeile.addWidget(_btn_ordner_weg) + _kontext_sek.addLayout(_ordner_zeile) + + editor._projekt_karte_box = QtWidgets.QCheckBox( + "ProjektΓΌbersicht bei jeder Anfrage mitschicken") + editor._projekt_karte_box.setChecked(lade_projektkarte_aktiv()) + editor._projekt_karte_box.setToolTip( + "Sendet Ordnerbaum und Klassen-/FunktionsΓΌbersicht mit β€”\n" + "keinen Quelltext. Kostet nur wenige hundert Token.") + editor._projekt_karte_box.toggled.connect(speichere_projektkarte_aktiv) + _kontext_sek.addWidget(editor._projekt_karte_box) + + editor._projekt_info_lbl = QtWidgets.QLabel("") + editor._projekt_info_lbl.setWordWrap(True) + _kontext_sek.addWidget(editor._projekt_info_lbl) + + def _projekt_info_aktualisieren(): + pfad = editor._projekt_pfad_feld.text().strip() + if not pfad: + editor._projekt_info_lbl.setText("") + return + try: + from editor.ki.projekt_kontext import projekt_statistik + anzahl, tokens = projekt_statistik(pfad) + except Exception as exc: + editor._projekt_info_lbl.setText(f"⚠ Ordner nicht lesbar: {exc}") + return + if not anzahl: + editor._projekt_info_lbl.setText( + "⚠ Keine lesbaren Textdateien in diesem Ordner gefunden") + else: + editor._projekt_info_lbl.setText( + f"πŸ“‚ {anzahl} Datei(en) Β· Übersicht β‰ˆ {tokens} Token je Anfrage") + + def _projekt_ordner_waehlen(): + start = editor._projekt_pfad_feld.text().strip() or "" + pfad = QtWidgets.QFileDialog.getExistingDirectory( + editor, "Projektordner wΓ€hlen", start) + if not pfad: + return + editor._projekt_pfad_feld.setText(pfad) + speichere_projektordner(pfad) + _projekt_info_aktualisieren() + + def _projekt_ordner_loeschen(): + editor._projekt_pfad_feld.clear() + speichere_projektordner("") + _projekt_info_aktualisieren() + + _btn_ordner.clicked.connect(_projekt_ordner_waehlen) + _btn_ordner_weg.clicked.connect(_projekt_ordner_loeschen) + # Beim Start nicht sofort durchsuchen β€” das Panel soll zΓΌgig aufgehen + QtCore.QTimer.singleShot(1500, _projekt_info_aktualisieren) + ki_layout.addWidget(_input_w, stretch=1) ki_layout.addWidget(_kontext_sek) editor._dock_ki = editor._make_dock("πŸ€– KI", "dock_ki", _L, ki_widget) diff --git a/editor/ki/ki_anfrage.py b/editor/ki/ki_anfrage.py index 4433a33..a022b08 100644 --- a/editor/ki/ki_anfrage.py +++ b/editor/ki/ki_anfrage.py @@ -79,6 +79,18 @@ def _relevante_api_hints(frage: str, max_hints: int = 5) -> str: return "\n".join(zeilen) +def _projektkarte_block(kompakt: bool = False) -> str: + """Projektkarte des gewΓ€hlten Ordners β€” "" wenn keiner gesetzt/aktiv ist.""" + try: + from core.params import lade_projektordner, lade_projektkarte_aktiv + if not lade_projektkarte_aktiv(): + return "" + from editor.ki.projekt_kontext import karte_als_prompt_block + return karte_als_prompt_block(lade_projektordner(), kompakt=kompakt) + except Exception: + return "" + + class KIAnfrage: """Baut KI-Prompts auf und startet Worker-Threads.""" @@ -266,6 +278,21 @@ def ki_fragen(self): self._c._status.setText( f"πŸ€– Warte auf ersten Token … | Verlauf: {verlauf_kb} KB") + # Projektordner: kompakte Karte des ganzen Ordnerbaums (ohne Quelltext). + # FΓΌr Ollama nur der Ordnerbaum β€” die Struktur-Übersicht sprengt sonst + # das kleine Kontextfenster lokaler Modelle. + projekt_block = _projektkarte_block( + kompakt=self._c._src_box.currentText().startswith("Ollama")) + # Nachlade-Protokoll nur dort anbieten, wo die Folgeanfrage auch lΓ€uft + nachlade_hinweis = "" + if projekt_block and not (ist_nl_modus or ist_pd_modus + or ist_sw_modus or ist_tc_modus): + from editor.ki.projekt_nachladen import ANFORDERUNGS_HINWEIS + nachlade_hinweis = f"{ANFORDERUNGS_HINWEIS}\n" + self._c._projekt_nachlade_runde = 0 + # Nur nachladen, wenn die KI das Protokoll auch erklΓ€rt bekommen hat + self._c._projekt_nachlade_erlaubt = bool(nachlade_hinweis) + # Sitemap: nur Struktur-Übersicht, kein voller Code-Text sitemap = erstelle_code_sitemap(self._c._editor.toPlainText()) sitemap_block = ( @@ -285,6 +312,7 @@ def ki_fragen(self): if code_quelle == "nur_frage": full_prompt = ( + f"{projekt_block}{nachlade_hinweis}" f"{sitemap_block}" f"{prompt}{_hints_section}\n\n" f"{modus_prefix}" @@ -292,6 +320,7 @@ def ki_fragen(self): else: herkunft = "Markierter Block" if code_quelle == "suchfeld" else "Editor-Inhalt" full_prompt = ( + f"{projekt_block}{nachlade_hinweis}" f"{sitemap_block}" f"Aufgabe: {prompt}\n\n" f"{herkunft}:\n```python\n{code}\n```" @@ -391,6 +420,11 @@ def ki_fragen(self): except Exception: pass + # Projektordner-Übersicht auch fΓΌr FC11 (ohne Nachlade-Protokoll β€” + # FC11 darf ausschließlich Code zurΓΌckgeben) + if projekt_block: + fc11_prompt = fc11_prompt + "\n" + projekt_block + # Skills: passende Skill-Dateien anhand von StichwΓΆrtern anhΓ€ngen try: import re as _re, os as _os @@ -527,6 +561,8 @@ def ki_fehler_erklaeren(self): aktueller_code = self._c._editor.toPlainText() code_ausschnitt = extrahiere_fehler_kontext(aktueller_code, fehler_text) + # Fehler-ErklΓ€rung lΓ€uft ohne Projektkarte β€” also auch ohne Nachladen + self._c._projekt_nachlade_erlaubt = False if code_ausschnitt: system = ( diff --git a/editor/ki/ki_chunk.py b/editor/ki/ki_chunk.py index 3f3e540..6b63cf6 100644 --- a/editor/ki/ki_chunk.py +++ b/editor/ki/ki_chunk.py @@ -96,6 +96,18 @@ def on_stream_done(self): self._c._btn_ki.setEnabled(True) return + # Projektordner: hat die KI Dateien angefordert, werden sie nachgeladen + # und die Anfrage lΓ€uft automatisch weiter (nur im normalen Chat-Modus). + if not (getattr(self._c, "_nl_antwort_aktiv", False) + or getattr(self._c, "_tc_modus_aktiv", False) + or getattr(self._c, "_sw_modus_aktiv", False)): + try: + from editor.ki.projekt_nachladen import nachladen_falls_angefordert + if nachladen_falls_angefordert(self._c, full): + return + except Exception: + pass + if getattr(self._c, "_nl_antwort_aktiv", False): self._c._nl_antwort_aktiv = False clean = extrahiere_code_aus_nl_antwort(clean) diff --git a/editor/ki/ki_werkzeuge.py b/editor/ki/ki_werkzeuge.py index fe33b54..8b77f94 100644 --- a/editor/ki/ki_werkzeuge.py +++ b/editor/ki/ki_werkzeuge.py @@ -351,6 +351,86 @@ def _makro_ausfuehren(code: str) -> WerkzeugErgebnis: fehler=str(exc)) +# ══════════════════════════════════════════════════════════════════════════════ +# Projektordner β€” die KI lΓ€dt sich Dateien selbst nach +# ══════════════════════════════════════════════════════════════════════════════ + +def _projekt_wurzel() -> str: + """GewΓ€hlter Projektordner aus den FreeCAD-Parametern ("" wenn keiner).""" + try: + from core.params import lade_projektordner + return lade_projektordner() + except Exception: + return "" + + +def _ohne_projektordner() -> WerkzeugErgebnis: + return WerkzeugErgebnis( + erfolg=False, ausgabe="", + fehler=("Kein Projektordner gewΓ€hlt. Im KI-Panel unter " + "β€žπŸ“Œ Projekt-Kontext\" einen Ordner auswΓ€hlen.")) + + +def _projekt_dateien_auflisten() -> WerkzeugErgebnis: + """Listet alle Textdateien des Projektordners auf.""" + wurzel = _projekt_wurzel() + if not wurzel: + return _ohne_projektordner() + try: + from editor.ki.projekt_kontext import dateien_sammeln, ordnerbaum + rel_pfade = dateien_sammeln(wurzel) + except Exception as exc: + return WerkzeugErgebnis(erfolg=False, ausgabe="", fehler=str(exc)) + + if not rel_pfade: + return WerkzeugErgebnis( + erfolg=True, ausgabe="(keine lesbaren Dateien gefunden)", + daten={"count": 0, "files": []}) + + return WerkzeugErgebnis( + erfolg=True, + ausgabe=f"{len(rel_pfade)} Datei(en) in {wurzel}:\n" + + ordnerbaum(wurzel, rel_pfade), + daten={"count": len(rel_pfade), "files": rel_pfade}) + + +def _projekt_datei_lesen(pfad: str) -> WerkzeugErgebnis: + """Liest eine Datei aus dem Projektordner (nur lesend, nie außerhalb).""" + wurzel = _projekt_wurzel() + if not wurzel: + return _ohne_projektordner() + try: + from editor.ki.projekt_kontext import datei_lesen + inhalt = datei_lesen(wurzel, pfad) + except ValueError as exc: + return WerkzeugErgebnis(erfolg=False, ausgabe="", fehler=str(exc)) + except Exception as exc: + return WerkzeugErgebnis( + erfolg=False, ausgabe="", fehler=f"Datei nicht lesbar: {exc}") + + return WerkzeugErgebnis( + erfolg=True, ausgabe=f"── {pfad} ──\n{inhalt}", + daten={"path": pfad, "chars": len(inhalt)}) + + +def _projekt_suchen(muster: str) -> WerkzeugErgebnis: + """Sucht einen Text in allen Dateien des Projektordners.""" + wurzel = _projekt_wurzel() + if not wurzel: + return _ohne_projektordner() + try: + from editor.ki.projekt_kontext import projekt_suchen + ausgabe = projekt_suchen(wurzel, muster) + except ValueError as exc: + return WerkzeugErgebnis(erfolg=False, ausgabe="", fehler=str(exc)) + except Exception as exc: + return WerkzeugErgebnis( + erfolg=False, ausgabe="", fehler=f"Suche fehlgeschlagen: {exc}") + + return WerkzeugErgebnis(erfolg=True, ausgabe=ausgabe, + daten={"pattern": muster}) + + # ══════════════════════════════════════════════════════════════════════════════ # Werkzeug-Register # ══════════════════════════════════════════════════════════════════════════════ @@ -462,6 +542,49 @@ def _registrieren(defn: WerkzeugDefinition) -> None: ], )) +_registrieren(WerkzeugDefinition( + name="projekt_dateien_auflisten", + beschreibung=( + "Listet alle Textdateien des gewΓ€hlten Projektordners als Ordnerbaum auf. " + "Verwende dies zuerst, um herauszufinden welche Dateien es gibt, " + "bevor du mit projekt_datei_lesen einzelne Dateien anforderst." + ), + kategorie="projekt", + handler=_projekt_dateien_auflisten, + parameter=[], +)) + +_registrieren(WerkzeugDefinition( + name="projekt_datei_lesen", + beschreibung=( + "Liest den Inhalt EINER Datei aus dem Projektordner, mit Zeilennummern. " + "Der Pfad ist relativ zum Projektordner, z.B. 'core/params.py'. " + "Nur lesend β€” außerhalb des Projektordners ist kein Zugriff mΓΆglich." + ), + kategorie="projekt", + handler=_projekt_datei_lesen, + parameter=[ + WerkzeugParam("pfad", "string", + "Pfad relativ zum Projektordner, z.B. 'editor/panel.py'"), + ], +)) + +_registrieren(WerkzeugDefinition( + name="projekt_suchen", + beschreibung=( + "Sucht einen Text in allen Dateien des Projektordners und gibt die " + "Fundstellen als 'pfad:zeile: inhalt' zurΓΌck. Groß-/Kleinschreibung " + "wird ignoriert. NΓΌtzlich um zu finden, wo eine Funktion definiert " + "oder verwendet wird." + ), + kategorie="projekt", + handler=_projekt_suchen, + parameter=[ + WerkzeugParam("muster", "string", + "Gesuchter Text, z.B. 'def lade_pfad' oder 'Placement'"), + ], +)) + # ══════════════════════════════════════════════════════════════════════════════ # Schema-Export & AusfΓΌhrung diff --git a/editor/ki/projekt_kontext.py b/editor/ki/projekt_kontext.py new file mode 100644 index 0000000..e685fa1 --- /dev/null +++ b/editor/ki/projekt_kontext.py @@ -0,0 +1,344 @@ +# -*- coding: utf-8 -*- +""" +projekt_kontext.py +────────────────── +Projektordner als KI-Kontext β€” ganze OrdnerbΓ€ume statt nur einer Datei. + +Zwei Betriebsarten, die sich ergΓ€nzen: + + A) Projektkarte β€” kompakte Übersicht (Ordnerbaum + AST-Struktur je + Python-Datei), die bei jedem KI-Aufruf mitgeschickt wird. + Kostet nur wenige hundert Token statt hunderttausende und + funktioniert daher auch mit lokalen Ollama-Modellen. + + B) Nachlade-Werkzeuge β€” die KI holt sich ΓΌber + `projekt_dateien_auflisten` / `projekt_datei_lesen` / + `projekt_suchen` (siehe ki_werkzeuge.py) gezielt genau die Dateien, + die sie wirklich braucht. + +Sicherheit: Alle Pfade werden gegen den gewΓ€hlten Projektordner geprΓΌft +(`sichere_pfad`). AusbrΓΌche ΓΌber β€ž..β€œ oder Symlinks sind ausgeschlossen, +und es wird ausschließlich gelesen β€” nie geschrieben. + +Dieses Modul ist bewusst frei von Qt- und FreeCAD-AbhΓ€ngigkeiten, damit +es ohne laufendes FreeCAD getestet werden kann. +""" + +from __future__ import annotations + +import os + +from editor.ki.kod_analyse import erstelle_code_sitemap + + +# ── Grenzwerte ──────────────────────────────────────────────────────────────── + +MAX_DATEIEN = 400 # so viele Dateien wandern hΓΆchstens in die Karte +MAX_DATEI_BYTES = 200_000 # grâßere Dateien werden ΓΌbersprungen +MAX_KARTE_ZEICHEN = 12_000 # harte Obergrenze der fertigen Projektkarte +MAX_LESE_ZEICHEN = 40_000 # Obergrenze fΓΌr projekt_datei_lesen +MAX_TREFFER = 40 # Obergrenze fΓΌr projekt_suchen + +# Ordner, die nie durchsucht werden (MΓΌll, Caches, Fremdcode) +IGNORIER_ORDNER = { + "__pycache__", ".git", ".hg", ".svn", ".tox", ".venv", "venv", "env", + "node_modules", "build", "dist", ".mypy_cache", ".pytest_cache", + ".ruff_cache", ".idea", ".vscode", ".ipynb_checkpoints", "site-packages", +} + +# Endungen, die als Text gelesen werden dΓΌrfen +TEXT_ENDUNGEN = { + ".py", ".pyw", ".fcmacro", ".md", ".txt", ".rst", ".json", ".yaml", ".yml", + ".toml", ".ini", ".cfg", ".csv", ".xml", ".html", ".css", ".js", ".sh", + ".bat", ".ts", +} + + +# ── Pfad-Absicherung ────────────────────────────────────────────────────────── + +def sichere_pfad(wurzel: str, rel_pfad: str) -> str: + """LΓΆst ``rel_pfad`` innerhalb von ``wurzel`` auf. + + Gibt den absoluten Pfad zurΓΌck. Wirft ValueError, wenn der Pfad aus dem + Projektordner herausfΓΌhrt β€” egal ob ΓΌber β€ž..β€œ, einen absoluten Pfad oder + einen Symlink. + """ + if not wurzel: + raise ValueError("Kein Projektordner gewΓ€hlt.") + + wurzel_abs = os.path.realpath(os.path.expanduser(wurzel)) + if not os.path.isdir(wurzel_abs): + raise ValueError(f"Projektordner existiert nicht: {wurzel}") + + rel = (rel_pfad or "").strip().replace("\\", "/") + if not rel: + raise ValueError("Kein Pfad angegeben.") + + # Absolute Pfade sind erlaubt, solange sie im Projektordner liegen β€” + # die PrΓΌfung unten weist alles andere ab. + ziel = os.path.realpath(rel if os.path.isabs(rel) + else os.path.join(wurzel_abs, rel)) + + if ziel != wurzel_abs and not ziel.startswith(wurzel_abs + os.sep): + raise ValueError(f"Pfad liegt außerhalb des Projektordners: {rel_pfad}") + return ziel + + +# ── Dateien einsammeln ──────────────────────────────────────────────────────── + +def _ist_textdatei(name: str) -> bool: + return os.path.splitext(name)[1].lower() in TEXT_ENDUNGEN + + +def dateien_sammeln(wurzel: str, max_dateien: int = MAX_DATEIEN, + max_bytes: int = MAX_DATEI_BYTES) -> list[str]: + """Sammelt alle Textdateien unterhalb von ``wurzel``. + + RΓΌckgabe: sortierte Liste relativer Pfade mit β€ž/β€œ als Trenner. + Ordner aus IGNORIER_ORDNER und zu große Dateien werden ausgelassen. + """ + wurzel_abs = os.path.realpath(os.path.expanduser(wurzel or "")) + if not os.path.isdir(wurzel_abs): + return [] + + gefunden: list[str] = [] + for ordner, unterordner, dateien in os.walk(wurzel_abs): + # In-place filtern, damit os.walk gar nicht erst absteigt + unterordner[:] = sorted( + u for u in unterordner + if u not in IGNORIER_ORDNER and not u.endswith(".egg-info") + ) + for name in sorted(dateien): + if not _ist_textdatei(name): + continue + voll = os.path.join(ordner, name) + try: + if os.path.getsize(voll) > max_bytes: + continue + except OSError: + continue + gefunden.append(os.path.relpath(voll, wurzel_abs).replace(os.sep, "/")) + if len(gefunden) >= max_dateien: + return sorted(gefunden) + return sorted(gefunden) + + +# ── Ordnerbaum zeichnen ─────────────────────────────────────────────────────── + +def _baum_aufbauen(rel_pfade: list[str]) -> dict: + baum: dict = {} + for pfad in rel_pfade: + knoten = baum + teile = pfad.split("/") + for teil in teile[:-1]: + knoten = knoten.setdefault(teil, {}) + knoten[teile[-1]] = None + return baum + + +def _baum_zeichnen(knoten: dict, einzug: str = "") -> list[str]: + zeilen: list[str] = [] + eintraege = sorted(knoten.items(), + key=lambda kv: (kv[1] is None, kv[0].lower())) + for i, (name, kind) in enumerate(eintraege): + letzter = (i == len(eintraege) - 1) + zweig = "└─ " if letzter else "β”œβ”€ " + if kind is None: + zeilen.append(f"{einzug}{zweig}{name}") + else: + zeilen.append(f"{einzug}{zweig}{name}/") + zeilen.extend(_baum_zeichnen(kind, einzug + (" " if letzter else "β”‚ "))) + return zeilen + + +def ordnerbaum(wurzel: str, rel_pfade: list[str]) -> str: + """Zeichnet den Ordnerbaum der ΓΌbergebenen relativen Pfade.""" + if not rel_pfade: + return "(keine lesbaren Dateien gefunden)" + kopf = os.path.basename(os.path.normpath(wurzel)) or wurzel + return "\n".join([f"{kopf}/"] + _baum_zeichnen(_baum_aufbauen(rel_pfade))) + + +# ── Projektkarte ────────────────────────────────────────────────────────────── + +# AST-Parsen ist der teuerste Teil der Karte. Ergebnisse werden je Datei +# gemerkt und nur bei geΓ€nderter Grâße/Γ„nderungszeit neu berechnet β€” sonst +# wΓΌrde jede KI-Anfrage das ganze Projekt erneut parsen. +_SITEMAP_CACHE: dict[str, tuple[float, int, str]] = {} +_CACHE_MAX = 2_000 + + +def _sitemap_fuer_datei(voll_pfad: str) -> str: + """AST-Inhaltsverzeichnis einer Datei, gepuffert ΓΌber mtime und Grâße.""" + try: + stat = os.stat(voll_pfad) + except OSError: + return "" + + gemerkt = _SITEMAP_CACHE.get(voll_pfad) + if gemerkt and gemerkt[0] == stat.st_mtime and gemerkt[1] == stat.st_size: + return gemerkt[2] + + try: + with open(voll_pfad, encoding="utf-8", errors="replace") as f: + sitemap = erstelle_code_sitemap(f.read()) + except OSError: + return "" + + if len(_SITEMAP_CACHE) >= _CACHE_MAX: + _SITEMAP_CACHE.clear() + _SITEMAP_CACHE[voll_pfad] = (stat.st_mtime, stat.st_size, sitemap) + return sitemap + + +def _sitemap_block(wurzel: str, rel_pfade: list[str]) -> list[str]: + """Erzeugt je Python-Datei ein AST-Inhaltsverzeichnis.""" + bloecke: list[str] = [] + for rel in rel_pfade: + if not rel.lower().endswith((".py", ".pyw", ".fcmacro")): + continue + sitemap = _sitemap_fuer_datei(os.path.join(wurzel, rel)) + if not sitemap: + continue + eingerueckt = "\n".join(f" {z}" for z in sitemap.splitlines()) + bloecke.append(f"{rel}\n{eingerueckt}") + return bloecke + + +def erstelle_projektkarte(wurzel: str, + max_dateien: int = MAX_DATEIEN, + max_zeichen: int = MAX_KARTE_ZEICHEN, + mit_struktur: bool = True) -> str: + """Baut die kompakte ProjektΓΌbersicht fΓΌr den Prompt. + + EnthΓ€lt Ordnerbaum und die Struktur (Klassen/Funktionen) aller + Python-Dateien β€” aber keinen Quelltext. Gibt "" zurΓΌck, wenn der Ordner + leer oder nicht lesbar ist. + + ``mit_struktur=False`` liefert nur den Ordnerbaum β€” deutlich sparsamer + und damit die richtige Wahl fΓΌr lokale Modelle mit kleinem Kontext. + """ + wurzel_abs = os.path.realpath(os.path.expanduser(wurzel or "")) + if not os.path.isdir(wurzel_abs): + return "" + + rel_pfade = dateien_sammeln(wurzel_abs, max_dateien=max_dateien) + if not rel_pfade: + return "" + + kopf = (f"Projektordner: {wurzel_abs}\n" + f"Dateien: {len(rel_pfade)}" + f"{' (gekΓΌrzt)' if len(rel_pfade) >= max_dateien else ''}\n\n" + f"{ordnerbaum(wurzel_abs, rel_pfade)}") + + bloecke = _sitemap_block(wurzel_abs, rel_pfade) if mit_struktur else [] + if not bloecke: + return kopf[:max_zeichen] + + teile = [kopf, "", "Struktur der Python-Dateien:"] + laenge = sum(len(t) + 1 for t in teile) + for block in bloecke: + if laenge + len(block) + 1 > max_zeichen: + teile.append("… (weitere Dateien gekΓΌrzt β€” " + "bei Bedarf projekt_datei_lesen verwenden)") + break + teile.append(block) + laenge += len(block) + 1 + return "\n".join(teile) + + +def karte_als_prompt_block(wurzel: str, kompakt: bool = False) -> str: + """Projektkarte fertig umrahmt fΓΌr den Prompt β€” "" wenn nichts vorliegt. + + ``kompakt=True`` (fΓΌr Ollama & Co.): nur Ordnerbaum, halbes Zeichenlimit. + """ + if kompakt: + karte = erstelle_projektkarte(wurzel, + max_zeichen=MAX_KARTE_ZEICHEN // 3, + mit_struktur=False) + else: + karte = erstelle_projektkarte(wurzel) + if not karte: + return "" + return ( + "━━━ PROJEKTORDNER (Übersicht, kein Quelltext) ━━━\n" + f"{karte}\n" + "Hinweis: Der Quelltext einzelner Dateien ist NICHT enthalten. " + "Frage gezielt nach einer Datei, wenn du ihren Inhalt brauchst.\n" + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + ) + + +# ── Nachladen: lesen und suchen ─────────────────────────────────────────────── + +def datei_lesen(wurzel: str, rel_pfad: str, + max_zeichen: int = MAX_LESE_ZEICHEN) -> str: + """Liest eine einzelne Projektdatei mit Zeilennummern. + + Wirft ValueError bei Pfaden außerhalb des Projektordners, fehlenden + Dateien oder nicht unterstΓΌtzten Dateitypen. + """ + ziel = sichere_pfad(wurzel, rel_pfad) + if not os.path.isfile(ziel): + raise ValueError(f"Datei nicht gefunden: {rel_pfad}") + if not _ist_textdatei(os.path.basename(ziel)): + raise ValueError(f"Kein unterstΓΌtztes Textformat: {rel_pfad}") + + with open(ziel, encoding="utf-8", errors="replace") as f: + inhalt = f.read() + + gekuerzt = len(inhalt) > max_zeichen + if gekuerzt: + inhalt = inhalt[:max_zeichen] + + zeilen = [f"{nr:5d}: {text}" + for nr, text in enumerate(inhalt.splitlines(), 1)] + if gekuerzt: + zeilen.append(f"… (gekΓΌrzt bei {max_zeichen} Zeichen)") + return "\n".join(zeilen) + + +def projekt_suchen(wurzel: str, muster: str, + max_treffer: int = MAX_TREFFER) -> str: + """Sucht ``muster`` (Klartext, Groß-/Kleinschreibung egal) im Projekt. + + RΓΌckgabe: Zeilen der Form ``pfad:zeilennummer: inhalt``. + """ + if not (muster or "").strip(): + raise ValueError("Suchmuster ist leer.") + + wurzel_abs = os.path.realpath(os.path.expanduser(wurzel or "")) + if not os.path.isdir(wurzel_abs): + raise ValueError(f"Projektordner existiert nicht: {wurzel}") + + nadel = muster.lower() + treffer: list[str] = [] + for rel in dateien_sammeln(wurzel_abs): + try: + with open(os.path.join(wurzel_abs, rel), encoding="utf-8", + errors="replace") as f: + for nr, zeile in enumerate(f, 1): + if nadel in zeile.lower(): + treffer.append(f"{rel}:{nr}: {zeile.rstrip()[:200]}") + if len(treffer) >= max_treffer: + treffer.append( + f"… (bei {max_treffer} Treffern abgebrochen)") + return "\n".join(treffer) + except OSError: + continue + return "\n".join(treffer) if treffer else f"Keine Treffer fΓΌr '{muster}'." + + +# ── Statistik fΓΌr die OberflΓ€che ────────────────────────────────────────────── + +def schaetze_tokens(text: str) -> int: + """Grobe Token-SchΓ€tzung (~4 Zeichen je Token).""" + return len(text) // 4 + + +def projekt_statistik(wurzel: str) -> tuple[int, int]: + """Gibt (Anzahl Dateien, geschΓ€tzte Token der Projektkarte) zurΓΌck.""" + karte = erstelle_projektkarte(wurzel) + if not karte: + return 0, 0 + return len(dateien_sammeln(wurzel)), schaetze_tokens(karte) diff --git a/editor/ki/projekt_nachladen.py b/editor/ki/projekt_nachladen.py new file mode 100644 index 0000000..e02973b --- /dev/null +++ b/editor/ki/projekt_nachladen.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +""" +projekt_nachladen.py +──────────────────── +Die KI holt sich Projektdateien selbst nach. + +Mit der Projektkarte (siehe projekt_kontext.py) kennt das Modell die +Struktur des Projekts, aber keinen Quelltext. Braucht es den Inhalt einer +Datei, antwortet es mit Anforderungszeilen: + + #DATEI: core/params.py + #SUCHE: Placement + +Dieses Modul erkennt solche Zeilen nach dem Stream-Ende, liest die Dateien +(abgesichert ΓΌber projekt_kontext.sichere_pfad), hΓ€ngt sie an den +GesprΓ€chsverlauf an und startet die Anfrage automatisch neu β€” das Modell +antwortet dann mit dem Wissen aus den Dateien. + +Der Umweg ΓΌber Textmarken statt echter Tool-Call-APIs ist Absicht: so +funktioniert das Nachladen mit allen 19 Anbietern, auch mit lokalen +Ollama-Modellen ohne Tool-UnterstΓΌtzung. + +Die reinen Funktionen (`finde_anforderungen`, `baue_nachlade_block`) sind +frei von Qt und FreeCAD und damit ohne laufendes FreeCAD testbar. +""" + +from __future__ import annotations + +import re + +from editor.ki.projekt_kontext import MAX_LESE_ZEICHEN, datei_lesen, projekt_suchen + +# HΓΆchstens so viele Dateien/Suchen je Runde … +MAX_ANFORDERUNGEN = 3 +# … und hΓΆchstens so viele Nachlade-Runden je Frage (Schutz vor Endlosschleifen) +MAX_RUNDEN = 2 +# KΓΌrzeres Limit als beim direkten Lesen β€” es kΓΆnnen mehrere Dateien sein +MAX_ZEICHEN_JE_DATEI = MAX_LESE_ZEICHEN // 2 + +_RE_FENCE = re.compile(r"```.*?```", re.DOTALL) +_RE_ANFORDERUNG = re.compile( + r"^[ \t]*#[ \t]*(DATEI|SUCHE)[ \t]*:[ \t]*(.+?)[ \t]*$", + re.MULTILINE | re.IGNORECASE) + +ANFORDERUNGS_HINWEIS = ( + "Wenn du den Inhalt einer Datei brauchst, antworte NUR mit " + "Anforderungszeilen (nichts sonst):\n" + " #DATEI: relativer/pfad.py β†’ liefert den Dateiinhalt\n" + " #SUCHE: suchbegriff β†’ liefert Fundstellen im Projekt\n" + f"HΓΆchstens {MAX_ANFORDERUNGEN} Zeilen pro Antwort. Du bekommst das " + "Ergebnis automatisch und antwortest danach ganz normal.\n" +) + + +# ── Reine Logik ─────────────────────────────────────────────────────────────── + +def finde_anforderungen(text: str) -> list[tuple[str, str]]: + """Sucht Anforderungszeilen in einer KI-Antwort. + + RΓΌckgabe: Liste aus ("datei"|"suche", Argument), hΓΆchstens + MAX_ANFORDERUNGEN EintrΓ€ge, Duplikate entfernt. CodeblΓΆcke in ``` ``` + werden vorher entfernt, damit ein Kommentar im generierten Code keine + Anforderung auslΓΆst. + """ + ohne_code = _RE_FENCE.sub("", text or "") + + gefunden: list[tuple[str, str]] = [] + gesehen: set[tuple[str, str]] = set() + for art, wert in _RE_ANFORDERUNG.findall(ohne_code): + eintrag = (art.lower(), wert.strip().strip("\"'`")) + if not eintrag[1] or eintrag in gesehen: + continue + gesehen.add(eintrag) + gefunden.append(eintrag) + if len(gefunden) >= MAX_ANFORDERUNGEN: + break + return gefunden + + +def baue_nachlade_block(wurzel: str, + anforderungen: list[tuple[str, str]]) -> str: + """LΓΆst die Anforderungen auf und baut den Text fΓΌr die Folgeanfrage.""" + teile: list[str] = [] + for art, wert in anforderungen: + try: + if art == "datei": + inhalt = datei_lesen(wurzel, wert, + max_zeichen=MAX_ZEICHEN_JE_DATEI) + teile.append(f"── Datei: {wert} ──\n{inhalt}") + else: + inhalt = projekt_suchen(wurzel, wert) + teile.append(f"── Suche: {wert} ──\n{inhalt}") + except ValueError as exc: + teile.append(f"── {wert} ──\n(nicht verfΓΌgbar: {exc})") + except Exception as exc: # defekte Datei, Rechte, … + teile.append(f"── {wert} ──\n(Fehler beim Lesen: {exc})") + + if not teile: + return "" + return ( + "Angeforderte Projektinhalte:\n" + + "\n\n".join(teile) + + "\n\nBeantworte jetzt die ursprΓΌngliche Frage. " + "Fordere nichts erneut an, wenn es oben bereits steht." + ) + + +def kurzfassung(anforderungen: list[tuple[str, str]]) -> str: + """Statuszeile fΓΌr die OberflΓ€che.""" + return ", ".join(w for _, w in anforderungen) + + +# ── Anbindung an den Editor ─────────────────────────────────────────────────── + +def nachladen_falls_angefordert(controller, antwort: str) -> bool: + """PrΓΌft die Antwort auf Anforderungen und startet ggf. die Folgeanfrage. + + Gibt True zurΓΌck, wenn eine Folgeanfrage lΓ€uft β€” der Aufrufer soll die + Antwort dann nicht als Endergebnis behandeln. + """ + # Nur wenn die Anfrage das Protokoll ΓΌberhaupt angeboten hat + if not getattr(controller, "_projekt_nachlade_erlaubt", False): + return False + + anforderungen = finde_anforderungen(antwort) + if not anforderungen: + controller._projekt_nachlade_runde = 0 + return False + + try: + from core.params import lade_projektordner + wurzel = lade_projektordner() + except Exception: + wurzel = "" + if not wurzel: + return False + + runde = getattr(controller, "_projekt_nachlade_runde", 0) + 1 + if runde > MAX_RUNDEN: + controller._projekt_nachlade_runde = 0 + controller._set_status( + f"⚠ Nachladen nach {MAX_RUNDEN} Runden gestoppt – " + "Frage bitte konkreter stellen") + return False + + block = baue_nachlade_block(wurzel, anforderungen) + if not block: + controller._projekt_nachlade_runde = 0 + return False + + controller._projekt_nachlade_runde = runde + controller._chat_verlauf.append({"role": "assistant", "content": antwort.strip()}) + controller._chat_verlauf.append({"role": "user", "content": block}) + + controller._set_status( + f"πŸ“‚ Lade nach (Runde {runde}/{MAX_RUNDEN}): " + f"{kurzfassung(anforderungen)}") + _folgeanfrage_starten(controller) + return True + + +def _folgeanfrage_starten(controller) -> None: + """Startet denselben Worker wie eine normale Chat-Anfrage neu.""" + import threading + import time + + controller._ki_lauf_ui(True) + controller._btn_ersetzen.setEnabled(False) + controller._ki_area.clear() + controller._chunk_buffer.clear() + controller._stream_token_count = 0 + controller._warte_dots = 0 + controller._warte_aktiv = True + controller._stream_start_time = time.monotonic() + controller._flush_timer.start() + controller._status_timer.start() + if hasattr(controller, "_warte_timer"): + controller._warte_timer.start() + + threading.Thread( + target=controller._streaming.worker_mit_verlauf, + args=(controller._src_box.currentText(), + controller._model_box.currentText(), + list(controller._chat_verlauf), + controller._temp_box.value()), + daemon=True + ).start() diff --git a/tests/test_back_funktionen.py b/tests/test_back_funktionen.py index bc1a060..f991958 100644 --- a/tests/test_back_funktionen.py +++ b/tests/test_back_funktionen.py @@ -575,6 +575,298 @@ def test_alle_prompts_enden_mit_python_only_hinweis(self): self.assertIn("Python", prompt) +class TestProjektKontext(unittest.TestCase): + """Tests fΓΌr projekt_kontext.py – Projektordner als KI-Kontext.""" + + @classmethod + def setUpClass(cls): + projektpfad = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..") + ) + if projektpfad not in sys.path: + sys.path.insert(0, projektpfad) + from editor.ki import projekt_kontext as pk + cls.pk = pk + + def setUp(self): + """Legt einen kleinen Beispiel-Projektordner an.""" + self.wurzel = tempfile.mkdtemp(prefix="projekt_test_") + os.makedirs(os.path.join(self.wurzel, "core")) + os.makedirs(os.path.join(self.wurzel, "__pycache__")) + self._schreibe("main.py", "import core\n\ndef start(pfad):\n return pfad\n") + self._schreibe("core/params.py", + "class Params:\n" + " def lade(self, name):\n" + " return name\n") + self._schreibe("README.md", "# Beispielprojekt\n") + self._schreibe("__pycache__/main.cpython-311.pyc", "binaermuell") + self._schreibe("bild.png", "kein text") + + def tearDown(self): + shutil.rmtree(self.wurzel, ignore_errors=True) + + def _schreibe(self, rel, inhalt): + pfad = os.path.join(self.wurzel, rel) + with open(pfad, "w", encoding="utf-8") as f: + f.write(inhalt) + + # ── Dateien sammeln ─────────────────────────────────────────────────── + + def test_sammelt_nur_textdateien(self): + """Nur unterstΓΌtzte Textformate landen in der Liste.""" + dateien = self.pk.dateien_sammeln(self.wurzel) + self.assertIn("main.py", dateien) + self.assertIn("core/params.py", dateien) + self.assertIn("README.md", dateien) + self.assertNotIn("bild.png", dateien) + + def test_ignoriert_pycache(self): + """__pycache__ und andere MΓΌll-Ordner werden ΓΌbersprungen.""" + dateien = self.pk.dateien_sammeln(self.wurzel) + self.assertFalse([d for d in dateien if "__pycache__" in d]) + + def test_max_dateien_wird_beachtet(self): + """Die Obergrenze begrenzt die Ergebnisliste.""" + self.assertEqual(len(self.pk.dateien_sammeln(self.wurzel, max_dateien=2)), 2) + + def test_leerer_ordner_gibt_leere_liste(self): + """Ein nicht existierender Ordner fΓΌhrt nicht zu einer Exception.""" + self.assertEqual(self.pk.dateien_sammeln("/gibt/es/nicht"), []) + + # ── Pfad-Absicherung ────────────────────────────────────────────────── + + def test_sichere_pfad_erlaubt_innen(self): + """Pfade innerhalb des Projektordners werden aufgelΓΆst.""" + ziel = self.pk.sichere_pfad(self.wurzel, "core/params.py") + self.assertTrue(ziel.endswith(os.path.join("core", "params.py"))) + + def test_sichere_pfad_blockt_ausbruch(self): + """'..' darf nicht aus dem Projektordner herausfΓΌhren.""" + with self.assertRaises(ValueError): + self.pk.sichere_pfad(self.wurzel, "../../etc/passwd") + + def test_sichere_pfad_blockt_absolut(self): + """Ein absoluter Fremdpfad wird ebenfalls abgewiesen.""" + with self.assertRaises(ValueError): + self.pk.sichere_pfad(self.wurzel, "/etc/passwd") + + def test_sichere_pfad_ohne_wurzel(self): + """Ohne gewΓ€hlten Projektordner gibt es keinen Zugriff.""" + with self.assertRaises(ValueError): + self.pk.sichere_pfad("", "main.py") + + # ── Projektkarte ────────────────────────────────────────────────────── + + def test_projektkarte_enthaelt_baum_und_struktur(self): + """Die Karte zeigt Dateien und die Funktionen/Klassen darin.""" + karte = self.pk.erstelle_projektkarte(self.wurzel) + self.assertIn("main.py", karte) + self.assertIn("core/", karte) + self.assertIn("Funktion start(pfad)", karte) + self.assertIn("Klasse Params", karte) + + def test_projektkarte_ohne_quelltext(self): + """Die Karte transportiert bewusst keinen Quelltext.""" + karte = self.pk.erstelle_projektkarte(self.wurzel) + self.assertNotIn("return pfad", karte) + + def test_projektkarte_haelt_zeichenlimit(self): + """Das Zeichenlimit wird nicht ΓΌberschritten.""" + karte = self.pk.erstelle_projektkarte(self.wurzel, max_zeichen=200) + self.assertLessEqual(len(karte), 400) # Kopf + Abbruchhinweis + + def test_projektkarte_leer_bei_falschem_pfad(self): + """Kein Ordner β†’ leere Karte statt Exception.""" + self.assertEqual(self.pk.erstelle_projektkarte("/gibt/es/nicht"), "") + + # ── Lesen und Suchen ────────────────────────────────────────────────── + + def test_datei_lesen_mit_zeilennummern(self): + """Gelesene Dateien bekommen Zeilennummern fΓΌr genaue Verweise.""" + inhalt = self.pk.datei_lesen(self.wurzel, "main.py") + self.assertIn("import core", inhalt) + self.assertIn(" 1: ", inhalt) + + def test_datei_lesen_blockt_ausbruch(self): + """Auch beim Lesen greift die Pfad-Absicherung.""" + with self.assertRaises(ValueError): + self.pk.datei_lesen(self.wurzel, "../geheim.txt") + + def test_datei_lesen_meldet_fehlende_datei(self): + with self.assertRaises(ValueError): + self.pk.datei_lesen(self.wurzel, "gibtsnicht.py") + + def test_datei_lesen_kuerzt(self): + """Zu lange Dateien werden gekΓΌrzt und das wird vermerkt.""" + self._schreibe("lang.py", "# zeile\n" * 500) + inhalt = self.pk.datei_lesen(self.wurzel, "lang.py", max_zeichen=100) + self.assertIn("gekΓΌrzt", inhalt) + + def test_projekt_suchen_findet_treffer(self): + """Die Suche liefert Pfad, Zeilennummer und Inhalt.""" + treffer = self.pk.projekt_suchen(self.wurzel, "def lade") + self.assertIn("core/params.py:2:", treffer) + + def test_projekt_suchen_ohne_treffer(self): + self.assertIn("Keine Treffer", + self.pk.projekt_suchen(self.wurzel, "zzz_gibt_es_nicht")) + + def test_projekt_suchen_leeres_muster(self): + with self.assertRaises(ValueError): + self.pk.projekt_suchen(self.wurzel, " ") + + def test_karte_erkennt_geaenderte_datei(self): + """Der Sitemap-Puffer darf keine veralteten Strukturen liefern.""" + self.assertIn("Funktion start(pfad)", + self.pk.erstelle_projektkarte(self.wurzel)) + self._schreibe("main.py", "def voellig_neu(a, b):\n return a\n") + karte = self.pk.erstelle_projektkarte(self.wurzel) + self.assertIn("Funktion voellig_neu(a, b)", karte) + self.assertNotIn("Funktion start(pfad)", karte) + + def test_statistik(self): + """Statistik liefert Dateizahl und Token-SchΓ€tzung.""" + anzahl, tokens = self.pk.projekt_statistik(self.wurzel) + self.assertEqual(anzahl, 3) + self.assertGreater(tokens, 0) + + +class TestProjektNachladen(unittest.TestCase): + """Tests fΓΌr projekt_nachladen.py – Dateien auf Zuruf der KI.""" + + @classmethod + def setUpClass(cls): + projektpfad = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..") + ) + if projektpfad not in sys.path: + sys.path.insert(0, projektpfad) + from editor.ki import projekt_nachladen as pn + cls.pn = pn + + def setUp(self): + self.wurzel = tempfile.mkdtemp(prefix="nachlade_test_") + with open(os.path.join(self.wurzel, "main.py"), "w", encoding="utf-8") as f: + f.write("def start():\n return 1\n") + + def tearDown(self): + shutil.rmtree(self.wurzel, ignore_errors=True) + + def test_erkennt_dateianforderung(self): + gefunden = self.pn.finde_anforderungen("#DATEI: core/params.py") + self.assertEqual(gefunden, [("datei", "core/params.py")]) + + def test_erkennt_suchanforderung(self): + gefunden = self.pn.finde_anforderungen("Ich schaue nach.\n#SUCHE: Placement") + self.assertEqual(gefunden, [("suche", "Placement")]) + + def test_ignoriert_marker_im_codeblock(self): + """Ein Kommentar im generierten Code ist keine Anforderung.""" + antwort = "```python\n#DATEI: nicht_anfordern.py\nprint(1)\n```" + self.assertEqual(self.pn.finde_anforderungen(antwort), []) + + def test_normale_antwort_ohne_anforderung(self): + self.assertEqual(self.pn.finde_anforderungen("Der Code legt eine Box an."), []) + + def test_entfernt_duplikate_und_begrenzt(self): + antwort = "\n".join(["#DATEI: a.py"] * 3 + [f"#DATEI: b{i}.py" for i in range(5)]) + gefunden = self.pn.finde_anforderungen(antwort) + self.assertLessEqual(len(gefunden), self.pn.MAX_ANFORDERUNGEN) + self.assertEqual(len(gefunden), len(set(gefunden))) + + def test_nachlade_block_enthaelt_datei(self): + block = self.pn.baue_nachlade_block(self.wurzel, [("datei", "main.py")]) + self.assertIn("main.py", block) + self.assertIn("def start():", block) + + def test_nachlade_block_meldet_fehler_statt_absturz(self): + """Ein ungΓΌltiger Pfad erzeugt einen Hinweis, keine Exception.""" + block = self.pn.baue_nachlade_block(self.wurzel, [("datei", "../weg.py")]) + self.assertIn("nicht verfΓΌgbar", block) + + # ── Ablauf mit Fake-Editor (ohne Qt und ohne FreeCAD) ───────────────── + + def _fake_controller(self): + """Minimaler Editor-Ersatz mit allem, was das Nachladen anfasst.""" + testfall = self + + class Attrappe: + def __getattr__(self, name): + return lambda *a, **k: None + + class Streaming: + def worker_mit_verlauf(self, source, model, verlauf, temp): + testfall.gestartet.append(len(verlauf)) + + class Feld: + def currentText(self): + return "Anthropic" + + def value(self): + return 0.2 + + class Controller: + _projekt_nachlade_erlaubt = True + _src_box = _model_box = _temp_box = Feld() + _ki_area = _chunk_buffer = _btn_ersetzen = Attrappe() + _flush_timer = _status_timer = Attrappe() + _streaming = Streaming() + + def __init__(self): + self._chat_verlauf = [{"role": "user", "content": "Frage"}] + + def _ki_lauf_ui(self, laeuft): + pass + + def _set_status(self, text): + testfall.status.append(text) + + self.gestartet = [] + self.status = [] + return Controller() + + def _mit_projektordner(self): + """Ersetzt core.params vorΓΌbergehend durch einen Stub.""" + import types + self._params_vorher = sys.modules.get("core.params") + stub = types.ModuleType("core.params") + stub.lade_projektordner = lambda: self.wurzel + sys.modules["core.params"] = stub + self.addCleanup(self._params_zurueck) + + def _params_zurueck(self): + if self._params_vorher is None: + sys.modules.pop("core.params", None) + else: + sys.modules["core.params"] = self._params_vorher + + def test_ablauf_haengt_datei_an_verlauf(self): + """Nach einer Anforderung stehen Antwort und Dateiinhalt im Verlauf.""" + self._mit_projektordner() + c = self._fake_controller() + self.assertTrue(self.pn.nachladen_falls_angefordert(c, "#DATEI: main.py")) + rollen = [m["role"] for m in c._chat_verlauf] + self.assertEqual(rollen, ["user", "assistant", "user"]) + self.assertIn("def start():", c._chat_verlauf[-1]["content"]) + + def test_ablauf_stoppt_nach_max_runden(self): + """Nach MAX_RUNDEN wird nicht endlos weiter nachgeladen.""" + self._mit_projektordner() + c = self._fake_controller() + for _ in range(self.pn.MAX_RUNDEN): + self.assertTrue(self.pn.nachladen_falls_angefordert(c, "#DATEI: main.py")) + self.assertFalse(self.pn.nachladen_falls_angefordert(c, "#DATEI: main.py")) + self.assertTrue(any("gestoppt" in s for s in self.status)) + + def test_ablauf_nur_wenn_erlaubt(self): + """Ohne angebotenes Protokoll wird nichts nachgeladen.""" + self._mit_projektordner() + c = self._fake_controller() + c._projekt_nachlade_erlaubt = False + self.assertFalse(self.pn.nachladen_falls_angefordert(c, "#DATEI: main.py")) + self.assertEqual(self.gestartet, []) + + # ══════════════════════════════════════════════════════════════════════════════ # πŸ› FEHLER-ZONE 7: Absichtlich falsch – fΓΌr den Editor-Fehler-Tab # Syntax-Checker-Test einschalten β†’ Klasse einkommentieren. @@ -644,6 +936,8 @@ def zeige_fehler_demo(): TestPanelPlatzierung, TestPresets, TestNlGenerator, + TestProjektKontext, + TestProjektNachladen, ]: suite.addTests(loader.loadTestsFromTestCase(klasse))