chemgraph-loop / src /ui /message_utils.py
rockyaaos's picture
ChemGraph Loop: guarded real-agent API (EMT/TBLite single-point energy)
c509967 verified
Raw
History Blame Contribute Delete
24.7 kB
"""Message parsing and molecular-structure extraction helpers.
Every function in this module is **Streamlit-free** so it can be unit-tested
without a running Streamlit runtime.
"""
import ast
import json
import re
from typing import Any, Literal, Optional
from ase.data import chemical_symbols
# ---------------------------------------------------------------------------
# Content normalisation
# ---------------------------------------------------------------------------
def normalize_message_content(content: Any) -> str:
"""Convert varying message content payloads into plain text.
Parameters
----------
content : Any
Message content as text, list blocks, dictionary, or scalar.
Returns
-------
str
Normalized plain-text content.
"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
text = item.get("text")
if isinstance(text, str):
parts.append(text)
else:
parts.append(str(item))
else:
parts.append(str(item))
return "\n".join(p for p in parts if p)
if isinstance(content, dict):
text = content.get("text")
if isinstance(text, str):
return text
return str(content)
return str(content)
def normalize_latex_delimiters(text: str) -> str:
"""Convert common LLM math delimiters into Streamlit-renderable Markdown.
Parameters
----------
text : str
Markdown text that may contain LLM-style math delimiters.
Returns
-------
str
Text with display and inline math delimiters normalized.
"""
if not text:
return ""
text = _convert_square_bracket_math(text)
text = _convert_bare_display_math_outside_display(text)
text = _normalize_common_inline_latex_units(text)
return _convert_parenthetical_math_outside_display(text)
def _is_latex_square_delimiter(text: str, index: int) -> bool:
"""Return whether a bracket belongs to ``\\left``/``\\right``.
Parameters
----------
text : str
Source text.
index : int
Bracket index to inspect.
Returns
-------
bool
``True`` when the bracket is part of a LaTeX delimiter command.
"""
prefix = text[max(0, index - 6) : index]
return prefix.endswith(r"\left") or prefix.endswith(r"\right")
def _find_square_math_close(text: str, start: int) -> int | None:
"""Find the matching closing square bracket for display math.
Parameters
----------
text : str
Source text.
start : int
Index of the opening square bracket.
Returns
-------
int or None
Closing bracket index, or ``None`` if unmatched.
"""
depth = 1
index = start + 1
while index < len(text):
if text.startswith(r"\left[", index):
index += len(r"\left[")
continue
if text.startswith(r"\right]", index):
index += len(r"\right]")
continue
char = text[index]
if char == "[":
depth += 1
elif char == "]":
depth -= 1
if depth == 0:
return index
index += 1
return None
def _convert_square_bracket_math(text: str) -> str:
"""Convert LLM-style ``[ TeX ]`` display math while preserving links.
Parameters
----------
text : str
Source Markdown text.
Returns
-------
str
Text with display math converted to ``$$`` blocks.
"""
chunks: list[str] = []
index = 0
while index < len(text):
char = text[index]
if char != "[" or _is_latex_square_delimiter(text, index):
chunks.append(char)
index += 1
continue
close_index = _find_square_math_close(text, index)
if close_index is None:
chunks.append(char)
index += 1
continue
body = text[index + 1 : close_index].strip()
has_latex_command = bool(re.search(r"\\[A-Za-z]+", body))
is_markdown_link = close_index + 1 < len(text) and text[close_index + 1] == "("
if body and has_latex_command and not is_markdown_link:
chunks.append(f"$$\n{body}\n$$")
else:
chunks.append(text[index : close_index + 1])
index = close_index + 1
return "".join(chunks)
def _find_parenthesis_close(text: str, start: int) -> int | None:
"""Find the matching closing parenthesis.
Parameters
----------
text : str
Source text.
start : int
Index of the opening parenthesis.
Returns
-------
int or None
Closing parenthesis index, or ``None`` if unmatched.
"""
depth = 1
index = start + 1
while index < len(text):
char = text[index]
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if depth == 0:
return index
index += 1
return None
def _normalize_common_inline_latex_units(text: str) -> str:
"""Render common scientific units as text when inline KaTeX is unreliable."""
chunks: list[str] = []
last_end = 0
for match in re.finditer(r"(?s)\$\$.*?\$\$", text):
chunks.append(
_normalize_common_inline_latex_units_chunk(text[last_end : match.start()])
)
chunks.append(match.group(0))
last_end = match.end()
chunks.append(_normalize_common_inline_latex_units_chunk(text[last_end:]))
return "".join(chunks)
def _normalize_common_inline_latex_units_chunk(text: str) -> str:
"""Normalize inline unit fragments outside display-math blocks."""
angstrom = "\u00c5"
dipole_unit = re.compile(
r"\$\s*"
r"(?:e|\\mathrm\{e\})"
r"\s*\\cdot\s*"
rf"(?:\\text\{{(?:{angstrom}|A|Angstrom|angstrom|\\AA)\}}"
rf"|\\mathrm\{{(?:{angstrom}|A)\}}|{angstrom}|\\AA)"
r"\s*\$"
)
return dipole_unit.sub("e\u00b7\u00c5", text)
def _is_markdown_link_target(text: str, paren_index: int) -> bool:
"""Return whether ``(`` starts a Markdown link/image target."""
prefix = text[:paren_index]
return bool(re.search(r"!?\[[^\]\n]*\]$", prefix))
def _is_url_or_path_like(text: str) -> bool:
"""Return whether parenthetical text looks like a URL or filesystem path."""
stripped = text.strip()
if re.match(r"(?i)^[a-z][a-z0-9+.-]*:", stripped):
return True
if stripped.startswith(("/", "./", "../", "~/")):
return True
return bool(re.match(r"^[A-Za-z]:[\\/]", stripped))
def _convert_parenthetical_inline_math(text: str) -> str:
"""Convert parenthesized math-like text to inline math.
Parameters
----------
text : str
Text outside display-math blocks.
Returns
-------
str
Text with math-like parenthetical content wrapped in ``$``.
"""
chunks: list[str] = []
index = 0
while index < len(text):
char = text[index]
if char != "(":
chunks.append(char)
index += 1
continue
close_index = _find_parenthesis_close(text, index)
if close_index is None:
chunks.append(char)
index += 1
continue
body = text[index + 1 : close_index].strip()
has_latex = bool(re.search(r"\\[A-Za-z]+|\\\s|[_^{}]", body))
if (
body
and has_latex
and not _is_markdown_link_target(text, index)
and not _is_url_or_path_like(body)
):
chunks.append(f"${body}$")
else:
chunks.append(text[index : close_index + 1])
index = close_index + 1
return "".join(chunks)
def _convert_parenthetical_math_outside_display(text: str) -> str:
"""Convert parenthetical math outside display-math blocks.
Parameters
----------
text : str
Markdown text that may contain display-math blocks.
Returns
-------
str
Text with inline parenthetical math normalized.
"""
chunks: list[str] = []
last_end = 0
for match in re.finditer(r"(?s)\$\$.*?\$\$", text):
chunks.append(_convert_parenthetical_inline_math(text[last_end : match.start()]))
chunks.append(match.group(0))
last_end = match.end()
chunks.append(_convert_parenthetical_inline_math(text[last_end:]))
return "".join(chunks)
def _strip_dangling_backslash(text: str) -> str:
"""Remove one invalid trailing backslash while preserving ``\\\\``."""
stripped = text.rstrip()
if stripped.endswith("\\") and not stripped.endswith("\\\\"):
return stripped[:-1].rstrip()
return text
def _looks_like_bare_display_math(line: str) -> bool:
"""Return whether a standalone line is likely display LaTeX."""
stripped = line.strip()
if not stripped or stripped.startswith(("$$", "$", r"\[", r"\(")):
return False
if stripped.startswith(("#", "-", "*", ">", "|")):
return False
has_latex_command = bool(re.search(r"\\[A-Za-z]+", stripped))
has_reaction_arrow = bool(
re.search(r"\\(?:rightarrow|leftarrow|leftrightarrow)\b", stripped)
)
return has_latex_command and (stripped.startswith("\\") or has_reaction_arrow)
def _convert_bare_display_math_lines(text: str) -> str:
"""Wrap standalone bare LaTeX lines in display math delimiters."""
lines = text.splitlines()
converted: list[str] = []
for line in lines:
if _looks_like_bare_display_math(line):
for index in range(len(converted) - 1, -1, -1):
if converted[index].strip():
converted[index] = _strip_dangling_backslash(converted[index])
break
body = _strip_dangling_backslash(line.strip())
converted.extend(["$$", body, "$$"])
else:
converted.append(line)
return "\n".join(converted)
def _convert_bare_display_math_outside_display(text: str) -> str:
"""Convert bare LaTeX lines outside existing display-math blocks."""
chunks: list[str] = []
last_end = 0
for match in re.finditer(r"(?s)\$\$.*?\$\$", text):
chunks.append(_convert_bare_display_math_lines(text[last_end : match.start()]))
chunks.append(match.group(0))
last_end = match.end()
chunks.append(_convert_bare_display_math_lines(text[last_end:]))
return "".join(chunks)
def split_markdown_latex_blocks(
text: str,
) -> list[tuple[Literal["markdown", "latex"], str]]:
"""Split text into Markdown and display-LaTeX blocks.
Parameters
----------
text : str
Markdown text that may contain display math blocks.
Returns
-------
list[tuple[Literal["markdown", "latex"], str]]
Ordered render blocks for Streamlit.
"""
normalized = normalize_latex_delimiters(text)
if not normalized:
return []
parts: list[tuple[Literal["markdown", "latex"], str]] = []
last_end = 0
for match in re.finditer(r"(?s)\$\$\s*(.*?)\s*\$\$", normalized):
markdown = normalized[last_end : match.start()].strip()
if markdown:
parts.append(("markdown", markdown))
latex = match.group(1).strip()
if latex:
parts.append(("latex", latex))
last_end = match.end()
trailing = normalized[last_end:].strip()
if trailing:
parts.append(("markdown", trailing))
return parts
# ---------------------------------------------------------------------------
# Message extraction
# ---------------------------------------------------------------------------
def extract_messages_from_result(result: Any) -> list:
"""Extract messages from a result object, handling different formats.
Parameters
----------
result : Any
Agent result, state dictionary, message list, or scalar result.
Returns
-------
list
Extracted message-like objects.
"""
if isinstance(result, list):
return result
elif isinstance(result, dict) and "messages" in result:
# Copy the list so we never mutate the original stored in
# conversation_history -- without this, worker messages would be
# duplicated on every Streamlit rerun.
messages = list(result["messages"])
# For multi-agent workflows, also extract messages from worker_channel
if "worker_channel" in result:
worker_channel = result["worker_channel"]
for _worker_id, worker_messages in worker_channel.items():
if isinstance(worker_messages, list):
messages.extend(worker_messages)
return messages
else:
return [result]
# ---------------------------------------------------------------------------
# Structure extraction
# ---------------------------------------------------------------------------
def extract_molecular_structure(message_content: str) -> Optional[dict]:
"""Return embedded molecular structure data if present.
Parameters
----------
message_content : str
Message text that may contain JSON or plain-text structure data.
Returns
-------
dict or None
Dictionary with ``atomic_numbers`` and ``positions``, or ``None``.
"""
if not message_content:
return None
# Try JSON first
try:
if message_content.strip().startswith("{") and message_content.strip().endswith(
"}"
):
json_data = json.loads(message_content)
structure_data = None
if "answer" in json_data:
structure_data = json_data["answer"]
elif "numbers" in json_data and "positions" in json_data:
structure_data = json_data
elif "atomic_numbers" in json_data and "positions" in json_data:
structure_data = json_data
if (
structure_data
and "numbers" in structure_data
and "positions" in structure_data
):
return {
"atomic_numbers": structure_data["numbers"],
"positions": structure_data["positions"],
}
elif (
structure_data
and "atomic_numbers" in structure_data
and "positions" in structure_data
):
return {
"atomic_numbers": structure_data["atomic_numbers"],
"positions": structure_data["positions"],
}
except (json.JSONDecodeError, KeyError):
pass
# Plain-text format fallback
lines = message_content.splitlines()
atomic_numbers, positions = None, None
for i, line in enumerate(lines):
if "Atomic Numbers" in line:
try:
numbers_str = line.split(":")[1].strip()
atomic_numbers = ast.literal_eval(numbers_str)
except Exception:
pass
elif "Positions" in line:
positions = []
for sub in lines[i + 1 :]:
sub = sub.strip()
if sub.startswith("- [") and sub.endswith("]"):
try:
positions.append(ast.literal_eval(sub[2:]))
except Exception:
pass
elif not sub.startswith("-") and positions:
break
if (
isinstance(atomic_numbers, list)
and isinstance(positions, list)
and len(atomic_numbers) == len(positions)
):
return {"atomic_numbers": atomic_numbers, "positions": positions}
return None
def find_structure_in_messages(messages: list) -> Optional[dict]:
"""Look through messages in reverse to find the latest structure data.
Parameters
----------
messages : list
Message-like objects or dictionaries to scan.
Returns
-------
dict or None
Latest embedded structure dictionary, or ``None``.
"""
for message in reversed(messages):
if hasattr(message, "content") or isinstance(message, dict):
raw_content = (
getattr(message, "content", "")
if hasattr(message, "content")
else message.get("content", "")
)
content = normalize_message_content(raw_content)
structure = extract_molecular_structure(content)
if structure:
return structure
return None
def has_structure_signal(
messages: list, query_text: str = "", final_answer: str = ""
) -> bool:
"""Return True when an interaction appears to include structure artifacts.
Parameters
----------
messages : list
Message-like objects or dictionaries to inspect.
query_text : str, optional
Original user query text.
final_answer : str, optional
Final assistant answer text.
Returns
-------
bool
``True`` when structure-related tools, artifacts, or text are found.
"""
structure_tools = {
"smiles_to_coordinate_file",
"run_ase",
"file_to_atomsdata",
"save_atomsdata_to_file",
}
structure_markers = (
".xyz",
"final_structure",
"atomic_numbers",
"positions",
"coordinate_file",
)
for message in messages:
name = getattr(message, "name", None)
content = getattr(message, "content", "")
if isinstance(message, dict):
name = message.get("name", name)
content = message.get("content", content)
if name in structure_tools:
return True
if isinstance(content, str):
lowered = content.lower()
if any(marker in lowered for marker in structure_markers):
return True
combined_text = f"{query_text}\n{final_answer}".lower()
keyword_markers = (
"geometry",
"optimiz",
"structure",
"coordinates",
"xyz",
)
return any(marker in combined_text for marker in keyword_markers)
# ---------------------------------------------------------------------------
# HTML report helpers
# ---------------------------------------------------------------------------
def find_html_filename(messages: list) -> Optional[str]:
"""Scan *messages* in reverse for the first ``*.html`` reference.
Returns the matched substring (path or bare filename) or ``None``.
Parameters
----------
messages : list
Message-like objects, dictionaries, or strings to scan.
Returns
-------
str or None
First HTML path/filename found from the end of the message list.
"""
pattern = r"[\w./-]+\.html\b"
for message in reversed(messages):
raw_content = ""
if hasattr(message, "content"):
raw_content = getattr(message, "content", "")
elif isinstance(message, dict):
raw_content = message.get("content", "")
elif isinstance(message, str):
raw_content = message
else:
raw_content = str(message)
content = normalize_message_content(raw_content)
if content:
match = re.search(pattern, content, flags=re.IGNORECASE)
if match:
return match.group(0)
return None
def extract_xyz_from_report_html(html_content: str) -> Optional[dict]:
"""Decode base64-encoded XYZ data from an HTML report.
Parameters
----------
html_content : str
HTML report content containing an ``atob()`` XYZ payload.
Returns
-------
dict or None
Structure dictionary with atomic numbers and positions, or ``None``.
"""
import base64 as _b64
match = re.search(r'atob\(["\']([A-Za-z0-9+/=]+)["\']\)', html_content)
if not match:
return None
try:
xyz_text = _b64.b64decode(match.group(1)).decode("utf-8")
except Exception:
return None
lines = xyz_text.strip().splitlines()
if len(lines) < 3:
return None
try:
num_atoms = int(lines[0].strip())
except ValueError:
return None
sym_to_num = {s: i for i, s in enumerate(chemical_symbols)}
atomic_numbers: list[int] = []
positions: list[list[float]] = []
for line in lines[2 : 2 + num_atoms]:
parts = line.strip().split()
if len(parts) < 4:
continue
symbol = parts[0]
anum = sym_to_num.get(symbol)
if anum is None:
return None
try:
pos = [float(parts[1]), float(parts[2]), float(parts[3])]
except ValueError:
return None
atomic_numbers.append(anum)
positions.append(pos)
if len(atomic_numbers) != num_atoms:
return None
return {"atomic_numbers": atomic_numbers, "positions": positions}
def strip_viewer_from_report_html(html_content: str) -> str:
"""Remove the NGL 3D-viewer section from a ChemGraph HTML report.
Strips the ``<div id="viewer">`` element, the NGL ``<script src>`` tag,
and the inline ``<script>`` that initialises the NGL stage. Keeps the
toggle-section JS so collapsible sections still work.
Parameters
----------
html_content : str
Raw HTML report content.
Returns
-------
str
HTML content with the embedded molecule viewer removed.
"""
# 1. External NGL script tag
html_content = re.sub(
r'<script\s+src="[^"]*ngl[^"]*"[^>]*>\s*</script>\s*',
"",
html_content,
flags=re.IGNORECASE,
)
# 2. Viewer div
html_content = re.sub(
r'<div\s+id="viewer"[^>]*>\s*</div>\s*',
"",
html_content,
)
# 3. Inline NGL script block -- keep toggle helpers
html_content = re.sub(
r"<script>\s*function toggleSection.*?const stage = new NGL\.Stage.*?</script>",
"""<script>
function toggleSection(sectionId) {
const content = document.getElementById(sectionId);
const header = content.previousElementSibling;
content.classList.toggle('collapsed');
header.classList.toggle('collapsed');
}
function toggleSubSection(sectionId) {
const content = document.getElementById(sectionId);
const header = content.previousElementSibling;
if (content.style.display === 'none') {
content.style.display = 'block';
header.classList.remove('collapsed');
} else {
content.style.display = 'none';
header.classList.add('collapsed');
}
}
document.addEventListener('DOMContentLoaded', function() {
const subSections = document.querySelectorAll('.sub-section-content');
subSections.forEach(section => { section.style.display = 'block'; });
});
</script>""",
html_content,
flags=re.DOTALL,
)
# 4. "XYZ Molecule Viewer" heading
html_content = re.sub(
r"<h1>\s*XYZ Molecule Viewer\s*</h1>\s*",
"",
html_content,
)
return html_content
# ---------------------------------------------------------------------------
# IR spectrum detection
# ---------------------------------------------------------------------------
def is_infrared_requested(messages: list) -> bool:
"""Return True if any message mentions infrared or IR.
Parameters
----------
messages : list
Message-like objects, dictionaries, or strings to inspect.
Returns
-------
bool
``True`` when infrared-related wording is found.
"""
for message in messages:
raw_content = ""
if hasattr(message, "content"):
raw_content = getattr(message, "content", "")
elif isinstance(message, dict):
raw_content = message.get("content", "")
elif isinstance(message, str):
raw_content = message
else:
raw_content = str(message)
content = normalize_message_content(raw_content)
lowered = content.lower()
if content and (("infrared" in lowered) or re.search(r"\bir\b", lowered)):
return True
return False