"""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)