From 4d59734df8928853b145104b68a255a4ff3f7377 Mon Sep 17 00:00:00 2001 From: Hugo De Oliveira <80337112+hugros-93@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:47:11 +0100 Subject: [PATCH 1/6] update app, add placeholder for filters --- eu_fact_force/dash-app/app.py | 5 +-- eu_fact_force/dash-app/pages/graph.py | 63 ++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/eu_fact_force/dash-app/app.py b/eu_fact_force/dash-app/app.py index a2d3cc5..b170bfc 100644 --- a/eu_fact_force/dash-app/app.py +++ b/eu_fact_force/dash-app/app.py @@ -172,7 +172,7 @@ def activate_search_buton(search_text, graph): Output("graph-cytoscape", "elements"), Output("list-elements", "children"), Output("graph", "style"), - Output("list", "style"), + Output("results", "style"), Output("search-input", "value"), ], inputs=[Input("search-button", "n_clicks")], @@ -208,9 +208,6 @@ def update_graph(n_clicks, search_text): "display": "block", }, { - "border-radius": "15px", - "padding": "20px", - "background-color": EUPHAColors.white, "display": "block", }, "", diff --git a/eu_fact_force/dash-app/pages/graph.py b/eu_fact_force/dash-app/pages/graph.py index 40c6e3f..60ba62c 100644 --- a/eu_fact_force/dash-app/pages/graph.py +++ b/eu_fact_force/dash-app/pages/graph.py @@ -11,9 +11,9 @@ def make_layout(): # Search bar search_bar = html.Div( children=[ - html.H3("Search"), dbc.Row( [ + dbc.Col(html.H5("Search", style={"margin-bottom": '2px'}), width="auto"), dbc.Col( dbc.Input( id="search-input", @@ -48,12 +48,14 @@ def make_layout(): graph_results = html.Div( id="graph", children=[ - html.H3("Graph"), + html.H5("Graph"), cyto.Cytoscape( id="graph-cytoscape", stylesheet=stylesheet, layout={"name": "cose"}, - style={"width": "100%", "height": "400px"}, + style={"width": "100%", "height": "300px"}, + zoomingEnabled=True, + userZoomingEnabled=False, ), ], style={ @@ -76,15 +78,62 @@ def make_layout(): # List list_results = html.Div( id="list", - children=[html.H3("List of results"), html.Div(id="list-elements")], + children=[html.H5("List of results"), html.Div(id="list-elements")], style={ "border-radius": "15px", "padding": "20px", - "background-color": EUPHAColors.light_blue, - "display": "none", + "background-color": EUPHAColors.white, + }, + ) + + # Filters + + # > Health topics + topics_filter = dbc.Row([html.H6("Health topics")]) + + # > Keywords + keyword_filter = dbc.Row([html.H6("Keywords")]) + + # > Evidence type + evidence_filter = dbc.Row([html.H6("Evidence type")]) + + # > Type of document + doc_type_filter = dbc.Row([html.H6("Document type")]) + + # > Paper filters + paper_filter = dbc.Row([html.H6("Paper filters")]) + + filter_results = html.Div( + id="filters", + children=[ + html.H5("Filters"), + topics_filter, + html.Br(), + keyword_filter, + html.Br(), + evidence_filter, + html.Br(), + doc_type_filter, + html.Br(), + paper_filter, + html.Br(), + ], + style={ + "border-radius": "15px", + "padding": "20px", + "background-color": EUPHAColors.white, }, ) + # Results + results = html.Div( + id="results", + children=dbc.Row( + [dbc.Col(filter_results, width=3), dbc.Col(list_results, width=9)] + ), + style={"display": "none"}, + ) + return html.Div( - [search_bar, html.Br(), graph_results, html.Br(), list_results, offcanevas] + [search_bar, html.Br(), graph_results, html.Br(), results, offcanevas] ) From 3ca3b44eca0b3b8842dec3e1762f0c5dc1611dd2 Mon Sep 17 00:00:00 2001 From: Hugo De Oliveira <80337112+hugros-93@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:16:10 +0200 Subject: [PATCH 2/6] update --- eu_fact_force/dash-app/pages/graph.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/eu_fact_force/dash-app/pages/graph.py b/eu_fact_force/dash-app/pages/graph.py index 60ba62c..3852ce1 100644 --- a/eu_fact_force/dash-app/pages/graph.py +++ b/eu_fact_force/dash-app/pages/graph.py @@ -13,7 +13,9 @@ def make_layout(): children=[ dbc.Row( [ - dbc.Col(html.H5("Search", style={"margin-bottom": '2px'}), width="auto"), + dbc.Col( + html.H5("Search", style={"margin-bottom": "2px"}), width="auto" + ), dbc.Col( dbc.Input( id="search-input", @@ -88,9 +90,6 @@ def make_layout(): # Filters - # > Health topics - topics_filter = dbc.Row([html.H6("Health topics")]) - # > Keywords keyword_filter = dbc.Row([html.H6("Keywords")]) @@ -107,8 +106,6 @@ def make_layout(): id="filters", children=[ html.H5("Filters"), - topics_filter, - html.Br(), keyword_filter, html.Br(), evidence_filter, From f3a80ef8dbb7f8b46af9d6f0ba3e8d7f39a69c64 Mon Sep 17 00:00:00 2001 From: Hugo De Oliveira <80337112+hugros-93@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:20:02 +0200 Subject: [PATCH 3/6] fix typo --- eu_fact_force/dash-app/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eu_fact_force/dash-app/README.md b/eu_fact_force/dash-app/README.md index 32f2ec0..436cb06 100644 --- a/eu_fact_force/dash-app/README.md +++ b/eu_fact_force/dash-app/README.md @@ -11,7 +11,7 @@ This folder contains a first version of a local Dash app. ## Setup - Install `graph` group depedencies using `uv sync --group graph`. -- Start app from here with `pyhon app.py`. +- Start app from here with `python app.py`. - Visit `http://127.0.0.1:8050/` to see the app on your local. ## App overview From 517465553fde4846141aba620bba6eb86b84b7da Mon Sep 17 00:00:00 2001 From: Hugo De Oliveira <80337112+hugros-93@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:54:47 +0200 Subject: [PATCH 4/6] constrain search, add test search results json, add filter layout --- eu_fact_force/dash-app/app.py | 322 ++++++++++++------ .../dash-app/data/search_results.json | 98 ++++++ eu_fact_force/dash-app/pages/graph.py | 57 ++-- eu_fact_force/dash-app/utils/colors.py | 3 +- eu_fact_force/dash-app/utils/graph.py | 138 +++++++- 5 files changed, 491 insertions(+), 127 deletions(-) create mode 100644 eu_fact_force/dash-app/data/search_results.json diff --git a/eu_fact_force/dash-app/app.py b/eu_fact_force/dash-app/app.py index 285374b..58ead02 100644 --- a/eu_fact_force/dash-app/app.py +++ b/eu_fact_force/dash-app/app.py @@ -11,7 +11,7 @@ import uuid from utils.colors import EUPHAColors -from utils.graph import RandomGraphGenerator +from utils.graph import TestGraph from utils.parsing import extract_pdf_metadata from pages import readme, ingest, graph @@ -133,9 +133,12 @@ id="page-content", ) +# Storage +store = html.Div([dcc.Store(id="store-search")]) + # Layout -app.layout = html.Div([dcc.Location(id="url", refresh=False), header, content]) +app.layout = html.Div([dcc.Location(id="url", refresh=False), header, content, store]) # -------------------- @@ -170,23 +173,102 @@ def activate_search_buton(search_text, graph): return False -# Callback update graph +# Callback get search data @app.callback( [ - Output("graph-cytoscape", "elements"), - Output("list-elements", "children"), - Output("graph", "style"), + Output("store-search", "data"), Output("results", "style"), Output("search-input", "value"), + Output("filter_chunk_types", "options"), + Output("filter_keywords", "options"), + Output("filter_documents", "options"), + Output("filter_journals", "options"), + Output("filter_authors", "options"), + Output("filter_dates", "min_date_allowed"), + Output("filter_dates", "max_date_allowed"), + Output("filter_chunk_types", "value"), + Output("filter_keywords", "value"), + Output("filter_documents", "value"), + Output("filter_journals", "value"), + Output("filter_authors", "value"), + Output("filter_dates", "start_date"), + Output("filter_dates", "end_date"), ], inputs=[Input("search-button", "n_clicks")], state=[State("search-input", "value")], prevent_updates=True, ) -def update_graph(n_clicks, search_text): +def load_data(n_clicks, search_text): if n_clicks > 0: - # Graph generator - graph_elements = RandomGraphGenerator().get_graph_data() + nodes, edges, filters = TestGraph().transform() + return [ + {"nodes": nodes, "edges": edges}, + { + "display": "block", + }, + "", + list(set(filters["chunk_types"])), + list(set(filters["keywords"])), + list(set(filters["documents"])), + list(set(filters["journal"])), + list(set(filters["authors"])), + min(filters["date"]), + max(filters["date"]), + list(set(filters["chunk_types"])), + list(set(filters["keywords"])), + list(set(filters["documents"])), + list(set(filters["journal"])), + list(set(filters["authors"])), + min(filters["date"]), + max(filters["date"]), + ] + else: + raise PreventUpdate + + +# Callback update graph +@app.callback( + [ + Output("graph-cytoscape", "elements"), + Output("list-elements", "children"), + Output("graph", "style"), + ], + inputs=[ + Input("store-search", "data"), + Input("filter_chunk_types", "value"), + Input("filter_keywords", "value"), + Input("filter_documents", "value"), + Input("filter_journals", "value"), + Input("filter_authors", "value"), + Input("filter_dates", "start_date"), + Input("filter_dates", "end_date"), + ], + prevent_updates=True, +) +def update_graph( + store_search, + filter_chunk_types, + filter_keywords, + filter_documents, + filter_journals, + filter_authors, + start_date, + end_date, +): + if store_search is None: + raise PreventUpdate + else: + # Search data + nodes = store_search["nodes"] + edges = store_search["edges"] + + # Filters + # TODO: filter nodes and edges + + # Graph elements + graph_elements = [nodes[x] for x in nodes] + edges + + # List elements list_elements = [x["data"] for x in graph_elements if "id" in x["data"]] list_elements = sorted(list_elements, key=lambda x: x["id"]) return [ @@ -199,7 +281,7 @@ def update_graph(n_clicks, search_text): [f"- {key.capitalize()} : __{x[key]}__" for key in x] ) ), - title=x["label"], + title=x["label"].replace("_", " ").title(), ) for x in list_elements ], @@ -211,13 +293,7 @@ def update_graph(n_clicks, search_text): "background-color": EUPHAColors.white, "display": "block", }, - { - "display": "block", - }, - "", ] - else: - raise PreventUpdate # Callback show selected element @@ -252,15 +328,16 @@ def toggle_offcanvas(node_data, is_open): ### Create here callbacks for ingestions + @app.callback( - Output('input-doi', 'value'), - Output('input-abstract', 'value'), - Output('input-journal', 'value'), - Output('input-date', 'value'), - Output('input-link', 'value'), - Output('input-title', 'value'), - Output('session-store', 'data'), - Input('upload-pdf', 'contents') + Output("input-doi", "value"), + Output("input-abstract", "value"), + Output("input-journal", "value"), + Output("input-date", "value"), + Output("input-link", "value"), + Output("input-title", "value"), + Output("session-store", "data"), + Input("upload-pdf", "contents"), ) def handle_pdf_upload(contents): @@ -268,77 +345,91 @@ def handle_pdf_upload(contents): return no_update, no_update, no_update, no_update, no_update, no_update, {} # decoding of passed PDFs - content_type, content_string = contents.split(',') + content_type, content_string = contents.split(",") decoded = base64.b64decode(content_string) # extract_pdf_metadata call metadata = extract_pdf_metadata(io.BytesIO(decoded)) return ( - metadata.get('doi', ''), - metadata.get('abstract', ''), - metadata.get('journal', ''), - metadata.get('publication_date', ''), - metadata.get('article_link', ''), - metadata.get('title', ''), - metadata + metadata.get("doi", ""), + metadata.get("abstract", ""), + metadata.get("journal", ""), + metadata.get("publication_date", ""), + metadata.get("article_link", ""), + metadata.get("title", ""), + metadata, ) + + @app.callback( - Output('authors-container', 'children'), - Input('btn-add-author', 'n_clicks'), - Input({'type': 'remove-author', 'index': ALL}, 'n_clicks'), - Input('session-store', 'data'), - State({'type': 'auth-name', 'index': ALL}, 'value'), - State({'type': 'auth-surname', 'index': ALL}, 'value'), - State({'type': 'auth-email', 'index': ALL}, 'value'), - State({'type': 'auth-name', 'index': ALL}, 'id'), + Output("authors-container", "children"), + Input("btn-add-author", "n_clicks"), + Input({"type": "remove-author", "index": ALL}, "n_clicks"), + Input("session-store", "data"), + State({"type": "auth-name", "index": ALL}, "value"), + State({"type": "auth-surname", "index": ALL}, "value"), + State({"type": "auth-email", "index": ALL}, "value"), + State({"type": "auth-name", "index": ALL}, "id"), ) -def update_authors_list(add_clicks, remove_clicks, metadata, names, surnames, emails, ids): +def update_authors_list( + add_clicks, remove_clicks, metadata, names, surnames, emails, ids +): triggered = ctx.triggered_id # on a new pdf uplaod - if triggered == 'session-store' and metadata: - authors = metadata.get('authors', []) - return [ingest.add_author_line(str(uuid.uuid4()), a.get('name', ''), a.get('surname', ''), a.get('email', '')) for a in authors] + if triggered == "session-store" and metadata: + authors = metadata.get("authors", []) + return [ + ingest.add_author_line( + str(uuid.uuid4()), + a.get("name", ""), + a.get("surname", ""), + a.get("email", ""), + ) + for a in authors + ] # reconstructing authors list current_authors = [] if ids: for idx_id, name, surname, email in zip(ids, names, surnames, emails): - current_authors.append({ - 'index': idx_id['index'], - 'name': name or "", - 'surname': surname or "", - 'email': email or "" - }) + current_authors.append( + { + "index": idx_id["index"], + "name": name or "", + "surname": surname or "", + "email": email or "", + } + ) # if missing author - if triggered == 'btn-add-author': - current_authors.append({ - 'index': str(uuid.uuid4()), - 'name': "", - 'surname': "", - 'email': "" - }) + if triggered == "btn-add-author": + current_authors.append( + {"index": str(uuid.uuid4()), "name": "", "surname": "", "email": ""} + ) # remove blank/irrelevant author field - if isinstance(triggered, dict) and triggered.get('type') == 'remove-author': - remove_index = triggered.get('index') - current_authors = [a for a in current_authors if a['index'] != remove_index] + if isinstance(triggered, dict) and triggered.get("type") == "remove-author": + remove_index = triggered.get("index") + current_authors = [a for a in current_authors if a["index"] != remove_index] - return [ingest.add_author_line(a['index'], a['name'], a['surname'], a['email']) for a in current_authors] + return [ + ingest.add_author_line(a["index"], a["name"], a["surname"], a["email"]) + for a in current_authors + ] @app.callback( - Output('input-doi', 'disabled'), - Output('input-abstract', 'disabled'), - Output('input-journal', 'disabled'), - Output('input-date', 'disabled'), - Output('input-link', 'disabled'), - Output('input-category', 'disabled'), - Output('input-type', 'disabled'), - Output('input-title', 'disabled'), - Input('chk-meta-correct', 'value') + Output("input-doi", "disabled"), + Output("input-abstract", "disabled"), + Output("input-journal", "disabled"), + Output("input-date", "disabled"), + Output("input-link", "disabled"), + Output("input-category", "disabled"), + Output("input-type", "disabled"), + Output("input-title", "disabled"), + Input("chk-meta-correct", "value"), ) def lock_metadata(is_correct): val = bool(is_correct) @@ -346,43 +437,63 @@ def lock_metadata(is_correct): @app.callback( - Output({'type': 'auth-name', 'index': ALL}, 'disabled'), - Output({'type': 'auth-surname', 'index': ALL}, 'disabled'), - Output({'type': 'auth-email', 'index': ALL}, 'disabled'), - Output({'type': 'remove-author', 'index': ALL}, 'disabled'), - Output('btn-add-author', 'disabled'), - Input('chk-authors-correct', 'value'), - State({'type': 'auth-name', 'index': ALL}, 'id') + Output({"type": "auth-name", "index": ALL}, "disabled"), + Output({"type": "auth-surname", "index": ALL}, "disabled"), + Output({"type": "auth-email", "index": ALL}, "disabled"), + Output({"type": "remove-author", "index": ALL}, "disabled"), + Output("btn-add-author", "disabled"), + Input("chk-authors-correct", "value"), + State({"type": "auth-name", "index": ALL}, "id"), ) def lock_authors(is_correct, ids): is_corr = bool(is_correct) if not ids: return [], [], [], [], is_corr length = len(ids) - return [is_corr]*length, [is_corr]*length, [is_corr]*length, [is_corr]*length, is_corr + return ( + [is_corr] * length, + [is_corr] * length, + [is_corr] * length, + [is_corr] * length, + is_corr, + ) @app.callback( - Output('final-output', 'children'), - Input('btn-final-upload', 'n_clicks'), - State('input-doi', 'value'), - State('input-abstract', 'value'), - State('input-journal', 'value'), - State('input-date', 'value'), - State('input-link', 'value'), - State('input-category', 'value'), - State('input-type', 'value'), - State('input-title', 'value'), - State({'type': 'auth-name', 'index': ALL}, 'value'), - State({'type': 'auth-surname', 'index': ALL}, 'value'), - State({'type': 'auth-email', 'index': ALL}, 'value'), - prevent_initial_call=True + Output("final-output", "children"), + Input("btn-final-upload", "n_clicks"), + State("input-doi", "value"), + State("input-abstract", "value"), + State("input-journal", "value"), + State("input-date", "value"), + State("input-link", "value"), + State("input-category", "value"), + State("input-type", "value"), + State("input-title", "value"), + State({"type": "auth-name", "index": ALL}, "value"), + State({"type": "auth-surname", "index": ALL}, "value"), + State({"type": "auth-email", "index": ALL}, "value"), + prevent_initial_call=True, ) -def finalize_and_display_json(n_clicks, doi, abstract, journal, date, link, category, study_type, title, names, surnames, emails): +def finalize_and_display_json( + n_clicks, + doi, + abstract, + journal, + date, + link, + category, + study_type, + title, + names, + surnames, + emails, +): authors_list = [ {"name": n, "surname": s, "email": e} - for n, s, e in zip(names, surnames, emails) if n or s + for n, s, e in zip(names, surnames, emails) + if n or s ] metadata_json = { @@ -394,14 +505,25 @@ def finalize_and_display_json(n_clicks, doi, abstract, journal, date, link, cate "doi": doi, "article_link": link, "abstract": abstract, - "authors": authors_list + "authors": authors_list, } - return html.Div([ - dbc.Alert("Successfully contributed, thank you!", color="success"), - html.H4("Metadata JSON"), - html.Pre(json.dumps(metadata_json, indent=4), style={'backgroundColor': '#f8f9fa', 'padding': '15px', 'borderRadius': '8px', 'border': '1px solid #dee2e6'}) - ]) + return html.Div( + [ + dbc.Alert("Successfully contributed, thank you!", color="success"), + html.H4("Metadata JSON"), + html.Pre( + json.dumps(metadata_json, indent=4), + style={ + "backgroundColor": "#f8f9fa", + "padding": "15px", + "borderRadius": "8px", + "border": "1px solid #dee2e6", + }, + ), + ] + ) + if __name__ == "__main__": app.run(debug=True) diff --git a/eu_fact_force/dash-app/data/search_results.json b/eu_fact_force/dash-app/data/search_results.json new file mode 100644 index 0000000..2b7305f --- /dev/null +++ b/eu_fact_force/dash-app/data/search_results.json @@ -0,0 +1,98 @@ +{ + "status": "success", + "narrative": { + "theme": "Test local" + }, + "chunks": [ + { + "type": "text", + "content": "This is the text from chunk 0...", + "score": 0.94, + "metadata": { + "document_id": "document_0", + "page": 0, + "keywords": [ + "keyword_0", + "keyword_1" + ] + } + }, + { + "type": "text", + "content": "This is the text from chunk 1...", + "score": 0.90, + "metadata": { + "document_id": "document_0", + "page": 0, + "keywords": [ + "keyword_0", + "keyword_2" + ] + } + }, + { + "type": "text", + "content": "This is the text from chunk 2...", + "score": 0.84, + "metadata": { + "document_id": "document_1", + "page": 0, + "keywords": [ + "keyword_0", + "keyword_1", + "keyword_2" + ] + } + }, + { + "type": "text", + "content": "This is the text from chunk 3...", + "score": 0.70, + "metadata": { + "document_id": "document_1", + "page": 0, + "keywords": [ + "keyword_1", + "keyword_2" + ] + } + }, + { + "type": "text", + "content": "This is the text from chunk 4...", + "score": 0.5, + "metadata": { + "document_id": "document_1", + "page": 0, + "keywords": [ + "keyword_0", + "keyword_2" + ] + } + } + ], + "documents": { + "document_0": { + "link": "...", + "title": "Document 0", + "date": "2025-01-01", + "journal": "Journal A", + "authors": [ + "Author A", + "Author B" + ], + "abstract": "Abstract document 0..." + }, + "document_1": { + "link": "...", + "title": "Document 1", + "date": "2026-01-01", + "journal": "Journal A", + "authors": [ + "Author A", + "Author C" + ], + "abstract": "Abstract document 1..." + } + } +} \ No newline at end of file diff --git a/eu_fact_force/dash-app/pages/graph.py b/eu_fact_force/dash-app/pages/graph.py index 3852ce1..93bc824 100644 --- a/eu_fact_force/dash-app/pages/graph.py +++ b/eu_fact_force/dash-app/pages/graph.py @@ -1,4 +1,4 @@ -from dash import html +from dash import html, dcc import dash_bootstrap_components as dbc import dash_cytoscape as cyto @@ -17,10 +17,11 @@ def make_layout(): html.H5("Search", style={"margin-bottom": "2px"}), width="auto" ), dbc.Col( - dbc.Input( + dcc.Dropdown( id="search-input", - placeholder="Disinformation narrative...", - style={"overflow": "hidden"}, + options=[ + {"label": "Test local", "value": "Test local"}, + ], ) ), dbc.Col( @@ -55,7 +56,7 @@ def make_layout(): id="graph-cytoscape", stylesheet=stylesheet, layout={"name": "cose"}, - style={"width": "100%", "height": "300px"}, + style={"width": "100%", "height": "400px"}, zoomingEnabled=True, userZoomingEnabled=False, ), @@ -90,17 +91,30 @@ def make_layout(): # Filters - # > Keywords - keyword_filter = dbc.Row([html.H6("Keywords")]) - - # > Evidence type - evidence_filter = dbc.Row([html.H6("Evidence type")]) + # > Chunk types + chunk_type_filter = dbc.Row( + [html.H6("Chunk types"), dcc.Dropdown(id="filter_chunk_types", multi=True)] + ) - # > Type of document - doc_type_filter = dbc.Row([html.H6("Document type")]) + # > Keywords + keyword_filter = dbc.Row( + [html.H6("Keywords"), dcc.Dropdown(id="filter_keywords", multi=True)] + ) - # > Paper filters - paper_filter = dbc.Row([html.H6("Paper filters")]) + # > Document filters + document_filter = dbc.Row( + [ + html.H6("Document filters"), + html.P("Date", style={"margin-bottom": 0, "margin-top": "5px"}), + dcc.DatePickerRange(id="filter_dates"), + html.P("Journal", style={"margin-bottom": 0, "margin-top": "5px"}), + dcc.Dropdown(id="filter_journals", multi=True), + html.P("Authors", style={"margin-bottom": 0, "margin-top": "5px"}), + dcc.Dropdown(id="filter_authors", multi=True), + html.P("Documents", style={"margin-bottom": 0, "margin-top": "5px"}), + dcc.Dropdown(id="filter_documents", multi=True), + ] + ) filter_results = html.Div( id="filters", @@ -108,11 +122,9 @@ def make_layout(): html.H5("Filters"), keyword_filter, html.Br(), - evidence_filter, + chunk_type_filter, html.Br(), - doc_type_filter, - html.Br(), - paper_filter, + document_filter, html.Br(), ], style={ @@ -126,11 +138,12 @@ def make_layout(): results = html.Div( id="results", children=dbc.Row( - [dbc.Col(filter_results, width=3), dbc.Col(list_results, width=9)] + [ + dbc.Col(filter_results, width=3), + dbc.Col([graph_results, html.Br(), list_results], width=9), + ] ), style={"display": "none"}, ) - return html.Div( - [search_bar, html.Br(), graph_results, html.Br(), results, offcanevas] - ) + return html.Div([search_bar, html.Br(), results, offcanevas]) diff --git a/eu_fact_force/dash-app/utils/colors.py b/eu_fact_force/dash-app/utils/colors.py index 875cef2..5210883 100644 --- a/eu_fact_force/dash-app/utils/colors.py +++ b/eu_fact_force/dash-app/utils/colors.py @@ -2,6 +2,7 @@ class EUPHAColors: dark_blue = "#2d61a4" light_blue = "#7fa7d3" white = "#ffffff" - green = "#a0c063" + dark_green = "#60733d" + light_green = "#a0c063" orange = "#f29f05" black = "#161616" diff --git a/eu_fact_force/dash-app/utils/graph.py b/eu_fact_force/dash-app/utils/graph.py index 4039ec7..6efcb17 100644 --- a/eu_fact_force/dash-app/utils/graph.py +++ b/eu_fact_force/dash-app/utils/graph.py @@ -1,4 +1,5 @@ import random +import json from .colors import EUPHAColors @@ -9,24 +10,43 @@ "label": "data(label)", "text-valign": "center", "color": "black", + "font-size": 10, }, }, { - "selector": 'node[type="paper"]', + "selector": 'node[type="chunk"]', "style": { - "background-color": EUPHAColors.green, + "background-color": EUPHAColors.light_green, }, }, { - "selector": 'node[type="journal"]', + "selector": 'node[type="document"]', "style": { "background-color": EUPHAColors.orange, }, }, + { + "selector": 'node[type="author"]', + "style": { + "background-color": EUPHAColors.light_blue, + }, + }, + { + "selector": 'node[type="journal"]', + "style": { + "background-color": EUPHAColors.dark_blue, + }, + }, + { + "selector": 'node[type="keyword"]', + "style": { + "background-color": EUPHAColors.dark_green, + }, + }, { "selector": "edge", "style": { - "width": 2, + "width": 1, "line-color": "black", }, }, @@ -65,3 +85,113 @@ def get_graph_data(self): } ) return nodes + self.nodes_journal + edges + + +class TestGraph: + def __init__(self): + self.load_search_results() + self.stylesheet = stylesheet + + def load_search_results(self): + with open("data/search_results.json", "r") as f: + self.search_results = json.load(f) + + def transform(self): + nodes = {} + edges = [] + filters = { + "chunk_types": [], + "documents": [], + "journal": [], + "keywords": [], + "authors": [], + "date": [], + } + + # chunks + for i, chunk in enumerate(self.search_results["chunks"]): + chunk_id = f"chunk_{i}" + filters["chunk_types"].append(chunk["type"]) + nodes[chunk_id] = { + "data": { + "id": chunk_id, + "label": chunk_id, + "type": "chunk", + "metadata": chunk, + } + } + # documents + document_id = chunk["metadata"]["document_id"] + document_metadata = self.search_results["documents"][document_id] + filters["documents"].append(document_id) + filters["date"].append(document_metadata["date"]) + if document_id not in nodes: + nodes[document_id] = { + "data": { + "id": document_id, + "label": document_metadata["title"], + "type": "document", + "metadata": document_metadata, + } + } + edges.append( + { + "data": { + "source": chunk_id, + "target": document_id, + } + } + ) + # journal and authors + journal_id = f"journal_{document_metadata['journal']}" + filters["journal"].append(journal_id) + if journal_id not in nodes: + nodes[journal_id] = { + "data": { + "id": journal_id, + "label": document_metadata["journal"], + "type": "journal", + } + } + edges.append( + { + "data": { + "source": document_id, + "target": journal_id, + } + } + ) + for author in document_metadata["authors"]: + author_id = f"author_{author}" + filters["authors"].append(author_id) + if author_id not in nodes: + nodes[author_id] = { + "data": {"id": author_id, "label": author, "type": "author"} + } + edges.append( + { + "data": { + "source": document_id, + "target": author_id, + } + } + ) + + # keywords + for keyword in chunk["metadata"]["keywords"]: + keyword_id = f"keyword_{keyword}" + filters["keywords"].append(keyword_id) + if keyword_id not in nodes: + nodes[keyword_id] = { + "data": {"id": keyword_id, "label": keyword, "type": "keyword"} + } + edges.append( + { + "data": { + "source": chunk_id, + "target": keyword_id, + } + } + ) + + return nodes, edges, filters From 56f79f46628fcbdb8c2ac20692d4ca32a887c9fa Mon Sep 17 00:00:00 2001 From: Hugo De Oliveira <80337112+hugros-93@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:01:59 +0200 Subject: [PATCH 5/6] deprecte random graph, add filtering on node type --- eu_fact_force/dash-app/app.py | 37 ++++++++++++++++---- eu_fact_force/dash-app/assets/custom.css | 13 +++++++ eu_fact_force/dash-app/pages/graph.py | 43 +++++++++++++++++------ eu_fact_force/dash-app/utils/graph.py | 44 +++++------------------- 4 files changed, 84 insertions(+), 53 deletions(-) diff --git a/eu_fact_force/dash-app/app.py b/eu_fact_force/dash-app/app.py index 58ead02..d87a1a5 100644 --- a/eu_fact_force/dash-app/app.py +++ b/eu_fact_force/dash-app/app.py @@ -173,12 +173,13 @@ def activate_search_buton(search_text, graph): return False -# Callback get search data +# Callback search data @app.callback( [ Output("store-search", "data"), Output("results", "style"), Output("search-input", "value"), + Output("filter_node_types", "options"), Output("filter_chunk_types", "options"), Output("filter_keywords", "options"), Output("filter_documents", "options"), @@ -186,6 +187,7 @@ def activate_search_buton(search_text, graph): Output("filter_authors", "options"), Output("filter_dates", "min_date_allowed"), Output("filter_dates", "max_date_allowed"), + Output("filter_node_types", "value"), Output("filter_chunk_types", "value"), Output("filter_keywords", "value"), Output("filter_documents", "value"), @@ -198,15 +200,19 @@ def activate_search_buton(search_text, graph): state=[State("search-input", "value")], prevent_updates=True, ) -def load_data(n_clicks, search_text): +def get_search_data(n_clicks, search_text): if n_clicks > 0: nodes, edges, filters = TestGraph().transform() return [ {"nodes": nodes, "edges": edges}, { "display": "block", + "border-radius": "15px", + "padding": "20px", + "background-color": EUPHAColors.white, }, "", + list(set(filters["node_types"])), list(set(filters["chunk_types"])), list(set(filters["keywords"])), list(set(filters["documents"])), @@ -214,6 +220,7 @@ def load_data(n_clicks, search_text): list(set(filters["authors"])), min(filters["date"]), max(filters["date"]), + list(set(filters["node_types"])), list(set(filters["chunk_types"])), list(set(filters["keywords"])), list(set(filters["documents"])), @@ -226,7 +233,7 @@ def load_data(n_clicks, search_text): raise PreventUpdate -# Callback update graph +# Callback update graph and list @app.callback( [ Output("graph-cytoscape", "elements"), @@ -235,6 +242,7 @@ def load_data(n_clicks, search_text): ], inputs=[ Input("store-search", "data"), + Input("filter_node_types", "value"), Input("filter_chunk_types", "value"), Input("filter_keywords", "value"), Input("filter_documents", "value"), @@ -245,8 +253,9 @@ def load_data(n_clicks, search_text): ], prevent_updates=True, ) -def update_graph( +def update_graph_and_list( store_search, + filter_node_types, filter_chunk_types, filter_keywords, filter_documents, @@ -262,8 +271,22 @@ def update_graph( nodes = store_search["nodes"] edges = store_search["edges"] - # Filters - # TODO: filter nodes and edges + # Filters TODO: update filtering step here using: + # > filter_chunk_types + # > filter_keywords + # > filter_documents + # > filter_journals + # > filter_authors + # > start_date + # > end_date + nodes = { + n: nodes[n] for n in nodes if nodes[n]["data"]["type"] in filter_node_types + } + edges = [ + e + for e in edges + if e["data"]["source"] in nodes and e["data"]["target"] in nodes + ] # Graph elements graph_elements = [nodes[x] for x in nodes] + edges @@ -296,7 +319,7 @@ def update_graph( ] -# Callback show selected element +# Callback focus selected element @app.callback( [ Output("offcanvas", "is_open"), diff --git a/eu_fact_force/dash-app/assets/custom.css b/eu_fact_force/dash-app/assets/custom.css index 013cb23..82b4e48 100644 --- a/eu_fact_force/dash-app/assets/custom.css +++ b/eu_fact_force/dash-app/assets/custom.css @@ -79,3 +79,16 @@ hr { background-color: var(--color-light-blue); color: var(--color-black) !important; } + +/* Tabs */ +.card { + --bs-card-border-color: none !important; +} + +.nav-tabs .nav-link { + color: var(--color-black) !important; +} + +.nav-tabs .nav-link.active { + color: var(--color-black) !important; +} \ No newline at end of file diff --git a/eu_fact_force/dash-app/pages/graph.py b/eu_fact_force/dash-app/pages/graph.py index 93bc824..86599a2 100644 --- a/eu_fact_force/dash-app/pages/graph.py +++ b/eu_fact_force/dash-app/pages/graph.py @@ -51,12 +51,11 @@ def make_layout(): graph_results = html.Div( id="graph", children=[ - html.H5("Graph"), cyto.Cytoscape( id="graph-cytoscape", stylesheet=stylesheet, layout={"name": "cose"}, - style={"width": "100%", "height": "400px"}, + style={"width": "100%", "height": "500px"}, zoomingEnabled=True, userZoomingEnabled=False, ), @@ -81,7 +80,7 @@ def make_layout(): # List list_results = html.Div( id="list", - children=[html.H5("List of results"), html.Div(id="list-elements")], + children=[html.Div(id="list-elements")], style={ "border-radius": "15px", "padding": "20px", @@ -91,9 +90,20 @@ def make_layout(): # Filters + # > Nodes + node_type_filter = dbc.Row( + [ + html.H6("Nodes"), + dcc.Dropdown(id="filter_node_types", multi=True, searchable=False), + ] + ) + # > Chunk types chunk_type_filter = dbc.Row( - [html.H6("Chunk types"), dcc.Dropdown(id="filter_chunk_types", multi=True)] + [ + html.H6("Chunk types"), + dcc.Dropdown(id="filter_chunk_types", multi=True, searchable=False), + ] ) # > Keywords @@ -104,7 +114,7 @@ def make_layout(): # > Document filters document_filter = dbc.Row( [ - html.H6("Document filters"), + html.H6("Documents"), html.P("Date", style={"margin-bottom": 0, "margin-top": "5px"}), dcc.DatePickerRange(id="filter_dates"), html.P("Journal", style={"margin-bottom": 0, "margin-top": "5px"}), @@ -120,6 +130,8 @@ def make_layout(): id="filters", children=[ html.H5("Filters"), + node_type_filter, + html.Br(), keyword_filter, html.Br(), chunk_type_filter, @@ -127,11 +139,20 @@ def make_layout(): document_filter, html.Br(), ], - style={ - "border-radius": "15px", - "padding": "20px", - "background-color": EUPHAColors.white, - }, + ) + + # Tabs + tab_graph = dbc.Card(dbc.CardBody([graph_results, offcanevas])) + + tab_list = dbc.Card( + dbc.CardBody([list_results]), + ) + + tabs = dbc.Tabs( + [ + dbc.Tab(tab_graph, label="Graph"), + dbc.Tab(tab_list, label="List"), + ] ) # Results @@ -140,7 +161,7 @@ def make_layout(): children=dbc.Row( [ dbc.Col(filter_results, width=3), - dbc.Col([graph_results, html.Br(), list_results], width=9), + dbc.Col(tabs, width=9), ] ), style={"display": "none"}, diff --git a/eu_fact_force/dash-app/utils/graph.py b/eu_fact_force/dash-app/utils/graph.py index 6efcb17..53838dc 100644 --- a/eu_fact_force/dash-app/utils/graph.py +++ b/eu_fact_force/dash-app/utils/graph.py @@ -1,4 +1,3 @@ -import random import json from .colors import EUPHAColors @@ -53,53 +52,28 @@ ] -class RandomGraphGenerator: - def __init__(self): - self.n_min_paper_nodes = 5 - self.n_max_paper_nodes = 10 - self.nodes_paper = [ - {"data": {"id": f"node_paper_{i}", "label": f"Paper {i}", "type": "paper"}} - for i in range(self.n_max_paper_nodes) - ] - self.nodes_journal = [ - {"data": {"id": "node_journal_0", "label": "Journal A", "type": "journal"}}, - {"data": {"id": "node_journal_1", "label": "Journal B", "type": "journal"}}, - {"data": {"id": "node_journal_2", "label": "Journal C", "type": "journal"}}, - ] - self.stylesheet = stylesheet - - def get_graph_data(self): - nodes = random.sample( - self.nodes_paper, - random.randint(self.n_min_paper_nodes, self.n_max_paper_nodes), - ) - edges = [] - for source_node in nodes: - target_node = random.sample(self.nodes_journal, 1)[0] - edges.append( - { - "data": { - "source": source_node["data"]["id"], - "target": target_node["data"]["id"], - } - } - ) - return nodes + self.nodes_journal + edges - - class TestGraph: + """Test graph object from static JSON file.""" + def __init__(self): self.load_search_results() self.stylesheet = stylesheet def load_search_results(self): + """Load JSON file from data/""" with open("data/search_results.json", "r") as f: self.search_results = json.load(f) def transform(self): + """Parse JSON file and create nodes (dict), edges (list) and filters (dict).""" nodes = {} edges = [] filters = { + "node_types": [ + x["selector"].split('type="')[1].split('"')[0] + for x in self.stylesheet + if "type" in x["selector"] + ], "chunk_types": [], "documents": [], "journal": [], From 0ad600109eb7b2350d6cc07aade4340c50318322 Mon Sep 17 00:00:00 2001 From: Hugo De Oliveira <80337112+hugros-93@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:17:32 +0200 Subject: [PATCH 6/6] implement remaining filters --- eu_fact_force/dash-app/app.py | 121 ++++++++++++++++++++++++-- eu_fact_force/dash-app/utils/graph.py | 17 ++-- 2 files changed, 122 insertions(+), 16 deletions(-) diff --git a/eu_fact_force/dash-app/app.py b/eu_fact_force/dash-app/app.py index d87a1a5..178f7f4 100644 --- a/eu_fact_force/dash-app/app.py +++ b/eu_fact_force/dash-app/app.py @@ -271,23 +271,128 @@ def update_graph_and_list( nodes = store_search["nodes"] edges = store_search["edges"] - # Filters TODO: update filtering step here using: - # > filter_chunk_types - # > filter_keywords - # > filter_documents - # > filter_journals - # > filter_authors - # > start_date - # > end_date + # Filter node type nodes = { n: nodes[n] for n in nodes if nodes[n]["data"]["type"] in filter_node_types } + + # Filter chunk type + nodes = { + n: nodes[n] + for n in nodes + if nodes[n]["data"]["type"] != "chunk" + or nodes[n]["data"]["metadata"]["type"] in filter_chunk_types + } + + # Filter keywords + nodes = { + n: nodes[n] + for n in nodes + if nodes[n]["data"]["type"] not in ("chunk", "keyword") + or ( + nodes[n]["data"]["type"] == "chunk" + and any( + item in filter_keywords + for item in nodes[n]["data"]["metadata"]["metadata"]["keywords"] + ) + ) + or ( + nodes[n]["data"]["type"] == "keyword" + and nodes[n]["data"]["label"] in filter_keywords + ) + } + + # Filter dates + nodes = { + n: nodes[n] + for n in nodes + if nodes[n]["data"]["type"] not in ("chunk", "document") + or ( + nodes[n]["data"]["type"] == "chunk" + and nodes[n]["data"]["document_metadata"]["date"] >= start_date + and nodes[n]["data"]["document_metadata"]["date"] <= end_date + ) + or ( + nodes[n]["data"]["type"] == "document" + and nodes[n]["data"]["metadata"]["date"] >= start_date + and nodes[n]["data"]["metadata"]["date"] <= end_date + ) + } + + # Filter documents + nodes = { + n: nodes[n] + for n in nodes + if nodes[n]["data"]["type"] not in ("chunk", "document") + or ( + nodes[n]["data"]["type"] == "chunk" + and nodes[n]["data"]["metadata"]["metadata"]["document_id"] + in filter_documents + ) + or ( + nodes[n]["data"]["type"] == "document" + and nodes[n]["data"]["id"] in filter_documents + ) + } + + # Filter journals + nodes = { + n: nodes[n] + for n in nodes + if nodes[n]["data"]["type"] not in ("chunk", "document", "journal") + or ( + nodes[n]["data"]["type"] == "chunk" + and nodes[n]["data"]["document_metadata"]["journal"] in filter_journals + ) + or ( + nodes[n]["data"]["type"] == "document" + and nodes[n]["data"]["metadata"]["journal"] in filter_journals + ) + or ( + nodes[n]["data"]["type"] == "journal" + and nodes[n]["data"]["label"] in filter_journals + ) + } + + # Filter authors + nodes = { + n: nodes[n] + for n in nodes + if nodes[n]["data"]["type"] not in ("chunk", "document", "author") + or ( + nodes[n]["data"]["type"] == "chunk" + and any( + item in filter_authors + for item in nodes[n]["data"]["document_metadata"]["authors"] + ) + ) + or ( + nodes[n]["data"]["type"] == "document" + and any( + item in filter_authors + for item in nodes[n]["data"]["metadata"]["authors"] + ) + ) + or ( + nodes[n]["data"]["type"] == "author" + and nodes[n]["data"]["label"] in filter_authors + ) + } + + # Update edges edges = [ e for e in edges if e["data"]["source"] in nodes and e["data"]["target"] in nodes ] + # Clean nodes without any edge + nodes = { + n: nodes[n] + for n in nodes + if any(n == e["data"]["source"] or n == e["data"]["target"] for e in edges) + } + # Graph elements graph_elements = [nodes[x] for x in nodes] + edges diff --git a/eu_fact_force/dash-app/utils/graph.py b/eu_fact_force/dash-app/utils/graph.py index 53838dc..d49acdc 100644 --- a/eu_fact_force/dash-app/utils/graph.py +++ b/eu_fact_force/dash-app/utils/graph.py @@ -85,20 +85,21 @@ def transform(self): # chunks for i, chunk in enumerate(self.search_results["chunks"]): chunk_id = f"chunk_{i}" + document_id = chunk["metadata"]["document_id"] + document_metadata = self.search_results["documents"][document_id] filters["chunk_types"].append(chunk["type"]) + filters["documents"].append(document_id) + filters["date"].append(document_metadata["date"]) nodes[chunk_id] = { "data": { "id": chunk_id, "label": chunk_id, "type": "chunk", "metadata": chunk, + "document_metadata": document_metadata, } } - # documents - document_id = chunk["metadata"]["document_id"] - document_metadata = self.search_results["documents"][document_id] - filters["documents"].append(document_id) - filters["date"].append(document_metadata["date"]) + if document_id not in nodes: nodes[document_id] = { "data": { @@ -118,7 +119,7 @@ def transform(self): ) # journal and authors journal_id = f"journal_{document_metadata['journal']}" - filters["journal"].append(journal_id) + filters["journal"].append(document_metadata["journal"]) if journal_id not in nodes: nodes[journal_id] = { "data": { @@ -137,7 +138,7 @@ def transform(self): ) for author in document_metadata["authors"]: author_id = f"author_{author}" - filters["authors"].append(author_id) + filters["authors"].append(author) if author_id not in nodes: nodes[author_id] = { "data": {"id": author_id, "label": author, "type": "author"} @@ -154,7 +155,7 @@ def transform(self): # keywords for keyword in chunk["metadata"]["keywords"]: keyword_id = f"keyword_{keyword}" - filters["keywords"].append(keyword_id) + filters["keywords"].append(keyword) if keyword_id not in nodes: nodes[keyword_id] = { "data": {"id": keyword_id, "label": keyword, "type": "keyword"}