Context Chips and Reader Excerpts Architecture¶
This document details the architectural design, data contracts, DOM construction, and agent integration for the context selection systems in Zocrates. The architecture unifies macro-level context selection (entire papers, collections, or whole libraries) with micro-focal excerpt selection (text quotations and visual area crops captured directly from Zotero's built-in PDF Reader).
1. System Overview & Architectural Motivation¶
Scholarly research workflows operate at two distinct granularities:
- Macro-Level Context: Researchers converse against broad literature corpora—querying an entire Zotero library, a specific topical collection, or a curated set of multi-selected papers.
- Micro-Focal Context: Researchers reading literature inside Zotero's built-in PDF Reader encounter specific mathematical formulations, algorithmic pseudocode, method paragraphs, architecture diagrams, or benchmark tables. In this mode, they require immediate analysis of the focal excerpt (e.g., "Explain the notation in this equation" or "What trade-offs does this ablation curve illustrate?").
Zocrates bridges these two workflows by hosting both macro-level Context Chips and micro-focal Attachment Context Pills inside a unified context panel (#chips-container) directly above the chat input box.
sequenceDiagram
autonumber
actor User as Researcher
participant Reader as Zotero PDF Reader
participant Bridge as ReaderContext (Gecko Bridge)
participant Sidebar as Zocrates Sidebar (sidebar.js)
participant Backend as FastAPI Backend (:8765)
participant ADK as Coordinator Agent (Google ADK)
alt Micro-Focal Selection: Text or Visual Excerpt
User->>Reader: Selects text or draws area box
User->>Reader: Right-clicks -> "Zocrates: Add to Context"
Reader->>Bridge: createViewContextMenu / createAnnotationContextMenu
Bridge->>Bridge: Extract text or render canvas crop (<150KB JPEG)
Bridge->>Sidebar: Zotero.Zocrates.addSnippetToContext(snippet)
Sidebar->>Sidebar: Auto-switch to "papers" mode, anchor parent paper chip, render snippet pill
else Macro Selection: Library Tree Click
User->>Sidebar: Clicks items in Zotero library pane
Sidebar->>Sidebar: 400ms polling checks ZoteroPane.getSelectedItems()
Sidebar->>Sidebar: Map attachments to parents, deduplicate, render context chips
end
User->>Sidebar: Enters prompt and clicks Send
Sidebar->>Backend: POST /chat { prompt, active_items, snippets, mode }
alt Coordinator Model is Vision-Capable
Backend->>ADK: new_message with types.Part.from_bytes(image) + text
else Coordinator Model is Text-Only
Backend->>ADK: new_message with <image_snippet> metadata tag + text
Backend-->>Sidebar: SSE notice: Model is text-only
end
ADK-->>Sidebar: Stream synthesized response
Sidebar->>Sidebar: finalizeChatStream: Auto-clear snippet pills from input bar
Note over Sidebar,ADK: Snippets persisted in user_evt.actions.state_delta["snippets"] for multi-turn history
2. Macro Selection: Library Context Chips¶
Context chips represent Zotero library items selected by the researcher to define the active retrieval boundary.
2.1 Focus-Safe High-Frequency Polling¶
The sidebar chat interface runs inside an iframe embedded within Zotero's main window. When a researcher clicks inside the chat textarea, focus shifts away from Zotero's main library tree, which causes native selection APIs (such as ZoteroPane.getSelectedItems()) to return an empty array.
To preserve selection state without relying on invasive monkey-patching of Zotero internals, selection capture is managed by a focus-aware polling engine:
- Polling Cadence: An internal timer polls every 400 milliseconds to query the parent window's
ZoteroPane.getSelectedItems(). - Parent Item Mapping: If a child attachment (such as an imported PDF or extracted note) is highlighted, the engine automatically walks the hierarchy to its parent bibliographic item (
item.parentItem). Standalone top-level attachments without parents are preserved as their own context chips. - Deduplication: Selected items are deduplicated by their unique database keys to prevent duplicate chips when both a parent paper and its child attachment are simultaneously selected.
- Focus Safety via
document.hasFocus(): When the user clicks or types inside the chat sidebar, causinggetSelectedItems()to report empty, the engine evaluatesdocument.hasFocus(). If the sidebar has active focus, the system retains the existing chips rather than clearing them. - Explicit Clearing: Chips are cleared only when the user deliberately clicks empty canvas space in Zotero's main library window, which causes
document.hasFocus()to returnfalsewhile the selection list is empty.
2.2 Sticky Mode (Pinned Context)¶
Researchers frequently need to assemble a working set of papers scattered across different collections. Sticky Mode allows non-contiguous accumulation of context items:
- Toggle Action: Activated via the thumbtack button (
#toggle-sticky-btn) on the context panel header. - Accumulation & Deduplication: Clicking different items in Zotero continuously appends them to the active chip list. Items already present in
activeItemsList(matched by database key) are skipped. - Re-selecting Removed Papers: If a chip was dismissed via its
✕button, re-selecting that item in Zotero while in Sticky Mode removes its key from the internalremovedKeysset and restores the chip. - Selection Immunity: Deselecting items or clicking empty space in the library tree does not clear accumulated chips while Sticky Mode is active.
- Mode Deactivation: Toggling Sticky Mode off retains the accumulated chips until the next selection change event in Zotero, which then synchronizes the chip list to the immediate selection.
2.3 DOM Construction & XSS Sanitization¶
All chip elements are constructed dynamically using standard DOM APIs:
const titleSpan = document.createElement("span");
titleSpan.className = "chip-text";
titleSpan.textContent = paper.title; // Safe against script and markup injection
chip.appendChild(titleSpan);
When rendering markdown responses from the AI backend:
- Markdown is parsed via
marked.parse(). - Raw HTML is filtered through an allowlist sanitizer (
sanitizeHTML()) that purges dangerous tags (<script>,<iframe>,<object>,<embed>,<form>), inline event handlers (on*), and dangerous URI schemes (javascript:,data:on anchor tags). - The sanitized HTML is inserted into the chat bubble.
2.4 Chip Styling & Theming¶
Chips adapt dynamically to Zotero's interface themes using CSS custom properties:
--chip-bg,--chip-border, and--chip-hover-bgcontrol palette shifts across Dark, Slate, Amoled, Sepia, and Light themes.- Font stacks inherit
system-uideclarations to match host operating system typography. - Individual chips feature an
✕button that appends the target key toremovedKeysand filters the DOM. - A "Clear All" action appears when two or more chips are present. Clicking it purges the active set and synchronizes the baseline selection state to prevent the 400ms polling cycle from immediately re-adding the highlighted items.
2.5 Generation Cancellation (Stop Button)¶
During streaming responses:
- The send button (
#send-btn) switches to a pulsing red stop icon via the.stop-modeclass. - An
AbortControllersignal is bound to the activefetch()request. - Clicking Stop aborts the network connection, finalizes the partial text, and appends a
[Generation stopped by user]notice. - While a generation is in progress, secondary submissions are locked.
3. Micro-Focal Selection: PDF Reader Bridge (readerContext.js)¶
The readerContext.js bridge registers declarative event hooks with Zotero's built-in PDF Reader to extract selected text and visual area crops directly into the conversational context.
┌────────────────────────────────────────────────────────────────────────┐
│ Zotero 10 PDF Reader │
│ ┌────────────────────────┐ ┌───────────────────────────────┐ │
│ │ Text Selection │ │ Area Annotation Box │ │
│ │ "Attention weights" │ │ [Architecture Diagram Crop] │ │
│ └───────────┬────────────┘ └───────────────┬───────────────┘ │
└──────────────┼──────────────────────────────────────┼──────────────────┘
│ createViewContextMenu │ createAnnotationContextMenu
▼ ▼
┌────────────────────────────────────────────────────────────────────────┐
│ ReaderContext Bridge (readerContext.js) │
│ • Resolves reader.item & parent paper bibliographic metadata │
│ • Extracts 1-indexed page index │
│ • Text: Normalizes whitespace and builds text snippet payload │
│ • Area: Offscreen canvas extraction -> JPEG (<150KB base64) │
│ • modifyAnnotation: Listens for resize events and updates thumbnails │
└──────────────────────────────────────┬─────────────────────────────────┘
│ Zotero.Zocrates.addSnippetToContext()
▼
┌────────────────────────────────────────────────────────────────────────┐
│ Zocrates Sidebar (#chips-container) │
│ • Auto-switches active context mode to "papers" │
│ • Auto-anchors parent paper chip in activeItemsList │
│ • Renders turn-sticky snippet pill with page badge & popover │
└────────────────────────────────────────────────────────────────────────┘
3.1 Declarative Reader Registration¶
Extensions register hooks with the PDF Reader declaratively using Zotero.Reader.registerEventListener with the extension identifier:
const ReaderContext = {
init() {
// 1. Viewport context menu (text selection or canvas right-click)
Zotero.Reader.registerEventListener(
"createViewContextMenu",
this._handleViewContextMenu,
PLUGIN_ID
);
// 2. Annotation context menu (right-clicking existing annotations)
Zotero.Reader.registerEventListener(
"createAnnotationContextMenu",
this._handleAnnotationContextMenu,
PLUGIN_ID
);
}
};
3.2 Text Selection Extraction¶
When a researcher right-clicks highlighted text in the reader:
ReaderContext.handleViewContextMenuinspectsreader._internalReader.getSelectedText()or the event's selection payload.- The bridge resolves the reader's underlying item (
reader.item), its top-level bibliographic parent (item.parentItem || item), publication year, and primary author (e.g.,"Vaswani et al."). - Extracts the 1-indexed page number (
reader._internalReader._state.pageIndex + 1). - Generates a client identifier (
snip_${Date.now()}_${random}) and passes the structured snippet object to the sidebar:
Zotero.Zocrates.addSnippetToContext({
id: `snip_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
type: "text",
text: selectedText.trim(),
page: pageNumber,
paperTitle: paper.getField("title"),
authors: formattedAuthors,
parentKey: parentPaper.key,
itemKey: attachmentItem.key
});
3.3 Visual Area Crop Extraction & Canvas Rendering¶
When a researcher right-clicks a drawn area box annotation:
ReaderContext.handleAnnotationContextMenudetects an area selection (annotation.type === "image"or"box").- The bridge extracts the bounding box coordinates (
rectsorposition). - It renders the bounding box region from the underlying PDF canvas using
reader._internalReader.renderAnnotationImage(annotation)or offscreen canvas extraction. - The canvas crop is compressed to JPEG format (
image/jpeg, quality0.85), targeting compact base64 data URLs (~50–150 KB) with clamped dimensions (maxDim = 1200) to keep SSE payloads fast and lightweight.
3.4 Live Area-Resize Auto-Update¶
If a researcher resizes or repositions an area box annotation after adding it to the context panel:
ReaderContextobserves annotation update events viaZotero.Notifier.registerObserver(this, ["item"], "zocrates-area-resize-watcher")on"modify"actions.- It queries the in-memory reader state (
reader._internalReader._state.annotations). - A retry loop with dual ID/key matching captures the final rendered bounding rects once drag handles are released.
- The bridge re-renders the canvas crop and broadcasts via
Zotero.Zocrates.updateSnippet(updatedPayload), whichsidebar.js::handleSnippetUpdatereceives to update both the DOM thumbnail and the outgoing JSON payload in real time.
4. Context Mode Override & Turn Lifecycle¶
4.1 Automatic Scope Anchoring¶
Adding a text or area excerpt initiates an automatic scope anchoring sequence:
- Parent Paper Auto-Selection: The parent bibliographic item is automatically resolved and added to
activeItemsListif not already present. - Context Mode Override: If the sidebar was configured in
"collection"or"library"context mode, it automatically switches to"papers"mode. This guarantees that coordinator instructions and retrieval tools focus specifically on the document containing the excerpt.
4.2 Turn-Sticky Lifecycle & Stream Cancellation Safety¶
Unlike macro library selections that change whenever the user clicks different items in Zotero's library pane, snippet pills follow a turn-sticky lifecycle:
- Tree Click Immunity: Clicking around the library does not clear active snippet pills.
- Preservation on Stream Abort: If a generation is cancelled by clicking Stop (
isAborted = true), the snippet pills inactiveSnippetsListremain in#chips-container. The user can adjust their prompt and re-submit without re-selecting the excerpt in the reader. - Auto-Clear on Completion: When a turn completes successfully (
finalizeChatStreamwith!isAborted), snippet pills are cleared from#chips-containerandactiveSnippetsListis reset. - Session Purge: Creating a new session or loading a different session purges any unsubmitted snippet pills.
4.3 History Inspection & Rewind Restoration¶
- Message Info Modal: Clicking the info icon on any assistant or user message opens a metadata modal featuring a dedicated Focal Snippets panel alongside active context chips. Text snippets show the full quotation with page attribution; image snippets show a thumbnail preview and page number.
- Turn Rewind & Inline Edit: Triggering a rerun or inline edit on a previous turn restores that specific turn's
activeItemsList,activeSnippetsList, andcontextMode, reconstructing the exact conversational state.
5. Backend Data Contracts & Key Visibility Invariant¶
5.1 Request Schemas (zocrates/schemas/item_request.py)¶
Snippet items are transmitted in POST /chat payloads alongside standard active items:
class SnippetItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
type: Literal["text", "image"] = Field(..., description="Snippet type: text quote or image area")
id: str | None = Field(default=None, description="Unique client identifier")
text: str | None = Field(default=None, description="Extracted text quotation")
image_data: str | None = Field(
default=None,
max_length=5_000_000,
validation_alias=AliasChoices("image_data", "imageData"),
description="Base64 data URL for image snippet",
)
page: int | str | None = Field(default=None, description="Document page number")
paper_title: str | None = Field(
default=None,
validation_alias=AliasChoices("paper_title", "paperTitle"),
description="Source document title",
)
authors: str | None = Field(default=None, description="Author attribution string")
parent_key: str | None = Field(
default=None,
validation_alias=AliasChoices("parent_key", "parentKey"),
description="Parent top-level item key",
)
item_key: str | None = Field(
default=None,
validation_alias=AliasChoices("item_key", "itemKey"),
description="Direct attachment item key",
)
To prevent validation failures when restoring sessions where keys were serialized differently, ActiveItem supports multi-alias resolution:
class ActiveItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
key: str = Field(
...,
validation_alias=AliasChoices("key", "item_key", "itemKey"),
description="Zotero item or collection key",
)
title: str = Field(..., description="Title of item or collection")
item_type: str = Field(
default="item",
validation_alias=AliasChoices("item_type", "type"),
description="Type: 'item' or 'collection'",
)
5.2 Internal Key Visibility Invariant¶
A critical requirement of academic grounding is preventing models from hallucinating database keys or emitting raw attachment identifiers as citations. Zocrates enforces a strict visibility boundary:
- Coordinator and Synthesizer Agents: The helper
strip_snippet_keys(focal_snippets_str)stripskey="..."attributes before injecting context into the coordinator and synthesizer system prompts. These agents only see clean bibliographic metadata: - Retrieval Subagents (
library_search_agent): Retainskey="..."attributes (include_keys=True) so low-level retrieval tools (read_document,get_document_toc) have exact database identifiers to target specific document bundles. - Canonical Storage: The raw event stored in
sessions.dbpreserves the full key metadata across all modalities.
6. Multimodal Routing & Vision Capability Detection¶
In zocrates/fast_api_app.py, incoming snippets are routed according to the capabilities of the active model:
formatted_context, inline_image_parts, notices = await _process_request_snippets(
request.snippets,
coord_model=coord_model,
zotero_dir=zotero_dir,
)
6.1 Vision-Capable Models¶
When the coordinator model supports vision (e.g., Gemini, GPT-6, Claude Sonnet, Qwen-VL):
- The base64 JPEG data URL is decoded into raw binary bytes.
- Google ADK
types.Part.from_bytes(data=raw_bytes, mime_type=mime_type)objects are appended directly to the outgoing message parts. - A semantic XML tag is added to the text prompt:
6.2 Text-Only Models (Zero-Latency Notice Tag)¶
Calling secondary Vision-Language Models (VLMs) during an interactive chat turn introduces latency (10–15s timeouts), temporary disk files, and failure points. When the active model does not support vision:
- The backend immediately generates an informative untranscribed tag preserving all paper, author, and page metadata:
- Emits an SSE notice event alerting the researcher:
"Image area attached, but active model is text-only. The agent has been notified." - Returns in
<0.1mswithout blocking the chat turn.
6.3 Four-Tier Vision Capability Detection¶
To accurately determine whether a model supports vision without relying on static hardcoded lists, is_model_vision_capable uses a four-tier evaluation:
- Tier 1 (Authoritative OpenRouter Catalog Lookup): For OpenRouter models, inspects the cached OpenRouter model catalog (
llm_models.json). If the model entry exists, checksinput_modalitiesdirectly. If"image"is present, returnsTrue. If the model is cataloged without image input support, returnsFalseimmediately, eliminating false positives. - Tier 2 (Structural Indicators): Evaluates whether the model name contains explicit structural keywords (
vision,multimodal,vlm) or-vlsuffix patterns. - Tier 3 (Multimodal Family Regex): Evaluates regex patterns covering vision-capable families (Gemini, Claude, GPT, OpenAI o-series excluding mini, GLM vision, Grok vision, Amazon Nova, Qwen VL/Flash, Pixtral, Omni, Molmo, InternVL, MiniCPM, CogVLM).
- Tier 4 (LiteLLM Fallback): For direct cloud providers (Azure, Bedrock, Vertex AI), delegates to
litellm.supports_vision(...).
7. Google ADK Active Task Scope Rewind Patch¶
7.1 The Rewind Bug Mechanism in Upstream ADK¶
When a conversational turn containing an in-flight subagent pipeline (isolation_scope = "search_library_pipeline:...") is rewound before the subagent completes:
- Google ADK immutably records the subagent events in SQLite and writes a rewind marker (
rewind_before_invocation_id). - On the subsequent user turn, ADK's
Runner._append_user_eventcalls_find_active_task_scope(session). - In Google ADK 2.8.0,
_find_active_task_scopeiteratessession.eventsdirectly without applying rewind filtering (_apply_rewinds). - Seeing the unfinished subagent event from the rewound turn, ADK mistakenly assumes the subagent is an active paused task awaiting user input, and stamps the new user event with
isolation_scope = "search_library_pipeline:...". - When the coordinator agent runs (
isolation_scope = None), ADK's content filter drops the user event, hiding the researcher's prompt from the model.
7.2 Compatibility Patch (zocrates/agents/common/adk_compat.py)¶
Zocrates patches google.adk.runners._find_active_task_scope at application startup:
def patch_adk_active_task_scope_rewind() -> None:
global _adk_active_task_scope_rewind_patched
if _adk_active_task_scope_rewind_patched:
return
import google.adk.runners as adk_runners
from google.adk.flows.llm_flows.contents import _apply_rewinds
orig_find_active_task_scope = adk_runners._find_active_task_scope
class _RewoundSessionProxy:
def __init__(self, session: Any, live_events: list[Any]) -> None:
self._session = session
self.events = live_events
def __getattr__(self, name: str) -> Any:
return getattr(self._session, name)
def patched_find_active_task_scope(session: Any) -> tuple[str, str] | None:
if not session or getattr(session, "events", None) is None:
return None
live_events = _apply_rewinds(session.events)
proxy = _RewoundSessionProxy(session, live_events)
return orig_find_active_task_scope(proxy)
adk_runners._find_active_task_scope = patched_find_active_task_scope
_adk_active_task_scope_rewind_patched = True
The patch applies _apply_rewinds before computing the active task scope. Unrewound active tasks (such as Human-In-The-Loop confirmation checkpoints) continue to operate normally, while rewound tasks return None, restoring normal coordinator dispatch.
8. Adaptive Retrieval Dispatch Rules¶
The coordinator agent's system instructions enforce an adaptive retrieval strategy when focal snippets are present:
- Self-Contained Excerpts: If the researcher's query can be fully answered using the focal snippet alone (e.g., explaining notation in a specific equation, translating a sentence, or interpreting a clear chart trend), the coordinator answers directly with zero tool overhead and zero numeric citation brackets (
[1]). - Context-Dependent Inquiries: If resolving the question requires information beyond the snippet (e.g., experimental parameters described in an earlier section, mathematical proofs in an appendix, or comparisons to other library items), the coordinator invokes
search_libraryorsearch_online_literature, passing the focal excerpt and paper title into the retrieval pipeline.
9. Highlight-Free Best-Effort Section Heading Navigation¶
In addition to micro-focal excerpt capture from the reader into chat, Zocrates provides a complementary reverse bridge: when researchers inspect citation pills in chat, clicking Open in Reader navigates directly to the cited section within Zotero's built-in reader.
9.1 The "Best-Effort" Philosophy & Invariants¶
Navigating academic literature inside PDF and HTML readers presents fundamental layout and search challenges:
* Raw in-PDF text search (FindController) often matches common phrases elsewhere in the document, wraps across multi-column layouts unpredictably, fails across line breaks or soft hyphens, and leaves distracting yellow highlight overlays on the screen.
* Modifying user annotations to insert artificial anchors pollutes the user's permanent SQLite database (zotero.sqlite).
To maintain strict reading ergonomics and database purity, Zocrates implements Highlight-Free Best-Effort Section Navigation:
1. Best-Effort Navigation: The system navigates to the cited section heading whenever a structural document outline (for PDFs) or semantic heading element (for HTML snapshots) is available. If an outline is absent, unindexed, or does not contain the heading, the document cleanly opens at its standard/last-viewed position without throwing errors or breaking workflow.
2. Zero Database Pollution: Never writes transient search markers, bookmark tags, or artificial annotations to zotero.sqlite.
3. Zero Visual Clutter (Highlight-Free): Never activates yellow find-bar highlights, search overlays, or persistent focus borders on the canvas.
9.2 Execution Pipeline (openItemInReader)¶
When a researcher clicks "Open in Reader" on an Evidence Peek popover, sidebar.js dispatches openItemInReader(attachmentKey, rawSection):
sequenceDiagram
autonumber
actor User as Researcher
participant Popover as Evidence Peek Popover
participant Sidebar as sidebar.js (openItemInReader)
participant Reader as Zotero.Reader Instance
participant PDF as PDFViewerApplication / DOM
User->>Popover: Click "Open in Reader"
Popover->>Sidebar: openItemInReader(attachmentKey, rawSection)
Sidebar->>Sidebar: Coalesce duplicate clicks via _activeReaderNavigations
Sidebar->>Reader: reader.open(targetItemID)
Sidebar->>Sidebar: extractLeafHeading(rawSection, docTitle)
alt Branch A: PDF Attachment
Sidebar->>PDF: Poll internalReader._state.outline / pdfDocument.getOutline2() / getOutline() (<= 3.5s)
Sidebar->>Sidebar: findOutlineDestination(outline, leaf, parents)
opt Leaf Missing in Shallow Outline
Sidebar->>Sidebar: Walk parents hierarchy backwards to find containing section
end
Sidebar->>PDF: internalReader.navigate(targetLocation) with timeout race
else Branch B: HTML Snapshot
Sidebar->>PDF: Poll iframeWindow.document readyState === 'complete' (<= 3.5s)
Sidebar->>Sidebar: findHtmlSnapshotHeading(doc, leaf)
Sidebar->>PDF: headingEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
end
9.3 Heading Extraction, Outline Scoring & Hierarchical Fallback¶
- Heading Extraction & Math Normalization (
extractLeafHeading,normalizeForComparison): - Parses breadcrumbs (
"Attention > 3 Architecture > 3.2 Attention"\(\rightarrow\) leaf:"3.2 Attention", parents:["Attention", "3 Architecture"]). - Strips leading section symbols (
§), Markdown formatting (**bold**,*italic*), and backticks. - Unwraps LaTeX math expressions (
$[Ca^{2+}]$\(\rightarrow\)[Ca^{2+}]) and normalizes LaTeX punctuation ([\^\{\}\[\]\+_\\]) so equations match outline entries (Ca2+), while preserving word hyphens as spaces (Multi-Head Attention\(\leftrightarrow\)Multi Head Attention). - Filters out non-navigable document labels (
"Frontmatter","Full Document","Abstract",/^(?:figure|table)\s+\d/i) viaisSyntheticOrTitle. - Compares against the document title to avoid redundant jumps to the top of page 1.
- Dual-Schema Outline Compatibility:
- Supports both pre-compiled PDF Document Outlines (
node.dest\(\rightarrow\){ dest }) and Zotero's layout-synthesized outline schema (node.location = { position: { pageIndex, rects } }). - Polling queries Zotero's enhanced layout outline generator (
pdfDoc.getOutline2()) when internal state is empty before falling back to nativepdfDoc.getOutline(). - Heading Decomposition (
parseHeadingParts): - Separates numbering prefixes (
3.2.2,III,Appendix B) from core titles using case-sensitive rules (preserving plain titles like"Mix of Experts"and"A Survey"). - Scored Pre-Order DFS Traversal (
findOutlineDestination): - Evaluates outline entries across structural tiers:
- Exact match on core title + numbering (\(+100\)).
- Numbering conflict rejection: if the query specifies section
3.2and the candidate is4.2, the match is rejected (\(-\infty\)). - Parent corroboration: candidate nodes whose ancestors match query breadcrumbs receive \(+20\) points per matching ancestor.
- Ambiguity rejection: if multiple destinations tie with the exact same top score, navigation is skipped to prevent jumping to the wrong location.
- Hierarchical Parent Breadcrumb Fallback:
- If a specific sub-section leaf is omitted in a shallow outline (e.g., outline only indexes H1/H2 chapters like Results), the algorithm walks backwards through
parentsto find the nearest non-synthetic ancestor, jumping to the containing chapter instead of abandoning navigation. - Bounded Execution:
- Destination jumps via
internalReader.navigate(targetLocation)are bounded by a timeout race against the remaining deadline, preventing permanently stuck promises in_activeReaderNavigationsif a host call hangs. - Lifecycle & Concurrency Guards:
- Promise Coalescing: Rapid multi-clicks on the same attachment and section coalesce into a single in-flight
Promisein_activeReaderNavigations, with.finally()cleanup guaranteeing no stuck promises on early returns. - Deadline Bounds: Both PDF outline polling and HTML document readiness are bounded by a strict 3.5-second timeout, immediately failing open if the reader takes longer to load.