Files
Saiki/src/saiki/words.py
T

528 lines
19 KiB
Python

"""Extract and compare language-learning vocabulary."""
from __future__ import annotations
import logging
import os
from collections import Counter
import regex as re
from typing import Any, Callable
from .ankiconnect import anki_request
from .anki_tsv import parse_anki_tsv
from .config import Config
from .spanish import (
clean_anki_field_text,
extract_detailed_counts,
lint_anki_cards,
load_bad_lemmas,
load_lemma_corrections,
safe_spanish_lemma_info,
spanish_content_filter,
spanish_function_word_filter,
write_debug_tsv,
write_suspicious_tokens,
)
from .text import extract_first_visible_line, extract_visible_text, normalize_word_key
ACCENT_MAP = str.maketrans("áéíóúüñÁÉÍÓÚÜÑ", "aeiouunAEIOUUN")
JAPANESE_CHAR_RE = re.compile(r"[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}ー]+")
JAPANESE_PARTICLES = {
"", "", "", "", "", "", "", "", "", "から", "まで", "より", "", "なら",
"", "", "", "", "", "", "", "", "", "って", "とき", "ってば", "けど", "けれど",
"しかし", "でも", "ながら", "ほど", "", "もの", "こと", "ところ", "よう", "らしい", "られる",
}
JAPANESE_GRAMMAR_EXCLUDE = {
"", "", "ます", "れる", "てる", "", "", "しまう", "いる", "ない", "なる", "ある", "", "です",
}
JAPANESE_ALLOWED_POS = {"NOUN", "PROPN", "VERB", "ADJ"}
def setup_logging(logfile: str) -> None:
"""Configure file logging for word extraction scripts."""
os.makedirs(os.path.dirname(os.path.abspath(logfile)), exist_ok=True)
logging.basicConfig(filename=logfile, level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
def build_query_from_decks(decks: list[str]) -> str:
"""Build an Anki search query that matches any configured deck."""
return " OR ".join(f'deck:"{d}"' for d in decks)
def japanese_filter(token) -> bool:
"""Return whether a spaCy token is useful Japanese vocabulary.
The filter is intentionally conservative: it keeps content words and drops
common particles, helper grammar, stop words, URLs, and obvious HTML debris.
"""
text = (token.text or "").strip()
lemma = (token.lemma_ or "").strip()
if not text or not JAPANESE_CHAR_RE.fullmatch(text):
return False
if lemma in JAPANESE_GRAMMAR_EXCLUDE or text in JAPANESE_PARTICLES:
return False
if getattr(token, "pos_", None) not in JAPANESE_ALLOWED_POS:
return False
if getattr(token, "is_stop", False) or getattr(token, "like_url", False) or getattr(token, "like_email", False):
return False
if any(c in text for c in "<>=/\\:&%"):
return False
return text not in {"ruby", "rt", "div", "br", "nbsp", "href", "strong", "a"}
def spanish_filter(token) -> bool:
"""Return whether a spaCy token is useful Spanish vocabulary."""
return bool(getattr(token, "is_alpha", False)) and not bool(getattr(token, "is_stop", False))
def spanish_format(token) -> str:
"""Normalize a Spanish token to its lowercase lemma."""
return (token.lemma_ or token.text or "").lower().strip()
def japanese_format(token) -> str:
"""Format a Japanese token as lemma plus surface form when they differ."""
lemma = (token.lemma_ or "").strip()
surface = (token.text or "").strip()
if lemma and surface and lemma != surface:
return f"{lemma} ({surface})"
return lemma or surface
LANGUAGE_PROFILES = {
"spanish": {"token_filter": spanish_filter, "output_format": spanish_format},
"japanese": {"token_filter": japanese_filter, "output_format": japanese_format},
}
def load_spacy_model(model_name: str):
"""Load a spaCy model with installation-oriented error messages."""
try:
import spacy # type: ignore
except Exception as e:
raise RuntimeError("Failed to import spaCy. Use a Python version supported by spaCy.") from e
try:
return spacy.load(model_name)
except Exception as e:
raise RuntimeError(f"Failed to load spaCy model '{model_name}'. Try: python -m spacy download {model_name}") from e
def get_notes(query: str, config: Config, request: Callable = anki_request) -> list[dict]:
"""Fetch Anki note details matching a search query."""
note_ids = request("findNotes", url=config.anki_connect_url, query=query) or []
if not note_ids:
return []
return request("notesInfo", url=config.anki_connect_url, notes=note_ids) or []
def extract_counts(
notes: list[dict],
field_name: str,
nlp,
token_filter: Callable,
output_format: Callable,
use_full_field: bool,
) -> Counter:
"""Count formatted vocabulary items across Anki notes."""
counter: Counter = Counter()
for note in notes:
fields = note.get("fields", {}) or {}
raw_val = (fields.get(field_name, {}) or {}).get("value", "") or ""
text = extract_visible_text(raw_val) if use_full_field else extract_first_visible_line(raw_val)
if not text:
continue
for token in nlp(text):
if token_filter(token):
key = output_format(token)
if key:
counter[key] += 1
return counter
def write_counts(counter: Counter, out_path: str, min_freq: int) -> int:
"""Write a sorted ``word frequency`` list and return the number of rows."""
items = [(w, c) for (w, c) in counter.items() if c >= min_freq]
items.sort(key=lambda x: (-x[1], x[0]))
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
for word, freq in items:
f.write(f"{word} {freq}\n")
return len(items)
def read_word_file(path: str) -> set[str]:
"""Read a ``word frequency`` file into normalized word keys."""
words: set[str] = set()
with open(os.path.expanduser(path), "r", encoding="utf-8") as fh:
for line in fh:
stripped = line.strip()
if not stripped:
continue
word = stripped.rsplit(" ", 1)[0]
words.add(normalize_word_key(word))
return words
def compare_word_files(source_path: str, known_path: str) -> list[str]:
"""Return source rows whose normalized word is not in the known list."""
known = read_word_file(known_path)
new_words: list[str] = []
with open(os.path.expanduser(source_path), "r", encoding="utf-8") as fh:
for line in fh:
stripped = line.strip()
if not stripped:
continue
word = stripped.rsplit(" ", 1)[0]
if normalize_word_key(word) not in known:
new_words.append(stripped)
return new_words
def extract_words(
config: Config,
lang: str,
query: str | None = None,
decks: list[str] | None = None,
field: str | None = None,
min_freq: int = 2,
outdir: str | None = None,
out: str | None = None,
full_field: bool = False,
spacy_model: str | None = None,
request: Callable = anki_request,
) -> dict[str, object]:
"""Extract frequent vocabulary from configured Anki cards.
The function accepts explicit query/deck/field overrides for CLI use, but
defaults to the selected language config. Its dictionary return value keeps
the CLI output simple and gives tests stable fields to assert against.
"""
language_bucket = config.language_name(lang)
profile = LANGUAGE_PROFILES[language_bucket]
search_query = query or build_query_from_decks(decks or config.decks_for(lang))
out_dir = os.path.expanduser(outdir) if outdir else os.path.join(config.word_output_root, language_bucket)
out_path = os.path.expanduser(out) if out else os.path.join(out_dir, f"words_{lang}.txt")
model_name = spacy_model or str(config.language(lang).get("word_model"))
nlp = load_spacy_model(model_name)
notes = get_notes(search_query, config, request=request)
if notes:
fields0 = (notes[0].get("fields", {}) or {})
field_name = field or config.field_for(lang)
if field_name not in fields0:
raise RuntimeError(f"Field '{field_name}' not found. Available fields: {list(fields0.keys())}")
else:
field_name = field or config.field_for(lang)
counter = extract_counts(notes, field_name, nlp, profile["token_filter"], profile["output_format"], full_field)
written = write_counts(counter, out_path, min_freq)
return {"query": search_query, "notes": len(notes), "unique": len(counter), "written": written, "out": out_path}
def _write_content_words(
stats: dict[str, dict[str, Any]],
path: str,
min_freq: int,
bad_lemmas: set[str] | None = None,
) -> int:
"""Write a sorted ``lemma count`` file from detailed stats."""
bad = bad_lemmas or set()
items = [
(lemma, data["count"])
for lemma, data in stats.items()
if data["count"] >= min_freq and lemma not in bad
]
items.sort(key=lambda x: (-x[1], x[0]))
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
for word, freq in items:
f.write(f"{word} {freq}\n")
return len(items)
def _write_function_words(
stats: dict[str, dict[str, Any]],
path: str,
) -> int:
"""Write a function-word frequency file from detailed stats."""
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
items = sorted(stats.items(), key=lambda x: (-x[1]["count"], x[0]))
with open(path, "w", encoding="utf-8") as f:
for word, data in items:
f.write(f"{word} {data['count']}\n")
return len(items)
def extract_words_from_file(
config: Config,
lang: str,
input_path: str,
field_index: int = 2,
min_freq: int = 2,
outdir: str | None = None,
out: str | None = None,
include_proper_nouns: bool = False,
function_words: bool = False,
debug: str | None = None,
lemma_corrections: str | None = None,
bad_lemma_file: str | None = None,
spacy_model: str | None = None,
include_tags: bool = False,
debug_min_freq: int = 0,
no_clean: bool = False,
) -> dict[str, Any]:
"""Extract frequent Spanish vocabulary from an Anki TSV export file.
Parameters
----------
config:
Saiki configuration.
lang:
Language code (e.g. ``"es"``).
input_path:
Path to the Anki TSV export file.
field_index:
1-based column index of the Spanish text (default 2).
min_freq:
Minimum frequency to include in output.
outdir:
Output directory (defaults to config word output root).
out:
Output filename or path for content words.
include_proper_nouns:
Include ``PROPN`` tokens.
function_words:
Also extract a separate function-word list.
debug:
Path for the debug TSV.
lemma_corrections:
Path to a ``bad\\tgood`` TSV of lemma corrections.
bad_lemma_file:
Path to a list of lemmas to skip.
spacy_model:
SpaCy model name override.
Returns
-------
A dict with keys ``records``, ``stats``, ``written``, ``out``,
``debug``, ``proper_nouns``, ``function_words``, ``suspicious``.
"""
language_bucket = config.language_name(lang)
out_dir = os.path.expanduser(outdir) if outdir else os.path.join(config.word_output_root, language_bucket)
out_path = os.path.expanduser(out) if out else os.path.join(out_dir, f"words_{lang}_content.txt")
model_name = spacy_model or str(config.language(lang).get("word_model"))
nlp = load_spacy_model(model_name)
corrections = None
if lemma_corrections:
corrections = load_lemma_corrections(lemma_corrections)
bad_lemmas = None
if bad_lemma_file:
bad_lemmas = load_bad_lemmas(bad_lemma_file)
records = parse_anki_tsv(
input_path,
field_index=field_index,
include_tags=include_tags,
)
if not records:
return {"records": 0, "stats": {}, "written": 0, "out": out_path}
# Build token filter
def _content_filter(token) -> bool:
return spanish_content_filter(token, include_proper_nouns=include_proper_nouns)
stats = extract_detailed_counts(
records, nlp, _content_filter,
lambda t: safe_spanish_lemma_info(t, extra_corrections=corrections),
clean=not no_clean,
)
written = _write_content_words(stats, out_path, min_freq, bad_lemmas=bad_lemmas)
result: dict[str, Any] = {
"records": len(records),
"stats": stats,
"written": written,
"out": out_path,
}
# Debug output
if debug:
debug_path = os.path.expanduser(debug)
write_debug_tsv(stats, debug_path, bad_lemmas=bad_lemmas, min_freq=debug_min_freq)
result["debug"] = debug_path
# Proper nouns
if include_proper_nouns:
proper_path = os.path.join(out_dir, f"words_{lang}_proper_nouns.txt")
proper_items = [
(lemma, data["count"])
for lemma, data in stats.items()
if data["pos_counts"].get("PROPN", 0) > 0
]
proper_items.sort(key=lambda x: (-x[1], x[0]))
os.makedirs(os.path.dirname(os.path.abspath(proper_path)), exist_ok=True)
with open(proper_path, "w", encoding="utf-8") as f:
for w, c in proper_items:
f.write(f"{w} {c}\n")
result["proper_nouns"] = proper_path
# Function words (separate extraction pass)
if function_words:
fw_path = os.path.join(out_dir, f"words_{lang}_function_words.txt")
fw_stats = extract_detailed_counts(
records, nlp, spanish_function_word_filter,
lambda t: safe_spanish_lemma_info(t, extra_corrections=corrections),
)
fw_written = _write_function_words(fw_stats, fw_path)
result["function_words"] = fw_path
result["function_words_written"] = fw_written
# Suspicious tokens
susp_path = os.path.join(out_dir, f"words_{lang}_suspicious_tokens.txt")
write_suspicious_tokens(stats, susp_path, bad_lemmas=bad_lemmas)
result["suspicious"] = susp_path
return result
def lint_anki_cards_main(
input_path: str,
field_index: int = 2,
output_path: str = "suspicious_cards_es.tsv",
verbose: bool = False,
) -> dict[str, Any]:
"""Read an Anki TSV export and produce a suspicious-card report.
When *verbose* is True every record is written to the report, not
only those with issues.
Returns ``{"records": …, "issues": …, "path": output_path}``.
"""
records = parse_anki_tsv(input_path, field_index=field_index)
issues = lint_anki_cards(records, verbose=verbose)
os.makedirs(os.path.dirname(os.path.abspath(output_path)) or ".", exist_ok=True)
with open(output_path, "w", encoding="utf-8") as fh:
fh.write(
"source_line\toriginal_text\tcleaned_text\treason\tsuggested_fix\n"
)
for issue in issues:
fh.write(
f"{issue['source_line']}\t"
f"{_tsv_escape(issue['original_text'])}\t"
f"{_tsv_escape(issue['cleaned_text'])}\t"
f"{_tsv_escape(issue['reason'])}\t"
f"{_tsv_escape(issue['suggested_fix'])}\n"
)
return {"records": len(records), "issues": len(issues), "path": output_path}
def _tsv_escape(value: str) -> str:
"""Escape a value for TSV output (backslash-escape tabs and newlines)."""
return value.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n")
def normalize_word_for_comparison(word: str) -> str:
"""Normalise a word for file-comparison: lowercase, strip accents."""
return word.lower().translate(ACCENT_MAP)
def read_word_file_normalized(path: str) -> dict[str, set[str]]:
"""Read a word-frequency file returning ``{normalised: {display_forms}}``.
Also returns a flat set of normalised forms.
"""
display_map: dict[str, set[str]] = {}
with open(os.path.expanduser(path), "r", encoding="utf-8") as fh:
for line in fh:
stripped = line.strip()
if not stripped:
continue
word = stripped.rsplit(" ", 1)[0]
key = normalize_word_for_comparison(word)
display_map.setdefault(key, set()).add(word)
return display_map
def compare_word_lists(
deck_words_path: str,
target_words_path: str,
output_path: str | None = None,
min_frequency: int = 0,
seen_output_path: str | None = None,
) -> dict[str, Any]:
"""Compare a deck word list against a target vocabulary list.
Parameters
----------
deck_words_path:
Path to the deck's extracted word list (``lemma count`` format).
target_words_path:
Path to the target vocabulary list (same format).
output_path:
If given, write missing-from-deck words here.
min_frequency:
Only consider deck words with frequency >= this value.
seen_output_path:
If given, write seen-in-deck words here.
Returns
-------
``{"missing": [str], "seen": [str], "total_missing": int}``
"""
# Read target words
target_map = read_word_file_normalized(target_words_path)
target_keys = set(target_map.keys())
# Read deck words, optionally filtering by frequency
deck_map: dict[str, set[str]] = {}
with open(os.path.expanduser(deck_words_path), "r", encoding="utf-8") as fh:
for line in fh:
stripped = line.strip()
if not stripped:
continue
parts = stripped.rsplit(" ", 1)
word = parts[0]
freq = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 1
if freq < min_frequency:
continue
key = normalize_word_for_comparison(word)
deck_map.setdefault(key, set()).add(word)
deck_keys = set(deck_map.keys())
missing_keys = target_keys - deck_keys
seen_keys = target_keys & deck_keys
missing_entries: list[str] = []
for key in sorted(missing_keys):
for display in sorted(target_map[key]):
missing_entries.append(display)
seen_entries: list[str] = []
for key in sorted(seen_keys):
for display in sorted(target_map[key]):
seen_entries.append(display)
if output_path:
out = os.path.expanduser(output_path)
os.makedirs(os.path.dirname(os.path.abspath(out)) or ".", exist_ok=True)
with open(out, "w", encoding="utf-8") as fh:
for entry in missing_entries:
fh.write(f"{entry}\n")
if seen_output_path:
seen_out = os.path.expanduser(seen_output_path)
os.makedirs(os.path.dirname(os.path.abspath(seen_out)) or ".", exist_ok=True)
with open(seen_out, "w", encoding="utf-8") as fh:
for entry in seen_entries:
fh.write(f"{entry}\n")
return {
"missing": missing_entries,
"seen": seen_entries,
"total_missing": len(missing_entries),
}