termprep / db.py
dzha0118's picture
Upload 25 files
52b091c verified
Raw
History Blame Contribute Delete
18 kB
"""SQLite-based terminology database with multi-termbase support."""
import csv
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Any
DB_DIR = Path(__file__).parent.parent / "data"
DEFAULT_DB = "terms.db"
@dataclass
class TermbaseInfo:
"""Metadata about a termbase."""
name: str = ""
path: str = ""
domain: str = ""
lang: str = ""
total_terms: int = 0
class TermDB:
"""Local terminology database backed by SQLite.
Supports multiple independent termbases stored as separate .db files
in the data/ directory. Each termbase tracks term status, domain
metadata, and associations.
"""
def __init__(self, db_path: Path | None = None, db_name: str | None = None):
"""Connect to a termbase.
Args:
db_path: Direct path to a .db file. If given, db_name is ignored.
db_name: Name of the termbase (e.g. "myproject"). Resolves to
data/{name}.db. Defaults to "terms".
"""
if db_path:
self.db_path = db_path
elif db_name:
db_name = db_name if db_name.endswith(".db") else f"{db_name}.db"
self.db_path = DB_DIR / db_name
else:
self.db_path = DB_DIR / DEFAULT_DB
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_schema()
# ---- Schema / Setup ----
def _get_conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def _init_schema(self) -> None:
with self._get_conn() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS terms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word TEXT NOT NULL,
lang TEXT NOT NULL DEFAULT 'auto',
type TEXT DEFAULT '',
translation TEXT DEFAULT '',
domain TEXT DEFAULT '',
status TEXT DEFAULT 'draft',
notes TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS associations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
term_id INTEGER NOT NULL,
related_word TEXT NOT NULL,
relation_type TEXT NOT NULL DEFAULT '',
score REAL DEFAULT 1.0,
source TEXT DEFAULT 'manual',
FOREIGN KEY (term_id) REFERENCES terms(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_terms_word ON terms(word);
CREATE INDEX IF NOT EXISTS idx_terms_domain ON terms(domain);
CREATE INDEX IF NOT EXISTS idx_terms_status ON terms(status);
CREATE INDEX IF NOT EXISTS idx_assoc_term ON associations(term_id);
CREATE INDEX IF NOT EXISTS idx_assoc_word ON associations(related_word);
""")
# Ensure meta has a name entry
conn.execute(
"INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)",
("name", self.db_path.stem)
)
# ---- Metadata ----
def get_meta(self, key: str, default: str = "") -> str:
"""Get a metadata value."""
with self._get_conn() as conn:
row = conn.execute(
"SELECT value FROM meta WHERE key = ?", (key,)
).fetchone()
return row["value"] if row else default
def set_meta(self, key: str, value: str) -> None:
"""Set a metadata value."""
with self._get_conn() as conn:
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)",
(key, value)
)
@property
def name(self) -> str:
return self.get_meta("name", self.db_path.stem)
@name.setter
def name(self, value: str) -> None:
self.set_meta("name", value)
@property
def domain(self) -> str:
return self.get_meta("domain")
@domain.setter
def domain(self, value: str) -> None:
self.set_meta("domain", value)
# ---- CRUD ----
def add_term(self, word: str, translation: str = "", lang: str = "auto",
domain: str = "", type_: str = "", status: str = "draft",
notes: str = "") -> int:
"""Add a new term. Returns the term ID."""
with self._get_conn() as conn:
cur = conn.execute(
"""INSERT INTO terms (word, translation, lang, domain, type, status, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(word, translation, lang, domain, type_, status, notes)
)
return cur.lastrowid or 0
def update_term(self, term_id: int, **kwargs: Any) -> None:
"""Update term fields. Pass keyword args for fields to change."""
allowed = {"word", "translation", "lang", "domain", "type",
"status", "notes"}
fields = {k: v for k, v in kwargs.items() if k in allowed}
if not fields:
return
fields["updated_at"] = "CURRENT_TIMESTAMP"
set_clause = ", ".join(
f"{k} = ?" if v != "CURRENT_TIMESTAMP" else f"{k} = CURRENT_TIMESTAMP"
for k, v in fields.items()
)
values = [v for k, v in fields.items()
if v != "CURRENT_TIMESTAMP"]
values.append(term_id)
with self._get_conn() as conn:
conn.execute(
f"UPDATE terms SET {set_clause} WHERE id = ?", values
)
def delete_term(self, term_id: int) -> None:
"""Delete a term and its associations."""
with self._get_conn() as conn:
conn.execute("DELETE FROM associations WHERE term_id = ?", (term_id,))
conn.execute("DELETE FROM terms WHERE id = ?", (term_id,))
def get_term(self, term_id: int) -> dict[str, Any] | None:
"""Get a single term by ID."""
with self._get_conn() as conn:
row = conn.execute(
"SELECT * FROM terms WHERE id = ?", (term_id,)
).fetchone()
return dict(row) if row else None
# ---- Search ----
def search(self, term: str, limit: int = 20) -> list[dict[str, Any]]:
"""Search for a term and its associations."""
with self._get_conn() as conn:
rows = conn.execute("""
SELECT a.related_word AS word,
a.relation_type AS type,
a.source,
a.score
FROM terms t
JOIN associations a ON t.id = a.term_id
WHERE t.word LIKE ?
ORDER BY a.score DESC
LIMIT ?
""", (f"%{term}%", limit)).fetchall()
return [dict(r) for r in rows]
def search_terms(self, query: str, limit: int = 20) -> list[dict[str, Any]]:
"""Search terms table by word, translation, or domain."""
with self._get_conn() as conn:
rows = conn.execute("""
SELECT * FROM terms
WHERE word LIKE ? OR translation LIKE ? OR domain LIKE ?
ORDER BY updated_at DESC
LIMIT ?
""", (f"%{query}%", f"%{query}%", f"%{query}%", limit)).fetchall()
return [dict(r) for r in rows]
def filter_by_domain(self, domain: str, limit: int = 100) -> list[dict[str, Any]]:
"""Filter terms by domain."""
with self._get_conn() as conn:
rows = conn.execute("""
SELECT * FROM terms WHERE domain = ?
ORDER BY word ASC LIMIT ?
""", (domain, limit)).fetchall()
return [dict(r) for r in rows]
def filter_by_status(self, status: str, limit: int = 100) -> list[dict[str, Any]]:
"""Filter terms by status: draft, confirmed, deprecated."""
with self._get_conn() as conn:
rows = conn.execute("""
SELECT * FROM terms WHERE status = ?
ORDER BY word ASC LIMIT ?
""", (status, limit)).fetchall()
return [dict(r) for r in rows]
# ---- Associations ----
def add_association(self, term_id: int, related_word: str,
relation_type: str = "", score: float = 1.0,
source: str = "manual") -> None:
"""Add an association to a term."""
with self._get_conn() as conn:
conn.execute(
"""INSERT INTO associations (term_id, related_word, relation_type, score, source)
VALUES (?, ?, ?, ?, ?)""",
(term_id, related_word, relation_type, score, source)
)
def get_associations(self, term_id: int) -> list[dict[str, Any]]:
"""Get all associations for a term."""
with self._get_conn() as conn:
rows = conn.execute("""
SELECT * FROM associations WHERE term_id = ?
ORDER BY score DESC
""", (term_id,)).fetchall()
return [dict(r) for r in rows]
# ---- Import / Export ----
def import_csv(self, csv_path: str, dedup: bool = True) -> int:
"""Import terms from a CSV file (columns: word, translation, type).
Args:
csv_path: Path to CSV file. Required columns: word.
Optional: translation, type, domain, status, lang, notes.
dedup: Skip rows where word+translation already exist in DB.
Returns:
Number of terms imported.
"""
count = 0
with open(csv_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
with self._get_conn() as conn:
for row in reader:
word = row.get("word", "").strip()
if not word:
continue
translation = row.get("translation", "").strip()
if dedup and self._term_exists(conn, word, translation):
continue
conn.execute(
"""INSERT INTO terms (word, translation, type, domain, status, lang, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(
word,
translation,
row.get("type", ""),
row.get("domain", ""),
row.get("status", "draft"),
row.get("lang", "auto"),
row.get("notes", ""),
)
)
count += 1
return count
def export_csv(self, output_path: str, status_filter: str | None = None) -> int:
"""Export all terms to a CSV file (includes status, domain, notes)."""
with self._get_conn() as conn:
if status_filter:
rows = conn.execute(
"SELECT word, translation, type, domain, status, lang, notes FROM terms WHERE status = ?",
(status_filter,)
).fetchall()
else:
rows = conn.execute(
"SELECT word, translation, type, domain, status, lang, notes FROM terms"
).fetchall()
with open(output_path, "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(
f, fieldnames=["word", "translation", "type", "domain",
"status", "lang", "notes"]
)
writer.writeheader()
for row in rows:
writer.writerow(dict(row))
return len(rows)
@staticmethod
def _term_exists(conn: sqlite3.Connection, word: str, translation: str) -> bool:
row = conn.execute(
"SELECT 1 FROM terms WHERE word = ? AND translation = ?",
(word, translation)
).fetchone()
return row is not None
# ---- Stats ----
def get_stats(self) -> dict[str, Any]:
"""Get database statistics."""
with self._get_conn() as conn:
total = conn.execute("SELECT COUNT(*) AS c FROM terms").fetchone()["c"]
by_domain = {
r["domain"]: r["c"] for r in
conn.execute(
"SELECT domain, COUNT(*) AS c FROM terms GROUP BY domain ORDER BY c DESC"
).fetchall()
}
by_status = {
r["status"]: r["c"] for r in
conn.execute(
"SELECT status, COUNT(*) AS c FROM terms GROUP BY status ORDER BY c DESC"
).fetchall()
}
by_lang = {
r["lang"]: r["c"] for r in
conn.execute(
"SELECT lang, COUNT(*) AS c FROM terms GROUP BY lang ORDER BY c DESC"
).fetchall()
}
confirmed = conn.execute(
"SELECT COUNT(*) AS c FROM terms WHERE status = 'confirmed'"
).fetchone()["c"]
associations = conn.execute(
"SELECT COUNT(*) AS c FROM associations"
).fetchone()["c"]
recent = [
dict(r) for r in conn.execute(
"SELECT word, translation, domain, status, created_at FROM terms ORDER BY created_at DESC LIMIT 10"
).fetchall()
]
return {
"name": self.name,
"domain": self.domain,
"total_terms": total,
"confirmed": confirmed,
"associations": associations,
"by_domain": by_domain,
"by_status": by_status,
"by_lang": by_lang,
"recent": recent,
}
# ---- Merge ----
def merge(self, other_path: str, dedup: bool = True) -> dict[str, int]:
"""Merge terms from another termbase into this one.
Args:
other_path: Path to the other .db file.
dedup: Skip terms that already exist in this database.
Returns:
Dict with keys: added (int), skipped (int), total_other (int).
"""
other = TermDB(db_path=Path(other_path))
stats = {"added": 0, "skipped": 0, "total_other": 0}
with other._get_conn() as other_conn:
other_terms = other_conn.execute(
"SELECT word, translation, type, domain, status, lang, notes FROM terms"
).fetchall()
stats["total_other"] = len(other_terms)
with self._get_conn() as conn:
for row in other_terms:
d = dict(row)
if dedup and self._term_exists(conn, d["word"], d["translation"]):
stats["skipped"] += 1
continue
conn.execute(
"""INSERT INTO terms (word, translation, type, domain, status, lang, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(d["word"], d["translation"], d["type"], d["domain"],
d["status"], d["lang"], d["notes"])
)
stats["added"] += 1
return stats
# ---- Module-level helpers ----
def list_termbases() -> list[TermbaseInfo]:
"""List all termbase .db files in the data directory."""
DB_DIR.mkdir(parents=True, exist_ok=True)
dbs: list[TermbaseInfo] = []
for f in sorted(DB_DIR.glob("*.db")):
try:
tdb = TermDB(db_path=f)
stats = tdb.get_stats()
dbs.append(TermbaseInfo(
name=tdb.name,
path=str(f),
domain=tdb.domain,
lang=", ".join(stats["by_lang"].keys()),
total_terms=stats["total_terms"],
))
except Exception:
dbs.append(TermbaseInfo(
name=f.stem,
path=str(f),
total_terms=0,
))
return dbs
def init_termbase(name: str, domain: str = "") -> TermDB:
"""Create a new empty termbase."""
db_name = name if name.endswith(".db") else f"{name}.db"
db_path = DB_DIR / db_name
if db_path.exists():
raise FileExistsError(f"Termbase '{name}' already exists at {db_path}")
tdb = TermDB(db_path=db_path)
tdb.name = name
if domain:
tdb.domain = domain
return tdb
def delete_termbase(name: str) -> None:
"""Delete a termbase file."""
import gc
import time
db_name = name if name.endswith(".db") else f"{name}.db"
db_path = DB_DIR / db_name
if not db_path.exists():
raise FileNotFoundError(f"Termbase '{name}' not found")
# Windows may hold file locks briefly after the last connection closes
gc.collect()
for attempt in range(3):
try:
db_path.unlink()
return
except PermissionError:
if attempt < 2:
time.sleep(0.5)
gc.collect()
else:
raise