diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bdfb286..1943438 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,7 +33,34 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -r requirements.txt pytest + python -m pip install -r requirements.txt ".[test]" + + - name: Diagnose QtWidgets before system GUI libraries + run: | + python - <<'PY' + import traceback + + try: + from PySide6.QtWidgets import QMessageBox + except Exception: + print("QtWidgets import failed before installing system GUI libraries:", flush=True) + traceback.print_exc() + else: + print(f"QtWidgets imported before system GUI libraries: {QMessageBox.__name__}") + PY + + - name: Install Linux Qt runtime libraries + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libgl1 libxkbcommon-x11-0 libdbus-1-3 + + - name: Verify QtWidgets import + run: | + python - <<'PY' + from PySide6.QtWidgets import QMessageBox + + print(f"QtWidgets import OK: {QMessageBox.__name__}") + PY - name: Compile Python sources run: python -m compileall -q main.py manage_translations.py translator.py src diff --git a/CHANGELOG.md b/CHANGELOG.md index a97fc3f..369f1c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ Alle wesentlichen Änderungen an diesem Projekt werden hier dokumentiert. Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). -## [Unreleased — 2026-10-06] +## [Unreleased — 2026-10-12] ### Fixed - GUI workers (`QThread`) are now kept alive until `finished` (`src/gui/worker_utils.py`); a running extraction is never replaced, queued files are no longer dropped, and `closeEvent` stops and waits for all workers before saving. @@ -12,9 +12,17 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - RAG search uses relevance scores (higher = better) instead of raw distances for confidence and thresholds. - Chat always receives the current project's document manager (no unfiltered queries over the shared collection) and a disabled RAG engine is propagated as `None`. - New projects inherit provider/model/URL from the saved app config; the LLM client is initialised at startup; projects are opened by ID, the current project is saved first, and project directories get a unique suffix. +- Ollama API keys use allowlisted native operating-system credential services, with an explicit user-triggered migration for legacy plaintext keys and a non-persisted environment override. +- The local ChromaDB client disables anonymized usage telemetry explicitly. +- Authenticated Ollama setup failures no longer retry without headers; provider exceptions and worker messages omit raw URLs, userinfo, headers, and secret-bearing exception text. +- Workspace export now shows a content disclosure and requires confirmation before writing; the disclosure states that reports, chat, and excerpts are not anonymized. +- Privacy and companion docs now distinguish remote Ollama transfer from local processing and describe that imported PWA workspaces remain session-only while review notes and UI locale persist locally. - Supported file types come from one shared set (adds `.pptx`, `.html`, `.htm`; drops unsupported `.odt`, `.ods`); HTML is converted to plain text. - Extractor: Excel `0`/`False` cells kept, RTF `\uN` fallback skipping and multi-byte code pages fixed, BOM/cp1252 detection for plain text, PPTX slides sorted numerically, PDF/Excel/MSG handles closed reliably. - Ollama availability is re-checked lazily instead of being cached forever after a failed start-up check. +- Package metadata now uses the AGPL-3.0-only SPDX expression and setuptools 77+; the explicit local dependency bootstrap lives in `setup_environment.py`, while `setup.py` is a side-effect-free setuptools entry point. +- Source archives include the `src*` runtime, selected public assets/docs/tests, and license files, while excluding local workspace, profile, output, and vector-store data. +- Desktop and Store documentation now distinguish the primarily German desktop UI from the six-language Web/PWA Companion and describe confirmed workspace exports and remote-provider transfer accurately. - YAML front matter quotes title/author, GUI PDF export has a timeout, error message and temp-file cleanup, chat messages render as plain text, translator hint matching uses whole words, and the Web Companion stores review notes per project ID (new optional `workspace.id` in the export). ## [Unreleased — 2026-09-22] diff --git a/EXPORTFORMAT.md b/EXPORTFORMAT.md index 5525723..fc4aab8 100644 --- a/EXPORTFORMAT.md +++ b/EXPORTFORMAT.md @@ -5,14 +5,19 @@ Stand: 2026-06-05 ## Zweck `notespacellm-workspace-v1.json` ist der Austauschvertrag zwischen der -Desktop-App und Web-/PWA-/Mobile-Companions. Das Format ermöglicht mobile -Recherche- und Review-Workflows, ohne lokale Rohdatenordner, Vektordatenbanken -oder API-Schlüssel zu kopieren. +Desktop-App und Web-/PWA-/Mobile-Companions. Der Export kann den vollständigen +Bericht und Chatverlauf sowie ausgewählte abgeschlossene Dokumentauszüge +enthalten. Er wird nicht anonymisiert. Lokale Rohdokumente, Vektordatenbanken +und gespeicherte API-Schlüsselfelder werden nicht kopiert. Frei verfasste +Bericht- und Chattexte können dennoch Geheimnisse enthalten und sollten vor dem +Weitergeben geprüft werden. Der erste Consumer liegt unter `web_companion/`: ein read-only PWA-Reader, der Workspace-JSON lokal im Browser importiert, Bericht und Dokumentmetadaten -anzeigt, den zuletzt geladenen Workspace offline wiederherstellt und eigene -Review-Notizen als Markdown exportiert. +anzeigt und eigene Review-Notizen als Markdown exportiert. Der importierte +Workspace bleibt nur in der aktuellen Sitzung; zum erneuten Öffnen muss die +Datei erneut importiert werden. Review-Notizen und UI-Sprache werden lokal +gespeichert. ## Implementierungsstatus @@ -26,15 +31,22 @@ Review-Notizen als Markdown exportiert. ## Datenschutzregeln -- API-Schlüssel werden nie exportiert. +- Gespeicherte API-Schlüsselfelder werden nicht exportiert. Frei verfasste + Bericht- und Chattexte werden nicht auf Geheimnisse geprüft und sollten vor + dem Weitergeben überprüft werden. - `chroma_db/`, lokale Datenbanken und Cache-Verzeichnisse werden nie exportiert. -- Rohdokumente werden standardmäßig nicht eingebettet. -- Dokumentauszüge werden nur exportiert, wenn sie bereits für Bericht, Chat, - Prompt oder Review ausgewählt wurden. -- Externe Provider werden nur als Typ und Konfigurationshinweis dokumentiert, - nicht mit geheimen Werten. +- Vollständige Rohdokumente werden nicht eingebettet; ausgewählte abgeschlossene + Sub-Query-Ergebnisse erscheinen als auf 2.000 Zeichen begrenzte Auszüge. +- Auszüge erscheinen nur für ausgewählte Dokumente und nur, wenn + `include_subquery_excerpts` aktiviert ist. +- Der aktuelle vollständige Bericht und Chatnachrichten von Nutzern und Assistenten + können enthalten sein; der Export ist nicht anonymisiert. +- Externe Provider werden nur als Typ und lokale/entfernte/unklare Einordnung + dokumentiert. Die konfigurierte URL und geheime Werte werden nicht exportiert. - Lokale absolute Pfade werden auf redigierte Hinweise oder relative `path_hint`-Werte reduziert. +- Vor dem Schreiben zeigt die Desktop-App die enthaltenen Bereiche an und fragt + ausdrücklich nach Bestätigung. ## Minimales Schema diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..a0034f1 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,34 @@ +include README.md README_de.md +include LICENSE NOTICE THIRD_PARTY_LICENSES.md THIRD_PARTY_LICENSES.txt +include CHANGELOG.md ARCHITECTURE.txt CODE_OF_CONDUCT.md CONTRIBUTING.md +include EXPORTFORMAT.md PRIVACY_POLICY.md SECURITY.md STORE_LISTING.md SUPPORT.md +include WINDOWS_STORE_PREP.md llms.txt +include pyproject.toml requirements.txt setup.py setup_environment.py MANIFEST.in +include main.py notespacellm_launcher.py manage_translations.py translator.py +include run.bat start.bat build_exe.bat NoteSpaceLLM.ico store_package.json +include locales/translations.json +include scripts/check_store_readiness.py +recursive-include .github/workflows *.yml *.yaml +graft src +graft assets +graft tests +graft web_companion +global-exclude __pycache__ *.py[cod] *.pyo *.db *.sqlite *.sqlite3 *.log +global-exclude .DS_Store Thumbs.db desktop.ini +exclude .env .env.* credentials.json client_secret*.json token.json +prune .git +prune .venv +prune venv +prune build +prune dist +prune data +prune output +prune profiles +prune workflows +prune storage +prune chroma_db +prune releases +prune .pytest_cache +prune .pytest_temp +prune .ruff_cache +prune web_companion/node_modules diff --git a/MARKETING-LOG.txt b/MARKETING-LOG.txt index 9d50087..4d9620d 100644 --- a/MARKETING-LOG.txt +++ b/MARKETING-LOG.txt @@ -5,6 +5,16 @@ Kanonisches Repo: https://github.com/file-bricks/NoteSpaceLLM Organisations-Kontext: file-bricks / open-bricks Distributionsmodus: GitHub Source + PySide6 Desktop GUI + Web/PWA Companion +Geltungsbereich aktualisiert: 2026-10-12 +Die folgenden Einträge dokumentieren frühere Positionierungen und Annahmen vom +22.09.2026. Sie sind keine aktuellen, verifizierten Produktgarantien oder +vollständigen Sicherheits-, Lizenz- oder Rechtsprüfungen. Insbesondere die +früheren INV-LOCAL-/Zero-Egress-/Offline-/Parserhärtungs-/SBOM-/Lizenzkompatibilitäts- +und § 521-/SLA-Aussagen sind überholt und nicht als heutige Zusicherung zu lesen. +Aktuelle, begrenzte Produkt- und Datenflussbeschreibungen stehen in README.md, +README_de.md, SECURITY.md, PRIVACY_POLICY.md, NOTICE und THIRD_PARTY_LICENSES.*. +Die Datumsangaben der historischen Einträge bleiben erhalten. + =============================================================================== 1. POSITIONIERUNG & MARKTKONTEXT =============================================================================== diff --git a/NOTICE b/NOTICE index f4ff105..af91769 100644 --- a/NOTICE +++ b/NOTICE @@ -2,7 +2,7 @@ NoteSpaceLLM ============= Copyright (c) 2026 Lukas Geiger (c/o file-bricks / open-bricks) -Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). +Licensed under the GNU Affero General Public License v3.0 only (AGPL-3.0-only). See LICENSE for the full license text. This NOTICE file is provided for informational and attribution purposes @@ -13,44 +13,31 @@ source code and in substantial portions of the software. 1. Purpose & Architecture -------------------------------------------------------------------- -NoteSpaceLLM is an unprivileged, local-first PySide6 desktop application -and offline research companion designed as a private alternative to Google -NotebookLM. It enables document analysis, multi-document synthesis, RAG-assisted -inquiries, and structured report generation across PDF, Word (DOCX), Markdown, -plain text, spreadsheets (XLSX), and email archives (EML, MSG). A lightweight -Web/PWA Companion provides zero-server mobile review of exported workspaces. +NoteSpaceLLM is a local-first PySide6 desktop application for document analysis, +research, and report generation across PDF, Word (DOCX), Markdown, plain text, +spreadsheets (XLSX), and email archives (EML, MSG). Project files and the +ChromaDB index are stored locally by default. When a remote model or embedding +endpoint is configured, the text and prompts used for that operation are sent +to the selected endpoint. A lightweight Web/PWA Companion opens exported +workspaces in a browser. -The software is engineered with the following explicit invariants: - * Local-First Privacy Boundary (INV-LOCAL-01): Documents, extracted texts, - ChromaDB vector stores, and SQLite workspace indexes remain strictly on the - local machine and are never transmitted to external cloud systems without - explicit user configuration. - * Unprivileged User-Space Execution (INV-LOCAL-02): RunAsInvoker non-elevation; - both PySide6 desktop GUI and CLI run with regular user privileges without - requiring administrator or elevated operating system rights. - * Zero Unsolicited Egress (INV-LOCAL-03): Core document parsing, text chunking, - and local vector retrieval perform zero unsolicited network calls; optional - cloud LLM integrations (OpenAI, Anthropic) activate only upon explicit user - selection and key provisioning. - * Offline-First Local Model Execution (INV-LOCAL-04): Native integration with - local Ollama instances provides private LLM synthesis without requiring an - active internet connection. - * Hardened Parser Security (INV-LOCAL-05): Dependency floors for document - parsers (openpyxl>=3.1.3, PyMuPDF>=1.24.10, python-docx>=1.1.0) guard - against XML entity expansion (CVE-2024-34064) and archive bombs. - * Portable Workspace Serialization (INV-LOCAL-06): Standardized JSON workspace - format (`notespacellm-workspace-v1.json`) allows safe, schema-validated - interchange with external tools and mobile review companions. - * PWA Offline Isolation (INV-LOCAL-07): The Web Companion operates 100% - client-side via Service Worker caching with zero telemetry or tracking. - * AGPL-3.0 & LGPL-3.0 Boundary Purity (INV-LOCAL-08): Full compliance with - PyMuPDF AGPL-3.0 copyleft and PySide6 LGPL-3.0 dynamic linking isolation. - * Version Freeze Discipline (INV-LOCAL-09): Strict compliance with policy - T-20260920-167562623; hygiene and marketing passes never modify the - application version outside authorized release ceremonies. - * Statutory Disclaimer & SLA (INV-LOCAL-10): Unpaid open-source software under - §§ 516 ff. BGB with liability governed by § 521 BGB; vulnerability reports - are subject to a 48-hour acknowledgment and 5-day triage SLA. +The following are selected implementation notes for this source snapshot, not a +security certification, complete network audit, complete SBOM, or legal +compliance assessment: + * The app disables ChromaDB's anonymized telemetry for its local client and + does not implement its own telemetry. This does not characterize behavior + of the operating system, browser, extensions, or configured providers. + * Saved credential fields are excluded from workspace exports, but user-authored + reports and chats may contain secrets and should be checked before sharing. + * A remote Ollama endpoint receives document text used for embeddings and + prompts/context for responses. An API key is sent to the configured URL in + a Bearer header; HTTP does not encrypt content or credentials. + * The PWA service worker caches application shell assets. Imported workspace + content is kept in the current page session and is not cached as workspace + data by the app. + * Declared dependency version constraints and selected license notes are + listed in THIRD_PARTY_LICENSES.md and THIRD_PARTY_LICENSES.txt. They are + not a resolved dependency inventory, security audit, or compatibility review. -------------------------------------------------------------------- 2. Organizational & Umbrella Context @@ -67,7 +54,7 @@ the open-bricks software federation: -------------------------------------------------------------------- NoteSpaceLLM utilizes open-source components: - * PySide6 / shiboken6 (The Qt Company) - LGPL-3.0-only + * PySide6 / shiboken6 (The Qt Company) - multiple upstream licensing options; see resolved distribution license files * PyMuPDF / fitz (Artifex Software) - AGPL-3.0-or-later * ChromaDB (Chroma) - Apache-2.0 * LangChain ecosystem (Harrison Chase) - MIT @@ -75,5 +62,5 @@ NoteSpaceLLM utilizes open-source components: * openpyxl (Eric Gazoni, Charlie Clark) - MIT * extract-msg (Ian Coleman) - MIT -Detailed licenses, runtime package matrices, and SBOM disclosures are -maintained in THIRD_PARTY_LICENSES.md and THIRD_PARTY_LICENSES.txt. +Selected dependency and license notes are maintained in THIRD_PARTY_LICENSES.md +and THIRD_PARTY_LICENSES.txt; these files are not complete component inventories. diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 182e440..72b89f4 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -8,6 +8,14 @@ Nicht für Git vorgesehen sind insbesondere `data/`, `profiles/`, `workflows/`, Wenn externe oder entfernte LLM-Provider aktiviert werden, können Prompts und ausgewählte Dokumentauszüge an diese Dienste oder Server übertragen werden. Dazu zählen insbesondere OpenAI, Anthropic, Claude Code und Remote-Ollama-Server. Die Verantwortung für die Auswahl geeigneter Provider und den Umgang mit vertraulichen Dokumenten liegt beim Nutzer. +Bei einem entfernten Ollama-Endpunkt werden außerdem Dokumenttexte zur Erstellung von Embeddings sowie Prompts und Kontext für die Antwortgenerierung an die konfigurierte URL gesendet. Ein gespeicherter Ollama-Schlüssel wird als Bearer-Header an diesen Endpunkt übermittelt. HTTP verschlüsselt weder Inhalt noch Zugangsdaten; HTTPS hängt vom Endpunkt und dessen Zertifikat ab. NoteSpaceLLM prüft nicht, welchem Betreiber ein entfernter Endpunkt gehört. + +Die Anwendung deaktiviert die anonymisierte ChromaDB-Telemetrie für ihren lokalen Client und implementiert keine eigene Telemetrie. + +Gespeicherte Ollama-Schlüssel nutzt die App über unterstützte native Zugangsdienste des Betriebssystems. Verfügbarkeit und Schutz hängen von Betriebssystem und dessen Konfiguration ab. `NOTESPACELLM_OLLAMA_API_KEY` überschreibt den gespeicherten Schlüssel, wird aber nicht von der App gespeichert. Vorhandene ältere Klartextschlüssel bleiben bis zu einer ausdrücklich gestarteten Migration in der Konfigurationsdatei und werden vorher nicht gesendet. + +Workspace-Exporte können den vollständigen Bericht, den Chatverlauf und ausgewählte abgeschlossene Dokumentauszüge enthalten. Sie werden nicht anonymisiert. Die PWA hält importierte Workspace-Inhalte nur in der aktuellen Sitzung; lokal gespeichert werden eigene Review-Notizen und die UI-Sprache. + ## English NoteSpaceLLM is designed as a local desktop application. Project files, document indexes, profiles, workflow settings, and exports stay on the local device by default. @@ -15,3 +23,11 @@ NoteSpaceLLM is designed as a local desktop application. Project files, document Paths such as `data/`, `profiles/`, `workflows/`, `output/`, `chroma_db/`, local builds, and personal configuration files are not intended for Git and are excluded via `.gitignore`. When external or remote LLM providers are enabled, prompts and selected document excerpts may be sent to those services or servers. This includes OpenAI, Anthropic, Claude Code, and remote Ollama servers. Users are responsible for choosing suitable providers and handling confidential documents appropriately. + +With a remote Ollama endpoint, document text used to create embeddings and prompts/context sent for answer generation are also sent to the configured URL. A saved Ollama key is sent to that endpoint in a Bearer header. Plain HTTP encrypts neither content nor credentials; HTTPS protection depends on the endpoint and its certificate. NoteSpaceLLM does not verify who operates a remote endpoint. + +The application disables ChromaDB's anonymized telemetry for its local client and does not implement its own telemetry. + +Saved Ollama keys are accessed through supported native operating-system credential services. Availability and protection depend on the operating system and its configuration. `NOTESPACELLM_OLLAMA_API_KEY` overrides a saved key but is not saved by the app. Existing legacy plaintext keys remain in the configuration file until the user explicitly starts migration and are not sent before then. + +Workspace exports can contain the complete report, chat history, and selected completed document excerpts. They are not anonymized. The PWA keeps imported workspace content only in the current session; only the user's review notes and UI language are stored locally. diff --git a/README.md b/README.md index 46670d5..b8f2dad 100644 --- a/README.md +++ b/README.md @@ -5,25 +5,20 @@ [](LICENSE) [](https://www.python.org/) []() -[]() -[]() -[]() -[](NOTICE) -[](THIRD_PARTY_LICENSES.md) [](https://github.com/file-bricks) [](https://github.com/open-bricks) [](llms.txt) [English](README.md) | [Deutsch](README_de.md) -> **Local NotebookLM alternative for private document analysis, RAG-assisted research, and multi-format report generation.** +> **Local-first document notebook for research and report generation with configurable local or remote language models.** > [!NOTE] > **For AI Agents & Automated Workflows:** -> NoteSpaceLLM provides machine-readable architecture and feature specifications in [`llms.txt`](llms.txt). -> Python unit tests (`pytest`) and Web Companion tests (`npm test` in `web_companion/`) run 100% offline without external network dependencies. +> NoteSpaceLLM provides a machine-readable architecture and feature overview in [`llms.txt`](llms.txt). +> The Python and Web Companion test suites are configured to use isolated local data and avoid external provider calls. -NoteSpaceLLM is an offline-first PySide6 desktop app for working with PDFs, +NoteSpaceLLM is a local-first PySide6 desktop app for working with PDFs, Word files, Markdown, mail exports, notes, and research folders. It keeps project data local by default, supports local or remote Ollama, OpenAI, Anthropic and Claude Code workflows, and exports analysis results to Markdown, @@ -32,7 +27,7 @@ reviewing exported workspaces on Android, iOS and browser devices without uploading documents to a server. **Best-fit searches:** NotebookLM alternative, local RAG document analysis, -private document chat, PySide6 research tool, offline AI report generator, +local document chat, PySide6 research tool, local-model report generator, local-first document workflow. ## Quick Start @@ -46,11 +41,15 @@ python main.py On Windows you can also launch via `start.bat`. A local Windows launcher can be built with `build_exe.bat`. +For a guided local bootstrap that installs requirements and creates starter +workflow/profile folders, run `python setup_environment.py` explicitly. Package +metadata and builds use the separate setuptools entry point and do not run it. + Run tests and compile smoke checks with: ```bash pytest -python -m compileall -q main.py manage_translations.py translator.py src +python -m compileall -q main.py manage_translations.py setup_environment.py translator.py src cd web_companion npm test ``` @@ -105,8 +104,8 @@ graph TD - **Claude Code integration**: Structured API responses or hand-off to an interactive Claude Code session - **Multi-format export**: Output to MD, PDF, DOCX, HTML, TXT - **Prompt export**: Save the report context as a Markdown prompt for external LLM workflows -- **Remote Ollama per project**: Configurable server URL and API key per project -- **6-language i18n**: Interface translations for de, en, es, zh, ja, ru +- **Ollama connection profiles**: App-wide provider settings can be reused across projects +- **Language behavior**: The desktop interface is currently primarily German. The Web/PWA Companion has a six-language UI (de, en, es, zh, ja, ru); the report-language setting changes generated content, not the desktop UI. - **Umlaut-safe UI**: German UI strings use native umlauts; the translation scanner avoids English false positives - **Profiles**: Reusable output-format combinations @@ -147,6 +146,18 @@ NoteSpaceLLM can connect to a remote Ollama server, e.g. via [ellmos-stack](http > **Tip:** Ollama has no built-in authentication. For remote access, use a reverse proxy (e.g. Nginx) with API key or Basic Auth. +The Ollama endpoint and model are configured in the app-wide settings or the +selected connection profile, not per project. API keys are stored through the +operating system's credential store when saved in the app. The optional +`NOTESPACELLM_OLLAMA_API_KEY` environment variable overrides the saved key and +is never written by the app. Legacy plaintext keys are not sent until you +explicitly migrate them in settings. + +When the configured Ollama server is remote, it receives document text used for +embeddings and prompts/context sent for generation. The API key is sent to the +configured URL as a Bearer header. Plain HTTP does not encrypt either content or +credentials; HTTPS behavior depends on the endpoint and its certificate. + ### OpenAI ```bash @@ -218,10 +229,10 @@ Use **"Export prompt"** to save the current working context as a Markdown file f Under `web_companion/` is a read-only Companion strand for Android, iOS and browser. It imports exported `notespacellm-workspace-v1.json` files locally in -the browser, shows the report, document metadata and selected excerpts, restores -the last loaded workspace locally for offline starts, supports a DE/EN UI with -browser/workspace locale plus manual language switch, and can export review -notes as Markdown. +the browser, shows the report, document metadata and selected excerpts, and can +export review notes as Markdown. The workspace remains in the current browser +session and must be re-imported after reopening; only review notes and UI locale +are persisted locally. Local browser test: @@ -241,11 +252,12 @@ The Android/iOS PWA test plan is documented in `web_companion/PWA_TESTPLAN.md`. ## Platform Strategy -The desktop app remains the authoritative full version for local documents, RAG -index, LLM providers and confidential work. For Android, iOS and browser a -Web/PWA Companion is planned as a separate strand, exchanging data via -`notespacellm-workspace-v1.json`. The public exchange contract is documented -in `EXPORTFORMAT.md`. +The desktop app is the full application for local documents, the RAG index, and +provider connections. The Android, iOS, and browser Web/PWA Companion is +available for reviewing a user-created `notespacellm-workspace-v1.json` export. +For sensitive material, configure local model and embedding endpoints and +review exports before sharing. The exchange contract is documented in +`EXPORTFORMAT.md`. ### macOS and Linux Source Smokes @@ -259,8 +271,8 @@ Additional source smokes run in GitHub Actions: NoteSpaceLLM/ ├── main.py # Entry point ├── requirements.txt # Dependencies -├── translator.py # i18n system (6 languages) -├── locales/ # Translation files +├── translator.py # Translation-catalog scan/helper; no desktop language switch +├── locales/ # Catalog inputs for the translation helper ├── src/ │ ├── core/ # Core functionality │ │ ├── document_manager.py @@ -313,27 +325,35 @@ and exports locally in `data/`, `profiles/`, `workflows/`, `output/` and When external LLM providers (OpenAI, Anthropic, Claude Code, remote Ollama) are enabled, prompts and selected document excerpts may be transmitted to those -services. For confidential documents use local models and review folder contents -before sharing. +services. A remote Ollama endpoint also receives document text used for +embeddings. For sensitive documents use local model and embedding endpoints +and review exported reports, chat history and selected excerpts before +sharing; workspace exports are not anonymized. ## Tips 1. **Large document sets**: Select only relevant documents first for faster results 2. **Sub-queries**: Use targeted sub-queries for higher-quality analysis -3. **Ollama**: Recommended for privacy and offline use +3. **Ollama**: A local Ollama endpoint supports offline processing. 4. **Workflow ordering**: Steps can be reordered in the workflow panel ## License -AGPL v3 — see [LICENSE](LICENSE) +AGPL-3.0-only — see [LICENSE](LICENSE) + +This project declares PySide6 and PyMuPDF. See the selected upstream license +references in [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md); the project's +own license is AGPL-3.0-only. + +## Related Project -This project uses PySide6 (LGPL) and PyMuPDF (AGPL). +[KnowledgeDigest](https://github.com/file-bricks/KnowledgeDigest) is a separate local knowledge database with SQLite/FTS5 search and optional LLM summaries; it is not integrated with NoteSpaceLLM. --- ## Deutsch -Ein lokaler, datenschutzfreundlicher Ersatz für Google NotebookLM zur Dokumentenanalyse und Berichterstellung. +Eine lokale Dokumenten- und Rechercheanwendung mit konfigurierbaren lokalen oder externen Sprachmodellen. ### Features @@ -346,8 +366,8 @@ Ein lokaler, datenschutzfreundlicher Ersatz für Google NotebookLM zur Dokumente - **Claude Code Integration**: Wahlweise strukturierte API-Antworten oder Übergabe an eine interaktive Claude-Code-Session - **Multi-Format-Export**: Ausgabe in MD, PDF, DOCX, HTML, TXT - **Prompt-Export**: Berichtskontext als Markdown-Prompt für externe LLM-Workflows exportieren -- **Remote-Ollama pro Projekt**: Eigene Base-URL und API-Key für lokale oder entfernte Ollama-Server -- **6-Sprachen-i18n**: Übersetzungen für de, en, es, zh, ja, ru +- **Ollama-Verbindungsprofile**: Appweite Provider-Einstellungen können in mehreren Projekten verwendet werden +- **Sprachoberflächen**: Die Desktop-Oberfläche ist derzeit überwiegend deutsch. Der Web/PWA-Companion bietet UI-Texte in Deutsch, Englisch, Spanisch, Chinesisch, Japanisch und Russisch; die Berichtssprache ändert nicht die Desktop-Oberfläche. - **Umlaut-sichere Oberfläche**: Deutsche UI-Texte verwenden echte Umlaute; der Übersetzungs-Scan vermeidet englische False Positives - **Profile**: Wiederverwendbare Ausgabeformat-Kombinationen @@ -371,7 +391,17 @@ Unter Windows alternativ per `start.bat`. Build: `build_exe.bat` #### Ollama (Remote-Server) -Konfigurierbare Server-URL und API-Key pro Projekt. Kompatibel mit [ellmos-stack](https://github.com/ellmos-ai/ellmos-stack). +Konfigurierbare Server-URL und Modell werden in den appweiten LLM-Einstellungen +oder im ausgewählten Verbindungsprofil verwaltet, nicht pro Projekt. Kompatibel +mit [ellmos-stack](https://github.com/ellmos-ai/ellmos-stack). + +Gespeicherte API-Schlüssel verwendet die App über den nativen Zugangsdienst des +Betriebssystems. `NOTESPACELLM_OLLAMA_API_KEY` überschreibt den gespeicherten +Schlüssel, wird aber nie gespeichert. Alte Klartextschlüssel werden erst nach +einer ausdrücklich gestarteten Migration verwendet. Ein entfernter Ollama-Server +erhält Dokumenttext für Embeddings sowie Prompts und Kontext für die Generierung; +der API-Schlüssel wird als Bearer-Header an die konfigurierte URL gesendet. HTTP +verschlüsselt diese Daten nicht. #### OpenAI / Anthropic (Claude) @@ -395,12 +425,14 @@ export ANTHROPIC_API_KEY="..." Desktop-App als autoritative Vollversion. Web/PWA-Companion für Android, iOS und Browser über `notespacellm-workspace-v1.json`. Details im [Exportformat](EXPORTFORMAT.md). ---- +## Verwandtes Projekt -## Haftung / Liability +[KnowledgeDigest](https://github.com/file-bricks/KnowledgeDigest) ist eine eigenständige lokale Wissensdatenbank mit SQLite/FTS5-Suche und optionalen LLM-Zusammenfassungen; sie ist nicht in NoteSpaceLLM integriert. + +--- -Dieses Projekt ist eine **unentgeltliche Open-Source-Schenkung** im Sinne der §§ 516 ff. BGB. Die Haftung des Urhebers ist gemäß **§ 521 BGB** auf **Vorsatz und grobe Fahrlässigkeit** beschränkt. Ergänzend gelten die Haftungsausschlüsse der GNU Affero General Public License v3.0, insbesondere §§ 15–16. +## Hinweise / Notes -Nutzung auf eigenes Risiko. Keine Wartungszusage, keine Verfügbarkeitsgarantie, keine Gewähr für Fehlerfreiheit oder Eignung für einen bestimmten Zweck. +Die Bedingungen der Projektlizenz stehen in [LICENSE](LICENSE). Es gibt keine Zusage für laufende Wartung oder Verfügbarkeit. -This project is an unpaid open-source donation. Liability is limited to intent and gross negligence (§ 521 German Civil Code). Use at your own risk. No warranty, no maintenance guarantee, no fitness-for-purpose assumed. +See [LICENSE](LICENSE) for the project's license terms. The maintainers do not promise ongoing maintenance or availability. diff --git a/README_de.md b/README_de.md index d32e9fa..bd77ec5 100644 --- a/README_de.md +++ b/README_de.md @@ -5,25 +5,20 @@ [](LICENSE) [](https://www.python.org/) []() -[]() -[]() -[]() -[](NOTICE) -[](THIRD_PARTY_LICENSES.md) [](https://github.com/file-bricks) [](https://github.com/open-bricks) [](llms.txt) [Deutsch](README_de.md) | [English](README.md) -> **Lokale NotebookLM-Alternative für private Dokumentenanalyse, RAG-gestützte Recherche und berichtsbasierte Workflows.** +> **Lokale Dokumenten- und Rechercheanwendung mit konfigurierbaren lokalen oder externen Sprachmodellen.** > [!NOTE] > **Für KI-Agenten & automatisierte Systeme:** > NoteSpaceLLM bietet eine maschinenlesbare Architektur- und Funktionsübersicht in [`llms.txt`](llms.txt). -> Sämtliche Unit-Tests (`pytest`) und Web-Companion-Tests (`npm test` in `web_companion/`) laufen vollständig offline ohne externe Netzwerkabhängigkeiten. +> Die Python- und Web-Companion-Tests sind für isolierte lokale Daten eingerichtet und rufen keine externen Modellanbieter auf. -Analyse lokaler Dokumente (PDF, DOCX, TXT, Markdown, XLSX, EML, MSG) ohne Upload auf fremde Server. Unterstützt lokale und externe Ollama-Server, OpenAI, Anthropic sowie Claude-Code-Workflows. Ergebnisse lassen sich in Markdown, PDF, DOCX, HTML und TXT exportieren. Mit dem Web/PWA-Companion können exportierte Workspaces auch unterwegs auf Smartphone oder Tablet (Android/iOS) eingesehen werden. +Analysiert lokale Dokumente (PDF, DOCX, TXT, Markdown, XLSX, EML, MSG) lokal. Bei ausdrücklich konfigurierten externen LLM-Anbietern werden ausgewählte Texte und Prompts an diese Anbieter übertragen. Unterstützt lokale und externe Ollama-Server, OpenAI, Anthropic sowie Claude-Code-Workflows. Ergebnisse lassen sich in Markdown, PDF, DOCX, HTML und TXT exportieren. Mit dem Web/PWA-Companion können exportierte Workspaces auch unterwegs auf Smartphone oder Tablet (Android/iOS) eingesehen werden. ## Schnellstart @@ -36,11 +31,15 @@ python main.py Unter Windows kann die Anwendung auch über `start.bat` gestartet werden. Ein Windows-Executable lässt sich mit `build_exe.bat` bauen. +Für einen geführten lokalen Bootstrap, der Abhängigkeiten installiert und +Startordner für Workflows/Profile anlegt, kann `python setup_environment.py` +ausdrücklich ausgeführt werden. Paketmetadaten und Builds starten diesen Installer nicht. + Tests und Kompilierungs-Checks ausführen: ```bash pytest -python -m compileall -q main.py manage_translations.py translator.py src +python -m compileall -q main.py manage_translations.py setup_environment.py translator.py src cd web_companion npm test ``` @@ -95,9 +94,9 @@ graph TD - **Claude Code Integration**: Strukturierte Antwort-Prompts oder Übergabe an eine interaktive Claude-Code-Session - **Multi-Format Export**: Ausgabe nach MD, PDF, DOCX, HTML, TXT - **Prompt Export**: Kontext als Markdown-Prompt für externe KI-Tools speichern -- **Remote Ollama pro Projekt**: Konfigurierbare Server-URL und API-Schlüssel je Projekt -- **Mehrsprachigkeit (6 Sprachen)**: Benutzeroberfläche auf Deutsch, Englisch, Spanisch, Chinesisch, Japanisch und Russisch -- **Web/PWA Companion**: Lokale PWA zur Durchsicht exportierter Workspaces auf Mobilgeräten +- **Ollama-Verbindungsprofile**: Appweite Provider-Einstellungen können in mehreren Projekten verwendet werden +- **Desktop-Oberfläche**: Derzeit überwiegend deutsch; die Berichtssprache ändert den Bericht, nicht die Desktop-Oberfläche. +- **Web/PWA Companion**: Oberfläche zur Durchsicht exportierter Workspaces mit Sprachwahl in Deutsch, Englisch, Spanisch, Chinesisch, Japanisch und Russisch ## Installation & Abhängigkeiten @@ -134,16 +133,30 @@ NoteSpaceLLM kann mit entfernten Ollama-Instanzen verbunden werden (z. B. via [e 2. **Ollama URL:** `http://dein-server:11435` 3. **API Key:** Falls der Server durch einen Auth-Proxy geschützt ist +Server-URL und Modell werden in den appweiten LLM-Einstellungen oder im +ausgewählten Verbindungsprofil verwaltet, nicht pro Projekt. Gespeicherte +API-Schlüssel verwendet die App über den nativen Zugangsdienst des +Betriebssystems. `NOTESPACELLM_OLLAMA_API_KEY` überschreibt den gespeicherten +Schlüssel, wird aber nie gespeichert. Alte Klartextschlüssel werden erst nach +einer ausdrücklich gestarteten Migration verwendet. + +Ein entfernter Ollama-Server erhält Dokumenttext für Embeddings sowie Prompts +und Kontext für die Generierung. Der API-Schlüssel wird als Bearer-Header an die +konfigurierte URL gesendet. HTTP verschlüsselt diese Inhalte und Zugangsdaten +nicht; bei HTTPS hängt der Schutz vom Endpunkt und dessen Zertifikat ab. + ## Lizenz & Community -- **Lizenz**: AGPL-3.0 +- **Lizenz**: AGPL-3.0-only - **Verzeichnis**: Teil des `file-bricks` Ökosystems -- **Sicherheit & Datenschutz**: Local-First Architecture — Dokumente verbleiben auf dem lokalen System. +- **Lokale Datenhaltung**: Projektdateien und der Vektorindex werden standardmäßig lokal gespeichert; konfigurierte externe Anbieter erhalten die für eine Anfrage verwendeten Texte und Prompts. ---- +## Verwandtes Projekt + +[KnowledgeDigest](https://github.com/file-bricks/KnowledgeDigest) ist eine eigenständige lokale Wissensdatenbank mit SQLite/FTS5-Suche und optionalen LLM-Zusammenfassungen; sie ist nicht in NoteSpaceLLM integriert. -## Haftung & Gesetzlicher Haftungsausschluss (§ 521 BGB) +--- -Dieses Projekt ist eine **unentgeltliche Open-Source-Schenkung** im Sinne der §§ 516 ff. BGB. Die Haftung des Urhebers ist gemäß **§ 521 BGB** auf **Vorsatz und grobe Fahrlässigkeit** beschränkt. Ergänzend gelten die Haftungsausschlüsse der GNU Affero General Public License v3.0, insbesondere §§ 15–16. +## Hinweise -Nutzung auf eigenes Risiko. Keine Wartungszusage, keine Verfügbarkeitsgarantie, keine Gewähr für Fehlerfreiheit oder Eignung für einen bestimmten Zweck. +Die Bedingungen der Projektlizenz stehen in [LICENSE](LICENSE). Es gibt keine Zusage für laufende Wartung oder Verfügbarkeit. diff --git a/SECURITY.md b/SECURITY.md index 219ff7f..7a21986 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy / Sicherheitsrichtlinie -Stand: 2026-09-18 | Policy: Bilingual (DE/EN) | P-006 Core Level +Stand: 2026-10-12 | Deutsch / English --- @@ -10,16 +10,17 @@ Stand: 2026-09-18 | Policy: Bilingual (DE/EN) | P-006 Core Level | Version | Unterstützt | Anmerkungen | | ------- | ----------- | ----------- | -| 1.0.x | :white_check_mark: | Aktiver Support, reguläre Sicherheits- und Wartungsupdates | +| 1.0.x | :white_check_mark: | Aktuelle Versionsreihe; Sicherheitsmeldungen können über die unten genannten Wege eingereicht werden | | < 1.0.0 | :x: | Vorabversionen werden nicht mehr gepflegt | -### Sicherheitsarchitektur & Datenschutzgarantien +### Sicherheitsarchitektur & Datenflüsse -NoteSpaceLLM ist als **Local-First & Privacy-First Forschungs- und Dokumentenanalysewerkzeug** konzipiert: +NoteSpaceLLM ist ein Local-First-Werkzeug. Der Datenfluss hängt vom ausgewählten Modell- und Embedding-Endpunkt ab: -1. **Lokale Verarbeitung & Zero-Egress**: Die Dokumentenextraktion (PDF, DOCX, XLSX, MSG), das Chunking, die Vektorspeicherung (ChromaDB) und lokale Sprachmodelle (via Ollama) laufen standardmäßig vollständig offline auf dem lokalen Gerät. Es erfolgt keinerlei unaufgeforderte Telemetrie oder externe Übertragung analysierter Dokumente. -2. **Schutz vor bösartigen Dokumenten**: Abhängigkeiten für XML-, Zip- und PDF-Parsing sind mit strengen Mindestversionen gegen Denial-of-Service (z. B. XML Entity Expansion CVE-2024-34064) und Speicherfehler gehärtet. -3. **Sicherer Umgang mit API-Schlüsseln**: Werden optionale Cloud-Modelle (OpenAI, Anthropic) genutzt, verbleiben die Schlüssel im Arbeitsspeicher bzw. im lokalen Keyring. API-Schlüssel werden niemals in Logdateien, getrackten Repositories oder Export-Paketen gespeichert. +1. **Lokaler Betrieb und Remote-Datenfluss**: Dokumentextraktion, Chunking und der ChromaDB-Vektorindex laufen lokal. Wird ein entfernter LLM- oder Embedding-Endpunkt konfiguriert, werden die dafür verwendeten Dokumenttexte, Embeddings, Prompts und Kontext an diesen Endpunkt gesendet. Die Anwendung deaktiviert die anonymisierte ChromaDB-Telemetrie für ihren lokalen Client und implementiert keine eigene Telemetrie. +2. **Parser-Abhängigkeiten**: Für XML-, ZIP- und PDF-Parsing sind Mindestversionen in den Projektmetadaten deklariert. Diese Versionsangaben sind keine Prüfung der gesamten Abhängigkeitskette und keine Garantie gegen schädliche Dateien. +3. **Umgang mit API-Schlüsseln**: Gespeicherte Ollama-Schlüssel werden über unterstützte native Zugangsdienste des Betriebssystems abgerufen; die Verfügbarkeit und der Schutz hängen von OS und Konfiguration ab und sind keine Garantie für Transportsicherheit. `NOTESPACELLM_OLLAMA_API_KEY` überschreibt den gespeicherten Ollama-Schlüssel und wird nicht persistiert. Ältere Klartextschlüssel werden erst nach ausdrücklicher Migration verwendet. Gespeicherte API-Schlüsselfelder und Authorization-Header werden nicht protokolliert; gespeicherte Credential-Felder werden nicht in Workspace-Exports aufgenommen. Frei verfasste Berichte und Chats können dennoch Geheimnisse enthalten und müssen vor dem Weitergeben geprüft werden. +4. **Remote-Transport**: Ollama sendet Dokumenttexte für Embeddings sowie Prompts und Kontext für Antworten an die konfigurierte URL. Ein API-Schlüssel wird als Bearer-Header mitgesendet. HTTP bietet keine Transportverschlüsselung; bei HTTPS hängt der Schutz vom Endpunkt und dessen Zertifikat ab. ### Meldung von Sicherheitslücken @@ -33,15 +34,15 @@ Wenn Sie eine Sicherheitslücke in NoteSpaceLLM entdecken, melden Sie diese bitt **Bitte eröffnen Sie keine öffentlichen Issues für ungepatchte Sicherheitslücken.** -### Reaktionszeit & SLA +### Angestrebte Reaktionszeiten -- **Erstbestätigung**: Innerhalb von **48 Stunden** nach Eingang der Meldung. -- **Triage & Schweregrad-Einstufung**: Innerhalb von **5 Werktagen**. -- **Sicherheits-Patch**: Kritische Schwachstellen werden prioritär behoben und mit einem Patch-Release bereitgestellt. +- **Erstbestätigung**: Ziel sind **48 Stunden** nach Eingang der Meldung. +- **Triage & Schweregrad-Einstufung**: Ziel sind **5 Werktage**. +- Diese Zeiten sind Ziele, keine garantierte SLA oder Zusage für einen Patch-Zeitplan. -### Gesetzlicher Haftungsausschluss (§ 521 BGB) +### Wartung -NoteSpaceLLM wird als unentgeltliche Open-Source-Software bereitgestellt. Gemäß § 521 BGB haftet der Urheber bei unentgeltlicher Überlassung der Software nur für Vorsatz und grobe Fahrlässigkeit. +Für laufende Wartung oder Verfügbarkeit gibt es keine Zusage. --- @@ -51,16 +52,17 @@ NoteSpaceLLM wird als unentgeltliche Open-Source-Software bereitgestellt. Gemä | Version | Supported | Notes | | ------- | ------------------ | ----- | -| 1.0.x | :white_check_mark: | Active support, regular security and maintenance updates | +| 1.0.x | :white_check_mark: | Current release series; security reports may be submitted through the channels below | | < 1.0.0 | :x: | Pre-release versions are no longer maintained | -### Security Architecture & Privacy Invariants +### Security Architecture & Data Flows -NoteSpaceLLM is engineered as a **Local-First & Privacy-First document analysis and research tool**: +NoteSpaceLLM is a local-first tool. Data flow depends on the selected model and embedding endpoints: -1. **Local Processing & Zero-Egress**: Document parsing (PDF, DOCX, XLSX, MSG), text chunking, local vector storage (ChromaDB), and local model execution (via Ollama) occur entirely offline on your local device. There is zero unsolicited telemetry, tracking, or network egress of document contents. -2. **Malicious Document Defense**: Parsers and dependencies are hardened to secure floors to guard against XML Entity Expansion (CVE-2024-34064), archive bombs, and memory corruption vulnerabilities. -3. **Secure API Key Management**: When optional cloud models (OpenAI, Anthropic) are enabled, user API keys reside in process memory or secure OS keyrings. Keys are never logged, exported, or tracked in git repositories. +1. **Local operation and remote data flow**: Document extraction, chunking, and the ChromaDB vector index run locally. If a remote LLM or embedding endpoint is configured, the document text, embeddings, prompts, and context used by that operation are sent to that endpoint. The application disables ChromaDB's anonymized telemetry for its local client and does not implement its own telemetry. +2. **Parser dependencies**: Minimum versions for XML, ZIP, and PDF parsing packages are declared in project metadata. These version declarations are not an audit of the full dependency chain or a guarantee against malicious files. +3. **API Key Handling**: Saved Ollama keys are accessed through supported native operating-system credential services; availability and protection depend on the OS and its configuration and do not guarantee transport security. `NOTESPACELLM_OLLAMA_API_KEY` overrides a saved Ollama key and is not persisted. Legacy plaintext keys are not used until explicitly migrated. Saved API key fields and Authorization headers are not logged; saved credential fields are not included in workspace exports. User-authored reports and chats may still contain secrets and should be reviewed before sharing. +4. **Remote Transport**: Ollama sends document text for embeddings and prompts/context for responses to the configured URL. An API key is sent in a Bearer header. HTTP does not encrypt transport; with HTTPS, protection depends on the endpoint and its certificate. ### Reporting a Vulnerability @@ -73,12 +75,12 @@ If you discover a security vulnerability in NoteSpaceLLM, please report it confi **Please do not open public issues for undisclosed security vulnerabilities.** -### Response Time & SLA +### Target Response Times -- **Initial Confirmation**: Within **48 hours** of receiving the report. -- **Triage & Severity Assessment**: Within **5 business days**. -- **Security Patch**: Critical vulnerabilities receive top priority and will be released in an expedited patch update. +- **Initial confirmation**: The target is within **48 hours** of receiving the report. +- **Triage & severity assessment**: The target is within **5 business days**. +- These are targets, not a guaranteed SLA or a promise of a patch timeline. -### Statutory Disclaimer (§ 521 BGB) +### Maintenance -NoteSpaceLLM is provided as free and open-source software without financial compensation. Under German statutory law (§ 521 BGB), liability in case of gratuitous software provision is strictly limited to intentional misconduct and gross negligence. +There is no promise of ongoing maintenance or availability. diff --git a/STORE_LISTING.md b/STORE_LISTING.md index 22fd181..18d6977 100644 --- a/STORE_LISTING.md +++ b/STORE_LISTING.md @@ -6,18 +6,18 @@ NoteSpaceLLM — Lokaler RAG-Recherche-Arbeitsplatz & Dokument-Analysator ### Kurzbeschreibung -Lokale Dokumenten-Recherche, Vektor-Suche (RAG), automatisierte Berichterstellung und LLM-Integration mit kompromisslosem Datenschutz. +Lokale Dokumenten-Recherche, Vektor-Suche (RAG) und Berichterstellung mit lokalen Modellen oder ausdrücklich konfigurierten externen LLM-Anbietern. ### Ausführliche Beschreibung -NoteSpaceLLM ist die professionelle Desktop-Anwendung für vertrauliche Dokumentenanalyse, Literatur-Recherche und KI-gestützte Berichterstellung. Die Software kombiniert eine leistungsfähige lokale RAG-Engine (Retrieval-Augmented Generation) mit einem flexiblen Multi-Provider-LLM-Backend (Ollama, Claude Code, OpenAI, Anthropic). +NoteSpaceLLM ist eine lokale Desktop-Anwendung für Dokumentenanalyse, Literatur-Recherche und KI-gestützte Berichterstellung. Sie verbindet einen lokal gespeicherten ChromaDB-Vektorindex mit einem konfigurierbaren LLM-Backend. Je nach Einstellung laufen Ollama und Embeddings lokal oder über einen externen Endpunkt. **Hauptfunktionen:** - **Lokale Dokumenten-Extraktion:** Unterstützung für PDF, RTF, PPTX, TXT, MD, CSV, JSON, XML, EML und MSG. -- **Privacy-First RAG-Engine:** Lokale Vektorindizierung mit ChromaDB und automatischem Embedding-Guard. -- **Flexible LLM-Anbindung:** Arbeiten Sie vollständig offline mit lokalen Ollama-Modellen oder binden Sie externe Cloud-Modelle sicher an. -- **Automatisierte Berichte:** Erstellung strukturierter Forschungsberichte, Zusammenfassungen und Quellenanalysen in PDF, HTML, Markdown und YAML. -- **Workspace-Export (PWA-Companion):** Redigierter Daten-Export (`notespacellm-workspace-v1.json`) zur sicheren mobilen Ansicht ohne Übertragung vertraulicher Rohdaten. -- **Multilinguales Interface:** Vollständig in 6 Sprachen verfügbar (Deutsch, Englisch, Spanisch, Chinesisch, Japanisch, Russisch). +- **Lokaler Vektorspeicher:** ChromaDB speichert den Index lokal. Der Embedding-Endpunkt kann lokal oder extern konfiguriert sein. +- **Flexible LLM-Anbindung:** Arbeiten Sie mit lokalen Ollama-Modellen oder konfigurieren Sie ausdrücklich einen externen Anbieter. Dokumenttext und Prompts werden an den ausgewählten entfernten Anbieter gesendet. +- **Automatisierte Berichte:** Erstellung strukturierter Forschungsberichte, Zusammenfassungen und Quellenanalysen in PDF, HTML, Markdown, DOCX und TXT. +- **Workspace-Export (PWA-Companion):** Nach einer Inhaltsbestätigung kann der Export den Bericht, Chatverlauf und ausgewählte abgeschlossene Dokumentauszüge enthalten. Der Export ist nicht anonymisiert. +- **Sprachen:** Die Desktop-Oberfläche ist derzeit überwiegend deutsch. Die Web/PWA Companion bietet eine Oberfläche in Deutsch, Englisch, Spanisch, Chinesisch, Japanisch und Russisch. --- @@ -27,15 +27,15 @@ NoteSpaceLLM ist die professionelle Desktop-Anwendung für vertrauliche Dokument NoteSpaceLLM — Local RAG Research Workspace & Document Analyzer ### Short Description -Local document research, vector search (RAG), automated report generation, and LLM integration with privacy-first architecture. +Local document research, vector search (RAG), and reporting with local models or explicitly configured external LLM providers. ### Full Description -NoteSpaceLLM is a professional desktop application designed for confidential document analysis, literature research, and AI-assisted reporting. It combines a powerful local Retrieval-Augmented Generation (RAG) engine with a flexible multi-provider LLM backend (Ollama, Claude Code, OpenAI, Anthropic). +NoteSpaceLLM is a local desktop application for document analysis, literature research, and AI-assisted reporting. It uses a locally stored ChromaDB vector index with a configurable LLM backend. Depending on the settings, Ollama and embedding requests can run locally or use an external endpoint. **Key Features:** - **Local Document Extraction:** Supports PDF, RTF, PPTX, TXT, MD, CSV, JSON, XML, EML, and MSG. -- **Privacy-First RAG Engine:** Local vector indexing via ChromaDB with automated embedding safeguards. -- **Flexible LLM Backend:** Work completely offline using local Ollama models or connect external cloud providers safely. -- **Automated Reporting:** Generate structured research reports, summaries, and source analyses exported as PDF, HTML, Markdown, or YAML. -- **Workspace Export (PWA Companion):** Redacted workspace export (`notespacellm-workspace-v1.json`) for safe mobile review without leaking raw documents. -- **Multilingual Interface:** Fully localized in 6 languages (German, English, Spanish, Chinese, Japanese, Russian). +- **Local vector storage:** ChromaDB stores the index locally. The embedding endpoint can be local or configured remotely. +- **Flexible LLM backend:** Use local Ollama models or explicitly configure an external provider. Document text and prompts are sent to the selected remote provider. +- **Automated reporting:** Generate structured research reports, summaries, and source analyses exported as PDF, HTML, Markdown, DOCX, or TXT. +- **Workspace export (PWA Companion):** After a content confirmation, an export can contain the report, chat history, and selected completed document excerpts. It is not anonymized. +- **Languages:** The desktop interface is currently primarily German. The Web/PWA Companion provides an interface in German, English, Spanish, Chinese, Japanese, and Russian. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index a152453..dd23742 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -1,50 +1,30 @@ -# NoteSpaceLLM — Level 1 Software Bill of Materials (SBOM) +# NoteSpaceLLM — Selected Dependency and License Notes -**Stand:** 2026-09-22 -**Projektlizenz:** GNU Affero General Public License v3.0 (AGPL-3.0) +**Paketangaben:** Dokumentationsstand 2026-09-22 (historischer Snapshot) +**Scope-Hinweis aktualisiert:** 2026-10-12 +**Projektlizenz:** GNU Affero General Public License v3.0 only (AGPL-3.0-only) **Kanonisches Repository:** https://github.com/file-bricks/NoteSpaceLLM **Organisation:** file-bricks / open-bricks ---- - -## 1. Lizenzanalyse & Copyleft-Architektur - -NoteSpaceLLM ist lizenziert unter der **GNU Affero General Public License v3.0 (AGPL-3.0)**. -Die Softwarearchitektur wahrt strikte Kompatibilität und rechtliche Grenzziehungen: - -1. **PyMuPDF (fitz)**: Lizenziert unter GNU AGPL-3.0-or-later. Die AGPL-3.0-Lizenz von NoteSpaceLLM erfüllt sämtliche Anforderungen der reziproken Copyleft-Klausel von PyMuPDF direkt und vollständig. -2. **PySide6 (Qt for Python)**: Lizenziert unter GNU Lesser General Public License v3 (LGPL-3.0-only). NoteSpaceLLM bindet PySide6 und shiboken6 ausschließlich über dynamische Bindungen (Standard CPython Shared Object / DLL Import) ein, ohne Modifikation des Quellcodes der Qt-Bibliotheken. Dies erfüllt die Anforderungen von LGPL-3.0 § 4 vollumfänglich. -3. **Permissive Komponenten**: LangChain, ChromaDB, python-docx, openpyxl, extract-msg, Pillow und pytesseract stehen unter permissiven Open-Source-Lizenzen (MIT, Apache-2.0, BSD-3-Clause) und sind uneingeschränkt kompatibel. -4. **Keine proprietären Binary Blobs**: Sämtliche Ausführungslogik basiert auf offenen, einsehbaren und lokal lauffähigen Python- und Web-Komponenten. +Diese manuell gepflegte Momentaufnahme fasst ausgewählte deklarierte Abhängigkeiten und Lizenzhinweise zusammen. Sie ist keine generierte SBOM und erfasst weder alle aufgelösten transitiven Abhängigkeiten noch eingebundene, native oder ausgelieferte Dateien. Die Versionsangaben sind Projektvorgaben und keine Liste installierter Versionen. Die Lizenzkennungen dienen der Orientierung; dieses Dokument bewertet weder Lizenzkompatibilität noch Compliance oder konkrete Weitergabepflichten. Maßgeblich sind die jeweils aktuellen Lizenztexte und Paketinformationen. --- -## 2. Invarianten-Matrix (INV-LOCAL-01 bis INV-LOCAL-10) - -| Invariante | Bezeichnung | Technische Durchsetzung | -| :--- | :--- | :--- | -| **INV-LOCAL-01** | Local-First Privacy Boundary | Dokumente, Extrakte, ChromaDB und SQLite verbleiben lokal auf dem Rechner. | -| **INV-LOCAL-02** | Unprivileged User-Space Execution | `RunAsInvoker` Non-Elevation; keine Admin- oder Root-Rechte erforderlich. | -| **INV-LOCAL-03** | Zero Unsolicited Egress | Keine automatische Telemetrie; externe APIs nur nach expliziter Nutzerkonfiguration. | -| **INV-LOCAL-04** | Offline-First Local Model Execution | Vollständige Unterstützung lokaler Ollama-Instanzen ohne Internetverbindung. | -| **INV-LOCAL-05** | Hardened Parser Security | Mindestversions-Floors (openpyxl>=3.1.3, PyMuPDF>=1.24.10, python-docx>=1.1.0). | -| **INV-LOCAL-06** | Portable Workspace Serialization | Strukturierte Spezifikation (`notespacellm-workspace-v1.json`) für Austausch und Review. | -| **INV-LOCAL-07** | PWA Offline Isolation | Web Companion läuft 100% client-seitig via Service Worker ohne Tracker. | -| **INV-LOCAL-08** | AGPL-3.0 & LGPL-3.0 Boundary Purity | Vollständige Einhaltung von Copyleft und dynamischer Qt-Bindung. | -| **INV-LOCAL-09** | Version Freeze Discipline | Strikte Einhaltung von Policy T-20260920-167562623 (kein Versions-Bump bei Pfad A/B). | -| **INV-LOCAL-10** | Statutory Disclaimer & SLA | Haftungsprivilegierung gem. § 521 BGB und verbindliche 48h Response / 5d Triage SLA. | +## 1. Projektlizenz und ausgewählte Lizenzhinweise ---- +Die Projektlizenz ist **GNU Affero General Public License v3.0 only (AGPL-3.0-only)**; siehe [LICENSE](LICENSE). Die folgende Tabelle notiert ausgewählte Lizenzangaben für deklarierte Pakete, ohne eine rechtliche Kompatibilitäts- oder Compliance-Aussage zu treffen. -## 3. Laufzeit-Abhängigkeiten (Runtime SBOM) +PyMuPDF und die Qt-for-Python-Pakete haben eigene Lizenztexte und Bedingungen. Ihre Einbindung oder der Import über Python bewertet hier nicht, welche Pflichten für einen konkreten Build oder eine Weitergabe gelten. Prüfen Sie hierfür die jeweils aktuellen Upstream-Lizenztexte und das tatsächlich ausgelieferte Paket. -| Paket | Deklarierte Version | Lizenz (SPDX) | Verwendung & Zweck | +## 2. Ausgewählte deklarierte Laufzeit-Abhängigkeiten + +| Paket | Deklarierte Version | Lizenzhinweis | Verwendung & Zweck | | :--- | :--- | :--- | :--- | -| `PySide6` | `>=6.5.0` | LGPL-3.0-only | Desktop-GUI, Event-Loop, Widgets, Qt-Framework | -| `shiboken6` | `>=6.5.0` | LGPL-3.0-only | CPython-Bindings-Laufzeit für PySide6 | +| `PySide6` | `>=6.5.0` | Multiple upstream licensing options; see resolved distribution license files. | Desktop-GUI, Event-Loop, Widgets, Qt-Framework | +| `shiboken6` | `>=6.5.0` | Multiple upstream licensing options; see resolved distribution license files. | CPython-Bindings-Laufzeit für PySide6 | | `PyMuPDF` | `>=1.24.10` | AGPL-3.0-or-later | High-Performance PDF-Parsing und Textextraktion | | `python-docx` | `>=1.1.0` | MIT | Word (.docx) Extraktion und Berichtsgenerierung | -| `openpyxl` | `>=3.1.3` | MIT | Excel (.xlsx) Analyse (gehärtet gegen CVE-2024-34064) | +| `openpyxl` | `>=3.1.3` | MIT | Excel (.xlsx) Analyse | | `extract-msg` | `>=0.48.0` | MIT | Outlook E-Mail (.msg) Extraktion | | `langchain` | `>=0.3.0` | MIT | Prompt-Orchestrierung und Document-Pipeline | | `langchain-core` | `>=0.3.0` | MIT | Basis-Schnittstellen für RAG und Chat | @@ -52,10 +32,11 @@ Die Softwarearchitektur wahrt strikte Kompatibilität und rechtliche Grenzziehun | `langchain-ollama` | `>=0.2.0` | MIT | Native lokale Ollama-Anbindung | | `langchain-chroma` | `>=0.1.0` | MIT | Vektorspeicher-Adapter für ChromaDB | | `chromadb` | `>=0.5.4` | Apache-2.0 | Lokale Embedding-Vektordatenbank | +| `keyring` | `>=25.7.0` | MIT | API-Zugriff auf erlaubte native Betriebssystem-Schlüsselspeicher | --- -## 4. Optionale Erweiterungen & Backends +## 3. Ausgewählte optionale Erweiterungen & Backends | Paket | Deklarierte Version | Lizenz (SPDX) | Verwendung & Zweck | | :--- | :--- | :--- | :--- | @@ -68,15 +49,15 @@ Die Softwarearchitektur wahrt strikte Kompatibilität und rechtliche Grenzziehun --- -## 5. Entwicklungs- & Test-Abhängigkeiten +## 4. Ausgewählte Entwicklungs- & Test-Abhängigkeiten | Paket | Deklarierte Version | Lizenz (SPDX) | Verwendung & Zweck | | :--- | :--- | :--- | :--- | -| `pytest` | `>=9.1.1` | MIT | Test-Framework (gehärtet gegen CVE-2025-7117) | +| `pytest` | `>=9.1.1` | MIT | Test-Framework | | `pytest-asyncio` | `>=1.4.0` | Apache-2.0 | Asynchrone Testausführung | | `pytest-timeout` | `>=2.4.0` | MIT | Timeout-Sicherung gegen Test-Hänger | | `ruff` | `>=0.9.0` | MIT / Apache-2.0 | High-Speed Linter und Code-Formatierer | --- -*Vollständige Lizenztexte und URLs sind zusätzlich in `THIRD_PARTY_LICENSES.txt` dokumentiert.* +*Ausgewählte Lizenztexte und Quellenhinweise stehen in `THIRD_PARTY_LICENSES.txt`; aktuelle Upstream-Angaben sind dort maßgeblich.* diff --git a/THIRD_PARTY_LICENSES.txt b/THIRD_PARTY_LICENSES.txt index 71f215c..723638c 100644 --- a/THIRD_PARTY_LICENSES.txt +++ b/THIRD_PARTY_LICENSES.txt @@ -1,39 +1,34 @@ -NoteSpaceLLM — Third-Party Licenses Inventory -============================================= -Stand: 2026-09-18 -Project License: GNU Affero General Public License v3.0 (AGPL-3.0) +NoteSpaceLLM — Selected Third-Party License Notes +================================================= +Package-note snapshot: 2026-09-18 +Scope note updated: 2026-10-12 +Project License: GNU Affero General Public License v3.0 only (AGPL-3.0-only) Repository: https://github.com/file-bricks/NoteSpaceLLM -------------------------------------------------------------------------------- -License Compatibility & Copyleft Analysis +Scope of these notes -------------------------------------------------------------------------------- -NoteSpaceLLM is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). -This license structure ensures compatibility across all runtime components: - -1. PyMuPDF is licensed under GNU AGPL-3.0-or-later. NoteSpaceLLM's AGPL-3.0 license - is directly and fully compliant with PyMuPDF's copyleft requirements. -2. PySide6 (Qt for Python) is licensed under the GNU Lesser General Public License v3 - (LGPL-3.0-only). NoteSpaceLLM utilizes dynamic imports / shared libraries without - modifying Qt source code, satisfying LGPL-3.0 section 4 requirements. -3. LangChain, ChromaDB, python-docx, openpyxl, extract-msg, pypdf, and pypdfium2 - are distributed under permissive licenses (MIT, Apache-2.0, BSD-3-Clause), - which are fully compatible with incorporation into an AGPL-3.0 licensed application. -4. Optional cloud integrations (OpenAI, Anthropic) and packaging tools (PyInstaller - with PyInstaller Special Exception) preserve these licensing boundaries. +The project's license is GNU Affero General Public License v3.0 only (AGPL-3.0-only); +see LICENSE. This file records selected package license identifiers, source URLs, +usage notes, and license text/attribution material. It is a manually maintained +documentation snapshot, not a generated SBOM or a complete inventory of resolved, +transitive, native, bundled, or deployed components. It does not assess license +compatibility, compliance, or distribution obligations. Consult current upstream +package metadata and license texts for the software actually used or distributed. -------------------------------------------------------------------------------- -1. Runtime Dependencies +1. Selected Declared Runtime Dependencies -------------------------------------------------------------------------------- 1.1 PySide6 - License: LGPL-3.0-only / GPL-2.0-only / GPL-3.0-only - SPDX: LGPL-3.0-only + License: Multiple upstream licensing options; see resolved distribution license files. + SPDX: No single identifier for all available options. URL: https://wiki.qt.io/Qt_for_Python Usage: Desktop GUI presentation layer, signals and widgets. 1.2 shiboken6 - License: LGPL-3.0-only - SPDX: LGPL-3.0-only + License: Multiple upstream licensing options; see resolved distribution license files. + SPDX: No single identifier for all available options. URL: https://wiki.qt.io/Qt_for_Python Usage: CPython bindings generator and runtime support for PySide6. @@ -103,8 +98,14 @@ This license structure ensures compatibility across all runtime components: URL: https://github.com/chroma-core/chroma Usage: Local vector database and embedding index. +1.14 keyring + License: MIT License + SPDX: MIT + URL: https://github.com/jaraco/keyring + Usage: API for the explicitly allowlisted native OS credential stores. + -------------------------------------------------------------------------------- -2. Optional Integrations & Backends +2. Selected Optional Integrations & Backends -------------------------------------------------------------------------------- 2.1 pypdfium2 (Alternative PDF Backend) @@ -156,7 +157,7 @@ This license structure ensures compatibility across all runtime components: Usage: HTML to styled PDF rendering engine. -------------------------------------------------------------------------------- -3. Development, Build & Test Dependencies +3. Selected Development, Build & Test Dependencies -------------------------------------------------------------------------------- 3.1 pytest diff --git a/WINDOWS_STORE_PREP.md b/WINDOWS_STORE_PREP.md index e8afba8..526f020 100644 --- a/WINDOWS_STORE_PREP.md +++ b/WINDOWS_STORE_PREP.md @@ -15,6 +15,17 @@ Stand: 2026-07-27 | `scripts/check_store_readiness.py` | Automatisierter Preflight-Auditor | OK | | `tests/test_store_readiness.py` | Pytest-Guard für Store-Compliance | OK | +## Aktueller Belegstand (2026-10-12) + +Die `OK`-Einträge oben belegen nur vorhandene Artefakte und einfache lokale +Formatprüfungen. Sie belegen weder eine Store-Einreichung noch Veröffentlichung +oder Zertifizierung. In den gelesenen Metadaten gibt es eine Versionsabweichung: +`pyproject.toml` und die Laufzeit melden `1.0.0`; das Root-Manifest +`store_package.json` nennt `1.1.0.0`, während +`releases/windowsstore/store_package.json` `1.0.0.0` nennt. Diese Abweichung +bleibt ungeklärt und wurde wegen des Versions-Freeze nicht geändert. Es wurde +keine Store-Einreichung oder Veröffentlichung ausgeführt. + ## Publisher Credentials - **Publisher ID:** `CN=52596601-BAB4-4F3F-B182-E8F3F273B202` - **Publisher Display Name:** `Lukas Geiger` diff --git a/llms.txt b/llms.txt index faf842e..7210e44 100644 --- a/llms.txt +++ b/llms.txt @@ -2,28 +2,28 @@ ## Last-checked: 2026-09-22 -> Local NotebookLM alternative for private document analysis, RAG-assisted research, and multi-format report generation. Verified with 207 passing unit tests (147 Python pytest + 60 Web Companion Node.js tests). +> Local-first document notebook for document research and report generation with configurable local or remote language models. Latest full Windows run (2026-10-12): 200 Python tests passed and 0 were skipped. Separately on 2026-10-12, all 62 Web Companion tests passed. ## Description -NoteSpaceLLM is an offline-first PySide6 desktop application for private document analysis. It keeps all project data local by default, supports Ollama (local and remote), OpenAI, Anthropic, and Claude Code workflows, and exports results to Markdown, PDF, DOCX, HTML, and TXT. A Web/PWA Companion is available for reviewing exported workspaces on Android, iOS, and browser devices without uploading documents to a server. +NoteSpaceLLM is a local-first PySide6 desktop application for document analysis. Project files and the ChromaDB index are stored locally by default. When a remote model or embedding endpoint is configured, the text and prompts used for that operation are sent to the selected endpoint. The app supports Ollama (local and remote), OpenAI, Anthropic, and Claude Code workflows, and exports results to Markdown, PDF, DOCX, HTML, and TXT. A Web/PWA Companion reviews user-created workspace exports on Android, iOS, and browser devices without a document-upload service. ## Type - Desktop application (PySide6, Python) - Web/PWA Companion (static, JavaScript) -- Open source (AGPL-3.0) +- Open source (AGPL-3.0-only) ## Language / Platform - Python 3.10+ -- PySide6 (LGPL) +- PySide6 (multiple upstream licensing options; see resolved distribution license files) - Windows, macOS, Linux ## Key Features -- Offline-first document management: PDF, DOCX, TXT, Markdown, XLSX, EML, MSG -- Local RAG with ChromaDB — no cloud upload required +- Local document management: PDF, DOCX, TXT, Markdown, XLSX, EML, MSG +- ChromaDB stores the vector index locally; the configured embedding endpoint may be remote - Multi-provider LLM support: Ollama (local + remote), OpenAI, Anthropic, Claude Code - Auto-indexing: new documents are processed and indexed immediately on add - Sub-queries: right-click a document for document-specific research @@ -32,7 +32,7 @@ NoteSpaceLLM is an offline-first PySide6 desktop application for private documen - Multi-format export: MD, PDF, DOCX, HTML, TXT - Prompt export for external LLM workflows (Claude Code integration) - Web/PWA Companion for mobile review of exported workspaces -- 6-language i18n infrastructure (de, en, es, zh, ja, ru) +- The Web/PWA Companion has a six-language UI (de, en, es, zh, ja, ru); the desktop UI is currently primarily German, and report language does not select a desktop UI language - PEP 621 pyproject.toml configuration with pytest pythonpath integration - macOS and Linux source smoke tests in GitHub Actions - Community workflows use `actions/stale@v10` and `actions/first-interaction@v3` @@ -40,30 +40,34 @@ NoteSpaceLLM is an offline-first PySide6 desktop application for private documen ## Audience - Privacy-conscious researchers and knowledge workers who need a local NotebookLM alternative -- Developers evaluating offline-first PySide6 desktop apps with ChromaDB and multi-provider LLM support -- Teams running local LLM infrastructure (Ollama, ellmos-stack) wanting document analysis without cloud upload -- Anyone who wants offline AI document analysis, report generation, and mobile review via PWA Companion +- Developers evaluating local-first PySide6 desktop apps with ChromaDB and configurable LLM providers +- Teams using local Ollama endpoints for document analysis without cloud-provider transfer +- Anyone who wants local-model document analysis, report generation, and mobile review via PWA Companion ## Search Phrases ``` -local NotebookLM alternative offline privacy -private document analysis RAG desktop app Python +local NotebookLM alternative document analysis +local-first document analysis with configurable remote providers PySide6 ChromaDB document research tool -offline RAG document analysis desktop app +local RAG document analysis desktop app local LLM document analysis Python NotebookLM alternative open source -offline AI report generator Python +local-model report generator Python local-first document workflow LLM Ollama document chat desktop PySide6 ``` ## Topics -notebooklm-alternative, rag, local-first, pyside6, document-analysis, document-chat, ai-report-generator, privacy-first, chromadb, llm, pwa, python, desktop-app, ai +notebooklm-alternative, rag, local-first, pyside6, document-analysis, document-chat, ai-report-generator, chromadb, llm, pwa, python, desktop-app, ai ## Repository - URL: https://github.com/file-bricks/NoteSpaceLLM - Organization: file-bricks -- License: AGPL-3.0 +- License: AGPL-3.0-only + +## Related Projects + +- [KnowledgeDigest](https://github.com/file-bricks/KnowledgeDigest): separate local knowledge database with SQLite/FTS5 search and optional LLM summaries; no integration with NoteSpaceLLM is documented. diff --git a/pyproject.toml b/pyproject.toml index 92fc46c..024e053 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,14 @@ [build-system] -requires = ["setuptools>=61.0"] +requires = ["setuptools>=77.0.0"] build-backend = "setuptools.build_meta" [project] name = "notespacellm" version = "1.0.0" -description = "Local NotebookLM alternative for private document analysis, RAG-assisted research, and multi-format report generation." +description = "Local-first document notebook and research desktop app with configurable local or remote LLM providers." readme = "README.md" requires-python = ">=3.10" -license = { text = "AGPL-3.0" } +license = "AGPL-3.0-only" license-files = [ "LICENSE", "NOTICE", @@ -25,15 +25,12 @@ keywords = [ "pyside6", "document-analysis", "document-chat", - "privacy-first", "chromadb", "llm", "pwa", "python", - "offline-first", "research-tools", "report-generator", - "zero-egress" ] classifiers = [ "Development Status :: 4 - Beta", @@ -41,7 +38,6 @@ classifiers = [ "Environment :: X11 Applications :: Qt", "Intended Audience :: End Users/Desktop", "Intended Audience :: Science/Research", - "License :: OSI Approved :: GNU Affero General Public License v3 (AGPLv3)", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Operating System :: MacOS", @@ -67,6 +63,7 @@ dependencies = [ "langchain-ollama>=0.2.0", "langchain-chroma>=0.1.0", "chromadb>=0.5.4", + "keyring>=25.7.0", ] [project.optional-dependencies] @@ -86,6 +83,8 @@ test = [ "pytest>=9.1.1", "pytest-asyncio>=1.4.0", "pytest-timeout>=2.4.0", + "build>=1.2", + "setuptools>=77.0.0", ] dev = [ "ruff>=0.9.0", diff --git a/requirements.txt b/requirements.txt index c17f603..8e682e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,9 +5,9 @@ PySide6>=6.5.0 # Document Processing -PyMuPDF>=1.24.10 # PDF reading (hardened memory safety floor) +PyMuPDF>=1.24.10 # Declared minimum for PDF reading; not a security guarantee python-docx>=1.1.0 # Word documents -openpyxl>=3.1.3 # Excel files (CVE-2024-34064 XML entity expansion protection) +openpyxl>=3.1.3 # Declared minimum for Excel files; not a security guarantee extract-msg>=0.48.0 # Outlook .msg files # ========================= @@ -19,7 +19,8 @@ langchain-community>=0.3.0 langchain-ollama>=0.2.0 # Ollama LLM Integration langchain-chroma>=0.1.0 # ChromaDB Vector Store -chromadb>=0.5.4 # Vector Database (hardened security floor) +chromadb>=0.5.4 # Vector Database +keyring>=25.7.0 # Native OS credential-store API (backend allowlist in app) # ========================= # Optional: OCR for image-based PDFs @@ -44,7 +45,7 @@ chromadb>=0.5.4 # Vector Database (hardened security floor) # anthropic>=0.18.0 # For Anthropic API # ========================= -# Development & Testing (Security Floored) +# Development & Testing # ========================= -# pytest>=9.1.1 # CVE-2025-7117 / GHSA-6w46-j5rx-g56g +# pytest>=9.1.1 # Declared test dependency minimum # ruff>=0.9.0 diff --git a/setup.py b/setup.py index 7046e71..0c66fa9 100644 --- a/setup.py +++ b/setup.py @@ -1,90 +1,6 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -NoteSpaceLLM Setup Script -========================= - -Initial setup and dependency installation. -""" - -import subprocess -import sys -from pathlib import Path - - -def main(): - print("=" * 50) - print("NoteSpaceLLM Setup") - print("=" * 50) - print() - - # Check Python version - if sys.version_info < (3, 10): - print(f"ERROR: Python 3.10+ required. You have {sys.version}") - sys.exit(1) - - print(f"[OK] Python {sys.version_info.major}.{sys.version_info.minor}") - - # Install dependencies - print("\nInstalling dependencies...") - requirements_file = Path(__file__).parent / "requirements.txt" - - try: - subprocess.check_call([ - sys.executable, "-m", "pip", "install", "-r", str(requirements_file) - ]) - print("[OK] Dependencies installed") - except subprocess.CalledProcessError: - print("[WARN] Some dependencies may have failed to install") - - # Create directories - print("\nCreating directories...") - base = Path(__file__).parent - directories = ["data", "workflows", "profiles", "output", "assets"] - - for dirname in directories: - (base / dirname).mkdir(exist_ok=True) - print(f" Created: {dirname}/") - - # Create default workflow file - workflows_file = base / "workflows" / "default.json" - if not workflows_file.exists(): - import json - default_workflows = { - "standard": { - "id": "standard", - "name": "Standard-Analyse", - "description": "Umfassende Dokumentenanalyse", - "steps": [ - {"id": "1", "name": "Textextraktion", "order": 1}, - {"id": "2", "name": "Einzelanalysen", "order": 2}, - {"id": "3", "name": "Synthese", "order": 3}, - {"id": "4", "name": "Berichterstellung", "order": 4} - ] - } - } - workflows_file.write_text(json.dumps(default_workflows, indent=2), encoding="utf-8") - print(f" Created: workflows/default.json") - - # Create default profile file - profiles_file = base / "profiles" / "default.json" - if not profiles_file.exists(): - import json - default_profiles = { - "default": {"name": "default", "formats": ["md"], "is_default": True}, - "full": {"name": "full", "formats": ["md", "pdf", "docx"], "is_default": False} - } - profiles_file.write_text(json.dumps(default_profiles, indent=2), encoding="utf-8") - print(f" Created: profiles/default.json") - - print("\n" + "=" * 50) - print("Setup complete!") - print("=" * 50) - print("\nStart the application with:") - print(" python main.py") - print("\nOr on Windows:") - print(" run.bat") +"""Side-effect-free setuptools compatibility entry point for PEP 517 builds.""" +from setuptools import setup if __name__ == "__main__": - main() + setup() diff --git a/setup_environment.py b/setup_environment.py new file mode 100644 index 0000000..133e0b0 --- /dev/null +++ b/setup_environment.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Opt-in local environment bootstrap for NoteSpaceLLM. + +This script installs the repository requirements and creates starter +directories/configuration when a developer explicitly runs it. Package builds +and metadata inspection use the side-effect-free setuptools entry point instead. +""" + +import json +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + print("=" * 50) + print("NoteSpaceLLM Environment Setup") + print("=" * 50) + print() + + if sys.version_info < (3, 10): # noqa: UP036 - keep a friendly explicit-bootstrap error + print(f"ERROR: Python 3.10+ required. You have {sys.version}") + return 1 + + print(f"[OK] Python {sys.version_info.major}.{sys.version_info.minor}") + base = Path(__file__).resolve().parent + requirements_file = base / "requirements.txt" + + print("\nInstalling dependencies...") + try: + subprocess.check_call([ + sys.executable, "-m", "pip", "install", "-r", str(requirements_file) + ]) + print("[OK] Dependencies installed") + except subprocess.CalledProcessError: + print("[ERROR] Dependency installation failed; setup did not complete.") + return 1 + + print("\nCreating directories...") + for dirname in ("data", "workflows", "profiles", "output", "assets"): + (base / dirname).mkdir(exist_ok=True) + print(f" Created: {dirname}/") + + workflows_file = base / "workflows" / "default.json" + if not workflows_file.exists(): + default_workflows = { + "standard": { + "id": "standard", + "name": "Standard-Analyse", + "description": "Umfassende Dokumentenanalyse", + "steps": [ + {"id": "1", "name": "Textextraktion", "order": 1}, + {"id": "2", "name": "Einzelanalysen", "order": 2}, + {"id": "3", "name": "Synthese", "order": 3}, + {"id": "4", "name": "Berichterstellung", "order": 4}, + ], + } + } + workflows_file.write_text( + json.dumps(default_workflows, indent=2, ensure_ascii=False), encoding="utf-8" + ) + print(" Created: workflows/default.json") + + profiles_file = base / "profiles" / "default.json" + if not profiles_file.exists(): + default_profiles = { + "default": {"name": "default", "formats": ["md"], "is_default": True}, + "full": { + "name": "full", + "formats": ["md", "pdf", "docx"], + "is_default": False, + }, + } + profiles_file.write_text( + json.dumps(default_profiles, indent=2, ensure_ascii=False), encoding="utf-8" + ) + print(" Created: profiles/default.json") + + print("\n" + "=" * 50) + print("Setup complete!") + print("=" * 50) + print("\nStart the application with:") + print(" python main.py") + print("\nOr on Windows:") + print(" run.bat") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/core/app_config.py b/src/core/app_config.py index 4a5a6fa..1daa045 100644 --- a/src/core/app_config.py +++ b/src/core/app_config.py @@ -1,23 +1,46 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -App Config - Local application-level settings for NoteSpaceLLM -=============================================================== +App Config - Local application-level settings for NoteSpaceLLM. -Stores global LLM defaults (provider, model, URL, API key) in a local -config file (~/.notespacellm/config.json). API keys are stored here -instead of in project files to prevent accidental sharing. - -Includes a profile system for quick switching between connection setups. +Provider settings and profile metadata remain app-global. API keys live in the +native credential store; legacy plaintext fields are only retained for an +explicit user-requested migration and are never returned to a provider. """ +from __future__ import annotations + +import copy import json +import os +import tempfile +import uuid from pathlib import Path -from typing import Optional, Dict, List +from typing import Dict, List + +from .credential_store import ( + CredentialNotFoundError, + CredentialStore, + CredentialStoreError, +) + -# Config lives outside synced project folders CONFIG_DIR = Path.home() / ".notespacellm" CONFIG_FILE = CONFIG_DIR / "config.json" +API_KEY_ENV_VAR = "NOTESPACELLM_OLLAMA_API_KEY" +_UNSET = object() + + +class AppConfigError(RuntimeError): + """App configuration could not be safely loaded or committed.""" + + +class AppConfigConflictError(AppConfigError): + """The config changed on disk since this instance loaded it.""" + + +class LegacyCredentialMigrationRequired(AppConfigError): + """A legacy key exists but must not be used before explicit migration.""" class AppConfig: @@ -27,54 +50,191 @@ class AppConfig: "llm_provider": "ollama", "llm_model": "llama3", "ollama_base_url": "http://localhost:11434", - "ollama_api_key": "", + "ollama_credential_id": "", "embedding_model": "nomic-embed-text", "claude_code_mode": "api", "active_profile": "", "profiles": {}, } - # Vordefinierte Profile BUILTIN_PROFILES = { "Lokal (Standard)": { "llm_provider": "ollama", "llm_model": "llama3", "ollama_base_url": "http://localhost:11434", - "ollama_api_key": "", "embedding_model": "nomic-embed-text", }, } - def __init__(self): - self._data: dict = dict(self.DEFAULTS) + def __init__(self, credential_store: CredentialStore | None = None): + self._data: dict = copy.deepcopy(self.DEFAULTS) + self._credential_store = credential_store or CredentialStore() + self._legacy_credentials: dict[str, str] = {} + self._loaded_config_bytes: bytes | None = None + self._load_failed = False + self._pending_api_key = _UNSET + self.last_cleanup_error = "" self._load() - def _load(self): - """Load config from disk.""" - if CONFIG_FILE.exists(): + def _load(self) -> None: + """Read settings without touching the OS keyring or migrating secrets.""" + config_path = Path(CONFIG_FILE) + try: + raw = config_path.read_bytes() + except FileNotFoundError: + return + except OSError: + self._load_failed = True + return + + self._loaded_config_bytes = raw + try: + stored = json.loads(raw.decode("utf-8")) + if not isinstance(stored, dict): + raise TypeError("configuration root must be an object") + except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError): + self._load_failed = True + return + + self._data = copy.deepcopy(stored) + for key, default in self.DEFAULTS.items(): + self._data.setdefault(key, copy.deepcopy(default)) + + global_secret = self._data.pop("ollama_api_key", "") + if isinstance(global_secret, str) and global_secret: + self._legacy_credentials["global"] = global_secret + + profiles = self._data.get("profiles") + if not isinstance(profiles, dict): + profiles = {} + self._data["profiles"] = profiles + + for name, profile in list(profiles.items()): + if not isinstance(profile, dict): + profiles[name] = {} + continue + profile = copy.deepcopy(profile) + legacy_secret = profile.pop("ollama_api_key", "") + if isinstance(legacy_secret, str) and legacy_secret: + self._legacy_credentials[f"profile:{name}"] = legacy_secret + profiles[name] = profile + + def _serialized_config(self, candidate: dict, legacy: dict[str, str]) -> bytes: + stored = copy.deepcopy(candidate) + stored.pop("ollama_api_key", None) + profiles = stored.setdefault("profiles", {}) + for profile in profiles.values(): + if isinstance(profile, dict): + profile.pop("ollama_api_key", None) + + if legacy.get("global"): + stored["ollama_api_key"] = legacy["global"] + for scope, secret in legacy.items(): + if not scope.startswith("profile:") or not secret: + continue + name = scope.partition(":")[2] + profile = profiles.get(name) + if isinstance(profile, dict): + profile["ollama_api_key"] = secret + + return json.dumps(stored, indent=2, ensure_ascii=False).encode("utf-8") + + def _write_candidate(self, candidate: dict, legacy: dict[str, str]) -> bytes: + if self._load_failed: + raise AppConfigError( + "The existing config could not be read; it was left unchanged." + ) + + config_path = Path(CONFIG_FILE) + try: + current_bytes = config_path.read_bytes() + except FileNotFoundError: + current_bytes = None + except OSError as exc: + raise AppConfigError("The existing config could not be checked.") from exc + + if current_bytes != self._loaded_config_bytes: + raise AppConfigConflictError( + "The config changed on disk. Reload it before saving to avoid overwriting changes." + ) + + encoded = self._serialized_config(candidate, legacy) + config_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=config_path.parent, + prefix=".config-", + suffix=".tmp", + delete=False, + ) as stream: + temp_path = Path(stream.name) + stream.write(encoded) + stream.flush() + os.fsync(stream.fileno()) + + readback = temp_path.read_bytes() + if readback != encoded: + raise AppConfigError("The temporary config failed its readback check.") + json.loads(readback.decode("utf-8")) + os.replace(temp_path, config_path) + temp_path = None + return encoded + except Exception as exc: + if temp_path is not None: + try: + temp_path.unlink(missing_ok=True) + except OSError: + pass + if isinstance(exc, AppConfigError): + raise + raise AppConfigError("The config could not be committed; the previous file was retained.") from exc + + def _commit(self, candidate: dict, legacy: dict[str, str]) -> None: + encoded = self._write_candidate(candidate, legacy) + self._data = copy.deepcopy(candidate) + self._legacy_credentials = copy.deepcopy(legacy) + self._loaded_config_bytes = encoded + self._load_failed = False + + @staticmethod + def _credential_ids(data: dict) -> set[str]: + ids = set() + global_id = data.get("ollama_credential_id") + if isinstance(global_id, str) and global_id: + ids.add(global_id) + profiles = data.get("profiles", {}) + if isinstance(profiles, dict): + for profile in profiles.values(): + if isinstance(profile, dict): + credential_id = profile.get("credential_id") + if isinstance(credential_id, str) and credential_id: + ids.add(credential_id) + return ids + + def _cleanup_unreferenced(self, old_ids: set[str], candidate: dict) -> None: + self.last_cleanup_error = "" + for credential_id in old_ids - self._credential_ids(candidate): try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - stored = json.load(f) - # Merge with defaults (new keys get default values) - for key, default in self.DEFAULTS.items(): - self._data[key] = stored.get(key, default) - except Exception: - pass - - def save(self): - """Write config to disk.""" - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(self._data, f, indent=2, ensure_ascii=False) - - # --- Properties --- + self._credential_store.delete_secret(credential_id) + except CredentialStoreError as exc: + self.last_cleanup_error = str(exc) + + def _discard_new_secret(self, credential_id: str | None) -> None: + if not credential_id: + return + try: + self._credential_store.delete_secret(credential_id) + except CredentialStoreError: + pass @property def llm_provider(self) -> str: return self._data["llm_provider"] @llm_provider.setter - def llm_provider(self, value: str): + def llm_provider(self, value: str) -> None: self._data["llm_provider"] = value @property @@ -82,7 +242,7 @@ def llm_model(self) -> str: return self._data["llm_model"] @llm_model.setter - def llm_model(self, value: str): + def llm_model(self, value: str) -> None: self._data["llm_model"] = value @property @@ -90,23 +250,24 @@ def ollama_base_url(self) -> str: return self._data["ollama_base_url"] @ollama_base_url.setter - def ollama_base_url(self, value: str): + def ollama_base_url(self, value: str) -> None: self._data["ollama_base_url"] = value @property def ollama_api_key(self) -> str: - return self._data["ollama_api_key"] + """Resolve a key only on explicit access; never read legacy plaintext.""" + return self.get_ollama_api_key() @ollama_api_key.setter - def ollama_api_key(self, value: str): - self._data["ollama_api_key"] = value + def ollama_api_key(self, value: str) -> None: + self._pending_api_key = value or "" @property def embedding_model(self) -> str: return self._data.get("embedding_model", "nomic-embed-text") @embedding_model.setter - def embedding_model(self, value: str): + def embedding_model(self, value: str) -> None: self._data["embedding_model"] = value @property @@ -114,16 +275,14 @@ def claude_code_mode(self) -> str: return self._data.get("claude_code_mode", "api") @claude_code_mode.setter - def claude_code_mode(self, value: str): + def claude_code_mode(self, value: str) -> None: self._data["claude_code_mode"] = value - # --- Profile System --- - @property def profiles(self) -> Dict[str, dict]: - """Alle gespeicherten Profile (Built-in + User).""" - all_profiles = dict(self.BUILTIN_PROFILES) - all_profiles.update(self._data.get("profiles", {})) + """All saved profiles, with legacy plaintext keys removed.""" + all_profiles = copy.deepcopy(self.BUILTIN_PROFILES) + all_profiles.update(copy.deepcopy(self._data.get("profiles", {}))) return all_profiles @property @@ -131,81 +290,340 @@ def active_profile(self) -> str: return self._data.get("active_profile", "") @active_profile.setter - def active_profile(self, value: str): + def active_profile(self, value: str) -> None: self._data["active_profile"] = value + @property + def has_legacy_credentials(self) -> bool: + return bool(self._legacy_credentials) + + @property + def legacy_credential_count(self) -> int: + return len(self._legacy_credentials) + + def _current_legacy_scope(self) -> str: + active = self.active_profile + if active and active in self._data.get("profiles", {}): + return f"profile:{active}" + return "global" + + def _current_credential_id(self) -> str: + credential_id = self._data.get("ollama_credential_id", "") + if credential_id: + return credential_id + active = self.active_profile + if active: + profile = self._data.get("profiles", {}).get(active, {}) + return profile.get("credential_id", "") if isinstance(profile, dict) else "" + return "" + + def get_ollama_api_key( + self, + profile_name: str | None = None, + explicit_key: str | None = None, + ) -> str: + """Resolve a request key with env override, profile, and keyring precedence.""" + env_key = os.environ.get(API_KEY_ENV_VAR, "") + if env_key: + return env_key + if explicit_key: + return explicit_key + + if profile_name is not None: + profile = self._data.get("profiles", {}).get(profile_name) + if not isinstance(profile, dict): + raise AppConfigError(f"Unknown connection profile: {profile_name}") + credential_id = profile.get("credential_id", "") + legacy_scope = f"profile:{profile_name}" + else: + credential_id = self._current_credential_id() + legacy_scope = self._current_legacy_scope() + + if credential_id: + return self._credential_store.get_secret(credential_id) + if self._legacy_credentials.get(legacy_scope): + raise LegacyCredentialMigrationRequired( + "A legacy API key is still stored in config and will not be sent. " + "Use the explicit key migration action or replace/remove that key." + ) + return "" + + def credential_status(self, profile_name: str | None = None) -> tuple[str, str]: + """Return a secret-free status for the settings UI.""" + if os.environ.get(API_KEY_ENV_VAR, ""): + return "environment", "Environment override active; it is never saved by the app." + + if profile_name is not None: + profile = self._data.get("profiles", {}).get(profile_name) + if not isinstance(profile, dict): + return "missing", "No saved API key for this profile." + credential_id = profile.get("credential_id", "") + legacy_scope = f"profile:{profile_name}" + else: + credential_id = self._current_credential_id() + legacy_scope = self._current_legacy_scope() + + if credential_id: + try: + self._credential_store.get_secret(credential_id) + return "stored", "API key is available in the native OS credential store." + except CredentialNotFoundError as exc: + return "missing", str(exc) + except CredentialStoreError as exc: + return "unavailable", str(exc) + if self._legacy_credentials.get(legacy_scope): + return ( + "legacy", + "A plaintext legacy key is present in config but will not be sent until migrated.", + ) + return "missing", "No API key is stored; local Ollama can run without one." + + def save(self) -> None: + """Save settings, writing a new credential only after explicit key input.""" + candidate = copy.deepcopy(self._data) + legacy = copy.deepcopy(self._legacy_credentials) + old_ids = self._credential_ids(self._data) + pending = self._pending_api_key + new_id = None + + if pending is _UNSET: + self._commit(candidate, legacy) + return + if os.environ.get(API_KEY_ENV_VAR, "") and pending: + raise CredentialStoreError( + f"{API_KEY_ENV_VAR} is active; the environment key cannot be persisted." + ) + + try: + if pending: + new_id = str(uuid.uuid4()) + self._credential_store.set_secret_verified(new_id, pending) + candidate["ollama_credential_id"] = new_id + legacy.pop("global", None) + else: + candidate["ollama_credential_id"] = "" + legacy.pop("global", None) + active = candidate.get("active_profile", "") + if active and isinstance(candidate.get("profiles", {}).get(active), dict): + candidate["profiles"][active].pop("credential_id", None) + legacy.pop(f"profile:{active}", None) + + self._commit(candidate, legacy) + except Exception: + self._discard_new_secret(new_id) + raise + + self._pending_api_key = _UNSET + self._cleanup_unreferenced(old_ids, candidate) + def save_profile(self, name: str) -> None: - """Speichert aktuelle Einstellungen als Profil.""" + """Save current app settings as a profile without storing a plaintext key.""" + if not name: + raise ValueError("A profile name is required.") + if os.environ.get(API_KEY_ENV_VAR, "") and self._pending_api_key: + raise CredentialStoreError( + f"{API_KEY_ENV_VAR} is active; its value cannot be saved into a profile." + ) + + candidate = copy.deepcopy(self._data) + legacy = copy.deepcopy(self._legacy_credentials) + old_ids = self._credential_ids(self._data) + profiles = candidate.setdefault("profiles", {}) + existing = profiles.get(name, {}) profile = { "llm_provider": self.llm_provider, "llm_model": self.llm_model, "ollama_base_url": self.ollama_base_url, - "ollama_api_key": self.ollama_api_key, "embedding_model": self.embedding_model, } - if "profiles" not in self._data: - self._data["profiles"] = {} - self._data["profiles"][name] = profile - self._data["active_profile"] = name - self.save() + pending = self._pending_api_key + new_id = None + + try: + if pending is not _UNSET: + if pending: + new_id = str(uuid.uuid4()) + self._credential_store.set_secret_verified(new_id, pending) + candidate["ollama_credential_id"] = new_id + profile["credential_id"] = new_id + else: + candidate["ollama_credential_id"] = "" + legacy.pop("global", None) + legacy.pop(f"profile:{name}", None) + else: + credential_id = candidate.get("ollama_credential_id", "") + if not credential_id and isinstance(existing, dict): + credential_id = existing.get("credential_id", "") + if credential_id: + profile["credential_id"] = credential_id + elif legacy.get("global") or legacy.get(f"profile:{name}"): + raise LegacyCredentialMigrationRequired( + "Migrate the legacy key or enter a replacement before saving this profile." + ) + + if pending is not _UNSET: + legacy.pop("global", None) + legacy.pop(f"profile:{name}", None) + profiles[name] = profile + candidate["active_profile"] = name + self._commit(candidate, legacy) + except Exception: + self._discard_new_secret(new_id) + raise + + self._pending_api_key = _UNSET + self._cleanup_unreferenced(old_ids, candidate) def load_profile(self, name: str) -> bool: - """Laedt ein Profil und setzt alle Einstellungen.""" + """Activate a profile without reading or writing its keyring secret.""" all_profiles = self.profiles if name not in all_profiles: return False profile = all_profiles[name] - self._data["llm_provider"] = profile.get("llm_provider", self.DEFAULTS["llm_provider"]) - self._data["llm_model"] = profile.get("llm_model", self.DEFAULTS["llm_model"]) - self._data["ollama_base_url"] = profile.get("ollama_base_url", self.DEFAULTS["ollama_base_url"]) - self._data["ollama_api_key"] = profile.get("ollama_api_key", self.DEFAULTS["ollama_api_key"]) - self._data["embedding_model"] = profile.get("embedding_model", self.DEFAULTS["embedding_model"]) + candidate = copy.deepcopy(self._data) + for key in ("llm_provider", "llm_model", "ollama_base_url", "embedding_model"): + candidate[key] = profile.get(key, self.DEFAULTS[key]) + candidate["ollama_credential_id"] = profile.get("credential_id", "") + candidate["active_profile"] = name + self._commit(candidate, copy.deepcopy(self._legacy_credentials)) + return True + + def set_active_profile(self, name: str) -> bool: + """Select a profile in memory for a settings dialog that has not committed yet.""" + if not name: + self._data["active_profile"] = "" + return True + profile = self._data.get("profiles", {}).get(name) + if not isinstance(profile, dict): + if name in self.BUILTIN_PROFILES: + profile = self.BUILTIN_PROFILES[name] + else: + return False self._data["active_profile"] = name - self.save() + self._data["ollama_credential_id"] = profile.get("credential_id", "") return True def delete_profile(self, name: str) -> bool: - """Löscht ein benutzerdefiniertes Profil.""" if name in self.BUILTIN_PROFILES: - return False # Built-ins nicht löschbar + return False profiles = self._data.get("profiles", {}) - if name in profiles: - del profiles[name] - if self._data.get("active_profile") == name: - self._data["active_profile"] = "" - self.save() - return True - return False + if name not in profiles: + return False + + candidate = copy.deepcopy(self._data) + old_ids = self._credential_ids(self._data) + removed = candidate["profiles"].pop(name) + legacy = copy.deepcopy(self._legacy_credentials) + legacy.pop(f"profile:{name}", None) + if candidate.get("active_profile") == name: + candidate["active_profile"] = "" + + self._commit(candidate, legacy) + removed_id = removed.get("credential_id") if isinstance(removed, dict) else None + self._cleanup_unreferenced(old_ids | ({removed_id} if removed_id else set()), candidate) + return True def rename_profile(self, old_name: str, new_name: str) -> bool: - """Benennt ein benutzerdefiniertes Profil um.""" - if old_name in self.BUILTIN_PROFILES: - return False # Built-ins nicht umbenennbar - if not new_name or new_name == old_name: + if old_name in self.BUILTIN_PROFILES or not new_name or new_name == old_name: return False profiles = self._data.get("profiles", {}) - if old_name not in profiles: + if old_name not in profiles or new_name in self.BUILTIN_PROFILES or new_name in profiles: return False - if new_name in self.BUILTIN_PROFILES or new_name in profiles: - return False # Zielname bereits belegt - profiles[new_name] = profiles.pop(old_name) - if self._data.get("active_profile") == old_name: - self._data["active_profile"] = new_name - self.save() + + candidate = copy.deepcopy(self._data) + candidate["profiles"][new_name] = candidate["profiles"].pop(old_name) + if candidate.get("active_profile") == old_name: + candidate["active_profile"] = new_name + legacy = copy.deepcopy(self._legacy_credentials) + legacy_scope = f"profile:{old_name}" + if legacy_scope in legacy: + legacy[f"profile:{new_name}"] = legacy.pop(legacy_scope) + self._commit(candidate, legacy) return True + def migrate_legacy_credentials(self) -> int: + """Explicitly move legacy config keys to verified native keyring entries.""" + if not self._legacy_credentials: + return 0 + if self._pending_api_key is not _UNSET: + raise AppConfigError("Save or discard the current key edit before migration.") + + candidate = copy.deepcopy(self._data) + old_ids = self._credential_ids(self._data) + created_ids: list[str] = [] + migrated = 0 + + # Validate all scopes before writing any keyring entries. This makes a + # stale/corrupt profile reference a visible, non-destructive error. + for scope in self._legacy_credentials: + if scope == "global": + continue + if not scope.startswith("profile:"): + raise AppConfigError("An unsupported legacy credential scope needs manual review.") + profile_name = scope.partition(":")[2] + profile = candidate.get("profiles", {}).get(profile_name) + if not isinstance(profile, dict): + raise AppConfigError( + "A legacy key refers to a missing profile; config was left unchanged." + ) + + try: + for scope, secret in self._legacy_credentials.items(): + if not secret: + continue + profile_name = None + current_id = candidate.get("ollama_credential_id", "") + if scope.startswith("profile:"): + profile_name = scope.partition(":")[2] + profile = candidate.get("profiles", {}).get(profile_name) + current_id = profile.get("credential_id", "") + + if current_id: + try: + current_secret = self._credential_store.get_secret(current_id) + except CredentialNotFoundError: + current_secret = None + if current_secret is not None: + if current_secret != secret: + raise AppConfigError( + "A legacy key conflicts with an existing stored key; " + "config was left unchanged." + ) + # The same key is already stored. The explicit migration + # can remove the duplicate plaintext without replacing it. + migrated += 1 + continue + + credential_id = str(uuid.uuid4()) + self._credential_store.set_secret_verified(credential_id, secret) + created_ids.append(credential_id) + if scope == "global": + candidate["ollama_credential_id"] = credential_id + elif profile_name is not None: + profile["credential_id"] = credential_id + migrated += 1 + + if not migrated: + return 0 + self._commit(candidate, {}) + except Exception: + for credential_id in created_ids: + self._discard_new_secret(credential_id) + raise + + self._cleanup_unreferenced(old_ids, candidate) + return migrated + def list_profile_names(self) -> List[str]: - """Gibt alle Profilnamen zurück.""" return list(self.profiles.keys()) -# Singleton instance -_instance: Optional[AppConfig] = None +_instance: AppConfig | None = None def get_app_config() -> AppConfig: - """Get the global app config (singleton).""" + """Get the global app config singleton.""" global _instance if _instance is None: _instance = AppConfig() diff --git a/src/core/credential_store.py b/src/core/credential_store.py new file mode 100644 index 0000000..94c2df7 --- /dev/null +++ b/src/core/credential_store.py @@ -0,0 +1,160 @@ +"""Access NoteSpaceLLM credentials through an allowlisted native keyring.""" + +from __future__ import annotations + +import importlib +import sys +import threading +from collections.abc import Callable +from typing import ClassVar + +SERVICE_NAME = "NoteSpaceLLM" + + +class CredentialStoreError(RuntimeError): + """A native credential store could not safely read or write a secret.""" + + +class CredentialNotFoundError(CredentialStoreError): + """A config entry points at a credential that is missing from the keyring.""" + + +class CredentialConflictError(CredentialStoreError): + """A generated credential ID already exists and will not be overwritten.""" + + +class CredentialStore: + """Lazy, fail-closed wrapper around the OS keyring implementations. + + The library's configurable backend loader is intentionally not used: it can + load third-party or plaintext backends from user configuration. Only the + backend classes listed for the current operating system are instantiated. + Constructing this object performs no keyring access. + """ + + _BACKENDS: ClassVar[dict[str, tuple[tuple[str, str, str], ...]]] = { + "win32": ( + ("keyring.backends.Windows", "WinVaultKeyring", "Windows Credential Locker"), + ), + "darwin": ( + ("keyring.backends.macOS", "Keyring", "macOS Keychain"), + ), + "linux": ( + ("keyring.backends.SecretService", "Keyring", "Freedesktop Secret Service"), + ("keyring.backends.kwallet", "DBusKeyring", "KDE KWallet"), + ), + } + + def __init__( + self, + platform: str | None = None, + backend_factories: dict[str, tuple[Callable[[], object], ...]] | None = None, + ): + self._platform = platform or sys.platform + self._backend_factories = backend_factories + self._resolved_backend = None + self._backend_lock = threading.Lock() + + def _factories(self): + if self._backend_factories is not None: + factories = self._backend_factories.get(self._platform, ()) + return tuple((factory, None) for factory in factories) + + specs = self._BACKENDS.get(self._platform, ()) + resolved = [] + for module_name, class_name, label in specs: + def factory(module_name=module_name, class_name=class_name): + module = importlib.import_module(module_name) + return getattr(module, class_name)() + + resolved.append((factory, label)) + return tuple(resolved) + + def _backend(self): + if self._platform not in self._BACKENDS and self._backend_factories is None: + raise CredentialStoreError( + f"No supported native credential store is configured for {self._platform}." + ) + + if self._resolved_backend is not None: + return self._resolved_backend + + with self._backend_lock: + if self._resolved_backend is not None: + return self._resolved_backend + + failures = [] + for factory, label in self._factories(): + try: + backend = factory() + priority = getattr(backend, "priority", 1) + if priority <= 0: + failures.append("native backend is unavailable") + continue + self._resolved_backend = (backend, label) + return self._resolved_backend + except Exception as exc: # noqa: BLE001 - sanitize native backend errors + # Backend setup details may include system-specific information; + # keep the public error useful without exposing that detail. + failures.append(type(exc).__name__) + + detail = ", ".join(dict.fromkeys(failures)) or "no allowlisted backend is installed" + raise CredentialStoreError( + f"No usable native credential store is available ({detail})." + ) + + def get_secret(self, credential_id: str) -> str: + if not credential_id: + return "" + backend, _ = self._backend() + try: + secret = backend.get_password(SERVICE_NAME, credential_id) + except Exception: # noqa: BLE001 - backend errors may contain secret-bearing details + raise CredentialStoreError("The native credential store could not read the key.") from None + if secret is None: + raise CredentialNotFoundError( + "The configured credential is missing from the native credential store." + ) + if not isinstance(secret, str): + raise CredentialStoreError("The native credential store returned an invalid value.") + return secret + + def set_secret_verified(self, credential_id: str, secret: str) -> str: + if not credential_id or not secret: + raise ValueError("A credential ID and non-empty secret are required.") + backend, label = self._backend() + try: + existing = backend.get_password(SERVICE_NAME, credential_id) + except Exception: # noqa: BLE001 - backend errors may contain secret-bearing details + raise CredentialStoreError("The native credential store could not check the key.") from None + if existing is not None: + raise CredentialConflictError( + "A credential already exists for this generated ID; it was not overwritten." + ) + + try: + backend.set_password(SERVICE_NAME, credential_id, secret) + readback = backend.get_password(SERVICE_NAME, credential_id) + if readback != secret: + raise RuntimeError("Credential readback did not match.") + except Exception: # noqa: BLE001 - backend errors may contain secret-bearing details + try: + backend.delete_password(SERVICE_NAME, credential_id) + except Exception: # noqa: BLE001, S110 - cleanup is best effort; keep the primary error + pass + raise CredentialStoreError( + "The native credential store could not save and verify the key." + ) from None + return label or "native OS credential store" + + def delete_secret(self, credential_id: str) -> None: + if not credential_id: + return + backend, _ = self._backend() + try: + backend.delete_password(SERVICE_NAME, credential_id) + except Exception: # noqa: BLE001 - backend errors may contain secret-bearing details + # Missing entries are harmless during post-commit cleanup. The + # caller treats all cleanup failures as best-effort and never + # rolls back a config file after it has committed. + raise CredentialStoreError("The native credential store could not remove an old key.") from None diff --git a/src/core/project.py b/src/core/project.py index 5de58b4..27dd787 100644 --- a/src/core/project.py +++ b/src/core/project.py @@ -46,7 +46,6 @@ class ProjectSettings: llm_provider: str = "ollama" # ollama, openai, anthropic, local llm_model: str = "llama3" ollama_base_url: str = "http://localhost:11434" - ollama_api_key: str = "" max_context_tokens: int = 8000 language: str = "de" @@ -75,7 +74,6 @@ def from_dict(cls, data: dict) -> "ProjectSettings": llm_provider=app_cfg.llm_provider, llm_model=app_cfg.llm_model, ollama_base_url=app_cfg.ollama_base_url, - ollama_api_key=app_cfg.ollama_api_key, max_context_tokens=data.get("max_context_tokens", 8000), language=data.get("language", "de") ) diff --git a/src/core/workspace_exporter.py b/src/core/workspace_exporter.py index e06d88e..81d5439 100644 --- a/src/core/workspace_exporter.py +++ b/src/core/workspace_exporter.py @@ -4,22 +4,26 @@ Workspace Exporter — notespacellm-workspace-v1 Format ====================================================== -Erzeugt ein datenschutzkonformes Workspace-JSON aus dem aktuellen Projekt. +Erstellt ein Workspace-JSON aus dem aktuellen Projekt. Das Format ist der Austauschvertrag zwischen Desktop-App und Web/PWA/Mobile. Datenschutzregeln (aus EXPORTFORMAT.md): -- API-Schlüssel werden nie exportiert +- Gespeicherte API-Schlüsselfelder werden nicht exportiert; frei verfasste + Bericht- und Chattexte können trotzdem Geheimnisse enthalten. - Vektordatenbanken und Cache-Verzeichnisse werden nie exportiert - Rohdokumente werden standardmäßig nicht eingebettet - Auszüge nur wenn bereits für Report/Chat/Review ausgewählt """ import json +import ipaddress +import re import tempfile import os from datetime import datetime, timezone from pathlib import Path from typing import Optional, List, TYPE_CHECKING +from urllib.parse import urlsplit if TYPE_CHECKING: from .project import Project @@ -56,7 +60,7 @@ def build_workspace_export_payload( continue excerpts = [] - if include_subquery_excerpts: + if include_subquery_excerpts and doc.is_selected: excerpts = _build_excerpts(project, doc.id) documents.append({ @@ -135,6 +139,46 @@ def export_workspace_to_file(payload: dict, filepath: Path) -> None: raise +def workspace_export_confirmation_text(payload: dict) -> str: + """Describe the exact user content included before the UI writes an export.""" + documents = payload.get("documents", []) + selected_documents = [doc for doc in documents if doc.get("selected")] + excerpt_count = sum(len(doc.get("excerpts", [])) for doc in selected_documents) + chat_messages = payload.get("chat", {}).get("messages", []) + report_content = payload.get("report", {}).get("content", "") + + report_line = "Bericht: vollständiger Inhalt" if report_content else "Bericht: leer" + return "\n".join(( + "Die Workspace-Datei enthält:", + f"• Projektmetadaten und Dateinamen für {len(documents)} Dokument(e)", + f"• {report_line}", + f"• vollständigen Chatverlauf mit {len(chat_messages)} Nachrichten", + ( + f"• {excerpt_count} abgeschlossene Auszüge aus " + f"{len(selected_documents)} ausgewählten Dokument(en)" + ), + "", + ( + "Der Export wird nicht anonymisiert. Bericht, Chat und Auszüge können " + "persönliche oder vertrauliche Angaben enthalten. Vollständige " + "Rohdokumente, gespeicherte API-Schlüsselfelder und lokale " + "Vektordatenbanken werden nicht aufgenommen. Frei verfasste Inhalte " + "werden nicht auf Geheimnisse geprüft; prüfe Bericht und Chat vor " + "dem Weitergeben." + ), + "", + "Möchtest du diese Inhalte in die gewählte Datei schreiben?", + )) + + +def export_workspace_if_confirmed(payload: dict, filepath: Path, confirmed: bool) -> bool: + """Write only after an explicit affirmative decision; return whether written.""" + if not confirmed: + return False + export_workspace_to_file(payload, filepath) + return True + + def _build_excerpts(project: "Project", doc_id: str) -> List[dict]: """Erzeugt Auszüge aus abgeschlossenen Sub-Queries eines Dokuments.""" excerpts = [] @@ -163,11 +207,62 @@ def _build_provider_info(project: "Project") -> dict: try: provider = project.settings.llm_provider or "unknown" model = project.settings.llm_model or "" + if provider == "ollama": + mode = _classify_ollama_endpoint( + getattr(project.settings, "ollama_base_url", "") + ) + elif provider in {"openai", "anthropic", "claude-code"}: + mode = "remote" + else: + mode = "unknown" return { - "mode": "local" if provider == "ollama" else "remote", + "mode": mode, "name": provider, "model_hint": model, "secret_exported": False, } except Exception: return {"mode": "unknown", "name": "unknown", "secret_exported": False} + + +def _classify_ollama_endpoint(value: object) -> str: + """Classify configured endpoints without DNS or network access. + + Only a valid HTTP(S) URL with an exact localhost hostname or an IP loopback + address is called local. Invalid or credential-bearing URLs stay unknown. + """ + if not isinstance(value, str) or not value or any(ch.isspace() for ch in value): + return "unknown" + try: + parsed = urlsplit(value) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: + return "unknown" + if parsed.username is not None or parsed.password is not None: + return "unknown" + hostname = parsed.hostname + port = parsed.port # Raises ValueError for malformed/out-of-range values. + except ValueError: + return "unknown" + + if not hostname or port == 0: + return "unknown" + + try: + address = ipaddress.ip_address(hostname) + except ValueError: + try: + ascii_host = hostname.encode("idna").decode("ascii").lower() + except UnicodeError: + return "unknown" + if ascii_host.endswith(".."): + return "unknown" + normalized_host = ascii_host.removesuffix(".") + labels = normalized_host.split(".") + label_pattern = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?") + if any( + not label or len(label) > 63 or not label_pattern.fullmatch(label) + for label in labels + ): + return "unknown" + return "local" if normalized_host == "localhost" else "remote" + return "local" if address.is_loopback else "remote" diff --git a/src/gui/chat_panel.py b/src/gui/chat_panel.py index e2d1543..89b4045 100644 --- a/src/gui/chat_panel.py +++ b/src/gui/chat_panel.py @@ -199,8 +199,11 @@ def run(self): self.response_complete.emit(full_response) - except Exception as e: - self.error_occurred.emit(str(e)) + except Exception as exc: + logger.error("LLM worker failed (%s).", type(exc).__name__) + self.error_occurred.emit( + f"LLM-Anfrage fehlgeschlagen ({type(exc).__name__})." + ) def stop(self): """Request stop.""" @@ -239,9 +242,11 @@ def run(self): "query": result.query }) - except Exception as e: - logger.error(f"RAG Worker Fehler: {e}") - self.error_occurred.emit(str(e)) + except Exception as exc: + logger.error("RAG worker failed (%s).", type(exc).__name__) + self.error_occurred.emit( + f"RAG request failed ({type(exc).__name__})." + ) class ChatPanel(QWidget if PYSIDE_AVAILABLE else object): diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 96251b9..a874152 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -10,6 +10,7 @@ import sys import logging +import os from pathlib import Path from typing import Optional @@ -22,7 +23,7 @@ QFileDialog, QInputDialog, QApplication, QProgressDialog, QDockWidget, QTabWidget, QDialog, QComboBox, QLabel, QDialogButtonBox, QFormLayout, QGroupBox, QLineEdit, - QPushButton + QPushButton, QCheckBox ) from PySide6.QtCore import Qt, QSize, QTimer, Slot from PySide6.QtGui import QAction, QIcon, QKeySequence @@ -53,8 +54,13 @@ def run(self): try: response = self._llm_client.chat(prompt, "") self.query_complete.emit(query_id, response, "") - except Exception as e: - self.query_complete.emit(query_id, "", str(e)) + except Exception as exc: # noqa: BLE001 - sanitize provider errors before UI/logging + logger.error("Analysis worker failed (%s).", type(exc).__name__) + self.query_complete.emit( + query_id, + "", + f"LLM-Anfrage fehlgeschlagen ({type(exc).__name__}).", + ) self.all_complete.emit() class ExtractionWorker(QThread): @@ -133,11 +139,11 @@ def run(self): client._check_availability() if client._is_available: models = client.get_models() - self.models_loaded.emit(models, f"{len(models)} Modelle auf {self._url}") + self.models_loaded.emit(models, f"Ollama erreichbar ({len(models)} Modelle)") else: - self.models_loaded.emit([], f"Ollama nicht erreichbar ({self._url})") - except Exception as e: - self.models_loaded.emit([], f"Fehler: {e}") + self.models_loaded.emit([], "Ollama am konfigurierten Endpunkt nicht erreichbar") + except Exception as exc: # noqa: BLE001 - show only a sanitized error category + self.models_loaded.emit([], f"Ollama-Abfrage fehlgeschlagen ({type(exc).__name__})") if PYSIDE_AVAILABLE: @@ -485,7 +491,7 @@ def _setup_menu(self): workspace_export_action.setShortcut("Ctrl+Shift+E") workspace_export_action.setToolTip( "Exportiert Projekt, Dokumente, Bericht und Chat als\n" - "notespacellm-workspace-v1.json (datenschutzkonform)" + "notespacellm-workspace-v1.json. Der Export ist nicht anonymisiert." ) workspace_export_action.triggered.connect(self._export_workspace) file_menu.addAction(workspace_export_action) @@ -875,7 +881,15 @@ def _refresh_models(self): self.statusbar.showMessage("Modelle werden abgefragt...") url = self._get_ollama_url() - api_key = self._current_project.settings.ollama_api_key if self._current_project else "" + try: + from ..core.app_config import get_app_config + api_key = get_app_config().get_ollama_api_key() + except Exception as exc: # noqa: BLE001 - show only a sanitized error category + logger.error("Credential lookup failed (%s).", type(exc).__name__) + self.statusbar.showMessage( + f"Ollama-Schlüssel nicht verfügbar ({type(exc).__name__})." + ) + return worker = ModelLoadWorker(url, api_key) worker.models_loaded.connect( @@ -892,6 +906,7 @@ def _llm_settings(self): from ..core.app_config import get_app_config app_cfg = get_app_config() + selected_profile = [None] # ===== Profile Section ===== profile_group = QGroupBox("Verbindungsprofile") @@ -925,10 +940,25 @@ def _llm_settings(self): provider_layout.addRow("Ollama URL:", ollama_url_edit) ollama_key_edit = QLineEdit() - ollama_key_edit.setPlaceholderText("Leer = keine Auth (lokal)") + ollama_key_edit.setPlaceholderText("Leer lassen = vorhandenen Schlüssel behalten") ollama_key_edit.setEchoMode(QLineEdit.EchoMode.Password) provider_layout.addRow("API-Key:", ollama_key_edit) + clear_key_check = QCheckBox("Gespeicherten API-Key entfernen") + provider_layout.addRow("", clear_key_check) + clear_key_check.toggled.connect(ollama_key_edit.setDisabled) + ollama_key_edit.textChanged.connect( + lambda text: clear_key_check.setChecked(False) if text else None + ) + + credential_status_label = QLabel("") + credential_status_label.setWordWrap(True) + credential_status_label.setStyleSheet("color: #666; font-size: 10px;") + provider_layout.addRow("Schlüsselstatus:", credential_status_label) + + migrate_legacy_btn = QPushButton("Alte Klartext-Schlüssel migrieren…") + provider_layout.addRow("", migrate_legacy_btn) + embedding_combo = QComboBox() embedding_combo.setEditable(True) embedding_combo.addItems(["nomic-embed-text", "mxbai-embed-large", "all-minilm"]) @@ -963,6 +993,47 @@ def _update_provider_visibility(provider: str): layout.addWidget(provider_group) + def _refresh_credential_status(profile_name=None): + state, detail = app_cfg.credential_status(profile_name) + credential_status_label.setText(f"{state}: {detail}") + migrate_legacy_btn.setEnabled(app_cfg.has_legacy_credentials) + + def _migrate_legacy_credentials(): + if not app_cfg.has_legacy_credentials: + QMessageBox.information( + dialog, "Schlüssel migrieren", "Es wurden keine alten Klartext-Schlüssel gefunden." + ) + return + answer = QMessageBox.question( + dialog, + "Alte Schlüssel migrieren", + f"{app_cfg.legacy_credential_count} alte(r) Schlüssel werden in den nativen " + "Schlüsselspeicher des Betriebssystems übertragen und anschließend aus der " + "Konfigurationsdatei entfernt. Fortfahren?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if answer != QMessageBox.StandardButton.Yes: + return + try: + migrated = app_cfg.migrate_legacy_credentials() + except Exception as exc: # noqa: BLE001 - AppConfig and CredentialStore normalize details + QMessageBox.warning( + dialog, + "Migration fehlgeschlagen", + f"Die bisherige Konfiguration wurde beibehalten. {exc}", + ) + _refresh_credential_status(selected_profile[0]) + return + _refresh_credential_status(selected_profile[0]) + QMessageBox.information( + dialog, + "Migration abgeschlossen", + f"{migrated} Schlüssel wurden überprüft und migriert.", + ) + + migrate_legacy_btn.clicked.connect(_migrate_legacy_credentials) + # ===== Model Section ===== model_group = QGroupBox("Modell") model_layout = QVBoxLayout(model_group) @@ -1003,10 +1074,11 @@ def _update_provider_visibility(provider: str): layout.addWidget(button_box) # ===== Helper: Populate form from values ===== - def _populate_form(provider, model, url, key, emb_model, cc_mode): + def _populate_form(provider, model, url, emb_model, cc_mode): provider_combo.setCurrentText(provider) ollama_url_edit.setText(url) - ollama_key_edit.setText(key) + ollama_key_edit.clear() + clear_key_check.setChecked(False) embedding_combo.setCurrentText(emb_model) claude_mode_combo.setCurrentText(cc_mode) _update_provider_visibility(provider) @@ -1026,18 +1098,28 @@ def _refresh_profiles(): def _on_profile_selected(name: str): if name == "(Aktuelle Einstellungen)" or not name: + selected_profile[0] = None + _populate_form( + app_cfg.llm_provider, + app_cfg.llm_model, + app_cfg.ollama_base_url, + app_cfg.embedding_model, + app_cfg.claude_code_mode, + ) + _refresh_credential_status() return profiles = app_cfg.profiles if name in profiles: + selected_profile[0] = name p = profiles[name] _populate_form( p.get("llm_provider", "ollama"), p.get("llm_model", "llama3"), p.get("ollama_base_url", "http://localhost:11434"), - p.get("ollama_api_key", ""), p.get("embedding_model", "nomic-embed-text"), app_cfg.claude_code_mode, ) + _refresh_credential_status(name) # Trigger model reload load_models_for_provider(p.get("llm_provider", "ollama")) # Try select model after load @@ -1051,14 +1133,26 @@ def _save_profile_dialog(): name, ok = QInputDialog.getText(dialog, "Profil speichern", "Profilname:") if ok and name: # Save current form values as profile + app_cfg.set_active_profile(selected_profile[0] or "") app_cfg.llm_provider = provider_combo.currentText() app_cfg.llm_model = model_combo.currentText().strip() app_cfg.ollama_base_url = ollama_url_edit.text().strip() or "http://localhost:11434" - app_cfg.ollama_api_key = ollama_key_edit.text().strip() + if clear_key_check.isChecked(): + app_cfg.ollama_api_key = "" + elif ollama_key_edit.text().strip(): + app_cfg.ollama_api_key = ollama_key_edit.text().strip() app_cfg.embedding_model = embedding_combo.currentText().strip() or "nomic-embed-text" - app_cfg.save_profile(name) + try: + app_cfg.save_profile(name) + except Exception as exc: # noqa: BLE001 - AppConfig and CredentialStore normalize details + QMessageBox.warning(dialog, "Profil nicht gespeichert", str(exc)) + return + ollama_key_edit.clear() + clear_key_check.setChecked(False) _refresh_profiles() profile_combo.setCurrentText(name) + selected_profile[0] = name + _refresh_credential_status(name) status_label.setText(f"Profil '{name}' gespeichert") def _delete_profile(): @@ -1067,6 +1161,8 @@ def _delete_profile(): return if app_cfg.delete_profile(name): _refresh_profiles() + selected_profile[0] = None + _refresh_credential_status() status_label.setText(f"Profil '{name}' gelöscht") def _rename_profile(): @@ -1082,6 +1178,8 @@ def _rename_profile(): if app_cfg.rename_profile(old_name, new_name.strip()): _refresh_profiles() profile_combo.setCurrentText(new_name.strip()) + selected_profile[0] = new_name.strip() + _refresh_credential_status(selected_profile[0]) status_label.setText(f"'{old_name}' → '{new_name.strip()}'") else: status_label.setText(f"Umbenennen fehlgeschlagen (Name belegt?)") @@ -1117,7 +1215,18 @@ def load_models_for_provider(provider: str): if _settings_model_worker[0] and _settings_model_worker[0].isRunning(): return url = ollama_url_edit.text().strip() or "http://localhost:11434" - key = ollama_key_edit.text().strip() + entered_key = ollama_key_edit.text().strip() + if clear_key_check.isChecked() and not os.environ.get("NOTESPACELLM_OLLAMA_API_KEY"): + key = "" + else: + try: + key = app_cfg.get_ollama_api_key( + profile_name=selected_profile[0], + explicit_key=entered_key or None, + ) + except Exception as exc: # noqa: BLE001 - AppConfig and CredentialStore normalize details + status_label.setText(f"Ollama-Schlüssel nicht verfügbar: {exc}") + return worker = ModelLoadWorker(url, key) def _on_ollama_models(models, msg): @@ -1162,10 +1271,11 @@ def _on_worker_done(finished_worker): # ===== Initial populate ===== _populate_form( app_cfg.llm_provider, app_cfg.llm_model, - app_cfg.ollama_base_url, app_cfg.ollama_api_key, + app_cfg.ollama_base_url, app_cfg.embedding_model, app_cfg.claude_code_mode, ) _refresh_profiles() + _refresh_credential_status(selected_profile[0]) load_models_for_provider(app_cfg.llm_provider) # ===== Dialog Accept ===== @@ -1182,19 +1292,30 @@ def _on_worker_done(finished_worker): new_embedding = embedding_combo.currentText().strip() or "nomic-embed-text" new_claude_mode = claude_mode_combo.currentText() - if self._current_project: - self._current_project.settings.llm_provider = new_provider - self._current_project.settings.llm_model = new_model - self._current_project.settings.ollama_base_url = new_url - self._current_project.settings.ollama_api_key = new_key - + app_cfg.set_active_profile(selected_profile[0] or "") app_cfg.llm_provider = new_provider app_cfg.llm_model = new_model app_cfg.ollama_base_url = new_url - app_cfg.ollama_api_key = new_key + if clear_key_check.isChecked(): + app_cfg.ollama_api_key = "" + elif new_key: + app_cfg.ollama_api_key = new_key app_cfg.embedding_model = new_embedding app_cfg.claude_code_mode = new_claude_mode - app_cfg.save() + try: + app_cfg.save() + except Exception as exc: # noqa: BLE001 - AppConfig and CredentialStore normalize details + QMessageBox.warning( + self, + "LLM-Einstellungen nicht gespeichert", + f"Die Konfigurationsdatei wurde nicht geändert. {exc}", + ) + return + + if self._current_project: + self._current_project.settings.llm_provider = new_provider + self._current_project.settings.llm_model = new_model + self._current_project.settings.ollama_base_url = new_url # Re-init RAG engine with new embedding model. _init_rag_engine gibt die # neue Engine (oder None, z.B. bei Nicht-Ollama-Provider) an @@ -1486,19 +1607,24 @@ def _init_llm_client(self): provider = self._current_project.settings.llm_provider model = self._current_project.settings.llm_model base_url = self._current_project.settings.ollama_base_url - api_key = self._current_project.settings.ollama_api_key try: from ..core.app_config import get_app_config - claude_mode = get_app_config().claude_code_mode + app_cfg = get_app_config() + api_key = app_cfg.get_ollama_api_key() if provider == "ollama" else "" + claude_mode = app_cfg.claude_code_mode self._llm_client = create_llm_client( provider, model, base_url=base_url, api_key=api_key, claude_code_mode=claude_mode ) self.chat_panel.set_llm_client(self._llm_client) self.statusbar.showMessage(f"LLM initialisiert: {provider}/{model}") - except Exception as e: - self.statusbar.showMessage(f"LLM-Fehler: {e}") + except Exception as exc: # noqa: BLE001 - do not log provider exception details + logger.error("LLM client initialization failed (%s).", type(exc).__name__) + self._llm_client = None + self.statusbar.showMessage( + f"LLM konnte nicht initialisiert werden ({type(exc).__name__})." + ) def _update_pipeline_phase(self): """Update the pipeline phase indicator in the status bar.""" @@ -1867,7 +1993,11 @@ def _export_workspace(self): QMessageBox.warning(self, "Workspace exportieren", "Kein Projekt geöffnet.") return - from ..core.workspace_exporter import build_workspace_export_payload, export_workspace_to_file + from ..core.workspace_exporter import ( + build_workspace_export_payload, + export_workspace_if_confirmed, + workspace_export_confirmation_text, + ) from datetime import datetime project_name = self._current_project.name @@ -1895,7 +2025,20 @@ def _export_workspace(self): chat_history=chat_history, include_subquery_excerpts=True, ) - export_workspace_to_file(payload, Path(filepath)) + confirmation = QMessageBox.question( + self, + "Workspace-Export bestätigen", + workspace_export_confirmation_text(payload), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if not export_workspace_if_confirmed( + payload, + Path(filepath), + confirmation == QMessageBox.StandardButton.Yes, + ): + self.statusbar.showMessage("Workspace-Export abgebrochen.") + return doc_count = len([d for d in self._current_project.documents.documents if not d.is_directory]) @@ -1905,12 +2048,19 @@ def _export_workspace(self): QMessageBox.information( self, "Workspace exportiert", f"Erfolgreich exportiert:\n{Path(filepath).name}\n\n" - f"Enthält: {doc_count} Dokument(e), Bericht, Chat\n" - f"API-Schlüssel und Rohdaten wurden nicht exportiert." + f"Enthält: Metadaten zu {doc_count} Dokument(en), Bericht, Chat und ausgewählte Auszüge.\n" + f"Der Export ist nicht anonymisiert. Gespeicherte API-Schlüsselfelder und vollständige " + f"Rohdokumente wurden nicht exportiert. Prüfe Bericht und Chat vor dem Weitergeben " + f"auf Geheimnisse." ) - except Exception as e: - QMessageBox.critical(self, "Fehler", f"Export fehlgeschlagen:\n{e}") - self.statusbar.showMessage(f"Workspace-Export fehlgeschlagen: {e}") + except Exception as exc: # noqa: BLE001 - do not log provider exception details + logger.warning("Workspace export failed (%s).", type(exc).__name__) + QMessageBox.critical( + self, + "Fehler", + f"Workspace-Export fehlgeschlagen ({type(exc).__name__}).", + ) + self.statusbar.showMessage("Workspace-Export fehlgeschlagen.") def closeEvent(self, event): """Handle window close: Worker stoppen/abwarten, dann Projekt speichern.""" @@ -1983,21 +2133,23 @@ def _init_rag_engine(self): embedding_model=embedding_model, llm_model=llm_model, ollama_base_url=ollama_url, - api_key=app_cfg.ollama_api_key + api_key=app_cfg.get_ollama_api_key() ) self._rag_engine_ready = True self._connect_rag_engine() - logger.info(f"RAG Engine initialisiert (URL: {ollama_url}, Embedding: {embedding_model})") + logger.info("RAG Engine initialisiert (Embedding: %s).", embedding_model) if hasattr(self, "statusbar"): - self.statusbar.showMessage(f"RAG-Engine bereit ({embedding_model} @ {ollama_url})", 5000) + self.statusbar.showMessage(f"RAG-Engine bereit ({embedding_model})", 5000) - except Exception as e: - logger.error(f"RAG Engine Initialisierung fehlgeschlagen: {e}") + except Exception as exc: + logger.error("RAG Engine initialization failed (%s).", type(exc).__name__) self._rag_engine = None self._rag_engine_ready = False self._connect_rag_engine() if hasattr(self, "statusbar"): - self.statusbar.showMessage(f"RAG-Engine nicht verfügbar: {e}", 8000) + self.statusbar.showMessage( + f"RAG-Engine nicht verfügbar ({type(exc).__name__}).", 8000 + ) def _index_all_documents(self): """Index all documents with extracted text (async).""" diff --git a/src/llm/ollama_client.py b/src/llm/ollama_client.py index 41d637c..2ec1f74 100644 --- a/src/llm/ollama_client.py +++ b/src/llm/ollama_client.py @@ -109,9 +109,11 @@ def chat(self, prompt: str, context: str = "") -> str: with urllib.request.urlopen(req, timeout=300) as response: result = json.loads(response.read().decode("utf-8")) return result.get("message", {}).get("content", "") - except Exception as e: + except Exception as e: # noqa: BLE001 - hide provider URL/auth details self._mark_unavailable_on(e) - raise + raise RuntimeError( + f"Ollama chat request failed ({type(e).__name__})." + ) from None def stream_chat(self, prompt: str, context: str = "") -> Iterator[str]: """Stream a chat response.""" @@ -146,9 +148,11 @@ def stream_chat(self, prompt: str, context: str = "") -> Iterator[str]: yield content except json.JSONDecodeError: pass - except Exception as e: + except Exception as e: # noqa: BLE001 - hide provider URL/auth details self._mark_unavailable_on(e) - raise + raise RuntimeError( + f"Ollama streaming request failed ({type(e).__name__})." + ) from None def get_models(self) -> list: """Get available models from Ollama.""" @@ -224,6 +228,8 @@ def generate(self, prompt: str, system: str = "") -> str: with urllib.request.urlopen(req, timeout=300) as response: result = json.loads(response.read().decode("utf-8")) return result.get("response", "") - except Exception as e: + except Exception as e: # noqa: BLE001 - hide provider URL/auth details self._mark_unavailable_on(e) - raise + raise RuntimeError( + f"Ollama generation request failed ({type(e).__name__})." + ) from None diff --git a/src/rag/embeddings.py b/src/rag/embeddings.py index de3b591..4d6c412 100644 --- a/src/rag/embeddings.py +++ b/src/rag/embeddings.py @@ -75,14 +75,23 @@ def _create_embeddings(self) -> OllamaEmbeddings: try: return OllamaEmbeddings(**kwargs) - except Exception as e: - # Fallback ohne client_kwargs falls Version zu alt/neu - logger.warning(f"OllamaEmbeddings mit client_kwargs fehlgeschlagen: {e}") - kwargs.pop("client_kwargs", None) - return OllamaEmbeddings( - model=self.model_name, - base_url=self.base_url, + except Exception as exc: + if self._headers: + logger.warning( + "Authenticated Ollama embeddings setup failed (%s); " + "not retrying without its authentication headers.", + type(exc).__name__, + ) + raise RuntimeError( + "The authenticated Ollama embeddings client could not be configured." + ) from None + # Compatibility fallback is safe only when there is no credential + # header to accidentally omit on a remote request. + logger.warning( + "OllamaEmbeddings setup failed (%s); retrying without optional headers.", + type(exc).__name__, ) + return OllamaEmbeddings(model=self.model_name, base_url=self.base_url) def embed_query(self, text: str) -> List[float]: """ @@ -148,6 +157,6 @@ def test_connection(self) -> bool: # Versuche ein einfaches Embedding zu erstellen test_embedding = self.embed_query("test") return len(test_embedding) > 0 - except Exception as e: - logger.error(f"Ollama Verbindungstest fehlgeschlagen: {e}") + except Exception as exc: + logger.error("Ollama connection test failed (%s).", type(exc).__name__) return False diff --git a/src/rag/engine.py b/src/rag/engine.py index 2d15a98..9575103 100644 --- a/src/rag/engine.py +++ b/src/rag/engine.py @@ -9,6 +9,7 @@ from pathlib import Path from dataclasses import dataclass +from chromadb.config import Settings from langchain_chroma import Chroma from langchain_core.documents import Document as LangChainDocument from langchain_ollama import ChatOllama @@ -116,8 +117,20 @@ def __init__( try: self.llm = ChatOllama(**llm_kwargs) - except Exception as e: - logger.warning(f"ChatOllama mit client_kwargs fehlgeschlagen: {e}") + except Exception as exc: + if headers: + logger.warning( + "Authenticated ChatOllama client setup failed (%s); " + "not retrying without its authentication headers.", + type(exc).__name__, + ) + raise RuntimeError( + "The authenticated Ollama chat client could not be configured." + ) from None + logger.warning( + "ChatOllama client setup failed (%s); retrying without optional headers.", + type(exc).__name__, + ) self.llm = ChatOllama( model=llm_model, base_url=ollama_base_url, @@ -142,7 +155,8 @@ def vectorstore(self) -> Chroma: self._vectorstore = Chroma( collection_name=self.collection_name, embedding_function=self.embeddings_manager.embeddings, - persist_directory=self.persist_directory + persist_directory=self.persist_directory, + client_settings=Settings(anonymized_telemetry=False), ) return self._vectorstore @@ -232,13 +246,17 @@ def index_document( success=True ) - except Exception as e: - logger.error(f"Fehler bei Indexierung von {document_id}: {e}") + except Exception as exc: + logger.error( + "Document indexing failed for %s (%s).", + document_id, + type(exc).__name__, + ) return DocumentIndexResult( document_id=document_id, chunks_created=0, success=False, - error=str(e) + error=f"Indexing failed ({type(exc).__name__})." ) def index_documents( @@ -490,9 +508,9 @@ def get_statistics(self) -> Dict[str, Any]: "embedding_model": self.embeddings_manager.model_name, "llm_model": self.llm.model } - except Exception as e: - logger.error(f"Fehler beim Abrufen der Statistiken: {e}") - return {"error": str(e)} + except Exception as exc: + logger.error("Index statistics failed (%s).", type(exc).__name__) + return {"error": f"Index statistics failed ({type(exc).__name__})."} def clear_index(self) -> bool: """ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9bd81d2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,96 @@ +"""Keep tests away from user state, native keyrings, and network services.""" + +from __future__ import annotations + +import os +import socket +import tempfile +import urllib.request +from pathlib import Path + +import pytest + +# Test modules import app_config during collection, so isolate its Path.home() +# default before those imports can capture the real user's profile path. +_COLLECTION_HOME = Path(tempfile.gettempdir()) / f"notespacellm-pytest-profile-{os.getpid()}" +os.environ["USERPROFILE"] = str(_COLLECTION_HOME) +os.environ["HOME"] = str(_COLLECTION_HOME) +os.environ["QT_QPA_PLATFORM"] = "offscreen" + + +class _TestCredentialStore: + """Per-test fake store used by legacy tests that construct AppConfig directly.""" + + def __init__(self): + self.secrets: dict[str, str] = {} + + def get_secret(self, credential_id: str) -> str: + from src.core.credential_store import CredentialNotFoundError + + try: + return self.secrets[credential_id] + except KeyError: + raise CredentialNotFoundError("No test credential is stored.") from None + + def set_secret_verified(self, credential_id: str, secret: str) -> str: + if credential_id in self.secrets: + raise RuntimeError("Duplicate test credential ID.") + self.secrets[credential_id] = secret + if self.secrets.get(credential_id) != secret: + raise RuntimeError("Test credential readback failed.") + return "isolated test store" + + def delete_secret(self, credential_id: str) -> None: + self.secrets.pop(credential_id, None) + + +@pytest.fixture(autouse=True) +def isolate_desktop_state(monkeypatch, tmp_path): + """Use temporary config/home paths and block any accidental provider request.""" + from src.core import app_config + + isolated_home = tmp_path.parent / f"{tmp_path.name}-isolated-home" + isolated_home.mkdir() + config_dir = isolated_home / ".notespacellm" + + monkeypatch.setattr(Path, "home", lambda: isolated_home) + monkeypatch.setattr(app_config, "CONFIG_DIR", config_dir) + monkeypatch.setattr(app_config, "CONFIG_FILE", config_dir / "config.json") + monkeypatch.setattr(app_config, "CredentialStore", _TestCredentialStore) + monkeypatch.setattr(app_config, "_instance", None) + + for name in ( + app_config.API_KEY_ENV_VAR, + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + ): + monkeypatch.delenv(name, raising=False) + + def block_network(*_args, **_kwargs): + raise OSError("Network access is disabled in the test suite.") + + monkeypatch.setattr(urllib.request, "urlopen", block_network) + monkeypatch.setattr(socket.socket, "connect", block_network) + monkeypatch.setattr(socket.socket, "connect_ex", block_network) + monkeypatch.setattr(socket, "create_connection", block_network) + + try: + import src.rag.engine as rag_engine + except ImportError: + # Unit tests may isolate provider/vector bindings when this host cannot + # import the RAG stack. The unmocked installed-package import is checked + # separately in tests/test_packaging.py and remains a required gate. + pass + else: + isolated_vector_db = tmp_path / "vector-db" + monkeypatch.setattr(rag_engine.RAGEngine, "DEFAULT_PERSIST_DIR", str(isolated_vector_db)) + defaults = rag_engine.RAGEngine.__init__.__defaults__ + if defaults: + monkeypatch.setattr( + rag_engine.RAGEngine.__init__, + "__defaults__", + (str(isolated_vector_db), *defaults[1:]), + ) + + yield + app_config._instance = None diff --git a/tests/test_app_config_credentials.py b/tests/test_app_config_credentials.py new file mode 100644 index 0000000..6e3ccf0 --- /dev/null +++ b/tests/test_app_config_credentials.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import json + +import pytest + +from src.core.app_config import ( + API_KEY_ENV_VAR, + AppConfig, + AppConfigConflictError, + AppConfigError, + LegacyCredentialMigrationRequired, +) +from src.core.credential_store import CredentialStoreError + + +class MemoryCredentialStore: + def __init__(self): + self.secrets = {} + self.set_calls = [] + self.delete_calls = [] + self.fail_set = False + + def get_secret(self, credential_id): + if credential_id not in self.secrets: + from src.core.credential_store import CredentialNotFoundError + raise CredentialNotFoundError("missing") + return self.secrets[credential_id] + + def set_secret_verified(self, credential_id, secret): + self.set_calls.append((credential_id, secret)) + if self.fail_set: + raise CredentialStoreError("fake keyring failure") + if credential_id in self.secrets: + raise CredentialStoreError("duplicate") + self.secrets[credential_id] = secret + if self.secrets[credential_id] != secret: + raise CredentialStoreError("readback mismatch") + return "fake native store" + + def delete_secret(self, credential_id): + self.delete_calls.append(credential_id) + self.secrets.pop(credential_id, None) + + +@pytest.fixture +def config_factory(tmp_path, monkeypatch): + import src.core.app_config as app_config_module + + config_file = tmp_path / "profile" / "config.json" + monkeypatch.setattr(app_config_module, "CONFIG_FILE", config_file) + monkeypatch.setattr(app_config_module, "CONFIG_DIR", config_file.parent) + monkeypatch.delenv(API_KEY_ENV_VAR, raising=False) + + def create(store=None): + return AppConfig(credential_store=store or MemoryCredentialStore()) + + return config_file, create + + +def write_json(path, payload): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def test_config_construction_does_not_read_keyring(config_factory): + class NoTouchStore(MemoryCredentialStore): + def get_secret(self, credential_id): + raise AssertionError("constructor touched keyring") + + _path, create = config_factory + cfg = create(NoTouchStore()) + assert cfg.llm_provider == "ollama" + + +def test_project_settings_load_ignores_legacy_keys_and_existing_credential_ids( + config_factory, monkeypatch +): + import src.core.app_config as app_config_module + from src.core.project import Project, ProjectSettings + + path, create = config_factory + write_json( + path, + { + "llm_provider": "ollama", + "ollama_credential_id": "existing-global-id", + "ollama_api_key": "legacy-global-secret", + "active_profile": "Migrated", + "profiles": { + "Migrated": { + "llm_provider": "ollama", + "credential_id": "existing-profile-id", + "ollama_api_key": "legacy-profile-secret", + } + }, + }, + ) + before = path.read_bytes() + + class NoReadStore(MemoryCredentialStore): + def __init__(self): + super().__init__() + self.read_calls = [] + + def get_secret(self, credential_id): + self.read_calls.append(credential_id) + raise AssertionError("project load must not read the credential store") + + store = NoReadStore() + cfg = create(store) + monkeypatch.setattr(app_config_module, "_instance", cfg) + + settings = ProjectSettings.from_dict( + {"ollama_credential_id": "project-id", "ollama_api_key": "project-legacy"} + ) + project = Project.from_dict( + { + "id": "project-id", + "name": "Synthetic project", + "created_at": "2026-01-01T00:00:00", + "settings": { + "ollama_credential_id": "project-id", + "ollama_api_key": "project-legacy", + }, + } + ) + + assert settings.llm_provider == "ollama" + assert project.settings.ollama_base_url == cfg.ollama_base_url + assert not hasattr(settings, "ollama_api_key") + assert not hasattr(project.settings, "ollama_api_key") + assert cfg.has_legacy_credentials + assert store.read_calls == [] + assert path.read_bytes() == before + + +def test_explicit_key_save_uses_verified_store_and_never_serializes_secret(config_factory): + path, create = config_factory + store = MemoryCredentialStore() + cfg = create(store) + cfg.ollama_api_key = "test-secret" + cfg.save() + + saved = json.loads(path.read_text(encoding="utf-8")) + credential_id = saved["ollama_credential_id"] + assert store.secrets[credential_id] == "test-secret" + assert cfg.get_ollama_api_key() == "test-secret" + assert "test-secret" not in path.read_text(encoding="utf-8") + + +def test_environment_override_is_never_saved(config_factory, monkeypatch): + path, create = config_factory + store = MemoryCredentialStore() + cfg = create(store) + cfg.ollama_api_key = "saved-secret" + cfg.save() + before = path.read_bytes() + + monkeypatch.setenv(API_KEY_ENV_VAR, "environment-secret") + assert cfg.get_ollama_api_key() == "environment-secret" + cfg.ollama_api_key = "environment-secret" + with pytest.raises(CredentialStoreError, match="cannot be persisted"): + cfg.save() + assert path.read_bytes() == before + assert "environment-secret" not in store.secrets.values() + + +def test_legacy_plaintext_is_never_returned_until_explicit_migration(config_factory): + path, create = config_factory + write_json(path, {"ollama_api_key": "legacy-secret", "profiles": {}}) + store = MemoryCredentialStore() + cfg = create(store) + + assert cfg.has_legacy_credentials + assert cfg.credential_status()[0] == "legacy" + with pytest.raises(LegacyCredentialMigrationRequired): + cfg.get_ollama_api_key() + assert store.set_calls == [] + + assert cfg.migrate_legacy_credentials() == 1 + assert cfg.get_ollama_api_key() == "legacy-secret" + saved_text = path.read_text(encoding="utf-8") + assert "legacy-secret" not in saved_text + assert not cfg.has_legacy_credentials + + +def test_migration_with_matching_existing_credential_removes_only_duplicate_plaintext(config_factory): + path, create = config_factory + write_json( + path, + { + "ollama_credential_id": "existing-id", + "ollama_api_key": "same-secret", + "profiles": {}, + }, + ) + store = MemoryCredentialStore() + store.secrets["existing-id"] = "same-secret" + cfg = create(store) + + assert cfg.migrate_legacy_credentials() == 1 + saved = json.loads(path.read_text(encoding="utf-8")) + assert saved["ollama_credential_id"] == "existing-id" + assert "ollama_api_key" not in saved + assert store.set_calls == [] + assert store.secrets == {"existing-id": "same-secret"} + + +def test_migration_conflict_with_existing_credential_preserves_config_and_secret(config_factory): + path, create = config_factory + write_json( + path, + { + "ollama_credential_id": "existing-id", + "ollama_api_key": "legacy-secret", + "profiles": {}, + }, + ) + before = path.read_bytes() + store = MemoryCredentialStore() + store.secrets["existing-id"] = "different-secret" + cfg = create(store) + + with pytest.raises(AppConfigError, match="conflicts with an existing"): + cfg.migrate_legacy_credentials() + assert path.read_bytes() == before + assert cfg.has_legacy_credentials + assert store.secrets == {"existing-id": "different-secret"} + assert store.set_calls == [] + assert store.delete_calls == [] + + +def test_missing_profile_legacy_key_is_not_written_to_keyring(config_factory): + path, create = config_factory + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + '{"profiles":{"Gone":{"ollama_api_key":"stale-secret"}}}', + encoding="utf-8", + ) + before = path.read_bytes() + store = MemoryCredentialStore() + cfg = create(store) + # Simulate an in-memory profile removal that left a legacy credential + # scope behind, as can happen after a damaged/older config transition. + cfg._data["profiles"].pop("Gone") + + with pytest.raises(AppConfigError, match="missing profile"): + cfg.migrate_legacy_credentials() + assert store.set_calls == [] + assert path.read_bytes() == before + + +def test_migration_keyring_failure_keeps_config_bytes_and_legacy_state(config_factory): + path, create = config_factory + write_json(path, {"ollama_api_key": "legacy-secret", "profiles": {}}) + before = path.read_bytes() + store = MemoryCredentialStore() + store.fail_set = True + cfg = create(store) + + with pytest.raises(CredentialStoreError): + cfg.migrate_legacy_credentials() + assert path.read_bytes() == before + assert cfg.has_legacy_credentials + + +def test_config_replace_failure_removes_new_key_and_keeps_old_file(config_factory, monkeypatch): + import src.core.app_config as app_config_module + + path, create = config_factory + write_json(path, {"ollama_api_key": "legacy-secret", "profiles": {}}) + before = path.read_bytes() + store = MemoryCredentialStore() + cfg = create(store) + + def fail_replace(*_args, **_kwargs): + raise OSError("simulated replace failure") + + monkeypatch.setattr(app_config_module.os, "replace", fail_replace) + with pytest.raises(AppConfigError, match="could not be committed"): + cfg.migrate_legacy_credentials() + assert path.read_bytes() == before + assert cfg.has_legacy_credentials + assert store.secrets == {} + assert len(store.delete_calls) == 1 + + +def test_external_config_change_conflict_cleans_new_secret(config_factory): + path, create = config_factory + store = MemoryCredentialStore() + cfg = create(store) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('{"llm_model":"external"}', encoding="utf-8") + cfg.ollama_api_key = "candidate-secret" + + with pytest.raises(AppConfigConflictError): + cfg.save() + assert path.read_text(encoding="utf-8") == '{"llm_model":"external"}' + assert store.secrets == {} + assert len(store.delete_calls) == 1 + + +def test_profile_rename_preserves_stable_credential_id(config_factory): + _path, create = config_factory + store = MemoryCredentialStore() + cfg = create(store) + cfg.ollama_api_key = "profile-secret" + cfg.save_profile("Before") + old_id = cfg.profiles["Before"]["credential_id"] + + assert cfg.rename_profile("Before", "After") + assert cfg.profiles["After"]["credential_id"] == old_id + assert cfg.get_ollama_api_key(profile_name="After") == "profile-secret" diff --git a/tests/test_bugfixes_2026_10.py b/tests/test_bugfixes_2026_10.py index 9d72307..2cba0a3 100644 --- a/tests/test_bugfixes_2026_10.py +++ b/tests/test_bugfixes_2026_10.py @@ -552,7 +552,6 @@ def test_project_create_uses_saved_app_config(monkeypatch): llm_provider="anthropic", llm_model="claude-test", ollama_base_url="http://remote:11434", - ollama_api_key="geheim", ) monkeypatch.setattr(app_config, "get_app_config", lambda: fake_cfg) @@ -561,6 +560,7 @@ def test_project_create_uses_saved_app_config(monkeypatch): assert project.settings.llm_provider == "anthropic" assert project.settings.llm_model == "claude-test" assert project.settings.ollama_base_url == "http://remote:11434" + assert not hasattr(project.settings, "ollama_api_key") def test_open_project_by_id_with_duplicate_names_and_unique_dirs(tmp_path): diff --git a/tests/test_credential_store.py b/tests/test_credential_store.py new file mode 100644 index 0000000..08e56c9 --- /dev/null +++ b/tests/test_credential_store.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import pytest + +from src.core.credential_store import ( + SERVICE_NAME, + CredentialConflictError, + CredentialStore, + CredentialStoreError, +) + + +class MemoryBackend: + priority = 1 + + def __init__(self): + self.passwords = {} + self.fail_readback = False + self.delete_calls = [] + + def get_password(self, service, username): + if self.fail_readback and (service, username) in self.passwords: + return "different value" + return self.passwords.get((service, username)) + + def set_password(self, service, username, password): + self.passwords[(service, username)] = password + + def delete_password(self, service, username): + self.delete_calls.append((service, username)) + self.passwords.pop((service, username), None) + + +def test_constructing_store_does_not_load_or_touch_backend(): + calls = [] + store = CredentialStore( + platform="linux", + backend_factories={"linux": (lambda: calls.append("backend"),)}, + ) + + assert calls == [] + assert store._platform == "linux" + + +def test_secret_write_is_verified_and_conflicting_id_is_not_overwritten(): + backend = MemoryBackend() + store = CredentialStore( + platform="test", backend_factories={"test": (lambda: backend,)} + ) + + assert store.set_secret_verified("credential-1", "secret") == "native OS credential store" + assert store.get_secret("credential-1") == "secret" + with pytest.raises(CredentialConflictError): + store.set_secret_verified("credential-1", "replacement") + assert backend.passwords[(SERVICE_NAME, "credential-1")] == "secret" + + +def test_failed_readback_removes_new_secret(): + backend = MemoryBackend() + backend.fail_readback = True + store = CredentialStore( + platform="test", backend_factories={"test": (lambda: backend,)} + ) + + with pytest.raises(CredentialStoreError, match="save and verify"): + store.set_secret_verified("new-credential", "secret") + assert (SERVICE_NAME, "new-credential") not in backend.passwords + assert backend.delete_calls == [(SERVICE_NAME, "new-credential")] + + +def test_backend_instance_is_reused_for_write_readback_and_later_reads(): + backend = MemoryBackend() + factory_calls = [] + + def factory(): + factory_calls.append("created") + return backend + + store = CredentialStore(platform="test", backend_factories={"test": (factory,)}) + store.set_secret_verified("credential-1", "secret") + assert store.get_secret("credential-1") == "secret" + assert factory_calls == ["created"] + + +def test_only_allowlisted_platforms_are_available_without_injected_factories(): + store = CredentialStore(platform="unsupported-os") + with pytest.raises(CredentialStoreError, match="No supported native"): + store.get_secret("credential-1") diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 9eb8359..0a1e61b 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Contract tests for NoteSpaceLLM metadata, licensing, SBOM, CI hardening, and invariants.""" +"""Checks NoteSpaceLLM metadata, declared dependency coverage, and CI configuration.""" from pathlib import Path import re @@ -24,24 +24,16 @@ def test_canonical_notice_present_and_well_formed(): assert "open-bricks" in content assert "AGPL-3.0" in content - # Check key invariants INV-LOCAL-01 through INV-LOCAL-10 - for i in range(1, 11): - inv = f"INV-LOCAL-{i:02d}" - assert inv in content, f"NOTICE must document invariant {inv}" -def test_level_1_sbom_present_and_well_formed(): - sbom_file = ROOT / "THIRD_PARTY_LICENSES.md" - assert sbom_file.is_file(), "Level 1 SBOM file THIRD_PARTY_LICENSES.md must exist" - content = sbom_file.read_text(encoding="utf-8") +def test_declared_dependency_license_references_present(): + notes_file = ROOT / "THIRD_PARTY_LICENSES.md" + assert notes_file.is_file(), "THIRD_PARTY_LICENSES.md must exist" + content = notes_file.read_text(encoding="utf-8") - assert "Level 1 Software Bill of Materials" in content - assert "LGPL-3.0" in content assert "PySide6" in content assert "PyMuPDF" in content - assert "dynamische Bindung" in content or "dynamic" in content.lower() - assert "INV-LOCAL-01" in content - assert "INV-LOCAL-10" in content + assert "AGPL-3.0-only" in content # Verify requirements coverage req_file = ROOT / "requirements.txt" @@ -50,7 +42,7 @@ def test_level_1_sbom_present_and_well_formed(): if not line or line.startswith("#"): continue pkg = re.split(r"[><=~]", line)[0].strip().lower() - assert pkg in content.lower(), f"Package {pkg} must be documented in Level 1 SBOM" + assert pkg in content.lower(), f"Package {pkg} must appear in the dependency notes" def test_pep621_pyproject_metadata_and_version_freeze(): @@ -61,15 +53,16 @@ def test_pep621_pyproject_metadata_and_version_freeze(): data = tomllib.load(f) project = data.get("project", {}) - # Strict Version Freeze Rule T-20260920-167562623: Pfad A/B passes never bump versions - assert project.get("version") == "1.0.0", "Version must remain frozen at 1.0.0" + assert project.get("license") == "AGPL-3.0-only" + # The current full-care change preserves the declared application version. + assert project.get("version") == "1.0.0", "Application version must remain 1.0.0" license_files = project.get("license-files", []) for expected_file in ["LICENSE", "NOTICE", "THIRD_PARTY_LICENSES.md", "THIRD_PARTY_LICENSES.txt"]: assert expected_file in license_files, f"{expected_file} must be declared in license-files" classifiers = project.get("classifiers", []) - assert len(classifiers) >= 8, "pyproject.toml must declare comprehensive PEP 621 classifiers" + assert len(classifiers) >= 8, "pyproject.toml must retain its declared platform and topic classifiers" urls = project.get("urls", {}) assert "Homepage" in urls @@ -111,7 +104,7 @@ def test_gitignore_cloud_sync_and_lock_guards(): assert ".ruff_cache/" in content -def test_ci_workflows_hardened(): +def test_ci_workflows_have_timeouts_and_read_scope(): ci_dir = ROOT / ".github" / "workflows" tests_file = ci_dir / "tests.yml" stale_file = ci_dir / "stale.yml" @@ -130,22 +123,14 @@ def test_ci_workflows_hardened(): assert "web-companion-tests" in tests_content -def test_security_sla_and_bgb_disclaimer(): +def test_security_reporting_links_are_present(): security_file = ROOT / "SECURITY.md" assert security_file.is_file() sec_content = security_file.read_text(encoding="utf-8") - assert "48" in sec_content, "SECURITY.md must define 48h acknowledgment SLA" - assert "5" in sec_content, "SECURITY.md must define 5-day triage SLA" - assert "521 BGB" in sec_content, "SECURITY.md must include § 521 BGB statutory disclaimer" - - readme_file = ROOT / "README.md" - readme_content = readme_file.read_text(encoding="utf-8") - assert "521 BGB" in readme_content, "README.md must include § 521 BGB statutory disclaimer" - - readme_de_file = ROOT / "README_de.md" - readme_de_content = readme_de_file.read_text(encoding="utf-8") - assert "521 BGB" in readme_de_content, "README_de.md must include § 521 BGB statutory disclaimer" + assert "https://github.com/file-bricks/NoteSpaceLLM/security/advisories" in sec_content + assert "security@file-bricks.org" in sec_content + assert "support@lukasgeiger.com" in sec_content def test_marketing_log_present_and_well_formed(): diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..e526417 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,273 @@ +"""Build-contract tests for the installable src package and source archive.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tarfile +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ROOT_FILES = ( + "ARCHITECTURE.txt", + "CHANGELOG.md", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "EXPORTFORMAT.md", + "LICENSE", + "MANIFEST.in", + "NOTICE", + "NoteSpaceLLM.ico", + "PRIVACY_POLICY.md", + "README.md", + "README_de.md", + "SECURITY.md", + "STORE_LISTING.md", + "SUPPORT.md", + "THIRD_PARTY_LICENSES.md", + "THIRD_PARTY_LICENSES.txt", + "WINDOWS_STORE_PREP.md", + "build_exe.bat", + "llms.txt", + "main.py", + "manage_translations.py", + "notespacellm_launcher.py", + "pyproject.toml", + "requirements.txt", + "run.bat", + "setup.py", + "setup_environment.py", + "start.bat", + "store_package.json", + "translator.py", +) +TREE_DIRS = ("assets", "locales", "scripts", "src", "tests", "web_companion") +IGNORED_NAMES = shutil.ignore_patterns( + "__pycache__", + ".git", + ".pytest_cache", + ".pytest_temp", + ".ruff_cache", + ".venv", + "venv", + "node_modules", + "*.pyc", + "*.pyo", + "*.db", + "*.sqlite", + "*.sqlite3", + "*.log", +) +LOCAL_ONLY_DIRS = ("data", "output", "profiles", "workflows", "storage", "chroma_db", "releases") + + +def _copy_build_snapshot(destination: Path) -> Path: + project = destination / "project" + project.mkdir(parents=True) + + for name in ROOT_FILES: + source = ROOT / name + if source.is_file(): + shutil.copy2(source, project / source.name) + + for name in TREE_DIRS: + source = ROOT / name + if source.is_dir(): + shutil.copytree(source, project / name, ignore=IGNORED_NAMES) + + workflow_source = ROOT / ".github" / "workflows" + if workflow_source.is_dir(): + shutil.copytree( + workflow_source, + project / ".github" / "workflows", + ignore=IGNORED_NAMES, + ) + + return project + + +def _run(command: list[str], *, cwd: Path, env: dict[str, str], timeout: int = 240): + return subprocess.run( + command, + cwd=cwd, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + ) + + +def test_environment_bootstrap_stops_when_dependency_install_fails( + tmp_path: Path, monkeypatch, capsys +): + import runpy + + bootstrap = tmp_path / "setup_environment.py" + shutil.copy2(ROOT / "setup_environment.py", bootstrap) + namespace = runpy.run_path(str(bootstrap), run_name="setup_environment_test") + calls = [] + + def fail_dependency_install(command): + calls.append(command) + raise subprocess.CalledProcessError(23, command) + + monkeypatch.setattr(namespace["subprocess"], "check_call", fail_dependency_install) + + assert namespace["main"]() == 1 + + assert len(calls) == 1 + assert calls[0][1:4] == ["-m", "pip", "install"] + assert not any( + (tmp_path / dirname).exists() + for dirname in ("data", "workflows", "profiles", "output", "assets") + ) + output = capsys.readouterr().out + assert "Dependency installation failed" in output + assert "Setup complete!" not in output + + +def test_setup_metadata_command_does_not_run_environment_bootstrap(tmp_path: Path): + project = _copy_build_snapshot(tmp_path) + # The bootstrap normally creates this directory, so leave it absent in + # this metadata-only fixture even though it is a source asset in the repo. + if (project / "assets").exists(): + shutil.rmtree(project / "assets") + fake_pip = project / "pip" + fake_pip.mkdir() + (fake_pip / "__init__.py").write_text("", encoding="utf-8") + (fake_pip / "__main__.py").write_text( + "import os\nfrom pathlib import Path\n" + "Path(os.environ['NOTESPACELLM_PIP_MARKER']).touch()\n" + "raise SystemExit(87)\n", + encoding="utf-8", + ) + + marker = tmp_path / "pip-was-called" + env = os.environ.copy() + env["NOTESPACELLM_PIP_MARKER"] = str(marker) + env["PYTHONDONTWRITEBYTECODE"] = "1" + result = _run([sys.executable, "setup.py", "--name"], cwd=project, env=env) + + assert result.returncode == 0, result.stdout + result.stderr + assert "notespacellm" in result.stdout.lower() + assert not marker.exists(), "metadata inspection must never invoke the bootstrap pip install" + for name in ("data", "workflows", "profiles", "output", "assets"): + assert not (project / name).exists(), f"metadata inspection created {name}/" + + +def test_sdist_wheel_and_installed_src_runtime_contract(tmp_path: Path): + project = _copy_build_snapshot(tmp_path) + for dirname in LOCAL_ONLY_DIRS: + local_dir = project / dirname + local_dir.mkdir(parents=True, exist_ok=True) + (local_dir / "synthetic-local-state-sentinel.txt").write_text( + "synthetic data that must not ship", encoding="utf-8" + ) + + dist = tmp_path / "dist" + dist.mkdir() + build_env = os.environ.copy() + build_env["PYTHONDONTWRITEBYTECODE"] = "1" + build = _run( + [ + sys.executable, + "-m", + "build", + "--no-isolation", + "--sdist", + "--wheel", + "--outdir", + str(dist), + ], + cwd=project, + env=build_env, + timeout=360, + ) + assert build.returncode == 0, build.stdout + build.stderr + + wheels = list(dist.glob("*.whl")) + sdists = list(dist.glob("*.tar.gz")) + assert len(wheels) == 1 + assert len(sdists) == 1 + + with zipfile.ZipFile(wheels[0]) as archive: + wheel_names = set(archive.namelist()) + assert "src/__init__.py" in wheel_names + assert "src/rag/engine.py" in wheel_names + assert "src/core/workspace_exporter.py" in wheel_names + assert any(name.endswith(".dist-info/licenses/LICENSE") for name in wheel_names) + metadata_path = next(name for name in wheel_names if name.endswith(".dist-info/METADATA")) + metadata = archive.read(metadata_path).decode("utf-8") + metadata_lines = metadata.splitlines() + assert "Version: 1.0.0" in metadata_lines + assert "License-Expression: AGPL-3.0-only" in metadata_lines + assert "Requires-Dist: PySide6" in metadata + assert "Requires-Dist: chromadb" in metadata + assert "Requires-Dist: keyring" in metadata + assert not any("synthetic-local-state-sentinel" in name for name in wheel_names) + assert not any(name.startswith(tuple(f"{d}/" for d in LOCAL_ONLY_DIRS)) for name in wheel_names) + + with tarfile.open(sdists[0], "r:gz") as archive: + sdist_names = {name.split("/", 1)[1] for name in archive.getnames() if "/" in name} + for expected in ( + "src/__init__.py", + "src/rag/engine.py", + "tests/test_packaging.py", + "LICENSE", + "NOTICE", + "THIRD_PARTY_LICENSES.md", + "setup_environment.py", + "MANIFEST.in", + "web_companion/index.html", + ): + assert expected in sdist_names, f"sdist omitted {expected}" + assert not any("synthetic-local-state-sentinel" in name for name in sdist_names) + assert not any(name.split("/", 1)[0] in LOCAL_ONLY_DIRS for name in sdist_names) + + target = tmp_path / "installed-target" + install = _run( + [ + sys.executable, + "-m", + "pip", + "install", + "--no-deps", + "--no-index", + "--target", + str(target), + str(wheels[0]), + ], + cwd=tmp_path, + env=build_env, + timeout=180, + ) + assert install.returncode == 0, install.stdout + install.stderr + + import_env = build_env.copy() + import_env.pop("PYTHONPATH", None) + import_env["PYTHONPATH"] = str(target) + import_env["HOME"] = str(tmp_path / "isolated-home") + import_env["USERPROFILE"] = str(tmp_path / "isolated-home") + import_env["APPDATA"] = str(tmp_path / "isolated-appdata") + import_smoke = _run( + [ + sys.executable, + "-c", + ( + "import src; import src.core.workspace_exporter; " + "import src.rag.engine; import src.rag.embeddings; " + "print(src.__file__)" + ), + ], + cwd=tmp_path, + env=import_env, + timeout=120, + ) + assert import_smoke.returncode == 0, import_smoke.stdout + import_smoke.stderr + assert str(target) in import_smoke.stdout diff --git a/tests/test_security_license_contract.py b/tests/test_security_license_contract.py index 268756d..76932c9 100644 --- a/tests/test_security_license_contract.py +++ b/tests/test_security_license_contract.py @@ -1,10 +1,9 @@ # -*- coding: utf-8 -*- -"""Security and License Contract Test Suite for NoteSpaceLLM. +"""Scoped security-policy and dependency-documentation checks for NoteSpaceLLM. -Validates dependency vulnerability floors, complete third-party license -inventory, .gitignore sync conflict and lock patterns, absence of hardcoded -developer paths / secrets, offline zero-egress guarantees of core parsers, -and bilingual security policy standards. +Checks selected declared dependency constraints and license notes, .gitignore +patterns, absence of hardcoded developer paths / secrets, direct network-client +imports in selected core modules, and bilingual security-reporting targets. """ from __future__ import annotations @@ -20,20 +19,20 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] -def test_dependency_vulnerability_floors() -> None: - """Ensure declared dependency versions meet security floors.""" +def test_declared_dependency_version_constraints() -> None: + """Check selected dependency constraints declared by the project.""" pyproject_text = (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8") pyproject_data = tomllib.loads(pyproject_text) deps = pyproject_data.get("project", {}).get("dependencies", []) assert any("PySide6>=6.5.0" in d for d in deps), ( - "pyproject.toml dependencies must declare PySide6>=6.5.0" + "pyproject.toml must retain the declared PySide6 lower bound" ) assert any("PyMuPDF>=1.24.10" in d for d in deps), ( "pyproject.toml dependencies must declare PyMuPDF>=1.24.10" ) assert any("openpyxl>=3.1.3" in d for d in deps), ( - "pyproject.toml dependencies must declare openpyxl>=3.1.3 to mitigate CVE-2024-34064" + "pyproject.toml must retain the declared openpyxl lower bound" ) assert any("chromadb>=0.5.4" in d for d in deps), ( "pyproject.toml dependencies must declare chromadb>=0.5.4" @@ -41,6 +40,9 @@ def test_dependency_vulnerability_floors() -> None: assert any("python-docx>=1.1.0" in d for d in deps), ( "pyproject.toml dependencies must declare python-docx>=1.1.0" ) + assert any("keyring>=25.7.0" in d for d in deps), ( + "pyproject.toml dependencies must declare keyring>=25.7.0" + ) opt_deps = pyproject_data.get("project", {}).get("optional-dependencies", {}) test_deps = opt_deps.get("test", []) @@ -48,7 +50,7 @@ def test_dependency_vulnerability_floors() -> None: all_test_deps = test_deps + dev_deps assert any("pytest>=9.1.1" in d for d in all_test_deps), ( - "pytest floor must be >=9.1.1 to protect against CVE-2025-7117 / GHSA-6w46-j5rx-g56g" + "the test dependency group must retain the declared pytest lower bound" ) req_text = (PROJECT_ROOT / "requirements.txt").read_text(encoding="utf-8") @@ -56,10 +58,11 @@ def test_dependency_vulnerability_floors() -> None: assert "PyMuPDF>=1.24.10" in req_text, "requirements.txt must declare PyMuPDF>=1.24.10" assert "openpyxl>=3.1.3" in req_text, "requirements.txt must declare openpyxl>=3.1.3" assert "chromadb>=0.5.4" in req_text, "requirements.txt must declare chromadb>=0.5.4" + assert "keyring>=25.7.0" in req_text, "requirements.txt must declare keyring>=25.7.0" -def test_third_party_licenses_inventory_complete() -> None: - """Ensure THIRD_PARTY_LICENSES.txt comprehensively covers dependencies.""" +def test_selected_declared_dependency_license_notes() -> None: + """Ensure selected package and license references are present.""" tpl_path = PROJECT_ROOT / "THIRD_PARTY_LICENSES.txt" assert tpl_path.is_file(), "THIRD_PARTY_LICENSES.txt must exist" text = tpl_path.read_text(encoding="utf-8") @@ -77,6 +80,7 @@ def test_third_party_licenses_inventory_complete() -> None: "langchain-ollama", "langchain-chroma", "chromadb", + "keyring", "pytest", "ruff", "PyInstaller", @@ -87,15 +91,7 @@ def test_third_party_licenses_inventory_complete() -> None: assert "License:" in text, "Missing License entries in inventory" assert "URL:" in text, "Missing URL entries in inventory" - # Verify sections exist - assert "1. Runtime Dependencies" in text - assert "2. Optional Integrations & Backends" in text - assert "3. Development, Build & Test Dependencies" in text - - # Verify copyleft & compatibility analysis assert "AGPL-3.0" in text - assert "LGPL-3.0-only" in text - assert "License Compatibility & Copyleft Analysis" in text def test_gitignore_security_and_conflict_hardening() -> None: @@ -152,8 +148,8 @@ def test_no_hardcoded_user_paths_or_dev_secrets() -> None: ) -def test_offline_core_document_parsers_zero_egress() -> None: - """Verify core document extraction and project modules perform zero network calls.""" +def test_selected_core_modules_do_not_import_network_clients_directly() -> None: + """Check direct imports in selected modules without claiming zero egress.""" forbidden_modules = { "urllib.request", "requests", @@ -188,18 +184,14 @@ def test_offline_core_document_parsers_zero_egress() -> None: ) -def test_security_policy_bilingual_and_sla() -> None: - """Verify SECURITY.md contains bilingual policy, contacts, and 48h SLA.""" +def test_security_policy_bilingual_reporting_channels() -> None: + """Verify the bilingual policy includes its reporting routes.""" sec_path = PROJECT_ROOT / "SECURITY.md" assert sec_path.is_file(), "SECURITY.md must exist" text = sec_path.read_text(encoding="utf-8") assert "## Deutsch" in text assert "## English" in text - assert "48 Stunden" in text - assert "48 hours" in text - assert "5 Werktagen" in text - assert "5 business days" in text assert "security@file-bricks.org" in text assert "support@lukasgeiger.com" in text assert "https://github.com/file-bricks/NoteSpaceLLM/security/advisories" in text diff --git a/tests/test_text_extractor_and_rag.py b/tests/test_text_extractor_and_rag.py index cebd629..f3e10d6 100644 --- a/tests/test_text_extractor_and_rag.py +++ b/tests/test_text_extractor_and_rag.py @@ -4,13 +4,121 @@ Tests for TextExtractor and DocumentManager RAG metadata integration. """ +import logging +import sys import tempfile -import pytest from pathlib import Path +from types import ModuleType from unittest.mock import MagicMock -from src.core.text_extractor import TextExtractor, ExtractionResult -from src.core.document_manager import DocumentManager, DocumentItem, DocumentStatus +import pytest + +from src.core.document_manager import DocumentItem, DocumentManager, DocumentStatus +from src.core.text_extractor import ExtractionResult, TextExtractor + + +def _stub_external_langchain_clients(monkeypatch, request): + """Stub provider/vector bindings; these tests never call them.""" + original_rag_modules = { + name: module + for name, module in sys.modules.items() + if name == "src.rag" or name.startswith("src.rag.") + } + for name in original_rag_modules: + del sys.modules[name] + + def restore_rag_modules(): + for name in tuple(sys.modules): + if name == "src.rag" or name.startswith("src.rag."): + del sys.modules[name] + sys.modules.update(original_rag_modules) + + request.addfinalizer(restore_rag_modules) + + chroma_module = ModuleType("langchain_chroma") + ollama_module = ModuleType("langchain_ollama") + chromadb_module = ModuleType("chromadb") + chromadb_module.__path__ = [] + chromadb_config_module = ModuleType("chromadb.config") + + class TestChromaSettings: + def __init__(self, *, anonymized_telemetry=True): + self.anonymized_telemetry = anonymized_telemetry + + chromadb_config_module.Settings = TestChromaSettings + chromadb_module.config = chromadb_config_module + + class UnusedChroma: + pass + + class UnusedOllamaClient: + pass + + chroma_module.Chroma = UnusedChroma + ollama_module.ChatOllama = UnusedOllamaClient + ollama_module.OllamaEmbeddings = UnusedOllamaClient + monkeypatch.setitem(sys.modules, "langchain_chroma", chroma_module) + monkeypatch.setitem(sys.modules, "langchain_ollama", ollama_module) + monkeypatch.setitem(sys.modules, "chromadb", chromadb_module) + monkeypatch.setitem(sys.modules, "chromadb.config", chromadb_config_module) + + +def test_local_chroma_client_disables_anonymized_telemetry(monkeypatch, request, tmp_path): + _stub_external_langchain_clients(monkeypatch, request) + from src.rag import engine as engine_module + + captured = {} + + class FakeEmbeddingsManager: + def __init__(self, **_kwargs): + self.embeddings = object() + + class FakeChatClient: + def __init__(self, **_kwargs): + pass + + class FakeChroma: + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(engine_module, "EmbeddingsManager", FakeEmbeddingsManager) + monkeypatch.setattr(engine_module, "ChatOllama", FakeChatClient) + monkeypatch.setattr(engine_module, "Chroma", FakeChroma) + + rag = engine_module.RAGEngine(persist_directory=str(tmp_path / "vector-store")) + _ = rag.vectorstore + + assert captured["client_settings"].anonymized_telemetry is False + + +def test_installed_langchain_ollama_clients_apply_auth_headers_without_requests(): + """Exercise the real LangChain Ollama constructors without opening a connection.""" + from langchain_ollama import ChatOllama, OllamaEmbeddings + + marker = "notespace-test-only-sentinel" + headers = {"Authorization": f"Bearer {marker}"} + base_url = "http://127.0.0.1:1" + clients = ( + ChatOllama( + model="notespace-test-model", + base_url=base_url, + temperature=0.3, + client_kwargs={"headers": headers}, + ), + OllamaEmbeddings( + model="notespace-test-embedding", + base_url=base_url, + client_kwargs={"headers": headers}, + ), + ) + + for client in clients: + http_client = client._client._client + try: + assert str(http_client.base_url) == base_url + assert http_client.headers.get("authorization") == f"Bearer {marker}" + finally: + http_client.close() def test_rtf_extraction_umlauts_and_metadata_stripping(tmp_path): @@ -140,3 +248,128 @@ def test_document_manager_index_tags_serialization(tmp_path): assert isinstance(metadata.get("tags"), str) assert "finanzen" in metadata["tags"] assert "analyse" in metadata["tags"] + + +def test_authenticated_embedding_setup_error_never_logs_key_or_url(monkeypatch, request, caplog): + _stub_external_langchain_clients(monkeypatch, request) + from src.rag import embeddings as embeddings_module + + secret = "test-bearer-secret" + userinfo = "user:pass@remote.example" + calls = [] + + class FailingEmbeddings: + def __init__(self, **kwargs): + calls.append(kwargs) + raise ValueError(f"bad endpoint https://{userinfo}; Bearer {secret}") + + monkeypatch.setattr(embeddings_module, "OllamaEmbeddings", FailingEmbeddings) + manager = embeddings_module.EmbeddingsManager( + base_url=f"https://{userinfo}", + headers={"Authorization": f"Bearer {secret}"}, + ) + + with caplog.at_level(logging.WARNING), pytest.raises(RuntimeError) as raised: + manager._create_embeddings() + + assert len(calls) == 1 + assert calls[0]["client_kwargs"]["headers"]["Authorization"] == f"Bearer {secret}" + assert secret not in str(raised.value) + assert userinfo not in str(raised.value) + assert secret not in caplog.text + assert userinfo not in caplog.text + + +def test_authenticated_rag_client_error_does_not_retry_without_headers( + monkeypatch, request, tmp_path, caplog +): + _stub_external_langchain_clients(monkeypatch, request) + from src.rag import engine as engine_module + + secret = "test-bearer-secret" + userinfo = "user:pass@remote.example" + calls = [] + + class FakeEmbeddingsManager: + def __init__(self, **kwargs): + self.model_name = kwargs["model_name"] + self.embeddings = object() + + class FailingChatClient: + def __init__(self, **kwargs): + calls.append(kwargs) + raise ValueError(f"bad endpoint https://{userinfo}; Bearer {secret}") + + monkeypatch.setattr(engine_module, "EmbeddingsManager", FakeEmbeddingsManager) + monkeypatch.setattr(engine_module, "ChatOllama", FailingChatClient) + + with caplog.at_level(logging.WARNING), pytest.raises(RuntimeError) as raised: + engine_module.RAGEngine( + persist_directory=str(tmp_path / "vector-store"), + ollama_base_url=f"https://{userinfo}", + api_key=secret, + ) + + assert len(calls) == 1 + assert calls[0]["client_kwargs"]["headers"]["Authorization"] == f"Bearer {secret}" + assert secret not in str(raised.value) + assert userinfo not in str(raised.value) + assert secret not in caplog.text + assert userinfo not in caplog.text + + +def test_ollama_request_error_redacts_bearer_and_url_userinfo(monkeypatch): + import urllib.error + + from src.llm import ollama_client as ollama_module + + secret = "test-bearer-secret" + userinfo = "user:pass@remote.example" + requests = [] + + def fail_request(request, timeout): + requests.append(request) + assert timeout == 300 + raise urllib.error.URLError(f"https://{userinfo}/api/chat Bearer {secret}") + + monkeypatch.setattr(ollama_module.urllib.request, "urlopen", fail_request) + client = ollama_module.OllamaClient( + model="test-model", + base_url=f"https://{userinfo}", + api_key=secret, + ) + client._is_available = True + + with pytest.raises(RuntimeError) as raised: + client.chat("test prompt") + + assert len(requests) == 1 + assert requests[0].get_header("Authorization") == f"Bearer {secret}" + assert secret not in str(raised.value) + assert userinfo not in str(raised.value) + + +def test_rag_worker_emits_sanitized_provider_error(caplog): + from src.gui import chat_panel + + if not chat_panel.PYSIDE_AVAILABLE: + pytest.skip("PySide6 is not installed") + + secret = "test-bearer-secret" + userinfo = "user:pass@remote.example" + + class FailingEngine: + def query(self, **_kwargs): + raise RuntimeError(f"https://{userinfo} Bearer {secret}") + + worker = chat_panel.RAGWorker(FailingEngine(), "question") + errors = [] + worker.error_occurred.connect(errors.append) + with caplog.at_level(logging.ERROR): + worker.run() + + assert len(errors) == 1 + assert secret not in errors[0] + assert userinfo not in errors[0] + assert secret not in caplog.text + assert userinfo not in caplog.text diff --git a/tests/test_ux_improvements.py b/tests/test_ux_improvements.py index 8975448..1c49ae6 100644 --- a/tests/test_ux_improvements.py +++ b/tests/test_ux_improvements.py @@ -7,12 +7,13 @@ import sys import pytest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock try: - from PySide6.QtWidgets import QApplication, QAction, QLabel, QPushButton + from PySide6.QtWidgets import QApplication, QLabel, QPushButton + from PySide6.QtGui import QAction from PySide6.QtCore import Qt - PYSIDE_AVAILABLE = True + PYSIDE_AVAILABLE = QAction is not None except ImportError: PYSIDE_AVAILABLE = False @@ -104,14 +105,7 @@ def test_pipeline_label_has_tooltip(self): @requires_pyside def test_update_pipeline_phase_no_project(self): """_update_pipeline_phase läuft ohne Projekt ohne Exception.""" - import importlib.util - spec = importlib.util.spec_from_file_location("mw2", "src/gui/main_window.py") - mod = importlib.util.module_from_spec(spec) - with patch.dict('sys.modules', { - 'langchain_chroma': MagicMock(), - 'chromadb': MagicMock(), - }): - spec.loader.exec_module(mod) + from src.gui import main_window as mod obj = MagicMock() obj._pipeline_label = MagicMock() @@ -123,14 +117,7 @@ def test_update_pipeline_phase_no_project(self): @requires_pyside def test_update_pipeline_phase_with_unextracted_docs(self): """Pipeline-Label zeigt 'extrahieren' wenn Docs ohne Text vorhanden.""" - import importlib.util - spec = importlib.util.spec_from_file_location("mw3", "src/gui/main_window.py") - mod = importlib.util.module_from_spec(spec) - with patch.dict('sys.modules', { - 'langchain_chroma': MagicMock(), - 'chromadb': MagicMock(), - }): - spec.loader.exec_module(mod) + from src.gui import main_window as mod mock_doc = MagicMock() mock_doc.is_directory = False @@ -152,14 +139,7 @@ def test_update_pipeline_phase_with_unextracted_docs(self): @requires_pyside def test_update_pipeline_phase_with_extracted_docs(self): """Pipeline-Label zeigt 'Bericht erstellen' wenn alle Docs extrahiert.""" - import importlib.util - spec = importlib.util.spec_from_file_location("mw4", "src/gui/main_window.py") - mod = importlib.util.module_from_spec(spec) - with patch.dict('sys.modules', { - 'langchain_chroma': MagicMock(), - 'chromadb': MagicMock(), - }): - spec.loader.exec_module(mod) + from src.gui import main_window as mod mock_doc = MagicMock() mock_doc.is_directory = False diff --git a/tests/test_worker_threads.py b/tests/test_worker_threads.py index c1c36d6..82bd441 100644 --- a/tests/test_worker_threads.py +++ b/tests/test_worker_threads.py @@ -88,6 +88,57 @@ def test_unreachable_server_emits_empty_list(self, qt_app): assert "erreichbar" in msg.lower() or "fehler" in msg.lower(), \ f"Status-Meldung soll Fehler kommunizieren: '{msg}'" + def test_provider_exception_does_not_reach_signal_or_log(self, monkeypatch, caplog): + from src.gui.main_window import ModelLoadWorker + from src.llm.ollama_client import OllamaClient + + secret = "model-worker-secret" + userinfo = "user:pass@provider.example" + + def fail_availability(_client): + raise ValueError(f"https://{userinfo}/api/tags Bearer {secret}") + + monkeypatch.setattr(OllamaClient, "_check_availability", fail_availability) + worker = ModelLoadWorker(f"https://{userinfo}/api", secret) + received = [] + worker.models_loaded.connect(lambda models, message: received.append((models, message))) + + with caplog.at_level("ERROR"): + worker.run() + + assert len(received) == 1 + assert received[0][0] == [] + assert "ValueError" in received[0][1] + assert secret not in received[0][1] + assert userinfo not in received[0][1] + assert secret not in caplog.text + assert userinfo not in caplog.text + + +def test_llm_worker_sanitizes_provider_exception_before_ui_signal(monkeypatch, caplog): + from src.gui.chat_panel import LLMWorker + + secret = "chat-worker-secret" + userinfo = "user:pass@provider.example" + + class FailingClient: + def stream_chat(self, *_args): + raise RuntimeError(f"https://{userinfo}/chat Bearer {secret}") + + worker = LLMWorker(FailingClient(), "question", "") + received = [] + worker.error_occurred.connect(received.append) + + with caplog.at_level("ERROR"): + worker.run() + + assert len(received) == 1 + assert "RuntimeError" in received[0] + assert secret not in received[0] + assert userinfo not in received[0] + assert secret not in caplog.text + assert userinfo not in caplog.text + def test_worker_is_not_gui_thread(self, qt_app): from src.gui.main_window import ModelLoadWorker @@ -189,7 +240,8 @@ def test_analysis_error_emits_error_signal(self, qt_app): assert len(results) == 1 assert results[0][0] == "q1" assert results[0][1] == "" - assert "LLM nicht erreichbar" in results[0][2] + assert "ConnectionError" in results[0][2] + assert "LLM nicht erreichbar" not in results[0][2] def test_analysis_success_emits_response(self, qt_app): from src.gui.main_window import AnalysisWorker diff --git a/tests/test_workspace_export.py b/tests/test_workspace_export.py index 16060f8..b11ab06 100644 --- a/tests/test_workspace_export.py +++ b/tests/test_workspace_export.py @@ -3,18 +3,20 @@ Verifiziert: - Schema-Version und Pflichtfelder vorhanden -- Datenschutzregeln: keine API-Schlüssel, keine Rohdaten +- Datenschutzregeln: keine gespeicherten API-Schlüsselfelder, keine vollständigen Rohdaten - Sub-Query-Auszüge werden korrekt eingebunden - Atomares Schreiben (Temp-Datei + replace) - Export abbruchbar wenn kein Projekt """ import json -import pytest import tempfile from pathlib import Path +from typing import ClassVar from unittest.mock import MagicMock, patch +import pytest + @pytest.fixture def mock_project(): @@ -34,6 +36,7 @@ def mock_project(): project.settings.language = "de" project.settings.llm_provider = "ollama" project.settings.llm_model = "llama3" + project.settings.ollama_base_url = "http://localhost:11434" project.documents.documents = [doc] project.subqueries.queries = [] return project @@ -86,8 +89,8 @@ def test_privacy_no_raw_content(self, mock_project): for doc in payload["documents"]: assert "extracted_text" not in doc - def test_privacy_no_api_keys(self, mock_project): - """API-Schlüssel dürfen nicht im Export erscheinen.""" + def test_saved_api_key_fields_are_not_exported(self, mock_project): + """Provider credential fields are excluded from the export payload.""" from src.core.workspace_exporter import build_workspace_export_payload payload = build_workspace_export_payload(mock_project) payload_str = json.dumps(payload) @@ -158,8 +161,8 @@ class TestWorkspaceSubQueryExcerpts: """Sub-Query-Auszüge werden korrekt in das Export-Format überführt.""" def test_completed_subqueries_become_excerpts(self, mock_project): - from src.core.workspace_exporter import build_workspace_export_payload from src.core.sub_query import SubQueryStatus, SubQueryType + from src.core.workspace_exporter import build_workspace_export_payload sq = MagicMock() sq.id = "sq-1" @@ -177,14 +180,14 @@ def test_completed_subqueries_become_excerpts(self, mock_project): assert "Das Hauptthema ist..." in doc["excerpts"][0]["text"] def test_pending_subqueries_excluded(self, mock_project): - from src.core.workspace_exporter import build_workspace_export_payload from src.core.sub_query import SubQueryStatus, SubQueryType + from src.core.workspace_exporter import build_workspace_export_payload sq = MagicMock() sq.id = "sq-2" sq.document_id = "doc-1" sq.query_text = "Frage..." - sq.result_text = "" + sq.result_text = "This result is still pending and must not be exported." sq.status = SubQueryStatus.PENDING sq.query_type = SubQueryType.ANALYZE @@ -192,9 +195,62 @@ def test_pending_subqueries_excluded(self, mock_project): payload = build_workspace_export_payload(mock_project, include_subquery_excerpts=True) assert payload["documents"][0]["excerpts"] == [] - def test_excerpt_text_truncated_at_2000_chars(self, mock_project): + def test_only_selected_documents_receive_completed_excerpts(self, mock_project): + from src.core.sub_query import SubQueryStatus, SubQueryType from src.core.workspace_exporter import build_workspace_export_payload + + unselected = MagicMock() + unselected.id = "doc-2" + unselected.name = "private-not-selected.txt" + unselected.extension = ".txt" + unselected.is_selected = False + unselected.is_directory = False + mock_project.documents.documents.append(unselected) + + selected_result = MagicMock() + selected_result.id = "sq-selected" + selected_result.document_id = "doc-1" + selected_result.query_text = "Selected document question" + selected_result.result_text = "selected excerpt" + selected_result.status = SubQueryStatus.COMPLETED + selected_result.query_type = SubQueryType.QUESTION + + unselected_result = MagicMock() + unselected_result.id = "sq-unselected" + unselected_result.document_id = "doc-2" + unselected_result.query_text = "Unselected document question" + unselected_result.result_text = "must not appear" + unselected_result.status = SubQueryStatus.COMPLETED + unselected_result.query_type = SubQueryType.QUESTION + + mock_project.subqueries.queries = [selected_result, unselected_result] + payload = build_workspace_export_payload(mock_project) + + assert payload["documents"][0]["excerpts"][0]["text"] == "selected excerpt" + assert payload["documents"][1]["excerpts"] == [] + + def test_disabled_subquery_excerpts_are_not_exported(self, mock_project): + from src.core.sub_query import SubQueryStatus, SubQueryType + from src.core.workspace_exporter import build_workspace_export_payload + + sq = MagicMock() + sq.id = "sq-disabled" + sq.document_id = "doc-1" + sq.query_text = "Question" + sq.result_text = "completed but not requested" + sq.status = SubQueryStatus.COMPLETED + sq.query_type = SubQueryType.QUESTION + mock_project.subqueries.queries = [sq] + + payload = build_workspace_export_payload( + mock_project, + include_subquery_excerpts=False, + ) + assert payload["documents"][0]["excerpts"] == [] + + def test_excerpt_text_truncated_at_2000_chars(self, mock_project): from src.core.sub_query import SubQueryStatus, SubQueryType + from src.core.workspace_exporter import build_workspace_export_payload sq = MagicMock() sq.id = "sq-3" @@ -214,7 +270,10 @@ class TestWorkspaceFileExport: """Atomares Schreiben der Workspace-Datei.""" def test_export_creates_valid_json_file(self, mock_project, tmp_path): - from src.core.workspace_exporter import build_workspace_export_payload, export_workspace_to_file + from src.core.workspace_exporter import ( + build_workspace_export_payload, + export_workspace_to_file, + ) payload = build_workspace_export_payload(mock_project) dest = tmp_path / "workspace.json" export_workspace_to_file(payload, dest) @@ -223,7 +282,10 @@ def test_export_creates_valid_json_file(self, mock_project, tmp_path): assert loaded["schema_version"] == "notespacellm-workspace-v1" def test_export_utf8_encoding(self, mock_project, tmp_path): - from src.core.workspace_exporter import build_workspace_export_payload, export_workspace_to_file + from src.core.workspace_exporter import ( + build_workspace_export_payload, + export_workspace_to_file, + ) mock_project.name = "Ünïcödë Prøjèct" payload = build_workspace_export_payload(mock_project) dest = tmp_path / "workspace_utf8.json" @@ -232,7 +294,10 @@ def test_export_utf8_encoding(self, mock_project, tmp_path): assert "Ünïcödë" in content def test_export_no_temp_file_left_on_success(self, mock_project, tmp_path): - from src.core.workspace_exporter import build_workspace_export_payload, export_workspace_to_file + from src.core.workspace_exporter import ( + build_workspace_export_payload, + export_workspace_to_file, + ) payload = build_workspace_export_payload(mock_project) dest = tmp_path / "workspace.json" export_workspace_to_file(payload, dest) @@ -240,8 +305,167 @@ def test_export_no_temp_file_left_on_success(self, mock_project, tmp_path): assert len(tmp_files) == 0, "Keine Temp-Dateien nach erfolgreichem Export" def test_export_creates_parent_directory(self, mock_project, tmp_path): - from src.core.workspace_exporter import build_workspace_export_payload, export_workspace_to_file + from src.core.workspace_exporter import ( + build_workspace_export_payload, + export_workspace_to_file, + ) payload = build_workspace_export_payload(mock_project) dest = tmp_path / "subdir" / "workspace.json" export_workspace_to_file(payload, dest) assert dest.exists() + + def test_cancel_does_not_create_or_overwrite_export(self, tmp_path): + from src.core.workspace_exporter import export_workspace_if_confirmed + + destination = tmp_path / "cancelled.json" + assert not export_workspace_if_confirmed({"private": "content"}, destination, False) + assert not destination.exists() + + destination.write_text("existing file", encoding="utf-8") + assert not export_workspace_if_confirmed({"private": "content"}, destination, False) + assert destination.read_text(encoding="utf-8") == "existing file" + + +class TestWorkspaceExportPrivacy: + def test_confirmation_describes_full_user_content_and_non_anonymous_export(self, mock_project): + from src.core.sub_query import SubQueryStatus, SubQueryType + from src.core.workspace_exporter import ( + build_workspace_export_payload, + workspace_export_confirmation_text, + ) + + sq = MagicMock() + sq.id = "sq-confirm" + sq.document_id = "doc-1" + sq.query_text = "Question" + sq.result_text = "selected excerpt" + sq.status = SubQueryStatus.COMPLETED + sq.query_type = SubQueryType.QUESTION + mock_project.subqueries.queries = [sq] + + payload = build_workspace_export_payload( + mock_project, + report_content="Private report text", + chat_history=[{"role": "user", "content": "Private chat text"}], + ) + confirmation = workspace_export_confirmation_text(payload) + + assert "vollständiger Inhalt" in confirmation + assert "vollständigen Chatverlauf mit 1 Nachrichten" in confirmation + assert "1 abgeschlossene Auszüge aus 1 ausgewählten Dokument(en)" in confirmation + assert "nicht anonymisiert" in confirmation + assert "vertrauliche Angaben" in confirmation + assert "gespeicherte api-schlüsselfelder" in confirmation.lower() + assert "nicht auf Geheimnisse geprüft" in confirmation + assert "prüfe bericht und chat vor dem weitergeben" in confirmation.lower() + + @pytest.mark.parametrize( + ("provider", "expected_mode"), + [ + ("ollama", "local"), + ("openai", "remote"), + ("anthropic", "remote"), + ("claude-code", "remote"), + ("local", "unknown"), + ("unsupported-provider", "unknown"), + ("", "unknown"), + ], + ) + def test_provider_mode_only_classifies_supported_providers(self, mock_project, provider, expected_mode): + from src.core.workspace_exporter import build_workspace_export_payload + + mock_project.settings.llm_provider = provider + if provider == "ollama": + mock_project.settings.ollama_base_url = "http://127.0.0.1:11434" + payload = build_workspace_export_payload(mock_project) + + assert payload["provider"]["mode"] == expected_mode + + @pytest.mark.parametrize( + ("url", "expected"), + [ + ("http://localhost:11434", "local"), + ("https://127.0.0.1:11434", "local"), + ("http://[::1]:11434", "local"), + ("https://localhost.evil.example/api", "remote"), + ("https://remote.example/api", "remote"), + ("https://user:secret@localhost:11434", "unknown"), + ("ftp://localhost:11434", "unknown"), + ("http://localhost:not-a-port", "unknown"), + ("http://[not-ipv6]", "unknown"), + ("localhost:11434", "unknown"), + ], + ) + def test_endpoint_classification_without_network(self, url, expected): + from src.core.workspace_exporter import _classify_ollama_endpoint + + assert _classify_ollama_endpoint(url) == expected + + def test_provider_export_never_includes_configured_url(self, mock_project): + from src.core.workspace_exporter import build_workspace_export_payload + + mock_project.settings.ollama_base_url = "https://user:password@remote.example/api" + payload = build_workspace_export_payload(mock_project) + serialized = json.dumps(payload) + + assert payload["provider"]["mode"] == "unknown" + assert "ollama_base_url" not in serialized + assert "remote.example" not in serialized + assert "password" not in serialized + + @pytest.mark.parametrize("answer_yes", [False, True]) + def test_gui_requires_confirmation_before_writing(self, mock_project, tmp_path, monkeypatch, answer_yes): + pytest.importorskip("PySide6") + from types import SimpleNamespace + + import src.gui.main_window as main_window_module + + class FakeMessageBox: + class StandardButton: + Yes = 1 + No = 2 + + questions: ClassVar[list] = [] + + @classmethod + def question(cls, *_args): + cls.questions.append(_args[2]) + return cls.StandardButton.Yes if answer_yes else cls.StandardButton.No + + @staticmethod + def information(*_args): + return None + + @staticmethod + def critical(*_args): + raise AssertionError("a controlled export should not fail") + + class FakeFileDialog: + @staticmethod + def getSaveFileName(*_args): + return str(tmp_path / "ui-export.json"), "JSON (*.json)" + + messages = [] + fake_window = SimpleNamespace( + _current_project=mock_project, + output_panel=SimpleNamespace( + get_output_directory=lambda: tmp_path, + get_content=lambda: "Potentially sensitive report", + ), + chat_panel=SimpleNamespace(get_messages=list), + statusbar=SimpleNamespace(showMessage=messages.append), + ) + monkeypatch.setattr(main_window_module, "QMessageBox", FakeMessageBox) + monkeypatch.setattr(main_window_module, "QFileDialog", FakeFileDialog) + + main_window_module.MainWindow._export_workspace(fake_window) + + destination = tmp_path / "ui-export.json" + assert "nicht anonymisiert" in FakeMessageBox.questions[0] + assert destination.exists() is answer_yes + if answer_yes: + exported = json.loads(destination.read_text(encoding="utf-8")) + assert exported["report"]["content"] == "Potentially sensitive report" + assert "prüfe bericht und chat vor dem weitergeben" in FakeMessageBox.questions[0].lower() + else: + assert messages[-1] == "Workspace-Export abgebrochen." diff --git a/web_companion/PWA_TESTPLAN.md b/web_companion/PWA_TESTPLAN.md index b0039ec..59f8743 100644 --- a/web_companion/PWA_TESTPLAN.md +++ b/web_companion/PWA_TESTPLAN.md @@ -8,7 +8,7 @@ Dieser Testplan prüft genau den mobilen P2-Pfad des Companions: - Installierbarkeit als PWA auf Android und iOS - lokaler JSON-Import ohne Server-Upload -- Offline-Start mit zuletzt lokal gespeichertem Workspace +- Offline-Start der PWA-Shell; ein Workspace muss nach erneutem Öffnen erneut importiert werden - Export lokaler Review-Notizen ## Voraussetzungen @@ -34,12 +34,12 @@ URL: `http://127.0.0.1:8765` | A1 | Android | Seite in Chrome öffnen | Hero, Status und PWA-Hinweise sind ohne horizontales Scrollen sichtbar | | A2 | Android | „Zum Startbildschirm hinzufügen“ oder Installieren auslösen | Companion erscheint als eigene PWA mit Titel und Theme-Farbe | | A3 | Android | Workspace importieren | Bericht, Dokumente und lokale Review-Notizen werden sichtbar | -| A4 | Android | Netz deaktivieren, PWA neu öffnen | Zuletzt gespeicherter Workspace wird lokal wiederhergestellt | +| A4 | Android | Workspace importieren, PWA schließen, offline erneut öffnen | PWA-Shell startet; Workspace wird nicht wiederhergestellt und muss erneut importiert werden | | A5 | Android | Review-Notizen exportieren | Markdown-Datei wird erzeugt oder Download wird angeboten | | I1 | iOS | Seite in Safari öffnen | Layout bleibt mobil lesbar, Import- und Notizbereich bleiben bedienbar | | I2 | iOS | Teilen → Zum Home-Bildschirm | Companion erscheint als Home-Screen-PWA | | I3 | iOS | Workspace importieren | Bericht und Dokumentauszüge werden lokal angezeigt | -| I4 | iOS | Safari/PWA offline erneut öffnen | Zuletzt gespeicherter Workspace wird lokal wiederhergestellt | +| I4 | iOS | Workspace importieren, Safari/PWA schließen, offline erneut öffnen | PWA-Shell startet; Workspace wird nicht wiederhergestellt und muss erneut importiert werden | | I5 | iOS | Review-Notizen exportieren | Markdown-Export wird gestartet; falls iOS Download begrenzt, Share-/Datei-Dialog prüfen | ## Checkliste pro Lauf @@ -48,11 +48,13 @@ URL: `http://127.0.0.1:8765` - [ ] Status nennt geladene Quelle korrekt - [ ] Plattform-Pill zeigt Android, iPhone/iPad oder Browser korrekt - [ ] Offline-Hinweis reagiert auf Online/Offline-Wechsel -- [ ] Cache-Hinweis zeigt den zuletzt gespeicherten Workspace -- [ ] „Workspace-Cache löschen“ entfernt den Offline-Startpfad wie erwartet +- [ ] Cache-Hinweis zeigt, dass keine Workspace-Payload dauerhaft gespeichert ist +- [ ] Review-Notizen bleiben getrennt vom Workspace lokal erhalten +- [ ] Ein erneuter Start verlangt den Dateiimport; lokale UI-Sprache bleibt erhalten ## Bekannte Grenzen - Der Companion bleibt read-only für importierte Workspaces. -- Rohdokumente, ChromaDB und API-Schlüssel werden nicht in die PWA übernommen. +- Importierte Workspace-Payloads bleiben nur in der aktuellen Sitzung; Offline-Verfügbarkeit betrifft die PWA-Shell, nicht Workspace-Inhalte. +- Vollständige Rohdokumente, lokale ChromaDB und gespeicherte API-Schlüsselfelder werden nicht in die PWA übernommen. Frei verfasste Berichte und Chats können dennoch Geheimnisse enthalten und sind vor dem Weitergeben zu prüfen. - Native Android-/iOS-Hüllen sind weiterhin kein Pflichtpfad; PWA bleibt die Referenzlinie. diff --git a/web_companion/README.md b/web_companion/README.md index eae5f2f..3da4750 100644 --- a/web_companion/README.md +++ b/web_companion/README.md @@ -10,7 +10,7 @@ Review-Notizen. - lokaler Import ohne Server-Upload - mobile Lesbarkeit für Android, iOS und Browser - Bericht, Dokumentmetadaten und ausgewählte Auszüge anzeigen -- zuletzt geladenen Workspace lokal für Offline-Starts wiederherstellen +- importierte Workspace-Inhalte nur in der aktuellen Sitzung anzeigen; nach einem Neustart erneut importieren - eigene Review-Notizen lokal speichern und als Markdown exportieren - DE/EN/ES/ZH-Hans/JA/RU-Oberfläche mit Browser-/Workspace-Locale und manuellem Sprachumschalter @@ -43,5 +43,6 @@ Der konkrete Android-/iOS-PWA-Ablauf steht in `PWA_TESTPLAN.md`. - kein Upload auf entfernte Server - keine Bearbeitung des Workspaces im Browser -- keine Rohdokumente, Vektordatenbanken oder API-Schlüssel im Companion +- keine vollständigen Rohdokumente, Vektordatenbanken oder gespeicherten API-Schlüsselfelder im Workspace-Export. Frei verfasste Berichte und Chats können dennoch Geheimnisse enthalten; prüfe sie vor dem Weitergeben. +- Workspace-Payloads werden nicht dauerhaft im Browser gespeichert; Review-Notizen und UI-Sprache sind separate lokale Daten - kein nativer Android-/iOS-Clone diff --git a/web_companion/index.html b/web_companion/index.html index 7bab465..9ba8e41 100644 --- a/web_companion/index.html +++ b/web_companion/index.html @@ -8,7 +8,7 @@