wuenlp_tools.visualize.coref

Self-contained HTML report for coreference annotations in XMI files.

  1"""Self-contained HTML report for coreference annotations in XMI files."""
  2
  3from __future__ import annotations
  4
  5import bisect
  6import html
  7import json
  8import sys
  9import webbrowser
 10from argparse import ArgumentParser
 11from collections import Counter, defaultdict
 12from pathlib import Path
 13from typing import Any
 14
 15from loguru import logger
 16from wuenlp import UIMADocument
 17from wuenlp.impl.uima import UIMAChapter, UIMACharacter, UIMACharacterReference
 18
 19from wuenlp_tools.models.characters.llm_alias_coref import (
 20        LLM_ALIASES_FEATURE,
 21        LLM_ANTI_REFERENCES_FEATURE,
 22)
 23
 24MENTIONS_PER_PAGE = 50
 25
 26
 27def _chapter_index(chapters: list[UIMAChapter]) -> tuple[list[int], list[str]]:
 28    """Return sorted chapter start offsets and normalized titles."""
 29    if not chapters:
 30        return [], []
 31    ordered = sorted(chapters, key=lambda ch: ch.begin)
 32    starts = [ch.begin for ch in ordered]
 33    titles = []
 34    for ch in ordered:
 35        title = " ".join(ch.text.split())
 36        titles.append(title[:120] if title else f"offset {ch.begin}")
 37    return starts, titles
 38
 39
 40def _chapter_at(
 41        pos: int,
 42        starts: list[int],
 43        titles: list[str],
 44) -> str:
 45    if not starts:
 46        return ""
 47    idx = bisect.bisect_right(starts, pos) - 1
 48    if idx < 0:
 49        return titles[0] if titles else ""
 50    return titles[idx]
 51
 52
 53def _format_context(
 54        doc_text: str,
 55        begin: int,
 56        end: int,
 57        *,
 58        context_chars: int,
 59) -> str:
 60    left = max(0, begin - context_chars)
 61    right = min(len(doc_text), end + context_chars)
 62    before = html.escape(doc_text[left:begin])
 63    mention = html.escape(doc_text[begin:end])
 64    after = html.escape(doc_text[end:right])
 65    prefix = "…" if left > 0 else ""
 66    suffix = "…" if right < len(doc_text) else ""
 67    return f"{prefix}{before}<mark>{mention}</mark>{after}{suffix}"
 68
 69
 70def _character_metadata(char: UIMACharacter) -> dict[str, Any]:
 71    meta: dict[str, Any] = {}
 72    if char.is_important:
 73        meta["is_important"] = True
 74    gender = char.gender
 75    if gender:
 76        meta["gender"] = gender
 77    features = char.additional_features
 78    if LLM_ALIASES_FEATURE in features:
 79        meta["llm_aliases"] = features[LLM_ALIASES_FEATURE]
 80    if LLM_ANTI_REFERENCES_FEATURE in features:
 81        meta["llm_anti_references"] = features[LLM_ANTI_REFERENCES_FEATURE]
 82    return meta
 83
 84
 85def build_report_data(
 86        doc: UIMADocument,
 87        *,
 88        context_chars: int = 100,
 89        min_mentions: int = 0,
 90) -> dict[str, Any]:
 91    """Extract character clusters, surface forms, and in-context mentions from a document."""
 92    doc_text = doc.text
 93    chapter_starts, chapter_titles = _chapter_index(list(doc.chapters))
 94
 95    refs_by_char: dict[str, list[UIMACharacterReference]] = defaultdict(list)
 96    unlinked = 0
 97    for ref in doc.character_references:
 98        entity = ref.referred_entity
 99        if entity is None:
100            unlinked += 1
101            continue
102        refs_by_char[str(entity.id)].append(ref)
103
104    characters: list[dict[str, Any]] = []
105    char_by_id: dict[str, UIMACharacter] = {str(c.id): c for c in doc.characters}
106
107    for char_id, char in char_by_id.items():
108        refs = refs_by_char.get(char_id, [])
109        if len(refs) < min_mentions:
110            continue
111
112        refs.sort(key=lambda r: r.begin)
113        surface_counts = Counter(ref.text for ref in refs)
114        total = len(refs)
115
116        surface_forms = [
117            {"form": form, "count": count, "share": round(100 * count / total, 1)}
118            for form, count in surface_counts.most_common()
119        ]
120
121        mentions = []
122        for ref in refs:
123            mentions.append({
124                "surface": ref.text,
125                "begin": ref.begin,
126                "chapter": _chapter_at(ref.begin, chapter_starts, chapter_titles),
127                "context_html": _format_context(
128                    doc_text, ref.begin, ref.end, context_chars=context_chars
129                ),
130            })
131
132        entry: dict[str, Any] = {
133            "id": char_id,
134            "name": char.name or char_id,
135            "mention_count": total,
136            "unique_forms": len(surface_forms),
137            "surface_forms": surface_forms,
138            "mentions": mentions,
139        }
140        entry.update(_character_metadata(char))
141        characters.append(entry)
142
143    characters.sort(key=lambda c: (-c["mention_count"], c["name"].casefold()))
144
145    linked_mentions = sum(c["mention_count"] for c in characters)
146    return {
147        "summary": {
148            "character_count": len(characters),
149            "mention_count": linked_mentions,
150            "unlinked_mentions": unlinked,
151            "total_references": linked_mentions + unlinked,
152        },
153        "characters": characters,
154        "mentions_per_page": MENTIONS_PER_PAGE,
155    }
156
157
158def render_html(report: dict[str, Any], *, source_path: str) -> str:
159    """Render a self-contained interactive HTML report."""
160    report_json = json.dumps(report, ensure_ascii=False)
161    source_escaped = html.escape(source_path)
162
163    return f"""<!DOCTYPE html>
164<html lang="en">
165<head>
166<meta charset="utf-8">
167<meta name="viewport" content="width=device-width, initial-scale=1">
168<title>Coref report — {source_escaped}</title>
169<style>
170  :root {{
171    --bg: #f6f7f9;
172    --panel: #ffffff;
173    --border: #d8dee6;
174    --text: #1f2933;
175    --muted: #5f6b7a;
176    --accent: #2563eb;
177    --accent-soft: #dbeafe;
178    --mark: #fde68a;
179  }}
180  * {{ box-sizing: border-box; }}
181  body {{
182    margin: 0;
183    font-family: "Segoe UI", system-ui, sans-serif;
184    background: var(--bg);
185    color: var(--text);
186    line-height: 1.45;
187  }}
188  header {{
189    padding: 1rem 1.25rem;
190    background: var(--panel);
191    border-bottom: 1px solid var(--border);
192  }}
193  header h1 {{ margin: 0 0 0.35rem; font-size: 1.25rem; }}
194  header .source {{ color: var(--muted); font-size: 0.9rem; word-break: break-all; }}
195  header .stats {{ margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.9rem; }}
196  .layout {{
197    display: grid;
198    grid-template-columns: minmax(220px, 22%) minmax(220px, 26%) 1fr;
199    gap: 0;
200    min-height: calc(100vh - 88px);
201  }}
202  @media (max-width: 1100px) {{
203    .layout {{ grid-template-columns: 1fr; }}
204  }}
205  .panel {{
206    background: var(--panel);
207    border-right: 1px solid var(--border);
208    padding: 1rem;
209    overflow: auto;
210    min-height: calc(100vh - 88px);
211  }}
212  .panel:last-child {{ border-right: none; }}
213  .panel-title {{
214    margin: 0 0 0.75rem;
215    font-size: 0.85rem;
216    font-weight: 600;
217    text-transform: uppercase;
218    letter-spacing: 0.04em;
219    color: var(--muted);
220  }}
221  .search {{
222    width: 100%;
223    padding: 0.5rem 0.65rem;
224    border: 1px solid var(--border);
225    border-radius: 6px;
226    margin-bottom: 0.75rem;
227    font-size: 0.95rem;
228  }}
229  table {{
230    width: 100%;
231    border-collapse: collapse;
232    font-size: 0.9rem;
233  }}
234  th, td {{
235    text-align: left;
236    padding: 0.4rem 0.35rem;
237    border-bottom: 1px solid var(--border);
238  }}
239  th {{
240    position: sticky;
241    top: 0;
242    background: var(--panel);
243    cursor: pointer;
244    user-select: none;
245  }}
246  th:hover {{ color: var(--accent); }}
247  tr.char-row {{ cursor: pointer; }}
248  tr.char-row:hover {{ background: #f0f4f8; }}
249  tr.char-row.selected {{ background: var(--accent-soft); }}
250  tr.surface-row {{ cursor: pointer; }}
251  tr.surface-row:hover {{ background: #f0f4f8; }}
252  tr.surface-row.active {{ background: #fef3c7; }}
253  .placeholder {{
254    color: var(--muted);
255    padding: 2rem 0;
256  }}
257  .meta {{
258    display: flex;
259    flex-wrap: wrap;
260    gap: 0.5rem 1rem;
261    margin: 0.5rem 0 1rem;
262    font-size: 0.9rem;
263    color: var(--muted);
264  }}
265  .meta strong {{ color: var(--text); }}
266  h2 {{ margin: 0 0 0.75rem; font-size: 1.15rem; }}
267  h3 {{ margin: 1.25rem 0 0.5rem; font-size: 1rem; }}
268  .mention {{
269    border: 1px solid var(--border);
270    border-radius: 8px;
271    padding: 0.65rem 0.75rem;
272    margin-bottom: 0.6rem;
273    background: #fafbfc;
274  }}
275  .mention .chapter {{
276    font-size: 0.8rem;
277    color: var(--muted);
278    margin-bottom: 0.25rem;
279  }}
280  .mention .surface-tag {{
281    display: inline-block;
282    font-size: 0.75rem;
283    background: var(--accent-soft);
284    color: var(--accent);
285    padding: 0.1rem 0.4rem;
286    border-radius: 4px;
287    margin-bottom: 0.35rem;
288  }}
289  .mention .context {{
290    font-family: ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
291    font-size: 0.82rem;
292    white-space: pre-wrap;
293    word-break: break-word;
294  }}
295  mark {{
296    background: var(--mark);
297    padding: 0 0.1rem;
298    border-radius: 2px;
299  }}
300  .pager {{
301    display: flex;
302    align-items: center;
303    gap: 0.75rem;
304    margin: 0.75rem 0 1rem;
305    font-size: 0.9rem;
306  }}
307  .pager button {{
308    padding: 0.35rem 0.7rem;
309    border: 1px solid var(--border);
310    border-radius: 6px;
311    background: var(--panel);
312    cursor: pointer;
313  }}
314  .pager button:disabled {{ opacity: 0.45; cursor: default; }}
315  .num {{ text-align: right; font-variant-numeric: tabular-nums; }}
316  .filter-hint {{
317    font-size: 0.9rem;
318    color: var(--muted);
319    margin: 0 0 0.75rem;
320  }}
321  .filter-hint button {{
322    padding: 0.2rem 0.5rem;
323    border: 1px solid var(--border);
324    border-radius: 4px;
325    background: var(--panel);
326    cursor: pointer;
327    font-size: 0.8rem;
328  }}
329</style>
330</head>
331<body>
332<header>
333  <h1>Coreference report</h1>
334  <div class="source">{source_escaped}</div>
335  <div class="stats" id="summary-stats"></div>
336</header>
337<div class="layout">
338  <section class="panel">
339    <h2 class="panel-title">Characters</h2>
340    <input class="search" id="char-search" type="search" placeholder="Filter characters…" autocomplete="off">
341    <table id="char-table">
342      <thead>
343        <tr>
344          <th data-sort="name">Character</th>
345          <th data-sort="mention_count" class="num">Mentions</th>
346          <th data-sort="unique_forms" class="num">Forms</th>
347        </tr>
348      </thead>
349      <tbody id="char-tbody"></tbody>
350    </table>
351  </section>
352  <section class="panel" id="surfaces">
353    <p class="placeholder">Select a character to view surface forms.</p>
354  </section>
355  <section class="panel" id="mentions">
356    <p class="placeholder">Mentions in context appear here.</p>
357  </section>
358</div>
359<script>
360const REPORT = {report_json};
361
362const summary = REPORT.summary;
363document.getElementById("summary-stats").innerHTML = [
364  `<span><strong>${{summary.character_count}}</strong> characters</span>`,
365  `<span><strong>${{summary.mention_count}}</strong> linked mentions</span>`,
366  summary.unlinked_mentions
367    ? `<span><strong>${{summary.unlinked_mentions}}</strong> unlinked</span>`
368    : "",
369  `<span><strong>${{summary.total_references}}</strong> total references</span>`,
370].filter(Boolean).join("");
371
372let selectedId = null;
373let surfaceFilter = null;
374let mentionPage = 0;
375let sortKey = "mention_count";
376let sortAsc = false;
377let searchQuery = "";
378
379const charTbody = document.getElementById("char-tbody");
380const surfacesEl = document.getElementById("surfaces");
381const mentionsEl = document.getElementById("mentions");
382const searchEl = document.getElementById("char-search");
383const pageSize = REPORT.mentions_per_page || 50;
384
385function filteredCharacters() {{
386  const q = searchQuery.trim().toLowerCase();
387  let chars = REPORT.characters;
388  if (q) {{
389    chars = chars.filter(c => c.name.toLowerCase().includes(q));
390  }}
391  return [...chars].sort((a, b) => {{
392    const av = a[sortKey];
393    const bv = b[sortKey];
394    if (typeof av === "string") {{
395      const cmp = av.localeCompare(bv, undefined, {{ sensitivity: "base" }});
396      return sortAsc ? cmp : -cmp;
397    }}
398    return sortAsc ? av - bv : bv - av;
399  }});
400}}
401
402function renderCharTable() {{
403  const chars = filteredCharacters();
404  charTbody.innerHTML = chars.map(c => `
405    <tr class="char-row${{c.id === selectedId ? " selected" : ""}}" data-id="${{c.id}}">
406      <td>${{escapeHtml(c.name)}}</td>
407      <td class="num">${{c.mention_count}}</td>
408      <td class="num">${{c.unique_forms}}</td>
409    </tr>
410  `).join("");
411}}
412
413function escapeHtml(text) {{
414  return String(text)
415    .replace(/&/g, "&amp;")
416    .replace(/</g, "&lt;")
417    .replace(/>/g, "&gt;")
418    .replace(/"/g, "&quot;");
419}}
420
421function renderSurfaces() {{
422  if (!selectedId) {{
423    surfacesEl.innerHTML = '<p class="placeholder">Select a character to view surface forms.</p>';
424    return;
425  }}
426  const char = REPORT.characters.find(c => c.id === selectedId);
427  if (!char) return;
428
429  const metaParts = [
430    `<span><strong>${{char.mention_count}}</strong> mentions</span>`,
431    `<span><strong>${{char.unique_forms}}</strong> surface forms</span>`,
432  ];
433  if (char.is_important) metaParts.push("<span>important</span>");
434  if (char.gender) metaParts.push(`<span>gender: <strong>${{escapeHtml(char.gender)}}</strong></span>`);
435  if (char.llm_aliases) metaParts.push(`<span>aliases: <strong>${{escapeHtml(char.llm_aliases)}}</strong></span>`);
436  if (char.llm_anti_references) metaParts.push(`<span>anti-refs: <strong>${{escapeHtml(char.llm_anti_references)}}</strong></span>`);
437
438  const surfaceRows = char.surface_forms.map(sf => `
439    <tr class="surface-row${{surfaceFilter === sf.form ? " active" : ""}}" data-form="${{escapeAttr(sf.form)}}">
440      <td>${{escapeHtml(sf.form)}}</td>
441      <td class="num">${{sf.count}}</td>
442      <td class="num">${{sf.share}}%</td>
443    </tr>
444  `).join("");
445
446  surfacesEl.innerHTML = `
447    <h2 class="panel-title">Surface forms</h2>
448    <h2>${{escapeHtml(char.name)}}</h2>
449    <div class="meta">${{metaParts.join("")}}</div>
450  ${{surfaceFilter ? `<p class="filter-hint">Filter: <em>${{escapeHtml(surfaceFilter)}}</em> <button type="button" id="clear-filter">clear</button></p>` : ""}}
451    <table>
452      <thead><tr><th>Form</th><th class="num">Count</th><th class="num">Share</th></tr></thead>
453      <tbody>${{surfaceRows}}</tbody>
454    </table>
455  `;
456
457  surfacesEl.querySelectorAll(".surface-row").forEach(row => {{
458    row.addEventListener("click", () => {{
459      const form = row.getAttribute("data-form");
460      surfaceFilter = surfaceFilter === form ? null : form;
461      mentionPage = 0;
462      renderSurfaces();
463      renderMentions();
464    }});
465  }});
466
467  const clearBtn = document.getElementById("clear-filter");
468  if (clearBtn) {{
469    clearBtn.addEventListener("click", () => {{
470      surfaceFilter = null;
471      mentionPage = 0;
472      renderSurfaces();
473      renderMentions();
474    }});
475  }}
476}}
477
478function renderMentions() {{
479  if (!selectedId) {{
480    mentionsEl.innerHTML = '<p class="placeholder">Mentions in context appear here.</p>';
481    return;
482  }}
483  const char = REPORT.characters.find(c => c.id === selectedId);
484  if (!char) return;
485
486  let mentions = char.mentions;
487  if (surfaceFilter) {{
488    mentions = mentions.filter(m => m.surface === surfaceFilter);
489  }}
490  const totalPages = Math.max(1, Math.ceil(mentions.length / pageSize));
491  if (mentionPage >= totalPages) mentionPage = totalPages - 1;
492  const pageStart = mentionPage * pageSize;
493  const pageMentions = mentions.slice(pageStart, pageStart + pageSize);
494
495  const mentionBlocks = pageMentions.map(m => `
496    <article class="mention">
497      <div class="chapter">${{escapeHtml(m.chapter || "—")}}</div>
498      <div class="surface-tag">${{escapeHtml(m.surface)}}</div>
499      <div class="context">${{m.context_html}}</div>
500    </article>
501  `).join("");
502
503  const filterLabel = surfaceFilter ? ` — ${{escapeHtml(surfaceFilter)}}` : "";
504
505  mentionsEl.innerHTML = `
506    <h2 class="panel-title">Mentions in context</h2>
507    <div class="pager">
508      <button type="button" id="prev-page" ${{mentionPage <= 0 ? "disabled" : ""}}>Previous</button>
509      <span>Page ${{mentionPage + 1}} / ${{totalPages}} (${{mentions.length}} mentions${{filterLabel}})</span>
510      <button type="button" id="next-page" ${{mentionPage >= totalPages - 1 ? "disabled" : ""}}>Next</button>
511    </div>
512    ${{mentionBlocks || '<p class="placeholder">No mentions for this filter.</p>'}}
513  `;
514
515  const prev = document.getElementById("prev-page");
516  const next = document.getElementById("next-page");
517  if (prev) prev.addEventListener("click", () => {{ mentionPage -= 1; renderMentions(); }});
518  if (next) next.addEventListener("click", () => {{ mentionPage += 1; renderMentions(); }});
519}}
520
521function renderDetail() {{
522  renderSurfaces();
523  renderMentions();
524}}
525
526function escapeAttr(text) {{
527  return String(text)
528    .replace(/&/g, "&amp;")
529    .replace(/"/g, "&quot;")
530    .replace(/</g, "&lt;");
531}}
532
533charTbody.addEventListener("click", (ev) => {{
534  const row = ev.target.closest(".char-row");
535  if (!row) return;
536  selectedId = row.getAttribute("data-id");
537  surfaceFilter = null;
538  mentionPage = 0;
539  renderCharTable();
540  renderDetail();
541}});
542
543document.querySelectorAll("#char-table th[data-sort]").forEach(th => {{
544  th.addEventListener("click", () => {{
545    const key = th.getAttribute("data-sort");
546    if (sortKey === key) {{
547      sortAsc = !sortAsc;
548    }} else {{
549      sortKey = key;
550      sortAsc = key === "name";
551    }}
552    renderCharTable();
553  }});
554}});
555
556searchEl.addEventListener("input", () => {{
557  searchQuery = searchEl.value;
558  renderCharTable();
559}});
560
561renderCharTable();
562</script>
563</body>
564</html>"""
565
566
567def visualize_coref(
568    xmi_path: Path,
569    output_path: Path,
570    *,
571    context_chars: int = 100,
572    min_mentions: int = 0,
573) -> Path:
574    """Load an XMI file and write an interactive HTML coref report."""
575    logger.info("Loading {}", xmi_path)
576    doc = UIMADocument.from_xmi(xmi_path)
577    report = build_report_data(doc, context_chars=context_chars, min_mentions=min_mentions)
578    html_content = render_html(report, source_path=str(xmi_path.resolve()))
579    output_path.parent.mkdir(parents=True, exist_ok=True)
580    output_path.write_text(html_content, encoding="utf-8")
581    logger.info(
582        "Wrote {} ({} characters, {} mentions)",
583        output_path,
584        report["summary"]["character_count"],
585        report["summary"]["mention_count"],
586    )
587    return output_path
588
589
590def main() -> int:
591    parser = ArgumentParser(description="Generate an interactive HTML coreference report from an XMI file.")
592    parser.add_argument("--xmi", type=Path, required=True, help="Path to .xmi or .xmi.zip file")
593    parser.add_argument(
594        "-o", "--output",
595        type=Path,
596        default=None,
597        help="Output HTML path (default: <xmi_stem>_coref_viz.html beside input)",
598    )
599    parser.add_argument("--context-chars", type=int, default=100, help="Context window size in characters")
600    parser.add_argument("--min-mentions", type=int, default=0, help="Hide characters with fewer mentions")
601    parser.add_argument("--open", action="store_true", help="Open the report in the default browser")
602    args = parser.parse_args()
603
604    xmi_path = args.xmi.resolve()
605    if not xmi_path.exists():
606        logger.error("XMI file not found: {}", xmi_path)
607        return 1
608
609    if args.output is not None:
610        output_path = args.output.resolve()
611    else:
612        stem = xmi_path.name
613        for suffix in (".xmi.zip", ".xmi"):
614            if stem.endswith(suffix):
615                stem = stem[: -len(suffix)]
616                break
617        output_path = xmi_path.parent / f"{stem}_coref_viz.html"
618
619    visualize_coref(
620        xmi_path,
621        output_path,
622        context_chars=args.context_chars,
623        min_mentions=args.min_mentions,
624    )
625
626    if args.open:
627        webbrowser.open(output_path.as_uri())
628
629    return 0
630
631
632if __name__ == "__main__":
633    sys.exit(main())
MENTIONS_PER_PAGE = 50

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.__int__(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal.

>>> int('0b100', base=0)
4
def build_report_data( doc: wuenlp.impl.uima.UIMANLPStructs.UIMADocument, *, context_chars: int = 100, min_mentions: int = 0) -> dict[str, typing.Any]:
 86def build_report_data(
 87        doc: UIMADocument,
 88        *,
 89        context_chars: int = 100,
 90        min_mentions: int = 0,
 91) -> dict[str, Any]:
 92    """Extract character clusters, surface forms, and in-context mentions from a document."""
 93    doc_text = doc.text
 94    chapter_starts, chapter_titles = _chapter_index(list(doc.chapters))
 95
 96    refs_by_char: dict[str, list[UIMACharacterReference]] = defaultdict(list)
 97    unlinked = 0
 98    for ref in doc.character_references:
 99        entity = ref.referred_entity
100        if entity is None:
101            unlinked += 1
102            continue
103        refs_by_char[str(entity.id)].append(ref)
104
105    characters: list[dict[str, Any]] = []
106    char_by_id: dict[str, UIMACharacter] = {str(c.id): c for c in doc.characters}
107
108    for char_id, char in char_by_id.items():
109        refs = refs_by_char.get(char_id, [])
110        if len(refs) < min_mentions:
111            continue
112
113        refs.sort(key=lambda r: r.begin)
114        surface_counts = Counter(ref.text for ref in refs)
115        total = len(refs)
116
117        surface_forms = [
118            {"form": form, "count": count, "share": round(100 * count / total, 1)}
119            for form, count in surface_counts.most_common()
120        ]
121
122        mentions = []
123        for ref in refs:
124            mentions.append({
125                "surface": ref.text,
126                "begin": ref.begin,
127                "chapter": _chapter_at(ref.begin, chapter_starts, chapter_titles),
128                "context_html": _format_context(
129                    doc_text, ref.begin, ref.end, context_chars=context_chars
130                ),
131            })
132
133        entry: dict[str, Any] = {
134            "id": char_id,
135            "name": char.name or char_id,
136            "mention_count": total,
137            "unique_forms": len(surface_forms),
138            "surface_forms": surface_forms,
139            "mentions": mentions,
140        }
141        entry.update(_character_metadata(char))
142        characters.append(entry)
143
144    characters.sort(key=lambda c: (-c["mention_count"], c["name"].casefold()))
145
146    linked_mentions = sum(c["mention_count"] for c in characters)
147    return {
148        "summary": {
149            "character_count": len(characters),
150            "mention_count": linked_mentions,
151            "unlinked_mentions": unlinked,
152            "total_references": linked_mentions + unlinked,
153        },
154        "characters": characters,
155        "mentions_per_page": MENTIONS_PER_PAGE,
156    }

Extract character clusters, surface forms, and in-context mentions from a document.

def render_html(report: dict[str, typing.Any], *, source_path: str) -> str:
159def render_html(report: dict[str, Any], *, source_path: str) -> str:
160    """Render a self-contained interactive HTML report."""
161    report_json = json.dumps(report, ensure_ascii=False)
162    source_escaped = html.escape(source_path)
163
164    return f"""<!DOCTYPE html>
165<html lang="en">
166<head>
167<meta charset="utf-8">
168<meta name="viewport" content="width=device-width, initial-scale=1">
169<title>Coref report — {source_escaped}</title>
170<style>
171  :root {{
172    --bg: #f6f7f9;
173    --panel: #ffffff;
174    --border: #d8dee6;
175    --text: #1f2933;
176    --muted: #5f6b7a;
177    --accent: #2563eb;
178    --accent-soft: #dbeafe;
179    --mark: #fde68a;
180  }}
181  * {{ box-sizing: border-box; }}
182  body {{
183    margin: 0;
184    font-family: "Segoe UI", system-ui, sans-serif;
185    background: var(--bg);
186    color: var(--text);
187    line-height: 1.45;
188  }}
189  header {{
190    padding: 1rem 1.25rem;
191    background: var(--panel);
192    border-bottom: 1px solid var(--border);
193  }}
194  header h1 {{ margin: 0 0 0.35rem; font-size: 1.25rem; }}
195  header .source {{ color: var(--muted); font-size: 0.9rem; word-break: break-all; }}
196  header .stats {{ margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.9rem; }}
197  .layout {{
198    display: grid;
199    grid-template-columns: minmax(220px, 22%) minmax(220px, 26%) 1fr;
200    gap: 0;
201    min-height: calc(100vh - 88px);
202  }}
203  @media (max-width: 1100px) {{
204    .layout {{ grid-template-columns: 1fr; }}
205  }}
206  .panel {{
207    background: var(--panel);
208    border-right: 1px solid var(--border);
209    padding: 1rem;
210    overflow: auto;
211    min-height: calc(100vh - 88px);
212  }}
213  .panel:last-child {{ border-right: none; }}
214  .panel-title {{
215    margin: 0 0 0.75rem;
216    font-size: 0.85rem;
217    font-weight: 600;
218    text-transform: uppercase;
219    letter-spacing: 0.04em;
220    color: var(--muted);
221  }}
222  .search {{
223    width: 100%;
224    padding: 0.5rem 0.65rem;
225    border: 1px solid var(--border);
226    border-radius: 6px;
227    margin-bottom: 0.75rem;
228    font-size: 0.95rem;
229  }}
230  table {{
231    width: 100%;
232    border-collapse: collapse;
233    font-size: 0.9rem;
234  }}
235  th, td {{
236    text-align: left;
237    padding: 0.4rem 0.35rem;
238    border-bottom: 1px solid var(--border);
239  }}
240  th {{
241    position: sticky;
242    top: 0;
243    background: var(--panel);
244    cursor: pointer;
245    user-select: none;
246  }}
247  th:hover {{ color: var(--accent); }}
248  tr.char-row {{ cursor: pointer; }}
249  tr.char-row:hover {{ background: #f0f4f8; }}
250  tr.char-row.selected {{ background: var(--accent-soft); }}
251  tr.surface-row {{ cursor: pointer; }}
252  tr.surface-row:hover {{ background: #f0f4f8; }}
253  tr.surface-row.active {{ background: #fef3c7; }}
254  .placeholder {{
255    color: var(--muted);
256    padding: 2rem 0;
257  }}
258  .meta {{
259    display: flex;
260    flex-wrap: wrap;
261    gap: 0.5rem 1rem;
262    margin: 0.5rem 0 1rem;
263    font-size: 0.9rem;
264    color: var(--muted);
265  }}
266  .meta strong {{ color: var(--text); }}
267  h2 {{ margin: 0 0 0.75rem; font-size: 1.15rem; }}
268  h3 {{ margin: 1.25rem 0 0.5rem; font-size: 1rem; }}
269  .mention {{
270    border: 1px solid var(--border);
271    border-radius: 8px;
272    padding: 0.65rem 0.75rem;
273    margin-bottom: 0.6rem;
274    background: #fafbfc;
275  }}
276  .mention .chapter {{
277    font-size: 0.8rem;
278    color: var(--muted);
279    margin-bottom: 0.25rem;
280  }}
281  .mention .surface-tag {{
282    display: inline-block;
283    font-size: 0.75rem;
284    background: var(--accent-soft);
285    color: var(--accent);
286    padding: 0.1rem 0.4rem;
287    border-radius: 4px;
288    margin-bottom: 0.35rem;
289  }}
290  .mention .context {{
291    font-family: ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
292    font-size: 0.82rem;
293    white-space: pre-wrap;
294    word-break: break-word;
295  }}
296  mark {{
297    background: var(--mark);
298    padding: 0 0.1rem;
299    border-radius: 2px;
300  }}
301  .pager {{
302    display: flex;
303    align-items: center;
304    gap: 0.75rem;
305    margin: 0.75rem 0 1rem;
306    font-size: 0.9rem;
307  }}
308  .pager button {{
309    padding: 0.35rem 0.7rem;
310    border: 1px solid var(--border);
311    border-radius: 6px;
312    background: var(--panel);
313    cursor: pointer;
314  }}
315  .pager button:disabled {{ opacity: 0.45; cursor: default; }}
316  .num {{ text-align: right; font-variant-numeric: tabular-nums; }}
317  .filter-hint {{
318    font-size: 0.9rem;
319    color: var(--muted);
320    margin: 0 0 0.75rem;
321  }}
322  .filter-hint button {{
323    padding: 0.2rem 0.5rem;
324    border: 1px solid var(--border);
325    border-radius: 4px;
326    background: var(--panel);
327    cursor: pointer;
328    font-size: 0.8rem;
329  }}
330</style>
331</head>
332<body>
333<header>
334  <h1>Coreference report</h1>
335  <div class="source">{source_escaped}</div>
336  <div class="stats" id="summary-stats"></div>
337</header>
338<div class="layout">
339  <section class="panel">
340    <h2 class="panel-title">Characters</h2>
341    <input class="search" id="char-search" type="search" placeholder="Filter characters…" autocomplete="off">
342    <table id="char-table">
343      <thead>
344        <tr>
345          <th data-sort="name">Character</th>
346          <th data-sort="mention_count" class="num">Mentions</th>
347          <th data-sort="unique_forms" class="num">Forms</th>
348        </tr>
349      </thead>
350      <tbody id="char-tbody"></tbody>
351    </table>
352  </section>
353  <section class="panel" id="surfaces">
354    <p class="placeholder">Select a character to view surface forms.</p>
355  </section>
356  <section class="panel" id="mentions">
357    <p class="placeholder">Mentions in context appear here.</p>
358  </section>
359</div>
360<script>
361const REPORT = {report_json};
362
363const summary = REPORT.summary;
364document.getElementById("summary-stats").innerHTML = [
365  `<span><strong>${{summary.character_count}}</strong> characters</span>`,
366  `<span><strong>${{summary.mention_count}}</strong> linked mentions</span>`,
367  summary.unlinked_mentions
368    ? `<span><strong>${{summary.unlinked_mentions}}</strong> unlinked</span>`
369    : "",
370  `<span><strong>${{summary.total_references}}</strong> total references</span>`,
371].filter(Boolean).join("");
372
373let selectedId = null;
374let surfaceFilter = null;
375let mentionPage = 0;
376let sortKey = "mention_count";
377let sortAsc = false;
378let searchQuery = "";
379
380const charTbody = document.getElementById("char-tbody");
381const surfacesEl = document.getElementById("surfaces");
382const mentionsEl = document.getElementById("mentions");
383const searchEl = document.getElementById("char-search");
384const pageSize = REPORT.mentions_per_page || 50;
385
386function filteredCharacters() {{
387  const q = searchQuery.trim().toLowerCase();
388  let chars = REPORT.characters;
389  if (q) {{
390    chars = chars.filter(c => c.name.toLowerCase().includes(q));
391  }}
392  return [...chars].sort((a, b) => {{
393    const av = a[sortKey];
394    const bv = b[sortKey];
395    if (typeof av === "string") {{
396      const cmp = av.localeCompare(bv, undefined, {{ sensitivity: "base" }});
397      return sortAsc ? cmp : -cmp;
398    }}
399    return sortAsc ? av - bv : bv - av;
400  }});
401}}
402
403function renderCharTable() {{
404  const chars = filteredCharacters();
405  charTbody.innerHTML = chars.map(c => `
406    <tr class="char-row${{c.id === selectedId ? " selected" : ""}}" data-id="${{c.id}}">
407      <td>${{escapeHtml(c.name)}}</td>
408      <td class="num">${{c.mention_count}}</td>
409      <td class="num">${{c.unique_forms}}</td>
410    </tr>
411  `).join("");
412}}
413
414function escapeHtml(text) {{
415  return String(text)
416    .replace(/&/g, "&amp;")
417    .replace(/</g, "&lt;")
418    .replace(/>/g, "&gt;")
419    .replace(/"/g, "&quot;");
420}}
421
422function renderSurfaces() {{
423  if (!selectedId) {{
424    surfacesEl.innerHTML = '<p class="placeholder">Select a character to view surface forms.</p>';
425    return;
426  }}
427  const char = REPORT.characters.find(c => c.id === selectedId);
428  if (!char) return;
429
430  const metaParts = [
431    `<span><strong>${{char.mention_count}}</strong> mentions</span>`,
432    `<span><strong>${{char.unique_forms}}</strong> surface forms</span>`,
433  ];
434  if (char.is_important) metaParts.push("<span>important</span>");
435  if (char.gender) metaParts.push(`<span>gender: <strong>${{escapeHtml(char.gender)}}</strong></span>`);
436  if (char.llm_aliases) metaParts.push(`<span>aliases: <strong>${{escapeHtml(char.llm_aliases)}}</strong></span>`);
437  if (char.llm_anti_references) metaParts.push(`<span>anti-refs: <strong>${{escapeHtml(char.llm_anti_references)}}</strong></span>`);
438
439  const surfaceRows = char.surface_forms.map(sf => `
440    <tr class="surface-row${{surfaceFilter === sf.form ? " active" : ""}}" data-form="${{escapeAttr(sf.form)}}">
441      <td>${{escapeHtml(sf.form)}}</td>
442      <td class="num">${{sf.count}}</td>
443      <td class="num">${{sf.share}}%</td>
444    </tr>
445  `).join("");
446
447  surfacesEl.innerHTML = `
448    <h2 class="panel-title">Surface forms</h2>
449    <h2>${{escapeHtml(char.name)}}</h2>
450    <div class="meta">${{metaParts.join("")}}</div>
451  ${{surfaceFilter ? `<p class="filter-hint">Filter: <em>${{escapeHtml(surfaceFilter)}}</em> <button type="button" id="clear-filter">clear</button></p>` : ""}}
452    <table>
453      <thead><tr><th>Form</th><th class="num">Count</th><th class="num">Share</th></tr></thead>
454      <tbody>${{surfaceRows}}</tbody>
455    </table>
456  `;
457
458  surfacesEl.querySelectorAll(".surface-row").forEach(row => {{
459    row.addEventListener("click", () => {{
460      const form = row.getAttribute("data-form");
461      surfaceFilter = surfaceFilter === form ? null : form;
462      mentionPage = 0;
463      renderSurfaces();
464      renderMentions();
465    }});
466  }});
467
468  const clearBtn = document.getElementById("clear-filter");
469  if (clearBtn) {{
470    clearBtn.addEventListener("click", () => {{
471      surfaceFilter = null;
472      mentionPage = 0;
473      renderSurfaces();
474      renderMentions();
475    }});
476  }}
477}}
478
479function renderMentions() {{
480  if (!selectedId) {{
481    mentionsEl.innerHTML = '<p class="placeholder">Mentions in context appear here.</p>';
482    return;
483  }}
484  const char = REPORT.characters.find(c => c.id === selectedId);
485  if (!char) return;
486
487  let mentions = char.mentions;
488  if (surfaceFilter) {{
489    mentions = mentions.filter(m => m.surface === surfaceFilter);
490  }}
491  const totalPages = Math.max(1, Math.ceil(mentions.length / pageSize));
492  if (mentionPage >= totalPages) mentionPage = totalPages - 1;
493  const pageStart = mentionPage * pageSize;
494  const pageMentions = mentions.slice(pageStart, pageStart + pageSize);
495
496  const mentionBlocks = pageMentions.map(m => `
497    <article class="mention">
498      <div class="chapter">${{escapeHtml(m.chapter || "—")}}</div>
499      <div class="surface-tag">${{escapeHtml(m.surface)}}</div>
500      <div class="context">${{m.context_html}}</div>
501    </article>
502  `).join("");
503
504  const filterLabel = surfaceFilter ? ` — ${{escapeHtml(surfaceFilter)}}` : "";
505
506  mentionsEl.innerHTML = `
507    <h2 class="panel-title">Mentions in context</h2>
508    <div class="pager">
509      <button type="button" id="prev-page" ${{mentionPage <= 0 ? "disabled" : ""}}>Previous</button>
510      <span>Page ${{mentionPage + 1}} / ${{totalPages}} (${{mentions.length}} mentions${{filterLabel}})</span>
511      <button type="button" id="next-page" ${{mentionPage >= totalPages - 1 ? "disabled" : ""}}>Next</button>
512    </div>
513    ${{mentionBlocks || '<p class="placeholder">No mentions for this filter.</p>'}}
514  `;
515
516  const prev = document.getElementById("prev-page");
517  const next = document.getElementById("next-page");
518  if (prev) prev.addEventListener("click", () => {{ mentionPage -= 1; renderMentions(); }});
519  if (next) next.addEventListener("click", () => {{ mentionPage += 1; renderMentions(); }});
520}}
521
522function renderDetail() {{
523  renderSurfaces();
524  renderMentions();
525}}
526
527function escapeAttr(text) {{
528  return String(text)
529    .replace(/&/g, "&amp;")
530    .replace(/"/g, "&quot;")
531    .replace(/</g, "&lt;");
532}}
533
534charTbody.addEventListener("click", (ev) => {{
535  const row = ev.target.closest(".char-row");
536  if (!row) return;
537  selectedId = row.getAttribute("data-id");
538  surfaceFilter = null;
539  mentionPage = 0;
540  renderCharTable();
541  renderDetail();
542}});
543
544document.querySelectorAll("#char-table th[data-sort]").forEach(th => {{
545  th.addEventListener("click", () => {{
546    const key = th.getAttribute("data-sort");
547    if (sortKey === key) {{
548      sortAsc = !sortAsc;
549    }} else {{
550      sortKey = key;
551      sortAsc = key === "name";
552    }}
553    renderCharTable();
554  }});
555}});
556
557searchEl.addEventListener("input", () => {{
558  searchQuery = searchEl.value;
559  renderCharTable();
560}});
561
562renderCharTable();
563</script>
564</body>
565</html>"""

Render a self-contained interactive HTML report.

def visualize_coref( xmi_path: pathlib.Path, output_path: pathlib.Path, *, context_chars: int = 100, min_mentions: int = 0) -> pathlib.Path:
568def visualize_coref(
569    xmi_path: Path,
570    output_path: Path,
571    *,
572    context_chars: int = 100,
573    min_mentions: int = 0,
574) -> Path:
575    """Load an XMI file and write an interactive HTML coref report."""
576    logger.info("Loading {}", xmi_path)
577    doc = UIMADocument.from_xmi(xmi_path)
578    report = build_report_data(doc, context_chars=context_chars, min_mentions=min_mentions)
579    html_content = render_html(report, source_path=str(xmi_path.resolve()))
580    output_path.parent.mkdir(parents=True, exist_ok=True)
581    output_path.write_text(html_content, encoding="utf-8")
582    logger.info(
583        "Wrote {} ({} characters, {} mentions)",
584        output_path,
585        report["summary"]["character_count"],
586        report["summary"]["mention_count"],
587    )
588    return output_path

Load an XMI file and write an interactive HTML coref report.

def main() -> int:
591def main() -> int:
592    parser = ArgumentParser(description="Generate an interactive HTML coreference report from an XMI file.")
593    parser.add_argument("--xmi", type=Path, required=True, help="Path to .xmi or .xmi.zip file")
594    parser.add_argument(
595        "-o", "--output",
596        type=Path,
597        default=None,
598        help="Output HTML path (default: <xmi_stem>_coref_viz.html beside input)",
599    )
600    parser.add_argument("--context-chars", type=int, default=100, help="Context window size in characters")
601    parser.add_argument("--min-mentions", type=int, default=0, help="Hide characters with fewer mentions")
602    parser.add_argument("--open", action="store_true", help="Open the report in the default browser")
603    args = parser.parse_args()
604
605    xmi_path = args.xmi.resolve()
606    if not xmi_path.exists():
607        logger.error("XMI file not found: {}", xmi_path)
608        return 1
609
610    if args.output is not None:
611        output_path = args.output.resolve()
612    else:
613        stem = xmi_path.name
614        for suffix in (".xmi.zip", ".xmi"):
615            if stem.endswith(suffix):
616                stem = stem[: -len(suffix)]
617                break
618        output_path = xmi_path.parent / f"{stem}_coref_viz.html"
619
620    visualize_coref(
621        xmi_path,
622        output_path,
623        context_chars=args.context_chars,
624        min_mentions=args.min_mentions,
625    )
626
627    if args.open:
628        webbrowser.open(output_path.as_uri())
629
630    return 0