Source code for vibesketch.document.reaction_library

"""Offline named-reaction catalogue and editable examples or unfinished scaffolds.

Names and references are factual index metadata. Molecular examples are the
project's teaching schemes from docs/_static/examples/named_reactions.py.
Scaffolds deliberately have no molecular graph or chemical reaction arrow:
they must not create species, reactions, or calculation jobs by inference.
"""

from __future__ import annotations

import json
import unicodedata
from functools import lru_cache
from importlib.resources import files
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict

from vibesketch.document.derive import Derivation
from vibesketch.document.formats import _merged, import_into
from vibesketch.document.ket import text_node
from vibesketch.document.model import Document

Kind = Literal["example", "scaffold", "reference"]
InsertionKind = Literal["example", "scaffold"]


[docs] class ReactionSource(BaseModel): model_config = ConfigDict(frozen=True) name: str url: str language: str
[docs] class ReactionEntry(BaseModel): model_config = ConfigDict(frozen=True) id: str name: str aliases: tuple[str, ...] scope: Literal["portal", "supplement"] kind: Kind sources: tuple[ReactionSource, ...] example_smiles: str | None = None note: str
[docs] class ReactionRefused(ValueError): """An unknown entry or an unavailable molecular example."""
@lru_cache(maxsize=1) def _entries() -> tuple[ReactionEntry, ...]: raw = files("vibesketch.document").joinpath("data/named_reactions.json").read_text() return tuple(ReactionEntry.model_validate(entry) for entry in json.loads(raw)["entries"]) def _normalise(value: str) -> str: return "".join(c for c in unicodedata.normalize("NFKD", value).casefold() if c.isalnum())
[docs] def list_reactions(query: str = "") -> list[ReactionEntry]: """Find entries by name, alias or ID; accents, punctuation and case are ignored.""" needle = _normalise(query) return [ entry for entry in _entries() if any(needle in _normalise(name) for name in (entry.id, entry.name, *entry.aliases)) ]
[docs] def get_reaction(identifier: str) -> ReactionEntry: """Resolve a stable catalogue ID, never guess a reaction from a name.""" for entry in _entries(): if entry.id == identifier: return entry raise ReactionRefused(f"Unknown reaction library entry: {identifier!r}")
[docs] def reaction_fragment(identifier: str, *, kind: InsertionKind = "example") -> dict[str, Any]: """Editable KET. Scaffolds require an explicit request; no guessed chemistry.""" from vibesketch.document.tlc import _shape entry = get_reaction(identifier) if kind not in ("example", "scaffold"): raise ReactionRefused("Choose example or scaffold") if entry.kind == "reference": raise ReactionRefused("This is a reference term, not an insertable reaction") if kind == "example": if entry.example_smiles is None: raise ReactionRefused("No molecular example is available; explicitly choose scaffold") ket: dict[str, Any] = dict( import_into(None, entry.example_smiles, "smiles").document.canvas.data ) points = [ a["location"] for key, mol in ket.items() if key.startswith("mol") for a in mol["atoms"] ] x = (min(p[0] for p in points) + max(p[0] for p in points)) / 2 y = max(p[1] for p in points) + 2 ket["root"]["nodes"].append(text_node(f"{entry.name}\nTeaching example", (x, y))) return ket # A graphical arrow, not KET's chemical reaction arrow. A pending drawing # must not turn neighbouring species into invented reaction participants. return { "root": { "nodes": [ text_node(f"{entry.name}\nUNFINISHED SCAFFOLD - structures not supplied", (0, 3)), text_node("Reactant(s) - add structures", (-7, 0)), text_node("Product(s) - add structures", (7, 0)), text_node("Reagents / conditions - verify", (0, 1.2)), _shape("line", (-2, 0), (2, 0)), _shape("line", (2, 0), (1.5, 0.3)), _shape("line", (2, 0), (1.5, -0.3)), ] } }
[docs] def insert_reaction( document: Document, identifier: str, *, kind: InsertionKind = "example" ) -> Derivation: """Append beside the existing drawing; preserve structures and document metadata.""" entry = get_reaction(identifier) fragment = reaction_fragment(identifier, kind=kind) # Keep a distinct block beyond the role parser's local grouping distance. # Reuse normal import ID remapping, but leave extra space for independent schemes. merged = _merged(dict(document.canvas.data), fragment, gap=6.0) result = import_into(document, json.dumps(merged), "ket", mode="replace") note = ( entry.note if kind == "example" else "Unfinished scaffold; no molecular structures or calculation results." ) return Derivation(result.document, [note, *result.warnings], result.result)