"""HTML for the playground: molecule drawings, route trees and score tables.""" from __future__ import annotations import html from functools import lru_cache from typing import Any from rdkit import Chem from rdkit.Chem.Draw import rdMolDraw2D from .molecule3d import viewer_url STYLE = """ """ # Heteroatoms in mid-tone element colours that read on light and dark backgrounds; carbon # and every bond stay black in the SVG and are then switched to the page's text colour. ATOM_COLOURS = { 5: (0.83, 0.48, 0.48), # B 7: (0.24, 0.44, 0.85), # N 8: (0.88, 0.28, 0.23), # O 9: (0.23, 0.65, 0.33), # F 15: (0.88, 0.54, 0.12), # P 16: (0.79, 0.64, 0.15), # S 17: (0.23, 0.65, 0.33), # Cl 35: (0.71, 0.40, 0.23), # Br 53: (0.56, 0.36, 0.71), # I } @lru_cache(maxsize=512) def molecule_svg(smiles: str, width: int = 240, height: int = 160) -> str: """A 2D drawing whose bonds follow the page theme and whose heteroatoms keep element colours.""" mol = Chem.MolFromSmiles(smiles) if mol is None: return 'unparsable SMILES' drawer = rdMolDraw2D.MolDraw2DSVG(width, height) options = drawer.drawOptions() options.clearBackground = False options.padding = 0.08 options.bondLineWidth = 1.4 options.useBWAtomPalette() options.updateAtomPalette(ATOM_COLOURS) drawer.DrawMolecule(mol) drawer.FinishDrawing() svg = drawer.GetDrawingText().replace("#000000", "currentColor") return f'{svg[svg.find("' def wrap(body: str) -> str: return f'
{body}
' def key_values(rows: list[tuple[str, str]]) -> str: items = "".join(f"
{html.escape(k)}
{v}
" for k, v in rows) return f'
{items}
' def target_panel(opening: dict[str, Any] | None, state: Any = None) -> str: if not opening: return wrap('

Choose a split and task, then start an episode.

') remaining = opening["max_tool_calls"] - (state.tool_calls if state else 0) rows = [ ("Task", f'{html.escape(opening["task_id"])}'), ("Target", f'{html.escape(opening["target_smiles"])}'), ("Depth", f"at most {opening['max_depth']} reactions (longest linear sequence)"), ("Variant", html.escape(opening.get("variant", "standard"))), ("Routes", f"{opening['min_routes']} to {opening['max_routes']}"), ("Stock", html.escape(opening["stock_id"])), ("Tool calls left", f"{remaining} of {opening['max_tool_calls']}"), ] if state is not None and state.done: rows.append(("Reward", f"{state.reward:.3f}" if state.reward is not None else "not scored")) return wrap(key_values(rows)) def structure_2d(smiles: str | None, width: int = 420, height: int = 280) -> str: if not smiles: return wrap('

Start an episode to see the target.

') return wrap(f'
{molecule_svg(smiles, width, height)}
') def structure_3d(smiles: str | None, height: int = 300) -> str: """The 3D view: an iframe onto the server's conformer page, loaded when it is shown.""" if not smiles: return wrap('

Start an episode to see the target.

') return wrap( f'
' '

One RDKit conformer (ETKDG, then MMFF), for shape only.

' ) def molecule_views(smiles: str | None) -> str: """The molecule under study, 2D beside 3D.""" if not smiles: return wrap("") return wrap( f'

{html.escape(smiles)}

' f'
{molecule_svg(smiles, 360, 240)}' f'
' ) def _route_node(node: Any, depth: int = 0) -> str: if not isinstance(node, dict) or depth > 12: return '
not a molecule node
' smiles = str(node.get("smiles", "")) stock = node.get("in_stock") label = "in stock" if stock else ("intermediate" if node.get("children") else "leaf, not claimed in stock") out = ( f'
{molecule_svg(smiles, 150, 100)}' f'
{html.escape(smiles)}
{label}
' ) for reaction in node.get("children") or []: if not isinstance(reaction, dict): continue metadata = reaction.get("metadata") or {} title = html.escape(str(metadata.get("reaction_class") or "reaction")) note = html.escape(str(metadata.get("explanation") or "")[:240]) # slice first: never split an entity children = "".join(_route_node(child, depth + 1) for child in reaction.get("children") or []) out += f'
{title}{" ยท " + note if note else ""}
{children}
' return out def plan_panel(target: str, plan: dict[str, tuple[str, ...]], stock: dict[str, bool]) -> str: """The route being assembled by hand, with each precursor's stock status as the tools reported it.""" if not target: return wrap('

Start an episode to plan a route.

') def node(smiles: str, depth: int = 0) -> str: precursors = plan.get(smiles) if depth <= 12 else None if precursors: label = "to make" elif stock.get(smiles): label = 'in stock' elif smiles in stock: label = 'not in stock, not yet cut' else: label = 'stock not checked' out = ( f'
{molecule_svg(smiles, 140, 95)}' f'
{html.escape(smiles)}
{label}
' ) for precursor in precursors or (): out += f'
{node(precursor, depth + 1)}
' return out return wrap(node(target)) def verdict_panel(result: dict[str, Any]) -> str: """What validate_disconnection said about one proposed cut.""" if not isinstance(result, dict) or not result: return wrap("") if result.get("error"): return wrap(f'

{html.escape(str(result["error"]))}

') supported = bool(result.get("supported")) kind = {"known_precedent": "a recorded reaction", "reaction_template": "a frequent reaction template"}.get( str(result.get("support")), str(result.get("support")) ) head = ( f'Supported by {html.escape(kind)}' if supported else 'The train-visible evidence does not support this cut' ) rows = [("Reaction class", html.escape(str(result.get("reaction_class") or "unclassified")))] if result.get("template_frequency"): rows.append(("Template seen", f"{result['template_frequency']} times")) if result.get("stereo_consistent") is not None: rows.append(("Stereochemistry", "agrees" if result["stereo_consistent"] else "differs")) return wrap( f"

{head}

" + key_values(rows) + '

The final verifier uses a larger library, so it may still ' "accept a cut this call rejects.

" ) def stock_panel(found: list[tuple[str, dict[str, Any]]]) -> str: """Stock lookups for the precursors of one proposed cut.""" rows = [] for smiles, result in found: held = bool(result.get("results")) status = 'in stock' if held else 'not in stock' rows.append( f"{molecule_svg(smiles, 110, 70)}" f'{html.escape(smiles)}{status}' ) return wrap(f"{''.join(rows)}
") def routes_panel(submission: Any) -> str: routes = submission.get("routes") if isinstance(submission, dict) else None if not routes: return wrap('

Submitted routes appear here.

') blocks = "".join(f"

Route {i + 1}

{_route_node(route)}" for i, route in enumerate(routes)) return wrap(blocks) def score_panel(score: dict[str, Any] | None) -> str: if not score: return wrap('

Emit routes to score them.

') valid = score.get("valid") status = f'{"Pass" if valid else "Fail"}' rows = "".join( f"{html.escape(name.replace('_', ' '))}{value:.3f}" for name, value in sorted((score.get("components") or {}).items()) ) failures = "".join(f"
  • {html.escape(str(f))}
  • " for f in (score.get("hard_failures") or [])[:6]) return wrap( key_values( [ ("Result", status), ("Reward", f"{score.get('reward', 0.0):.3f}"), ("Tier", html.escape(str(score.get("verification_tier")))), ] ) + f"{rows}
    ComponentScore
    " + (f"

    Why it failed

    " if failures else "") ) DATASET = "LiteFold/RetroEnv" DATASET_URL = "https://huggingface.co/datasets/LiteFold/RetroEnv" def _link(url: str, text: str) -> str: return f'{html.escape(text)}' def credit_line() -> str: """The one-line credit under the title: whose dataset this environment serves.""" return ( f"Tasks and known routes from {_link(DATASET_URL, DATASET)}, the dataset built and released by " f"{_link('https://huggingface.co/LiteFold', 'LiteFold')} from PaRoutes v2 (CC BY 4.0). Credits at the " "bottom of the page." ) def credits_panel(source: dict[str, Any] | None, snapshot: dict[str, Any] | None) -> str: """Where the data comes from, for the playground's footer; values come from the served manifests.""" source, snapshot = source or {}, snapshot or {} revision = (snapshot.get("source") or {}).get("revision") dataset = _link(DATASET_URL, DATASET) + ( f" at revision {revision[:12]}" if revision else "" ) rows = [ ( "Dataset", f"{dataset}, built and released by {_link('https://huggingface.co/LiteFold', 'LiteFold')}: every " "task, known route and difficulty label, the stock and the reaction library served here", ), ( "Routes", f"{html.escape(source.get('name', 'PaRoutes v2'))}: S. Genheden and E. Bjerrum, PaRoutes: towards a " "framework for benchmarking retrosynthesis route predictions, Digital Discovery 2022 " f"({_link('https://doi.org/10.1039/D2DD00015F', 'doi:10.1039/D2DD00015F')}); data " f"{_link(source.get('url', 'https://zenodo.org/records/7341155'), 'Zenodo 7341155')}, " f"{html.escape(source.get('license', 'CC-BY-4.0'))}", ), ( "Reactions", "D. Lowe, Chemical reactions from US patents (1976 to Sep 2016), " f"{_link('https://doi.org/10.6084/m9.figshare.5104873.v1', 'figshare')}, CC0", ), ("Software", "RDKit, rdchiral, 3Dmol.js and OpenEnv"), ] if snapshot.get("bucket_id"): bucket = snapshot["bucket_id"] rows.insert( 3, ( "Served from", f"{_link(f'https://huggingface.co/buckets/{bucket}', bucket)}, snapshot " f"{html.escape(snapshot.get('snapshot_id', '')[:12])}", ), ) return wrap("

    Data and credits

    " + key_values(rows)) def history_panel(history: list[dict[str, Any]]) -> str: if not history: return wrap('

    Tool calls appear here.

    ') rows = "".join( f"{i + 1}{html.escape(item['tool'])}" f"{html.escape(item['arguments'][:120])}{html.escape(item['summary'][:160])}" for i, item in enumerate(history) ) return wrap( f"{rows}
    #ToolArgumentsResult
    " )