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(save_path=True)
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 a collection of notes containing entities.

import logging
from pathlib import Path

URL = "https://quaerofrenchmed.limsi.fr/QUAERO_FrenchMed_brat.zip"
DOWNLOAD_DIR = Path("./downloaded")


def download_quaero():
    """
    If the Quaero dataset is not already downloaded,
    download the Quaero dataset from the official URL and extract it
    in the QUAERO_FrenchMed_brat directory.
    """
    import os
    import tempfile
    from zipfile import ZipFile

    import requests

    if os.path.exists(DOWNLOAD_DIR / "QUAERO_FrenchMed"):
        logging.info(f"Quaero dataset already exists in {DOWNLOAD_DIR / 'QUAERO_FrenchMed'}")
        return
    logging.info("Downloading Quaero dataset...")
    response = requests.get(URL)
    if response.status_code != 200:
        raise RuntimeError(f"Failed to download Quaero dataset: {response.status_code}")
    with tempfile.NamedTemporaryFile(delete=False) as temp_file:
        temp_file.write(response.content)
        temp_file_path = temp_file.name
    logging.info(f"Extracting Quaero dataset in {DOWNLOAD_DIR}...")
    with ZipFile(temp_file_path, "r") as zip_ref:
        zip_ref.extractall(DOWNLOAD_DIR)


def build_data():
    import uuid

    import edsnlp

    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"#{uuid.uuid4()}",
                        "text": str(e),
                        "begin": e.start_char,
                        "end": e.end_char,
                        "label": e.label_,
                        "concept": e._.cui,
                    }
                    for e in sorted(doc.spans["entities"])
                ],
            }
        )
    return {"notes": notes}

2) Make a widget factory

Let's use the DataWidgetFactory: it's a helper class that we can use to quickly compose apps to view and annotate views of our data. Under the hood, it orchestrate Metanno components such as Table, AnnotatedText, form fields to keep views synchronized and automatically infer schema relationships from the data.

We'll pass the build_data function (and not it's result), and a path to save the current state of the annotated data. If no serialized store exists already, the factory will run the function. Otherwise, it will load the app state from the store directly: this is why we pass a function.

from pret.hooks import RefType, use_ref

from metanno.recipes.data_widget_factory import (
    DataWidgetFactory,
    FormWidgetHandle,
    TableWidgetHandle,
    TextWidgetHandle,
    infer_fields,
)

factory = DataWidgetFactory(
    data=build_data,
    sync="quaero_app_state.bin",
)
data = factory.data

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.

PALETTE = [
    "rgb(255,200,206)",
    "rgb(210,236,247)",
    "rgb(211,242,206)",
    "rgb(208,245,229)",
    "rgb(208,210,249)",
    "rgb(232,205,251)",
    "rgb(253,203,241)",
    "rgb(252,221,201)",
    "rgb(249,243,203)",
    "rgb(230,246,204)",
]

all_labels = list(dict.fromkeys(e["label"] for n in data["notes"] for e in n["entities"]))
shortcuts = set()
labels_config = {}
print("Labels of the dataset:")
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
    print(" -", lab.ljust(5), "->", labels_config[lab])
Labels of the dataset:
 - CHEM  -> {'color': 'rgb(255,200,206)', 'shortcut': 'c'}
 - PROC  -> {'color': 'rgb(210,236,247)', 'shortcut': 'p'}
 - LIVB  -> {'color': 'rgb(211,242,206)', 'shortcut': 'l'}
 - DISO  -> {'color': 'rgb(208,245,229)', 'shortcut': 'd'}
 - PHYS  -> {'color': 'rgb(208,210,249)', 'shortcut': 'h'}
 - ANAT  -> {'color': 'rgb(232,205,251)', 'shortcut': 'a'}
 - OBJC  -> {'color': 'rgb(253,203,241)', 'shortcut': 'o'}
 - GEOG  -> {'color': 'rgb(252,221,201)', 'shortcut': 'g'}
 - PHEN  -> {'color': 'rgb(249,243,203)', 'shortcut': 'e'}
 - DEVI  -> {'color': 'rgb(230,246,204)', 'shortcut': 'v'}

4) 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. For each view, we'll also create a "handle" to be able to control the widgets imperatively (e.g. change the filters, scroll to a line) if we want to.

View the documents as a table:

def make_note_kind_options(note):
    if "EMEA" in note["note_id"]:
        return ["interesting", "very interesting"]
    else:
        return []

notes_table_handle: RefType[TableWidgetHandle] = use_ref()
notes_view = factory.create_table_widget(
    store_key="notes",
    primary_key="note_id",
    # Instead of using infer_fields, we can also define the
    # fields manually which can actually be simpler
    fields=[  # type: ignore
        {"key": "note_id", "name": "note_id", "kind": "text", "filterable": True},
        {"key": "note_text", "name": "note_text", "kind": "text", "editable": False, "filterable": True},  # noqa: E501
        {"key": "note_kind", "name": "note_kind", "kind": "text", "editable": True, "options": make_note_kind_options, "filterable": True},  # noqa: E501
        {"key": "seen", "name": "seen", "kind": "boolean", "editable": True, "filterable": True},  # noqa: E501
    ],
    style={"--min-notebook-height": "300px"},
    handle=notes_table_handle,
)

Show the selected note as a form:

note_form_handle: RefType[FormWidgetHandle] = use_ref()
note_form_view = factory.create_form_widget(
    store_key="notes",
    primary_key="note_id",
    fields=[  # type: ignore
        {"key": "note_id", "kind": "text"},
        {"key": "note_kind", "kind": "radio", "editable": True, "options": make_note_kind_options, "filterable": True},  # noqa: E501
        {"key": "seen", "kind": "boolean", "editable": True},
    ],
    add_navigation_buttons=True,
    style={"--min-notebook-height": "300px", "margin": "10px"},
    handle=note_form_handle,
)
# fmt: on

View the entities as a table:

ents_table_handle: RefType[TableWidgetHandle] = use_ref()
entities_view = factory.create_table_widget(
    store_key="notes.entities",
    primary_key="id",
    fields=infer_fields(
        [e for n in data["notes"] for e in n["entities"]],
        visible_keys=["id", "text", "label", "concept"],
        id_keys=["id"],
        editable_keys=["label", "concept"],
        categorical_keys=["label", "concept"],
    ),
    style={"--min-notebook-height": "300px"},
    handle=ents_table_handle,
)

View and edit the note text with highlighted entities.
It returns both the text view and a view for the entity being edited

note_text_handle: RefType[TextWidgetHandle] = use_ref()
note_text_view, ent_view = factory.create_text_widget(
    store_text_key="notes",
    # Where to look for spans data in the app data
    store_spans_key="notes.entities",
    # Fields that will be displayed in the toolbar
    fields=infer_fields(
        [e for n in data["notes"] for e in n["entities"]],
        visible_keys=["label", "concept"],
        editable_keys=["label", "concept"],
        categorical_keys=["label", "concept"],
    ),
    text_key="note_text",
    text_primary_key="note_id",
    spans_primary_key="id",
    labels=labels_config,
    style={"--min-notebook-height": "300px"},
)

Finally, let's create a header for the note text panel, to be able to tell quickly which document we are viewing

note_header = factory.create_selected_field_view(
    store_key="notes",
    shown_key="note_id",
    fallback="Note",
)

5) Assemble everything

Let's put everything in a panel layout (using the pret-simple-dock package).
Panels can be resized, rearranged (drag the tab handles), or hidden by docking into another panel’s tab bar.

from pret.react import div
from pret_joy import Box, Divider, Stack
from pret_markdown import Markdown
from pret_simple_dock import Layout, Panel

layout = Stack(
    Layout(
        div(Markdown("A markdown description of the dataset/task"), style={"margin": "10px"}),
        Panel(notes_view, key="Notes"),
        Panel(entities_view, key="Entities"),
        Panel(note_text_view, key="Note Text", header=note_header),
        Panel(Stack(note_form_view, Divider(), Box(ent_view, sx={"m": "10px"})), key="Info"),
        # Describe how the panels should be arranged by default
        default_config={
            "kind": "row",
            "children": [
                {
                    "kind": "column",
                    "size": 25,
                    "children": [
                        {"tabs": ["Description"], "size": 40},
                        {"tabs": ["Notes"], "size": 30},
                    ],
                },
                {"tabs": ["Note Text"], "size": 50},
                {
                    "kind": "column",
                    "size": 25,
                    "children": [
                        {"tabs": ["Info"], "size": 65},
                        {"tabs": ["Entities"], "size": 35},
                    ],
                },
            ],
        },
        collapse_tabs_on_mobile=[
            "Note Text",
            "Description",
            "Notes",
            "Note Form",
            "Entities",
        ],
    ),
    factory.create_connection_status_bar(),
    style={
        "background": "var(--joy-palette-background-level2, #f0f0f0)",
        "width": "100%",
        "height": "100%",
        "minHeight": "300px",
        "--sd-background-color": "transparent",
    },
)

6) Render or serve

You can either serve it or display it in a notebook. Check out the Notebook to App pret tutorial for more information. In the mean time, just display the layout var in a cell if you're in Jupyter, then right click it and click on "⚛️ Open in a new browser tab" to create a dedicated tab to the app, or click "Detach" to put the app in its own JupyterLab panel.

layout

Offline app

We defined our app as synced with a server file above when we instantiated the factory : since you are likely seing this in a github pages documentation, there is no server to sync to, or said otherwise, the server is currently unreachable. To avoid having users thinking their changes are saved, disconnected apps automatically rollback any changes made to a synchronized store.
This is the reason why you cannot edit the data of the above app. To "unlock" it in offline mode, comment "sync=..."

You can interact programmatically with the app using the handles, for instance to change the currently displayed doc.

note_text_handle.current.set_doc_idx(20);

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 = DataWidgetFactory(
    data=build_data,
    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

As shown above, you can provide a file path to append every change to an on‑disk log:

app = DataWidgetFactory(
    data=build_data,
    sync="quaero_app_state.bin",
)

Changes are now 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 use create_store synced with the file and read current state, but the object will receive live updates as the underlying file changes, and you risk mutating it by accident.

Prefer using the load_store_snapshot function to load the current state of the store as a pure Python object without subscribing to updates or risking modifying it:

from pret.store import load_store_snapshot

data = load_store_snapshot("quaero_app_state.bin")
print(len(data["notes"]), type(data["notes"]))
2536 <class 'list'>

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.