""" Text-processing helpers and webservice call utility for the tei-annotator plugin. """ from __future__ import annotations import asyncio import logging from typing import Any import requests from lxml import etree from fastapi_app.lib.utils.config_utils import get_config from fastapi_app.plugins.tei_annotator.config import LB_PLACEHOLDER, TEI_NS logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Text-processing helpers # --------------------------------------------------------------------------- def bibl_to_plain_text(bibl_el: etree._Element) -> str: """ Extract plain text from a element, replacing with LB_PLACEHOLDER. All annotation child elements (author, title, etc.) are stripped; only their text content is preserved. """ return _collect_text(bibl_el, strip_bibl=False) def element_to_plain_text_with_lb(el: etree._Element) -> str: """ Extract plain text from any element, replacing with LB_PLACEHOLDER and stripping wrapper tags along with all other annotation elements. """ return _collect_text(el, strip_bibl=True) def restore_lb(xml_fragment: str) -> str: """Replace LB_PLACEHOLDER occurrences with in an XML fragment string.""" return xml_fragment.replace(LB_PLACEHOLDER, "") def _collect_text(el: etree._Element, strip_bibl: bool) -> str: """ Recursively collect text content of *el*, converting lb elements to the placeholder and stripping all other element wrappers. """ parts: list[str] = [] def _visit(node: etree._Element) -> None: if node.text: parts.append(node.text) for child in node: local = etree.QName(child.tag).localname if isinstance(child.tag, str) else None if local == "lb": parts.append(LB_PLACEHOLDER) elif local == "bibl" and strip_bibl: _visit(child) else: _visit(child) if child.tail: parts.append(child.tail) if el.text: parts.append(el.text) for child in el: local = etree.QName(child.tag).localname if isinstance(child.tag, str) else None if local == "lb": parts.append(LB_PLACEHOLDER) else: _visit(child) if child.tail: parts.append(child.tail) return "".join(parts) # --------------------------------------------------------------------------- # Webservice call # --------------------------------------------------------------------------- async def call_annotate( *, text: str, schema: dict[str, Any], provider: str, model: str, ) -> dict[str, Any]: """ POST a single text to the TEI Annotator webservice (/api/annotate). Returns the first response dict (keys: "xml", "fuzzy_spans", "elapsed_seconds"), or an empty dict if the service returns no results. Raises RuntimeError if the server URL is not configured. """ config = get_config() server_url = config.get("tei-annotator.server.url") if not server_url: raise RuntimeError("tei-annotator.server.url is not configured") url = f"{server_url.rstrip('/')}/api/annotate" api_key = config.get("tei-annotator.server.api-key") headers: dict[str, str] = {} if api_key: headers["Authorization"] = f"Bearer {api_key}" body: dict[str, Any] = { "provider": provider, "model": model, "schema": schema, "text": text, } # Log request details for debugging logger.debug( "TEI Annotator request to %s: provider=%s, model=%s, text_length=%d, schema_keys=%s", url, provider, model, len(text), list(schema.keys()) if isinstance(schema, dict) else type(schema).__name__, ) def _do_post() -> list[dict[str, Any]]: resp = requests.post(url, json=body, headers=headers, timeout=300) try: resp.raise_for_status() except requests.exceptions.HTTPError as exc: # Log the response body for debugging 422 errors logger.error( "TEI Annotator API error %s for %s. Response body: %s", resp.status_code, url, resp.text[:1000] if resp.text else "(empty)", ) raise data = resp.json() return data if isinstance(data, list) else [data] results = await asyncio.to_thread(_do_post) return results[0] if results else {}