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, "&") 416 .replace(/</g, "<") 417 .replace(/>/g, ">") 418 .replace(/"/g, """); 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, "&") 529 .replace(/"/g, """) 530 .replace(/</g, "<"); 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())
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
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.
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, "&") 417 .replace(/</g, "<") 418 .replace(/>/g, ">") 419 .replace(/"/g, """); 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, "&") 530 .replace(/"/g, """) 531 .replace(/</g, "<"); 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.
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.
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