295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""SQLite-backed global terminology glossary."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from collections.abc import Iterable, Iterator
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
GLOSSARY_CATEGORIES = (
|
|
"product",
|
|
"material",
|
|
"organization",
|
|
"technical_term",
|
|
"acronym",
|
|
"other",
|
|
)
|
|
|
|
|
|
class GlossaryConflictError(ValueError):
|
|
"""Raised when a canonical term or alias conflicts with existing terminology."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GlossaryEntry:
|
|
id: int
|
|
canonical_term: str
|
|
category: str
|
|
description: str | None
|
|
is_active: bool
|
|
aliases: tuple[str, ...]
|
|
created_at: str
|
|
updated_at: str
|
|
|
|
|
|
class GlossaryRepository:
|
|
"""Small data-access boundary for the local glossary database."""
|
|
|
|
def __init__(self, database_path: Path) -> None:
|
|
self.database_path = Path(database_path)
|
|
|
|
def initialize(self) -> None:
|
|
"""Create the database and current schema when absent."""
|
|
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with self._connect() as connection:
|
|
connection.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS glossary_entries (
|
|
id INTEGER PRIMARY KEY,
|
|
canonical_term TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
|
category TEXT NOT NULL CHECK (category IN (
|
|
'product', 'material', 'organization',
|
|
'technical_term', 'acronym', 'other'
|
|
)),
|
|
description TEXT,
|
|
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS glossary_aliases (
|
|
id INTEGER PRIMARY KEY,
|
|
entry_id INTEGER NOT NULL REFERENCES glossary_entries(id)
|
|
ON DELETE CASCADE,
|
|
alias TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_glossary_aliases_entry_id
|
|
ON glossary_aliases(entry_id);
|
|
PRAGMA user_version = 1;
|
|
"""
|
|
)
|
|
|
|
def create(
|
|
self,
|
|
canonical_term: str,
|
|
category: str,
|
|
*,
|
|
aliases: Iterable[str] = (),
|
|
description: str | None = None,
|
|
is_active: bool = True,
|
|
) -> GlossaryEntry:
|
|
canonical, normalized_aliases = self._validate_values(canonical_term, category, aliases)
|
|
now = _timestamp()
|
|
try:
|
|
with self._connect() as connection:
|
|
self._ensure_terms_available(connection, canonical, normalized_aliases)
|
|
cursor = connection.execute(
|
|
"""INSERT INTO glossary_entries
|
|
(canonical_term, category, description, is_active, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
(canonical, category, _optional_text(description), is_active, now, now),
|
|
)
|
|
entry_id = int(cursor.lastrowid)
|
|
connection.executemany(
|
|
"INSERT INTO glossary_aliases (entry_id, alias, created_at) VALUES (?, ?, ?)",
|
|
((entry_id, alias, now) for alias in normalized_aliases),
|
|
)
|
|
except sqlite3.IntegrityError as exc:
|
|
raise GlossaryConflictError("Canonical term or alias already exists.") from exc
|
|
return self.get(entry_id)
|
|
|
|
def get(self, entry_id: int) -> GlossaryEntry:
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT * FROM glossary_entries WHERE id = ?", (entry_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
raise KeyError(f"Unknown glossary entry: {entry_id}")
|
|
return self._to_entry(connection, row)
|
|
|
|
def list(self, search: str = "", *, active_only: bool = False) -> list[GlossaryEntry]:
|
|
clauses: list[str] = []
|
|
parameters: list[object] = []
|
|
if active_only:
|
|
clauses.append("entry.is_active = 1")
|
|
if search.strip():
|
|
clauses.append(
|
|
"(entry.canonical_term LIKE ? COLLATE NOCASE "
|
|
"OR entry.category LIKE ? COLLATE NOCASE "
|
|
"OR entry.description LIKE ? COLLATE NOCASE "
|
|
"OR alias.alias LIKE ? COLLATE NOCASE)"
|
|
)
|
|
pattern = f"%{search.strip()}%"
|
|
parameters.extend([pattern] * 4)
|
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
with self._connect() as connection:
|
|
rows = connection.execute(
|
|
f"""SELECT DISTINCT entry.* FROM glossary_entries AS entry
|
|
LEFT JOIN glossary_aliases AS alias ON alias.entry_id = entry.id
|
|
{where} ORDER BY entry.canonical_term COLLATE NOCASE""", # noqa: S608
|
|
parameters,
|
|
).fetchall()
|
|
return [self._to_entry(connection, row) for row in rows]
|
|
|
|
def update(
|
|
self,
|
|
entry_id: int,
|
|
canonical_term: str,
|
|
category: str,
|
|
*,
|
|
aliases: Iterable[str] = (),
|
|
description: str | None = None,
|
|
is_active: bool = True,
|
|
) -> GlossaryEntry:
|
|
canonical, normalized_aliases = self._validate_values(canonical_term, category, aliases)
|
|
try:
|
|
with self._connect() as connection:
|
|
exists = connection.execute(
|
|
"SELECT 1 FROM glossary_entries WHERE id = ?", (entry_id,)
|
|
).fetchone()
|
|
if exists is None:
|
|
raise KeyError(f"Unknown glossary entry: {entry_id}")
|
|
self._ensure_terms_available(
|
|
connection, canonical, normalized_aliases, excluding_entry_id=entry_id
|
|
)
|
|
now = _timestamp()
|
|
connection.execute(
|
|
"""UPDATE glossary_entries SET canonical_term = ?, category = ?,
|
|
description = ?, is_active = ?, updated_at = ? WHERE id = ?""",
|
|
(
|
|
canonical,
|
|
category,
|
|
_optional_text(description),
|
|
is_active,
|
|
now,
|
|
entry_id,
|
|
),
|
|
)
|
|
connection.execute("DELETE FROM glossary_aliases WHERE entry_id = ?", (entry_id,))
|
|
connection.executemany(
|
|
"INSERT INTO glossary_aliases (entry_id, alias, created_at) VALUES (?, ?, ?)",
|
|
((entry_id, alias, now) for alias in normalized_aliases),
|
|
)
|
|
except sqlite3.IntegrityError as exc:
|
|
raise GlossaryConflictError("Canonical term or alias already exists.") from exc
|
|
return self.get(entry_id)
|
|
|
|
def set_active(self, entry_id: int, is_active: bool) -> GlossaryEntry:
|
|
entry = self.get(entry_id)
|
|
return self.update(
|
|
entry.id,
|
|
entry.canonical_term,
|
|
entry.category,
|
|
aliases=entry.aliases,
|
|
description=entry.description,
|
|
is_active=is_active,
|
|
)
|
|
|
|
def delete(self, entry_id: int) -> None:
|
|
with self._connect() as connection:
|
|
cursor = connection.execute("DELETE FROM glossary_entries WHERE id = ?", (entry_id,))
|
|
if cursor.rowcount == 0:
|
|
raise KeyError(f"Unknown glossary entry: {entry_id}")
|
|
|
|
@contextmanager
|
|
def _connect(self) -> Iterator[sqlite3.Connection]:
|
|
connection = sqlite3.connect(self.database_path, timeout=5)
|
|
try:
|
|
connection.row_factory = sqlite3.Row
|
|
connection.execute("PRAGMA foreign_keys = ON")
|
|
connection.execute("PRAGMA journal_mode = WAL")
|
|
connection.execute("PRAGMA busy_timeout = 5000")
|
|
with connection:
|
|
yield connection
|
|
finally:
|
|
connection.close()
|
|
|
|
@staticmethod
|
|
def _validate_values(
|
|
canonical_term: str, category: str, aliases: Iterable[str]
|
|
) -> tuple[str, tuple[str, ...]]:
|
|
canonical = canonical_term.strip()
|
|
if not canonical:
|
|
raise ValueError("Canonical term is required.")
|
|
if category not in GLOSSARY_CATEGORIES:
|
|
raise ValueError(f"Unsupported glossary category: {category}")
|
|
normalized_aliases = tuple(
|
|
dict.fromkeys(alias.strip() for alias in aliases if alias.strip())
|
|
)
|
|
folded = [alias.casefold() for alias in normalized_aliases]
|
|
if len(folded) != len(set(folded)) or canonical.casefold() in folded:
|
|
raise GlossaryConflictError(
|
|
"Aliases must be unique and differ from the canonical term."
|
|
)
|
|
return canonical, normalized_aliases
|
|
|
|
@staticmethod
|
|
def _ensure_terms_available(
|
|
connection: sqlite3.Connection,
|
|
canonical: str,
|
|
aliases: tuple[str, ...],
|
|
*,
|
|
excluding_entry_id: int | None = None,
|
|
) -> None:
|
|
terms = (canonical, *aliases)
|
|
placeholders = ", ".join("?" for _ in terms)
|
|
exclusion = "AND entry_id != ?" if excluding_entry_id is not None else ""
|
|
alias_parameters: list[object] = [*terms]
|
|
if excluding_entry_id is not None:
|
|
alias_parameters.append(excluding_entry_id)
|
|
alias_conflict = connection.execute(
|
|
f"SELECT 1 FROM glossary_aliases WHERE alias IN ({placeholders}) {exclusion} LIMIT 1", # noqa: S608
|
|
alias_parameters,
|
|
).fetchone()
|
|
entry_exclusion = "AND id != ?" if excluding_entry_id is not None else ""
|
|
entry_parameters: list[object] = [*terms]
|
|
if excluding_entry_id is not None:
|
|
entry_parameters.append(excluding_entry_id)
|
|
canonical_conflict = connection.execute(
|
|
f"SELECT 1 FROM glossary_entries WHERE canonical_term IN ({placeholders}) " # noqa: S608
|
|
f"{entry_exclusion} LIMIT 1",
|
|
entry_parameters,
|
|
).fetchone()
|
|
if alias_conflict or canonical_conflict:
|
|
raise GlossaryConflictError("Canonical term or alias already exists.")
|
|
|
|
@staticmethod
|
|
def _to_entry(connection: sqlite3.Connection, row: sqlite3.Row) -> GlossaryEntry:
|
|
aliases = connection.execute(
|
|
"SELECT alias FROM glossary_aliases WHERE entry_id = ? ORDER BY alias COLLATE NOCASE",
|
|
(row["id"],),
|
|
).fetchall()
|
|
return GlossaryEntry(
|
|
id=row["id"],
|
|
canonical_term=row["canonical_term"],
|
|
category=row["category"],
|
|
description=row["description"],
|
|
is_active=bool(row["is_active"]),
|
|
aliases=tuple(alias["alias"] for alias in aliases),
|
|
created_at=row["created_at"],
|
|
updated_at=row["updated_at"],
|
|
)
|
|
|
|
|
|
def render_glossary_terms(entries: Iterable[GlossaryEntry]) -> list[str]:
|
|
"""Render concise canonical terms with recognition aliases for Meeting Context."""
|
|
rendered = []
|
|
for entry in entries:
|
|
item = entry.canonical_term
|
|
if entry.aliases:
|
|
item += f" (aliases: {', '.join(entry.aliases)})"
|
|
rendered.append(item)
|
|
return rendered
|
|
|
|
|
|
def _timestamp() -> str:
|
|
return datetime.now(UTC).isoformat(timespec="seconds")
|
|
|
|
|
|
def _optional_text(value: str | None) -> str | None:
|
|
stripped = value.strip() if value else ""
|
|
return stripped or None
|