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.