From 62a485dd6792a6e365b00646cf7a9bbec947776c Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 6 Oct 2026 04:35:11 +0000
Subject: [PATCH] =?UTF-8?q?fix:=20Bugfix-Sweep=20=E2=80=93=20Worker-Lebens?=
=?UTF-8?q?dauer,=20HTML-Export,=20RAG-Verdrahtung,=20Extraktion?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- QThread-Worker werden bis finished gehalten (kein "Destroyed while
thread is still running"), closeEvent stoppt und wartet auf Worker,
laufende Extraktion wird nicht mehr ersetzt (worker_utils.py)
- HTML-Export: gesamter Text wird escaped, Titel escaped, nur
http/https/mailto-Links; GUI nutzt den gehärteten ReportExporter
- RAG: Indexierung nicht mehr synchron im GUI-Thread; Chat bekommt immer
den aktuellen DocumentManager (keine projektfremden Chunks); Relevanz
statt Distanz als Konfidenz; Re-Index bettet erst ein, dann ersetzt er
- Projekte: neue Projekte übernehmen die gespeicherte LLM-Konfiguration,
Öffnen per ID, aktuelles Projekt wird vor dem Wechsel gespeichert,
eindeutige Projektordner
- Dateien aus Menü/Ordner werden extrahiert, Warteschlange verliert keine
Dateien mehr; Dateitypen aus einer gemeinsamen Liste (.pptx/.html)
- Extraktion: Excel-Nullwerte, RTF-\uN/Codepages, BOM/UTF-16, PPTX-
Folienreihenfolge, Ressourcen werden geschlossen
- TXT-Export entfernt nur Markdown-Syntax; YAML-Front-Matter gequotet;
Pandoc mit Timeout; Chat rendert Plaintext; Ollama-Verfügbarkeit wird
erneut geprüft; Translator-Wortgrenzen; Companion-Notizen per Projekt-ID
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_014VFa8w68q2cEYMqHQqfiH2
---
CHANGELOG.md | 14 +
EXPORTFORMAT.md | 1 +
README.md | 4 +-
README_de.md | 4 +-
llms.txt | 2 +-
src/core/document_manager.py | 118 +++-
src/core/project.py | 43 +-
src/core/text_extractor.py | 273 ++++++---
src/core/workspace_exporter.py | 3 +
src/gui/chat_panel.py | 91 ++-
src/gui/document_panel.py | 3 +-
src/gui/main_window.py | 449 ++++++++++-----
src/gui/worker_utils.py | 75 +++
src/llm/ollama_client.py | 81 ++-
src/rag/engine.py | 79 ++-
src/reports/exporter.py | 123 ++--
tests/test_bugfixes_2026_10.py | 832 +++++++++++++++++++++++++++
translator.py | 23 +-
web_companion/app.js | 6 +
web_companion/library.js | 2 +
web_companion/sw.js | 2 +-
web_companion/tests/library.test.mjs | 27 +
22 files changed, 1881 insertions(+), 374 deletions(-)
create mode 100644 src/gui/worker_utils.py
create mode 100644 tests/test_bugfixes_2026_10.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 08e6868..a97fc3f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,20 @@
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]
+
+### 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.
+- HTML export escapes all Markdown text and the document title and only renders `http`/`https`/`mailto`/relative links; the GUI HTML export uses the same `ReportExporter` implementation. TXT export strips Markdown syntax only (keeps `C#`, `#12`, `file_name`).
+- Text extraction no longer triggers synchronous embedding on the GUI thread; documents are queued and indexed by the `IndexWorker`. Re-indexing embeds before deleting old chunks; failed indexing resets `is_indexed`; stale `is_indexed` flags are reconciled with the vector index when a project is activated.
+- 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.
+- 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.
+- 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]
### Added
diff --git a/EXPORTFORMAT.md b/EXPORTFORMAT.md
index 6a117fc..5525723 100644
--- a/EXPORTFORMAT.md
+++ b/EXPORTFORMAT.md
@@ -47,6 +47,7 @@ Review-Notizen als Markdown exportiert.
"exported_at": "2026-05-26T00:00:00Z"
},
"workspace": {
+ "id": "8f0c6a2e-…",
"title": "Projektname",
"question": "Zentrale Fragestellung",
"workflow_type": "analysis",
diff --git a/README.md b/README.md
index 4c75c66..46670d5 100644
--- a/README.md
+++ b/README.md
@@ -6,8 +6,8 @@
[](https://www.python.org/)
[]()
[]()
-[]()
-[]()
+[]()
+[]()
[](NOTICE)
[](THIRD_PARTY_LICENSES.md)
[](https://github.com/file-bricks)
diff --git a/README_de.md b/README_de.md
index 235c724..d32e9fa 100644
--- a/README_de.md
+++ b/README_de.md
@@ -6,8 +6,8 @@
[](https://www.python.org/)
[]()
[]()
-[]()
-[]()
+[]()
+[]()
[](NOTICE)
[](THIRD_PARTY_LICENSES.md)
[](https://github.com/file-bricks)
diff --git a/llms.txt b/llms.txt
index a4b7b33..faf842e 100644
--- a/llms.txt
+++ b/llms.txt
@@ -2,7 +2,7 @@
## Last-checked: 2026-09-22
-> Local NotebookLM alternative for private document analysis, RAG-assisted research, and multi-format report generation. Verified with 161 passing unit tests (103 Python pytest + 58 Web Companion Node.js tests).
+> 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).
## Description
diff --git a/src/core/document_manager.py b/src/core/document_manager.py
index 593ce7c..0b7ca79 100644
--- a/src/core/document_manager.py
+++ b/src/core/document_manager.py
@@ -25,6 +25,8 @@
from typing import Dict, List, Optional, Set, Callable, TYPE_CHECKING
import uuid
+from .text_extractor import SUPPORTED_EXTENSIONS as _EXTRACTOR_SUPPORTED_EXTENSIONS
+
if TYPE_CHECKING:
from ..rag.engine import RAGEngine
@@ -147,21 +149,8 @@ class DocumentManager:
- Manage sub-query associations
"""
- # Supported file types
- SUPPORTED_EXTENSIONS = {
- # Text
- ".txt", ".md", ".rst", ".log",
- # Documents
- ".pdf", ".docx", ".doc", ".odt", ".rtf",
- # Data
- ".json", ".xml", ".yaml", ".yml", ".csv",
- # Code (for analysis)
- ".py", ".js", ".ts", ".java", ".cpp", ".c", ".h",
- # Spreadsheets
- ".xlsx", ".xls", ".ods",
- # Email
- ".eml", ".msg"
- }
+ # Supported file types -- abgeleitet aus dem TextExtractor (eine Quelle der Wahrheit)
+ SUPPORTED_EXTENSIONS = _EXTRACTOR_SUPPORTED_EXTENSIONS
def __init__(self, project_path: Optional[Path] = None, rag_engine: Optional['RAGEngine'] = None):
"""
@@ -179,6 +168,7 @@ def __init__(self, project_path: Optional[Path] = None, rag_engine: Optional['RA
self._auto_extract: bool = True # Automatisch Text extrahieren bei add_file
self._text_extractor = None # Lazy-loaded
self._pending_extractions: List[str] = [] # doc_ids waiting for async extraction
+ self._pending_index: List[str] = [] # doc_ids waiting for async (re-)indexing
if project_path:
self._cache_dir = project_path / ".cache"
@@ -492,15 +482,41 @@ def _try_auto_extract(self, doc: DocumentItem) -> None:
self._notify_change("update", doc)
logger.error(f"Auto-Extraktion Fehler: {doc.name}: {e}")
- def set_rag_engine(self, rag_engine: 'RAGEngine') -> None:
+ def set_rag_engine(self, rag_engine: Optional['RAGEngine']) -> None:
"""
Setzt die RAG Engine für semantische Suche.
Args:
- rag_engine: Die RAG Engine Instanz
+ rag_engine: Die RAG Engine Instanz oder None zum Trennen
"""
self._rag_engine = rag_engine
- logger.info("RAG Engine verbunden")
+ if rag_engine is None:
+ self._pending_index.clear()
+ logger.info("RAG Engine getrennt")
+ else:
+ logger.info("RAG Engine verbunden")
+
+ def sync_index_flags(self) -> None:
+ """Gleicht is_indexed mit dem tatsächlichen Inhalt des Vektor-Index ab.
+
+ Nötig, weil der Index projektübergreifend geleert werden kann und
+ documents.json dann veraltete is_indexed-Flags enthält.
+ """
+ if not self._rag_engine:
+ return
+ flagged = [d for d in self._documents.values() if d.is_indexed and not d.is_directory]
+ if not flagged:
+ return
+ try:
+ present = self._rag_engine.get_indexed_document_ids([d.id for d in flagged])
+ except Exception as e:
+ logger.warning("Index-Abgleich fehlgeschlagen: %s", e)
+ return
+ for doc in flagged:
+ if doc.id not in present:
+ doc.is_indexed = False
+ doc.chunk_count = 0
+ self._notify_change("deindexed", doc)
def pop_pending_extractions(self) -> List[tuple]:
"""Gibt ausstehende Extraktionen zurück und leert die Queue.
@@ -516,6 +532,38 @@ def pop_pending_extractions(self) -> List[tuple]:
self._pending_extractions.clear()
return result
+ def has_pending_extractions(self) -> bool:
+ """True, wenn Dokumente auf die asynchrone Extraktion warten."""
+ return any(
+ doc_id in self._documents and not self._documents[doc_id].is_directory
+ for doc_id in self._pending_extractions
+ )
+
+ def discard_pending_extractions(self, doc_ids) -> None:
+ """Entfernt Dokumente aus der Extraktions-Queue (z.B. weil sie bereits extrahiert werden)."""
+ ids = set(doc_ids)
+ self._pending_extractions = [d for d in self._pending_extractions if d not in ids]
+
+ def pop_pending_index(self) -> List[tuple]:
+ """Gibt Dokumente zurück, die (neu) indexiert werden sollen, und leert die Queue.
+
+ Returns:
+ Liste von (doc_id, doc_name) Tupeln
+ """
+ result = []
+ seen = set()
+ for doc_id in self._pending_index:
+ doc = self._documents.get(doc_id)
+ if doc and not doc.is_directory and doc.extracted_text and doc_id not in seen:
+ seen.add(doc_id)
+ result.append((doc.id, doc.name))
+ self._pending_index.clear()
+ return result
+
+ def index_pending_documents(self) -> Dict[str, bool]:
+ """Indexiert alle wartenden Dokumente synchron (nur außerhalb des GUI-Threads nutzen)."""
+ return {doc_id: self.index_document(doc_id) for doc_id, _ in self.pop_pending_index()}
+
def set_auto_index(self, enabled: bool) -> None:
"""Aktiviert/Deaktiviert automatische Indexierung."""
self._auto_index = enabled
@@ -562,12 +610,21 @@ def index_document(self, doc_id: str) -> bool:
return True
else:
logger.error(f"Indexierung fehlgeschlagen: {result.error}")
+ self._mark_not_indexed(doc)
return False
except Exception as e:
logger.error(f"Fehler bei Indexierung von {doc_id}: {e}")
+ self._mark_not_indexed(doc)
return False
+ def _mark_not_indexed(self, doc: DocumentItem) -> None:
+ """Setzt den Index-Status nach einem Fehlschlag zurück."""
+ if doc.is_indexed or doc.chunk_count:
+ doc.is_indexed = False
+ doc.chunk_count = 0
+ self._notify_change("deindexed", doc)
+
def index_all_documents(self) -> Dict[str, bool]:
"""
Indexiert alle Dokumente mit extrahiertem Text.
@@ -741,17 +798,30 @@ def get_rag_statistics(self) -> Dict:
return stats
- # Override update_content to auto-index
def update_content(self, doc_id: str, text: str) -> None:
- """Update extracted text for a document."""
+ """Update extracted text for a document.
+
+ Indexiert NICHT synchron (Embedding-Aufrufe gehen über HTTP und würden
+ den GUI-Thread blockieren). Bei aktivem Auto-Index wird das Dokument in
+ eine Queue gestellt, die die GUI per IndexWorker abarbeitet
+ (pop_pending_index) bzw. Nicht-GUI-Aufrufer per index_pending_documents().
+ """
doc = self._documents.get(doc_id)
if doc:
+ new_hash = hashlib.md5(text.encode()).hexdigest()
+ content_changed = new_hash != doc.content_hash
doc.extracted_text = text
doc.text_length = len(text)
- doc.content_hash = hashlib.md5(text.encode()).hexdigest()
+ doc.content_hash = new_hash
doc.status = DocumentStatus.READY
+ if content_changed and doc.is_indexed:
+ # Vorhandene Chunks passen nicht mehr zum Text
+ doc.is_indexed = False
+ doc.chunk_count = 0
self._notify_change("update", doc)
- # Auto-indexieren wenn aktiviert
- if self._auto_index and self._rag_engine and text:
- self.index_document(doc_id)
+ # Auto-Indexierung vormerken (asynchron)
+ needs_index = content_changed or not doc.is_indexed
+ if (self._auto_index and self._rag_engine and text and needs_index
+ and doc_id not in self._pending_index):
+ self._pending_index.append(doc_id)
diff --git a/src/core/project.py b/src/core/project.py
index 3d911bf..5de58b4 100644
--- a/src/core/project.py
+++ b/src/core/project.py
@@ -60,6 +60,11 @@ def to_dict(self) -> dict:
"language": self.language
}
+ @classmethod
+ def from_app_config(cls) -> "ProjectSettings":
+ """Neue Projekt-Settings mit den gespeicherten LLM-Einstellungen der App."""
+ return cls.from_dict({})
+
@classmethod
def from_dict(cls, data: dict) -> "ProjectSettings":
from .app_config import get_app_config
@@ -126,7 +131,9 @@ def create(cls, name: str, main_question: str = "", report_type: str = "analysis
id=str(uuid.uuid4()),
name=name,
main_question=main_question,
- report_type=report_type
+ report_type=report_type,
+ # LLM-Provider/Modell/URL aus der gespeicherten App-Konfiguration
+ settings=ProjectSettings.from_app_config(),
)
@property
@@ -359,23 +366,35 @@ def open_project(self, project_id_or_name: str) -> Optional[Project]:
Returns:
The project if found
"""
- # Search by ID or name
- for item in self.projects_dir.iterdir():
+ # ID hat Vorrang vor dem (nicht eindeutigen) Namen
+ by_name = []
+ for item in sorted(self.projects_dir.iterdir()):
if item.is_dir():
project_file = item / "project.json"
if project_file.exists():
try:
data = json.loads(project_file.read_text(encoding="utf-8"))
- if data["id"] == project_id_or_name or data["name"] == project_id_or_name:
- project = Project.load(item)
- if project:
- self._current_project = project
- return project
- except Exception:
- pass
+ except (OSError, ValueError):
+ continue # beschädigte project.json überspringen
+ if data.get("id") == project_id_or_name:
+ return self._load_as_current(item)
+ if data.get("name") == project_id_or_name:
+ by_name.append((data.get("modified_at", data.get("created_at", "")), item))
+
+ if by_name:
+ # Bei Namensdubletten das zuletzt geänderte Projekt öffnen
+ by_name.sort(key=lambda entry: entry[0], reverse=True)
+ return self._load_as_current(by_name[0][1])
return None
+ def _load_as_current(self, directory: Path) -> Optional[Project]:
+ """Load a project directory and make it the current project."""
+ project = Project.load(directory)
+ if project:
+ self._current_project = project
+ return project
+
def save_current(self) -> bool:
"""Save the current project."""
if not self._current_project:
@@ -438,9 +457,9 @@ def _safe_dirname(self, name: str) -> str:
safe = "".join(c for c in name if c.isalnum() or c in " -_").strip()
safe = safe.replace(" ", "_")
- # Add timestamp for uniqueness
+ # Timestamp + Kurz-UUID: zwei Projekte in derselben Sekunde kollidieren nicht
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
- return f"{safe}_{timestamp}"
+ return f"{safe or 'Projekt'}_{timestamp}_{uuid.uuid4().hex[:8]}"
# Output profiles
def get_output_profiles(self) -> List[OutputProfile]:
diff --git a/src/core/text_extractor.py b/src/core/text_extractor.py
index 0e36482..5190142 100644
--- a/src/core/text_extractor.py
+++ b/src/core/text_extractor.py
@@ -29,19 +29,56 @@ class ExtractionResult:
method: str = "" # e.g., "native", "ocr", "conversion"
+# Dateiendungen, die als Klartext gelesen werden (Encoding-Erkennung inklusive).
+PLAIN_TEXT_EXTENSIONS = frozenset({
+ ".txt", ".md", ".rst", ".log", ".py", ".js", ".ts",
+ ".java", ".cpp", ".c", ".h", ".json", ".xml", ".yaml",
+ ".yml", ".csv", ".css",
+})
+
+# HTML wird gelesen und anschließend in Klartext umgewandelt.
+HTML_EXTENSIONS = frozenset({".html", ".htm"})
+
+# Einzige Quelle der Wahrheit für unterstützte Dateitypen (auch DocumentManager nutzt sie).
+SUPPORTED_EXTENSIONS = frozenset(
+ PLAIN_TEXT_EXTENSIONS
+ | HTML_EXTENSIONS
+ | {".pdf", ".docx", ".doc", ".rtf", ".pptx", ".xlsx", ".xls", ".eml", ".msg"}
+)
+
+
+def decode_text_bytes(raw: bytes) -> str:
+ """Decode text bytes: BOM detection (UTF-8/UTF-16), then utf-8, cp1252, latin-1."""
+ if raw.startswith(b"\xef\xbb\xbf"):
+ return raw[3:].decode("utf-8", errors="replace")
+ if raw.startswith(b"\xff\xfe"):
+ return raw[2:].decode("utf-16-le", errors="replace")
+ if raw.startswith(b"\xfe\xff"):
+ return raw[2:].decode("utf-16-be", errors="replace")
+ for encoding in ("utf-8", "cp1252"):
+ try:
+ return raw.decode(encoding)
+ except UnicodeDecodeError:
+ continue
+ return raw.decode("latin-1")
+
+
class TextExtractor:
"""
Extract text content from various document formats.
Supports:
- Plain text: .txt, .md, .rst, .log
- - Documents: .pdf, .docx, .doc, .rtf
+ - Documents: .pdf, .docx, .doc, .rtf, .pptx
+ - Web: .html, .htm
- Data: .json, .xml, .yaml, .csv
- Spreadsheets: .xlsx, .xls
- Email: .eml, .msg
- Code: .py, .js, .java, etc.
"""
+ SUPPORTED_EXTENSIONS = SUPPORTED_EXTENSIONS
+
def __init__(self, enable_ocr: bool = True):
"""
Initialize the extractor.
@@ -117,11 +154,13 @@ def extract(self, filepath: Path) -> ExtractionResult:
try:
# Plain text files
- if suffix in {".txt", ".md", ".rst", ".log", ".py", ".js", ".ts",
- ".java", ".cpp", ".c", ".h", ".json", ".xml", ".yaml",
- ".yml", ".csv", ".html", ".css"}:
+ if suffix in PLAIN_TEXT_EXTENSIONS:
return self._extract_text_file(filepath)
+ # HTML -> Klartext
+ elif suffix in HTML_EXTENSIONS:
+ return self._extract_html_file(filepath)
+
# PDF
elif suffix == ".pdf":
return self._extract_pdf(filepath)
@@ -164,12 +203,9 @@ def extract(self, filepath: Path) -> ExtractionResult:
def _extract_text_file(self, filepath: Path) -> ExtractionResult:
"""Extract text from plain text files."""
try:
- text = filepath.read_text(encoding="utf-8")
- except UnicodeDecodeError:
- try:
- text = filepath.read_text(encoding="latin-1")
- except Exception as e:
- return ExtractionResult(False, "", error=f"Encoding error: {e}")
+ text = decode_text_bytes(filepath.read_bytes())
+ except Exception as e:
+ return ExtractionResult(False, "", error=f"Encoding error: {e}")
return ExtractionResult(
success=True,
@@ -178,6 +214,21 @@ def _extract_text_file(self, filepath: Path) -> ExtractionResult:
method="native"
)
+ def _extract_html_file(self, filepath: Path) -> ExtractionResult:
+ """Extract readable text from HTML files (tags, scripts and styles stripped)."""
+ try:
+ raw_html = decode_text_bytes(filepath.read_bytes())
+ except Exception as e:
+ return ExtractionResult(False, "", error=f"Encoding error: {e}")
+
+ text = self._html_to_text(raw_html)
+ return ExtractionResult(
+ success=True,
+ text=text,
+ word_count=len(text.split()),
+ method="native"
+ )
+
def _extract_pdf(self, filepath: Path) -> ExtractionResult:
"""Extract text from PDF, with OCR fallback."""
if not self._deps["fitz"]:
@@ -191,15 +242,13 @@ def _extract_pdf(self, filepath: Path) -> ExtractionResult:
try:
doc = fitz.open(str(filepath))
text_parts = []
- pages_with_text = 0
-
- for page in doc:
- page_text = page.get_text().strip()
- if page_text:
- text_parts.append(page_text)
- pages_with_text += 1
-
- doc.close()
+ try:
+ for page in doc:
+ page_text = page.get_text().strip()
+ if page_text:
+ text_parts.append(page_text)
+ finally:
+ doc.close()
# If no text found, try OCR
if not text_parts and self.enable_ocr:
@@ -236,20 +285,21 @@ def _extract_pdf_ocr(self, filepath: Path) -> ExtractionResult:
doc = fitz.open(str(filepath))
text_parts = []
- for page_num, page in enumerate(doc):
- # Render page to image
- mat = fitz.Matrix(2, 2) # 2x zoom for better OCR
- pix = page.get_pixmap(matrix=mat)
- img_data = pix.tobytes("png")
-
- # OCR the image
- image = Image.open(io.BytesIO(img_data))
- page_text = pytesseract.image_to_string(image, lang="deu+eng")
+ try:
+ for page_num, page in enumerate(doc):
+ # Render page to image
+ mat = fitz.Matrix(2, 2) # 2x zoom for better OCR
+ pix = page.get_pixmap(matrix=mat)
+ img_data = pix.tobytes("png")
- if page_text.strip():
- text_parts.append(f"--- Page {page_num + 1} ---\n{page_text}")
+ # OCR the image
+ with Image.open(io.BytesIO(img_data)) as image:
+ page_text = pytesseract.image_to_string(image, lang="deu+eng")
- doc.close()
+ if page_text.strip():
+ text_parts.append(f"--- Page {page_num + 1} ---\n{page_text}")
+ finally:
+ doc.close()
if not text_parts:
return ExtractionResult(False, "", error="OCR found no text")
@@ -422,16 +472,23 @@ def get_text(self) -> str:
@staticmethod
def _parse_rtf_bytes(raw_bytes: bytes) -> str:
"""Extract plain text from RTF byte stream, handling codepages, hex escapes and unicode."""
+ import codecs
+
text = raw_bytes.decode('latin-1', errors='replace')
cp_match = re.search(r'\\ansicpg(\d+)', text)
encoding = f'cp{cp_match.group(1)}' if cp_match else 'cp1252'
+ try:
+ codecs.lookup(encoding)
+ except LookupError:
+ encoding = 'cp1252'
skip_destinations = {
'fonttbl', 'colortbl', 'stylesheet', 'info', 'generator',
'pict', 'header', 'footer', 'headerl', 'headerr', 'headerf',
'footerl', 'footerr', 'footerf', 'object', 'template', 'themedata'
}
+ control_word_re = re.compile(r'([a-zA-Z]+)(-?\d+)? ?')
stack = []
ignorable = False
@@ -439,15 +496,55 @@ def _parse_rtf_bytes(raw_bytes: bytes) -> str:
uc_skip = 1
out = []
+ # Aufeinanderfolgende \'hh-Bytes werden gesammelt und gemeinsam dekodiert,
+ # damit Mehrbyte-Codepages (z.B. cp932/cp936) korrekt funktionieren.
+ pending_bytes = bytearray()
+
+ def flush_bytes():
+ if pending_bytes:
+ out.append(bytes(pending_bytes).decode(encoding, errors='replace'))
+ pending_bytes.clear()
+
+ def emit(value: str):
+ flush_bytes()
+ out.append(value)
+
+ def skip_fallback_units(pos: int, count: int) -> int:
+ """Skip `count` RTF units after \\uN (a char, an \\'hh escape or a control word)."""
+ for _ in range(count):
+ if pos >= n:
+ break
+ ch0 = text[pos]
+ if ch0 in ('{', '}'):
+ break # Gruppengrenzen nie überspringen
+ if ch0 in ('\r', '\n'):
+ # Zeilenumbrüche sind in RTF bedeutungslos und zählen nicht
+ pos += 1
+ continue
+ if ch0 == '\\' and pos + 1 < n:
+ nxt = text[pos + 1]
+ if nxt == "'":
+ pos += 4
+ elif nxt.isalpha():
+ m = control_word_re.match(text, pos + 1)
+ pos = m.end() if m else pos + 2
+ else:
+ pos += 2
+ else:
+ pos += 1
+ return pos
+
i = 0
n = len(text)
while i < n:
c = text[i]
if c == '{':
+ flush_bytes()
stack.append((skip_depth, uc_skip))
i += 1
elif c == '}':
+ flush_bytes()
if stack:
skip_depth, uc_skip = stack.pop()
i += 1
@@ -458,15 +555,15 @@ def _parse_rtf_bytes(raw_bytes: bytes) -> str:
ch = text[i]
if ch in ('\\', '{', '}'):
if skip_depth == 0:
- out.append(ch)
+ emit(ch)
i += 1
elif ch == '~':
if skip_depth == 0:
- out.append(' ')
+ emit(' ')
i += 1
elif ch == '_':
if skip_depth == 0:
- out.append('-')
+ emit('-')
i += 1
elif ch == '*':
i += 1
@@ -476,24 +573,22 @@ def _parse_rtf_bytes(raw_bytes: bytes) -> str:
i += 3
if skip_depth == 0 and len(hex_str) == 2:
try:
- byte_val = bytes.fromhex(hex_str)
- out.append(byte_val.decode(encoding, errors='replace'))
- except Exception:
+ pending_bytes.extend(bytes.fromhex(hex_str))
+ except ValueError:
pass
else:
- match = re.match(r'([a-zA-Z]+)(-?\d+)? ?', text[i:])
+ match = control_word_re.match(text, i)
if match:
word = match.group(1)
param = match.group(2)
- full_len = match.end()
- i += full_len
+ i = match.end()
if word in ('par', 'line', 'row', 'sect'):
if skip_depth == 0:
- out.append('\n')
+ emit('\n')
elif word in ('tab', 'cell'):
if skip_depth == 0:
- out.append('\t')
+ emit('\t')
elif word == 'uc':
if param:
uc_skip = max(0, int(param))
@@ -504,10 +599,10 @@ def _parse_rtf_bytes(raw_bytes: bytes) -> str:
code_point += 65536
if skip_depth == 0:
try:
- out.append(chr(code_point))
- except Exception:
+ emit(chr(code_point))
+ except (ValueError, OverflowError):
pass
- i += uc_skip
+ i = skip_fallback_units(i, uc_skip)
elif word in skip_destinations or ignorable:
skip_depth += 1
ignorable = False
@@ -515,9 +610,10 @@ def _parse_rtf_bytes(raw_bytes: bytes) -> str:
i += 1
else:
if skip_depth == 0 and c not in ('\r', '\n'):
- out.append(c)
+ emit(c)
i += 1
+ flush_bytes()
res = ''.join(out)
lines = [re.sub(r'[ \t]+', ' ', line).strip() for line in res.split('\n')]
return '\n'.join(line for line in lines if line)
@@ -549,12 +645,14 @@ def _extract_pptx(self, filepath: Path) -> ExtractionResult:
parts = []
with ZipFile(str(filepath)) as z:
# pptx slides are in ppt/slides/slide*.xml
- slide_names = sorted([
- n for n in z.namelist()
- if n.startswith("ppt/slides/slide") and n.endswith(".xml")
- ])
-
- ns = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
+ # Numerisch sortieren: slide2.xml vor slide10.xml
+ slide_names = sorted(
+ (
+ n for n in z.namelist()
+ if re.fullmatch(r"ppt/slides/slide\d+\.xml", n)
+ ),
+ key=lambda n: int(re.search(r"(\d+)\.xml$", n).group(1)),
+ )
for i, slide_name in enumerate(slide_names, 1):
with z.open(slide_name) as f:
@@ -591,16 +689,18 @@ def _extract_excel(self, filepath: Path) -> ExtractionResult:
wb = openpyxl.load_workbook(str(filepath), data_only=True)
parts = []
- for sheet_name in wb.sheetnames:
- sheet = wb[sheet_name]
- parts.append(f"[Sheet: {sheet_name}]")
-
- for row in sheet.iter_rows(values_only=True):
- cells = [str(c) if c else "" for c in row]
- if any(cells):
- parts.append(" | ".join(cells))
-
- wb.close()
+ try:
+ for sheet_name in wb.sheetnames:
+ sheet = wb[sheet_name]
+ parts.append(f"[Sheet: {sheet_name}]")
+
+ for row in sheet.iter_rows(values_only=True):
+ # 0 und False sind gültige Zellwerte -- nur None ist leer
+ cells = ["" if c is None else str(c) for c in row]
+ if any(cells):
+ parts.append(" | ".join(cells))
+ finally:
+ wb.close()
text = "\n".join(parts)
return ExtractionResult(
@@ -694,30 +794,31 @@ def _extract_msg(self, filepath: Path) -> ExtractionResult:
msg = extract_msg.Message(str(filepath))
parts = []
- if msg.date:
- parts.append(f"Date: {msg.date}")
- if msg.sender:
- parts.append(f"From: {msg.sender}")
- if msg.to:
- parts.append(f"To: {msg.to}")
- if msg.subject:
- parts.append(f"Subject: {msg.subject}")
-
- parts.append("")
-
- body_text = ""
- if msg.body:
- body_text = msg.body
- elif getattr(msg, 'htmlBody', None):
- html_raw = msg.htmlBody
- if isinstance(html_raw, bytes):
- html_raw = html_raw.decode('utf-8', errors='replace')
- body_text = self._html_to_text(str(html_raw))
-
- if body_text:
- parts.append(body_text)
-
- msg.close()
+ try:
+ if msg.date:
+ parts.append(f"Date: {msg.date}")
+ if msg.sender:
+ parts.append(f"From: {msg.sender}")
+ if msg.to:
+ parts.append(f"To: {msg.to}")
+ if msg.subject:
+ parts.append(f"Subject: {msg.subject}")
+
+ parts.append("")
+
+ body_text = ""
+ if msg.body:
+ body_text = msg.body
+ elif getattr(msg, 'htmlBody', None):
+ html_raw = msg.htmlBody
+ if isinstance(html_raw, bytes):
+ html_raw = html_raw.decode('utf-8', errors='replace')
+ body_text = self._html_to_text(str(html_raw))
+
+ if body_text:
+ parts.append(body_text)
+ finally:
+ msg.close()
text = "\n".join(parts)
return ExtractionResult(
diff --git a/src/core/workspace_exporter.py b/src/core/workspace_exporter.py
index dfa8a89..e06d88e 100644
--- a/src/core/workspace_exporter.py
+++ b/src/core/workspace_exporter.py
@@ -95,6 +95,9 @@ def build_workspace_export_payload(
"exported_at": now_iso,
},
"workspace": {
+ # Stabile Projekt-ID (optional im Schema): Companion-Notizen werden
+ # damit pro Projekt statt pro (nicht eindeutigem) Titel gespeichert.
+ "id": str(getattr(project, "id", "") or ""),
"title": project.name,
"question": project.main_question or "",
"workflow_type": project.report_type or "analysis",
diff --git a/src/gui/chat_panel.py b/src/gui/chat_panel.py
index 9182000..e2d1543 100644
--- a/src/gui/chat_panel.py
+++ b/src/gui/chat_panel.py
@@ -13,7 +13,8 @@
"""
from datetime import datetime
-from typing import List, Optional, Callable, TYPE_CHECKING
+from pathlib import Path
+from typing import Dict, List, Optional, Callable, TYPE_CHECKING
from dataclasses import dataclass, field
import logging
@@ -30,6 +31,7 @@
)
from PySide6.QtCore import Qt, Signal, QThread, QTimer
from PySide6.QtGui import QTextCursor, QFont
+ from .worker_utils import retain_until_finished, stop_workers
PYSIDE_AVAILABLE = True
except ImportError:
PYSIDE_AVAILABLE = False
@@ -122,8 +124,10 @@ def _setup_ui(self):
layout.addLayout(header)
- # Content
- self.content_label = QLabel(self.message.content)
+ # Content (PlainText: LLM-/Dokumenttext darf kein Rich-Text/HTML rendern)
+ self.content_label = QLabel()
+ self.content_label.setTextFormat(Qt.TextFormat.PlainText)
+ self.content_label.setText(self.message.content)
self.content_label.setWordWrap(True)
self.content_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse |
@@ -133,19 +137,27 @@ def _setup_ui(self):
# Document references
if self.message.document_refs:
- refs_label = QLabel(f"Dokumente: {', '.join(self.message.document_refs)}")
+ refs_label = QLabel()
+ refs_label.setTextFormat(Qt.TextFormat.PlainText)
+ refs_label.setText(f"Dokumente: {', '.join(self.message.document_refs)}")
refs_label.setStyleSheet("color: #666; font-size: 10px; font-style: italic;")
layout.addWidget(refs_label)
# RAG Sources
if self.message.sources:
sources_text = "Quellen: "
- source_names = list(set(s.get('source', 'Unbekannt').split('/')[-1] for s in self.message.sources[:3]))
+ # Reihenfolge stabil halten, nur Dateinamen anzeigen (auch bei Windows-Pfaden)
+ source_names = list(dict.fromkeys(
+ Path(s.get('source') or 'Unbekannt').name or 'Unbekannt'
+ for s in self.message.sources[:3]
+ ))
sources_text += ", ".join(source_names)
if self.message.confidence > 0:
sources_text += f" (Konfidenz: {self.message.confidence:.0%})"
- sources_label = QLabel(sources_text)
+ sources_label = QLabel()
+ sources_label.setTextFormat(Qt.TextFormat.PlainText)
+ sources_label.setText(sources_text)
sources_label.setStyleSheet("color: #27ae60; font-size: 10px; font-style: italic;")
sources_label.setWordWrap(True)
layout.addWidget(sources_label)
@@ -253,6 +265,8 @@ def __init__(self, parent=None):
self._llm_client = None
self._document_context = ""
self._current_worker: Optional[LLMWorker] = None
+ # Hält Worker bis zum Thread-Ende (verhindert "QThread: Destroyed while running")
+ self._workers: Dict[int, QThread] = {}
self._streaming_widget: Optional[MessageWidget] = None
# RAG Integration
@@ -382,15 +396,20 @@ def set_llm_client(self, client):
"""Set the LLM client for chat."""
self._llm_client = client
- def set_rag_engine(self, rag_engine: 'RAGEngine'):
- """Set the RAG engine for semantic search."""
+ def set_rag_engine(self, rag_engine: Optional['RAGEngine']):
+ """Set (or clear with None) the RAG engine for semantic search."""
self._rag_engine = rag_engine
self._update_rag_status()
- logger.info("RAG Engine im Chat-Panel verbunden")
+ self._update_status()
+ if rag_engine is None:
+ logger.info("RAG Engine im Chat-Panel getrennt")
+ else:
+ logger.info("RAG Engine im Chat-Panel verbunden")
- def set_document_manager(self, doc_manager: 'DocumentManager'):
+ def set_document_manager(self, doc_manager: Optional['DocumentManager']):
"""Set the document manager for RAG queries."""
self._document_manager = doc_manager
+ self._update_status()
def set_document_context(self, context: str):
"""Set the document context for the chat."""
@@ -412,6 +431,8 @@ def _update_rag_status(self):
f"ChromaDB: {stats.get('total_chunks', 0)} Chunks indexiert\n"
f"Embedding: {stats.get('embedding_model', 'N/A')}"
)
+ else:
+ self.rag_toggle.setToolTip("RAG-Engine nicht verfügbar")
def _update_status(self):
"""Update status label."""
@@ -481,6 +502,7 @@ def _request_response(self, prompt: str):
# Start worker
self._current_worker = LLMWorker(self._llm_client, prompt, context_prompt)
+ retain_until_finished(self._workers, self._current_worker, self._on_worker_finished)
self._current_worker.response_chunk.connect(self._on_response_chunk)
self._current_worker.response_complete.connect(self._on_response_complete)
self._current_worker.error_occurred.connect(self._on_response_error)
@@ -524,23 +546,23 @@ def _request_rag_response(self, question: str):
self.send_btn.setEnabled(False)
self.status_label.setText("🔍 RAG-Suche...")
- # Get selected document IDs for filtering
+ # Get selected document IDs for filtering. Ohne Dokument-Manager wird NICHT
+ # ungefiltert gesucht -- die Chroma-Collection ist projektübergreifend.
doc_ids = None
if self._document_manager:
selected_docs = self._document_manager.selected_documents
- indexed_docs = [d for d in selected_docs if d.is_indexed]
- if indexed_docs:
- doc_ids = [d.id for d in indexed_docs]
- else:
- # Keine indexierten Dokumente
- self._add_system_message(
- "⚠️ Keine indexierten Dokumente gefunden. "
- "Bitte Dokumente laden und indexieren (automatisch bei Text-Extraktion)."
- )
- self.input_edit.setEnabled(True)
- self.send_btn.setEnabled(True)
- self._update_status()
- return
+ doc_ids = [d.id for d in selected_docs if d.is_indexed]
+
+ if not doc_ids:
+ # Keine indexierten Dokumente
+ self._add_system_message(
+ "⚠️ Keine indexierten Dokumente gefunden. "
+ "Bitte Dokumente laden und indexieren (automatisch bei Text-Extraktion)."
+ )
+ self.input_edit.setEnabled(True)
+ self.send_btn.setEnabled(True)
+ self._update_status()
+ return
# Start RAG worker
self._current_worker = RAGWorker(
@@ -549,6 +571,7 @@ def _request_rag_response(self, question: str):
document_ids=doc_ids,
k=self._rag_k
)
+ retain_until_finished(self._workers, self._current_worker, self._on_worker_finished)
self._current_worker.response_ready.connect(self._on_rag_response)
self._current_worker.error_occurred.connect(self._on_rag_error)
self._current_worker.start()
@@ -623,13 +646,29 @@ def _clear_history(self):
self._add_system_message("Verlauf gelöscht. Stelle eine neue Frage.")
+ def _on_worker_finished(self, worker):
+ """Drop the current-worker reference once its thread has really finished."""
+ if self._current_worker is worker:
+ self._current_worker = None
+
def stop_generation(self):
"""Stop the current generation."""
if self._current_worker and self._current_worker.isRunning():
- self._current_worker.stop()
- self._current_worker.wait()
+ stop_workers([self._current_worker], timeout_ms=10000)
self._on_response_error("Abgebrochen")
+ def shutdown_workers(self, timeout_ms: int = 3000) -> bool:
+ """Stop all chat workers (e.g. on application exit).
+
+ Returns:
+ True, wenn kein Worker mehr läuft
+ """
+ return stop_workers(list(self._workers.values()), timeout_ms)
+
+ def has_running_workers(self) -> bool:
+ """True, wenn noch ein Chat-Worker läuft."""
+ return any(w.isRunning() for w in self._workers.values())
+
def get_messages(self) -> List[ChatMessage]:
"""Get all messages."""
return list(self._messages)
diff --git a/src/gui/document_panel.py b/src/gui/document_panel.py
index 409f560..a7bc0ff 100644
--- a/src/gui/document_panel.py
+++ b/src/gui/document_panel.py
@@ -355,7 +355,7 @@ def _on_add_files(self):
self,
"Dateien hinzufügen",
"",
- "Alle unterstützten (*.pdf *.docx *.doc *.rtf *.txt *.md *.xlsx *.xls *.pptx *.py *.csv *.json *.xml *.eml *.msg);;Dokumente (*.pdf *.docx *.doc *.rtf *.txt *.md);;Tabellen (*.xlsx *.xls *.csv);;Code (*.py *.js *.java *.cpp *.c *.h);;Alle Dateien (*)"
+ "Alle unterstützten (*.pdf *.docx *.doc *.rtf *.txt *.md *.xlsx *.xls *.pptx *.html *.htm *.py *.csv *.json *.xml *.eml *.msg);;Dokumente (*.pdf *.docx *.doc *.rtf *.txt *.md *.pptx *.html *.htm);;Tabellen (*.xlsx *.xls *.csv);;Code (*.py *.js *.java *.cpp *.c *.h);;Alle Dateien (*)"
)
if files and self._document_manager:
@@ -369,6 +369,7 @@ def _on_add_folder(self):
if folder and self._document_manager:
self._document_manager.add_directory(Path(folder))
+ self.files_added.emit() # startet die asynchrone Extraktion
def _on_select_all(self):
"""Select all documents."""
diff --git a/src/gui/main_window.py b/src/gui/main_window.py
index 7ba9324..96251b9 100644
--- a/src/gui/main_window.py
+++ b/src/gui/main_window.py
@@ -34,6 +34,8 @@
if PYSIDE_AVAILABLE:
from PySide6.QtCore import QThread, Signal
+ from .worker_utils import retain_until_finished, stop_workers
+
class AnalysisWorker(QThread):
"""Worker thread for batch sub-query analysis."""
query_complete = Signal(str, str, str) # query_id, response, error
@@ -46,6 +48,8 @@ def __init__(self, llm_client, tasks):
def run(self):
for query_id, prompt in self._tasks:
+ if self.isInterruptionRequested():
+ break
try:
response = self._llm_client.chat(prompt, "")
self.query_complete.emit(query_id, response, "")
@@ -67,6 +71,8 @@ def __init__(self, extractor, docs):
def run(self):
total = len(self._docs)
for i, (doc_id, doc_path, doc_name) in enumerate(self._docs):
+ if self.isInterruptionRequested():
+ break
self.progress.emit(i, total, doc_name)
try:
result = self._extractor.extract(doc_path)
@@ -94,6 +100,8 @@ def run(self):
total = len(self._docs)
indexed = 0
for i, (doc_id, doc_name) in enumerate(self._docs):
+ if self.isInterruptionRequested():
+ break
self.progress.emit(i, total, doc_name)
try:
success = self._doc_manager.index_document(doc_id)
@@ -396,6 +404,12 @@ def __init__(self):
self._analysis_worker = None
self._extraction_worker = None
self._index_worker = None
+ self._model_load_worker = None
+ # Hält alle QThread-Worker bis zu ihrem finished-Signal am Leben
+ self._workers = {}
+ # Callbacks von _extract_all_text-Aufrufen, die auf einen laufenden Worker warten
+ self._deferred_extraction_callbacks = []
+ self._close_pending = False
# RAG Engine — lazy init nach erstem GUI-Render (verhindert Startup-Freeze
# bei Remote-Ollama oder langsamer Netzwerkverbindung)
@@ -674,25 +688,46 @@ def _connect_signals(self):
def _create_default_project(self):
"""Create a default project on startup."""
- self._current_project = self._project_manager.create_project(
+ project = self._project_manager.create_project(
"Neues Projekt",
"Was soll analysiert werden?",
"analysis"
)
+ self._activate_project(project)
+ self.statusbar.showMessage("Neues Projekt erstellt")
- # Connect managers to panels
- self.document_panel.set_managers(
- self._current_project.documents,
- self._current_project.subqueries
- )
+ def _activate_project(self, project, load_main_question: bool = False):
+ """Verbindet ein (neues oder geöffnetes) Projekt mit allen Panels und Diensten."""
+ self._current_project = project
- # RAG-Engine mit Document Manager verbinden
- if self._rag_engine:
- self._current_project.documents.set_rag_engine(self._rag_engine)
- self.chat_panel.set_rag_engine(self._rag_engine)
- self.chat_panel.set_document_manager(self._current_project.documents)
+ self.document_panel.set_managers(project.documents, project.subqueries)
+ if load_main_question:
+ self.workflow_panel.set_main_question(project.main_question)
- self.statusbar.showMessage("Neues Projekt erstellt")
+ # RAG-Engine (oder None) und Dokument-Manager immer an alle Konsumenten geben
+ self._connect_rag_engine()
+
+ # LLM-Client mit den Projekt-/App-Einstellungen initialisieren, damit
+ # der Chat sofort funktioniert (OllamaClient prüft Erreichbarkeit lazy)
+ self._init_llm_client()
+
+ self._update_document_context()
+
+ def _connect_rag_engine(self):
+ """Gibt die aktuelle RAG-Engine (auch None) an Dokumente und Chat weiter."""
+ if not hasattr(self, "chat_panel"):
+ return
+ engine = self._rag_engine
+ if self._current_project:
+ documents = self._current_project.documents
+ documents.set_rag_engine(engine)
+ if engine is not None:
+ # is_indexed-Flags mit dem (projektübergreifenden) Index abgleichen
+ documents.sync_index_flags()
+ self.chat_panel.set_document_manager(documents)
+ else:
+ self.chat_panel.set_document_manager(None)
+ self.chat_panel.set_rag_engine(engine)
# Menu actions
def _new_project(self):
@@ -700,21 +735,26 @@ def _new_project(self):
name, ok = QInputDialog.getText(self, "Neues Projekt", "Projektname:")
if ok and name:
self._project_manager.close_project()
- self._current_project = self._project_manager.create_project(name)
-
- self.document_panel.set_managers(
- self._current_project.documents,
- self._current_project.subqueries
- )
-
- # RAG-Engine verbinden
- if self._rag_engine:
- self._current_project.documents.set_rag_engine(self._rag_engine)
- self.chat_panel.set_rag_engine(self._rag_engine)
- self.chat_panel.set_document_manager(self._current_project.documents)
-
+ project = self._project_manager.create_project(name)
+ self._activate_project(project)
self.statusbar.showMessage(f"Projekt '{name}' erstellt")
+ @staticmethod
+ def _project_choice_labels(projects: list) -> dict:
+ """Eindeutige Anzeige-Labels -> Projekt-ID (Namen sind nicht eindeutig)."""
+ name_counts = {}
+ for p in projects:
+ name_counts[p["name"]] = name_counts.get(p["name"], 0) + 1
+
+ labels = {}
+ for p in projects:
+ label = p["name"]
+ if name_counts[p["name"]] > 1:
+ modified = str(p.get("modified_at", ""))[:16].replace("T", " ")
+ label = f"{p['name']} ({modified}, {p['id'][:8]})"
+ labels[label] = p["id"]
+ return labels
+
def _open_project(self):
"""Open an existing project."""
projects = self._project_manager.list_projects()
@@ -722,28 +762,20 @@ def _open_project(self):
QMessageBox.information(self, "Projekt öffnen", "Keine Projekte vorhanden.")
return
- names = [p["name"] for p in projects]
- name, ok = QInputDialog.getItem(
- self, "Projekt öffnen", "Projekt wählen:", names, editable=False
+ labels = self._project_choice_labels(projects)
+ label, ok = QInputDialog.getItem(
+ self, "Projekt öffnen", "Projekt wählen:", list(labels.keys()), editable=False
)
- if ok and name:
- project = self._project_manager.open_project(name)
+ if ok and label in labels:
+ # Aktuelles Projekt sichern, bevor es ersetzt wird
+ self._project_manager.save_current()
+ project = self._project_manager.open_project(labels[label])
if project:
- self._current_project = project
- self.document_panel.set_managers(
- project.documents,
- project.subqueries
- )
- self.workflow_panel.set_main_question(project.main_question)
-
- # RAG-Engine verbinden
- if self._rag_engine:
- project.documents.set_rag_engine(self._rag_engine)
- self.chat_panel.set_rag_engine(self._rag_engine)
- self.chat_panel.set_document_manager(project.documents)
-
- self.statusbar.showMessage(f"Projekt '{name}' geöffnet")
+ self._activate_project(project, load_main_question=True)
+ self.statusbar.showMessage(f"Projekt '{project.name}' geöffnet")
+ else:
+ self.statusbar.showMessage("Projekt konnte nicht geöffnet werden")
def _save_project(self):
"""Save the current project."""
@@ -761,7 +793,7 @@ def _add_files(self):
self,
"Dateien hinzufügen",
"",
- "Alle unterstützten (*.pdf *.docx *.doc *.rtf *.txt *.md *.xlsx *.xls *.pptx *.py *.csv *.json *.xml *.eml *.msg);;Dokumente (*.pdf *.docx *.doc *.rtf *.txt *.md);;Tabellen (*.xlsx *.xls *.csv);;Code (*.py *.js *.java *.cpp *.c *.h);;Alle Dateien (*)"
+ "Alle unterstützten (*.pdf *.docx *.doc *.rtf *.txt *.md *.xlsx *.xls *.pptx *.html *.htm *.py *.csv *.json *.xml *.eml *.msg);;Dokumente (*.pdf *.docx *.doc *.rtf *.txt *.md *.pptx *.html *.htm);;Tabellen (*.xlsx *.xls *.csv);;Code (*.py *.js *.java *.cpp *.c *.h);;Alle Dateien (*)"
)
if files:
@@ -769,6 +801,8 @@ def _add_files(self):
self._current_project.documents.add_file(Path(f))
self.statusbar.showMessage(f"{len(files)} Dateien hinzugefügt")
self._update_pipeline_phase()
+ # Asynchrone Extraktion für die neuen Dokumente starten
+ self._on_files_added()
def _add_folder(self):
"""Add a folder to the project."""
@@ -779,6 +813,9 @@ def _add_folder(self):
if folder:
docs = self._current_project.documents.add_directory(Path(folder))
self.statusbar.showMessage(f"{len(docs)} Elemente hinzugefügt")
+ self._update_pipeline_phase()
+ # Asynchrone Extraktion für die neuen Dokumente starten
+ self._on_files_added()
def _export(self):
"""Export the report."""
@@ -797,23 +834,53 @@ def _deselect_all_docs(self):
if self._current_project:
self._current_project.documents.deselect_all()
+ # ==================== Worker-Lebensdauer ====================
+
+ def _start_tracked_worker(self, attr: Optional[str], worker, on_finished=None):
+ """Startet einen Worker und hält ihn bis zum finished-Signal am Leben.
+
+ Args:
+ attr: Optionaler Attributname (z.B. "_extraction_worker"), der nach
+ Thread-Ende automatisch auf None gesetzt wird
+ worker: Der QThread-Worker
+ on_finished: Optionaler Callback nach Thread-Ende (GUI-Thread),
+ erhält den beendeten Worker als Argument
+ """
+ if self._close_pending:
+ logger.info("Worker-Start während des Beendens übersprungen: %s", type(worker).__name__)
+ return None
+ if attr:
+ setattr(self, attr, worker)
+
+ def _finished(finished_worker):
+ if attr and getattr(self, attr, None) is finished_worker:
+ setattr(self, attr, None)
+ if on_finished is not None:
+ on_finished(finished_worker)
+ if self._close_pending:
+ self._finish_pending_close()
+
+ retain_until_finished(self._workers, worker, _finished)
+ worker.start()
+ return worker
+
+ def _running_workers(self) -> list:
+ """Alle noch laufenden Worker (inkl. Chat-Panel)."""
+ return [w for w in self._workers.values() if w.isRunning()]
+
def _refresh_models(self):
"""Refresh the list of available models (async — no GUI freeze)."""
- if hasattr(self, '_model_load_worker') and self._model_load_worker and self._model_load_worker.isRunning():
+ if self._model_load_worker and self._model_load_worker.isRunning():
return
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 ""
- self._model_load_worker = ModelLoadWorker(url, api_key)
-
- def _on_models(models, msg):
- self.statusbar.showMessage(f"Ollama: {msg}")
- self._model_load_worker = None
-
- self._model_load_worker.models_loaded.connect(_on_models)
- self._model_load_worker.start()
+ worker = ModelLoadWorker(url, api_key)
+ worker.models_loaded.connect(
+ lambda models, msg: self.statusbar.showMessage(f"Ollama: {msg}"))
+ self._start_tracked_worker("_model_load_worker", worker)
def _llm_settings(self):
"""Show LLM settings dialog with provider, model, profiles and embedding config."""
@@ -1059,11 +1126,15 @@ def _on_ollama_models(models, msg):
model_combo.addItems(models)
status_label.setText(msg)
_select_current_model()
- _settings_model_worker[0] = None
+
+ def _on_worker_done(finished_worker):
+ if _settings_model_worker[0] is finished_worker:
+ _settings_model_worker[0] = None
worker.models_loaded.connect(_on_ollama_models)
_settings_model_worker[0] = worker
- worker.start()
+ # Referenz liegt in self._workers -- auch wenn der Dialog vorher schließt
+ self._start_tracked_worker(None, worker, _on_worker_done)
return # combo will be filled once worker finishes
elif provider == "openai":
@@ -1125,16 +1196,20 @@ def _on_ollama_models(models, msg):
app_cfg.claude_code_mode = new_claude_mode
app_cfg.save()
- # Re-init RAG engine with new embedding model
+ # Re-init RAG engine with new embedding model. _init_rag_engine gibt die
+ # neue Engine (oder None, z.B. bei Nicht-Ollama-Provider) an
+ # Dokumente und Chat weiter -- keine veraltete Engine bleibt zurück.
self._init_rag_engine()
if self._rag_engine and self._current_project:
- self._current_project.documents.set_rag_engine(self._rag_engine)
- self.chat_panel.set_rag_engine(self._rag_engine)
# Nachindexierung: Dokumente mit Text aber ohne Index
unindexed = [d for d in self._current_project.documents.documents
if d.extracted_text and not d.is_indexed and not d.is_directory]
if unindexed:
- self._start_index_worker([(d.id, d.name) for d in unindexed])
+ if self._index_worker and self._index_worker.isRunning():
+ self.statusbar.showMessage(
+ "Indexierung läuft bereits – Nachindexierung später über das RAG-Menü")
+ else:
+ self._start_index_worker([(d.id, d.name) for d in unindexed])
self._init_llm_client()
self.statusbar.showMessage(f"LLM: {new_provider} / {new_model}")
@@ -1164,41 +1239,74 @@ def _on_files_added(self):
"""Handle files added — start async extraction for pending docs."""
if not self._current_project:
return
- pending = self._current_project.documents.pop_pending_extractions()
- if not pending:
- return
- # Skip if extraction already running — queue will be picked up next time
+ # Läuft bereits eine Extraktion, bleibt die Queue erhalten und wird
+ # nach Ende des laufenden Workers abgearbeitet (_on_extraction_finished).
if self._extraction_worker and self._extraction_worker.isRunning():
return
+ pending = self._current_project.documents.pop_pending_extractions()
+ if not pending:
+ return
+
self.statusbar.showMessage(f"Extrahiere Text aus {len(pending)} Dokumenten...")
from ..core.text_extractor import TextExtractor
if not hasattr(self, '_text_extractor') or self._text_extractor is None:
self._text_extractor = TextExtractor()
- self._extraction_worker = ExtractionWorker(self._text_extractor, pending)
+ documents = self._current_project.documents
+ worker = ExtractionWorker(self._text_extractor, pending)
def _on_doc_extracted(doc_id, text, error):
from ..core.document_manager import DocumentStatus
if error:
- self._current_project.documents.set_status(doc_id, DocumentStatus.ERROR, error)
+ documents.set_status(doc_id, DocumentStatus.ERROR, error)
else:
- self._current_project.documents.update_content(doc_id, text)
+ documents.update_content(doc_id, text)
def _on_extraction_complete():
- self._extraction_worker = None
self._update_document_context()
self._update_pipeline_phase()
self.statusbar.showMessage(f"Extraktion abgeschlossen: {len(pending)} Dokumente")
+ self._start_pending_indexing()
- self._extraction_worker.doc_extracted.connect(_on_doc_extracted)
- self._extraction_worker.progress.connect(
+ worker.doc_extracted.connect(_on_doc_extracted)
+ worker.progress.connect(
lambda cur, total, name: self.statusbar.showMessage(
f"Extrahiere ({cur+1}/{total}): {name}") if name else None)
- self._extraction_worker.all_complete.connect(_on_extraction_complete)
- self._extraction_worker.start()
+ worker.all_complete.connect(_on_extraction_complete)
+ self._start_tracked_worker(
+ "_extraction_worker", worker, lambda _w: self._on_extraction_finished())
+
+ def _on_extraction_finished(self):
+ """Nach Thread-Ende: zurückgestellte Extraktions-Anfragen abarbeiten."""
+ if self._close_pending:
+ return
+ if self._deferred_extraction_callbacks:
+ callbacks = self._deferred_extraction_callbacks
+ self._deferred_extraction_callbacks = []
+
+ def _run_callbacks():
+ for callback in callbacks:
+ if callback:
+ callback()
+
+ self._extract_all_text(on_complete=_run_callbacks)
+ if self._extraction_worker and self._extraction_worker.isRunning():
+ return
+ # Dateien, die während der Extraktion hinzugefügt wurden
+ self._on_files_added()
+
+ def _start_pending_indexing(self):
+ """Indexiert frisch extrahierte Dokumente im Hintergrund (Auto-Index)."""
+ if self._close_pending or not self._current_project or not self._rag_engine:
+ return
+ if self._index_worker and self._index_worker.isRunning():
+ return # Queue bleibt erhalten, wird nach Ende des Workers abgearbeitet
+ pending = self._current_project.documents.pop_pending_index()
+ if pending:
+ self._start_index_worker(pending, silent=True)
def _on_subquery_requested(self, doc_id: str, query_type: str, query_text: str):
"""Handle sub-query request."""
@@ -1247,7 +1355,10 @@ def _on_export_requested(self, formats: list, directory: str):
output_dir.mkdir(parents=True, exist_ok=True)
formats_and_paths = [(fmt, output_dir / f"{base_name.strip()}.{fmt}") for fmt in formats]
+ from ..reports.exporter import ReportExporter
+
exported = []
+ failed = []
for fmt, filepath in formats_and_paths:
filepath.parent.mkdir(parents=True, exist_ok=True)
try:
@@ -1256,8 +1367,8 @@ def _on_export_requested(self, formats: list, directory: str):
exported.append(filepath.name)
elif fmt == "txt":
- import re
- plain = re.sub(r'[#*`_]', '', content)
+ # Nur Markdown-Syntax entfernen (C#, #12, file_name bleiben erhalten)
+ plain = ReportExporter.markdown_to_plain_text(content)
filepath.write_text(plain, encoding="utf-8")
exported.append(filepath.name)
@@ -1267,16 +1378,25 @@ def _on_export_requested(self, formats: list, directory: str):
exported.append(filepath.name)
elif fmt == "pdf":
- if self._export_pdf(content, filepath):
- exported.append(filepath.name)
+ self._export_pdf(content, filepath)
+ exported.append(filepath.name)
elif fmt == "docx":
if self._export_docx(content, filepath):
exported.append(filepath.name)
+ else:
+ failed.append(f"{fmt}: python-docx nicht installiert")
except Exception as e:
+ failed.append(f"{fmt}: {e}")
self.statusbar.showMessage(f"Fehler bei {fmt}: {e}")
+ if failed:
+ QMessageBox.warning(
+ self, "Export",
+ "Folgende Formate konnten nicht exportiert werden:\n" + "\n".join(failed)
+ )
+
if exported:
out_dir = str(formats_and_paths[0][1].parent)
self.output_panel.set_status(f"Exportiert: {', '.join(exported)}")
@@ -1286,46 +1406,45 @@ def _on_export_requested(self, formats: list, directory: str):
)
def _md_to_html(self, content: str) -> str:
- """Convert markdown to simple HTML."""
- import re
-
- html = content
- # Headers
- html = re.sub(r'^### (.+)$', r'\1
', html, flags=re.MULTILINE)
- html = re.sub(r'^## (.+)$', r'\1
', html, flags=re.MULTILINE)
- html = re.sub(r'^# (.+)$', r'\1
', html, flags=re.MULTILINE)
- # Bold
- html = re.sub(r'\*\*(.+?)\*\*', r'\1', html)
- # Italic
- html = re.sub(r'\*(.+?)\*', r'\1', html)
- # Code
- html = re.sub(r'`(.+?)`', r'\1', html)
- # Paragraphs
- html = re.sub(r'\n\n', r'
', html)
-
- return f"
Report{html}
"
-
- def _export_pdf(self, content: str, filepath: Path) -> bool:
- """Export to PDF."""
- try:
- # Try markdown2pdf or pandoc
- import subprocess
- import tempfile
+ """Convert markdown to a complete, HTML-escaped document.
- with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False, encoding='utf-8') as f:
- f.write(content)
- md_path = f.name
+ Nutzt dieselbe gehärtete Umsetzung wie ReportExporter (Escaping,
+ nur http/https/mailto/relative Links).
+ """
+ from ..reports.exporter import ReportExporter
+
+ title = self._current_project.name if self._current_project else "Bericht"
+ return ReportExporter.render_html_document(content, title)
+
+ def _export_pdf(self, content: str, filepath: Path) -> None:
+ """Export to PDF via pandoc.
+
+ Raises:
+ RuntimeError: mit verständlicher Fehlermeldung, wenn der Export scheitert
+ """
+ import subprocess
+ import tempfile
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False, encoding='utf-8') as f:
+ f.write(content)
+ md_path = f.name
+
+ try:
result = subprocess.run(
['pandoc', md_path, '-o', str(filepath)],
- capture_output=True
+ capture_output=True,
+ timeout=120,
)
+ except FileNotFoundError:
+ raise RuntimeError("pandoc nicht gefunden – bitte pandoc installieren") from None
+ except subprocess.TimeoutExpired:
+ raise RuntimeError("pandoc hat das Zeitlimit (120 s) überschritten") from None
+ finally:
+ Path(md_path).unlink(missing_ok=True)
- Path(md_path).unlink()
- return result.returncode == 0
-
- except Exception:
- return False
+ if result.returncode != 0:
+ stderr = (result.stderr or b"").decode("utf-8", errors="replace").strip()
+ raise RuntimeError(f"pandoc-Fehler: {stderr[:500] or f'Exit-Code {result.returncode}'}")
def _export_docx(self, content: str, filepath: Path) -> bool:
"""Export to DOCX."""
@@ -1435,6 +1554,13 @@ def _extract_all_text(self, on_complete=None):
on_complete()
return
+ # Laufenden Worker nie ersetzen: Anfrage zurückstellen und nach dessen Ende
+ # erneut ausführen (_on_extraction_finished).
+ if self._extraction_worker and self._extraction_worker.isRunning():
+ self._deferred_extraction_callbacks.append(on_complete)
+ self.statusbar.showMessage("Extraktion läuft bereits – Anfrage wird danach ausgeführt")
+ return
+
docs = self._current_project.documents.selected_documents
if not docs:
if on_complete:
@@ -1454,16 +1580,19 @@ def _extract_all_text(self, on_complete=None):
total = len(tasks)
self.statusbar.showMessage(f"Extrahiere Text aus {total} Dokumenten...")
+ documents = self._current_project.documents
+ # Diese Dokumente nicht zusätzlich über die Datei-Queue extrahieren
+ documents.discard_pending_extractions(d.id for d in docs_to_extract)
for d in docs_to_extract:
- self._current_project.documents.set_status(d.id, DocumentStatus.EXTRACTING)
+ documents.set_status(d.id, DocumentStatus.EXTRACTING)
- self._extraction_worker = ExtractionWorker(self._text_extractor, tasks)
+ worker = ExtractionWorker(self._text_extractor, tasks)
def _on_doc_extracted(doc_id, text, error):
if error:
- self._current_project.documents.set_status(doc_id, DocumentStatus.ERROR, error)
+ documents.set_status(doc_id, DocumentStatus.ERROR, error)
else:
- self._current_project.documents.update_content(doc_id, text)
+ documents.update_content(doc_id, text)
def _on_extraction_progress(current, total_count, filename):
if filename:
@@ -1472,14 +1601,15 @@ def _on_extraction_progress(current, total_count, filename):
def _on_extraction_complete():
self._update_document_context()
self.statusbar.showMessage(f"Textextraktion abgeschlossen: {total} Dokumente")
- self._extraction_worker = None
+ self._start_pending_indexing()
if on_complete:
on_complete()
- self._extraction_worker.doc_extracted.connect(_on_doc_extracted)
- self._extraction_worker.progress.connect(_on_extraction_progress)
- self._extraction_worker.all_complete.connect(_on_extraction_complete)
- self._extraction_worker.start()
+ worker.doc_extracted.connect(_on_doc_extracted)
+ worker.progress.connect(_on_extraction_progress)
+ worker.all_complete.connect(_on_extraction_complete)
+ self._start_tracked_worker(
+ "_extraction_worker", worker, lambda _w: self._on_extraction_finished())
def _run_analysis(self):
"""Run sub-query analyses in background thread."""
@@ -1516,11 +1646,11 @@ def _run_analysis(self):
total = len(tasks)
self.statusbar.showMessage(f"Führe {total} Analysen durch...")
- self._analysis_worker = AnalysisWorker(self._llm_client, tasks)
- self._analysis_worker.query_complete.connect(self._on_analysis_result)
- self._analysis_worker.all_complete.connect(
+ worker = AnalysisWorker(self._llm_client, tasks)
+ worker.query_complete.connect(self._on_analysis_result)
+ worker.all_complete.connect(
lambda: self.statusbar.showMessage(f"Analysen abgeschlossen: {total}"))
- self._analysis_worker.start()
+ self._start_tracked_worker("_analysis_worker", worker)
def _generate_report(self):
"""Generate the main report (fully async, no GUI freeze)."""
@@ -1551,6 +1681,8 @@ def _generate_report(self):
def _generate_report_step2(self):
"""Build prompt and start LLM generation (called after extraction)."""
+ if self._close_pending or not self._current_project:
+ return
# Build the main prompt
main_question = self.workflow_panel.get_main_question()
if not main_question:
@@ -1615,14 +1747,14 @@ def _generate_report_step2(self):
self.output_panel.set_status("Generiere Bericht...")
from .chat_panel import LLMWorker
- self._report_worker = LLMWorker(self._llm_client, prompt, "")
+ worker = LLMWorker(self._llm_client, prompt, "")
self._report_workflow = workflow
- self._report_worker.response_chunk.connect(
+ worker.response_chunk.connect(
lambda chunk: self.output_panel.append_content(chunk))
- self._report_worker.response_complete.connect(self._on_report_complete)
- self._report_worker.error_occurred.connect(self._on_report_error)
- self._report_worker.start()
+ worker.response_complete.connect(self._on_report_complete)
+ worker.error_occurred.connect(self._on_report_error)
+ self._start_tracked_worker("_report_worker", worker)
def _on_analysis_result(self, query_id: str, response: str, error: str):
"""Handle a single analysis result from the worker."""
@@ -1638,13 +1770,12 @@ def _on_report_complete(self, full_response: str):
for step in self._report_workflow.steps:
self.workflow_panel.update_step_status(step.id, "completed")
self.statusbar.showMessage("Bericht erfolgreich erstellt")
- self._report_worker = None
+ # Referenz wird erst nach Thread-Ende freigegeben (_start_tracked_worker)
def _on_report_error(self, error: str):
"""Handle report generation error."""
self.output_panel.set_status(f"Fehler: {error}")
self.statusbar.showMessage(f"Fehler bei Berichterstellung: {error}")
- self._report_worker = None
def _export_prompt(self):
"""Export the analysis prompt as .md file for manual LLM usage."""
@@ -1782,11 +1913,35 @@ def _export_workspace(self):
self.statusbar.showMessage(f"Workspace-Export fehlgeschlagen: {e}")
def closeEvent(self, event):
- """Handle window close."""
+ """Handle window close: Worker stoppen/abwarten, dann Projekt speichern."""
+ self._close_pending = True
+ workers_stopped = stop_workers(self._running_workers(), timeout_ms=3000)
+ chat_stopped = self.chat_panel.shutdown_workers(timeout_ms=3000)
+
+ if not (workers_stopped and chat_stopped):
+ # Ein Worker hängt noch in einem Netzwerkaufruf. Den QThread jetzt zu
+ # zerstören würde die App abstürzen lassen -> Fenster ausblenden und
+ # schließen, sobald alle Worker beendet sind (_finish_pending_close).
+ self._project_manager.save_current()
+ self.statusbar.showMessage("Warte auf laufende Hintergrundaufgaben …")
+ event.ignore()
+ self.hide()
+ QTimer.singleShot(500, self._finish_pending_close)
+ return
+
# Save project
self._project_manager.close_project()
event.accept()
+ def _finish_pending_close(self):
+ """Schließt das Fenster, sobald nach closeEvent keine Worker mehr laufen."""
+ if not self._close_pending:
+ return
+ if self._running_workers() or self.chat_panel.has_running_workers():
+ QTimer.singleShot(500, self._finish_pending_close)
+ return
+ self.close()
+
# ==================== RAG Methods ====================
def _init_rag_engine(self):
@@ -1812,6 +1967,8 @@ def _init_rag_engine(self):
if app_cfg.llm_provider not in ("ollama",):
logger.info("RAG Engine übersprungen (Provider ist nicht Ollama)")
self._rag_engine = None
+ self._rag_engine_ready = False
+ self._connect_rag_engine()
if hasattr(self, "statusbar"):
self.statusbar.showMessage("Bereit (RAG nicht aktiv — kein Ollama-Provider)")
return
@@ -1829,6 +1986,7 @@ def _init_rag_engine(self):
api_key=app_cfg.ollama_api_key
)
self._rag_engine_ready = True
+ self._connect_rag_engine()
logger.info(f"RAG Engine initialisiert (URL: {ollama_url}, Embedding: {embedding_model})")
if hasattr(self, "statusbar"):
self.statusbar.showMessage(f"RAG-Engine bereit ({embedding_model} @ {ollama_url})", 5000)
@@ -1836,6 +1994,8 @@ def _init_rag_engine(self):
except Exception as e:
logger.error(f"RAG Engine Initialisierung fehlgeschlagen: {e}")
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)
@@ -1877,23 +2037,32 @@ def _index_selected_documents(self):
self._start_index_worker([(d.id, d.name) for d in docs_with_text])
- def _start_index_worker(self, doc_ids_and_names):
- """Start the index worker thread."""
+ def _start_index_worker(self, doc_ids_and_names, silent: bool = False):
+ """Start the index worker thread.
+
+ Args:
+ doc_ids_and_names: [(doc_id, doc_name), ...]
+ silent: Nur Statusleiste statt Dialog (für Auto-Indexierung)
+ """
self.statusbar.showMessage(f"Indexiere {len(doc_ids_and_names)} Dokumente...")
- self._index_worker = IndexWorker(self._current_project.documents, doc_ids_and_names)
+ worker = IndexWorker(self._current_project.documents, doc_ids_and_names)
- self._index_worker.progress.connect(
+ worker.progress.connect(
lambda cur, total, name: self.statusbar.showMessage(
f"Indexiere ({cur+1}/{total}): {name}") if name else None)
- self._index_worker.all_complete.connect(self._on_index_complete)
- self._index_worker.start()
+ worker.all_complete.connect(
+ lambda indexed, total: self._on_index_complete(indexed, total, silent))
+ # Nach Thread-Ende: inzwischen vorgemerkte Dokumente indexieren
+ self._start_tracked_worker(
+ "_index_worker", worker, lambda _w: self._start_pending_indexing())
- def _on_index_complete(self, indexed, total):
+ def _on_index_complete(self, indexed, total, silent: bool = False):
"""Handle index worker completion."""
- self._index_worker = None
self.statusbar.showMessage(f"RAG: {indexed}/{total} Dokumente indexiert")
- QMessageBox.information(self, "RAG", f"{indexed}/{total} Dokumente erfolgreich indexiert.")
+ self.chat_panel._update_status()
+ if not silent and not self._close_pending:
+ QMessageBox.information(self, "RAG", f"{indexed}/{total} Dokumente erfolgreich indexiert.")
def _clear_rag_index(self):
"""Clear the RAG index."""
diff --git a/src/gui/worker_utils.py b/src/gui/worker_utils.py
new file mode 100644
index 0000000..bff18b8
--- /dev/null
+++ b/src/gui/worker_utils.py
@@ -0,0 +1,75 @@
+"""
+Worker-Hilfen - sichere Lebensdauer für QThread-Worker
+======================================================
+
+Ein QThread-Objekt darf erst zerstört werden, wenn der Thread wirklich beendet
+ist. Wird die letzte Python-Referenz schon in einem Slot von z.B.
+``all_complete`` verworfen (run() läuft dann noch), bricht Qt mit
+"QThread: Destroyed while thread is still running" ab.
+
+``retain_until_finished`` hält deshalb eine Referenz bis zum ``finished``-Signal
+und gibt sie erst danach (verzögert über die Event-Loop) frei.
+"""
+
+import time
+from collections.abc import Callable, Iterable
+
+from PySide6.QtCore import QThread, QTimer
+
+
+def retain_until_finished(
+ registry: dict[int, QThread],
+ worker: QThread,
+ on_finished: Callable[[QThread], None] | None = None,
+) -> QThread:
+ """Hält ``worker`` in ``registry``, bis der Thread beendet ist.
+
+ Args:
+ registry: Dict, das die Worker-Referenzen hält (z.B. ``self._workers``)
+ worker: Der (noch nicht gestartete) Worker
+ on_finished: Optionaler Callback, erhält den Worker nach Thread-Ende
+
+ Returns:
+ Den Worker (für Verkettung)
+ """
+ key = id(worker)
+ registry[key] = worker
+
+ # Die Closure referenziert den Worker bewusst NICHT direkt (nur über den
+ # Key), damit kein Referenzzyklus Worker -> Slot -> Worker entsteht.
+ def _release():
+ finished_worker = registry.pop(key, None)
+ if finished_worker is None:
+ return
+ # finished wird kurz vor dem endgültigen Thread-Ende emittiert
+ finished_worker.wait()
+ try:
+ if on_finished is not None:
+ on_finished(finished_worker)
+ finally:
+ # Letzte Referenz erst nach Rückkehr in die Event-Loop freigeben
+ QTimer.singleShot(0, lambda w=finished_worker: w.isFinished())
+
+ worker.finished.connect(_release)
+ return worker
+
+
+def stop_workers(workers: Iterable[QThread | None], timeout_ms: int = 3000) -> bool:
+ """Fordert alle laufenden Worker zum Beenden auf und wartet auf sie.
+
+ Returns:
+ True, wenn danach kein Worker mehr läuft
+ """
+ running = [w for w in workers if w is not None and w.isRunning()]
+ for worker in running:
+ worker.requestInterruption()
+ stop = getattr(worker, "stop", None)
+ if callable(stop):
+ stop()
+ deadline = time.monotonic() + timeout_ms / 1000.0
+ all_stopped = True
+ for worker in running:
+ remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
+ if not worker.wait(remaining_ms):
+ all_stopped = False
+ return all_stopped
diff --git a/src/llm/ollama_client.py b/src/llm/ollama_client.py
index 169f8a8..41d637c 100644
--- a/src/llm/ollama_client.py
+++ b/src/llm/ollama_client.py
@@ -8,6 +8,7 @@
"""
import json
+import time
from typing import Iterator, Optional
import urllib.request
import urllib.error
@@ -23,6 +24,8 @@ class OllamaClient(LLMClient):
"""
DEFAULT_URL = "http://localhost:11434"
+ # Nach einer fehlgeschlagenen Prüfung frühestens nach so vielen Sekunden erneut prüfen
+ RECHECK_INTERVAL = 5.0
def __init__(self, model: str = "llama3", base_url: str = DEFAULT_URL,
api_key: str = ""):
@@ -37,7 +40,9 @@ def __init__(self, model: str = "llama3", base_url: str = DEFAULT_URL,
super().__init__(model)
self.base_url = base_url.rstrip("/")
self.api_key = api_key
- self._check_availability()
+ # Erreichbarkeit wird lazy beim ersten Aufruf geprüft (im Worker-Thread,
+ # nicht im GUI-Thread) und nach Fehlschlägen erneut geprüft.
+ self._last_check: Optional[float] = None
def _auth_headers(self) -> dict:
"""Build request headers including auth if configured."""
@@ -55,10 +60,32 @@ def _check_availability(self):
self._is_available = response.status == 200
except Exception:
self._is_available = False
+ self._last_check = time.monotonic()
+
+ def _ensure_available(self) -> bool:
+ """Prüft die Erreichbarkeit erneut, solange der Server als nicht erreichbar gilt.
+
+ Ein einmal fehlgeschlagener Check wird NICHT dauerhaft gecacht: wird
+ Ollama nach dem App-Start gestartet, funktioniert der nächste Aufruf.
+ """
+ if self._is_available:
+ return True
+ last = getattr(self, "_last_check", None)
+ if last is None or time.monotonic() - last >= self.RECHECK_INTERVAL:
+ self._check_availability()
+ return self._is_available
+
+ def _mark_unavailable_on(self, exc: Exception) -> None:
+ """Verbindungsfehler bei echten Requests als 'nicht erreichbar' merken."""
+ if isinstance(exc, (urllib.error.URLError, ConnectionError, TimeoutError)) and not (
+ isinstance(exc, urllib.error.HTTPError)
+ ):
+ self._is_available = False
+ self._last_check = time.monotonic()
def chat(self, prompt: str, context: str = "") -> str:
"""Send a chat message and get a response."""
- if not self._is_available:
+ if not self._ensure_available():
raise ConnectionError("Ollama is not available")
messages = []
@@ -78,13 +105,17 @@ def chat(self, prompt: str, context: str = "") -> str:
headers=self._auth_headers()
)
- with urllib.request.urlopen(req, timeout=300) as response:
- result = json.loads(response.read().decode("utf-8"))
- return result.get("message", {}).get("content", "")
+ try:
+ 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:
+ self._mark_unavailable_on(e)
+ raise
def stream_chat(self, prompt: str, context: str = "") -> Iterator[str]:
"""Stream a chat response."""
- if not self._is_available:
+ if not self._ensure_available():
raise ConnectionError("Ollama is not available")
messages = []
@@ -104,20 +135,24 @@ def stream_chat(self, prompt: str, context: str = "") -> Iterator[str]:
headers=self._auth_headers()
)
- with urllib.request.urlopen(req, timeout=300) as response:
- for line in response:
- if line:
- try:
- chunk = json.loads(line.decode("utf-8"))
- content = chunk.get("message", {}).get("content", "")
- if content:
- yield content
- except json.JSONDecodeError:
- pass
+ try:
+ with urllib.request.urlopen(req, timeout=300) as response:
+ for line in response:
+ if line:
+ try:
+ chunk = json.loads(line.decode("utf-8"))
+ content = chunk.get("message", {}).get("content", "")
+ if content:
+ yield content
+ except json.JSONDecodeError:
+ pass
+ except Exception as e:
+ self._mark_unavailable_on(e)
+ raise
def get_models(self) -> list:
"""Get available models from Ollama."""
- if not self._is_available:
+ if not self._ensure_available():
return []
try:
@@ -167,7 +202,7 @@ def generate(self, prompt: str, system: str = "") -> str:
Returns:
Generated text
"""
- if not self._is_available:
+ if not self._ensure_available():
raise ConnectionError("Ollama is not available")
data = {
@@ -185,6 +220,10 @@ def generate(self, prompt: str, system: str = "") -> str:
headers=self._auth_headers()
)
- with urllib.request.urlopen(req, timeout=300) as response:
- result = json.loads(response.read().decode("utf-8"))
- return result.get("response", "")
+ try:
+ 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:
+ self._mark_unavailable_on(e)
+ raise
diff --git a/src/rag/engine.py b/src/rag/engine.py
index c200643..2d15a98 100644
--- a/src/rag/engine.py
+++ b/src/rag/engine.py
@@ -4,6 +4,7 @@
"""
import os
import logging
+import uuid
from typing import List, Optional, Dict, Any, Tuple
from pathlib import Path
from dataclasses import dataclass
@@ -165,9 +166,6 @@ def index_document(
DocumentIndexResult mit Status
"""
try:
- # Entferne vorhandene Chunks für dieses Dokument
- self._remove_document_chunks(document_id)
-
# Splitte Text in Chunks
chunks = self.splitter.split_text(
text=content,
@@ -176,6 +174,8 @@ def index_document(
)
if not chunks:
+ # Leeres Dokument: alte Chunks sind veraltet
+ self._remove_document_chunks(document_id)
return DocumentIndexResult(
document_id=document_id,
chunks_created=0,
@@ -207,8 +207,22 @@ def index_document(
metadata=doc_metadata
))
- # Füge zu ChromaDB hinzu
- self.vectorstore.add_documents(lc_documents)
+ # Erst einbetten (Netzwerk, kann fehlschlagen), DANN alte Chunks
+ # ersetzen -- schlägt das Embedding fehl, bleibt der alte Index intakt.
+ texts = [d.page_content for d in lc_documents]
+ vectors = self.embeddings_manager.embeddings.embed_documents(texts)
+ if len(vectors) != len(lc_documents):
+ raise RuntimeError(
+ f"Embedding lieferte {len(vectors)} Vektoren für {len(lc_documents)} Chunks"
+ )
+
+ self._remove_document_chunks(document_id)
+ self.vectorstore._collection.add(
+ ids=[str(uuid.uuid4()) for _ in lc_documents],
+ embeddings=[list(v) for v in vectors],
+ metadatas=[d.metadata for d in lc_documents],
+ documents=texts,
+ )
logger.info(f"Dokument indexiert: {document_id} ({len(chunks)} Chunks)")
@@ -290,19 +304,44 @@ def search(
if document_ids:
filter_dict = {"document_id": {"$in": document_ids}}
- # Führe Suche durch
- results = self.vectorstore.similarity_search_with_score(
- query=query,
- k=k,
- filter=filter_dict
- )
+ # Führe Suche durch -- Scores sind Relevanzwerte (0..1, höher = besser)
+ results = self._search_with_relevance(query=query, k=k, filter_dict=filter_dict)
- # Filtere nach Score
+ # Filtere nach Relevanz
if score_threshold > 0:
results = [(doc, score) for doc, score in results if score >= score_threshold]
return results
+ @staticmethod
+ def _distance_to_relevance(distance: float) -> float:
+ """Wandelt eine Distanz (kleiner = besser) in eine Relevanz (0..1, größer = besser)."""
+ distance = max(0.0, float(distance))
+ return 1.0 / (1.0 + distance)
+
+ def _search_with_relevance(
+ self,
+ query: str,
+ k: int,
+ filter_dict: Optional[Dict[str, Any]]
+ ) -> List[Tuple[LangChainDocument, float]]:
+ """Semantische Suche, die Relevanz-Scores statt Distanzen liefert."""
+ store = self.vectorstore
+ relevance_fn = getattr(store, "similarity_search_with_relevance_scores", None)
+ if callable(relevance_fn):
+ try:
+ results = relevance_fn(query, k=k, filter=filter_dict)
+ return [
+ (doc, min(1.0, max(0.0, float(score))))
+ for doc, score in results
+ ]
+ except (NotImplementedError, ValueError):
+ pass # Kein Relevanz-Mapping für diese Distanzfunktion
+
+ # Fallback: Distanz -> Relevanz umrechnen
+ results = store.similarity_search_with_score(query=query, k=k, filter=filter_dict)
+ return [(doc, self._distance_to_relevance(score)) for doc, score in results]
+
def query(
self,
question: str,
@@ -356,7 +395,7 @@ def query(
prompt_text = self.prompt.format(context=context, question=question)
response = self.llm.invoke(prompt_text)
- # Berechne durchschnittliche Konfidenz
+ # Berechne durchschnittliche Konfidenz (Relevanz 0..1)
avg_score = sum(d["score"] for d in source_docs) / len(source_docs) if source_docs else 0
return RetrievalResult(
@@ -424,6 +463,20 @@ def get_document_chunks(self, document_id: str) -> List[Dict[str, Any]]:
for doc in results
]
+ def get_indexed_document_ids(self, document_ids: List[str]) -> set:
+ """Gibt die Teilmenge der Dokument-IDs zurück, für die Chunks im Index existieren."""
+ if not document_ids:
+ return set()
+ result = self.vectorstore._collection.get(
+ where={"document_id": {"$in": list(document_ids)}},
+ include=["metadatas"],
+ )
+ return {
+ (meta or {}).get("document_id")
+ for meta in (result.get("metadatas") or [])
+ if (meta or {}).get("document_id")
+ }
+
def get_statistics(self) -> Dict[str, Any]:
"""Gibt Statistiken über den Index zurück"""
try:
diff --git a/src/reports/exporter.py b/src/reports/exporter.py
index 808f179..394c7bf 100644
--- a/src/reports/exporter.py
+++ b/src/reports/exporter.py
@@ -8,6 +8,7 @@
"""
import html as html_lib
+import json
import re
import subprocess
import tempfile
@@ -109,9 +110,9 @@ def _export_markdown(self, content: str, name: str, title: str, author: str) ->
if title or author:
header = "---\n"
if title:
- header += f"title: {title}\n"
+ header += f"title: {self._yaml_quote(title)}\n"
if author:
- header += f"author: {author}\n"
+ header += f"author: {self._yaml_quote(author)}\n"
header += f"date: {datetime.now().strftime('%Y-%m-%d')}\n"
header += "---\n\n"
@@ -127,36 +128,43 @@ def _export_text(self, content: str, name: str) -> ExportResult:
try:
filepath = self.output_dir / f"{name}.txt"
- # Strip Markdown formatting
- plain = content
- plain = re.sub(r'^#+\s+', '', plain, flags=re.MULTILINE) # Headers
- plain = re.sub(r'\*\*(.+?)\*\*', r'\1', plain) # Bold
- plain = re.sub(r'\*(.+?)\*', r'\1', plain) # Italic
- plain = re.sub(r'`(.+?)`', r'\1', plain) # Code
- plain = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', plain) # Links
- plain = re.sub(r'^\s*[-*]\s+', '- ', plain, flags=re.MULTILINE) # Lists
-
- filepath.write_text(plain, encoding="utf-8")
+ filepath.write_text(self.markdown_to_plain_text(content), encoding="utf-8")
return ExportResult(True, filepath, "txt")
except Exception as e:
return ExportResult(False, format="txt", error=str(e))
- def _export_html(self, content: str, name: str, title: str) -> ExportResult:
- """Export to HTML."""
- try:
- filepath = self.output_dir / f"{name}.html"
-
- # Convert Markdown to HTML
- html_content = self._markdown_to_html(content)
-
- html = f"""
+ @staticmethod
+ def _yaml_quote(value: str) -> str:
+ """Quote a scalar for YAML front matter (JSON strings are valid YAML)."""
+ return json.dumps(str(value), ensure_ascii=False)
+
+ @staticmethod
+ def markdown_to_plain_text(content: str) -> str:
+ """Strip Markdown syntax only -- literal characters like C#, #12 or file_name stay."""
+ plain = content
+ plain = re.sub(r'^[ \t]*(```|~~~)[^\n]*$\n?', '', plain, flags=re.MULTILINE) # Fences
+ plain = re.sub(r'^[ \t]{0,3}#{1,6}[ \t]+', '', plain, flags=re.MULTILINE) # Headers
+ plain = re.sub(r'^([ \t]*)[-*+][ \t]+', r'\1- ', plain, flags=re.MULTILINE) # Lists
+ plain = re.sub(r'\*\*(?=\S)(.+?)(?<=\S)\*\*', r'\1', plain) # Bold
+ plain = re.sub(r'(? str:
+ """Render Markdown content as a complete, escaped HTML document."""
+ html_content = cls.markdown_to_html(content)
+ safe_title = html_lib.escape(title or 'Bericht')
+
+ return f"""
- {title or 'Bericht'}
+ {safe_title}
"
+ "Grüße & mehr
",
+ encoding="utf-8",
+ )
+ result = TextExtractor().extract(page)
+ assert result.success
+ assert "Grüße & mehr" in result.text
+ assert "" not in result.text and "alert" not in result.text
+
+
+# --------------------------------------------------------------------------- #
+# 10: TXT-Export
+# --------------------------------------------------------------------------- #
+
+
+def test_plain_text_export_keeps_literal_characters():
+ md = (
+ "# Überschrift\n\n"
+ "C# und Ticket #12 in file_name sowie **fett** und *kursiv*.\n\n"
+ "* Punkt\n\n```python\nx = 1\n```\n[Link](https://example.org)"
+ )
+ plain = ReportExporter.markdown_to_plain_text(md)
+ assert plain.startswith("Überschrift")
+ assert "C# und Ticket #12 in file_name sowie fett und kursiv." in plain
+ assert "- Punkt" in plain
+ assert "```" not in plain and "x = 1" in plain
+ assert "Link" in plain and "](" not in plain
+
+
+# --------------------------------------------------------------------------- #
+# 11-13: Extraktor
+# --------------------------------------------------------------------------- #
+
+
+def test_excel_keeps_zero_and_false(tmp_path):
+ openpyxl = pytest.importorskip("openpyxl")
+ wb = openpyxl.Workbook()
+ ws = wb.active
+ ws.append(["A", 0, False, None, 1.5])
+ path = tmp_path / "tabelle.xlsx"
+ wb.save(path)
+
+ result = TextExtractor().extract(path)
+ assert result.success
+ assert "A | 0 | False | | 1.5" in result.text
+
+
+def test_rtf_unicode_fallback_and_hex_bytes():
+ rtf = rb"{\rtf1\ansi\ansicpg1252 Gr\u252\'fc\'df \u8364? Ende}"
+ assert TextExtractor._parse_rtf_bytes(rtf) == "Grüß € Ende"
+
+
+def test_rtf_uc2_skips_two_hex_units():
+ rtf = rb"{\rtf1\uc2 A\u8364\'80\'80B}"
+ assert TextExtractor._parse_rtf_bytes(rtf) == "A€B"
+
+
+def test_rtf_multibyte_codepage_bytes_are_buffered():
+ # "あ" in cp932 = 0x82 0xA0 -- nur korrekt, wenn beide Bytes gemeinsam dekodiert werden
+ rtf = rb"{\rtf1\ansi\ansicpg932 \'82\'a0 ok}"
+ assert TextExtractor._parse_rtf_bytes(rtf) == "あ ok"
+
+
+@pytest.mark.parametrize(
+ "raw",
+ [
+ "Grüße".encode("utf-16"), # BOM + UTF-16-LE
+ b"\xfe\xff" + "Grüße".encode("utf-16-be"),
+ b"\xef\xbb\xbf" + "Grüße".encode(),
+ "Grüße".encode(),
+ "Grüße".encode("cp1252"),
+ ],
+)
+def test_plain_text_encoding_detection(tmp_path, raw):
+ path = tmp_path / "text.txt"
+ path.write_bytes(raw)
+ result = TextExtractor().extract(path)
+ assert result.success
+ assert result.text == "Grüße"
+
+
+def test_cp1252_specific_characters(tmp_path):
+ path = tmp_path / "euro.txt"
+ path.write_bytes("Preis: 5 € – „gut“".encode("cp1252"))
+ assert TextExtractor().extract(path).text == "Preis: 5 € – „gut“"
+
+
+def test_pptx_slides_sorted_numerically(tmp_path):
+ ns = "http://schemas.openxmlformats.org/drawingml/2006/main"
+ path = tmp_path / "folien.pptx"
+ with zipfile.ZipFile(path, "w") as z:
+ for number in (1, 2, 10):
+ z.writestr(
+ f"ppt/slides/slide{number}.xml",
+ f'Folie {number}',
+ )
+ z.writestr("ppt/slides/_rels/slide1.xml.rels", "")
+
+ result = TextExtractor().extract(path)
+ assert result.success
+ order = [result.text.index(f"Folie {n}") for n in (1, 2, 10)]
+ assert order == sorted(order)
+
+
+def test_pdf_document_closed_on_error(tmp_path, monkeypatch):
+ pytest.importorskip("fitz")
+ import fitz
+
+ fake_doc = MagicMock()
+ fake_doc.__iter__.side_effect = RuntimeError("kaputt")
+ monkeypatch.setattr(fitz, "open", lambda *a, **k: fake_doc)
+ path = tmp_path / "x.pdf"
+ path.write_bytes(b"%PDF")
+
+ result = TextExtractor()._extract_pdf(path)
+ assert result.success is False
+ fake_doc.close.assert_called_once()
+
+
+# --------------------------------------------------------------------------- #
+# 14: Ollama-Verfügbarkeit
+# --------------------------------------------------------------------------- #
+
+
+def test_ollama_availability_is_rechecked(monkeypatch):
+ from src.llm.ollama_client import OllamaClient
+
+ checks = []
+
+ def fake_check(self):
+ checks.append(True)
+ self._is_available = len(checks) >= 2 # erster Check scheitert
+ self._last_check = 0.0
+
+ monkeypatch.setattr(OllamaClient, "_check_availability", fake_check)
+ monkeypatch.setattr(OllamaClient, "RECHECK_INTERVAL", 0.0)
+
+ client = OllamaClient("modell", "http://127.0.0.1:9")
+ assert checks == [] # kein Netzwerkzugriff im Konstruktor (GUI-Thread)
+
+ assert client._ensure_available() is False
+ assert client._ensure_available() is True
+ assert len(checks) == 2
+
+
+def test_ollama_unavailable_raises_connection_error(monkeypatch):
+ from src.llm.ollama_client import OllamaClient
+
+ def fake_check(self):
+ self._is_available = False
+ self._last_check = 0.0
+
+ monkeypatch.setattr(OllamaClient, "_check_availability", fake_check)
+ client = OllamaClient("modell", "http://127.0.0.1:9")
+ with pytest.raises(ConnectionError):
+ client.chat("Hallo")
+
+
+# --------------------------------------------------------------------------- #
+# Niedrige Schwere
+# --------------------------------------------------------------------------- #
+
+
+def test_markdown_front_matter_quotes_title(tmp_path):
+ yaml = pytest.importorskip("yaml")
+ exporter = ReportExporter(tmp_path)
+ title = 'Bericht: "Q3" # final'
+ result = exporter._export_markdown("Inhalt", "bericht", title, "Ä: B")
+ text = result.filepath.read_text(encoding="utf-8")
+ front = text.split("---\n")[1]
+ data = yaml.safe_load(front)
+ assert data["title"] == title
+ assert data["author"] == "Ä: B"
+
+
+@requires_qt
+def test_gui_pdf_export_reports_missing_pandoc_and_cleans_temp(tmp_path):
+ from src.gui.main_window import MainWindow
+
+ seen = {}
+
+ def fake_run(cmd, **kwargs):
+ seen["md"] = Path(cmd[1])
+ seen["timeout"] = kwargs.get("timeout")
+ assert seen["md"].exists()
+ raise FileNotFoundError("pandoc")
+
+ with patch.object(subprocess, "run", fake_run), pytest.raises(RuntimeError, match="pandoc"):
+ MainWindow._export_pdf(SimpleNamespace(), "# Text", tmp_path / "out.pdf")
+
+ assert seen["timeout"]
+ assert not seen["md"].exists()
+
+
+def test_translator_whole_word_and_readonly_safe(tmp_path):
+ from translator import TranslationSystem
+
+ tr = TranslationSystem("de", tmp_path)
+ assert tr._is_german("Start") is False
+ assert tr._is_german("Export") is False
+ assert tr._is_german("Jakarta") is False
+ assert tr._is_german("Datei laden") is True
+
+ # Schreibfehler beim Speichern dürfen t() nicht abbrechen
+ tr.translations_file = tmp_path # Verzeichnis -> OSError beim Schreiben
+ assert tr.t("Fehler beim Öffnen") == "Fehler beim Öffnen"
+
+
+def test_workspace_export_contains_project_id():
+ from src.core.project import Project
+ from src.core.workspace_exporter import build_workspace_export_payload
+
+ project = Project.create("Workspace")
+ payload = build_workspace_export_payload(project)
+ assert payload["workspace"]["id"] == project.id
+ json.dumps(payload) # weiterhin serialisierbar
diff --git a/translator.py b/translator.py
index 2930fb7..3c23efe 100644
--- a/translator.py
+++ b/translator.py
@@ -53,12 +53,19 @@ def __init__(self, default_lang: str = 'de', app_dir: Path = None):
re.compile(r'text\s*=\s*"([^"]+)"'),
]
+ # Nur eindeutig deutsche Wörter -- englisch identische Begriffe wie
+ # "ok", "start", "stop", "pause", "filter", "export", "import" würden
+ # englische UI-Texte fälschlich als deutsch einstufen.
self.german_hints = [
"datei", "bearbeiten", "ansicht", "hilfe", "öffnen", "speichern",
- "schließen", "einstellungen", "abbrechen", "ok", "ja", "nein",
- "start", "stop", "pause", "fortsetzen", "laden", "aktualisieren",
- "filter", "fehler", "export", "import", "optionen", "anzeigen",
+ "schließen", "einstellungen", "abbrechen", "ja", "nein",
+ "fortsetzen", "laden", "aktualisieren",
+ "fehler", "optionen", "anzeigen",
]
+ # Ganzwort-Abgleich: "ja" darf nicht in "Jakarta", "laden" nicht in "Paladin" greifen
+ self._german_hint_re = re.compile(
+ r"\b(?:" + "|".join(re.escape(h) for h in self.german_hints) + r")\b"
+ )
self.translations = {}
self._load_translations()
@@ -100,7 +107,12 @@ def t(self, key: str) -> str:
if lang != 'de':
entry[lang] = ""
self.translations[key] = entry
- self._save_translations()
+ try:
+ self._save_translations()
+ except OSError:
+ # z.B. schreibgeschütztes Installationsverzeichnis -- Übersetzen
+ # darf deshalb nie fehlschlagen
+ pass
return key
@@ -174,8 +186,7 @@ def _is_german(self, text: str) -> bool:
# Echte deutsche Sonderzeichen oder klare deutsche Schlüsselwörter.
if any(ch in text for ch in "\u00e4\u00f6\u00fc\u00c4\u00d6\u00dc\u00df"):
return True
- text_lower = text.lower()
- return any(hint in text_lower for hint in self.german_hints)
+ return bool(self._german_hint_re.search(text.lower()))
def get_missing_translations(self, lang: str = None) -> Dict[str, List[str]]:
"""Gibt fehlende Übersetzungen zurück. Ohne Argument: alle Sprachen."""
diff --git a/web_companion/app.js b/web_companion/app.js
index bca97a3..ecb9bab 100644
--- a/web_companion/app.js
+++ b/web_companion/app.js
@@ -83,6 +83,12 @@ function applyStaticTranslations() {
}
function workspaceStorageKey(payload) {
+ // Neue Exporte tragen eine Projekt-ID: Notizen gleichnamiger Projekte bleiben getrennt.
+ // Ältere Exporte ohne ID behalten den bisherigen Titel-Schlüssel (rückwärtskompatibel).
+ const id = payload?.workspace?.id;
+ if (id) {
+ return `${STORAGE_PREFIX}id:${id}`;
+ }
const title = payload?.workspace?.title || "workspace";
return `${STORAGE_PREFIX}${title}`;
}
diff --git a/web_companion/library.js b/web_companion/library.js
index 6c9f110..5ba69ae 100644
--- a/web_companion/library.js
+++ b/web_companion/library.js
@@ -562,6 +562,7 @@ const UI_TEXT = Object.freeze({
});
const DEFAULT_WORKSPACE = Object.freeze({
+ id: "",
title: "Unbenannter Workspace",
question: "",
workflow_type: "",
@@ -697,6 +698,7 @@ export function normalizeWorkspacePayload(payload, locale = "de") {
},
workspace: {
...DEFAULT_WORKSPACE,
+ id: asString(payload.workspace?.id, DEFAULT_WORKSPACE.id),
title: asString(payload.workspace?.title, uiText.untitledWorkspace),
question: asString(payload.workspace?.question, DEFAULT_WORKSPACE.question),
workflow_type: asString(payload.workspace?.workflow_type, DEFAULT_WORKSPACE.workflow_type),
diff --git a/web_companion/sw.js b/web_companion/sw.js
index 6a773f4..131f5a5 100644
--- a/web_companion/sw.js
+++ b/web_companion/sw.js
@@ -1,4 +1,4 @@
-const CACHE_NAME = "notespacellm-companion-v3";
+const CACHE_NAME = "notespacellm-companion-v4";
const ASSETS = [
"./",
"./index.html",
diff --git a/web_companion/tests/library.test.mjs b/web_companion/tests/library.test.mjs
index 6ffee9a..b825a5d 100644
--- a/web_companion/tests/library.test.mjs
+++ b/web_companion/tests/library.test.mjs
@@ -209,3 +209,30 @@ test("app.js speichert komplette Workspace-Payloads nicht in localStorage", () =
"Workspace-JSON darf wegen Dokumentinhalten und Berichtstext nicht persistent in localStorage landen"
);
});
+
+test("normalizeWorkspacePayload übernimmt die optionale Projekt-ID", () => {
+ const withId = normalizeWorkspacePayload({
+ schema_version: "notespacellm-workspace-v1",
+ workspace: { id: "proj-123", title: "Gleicher Titel" }
+ });
+ const withoutId = normalizeWorkspacePayload({
+ schema_version: "notespacellm-workspace-v1",
+ workspace: { title: "Gleicher Titel" }
+ });
+
+ assert.equal(withId.workspace.id, "proj-123");
+ assert.equal(withoutId.workspace.id, "");
+});
+
+test("app.js speichert Review-Notizen pro Projekt-ID (Titel nur als Legacy-Fallback)", () => {
+ assert.match(
+ appSource,
+ /\$\{STORAGE_PREFIX\}id:\$\{id\}/,
+ "Notizen müssen bei vorhandener Projekt-ID unter einem ID-Schlüssel liegen"
+ );
+ assert.match(
+ appSource,
+ /payload\?\.workspace\?\.id/,
+ "workspaceStorageKey muss workspace.id auswerten"
+ );
+});