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 @@ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/) [![Platform: Windows](https://img.shields.io/badge/platform-Windows-lightgrey.svg)]() [![Offline-first](https://img.shields.io/badge/offline--first-yes-green.svg)]() -[![Pytest](https://img.shields.io/badge/Pytest-103%20passed-brightgreen.svg)]() -[![Web Companion Tests](https://img.shields.io/badge/Web%20Companion-58%20passed-brightgreen.svg)]() +[![Pytest](https://img.shields.io/badge/Pytest-147%20passed-brightgreen.svg)]() +[![Web Companion Tests](https://img.shields.io/badge/Web%20Companion-60%20passed-brightgreen.svg)]() [![Notice: Invariants](https://img.shields.io/badge/Notice-INV--LOCAL--01..10-blue.svg)](NOTICE) [![SBOM: Level 1](https://img.shields.io/badge/SBOM-Level%201-blue.svg)](THIRD_PARTY_LICENSES.md) [![Ecosystem: file-bricks](https://img.shields.io/badge/Ecosystem-file--bricks-blue.svg)](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 @@ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/) [![Plattform: Windows](https://img.shields.io/badge/Plattform-Windows-lightgrey.svg)]() [![Offline-first](https://img.shields.io/badge/offline--first-ja-green.svg)]() -[![Pytest](https://img.shields.io/badge/Pytest-103%20bestanden-brightgreen.svg)]() -[![Web Companion Tests](https://img.shields.io/badge/Web%20Companion-58%20bestanden-brightgreen.svg)]() +[![Pytest](https://img.shields.io/badge/Pytest-147%20bestanden-brightgreen.svg)]() +[![Web Companion Tests](https://img.shields.io/badge/Web%20Companion-60%20bestanden-brightgreen.svg)]() [![Notice: Invarianten](https://img.shields.io/badge/Notice-INV--LOCAL--01..10-blue.svg)](NOTICE) [![SBOM: Level 1](https://img.shields.io/badge/SBOM-Level%201-blue.svg)](THIRD_PARTY_LICENSES.md) [![Ökosystem: file-bricks](https://img.shields.io/badge/%C3%96kosystem-file--bricks-blue.svg)](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" + ); +});