Exported source
esc_behaviour= Script("""
document.addEventListener('keydown', e => {
if (e.key === 'Escape') htmx.ajax('GET', '/editor-reset', {target: '#editor', swap: 'innerHTML'});
});
""")run_pipeline stops at status 'awaiting_curation' once a report is OCR’d. A person then curates the report in this app, in two steps:
page_*.md files and sets curation_status to 'headings_reviewed'.selected_headings and sets curation_status to 'sections_selected'.The next run_pipeline call maps the selected sections.
A report’s curation_status goes from 'pending' to 'headings_reviewed' to 'sections_selected'. The app saves it in the report’s results JSON, with the selection.
The app reads reports from BASE_PATH. The path is relative to the folder you start the app from. Like the base_path of run_pipeline, it holds md/, with the OCR’d pages, and results/, with the report JSON files. The reports stay on the machine that runs the app. To try the app on the test reports from nbs/, set BASE_PATH to 'files/test'.
app uses MonsterUI’s slate theme. esc_behaviour makes the Escape key close the editor.
Load every report saved in base_path
def get_reports(base_path=BASE_PATH, # Folder holding `md/` and `results/`
status=None # `curation_status` to keep; `None` or `'all'` keeps every report
) -> list: # Reports, most recent `Year` first
"Load every report saved in `base_path`"
results_dir = Path(base_path)/'results'
reports = [load_report(p.stem, base_path) for p in results_dir.glob('*.json')]
if status and status != 'all':
reports = [r for r in reports if r.curation_status == status]
reports.sort(key=lambda r: r.ev.meta.get('Year', '0'), reverse=True)
return reportsDisplay label for status, such as 'Headings Reviewed'
Count the tokens in the sections under selected_hdgs
def count_selected_tokens(r:Report, # Report to measure
selected_hdgs # Raw headings like `"## 1. Introduction ... page 4"`
) -> int: # Tokens in the text `extract_selected` returns; 0 if nothing is selected
"Count the tokens in the sections under `selected_hdgs`"
if not selected_hdgs: return 0
md = read_pgs(r.md_path)
return n_tokens(extract_selected(md, selected_hdgs))Count the curated reports in reports
List every Markdown heading in the report at md_path
get_reports loads the three test reports. One of them still waits for curation:
['pending', 'sections_selected', 'sections_selected']
get_progress counts the other two as done:
get_headings lists the headings that the editor shows for a report:
['# Final Evaluation of the EU-IOM Joint Initiative for migrant protection and reintegration in the horn of Africa ... page 1',
'## CONTENTS ... page 3',
'## 1. Introduction ... page 4',
'## 2. Background of the JI-HoA ... page 5']
count_selected_tokens measures its saved selection against the budget:
The page has two panels. The left panel lists the reports as ReportCards, under a ProgressBar and a StatusFilter. The right panel is the editor. It shows HeadingsEditor for a 'pending' report and SectionsSelector for any other. The components carry htmx attributes that call the routes below.
The token budget in SectionsSelector is a guide. Saving a selection works whatever its size.
Card with a report’s title, status, year, short ID, PDF link and Curate button
def ReportCard(r:Report, # Report to show
selected:bool=False, # Highlight the card as the report open in the editor
status:str='all' # Current status filter, passed to the editor route to keep the list filtered
):
"Card with a report's title, status, year, short ID, PDF link and Curate button"
title = r.ev.meta.get('Title', 'Untitled')
title_short = title[:40] + '...' if len(title) > 40 else title
year = r.ev.meta.get('Year', 'n/a')
status_cls = {
'pending': 'bg-red-500 text-white',
'headings_reviewed': 'bg-blue-500 text-white',
'sections_selected': 'bg-emerald-500 text-white'
}
highlight = 'ring-1 ring-primary' if selected else ''
return Card(
DivFullySpaced(
Div(
P(title_short, title=title, cls=TextPresets.bold_sm + ' uppercase truncate'),
DivLAligned(
Span(fmt_status(r.curation_status), cls=f'uk-label {status_cls[r.curation_status]} ' + TextT.xs),
Small(f"{year}", cls=TextPresets.muted_sm),
Small(f'{r.id[:4]}...', cls=TextPresets.muted_sm),
A("PDF", href=r.pdf_url, target='_blank', cls=TextT.sm + ' ' + AT.primary),
cls='gap-2'
),
cls='space-y-0.5'
),
Button(
"Curate",
hx_get=f'/report/{r.id}?status={status}',
hx_target='#editor',
hx_swap='innerHTML show:none',
cls='h-6 px-2 ' + TextT.xs + ButtonT.primary)
),
cls=f'mb-2 {highlight}', body_cls='p-3'
)Scrollable list of ReportCards, with element id report-list
def ReportList(reports, # Reports to list
selected_id=None, # ID of the report open in the editor
status='all', # Current status filter
oob=False # Return it for an out-of-band swap, alongside the editor
):
"Scrollable list of `ReportCard`s, with element id `report-list`"
return Div(
*[ReportCard(r, selected=(r.id == selected_id), status=status) for r in reports],
id='report-list',
cls='overflow-y-auto max-h-[80vh] p-3',
#hx_swap_oob='true' if oob else None
hx_swap_oob='morph' if oob else None
#hx_swap_oob='true show:none' if oob else None
)Steps bar with ‘Clean Headings’ and ‘Select Sections’, marking the steps already done
def StatusSteps(status): # The report's `curation_status`
"Steps bar with 'Clean Headings' and 'Select Sections', marking the steps already done"
steps = ['pending', 'headings_reviewed', 'sections_selected']
labels = ['Clean Headings', 'Select Sections']
current_idx = steps.index(status)
return Steps(*[
LiStep(label, cls=StepT.success if i < current_idx else StepT.neutral)
for i, label in enumerate(labels)
], cls='mb-2 w-full')Form with one text input per heading of r, posted to /report/{id}/save-headings
def HeadingsEditor(r:Report): # Report to edit
"Form with one text input per heading of `r`, posted to `/report/{id}/save-headings`"
headings = get_headings(r.md_path)
title = r.ev.meta.get('Title', 'Untitled')
return Div(
H5(title, cls='font-bold mb-2 text-sm'),
StatusSteps(r.curation_status),
Form(
Div(*[
Input(value=h, name=f'heading_{i}', cls='mb-1 w-full font-mono text-xs')
for i, h in enumerate(headings)
],
cls='space-y-1 max-h-[60vh] overflow-y-auto px-2 py-2 bg-slate-50 rounded border border-slate-200'
),
Hidden(name='report_id', value=r.id),
DivCentered(Button("Save Headings", type='submit', cls=ButtonT.primary), cls='w-full'),
hx_post=f'/report/{r.id}/save-headings',
hx_target='#editor'
)
)Token count, green below 80% of budget, amber below budget, and red from budget up
def token_display(tokens, # Tokens in the selected sections
budget=15000 # Token budget for the selection
):
"Token count, green below 80% of `budget`, amber below `budget`, and red from `budget` up"
color = 'green' if tokens < budget * 0.8 else 'amber' if tokens < budget else 'red'
return Span(
Strong(f"{tokens:,}"), " tokens",
id='token-count',
cls=f'ml-4 text-sm text-{color}-600 w-32 inline-block'
)Form with a checkbox per heading of r, ticked for selected_headings, and a live token count
def SectionsSelector(r:Report): # Report whose sections to select
"Form with a checkbox per heading of `r`, ticked for `selected_headings`, and a live token count"
headings = get_headings(r.md_path)
title = r.ev.meta.get('Title', 'Untitled')
return Div(
H5(title, cls='font-bold mb-2 text-sm'),
StatusSteps(r.curation_status),
Form(
Div(
Table(
Tbody(*[
Tr(
Td(
CheckboxX(name=f'hdg_{i}', checked=(h in r.selected_headings)),
cls='w-8 !py-1'),
Td(h, cls='font-mono text-xs !py-1'),
cls='border-b'
)
for i, h in enumerate(headings)
]),
cls='w-full uk-table-middle'
),
cls='max-h-[60vh] overflow-y-auto bg-slate-50 rounded border border-slate-200'
),
DivCentered(
DivLAligned(
Button(
"Save Selection",
type='submit',
cls=ButtonT.primary,
hx_swap='innerHTML',
hx_post=f'/report/{r.id}/save-sections',
hx_target='#editor'
),
token_display(count_selected_tokens(r, r.selected_headings))
),
cls='mt-2'
),
hx_post=f'/report/{r.id}/token-count',
hx_trigger='change from:input[type=checkbox]',
hx_target='#token-count',
hx_swap='outerHTML',
),
Button("← Back to Headings", hx_post=f'/report/{r.id}/reset-to-pending', hx_target='#editor', hx_swap='innerHTML', cls='mt-2')
)Progress bar of the reports with status 'sections_selected'
def ProgressBar(reports, # Reports to count
oob=False # Return it for an out-of-band swap
):
"Progress bar of the reports with status `'sections_selected'`"
done, total = get_progress(reports)
pct = int(done / total * 100) if total else 0
return Div(
DivLAligned(
Progress(value=pct, max=100, cls='mb-0'),
P(f"{done}/{total} complete", cls=TextT.sm + 'w-48'),
cls='gap-2'
),
id='progress-bar',
hx_swap_oob='true' if oob else None
)Tabs that filter the report list by curation_status
def StatusFilter(current='all'): # Selected tab: `'all'` or a `curation_status`
"Tabs that filter the report list by `curation_status`"
statuses = [('all', 'All'), ('pending', 'Pending'), ('headings_reviewed', 'Reviewed'), ('sections_selected', 'Selected')]
return TabContainer(*[
Li(A(label, hx_get=f'/filter?status={key}', hx_target='#report-list', hx_swap='outerHTML'),
cls='uk-active' if key == current else '')
for key, label in statuses
], alt=True, cls=TextT.sm)Each route returns HTML fragments that htmx swaps into the page. A route that changes a report also returns the report list for an out-of-band swap. The list then shows the new status.
Saving headings rewrites the report’s page_*.md files in place with apply_hdg_fixes. It keeps no copy of the original OCR output. An edit applies to every heading with the same original text. The save sets the status to 'headings_reviewed' even when no heading changed.
@rt('/')
def get():
"Page with the report list and an empty editor"
reports = get_reports()
return Container(
H2("IOMEVAL | Reports Curator", cls="text-2xl font-bold mb-6"),
Grid(
Card(
DivFullySpaced(
H4("Reports"),
Div(ProgressBar(reports), cls='w-4/5'),
cls='items-center'
),
DivCentered(Div(StatusFilter(), id='status-filter'), cls='w-2/3 mx-auto'),
#Div(*[ReportCard(r) for r in reports], id='report-list', cls='overflow-y-auto max-h-[80vh]')),
ReportList(reports)),
Card(
H4("Editor"),
Div(id='editor', cls='p-2')(
DivCentered(P('Click "Curate" to select a report', cls="font-normal"), cls='h-40')
),
cls="h-full"
),
cols=2, gap=4
),
cls="p-4"
)Open a report in HeadingsEditor while it is 'pending', and in SectionsSelector otherwise
@rt('/report/{id}')
def get(id:str, status:str='all'):
"Open a report in `HeadingsEditor` while it is `'pending'`, and in `SectionsSelector` otherwise"
r = load_report(id, BASE_PATH)
reports = get_reports(status=status)
editor = HeadingsEditor(r) if r.curation_status == 'pending' else SectionsSelector(r)
return (
ReportList(reports, selected_id=id, status=status, oob=True),
editor
)Apply the heading edits to every page of the report and set its status to 'headings_reviewed'
@rt('/report/{id}/save-headings')
async def post(id:str, req:Request):
"Apply the heading edits to every page of the report and set its status to `'headings_reviewed'`"
form = await req.form()
r = load_report(id, BASE_PATH)
original_headings = get_headings(r.md_path)
lut_fixes = {}
for i, orig in enumerate(original_headings):
edited = form.get(f'heading_{i}', orig)
if edited != orig: lut_fixes[orig] = edited
if lut_fixes:
for pg_path in sorted(r.md_path.glob('page_*.md')):
content = pg_path.read_text()
fixed = apply_hdg_fixes(content, lut_fixes)
pg_path.write_text(fixed)
r.curation_status = 'headings_reviewed'
r.save()
reports = get_reports()
return (
ReportList(reports, selected_id=id, oob=True),
SectionsSelector(r)
)Send the report back to the headings step, keeping its saved selection
@rt('/report/{id}/reset-to-pending')
def post(id:str):
"Send the report back to the headings step, keeping its saved selection"
r = load_report(id, BASE_PATH)
r.curation_status = 'pending'
r.save()
reports = get_reports()
return (
ReportList(reports, selected_id=id, oob=True),
HeadingsEditor(r),
ProgressBar(reports, oob=True)
)Token count of the ticked headings, recomputed on each checkbox change
@rt('/report/{id}/token-count')
async def post(id:str, req:Request):
"Token count of the ticked headings, recomputed on each checkbox change"
form = await req.form()
r = load_report(id, BASE_PATH)
selected = [int(k.split('_')[1]) for k in form.keys() if k.startswith('hdg_')]
headings = get_headings(r.md_path)
selected_hdgs = [headings[i] for i in selected]
tokens = count_selected_tokens(r, selected_hdgs)
return token_display(tokens)Save the ticked headings as selected_headings and set the status to 'sections_selected'
@rt('/report/{id}/save-sections')
async def post(id:str, req:Request):
"Save the ticked headings as `selected_headings` and set the status to `'sections_selected'`"
form = await req.form()
r = load_report(id, BASE_PATH)
headings = get_headings(r.md_path)
selected = [int(k.split('_')[1]) for k in form.keys() if k.startswith('hdg_')]
r.selected_headings = [headings[i] for i in selected]
r.curation_status = 'sections_selected'
r.save()
reports = get_reports()
return (
ReportList(reports, selected_id=id, oob=True),
DivCentered(P('Click "Curate" to select a report', cls="font-normal"), cls='h-40'),
ProgressBar(reports, oob=True)
)@rt('/filter')
def get(status:str='all'):
"Filter the report list by `status` and close the editor"
reports = get_reports(status=status)
return (
#Div(*[ReportCard(r, status=status) for r in reports], id='report-list'),
ReportList(reports, status=status),
Div(StatusFilter(current=status), id='status-filter', hx_swap_oob='true'),
Div(
DivCentered(P('Click "Curate" to select a report', cls="font-normal"), cls='h-40'),
id='editor', cls='p-2', hx_swap_oob='true'
)
)Start the app from a folder next to your data folder, then open http://localhost:5001:
main serves app with uvicorn, which reloads the app when its code changes.
Serve the curator app, reading reports from BASE_PATH
In a notebook, JupyUvi serves the app in the background, and srv.stop() stops it: