File: //opt/textanalyse/tools/hunspell_tool.py
# tools/hunspell_tool.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
hunspell_tool.py
Ein erweiterter deutschsprachiger Rechtschreib‑ und Grammatik‑Checker als
„Agentic Tool“ für Agno Agents. Kommt ohne weitere Abhängigkeiten als
Hunspell (Pflicht) und optional Language‑Tool‑Python aus und kann sowohl in
einem Agent‑Framework (z. B. LangChain) als auch als CLI‑Programm verwendet
werden – alles in einer einzigen Datei.
© 2025 — Veröffentlicht unter MIT‑Lizenz.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Iterable, List, Optional, Sequence, Tuple
import hunspell
# LanguageTool ist optional – fehlende Installation wird sauber abgefangen
try:
import language_tool_python # type: ignore
except ModuleNotFoundError: # pragma: no cover
language_tool_python = None # type: ignore
################################################################################
# Datenklassen
################################################################################
@dataclass(slots=True)
class Typo:
"""Repräsentiert einen Rechtschreibfehler samt Korrekturvorschlägen."""
word: str
suggestions: Sequence[str] = field(repr=False)
context: str
@dataclass(slots=True)
class GrammarIssue:
"""Repräsentiert einen Grammatik‑ bzw. Stil‑Fehler von Language Tool."""
rule_id: str
message: str
replacements: Sequence[str] = field(repr=False)
context: str
################################################################################
# Kern‑Logik
################################################################################
class GermanSpellChecker:
"""Erweiterter deutscher Spell‑ & Grammar‑Checker auf Basis Hunspell + LT."""
_WORD_RE: re.Pattern[str] = re.compile(r"\b[\wÄÖÜäöüß]+\b", flags=re.UNICODE)
def __init__(
self,
*,
dict_code: str = "de_DE",
dict_dir: Optional[os.PathLike[str]] = None,
personal_dict: Optional[Iterable[str]] = None,
max_suggestions: int = 5,
) -> None:
"""
Parameters
----------
dict_code
z. B. ``de_DE`` (Standard), ``de_AT`` oder ``de_CH``.
dict_dir
Verzeichnis, in dem ``*.dic``/``*.aff`` liegen. Fällt auf
systemweite Verzeichnisse zurück, falls ``None``.
personal_dict
Iterable zusätzlicher Wörter, die als korrekt gelten sollen.
max_suggestions
Maximale Anzahl Hunspell‑Vorschläge pro Word.
"""
self.max_suggestions = max_suggestions
dic_path, aff_path = self._resolve_dictionary(dict_code, dict_dir)
self._hs = hunspell.HunSpell(str(dic_path), str(aff_path))
if personal_dict:
for w in personal_dict:
self._hs.add(w)
# Optional LanguageTool‑Backend
if language_tool_python is not None: # pragma: no cover
self._lt = language_tool_python.LanguageTool(f"{dict_code.replace('_', '-')}")
else:
self._lt = None
# --------------------------------------------------------------------- API
def spell_check(self, text: str) -> List[Typo]:
"""Hunspell‑basierte Rechtschreibprüfung."""
results: List[Typo] = []
for match in self._WORD_RE.finditer(text):
word = match.group(0)
if word[0].isdigit():
continue # Hausnummern, Daten, §‑Zitate …
if not self._hs.spell(word):
ctx = self._context_window(text, match.span(), radius=40)
sug = self._hs.suggest(word)[: self.max_suggestions]
results.append(Typo(word=word, suggestions=sug, context=ctx))
return results
def grammar_check(self, text: str) -> List[GrammarIssue]:
"""Optionale Grammatik‑ & Stilprüfung via LanguageTool (falls vorhanden)."""
if self._lt is None:
return []
issues: List[GrammarIssue] = []
for m in self._lt.check(text): # type: ignore[attr-defined]
ctx = text[m.offset : m.offset + m.errorLength] # type: ignore[attr-defined]
issues.append(
GrammarIssue(
rule_id=m.ruleId, # type: ignore[attr-defined]
message=m.message, # type: ignore[attr-defined]
replacements=m.replacements[: self.max_suggestions], # type: ignore[attr-defined]
context=ctx,
)
)
return issues
# ------------------------------------------- Halbautomatische Korrekturen
def apply_quick_fixes(self, text: str) -> str:
"""
Wendet jeweils den *ersten* Hunspell‑Vorschlag an – nützlich für
Batch‑Korrekturen, aber natürlich ohne semantische Prüfung.
"""
corrections = {t.word: t.suggestions[0] for t in self.spell_check(text) if t.suggestions}
if not corrections:
return text
return re.sub(
self._WORD_RE,
lambda m: corrections.get(m.group(0), m.group(0)),
text,
)
# ---------------------------------------------------------------- Helpers
@staticmethod
def _context_window(text: str, span: Tuple[int, int], *, radius: int = 30) -> str:
start, end = span
return text[max(0, start - radius) : min(len(text), end + radius)].strip()
@staticmethod
def _resolve_dictionary(
code: str, directory: Optional[os.PathLike[str]]
) -> Tuple[Path, Path]:
"""
Liefert Pfade zur passenden ``*.dic``/``*.aff``‑Datei; wirft FileNotFoundError,
falls nicht auffindbar.
"""
potential_dirs: list[Path] = []
if directory:
potential_dirs.append(Path(directory))
potential_dirs.extend(
Path(p).expanduser()
for p in (
"/usr/share/hunspell",
"/usr/share/myspell",
"/usr/local/share/hunspell",
"~/.hunspell",
)
)
base_name = code
for d in potential_dirs:
dic = d / f"{base_name}.dic"
aff = d / f"{base_name}.aff"
if dic.is_file() and aff.is_file():
return dic, aff
raise FileNotFoundError(
f"Hunspell‑Wörterbuch für {code} nicht gefunden – "
"bitte Pfad via --dict-dir angeben."
)
################################################################################
# Agent‑Integration (LangChain‑kompatibles Tool)
################################################################################
try:
# optional, nur wenn LangChain installiert ist
from langchain.tools import BaseTool # type: ignore
from pydantic import BaseModel, Field # type: ignore
class _SpellCheckInput(BaseModel):
text: str = Field(..., description="Deutscher Freitext zur Prüfung")
class GermanSpellCheckTool(BaseTool):
"""
LangChain‑Tool für Agno Agents / Agenten‑Frameworks.
Der Rückgabewert ist ein JSON‑String mit Listen von Typos & Grammar‑Issues.
"""
name: str = "german_spell_check"
description: str = (
"Prüft deutschen Text auf Rechtschreib‑, Grammatik‑ und Stilfehler."
)
args_schema = _SpellCheckInput
def __init__(self, **kwargs): # noqa: D401
super().__init__(**kwargs)
self._checker = GermanSpellChecker()
def _run(self, text: str) -> str: # type: ignore[override]
typos = [asdict(t) for t in self._checker.spell_check(text)]
gramm = [asdict(g) for g in self._checker.grammar_check(text)]
return json.dumps({"typos": typos, "grammar": gramm}, ensure_ascii=False)
async def _arun(self, text: str) -> str: # type: ignore[override]
# keine asynchrone Spezialbehandlung nötig
return self._run(text)
except ModuleNotFoundError: # pragma: no cover
# Kein LangChain – Toolklasse wird nicht exportiert, ist aber optional
GermanSpellCheckTool = None # type: ignore
################################################################################
# CLI‑Interface
################################################################################
def _cli() -> None: # pragma: no cover
parser = argparse.ArgumentParser(
description="Erweiterter deutscher Spell‑ & Grammar‑Checker "
"(Hunspell + optional LanguageTool)."
)
parser.add_argument(
"file",
nargs="?",
help="Datei mit deutschem Text (UTF‑8). Fehlt der Pfad, "
"wird von STDIN gelesen.",
)
parser.add_argument(
"--quick‑fix",
action="store_true",
help="Ersetzt Fehler automatisch durch den jeweils ersten "
"Vorschlag (nur Hunspell).",
)
parser.add_argument("--json", action="store_true", help="Ausgabe als JSON.")
parser.add_argument("--dict-code", default="de_DE", help="Hunspell‑Sprachcode.")
parser.add_argument("--dict-dir", help="Verzeichnis der Hunspell‑Dictionaries.")
args = parser.parse_args()
text = (
Path(args.file).read_text(encoding="utf-8")
if args.file
else sys.stdin.read()
)
checker = GermanSpellChecker(dict_code=args.dict_code, dict_dir=args.dict_dir)
if args.quick_fix:
corrected = checker.apply_quick_fixes(text)
sys.stdout.write(corrected)
return
typos = checker.spell_check(text)
gramm = checker.grammar_check(text)
if args.json:
sys.stdout.write(
json.dumps(
{"typos": [asdict(t) for t in typos], "grammar": [asdict(g) for g in gramm]},
ensure_ascii=False,
indent=2,
)
+ "\n"
)
else:
for t in typos:
print(f"[TYPO] »{t.word}« → {', '.join(t.suggestions) or '—'}")
print(f" …{t.context}…\n")
for g in gramm:
print(f"[GRAM] ({g.rule_id}) {g.message}")
print(f" Vorschläge: {', '.join(g.replacements) or '—'}")
print(f" …{g.context}…\n")
# ------------------------------------------------------------------------------
if __name__ == "__main__":
_cli()