Run the Quaero Explorer
Run the Quaero Explorer demo app and discover Metanno’s collaborative workflow: real‑time syncing, multi‑panel editing, and simple persistence.
Prerequisites
Reuse the same environment you prepared in the New project tutorial. You should already have a working Python environment with Pret, Metanno, and EDS‑NLP installed.
The script downloads the Quaero FrenchMed dataset on first run, so an Internet access is required.
Create a new file named quaero.py in your project and copy the contents from the official example https://github.com/percevalw/metanno/blob/main/examples/quaero.py.
Run the app
from collections import Counter
from pathlib import Path
from typing import List
import edsnlp
from pret import component, create_store, use_ref, use_store_snapshot
from pret.hooks import RefType
# Pending deprecation, prefer pret.react
from pret.react import div
from pret_markdown import Markdown
from pret_simple_dock import Layout, Panel
from metanno.recipes.explorer import (
DatasetExplorerWidgetFactory,
FormWidgetHandle,
TableWidgetHandle,
TextWidgetHandle,
)
From a terminal. The server will start at http://localhost:5000.
python quaero.py --port 5000
Or in JupyterLab, run the following cell:
from quaero import app
view, handles = app()
view
Code breakdown
Below, we break down the script.
1) Load the data
We first fetch and extracts the dataset if it isn’t already present, and read the BRAT standoff annotations using EDS-NLP. We then build two Python lists: notes and entities with the fields we’ll display and edit.
download_quaero()
data = edsnlp.data.read_standoff(
DOWNLOAD_DIR / "QUAERO_FrenchMed/corpus",
span_setter="entities",
notes_as_span_attribute="cui",
)
notes = []
for idx, doc in enumerate(data):
notes.append(
{
"note_id": str(doc._.note_id),
"note_text": doc.text,
"note_kind": "interesting" if idx % 2 == 0 else "very interesting",
"seen": False,
}
)
entities = [
{
"id": f"#{doc._.note_id}-{e.start_char}-{e.end_char}-{e.label_}",
"note_id": str(doc._.note_id),
"text": str(e),
"begin": e.start_char,
"end": e.end_char,
"label": e.label_,
"concept": e._.cui,
}
for doc in data
for e in sorted(doc.spans["entities"])
]
2) Check unique entity IDs
The Table component requires unique IDs for each row. We check if the entity IDs are unique and raise an error if not, or deduplicate them automatically.
if deduplicate:
entities = list({v["id"]: v for v in entities}.values())
else:
ctr = Counter(e["id"] for e in entities)
if any(count > 1 for count in ctr.values()):
raise ValueError(
"Duplicate IDs found in the dataset: "
+ ", ".join(f"{i} (x{n})" for i, n in ctr.items() if n > 1)
)
3) Configure labels
We compute a stable list of labels, assign a color for each, and auto‑pick a one‑letter keyboard shortcut per label.
all_labels = list(dict.fromkeys(e["label"] for e in entities))
shortcuts = set()
labels_config = {}
for i, lab in enumerate(all_labels):
labels_config[lab] = {}
if i < len(PALETTE):
labels_config[lab]["color"] = PALETTE[i]
letter = next((c for c in lab.lower() if c not in shortcuts), None)
if letter:
shortcuts.add(letter)
labels_config[lab]["shortcut"] = letter
4) Instantiate the manager
We create a DatasetExplorerWidgetFactory (a ready‑to‑customize recipe). Under the hood, it composes Metanno components such as Table, AnnotatedText, buttons with a bit of app logic.
factory = DatasetExplorerWidgetFactory(
{
"notes": notes,
"entities": entities,
},
sync=save_path,
)
data = factory.data
5) Build the views and define their interactions
We define the views that will be rendered in the app. Each view is a component that displays a specific part of the data.
# Create handles to control the widgets imperatively
notes_table_handle: RefType[TableWidgetHandle] = use_ref()
ents_table_handle: RefType[TableWidgetHandle] = use_ref()
note_text_handle: RefType[TextWidgetHandle] = use_ref()
note_form_handle: RefType[FormWidgetHandle] = use_ref()
# State for the header
top_level_state = create_store({"note_id": notes[0]["note_id"] if notes else None})
def on_note_selected_in_table(row_id: str, row_idx: int, col: str, mode: str, cause: str):
if row_id is None:
return
# When user clicks a note row:
# Update NoteHeader (we could also use a handle with setNoteId in NoteHeader)
top_level_state["note_id"] = row_id
# Update text view
note_text_handle.current["set_doc_by_id"](row_id)
# Filter entity table to show only this note's entities
ents_table_handle.current["set_filter"]("note_id", str(row_id))
# Sync the form view
note_form_handle.current["set_row_id"](row_id)
async def on_ent_row_selected(row_id: str, row_idx: int, col: str, mode: str, cause: str):
if row_id is None:
return
# When user clicks an entity row
note_id = data["entities"][row_idx]["note_id"]
if top_level_state["note_id"] != note_id:
# Update NoteHeader
top_level_state["note_id"] = note_id
# Update text view
await note_text_handle.current["set_doc_by_id"](note_id)
# Sync the notes table
notes_table_handle.current["scroll_to_row_id"](note_id)
notes_table_handle.current["set_highlighted"]([note_id])
# Sync the form view
note_form_handle.current["set_row_id"](note_id)
# Scroll to entity in text view and highlight it
note_text_handle.current["scroll_to_span"](row_id)
note_text_handle.current["set_highlighted_spans"]([row_id])
def on_change_text_id(note_id: str):
# When user uses arrow keys in text view:
# Update NoteHeader (we could also use a handle with setNoteId in NoteHeader)
top_level_state["note_id"] = note_id
# Sync the entities table
ents_table_handle.current["set_filter"]("note_id", str(note_id))
# Scroll to note in note view
notes_table_handle.current["scroll_to_row_id"](note_id)
# Set highlighted note in notes table
notes_table_handle.current["set_highlighted"]([note_id])
# Sync the form view
note_form_handle.current["set_row_id"](note_id)
def on_hover_spans(span_ids: List[str], mod_keys: List[str]):
# When user hovers spans in text view:
# Highlight corresponding entities in entity table
ents_table_handle.current["set_highlighted"](span_ids)
def on_ent_row_hovered(span_id: str, span_idx: int, mod_keys: List[str]):
# When user hovers an entity row in entity table:
# Highlight corresponding span in text view
note_text_handle.current["set_highlighted_spans"]([span_id])
def on_click_entity_span(span_id: str, mod_keys: List[str]):
# When user clicks an entity span in text view:
# Highlight corresponding entity in entity table and scroll to it
ents_table_handle.current["scroll_to_row_id"](span_id)
ents_table_handle.current["set_highlighted"]([span_id])
notes_view = factory.create_table_widget(
store_key="notes",
primary_key="note_id",
first_keys=["note_id", "seen", "note_text", "note_kind"],
id_keys=["note_id"],
editable_keys=["seen", "note_kind"],
categorical_keys=["note_kind"],
hidden_keys=[],
style={"--min-notebook-height": "300px"},
handle=notes_table_handle,
on_position_change=on_note_selected_in_table,
)
note_form_view = factory.create_form_widget(
store_key="notes",
primary_key="note_id",
first_keys=["note_id", "note_kind", "seen"],
editable_keys=["seen", "note_kind"],
categorical_keys=["note_kind"],
hidden_keys=["note_text"],
style={
"--min-notebook-height": "300px",
"margin": "10px",
"alignItems": "flex-start",
},
handle=note_form_handle,
)
entities_view = factory.create_table_widget(
store_key="entities",
primary_key="id",
first_keys=["id", "note_id", "text", "label", "concept"],
id_keys=["id", "note_id"],
editable_keys=["label", "concept"],
categorical_keys=["label", "concept"],
style={"--min-notebook-height": "300px"},
handle=ents_table_handle,
on_position_change=on_ent_row_selected,
on_mouse_hover_row=on_ent_row_hovered,
)
note_text_view = factory.create_text_widget(
store_text_key="notes",
store_spans_key="entities",
handle=note_text_handle,
on_change_text_id=on_change_text_id,
on_hover_spans=on_hover_spans,
on_click_span=on_click_entity_span,
text_key="note_text",
text_primary_key="note_id",
# Where to look for spans data in the app data
spans_primary_key="id",
style={"--min-notebook-height": "300px"},
labels=labels_config,
)
6) Assemble everything
Panels can be resized, rearranged (drag the tab handles), or hidden by docking into another panel’s tab bar. The note panel header is customized to display the current note id.
@component
def NoteHeader():
note_id = use_store_snapshot(top_level_state)["note_id"]
return f"Note ({note_id})" if note_id else "Note"
layout = div(
Layout(
Panel(div(Markdown(DESC), style={"margin": "10px"}), key="Description"),
Panel(notes_view, key="Notes"),
Panel(note_form_view, key="Note Form"),
Panel(entities_view, key="Entities"),
Panel(note_text_view, header=NoteHeader(), key="Note Text"),
# Describe how the panels should be arranged by default
default_config={
"kind": "row",
"children": [
"Description",
["Notes", "Note Form", "Entities"],
"Note Text",
],
},
collapse_tabs_on_mobile=[
"Note Text",
"Description",
"Notes",
"Note Form",
"Entities",
],
),
style={
"background": "var(--joy-palette-background-level2, #f0f0f0)",
"width": "100%",
"height": "100%",
"minHeight": "300px",
"--sd-background-color": "transparent",
},
)
return (
# Return the pret component
layout,
# and expose handles for further manipulation if needed
{
"notes": notes_table_handle,
"entities": ents_table_handle,
"note_text": note_text_handle,
"note_form": note_form_handle,
},
)
7) Render or serve
You can either serve it or display it in a notebook, following the instructions in the previous section.
JupyterLab Tabs
In notebooks, Pret layouts cannot be "mixed" with JupyterLab’s own UI system, and will always be embedded in a single JupyterLab tab. You may prefer displaying specific views in separate cells.
Simply display the variables notes_view, entities_view, note_text_view (the return values of factory.create_table_widget, factory.create_form_widget and factory.create_text_widget) in separate cells.
Syncing, collaboration, and saving
Metanno (via Pret) can sync app state across clients and optionally persist it.
Live sync only (no persistence)
Pass sync=True when creating the app to enable real‑time collaboration without saving to disk:
app = DatasetExplorerWidgetFactory(
{
"notes": notes,
"entities": entities,
},
sync=True,
)
Open the same notebook twice or the same app URL in two tabs: edits in one tab are mirrored to the other.
Live sync and saving to a file
Provide a file path to append every change to an on‑disk log:
app = DatasetExplorerWidgetFactory(
{
"notes": notes,
"entities": entities,
},
sync="quaero.bin",
)
Now changes are saved on disk, and multiple servers/kernels can collaborate by pointing to the same file.
What is the saved format?
It’s a compact, binary, append‑only log of user mutations: you cannot read it directly.
To inspect it, you can create store synced with the file and read current state.
from pret.store import create_store
store = create_store(sync="quaero.bin")
pure_py_object = store.to_py()
To export to other formats, write a small exporter from your in‑memory data (app.data). We recommend using EDS‑NLP data connectors since it supports several data formats and schemas.