Skip to content

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 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.