Ajout d’une version anglaise

#1
.gitattributes CHANGED
@@ -41,5 +41,3 @@ application_neo4j/static/notice/notice_html/media/image2.png filter=lfs diff=lfs
41
  application_neo4j/static/notice/notice_html/media/image4.png filter=lfs diff=lfs merge=lfs -text
42
  application_neo4j/static/notice/notice.docx filter=lfs diff=lfs merge=lfs -text
43
  application_neo4j/static/notice/notice.pdf filter=lfs diff=lfs merge=lfs -text
44
- opengds/*.jar filter=lfs diff=lfs merge=lfs -text
45
- application_neo4j/static/notice/notice_en.pdf filter=lfs diff=lfs merge=lfs -text
 
41
  application_neo4j/static/notice/notice_html/media/image4.png filter=lfs diff=lfs merge=lfs -text
42
  application_neo4j/static/notice/notice.docx filter=lfs diff=lfs merge=lfs -text
43
  application_neo4j/static/notice/notice.pdf filter=lfs diff=lfs merge=lfs -text
 
 
.gitignore CHANGED
@@ -1,3 +1,2 @@
1
  __pycache__/
2
  *.pyc
3
- .venv/
 
1
  __pycache__/
2
  *.pyc
 
Dockerfile CHANGED
@@ -4,9 +4,7 @@ FROM neo4j:2025.09.0-community
4
  RUN mkdir -p /backups
5
  RUN chmod 640 /var/lib/neo4j/conf/neo4j.conf
6
  RUN chmod 640 /var/lib/neo4j/conf/neo4j-admin.conf
7
- ENV NEO4J_PLUGINS='[]'
8
- ENV NEO4J_dbms_security_procedures_unrestricted='gds.*'
9
- COPY opengds/open-gds-2.22.0-genmod.jar /var/lib/neo4j/plugins/graph-data-science.jar
10
 
11
  # python
12
  RUN apt-get update \
 
4
  RUN mkdir -p /backups
5
  RUN chmod 640 /var/lib/neo4j/conf/neo4j.conf
6
  RUN chmod 640 /var/lib/neo4j/conf/neo4j-admin.conf
7
+ ENV NEO4J_PLUGINS='["graph-data-science"]'
 
 
8
 
9
  # python
10
  RUN apt-get update \
application_neo4j/README.md CHANGED
@@ -130,26 +130,3 @@ Lors de son lancement, le Space charge un export de la base de données neo4j de
130
 
131
  Puisque le dataset est privé sur Hugging Face, le Space utilise un secret `HF_TOKEN` qui donne accès en lecture au dataset.
132
 
133
- ### Recherches simultanées
134
-
135
- L'application accepte par défaut deux recherches simultanées. Chaque recherche
136
- conserve son propre identifiant, son état de progression et son résultat. Les
137
- requêtes supplémentaires restent dans l'état `queued` jusqu'à ce qu'un worker
138
- soit disponible.
139
-
140
- Le nombre de recherches simultanées peut être configuré avec la variable
141
- d'environnement `SEARCH_MAX_WORKERS`. La valeur doit être un entier strictement
142
- positif ; une valeur absente ou invalide utilise la valeur par défaut `2`.
143
-
144
- La création initiale des projections GDS reste sérialisée afin que deux
145
- requêtes arrivant au démarrage ne tentent pas de créer le même graphe. Une fois
146
- les projections disponibles, les BFS et la construction de leurs résultats
147
- s'exécutent indépendamment.
148
-
149
- Lorsqu'un utilisateur quitte la page pendant une recherche, le navigateur
150
- envoie une demande d'annulation. Un job encore en file est retiré
151
- immédiatement. Pour un job actif, l'application termine la transaction Neo4j
152
- identifiée par les métadonnées du job, puis vérifie aussi un drapeau
153
- d'annulation entre les lots de construction du résultat. Le signal envoyé par
154
- le navigateur est une garantie au mieux : une fermeture brutale du processus
155
- ou une coupure réseau peut empêcher son émission.
 
130
 
131
  Puisque le dataset est privé sur Hugging Face, le Space utilise un secret `HF_TOKEN` qui donne accès en lecture au dataset.
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
application_neo4j/app.py CHANGED
@@ -1,22 +1,14 @@
1
  # --- Import et initialisation Flask ---
2
  import os
3
  from flask import Flask, request, render_template, jsonify, session, redirect, url_for
4
- from neo4j import GraphDatabase, Query, basic_auth
5
  from graphdatascience import GraphDataScience
6
  import app_algorithms as algo
7
  from translations import t
8
  import pandas as pd
9
  from typing import Dict
10
  from collections import defaultdict
11
- from concurrent.futures import ThreadPoolExecutor
12
- from threading import Lock
13
- from time import time
14
- from uuid import uuid4
15
  import argparse
16
- import atexit
17
- import math
18
- import json
19
- from datetime import datetime
20
 
21
  app = Flask(__name__, static_url_path="/static/") # Application Flask
22
  app.secret_key = os.urandom(24)
@@ -24,72 +16,6 @@ app.secret_key = os.urandom(24)
24
  # --- Connexion à Neo4j et GDS ---
25
  NEO4J_URI = "bolt://localhost:7687"
26
  GDS_GRAPH_NAME = "genealogie_gds"
27
- GDS_RELATIONSHIP_TYPES = ("IS_IN", "POSTED", "USED_IN")
28
- SEARCH_JOB_TTL_SECONDS = 60 * 60
29
- RESULT_BATCH_SIZE = 25
30
- DEFAULT_SEARCH_MAX_WORKERS = 2
31
- DATABASE_METADATA_PATH = os.environ.get(
32
- "DATABASE_METADATA_PATH",
33
- "/backups/database_metadata.json",
34
- )
35
-
36
-
37
- def database_dates():
38
- """Return display dates from metadata shipped with the loaded dump."""
39
- fallback = {"fr": "01/09/2025", "en": "2025-09-01"}
40
- try:
41
- with open(DATABASE_METADATA_PATH, encoding="utf-8") as metadata_file:
42
- built_at = json.load(metadata_file)["built_at"]
43
- built_date = datetime.fromisoformat(built_at.replace("Z", "+00:00")).date()
44
- except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError):
45
- return fallback
46
- return {
47
- "fr": built_date.strftime("%d/%m/%Y"),
48
- "en": built_date.isoformat(),
49
- }
50
-
51
-
52
- DATABASE_DATES = database_dates()
53
-
54
-
55
- def positive_int_env(name, default):
56
- """Read a strictly positive integer setting, falling back safely."""
57
- try:
58
- value = int(os.environ.get(name, default))
59
- except (TypeError, ValueError):
60
- return default
61
- return value if value > 0 else default
62
-
63
-
64
- # Neo4j's driver and the projected GDS graphs support concurrent read jobs.
65
- # Keep the pool bounded because every BFS is CPU- and memory-intensive. Jobs
66
- # beyond this limit remain visible in the existing "queued" stage.
67
- SEARCH_MAX_WORKERS = positive_int_env(
68
- "SEARCH_MAX_WORKERS",
69
- DEFAULT_SEARCH_MAX_WORKERS,
70
- )
71
- search_executor = ThreadPoolExecutor(
72
- max_workers=SEARCH_MAX_WORKERS,
73
- thread_name_prefix="search",
74
- )
75
- search_jobs = {}
76
- search_jobs_lock = Lock()
77
- graph_projection_lock = Lock()
78
-
79
-
80
- class SearchCancelled(Exception):
81
- """Raised by a worker when its browser no longer needs the result."""
82
-
83
-
84
- def search_cancel_requested(job_id):
85
- with search_jobs_lock:
86
- job = search_jobs.get(job_id)
87
- return bool(job and job.get("cancel_requested"))
88
-
89
-
90
- def raise_if_search_cancelled(job_id):
91
- if search_cancel_requested(job_id):
92
- raise SearchCancelled()
93
 
94
  # --- Configuration des arguments du script ---
95
  parser = argparse.ArgumentParser(description="Script pour lancer la web application.")
@@ -119,12 +45,7 @@ except Exception as e:
119
  exit() # Si la connexion échoue, l'application ne peut pas tourner
120
 
121
  # --- Projection du graphe pour GDS ---
122
- def ensure_graph_projected(
123
- gds: GraphDataScience,
124
- graph_name,
125
- search_job_id=None,
126
- progress_callback=None,
127
- ):
128
  """
129
  Projette deux graphes dans :
130
  - Graphe "naturel" : relations descendant (source -> target)
@@ -135,107 +56,50 @@ def ensure_graph_projected(
135
  natural_graph_name = f"{graph_name}_natural"
136
  reverse_graph_name = f"{graph_name}_reverse"
137
 
138
- # Projections are immutable for the lifetime of this Space container: the
139
- # database is restored only at startup. Reusing them makes subsequent
140
- # searches faster and avoids races between concurrent searches.
141
- with graph_projection_lock:
142
- g_natural_exists = gds.graph.exists(natural_graph_name).exists
143
- g_reverse_exists = gds.graph.exists(reverse_graph_name).exists
144
- if g_natural_exists and g_reverse_exists:
145
- return
146
-
147
- if g_natural_exists:
148
- gds.graph.get(natural_graph_name).drop()
149
- if g_reverse_exists:
150
- gds.graph.get(reverse_graph_name).drop()
151
-
152
- relationship_types_result = gds.run_cypher(
153
- """
154
- CALL db.relationshipTypes()
155
- YIELD relationshipType
156
- RETURN relationshipType
157
- """
158
- )
159
- available_relationship_types = set(
160
- relationship_types_result["relationshipType"].tolist()
161
- )
162
- projected_relationship_types = [
163
- relationship_type
164
- for relationship_type in GDS_RELATIONSHIP_TYPES
165
- if relationship_type in available_relationship_types
166
- ]
167
- if not projected_relationship_types:
168
- raise RuntimeError(
169
- "Aucun type de relation compatible avec la recherche GDS "
170
- "n'est présent dans Neo4j."
171
- )
172
- reverse_relationship_projection = {
173
- relationship_type: {
174
- "type": relationship_type,
175
- "orientation": "REVERSE",
176
- }
177
- for relationship_type in projected_relationship_types
178
- }
179
- print(
180
- "Types de relations projetés dans GDS : "
181
- + ", ".join(projected_relationship_types)
182
- )
183
-
184
- # Native projections accept a jobId, allowing gds.listProgress to
185
- # expose real progress while the first search prepares the graph.
186
- natural_projection_job_id = (
187
- f"{search_job_id}-project-natural" if search_job_id else uuid4().hex
188
- )
189
- if progress_callback:
190
- progress_callback("preparing_graph", natural_projection_job_id)
191
-
192
- # --- 1. Projection pour les descendants (sens normal) ---
193
- print("Projection du graphe naturel (descendant)...")
194
- gds.run_cypher(
195
- """
196
- CALL gds.graph.project(
197
- $graph_name,
198
- '*',
199
- $relationship_projection,
200
- {jobId: $job_id}
201
- )
202
- YIELD graphName, nodeCount, relationshipCount
203
- RETURN graphName, nodeCount, relationshipCount
204
- """,
205
- {
206
- "graph_name": natural_graph_name,
207
- "job_id": natural_projection_job_id,
208
- "relationship_projection": projected_relationship_types,
209
- },
210
- )
211
- print(f"Graphe '{natural_graph_name}' projeté.")
212
-
213
- reverse_projection_job_id = (
214
- f"{search_job_id}-project-reverse" if search_job_id else uuid4().hex
215
- )
216
- if progress_callback:
217
- progress_callback("preparing_graph", reverse_projection_job_id)
218
-
219
- # --- 2. Projection pour les ascendants (sens inversé) ---
220
- print("Projection du graphe inversé (ascendant)...")
221
- gds.run_cypher(
222
- """
223
- CALL gds.graph.project(
224
- $graph_name,
225
- '*',
226
- $relationship_projection,
227
- {jobId: $job_id}
228
- )
229
- YIELD graphName, nodeCount, relationshipCount
230
- RETURN graphName, nodeCount, relationshipCount
231
- """,
232
- {
233
- "graph_name": reverse_graph_name,
234
- "job_id": reverse_projection_job_id,
235
- "relationship_projection": reverse_relationship_projection,
236
- },
237
- )
238
- print(f"Graphe '{reverse_graph_name}' projeté.")
239
 
240
  @app.before_request
241
  def set_language():
@@ -267,10 +131,7 @@ def set_language_route():
267
  def inject_i18n():
268
  """Inject t() and current_lang into all Jinja2 templates."""
269
  def _t(key, **kwargs):
270
- lang = session.get("lang", "fr")
271
- if key in {"site.nav_subtitle_full", "home.download_date"}:
272
- kwargs.setdefault("date", DATABASE_DATES[lang])
273
- return t(key, lang, **kwargs)
274
  # Dictionnaire JS pour les clés utilisées côté client
275
  js_i18n_keys = [
276
  "js.node_info.name", "js.node_info.type", "js.node_info.followers",
@@ -278,23 +139,6 @@ def inject_i18n():
278
  "js.node_info.dataset", "js.node_info.undefined", "js.node_info.unknown_date",
279
  "js.node_info.see_on_hf",
280
  "js.unknown", "js.unknown_f", "js.na",
281
- "search.progress_title",
282
- "search.progress_stage_queued", "search.progress_stage_preparing",
283
- "search.progress_stage_descendants", "search.progress_stage_ancestors",
284
- "search.progress_stage_result", "search.progress_stage_nodes",
285
- "search.progress_stage_relationships", "search.progress_stage_formatting",
286
- "search.progress_stage_highlights",
287
- "search.progress_stage_completed", "search.progress_stage_failed",
288
- "search.progress_stage_cancelled",
289
- "search.progress_elapsed", "search.progress_items",
290
- "search.progress_queue_title",
291
- "search.progress_queue_position", "search.progress_queue_ahead_one",
292
- "search.progress_queue_ahead_many", "search.progress_queue_next",
293
- "search.progress_queue_running_one", "search.progress_queue_running_many",
294
- "search.progress_queue_note",
295
- "search.progress_server_percent",
296
- "search.progress_remaining_step", "search.progress_server_note",
297
- "search.progress_connection_error",
298
  ]
299
  lang = session.get("lang", "fr")
300
  js_i18n_data = {k: t(k, lang) for k in js_i18n_keys}
@@ -326,16 +170,13 @@ def autocomplete():
326
  if node_filter and node_filter in ["Model", "Dataset"]: # Mesure de sécurité
327
  label_cypher = f":{node_filter}"
328
 
329
- # Récupère les noms commençant par le préfixe fourni. Les suggestions les
330
- # plus téléchargées sont proposées en premier ; le nom garantit un ordre
331
- # stable lorsque plusieurs éléments ont le même nombre de téléchargements.
332
  cypher = f"""
333
  MATCH (n{label_cypher})
334
  WHERE toLower(n.name) STARTS WITH toLower($prefix)
335
  AND n.name IS NOT NULL
336
- RETURN n.name AS name, labels(n)[0] AS label,
337
- coalesce(n.downloads, 0) AS downloads
338
- ORDER BY downloads DESC, toLower(n.name) ASC
339
  LIMIT 10
340
  """
341
  try:
@@ -348,490 +189,12 @@ def autocomplete():
348
  return jsonify([])
349
 
350
 
351
- def update_search_job(job_id, **changes):
352
- """Update a background search job without exposing mutable shared state."""
353
- with search_jobs_lock:
354
- job = search_jobs.get(job_id)
355
- if not job:
356
- return
357
- stage_changed = (
358
- "stage" in changes and changes["stage"] != job.get("stage")
359
- )
360
- gds_operation_changed = (
361
- changes.get("gds_job_id")
362
- and changes["gds_job_id"] != job.get("gds_job_id")
363
- )
364
- if stage_changed or gds_operation_changed:
365
- changes.setdefault("stage_started_at", time())
366
- job.update(changes)
367
-
368
-
369
- def set_search_stage(job_id, stage, gds_job_id=None):
370
- update_search_job(
371
- job_id,
372
- stage=stage,
373
- gds_job_id=gds_job_id,
374
- application_progress_percent=None,
375
- completed_items=None,
376
- total_items=None,
377
- )
378
-
379
-
380
- def report_application_progress(job_id, stage, completed_items, total_items):
381
- percent = (
382
- completed_items * 100 / total_items
383
- if total_items
384
- else None
385
- )
386
- update_search_job(
387
- job_id,
388
- stage=stage,
389
- gds_job_id=None,
390
- application_progress_percent=percent,
391
- completed_items=completed_items,
392
- total_items=total_items,
393
- )
394
-
395
-
396
- def cleanup_search_jobs():
397
- cutoff = time() - SEARCH_JOB_TTL_SECONDS
398
- with search_jobs_lock:
399
- expired_ids = [
400
- job_id for job_id, job in search_jobs.items()
401
- if job.get("created_at", 0) < cutoff
402
- ]
403
- for job_id in expired_ids:
404
- del search_jobs[job_id]
405
-
406
-
407
- def make_search_result(name, depth, is_unlimited, filters, expert):
408
- return {
409
- "template": "expert.html" if expert else "search.html",
410
- "message": None,
411
- "message_key": None,
412
- "message_kwargs": {},
413
- "search": {
414
- "name": name,
415
- "depth": depth,
416
- "unlimited": is_unlimited,
417
- "filters": filters,
418
- },
419
- "graph_data": {"nodes": [], "edges": [], "models_count": []},
420
- "highlights": {},
421
- }
422
-
423
-
424
- def set_search_result_message(result, key, lang, **kwargs):
425
- """Store a translatable message while keeping its current rendering."""
426
- result["message_key"] = key
427
- result["message_kwargs"] = kwargs
428
- result["message"] = t(key, lang, **kwargs)
429
-
430
-
431
- def execute_search_job(job_id, name, depth, is_unlimited, filters, expert, lang):
432
- """Run the existing search pipeline while publishing its real server stage."""
433
- result = make_search_result(name, depth, is_unlimited, filters, expert)
434
- graph_data = result["graph_data"]
435
-
436
- try:
437
- raise_if_search_cancelled(job_id)
438
- update_search_job(job_id, status="running", started_at=time())
439
- def report_gds_stage(stage, gds_job_id):
440
- set_search_stage(job_id, stage, gds_job_id)
441
-
442
- set_search_stage(job_id, "preparing_graph")
443
- ensure_graph_projected(
444
- gds,
445
- GDS_GRAPH_NAME,
446
- search_job_id=job_id,
447
- progress_callback=report_gds_stage,
448
- )
449
- raise_if_search_cancelled(job_id)
450
-
451
- natural_graph_name = f"{GDS_GRAPH_NAME}_natural"
452
- reverse_graph_name = f"{GDS_GRAPH_NAME}_reverse"
453
-
454
- gds_result = algo.run_gds_bfs(
455
- gds,
456
- natural_graph_name,
457
- reverse_graph_name,
458
- name,
459
- None if is_unlimited else depth,
460
- expert,
461
- source_labels=filters,
462
- job_id=job_id,
463
- progress_callback=report_gds_stage,
464
- neo4j_driver=driver,
465
- cancel_check=lambda: raise_if_search_cancelled(job_id),
466
- )
467
-
468
- if not gds_result:
469
- set_search_result_message(
470
- result,
471
- "error.node_not_found_expert" if expert else "error.model_not_found",
472
- lang,
473
- name=name,
474
- )
475
- else:
476
- def report_result_progress(stage, completed_items, total_items):
477
- report_application_progress(
478
- job_id, stage, completed_items, total_items
479
- )
480
-
481
- set_search_stage(job_id, "building_nodes")
482
- process_gds_bfs_results(
483
- gds_result,
484
- graph_data,
485
- job_id=job_id,
486
- cancel_check=lambda: raise_if_search_cancelled(job_id),
487
- progress_callback=report_result_progress,
488
- )
489
-
490
- if expert:
491
- if not graph_data["nodes"] and not graph_data["edges"]:
492
- set_search_result_message(
493
- result, "error.no_neighbors", lang, name=name
494
- )
495
- elif gds_result["source_label"] == "Model":
496
- raise_if_search_cancelled(job_id)
497
- set_search_stage(job_id, "building_highlights")
498
- result["highlights"] = algo.get_genealogy_highlights(
499
- gds, name, lang=lang
500
- )
501
- elif gds_result["source_label"] == "Dataset":
502
- result["template"] = "search_dataset.html"
503
- elif not graph_data["nodes"] and not graph_data["edges"]:
504
- set_search_result_message(
505
- result, "error.no_neighbors", lang, name=name
506
- )
507
-
508
- raise_if_search_cancelled(job_id)
509
- update_search_job(
510
- job_id,
511
- status="completed",
512
- stage="completed",
513
- gds_job_id=None,
514
- application_progress_percent=None,
515
- completed_items=None,
516
- total_items=None,
517
- completed_at=time(),
518
- result=result,
519
- )
520
- except SearchCancelled:
521
- update_search_job(
522
- job_id,
523
- status="cancelled",
524
- stage="cancelled",
525
- gds_job_id=None,
526
- application_progress_percent=None,
527
- completed_items=None,
528
- total_items=None,
529
- completed_at=time(),
530
- result=None,
531
- )
532
- except Exception as error:
533
- if search_cancel_requested(job_id):
534
- update_search_job(
535
- job_id,
536
- status="cancelled",
537
- stage="cancelled",
538
- gds_job_id=None,
539
- application_progress_percent=None,
540
- completed_items=None,
541
- total_items=None,
542
- completed_at=time(),
543
- result=None,
544
- )
545
- return
546
- if "Failed to find a node" in str(error):
547
- message_key = "error.node_not_found"
548
- message_kwargs = {"name": name}
549
- else:
550
- print(f"Background GDS search error ({job_id}): {error}")
551
- message_key = "error.gds"
552
- message_kwargs = {"error": str(error)}
553
- set_search_result_message(
554
- result, message_key, lang, **message_kwargs
555
- )
556
- update_search_job(
557
- job_id,
558
- status="failed",
559
- stage="failed",
560
- gds_job_id=None,
561
- application_progress_percent=None,
562
- completed_items=None,
563
- total_items=None,
564
- completed_at=time(),
565
- result=result,
566
- )
567
-
568
-
569
- def get_gds_progress(gds_job_id):
570
- """Read the percentage published by Neo4j for the running GDS procedure."""
571
- if not gds_job_id:
572
- return None
573
- try:
574
- with driver.session() as neo4j_session:
575
- record = neo4j_session.run(
576
- """
577
- CALL gds.listProgress($job_id, false)
578
- YIELD progress, status
579
- RETURN progress, status
580
- LIMIT 1
581
- """,
582
- {"job_id": gds_job_id},
583
- ).single()
584
- if not record:
585
- return None
586
- progress_text = str(record["progress"]).strip().rstrip("%")
587
- return {
588
- "percent": max(0.0, min(100.0, float(progress_text))),
589
- "status": record["status"],
590
- }
591
- except Exception as error:
592
- # A progress lookup must never interrupt the actual search.
593
- print(f"Could not read GDS progress for {gds_job_id}: {error}")
594
- return None
595
-
596
-
597
- def terminate_search_transactions(job_id):
598
- """Terminate any active Neo4j transaction tagged for this search."""
599
- try:
600
- with driver.session() as neo4j_session:
601
- records = neo4j_session.run(
602
- """
603
- SHOW TRANSACTIONS
604
- YIELD transactionId, metaData, status
605
- WHERE metaData.search_job_id = $job_id
606
- AND NOT status STARTS WITH 'Terminated'
607
- RETURN transactionId
608
- """,
609
- {"job_id": job_id},
610
- )
611
- transaction_ids = [
612
- record["transactionId"] for record in records
613
- ]
614
- if transaction_ids:
615
- neo4j_session.run(
616
- """
617
- TERMINATE TRANSACTIONS $transaction_ids
618
- YIELD transactionId, message
619
- RETURN transactionId, message
620
- """,
621
- {"transaction_ids": transaction_ids},
622
- ).consume()
623
- except Exception as error:
624
- # The cooperative cancellation flag still stops the worker between
625
- # batches if transaction termination is unavailable.
626
- print(f"Could not terminate transactions for {job_id}: {error}")
627
-
628
-
629
- @app.route("/api/search-jobs", methods=["POST"])
630
- def create_search_job():
631
- cleanup_search_jobs()
632
-
633
- name = request.form.get("name", "").strip()
634
- if not name:
635
- return jsonify({"error": t("error.empty_name", session.get("lang", "fr"))}), 400
636
-
637
- try:
638
- depth = int(request.form.get("depth", 3))
639
- except (TypeError, ValueError):
640
- depth = 3
641
- depth = max(1, min(5, depth))
642
-
643
- is_unlimited = (
644
- "depth_unlimited" in request.form
645
- or "unlimited_depth" in request.form
646
- )
647
- expert = request.form.get("search_mode") == "expert"
648
- filters = [
649
- value for value in request.form.getlist("filters")
650
- if value in ("Model", "Dataset", "Author")
651
- ]
652
- lang = session.get("lang", "fr")
653
- job_id = uuid4().hex
654
- now = time()
655
-
656
- with search_jobs_lock:
657
- search_jobs[job_id] = {
658
- "status": "queued",
659
- "stage": "queued",
660
- "created_at": now,
661
- "stage_started_at": now,
662
- "started_at": None,
663
- "gds_job_id": None,
664
- "application_progress_percent": None,
665
- "completed_items": None,
666
- "total_items": None,
667
- "lang": lang,
668
- "result": None,
669
- "cancel_requested": False,
670
- "future": None,
671
- }
672
-
673
- future = search_executor.submit(
674
- execute_search_job,
675
- job_id,
676
- name,
677
- depth,
678
- is_unlimited,
679
- filters,
680
- expert,
681
- lang,
682
- )
683
- with search_jobs_lock:
684
- job = search_jobs.get(job_id)
685
- if job:
686
- job["future"] = future
687
- cancel_requested = job.get("cancel_requested")
688
- else:
689
- cancel_requested = True
690
- if cancel_requested and future.cancel():
691
- update_search_job(
692
- job_id,
693
- status="cancelled",
694
- stage="cancelled",
695
- completed_at=time(),
696
- )
697
-
698
- return jsonify({
699
- "job_id": job_id,
700
- "status_url": url_for("search_job_status", job_id=job_id),
701
- "result_url": url_for("search_job_result", job_id=job_id, lang=lang),
702
- "cancel_url": url_for("cancel_search_job", job_id=job_id),
703
- }), 202
704
-
705
-
706
- @app.route("/api/search-jobs/<job_id>/cancel", methods=["POST"])
707
- def cancel_search_job(job_id):
708
- with search_jobs_lock:
709
- job = search_jobs.get(job_id)
710
- if not job:
711
- return jsonify({"error": "Search job not found"}), 404
712
- if job["status"] in ("completed", "failed", "cancelled"):
713
- return jsonify({"status": job["status"]})
714
- job["cancel_requested"] = True
715
- job["cancel_requested_at"] = time()
716
- future = job.get("future")
717
-
718
- cancelled_before_start = bool(future and future.cancel())
719
- if cancelled_before_start:
720
- update_search_job(
721
- job_id,
722
- status="cancelled",
723
- stage="cancelled",
724
- completed_at=time(),
725
- result=None,
726
- )
727
- else:
728
- terminate_search_transactions(job_id)
729
-
730
- return jsonify({
731
- "status": "cancelled" if cancelled_before_start else "cancelling"
732
- }), 202
733
-
734
-
735
- @app.route("/api/search-jobs/<job_id>")
736
- def search_job_status(job_id):
737
- with search_jobs_lock:
738
- stored_job = search_jobs.get(job_id)
739
- job = dict(stored_job) if stored_job else None
740
- queued_jobs = sorted(
741
- (
742
- (queued_job.get("created_at", 0), queued_job_id)
743
- for queued_job_id, queued_job in search_jobs.items()
744
- if queued_job.get("status") == "queued"
745
- )
746
- )
747
- queued_job_ids = [
748
- queued_job_id for _, queued_job_id in queued_jobs
749
- ]
750
- queue_position = (
751
- queued_job_ids.index(job_id) + 1
752
- if job_id in queued_job_ids
753
- else None
754
- )
755
- running_jobs = sum(
756
- queued_job.get("status") == "running"
757
- for queued_job in search_jobs.values()
758
- )
759
- if not job:
760
- return jsonify({"error": "Search job not found"}), 404
761
-
762
- now = time()
763
- started_at = job.get("started_at")
764
- response = {
765
- "status": job["status"],
766
- "stage": job["stage"],
767
- "elapsed_seconds": max(0, math.floor(now - started_at)) if started_at else 0,
768
- "progress_percent": None,
769
- "remaining_seconds": None,
770
- "queue_position": queue_position,
771
- "queued_jobs": len(queued_job_ids),
772
- "running_jobs": running_jobs,
773
- "completed_items": job.get("completed_items"),
774
- "total_items": job.get("total_items"),
775
- "result_url": (
776
- url_for("search_job_result", job_id=job_id, lang=job["lang"])
777
- if job["status"] in ("completed", "failed")
778
- else None
779
- ),
780
- }
781
-
782
- gds_progress = get_gds_progress(job.get("gds_job_id"))
783
- if gds_progress:
784
- percent = gds_progress["percent"]
785
- response["progress_percent"] = round(percent, 1)
786
- elif job.get("application_progress_percent") is not None:
787
- percent = job["application_progress_percent"]
788
- response["progress_percent"] = round(percent, 1)
789
- else:
790
- percent = None
791
-
792
- stage_elapsed = max(0, now - job.get("stage_started_at", now))
793
- if percent is not None and 0 < percent < 100:
794
- response["remaining_seconds"] = math.ceil(
795
- stage_elapsed * (100 - percent) / percent
796
- )
797
-
798
- return jsonify(response)
799
-
800
-
801
- @app.route("/search-jobs/<job_id>/result")
802
- def search_job_result(job_id):
803
- with search_jobs_lock:
804
- stored_job = search_jobs.get(job_id)
805
- job = dict(stored_job) if stored_job else None
806
- if not job:
807
- return redirect(url_for("findnode", lang=session.get("lang", "fr")))
808
- if job["status"] not in ("completed", "failed") or not job.get("result"):
809
- return redirect(url_for("findnode", lang=job["lang"]))
810
-
811
- result = job["result"]
812
- message = result["message"]
813
- if result.get("message_key"):
814
- message = t(
815
- result["message_key"],
816
- session.get("lang", "fr"),
817
- **result.get("message_kwargs", {}),
818
- )
819
- template_args = {
820
- "message": message,
821
- "search": result["search"],
822
- "graph_data": result["graph_data"],
823
- }
824
- if result["template"] == "search.html":
825
- template_args["highlights"] = result["highlights"]
826
- return render_template(result["template"], **template_args)
827
-
828
-
829
  @app.route("/search", methods=["GET", "POST"]) # Recherche d'un noeud
830
  def findnode():
831
  """
832
  1. Récupère le nom à chercher et la profondeur
833
- 2. S'assure que les graphes GDS existent
834
- 3. Lance le BFS qui renvoie directement la profondeur de chaque nœud
835
  4. Traite les résultats et construit le sous-graphe à afficher
836
  5. Met à jour les données pour le template Flask
837
  """
@@ -852,17 +215,13 @@ def findnode():
852
  search_info = {
853
  "name": request.form.get("name", ""),
854
  "depth": int(request.form.get("depth", 3)),
855
- "unlimited": request.method == "GET" or (
856
- 'depth_unlimited' in request.form or 'unlimited_depth' in request.form
857
- ),
858
  "filters": current_filters # On passe la liste des filtres au template
859
  }
860
 
861
  if request.method == "POST" and request.form.get("submit") == "find_node":
862
  name = request.form.get("name", "").strip()
863
- is_unlimited = (
864
- 'depth_unlimited' in request.form or 'unlimited_depth' in request.form
865
- )
866
  depth = None if is_unlimited else int(request.form.get("depth", 3))
867
 
868
  search_info = {"name": name, "depth": request.form.get("depth", 3), "unlimited": is_unlimited}
@@ -872,23 +231,18 @@ def findnode():
872
  return render_template("search.html", message=message, search=search_info, graph_data=graph_data, highlights=highlights)
873
 
874
  try:
 
875
  ensure_graph_projected(gds, GDS_GRAPH_NAME)
876
  natural_graph_name = f"{GDS_GRAPH_NAME}_natural"
877
  reverse_graph_name = f"{GDS_GRAPH_NAME}_reverse"
878
- gds_result = algo.run_gds_bfs(
879
- gds,
880
- natural_graph_name,
881
- reverse_graph_name,
882
- name,
883
- depth,
884
- False,
885
- source_labels=current_filters,
886
- )
887
  if not gds_result :
888
  message = t("error.model_not_found", session.get("lang", "fr"), name=name)
889
  return render_template("search.html", message=message, search=search_info, graph_data=graph_data,highlights=highlights)
890
 
891
- process_gds_bfs_results(gds_result, graph_data)
892
  if gds_result["source_label"] == "Model" :
893
  highlights = algo.get_genealogy_highlights(gds, name, lang=session.get("lang", "fr"))
894
  return render_template("search.html", message=message, search=search_info, graph_data=graph_data,highlights=highlights)
@@ -914,8 +268,8 @@ def findnode():
914
  def findnode_expert():
915
  """
916
  1. Récupère le nom à chercher et la profondeur
917
- 2. S'assure que les graphes GDS existent
918
- 3. Lance le BFS qui renvoie directement la profondeur de chaque nœud
919
  4. Traite les résultats et construit le sous-graphe à afficher
920
  5. Met à jour les données pour le template Flask
921
  """
@@ -934,17 +288,13 @@ def findnode_expert():
934
  search_info = {
935
  "name": request.form.get("name", ""),
936
  "depth": int(request.form.get("depth", 3)),
937
- "unlimited": request.method == "GET" or (
938
- 'depth_unlimited' in request.form or 'unlimited_depth' in request.form
939
- ),
940
  "filters": current_filters # On passe la liste des filtres au template
941
  }
942
 
943
  if request.method == "POST" and request.form.get("submit") == "findnode_expert":
944
  name = request.form.get("name", "").strip()
945
- is_unlimited = (
946
- 'depth_unlimited' in request.form or 'unlimited_depth' in request.form
947
- )
948
  depth = None if is_unlimited else int(request.form.get("depth", 3))
949
 
950
  search_info = {"name": name, "depth": request.form.get("depth", 3), "unlimited": is_unlimited,"filters": current_filters }
@@ -954,24 +304,19 @@ def findnode_expert():
954
  return render_template("expert.html", message=message, search=search_info, graph_data=graph_data)
955
 
956
  try:
 
957
  ensure_graph_projected(gds, GDS_GRAPH_NAME)
958
  natural_graph_name = f"{GDS_GRAPH_NAME}_natural"
959
  reverse_graph_name = f"{GDS_GRAPH_NAME}_reverse"
960
- gds_result = algo.run_gds_bfs(
961
- gds,
962
- natural_graph_name,
963
- reverse_graph_name,
964
- name,
965
- depth,
966
- True,
967
- source_labels=current_filters,
968
- )
969
  if not gds_result :
970
  message = t("error.node_not_found_expert", session.get("lang", "fr"), name=name)
971
  return render_template("expert.html", message=message, search=search_info, graph_data=graph_data)
972
 
973
 
974
- process_gds_bfs_results(gds_result, graph_data)
975
 
976
  if not graph_data["nodes"] and not graph_data["edges"]:
977
  message = t("error.no_neighbors", session.get("lang", "fr"), name=name)
@@ -986,13 +331,7 @@ def findnode_expert():
986
  return render_template("expert.html", message=message, search=search_info, graph_data=graph_data)
987
 
988
 
989
- def process_gds_bfs_results(
990
- gds_result: Dict,
991
- graph_data: Dict,
992
- job_id=None,
993
- cancel_check=None,
994
- progress_callback=None,
995
- ):
996
  """
997
  Transforme les résultats d'un BFS GDS en un sous-graphe utilisable pour le front-end.
998
 
@@ -1002,204 +341,86 @@ def process_gds_bfs_results(
1002
  3. Formatage des nœuds et arêtes pour construire le dictionnaire `graph_data`.
1003
  """
1004
 
1005
- # --- PHASE 1 : Collecte des nœuds et profondeurs produits par le BFS ---
1006
- #
1007
- # La profondeur est calculée au moment où GDS découvre le nœud. Il n'est
1008
- # donc plus nécessaire de rechercher ensuite un chemin à longueur variable
1009
- # entre chacun des nœuds et l'origine.
1010
- distance_by_node = {}
1011
  source_id = gds_result.get("source_node")
1012
  if source_id is not None:
1013
- distance_by_node[int(source_id)] = 0
1014
 
 
1015
  desc_df = gds_result.get("descendant")
1016
  if desc_df is not None and not desc_df.empty:
1017
  node_ids = desc_df["nodeIds"].iloc[0]
1018
- depths = desc_df["depths"].iloc[0]
1019
- for node_id, depth in zip(node_ids, depths):
1020
- distance_by_node.setdefault(int(node_id), int(depth))
1021
 
 
1022
  asc_df = gds_result.get("ascendant")
1023
  if asc_df is not None and not asc_df.empty:
1024
  node_ids = asc_df["nodeIds"].iloc[0]
1025
- depths = asc_df["depths"].iloc[0]
1026
- for node_id, depth in zip(node_ids, depths):
1027
- node_id = int(node_id)
1028
- if node_id != source_id:
1029
- # Conserver la convention historique : profondeur négative
1030
- # pour un ascendant. Si un cycle rend le nœud accessible dans
1031
- # les deux sens, l'ascendance reste prioritaire.
1032
- distance_by_node[node_id] = -int(depth)
1033
 
1034
- if not distance_by_node:
1035
  return # Aucun nœud découvert → rien à faire
1036
 
1037
- discovered_node_ids = sorted(distance_by_node)
1038
- total_nodes = len(discovered_node_ids)
1039
- nodes_data = []
1040
- relationships = []
1041
-
1042
- # --- PHASE 2 : Récupération des nœuds par lots ---
1043
- #
1044
- # Neo4j 2025.09 does not expose a percentage for this read query. Running
1045
- # it in bounded batches lets the server report completed/total items after
1046
- # each real unit of work, instead of inventing a browser-side countdown.
1047
- if progress_callback:
1048
- progress_callback("building_nodes", 0, total_nodes)
1049
-
1050
  with driver.session() as session:
1051
- for batch_start in range(0, total_nodes, RESULT_BATCH_SIZE):
1052
- if cancel_check:
1053
- cancel_check()
1054
- batch_ids = discovered_node_ids[
1055
- batch_start:batch_start + RESULT_BATCH_SIZE
1056
- ]
1057
- batch_nodes = [
1058
- {"id": node_id, "distance": distance_by_node[node_id]}
1059
- for node_id in batch_ids
1060
- ]
1061
- query = Query(
1062
- """
1063
- UNWIND $nodes AS node_info
1064
- MATCH (n) WHERE id(n) = node_info.id
1065
- OPTIONAL MATCH (author:Author)-[:POSTED]->(n)
1066
- OPTIONAL MATCH (dataset:Dataset)-[:USED_IN]->(n)
1067
- CALL {
1068
- WITH n
1069
- OPTIONAL MATCH (ancestor:Model)-[:USED_IN*1..]->(n)
1070
- WITH n, count(DISTINCT ancestor) AS ascendantsCount
1071
- OPTIONAL MATCH (descendant:Model)<-[:USED_IN*1..]-(n)
1072
- WITH n, ascendantsCount,
1073
- count(DISTINCT descendant) AS descendantsCount
1074
- OPTIONAL MATCH (citation:Model)<-[:USED_IN]-(n)
1075
- RETURN ascendantsCount, descendantsCount,
1076
- count(DISTINCT citation) AS citationCount
1077
- }
1078
- WITH n, node_info, author, dataset, ascendantsCount,
1079
- descendantsCount, citationCount
1080
- RETURN collect({
1081
- id: id(n),
1082
- node: n,
1083
- dataset: properties(dataset),
1084
- author: properties(author),
1085
- task: n.task,
1086
- license: n.license,
1087
- createdAt: n.createdAt,
1088
- likes: n.likes,
1089
- properties: properties(n),
1090
- labels: labels(n),
1091
- ascendantsCount: ascendantsCount,
1092
- descendantsCount: descendantsCount,
1093
- citationCount: citationCount,
1094
- distance: node_info.distance
1095
- }) AS nodes_data
1096
- """,
1097
- metadata={"search_job_id": job_id} if job_id else None,
1098
- )
1099
- record = session.run(
1100
- query,
1101
- {"nodes": batch_nodes},
1102
- ).single()
1103
- if record:
1104
- nodes_data.extend(record["nodes_data"])
1105
- if progress_callback:
1106
- progress_callback(
1107
- "building_nodes",
1108
- min(batch_start + len(batch_ids), total_nodes),
1109
- total_nodes,
1110
- )
1111
-
1112
- # --- PHASE 3 : Récupération des relations par lots ---
1113
- #
1114
- # First count the relationships whose two endpoints belong to the
1115
- # result graph. We can then publish a real relationship counter while
1116
- # hydrating them in bounded source-node batches. Matching directed
1117
- # relationships ensures that each stored relationship is returned
1118
- # once, regardless of its direction in the result graph.
1119
- if progress_callback:
1120
- progress_callback("building_relationships", 0, 0)
1121
-
1122
- if cancel_check:
1123
- cancel_check()
1124
- count_query = Query(
1125
- """
1126
- UNWIND $all_ids AS source_id
1127
- MATCH (source) WHERE id(source) = source_id
1128
- MATCH (source)-[relationship]->(target)
1129
- WHERE id(target) IN $all_ids
1130
- RETURN count(relationship) AS relationship_count
1131
- """,
1132
- metadata={"search_job_id": job_id} if job_id else None,
1133
- )
1134
- count_record = session.run(
1135
- count_query,
1136
- {"all_ids": discovered_node_ids},
1137
- ).single()
1138
- total_relationships = (
1139
- count_record["relationship_count"] if count_record else 0
1140
- )
1141
- loaded_relationships = 0
1142
- if progress_callback:
1143
- progress_callback(
1144
- "building_relationships", 0, total_relationships
1145
- )
1146
-
1147
- for batch_start in range(0, total_nodes, RESULT_BATCH_SIZE):
1148
- if cancel_check:
1149
- cancel_check()
1150
- source_ids = discovered_node_ids[
1151
- batch_start:batch_start + RESULT_BATCH_SIZE
1152
- ]
1153
- relationship_query = Query(
1154
- """
1155
- UNWIND $source_ids AS source_id
1156
- MATCH (source) WHERE id(source) = source_id
1157
- MATCH (source)-[relationship]->(target)
1158
- WHERE id(target) IN $all_ids
1159
- RETURN collect({
1160
- relationship: relationship,
1161
- sourceName: startNode(relationship).name,
1162
- targetName: endNode(relationship).name
1163
- }) AS relationships
1164
- """,
1165
- metadata={"search_job_id": job_id} if job_id else None,
1166
- )
1167
- record = session.run(
1168
- relationship_query,
1169
- {
1170
- "source_ids": source_ids,
1171
- "all_ids": discovered_node_ids,
1172
- },
1173
- ).single()
1174
- batch_relationships = record["relationships"] if record else []
1175
- relationships.extend(batch_relationships)
1176
- loaded_relationships += len(batch_relationships)
1177
- if progress_callback:
1178
- progress_callback(
1179
- "building_relationships",
1180
- min(loaded_relationships, total_relationships),
1181
- total_relationships,
1182
- )
1183
-
1184
- print(
1185
- f"Result graph contains {len(nodes_data)} node rows and "
1186
- f"{len(relationships)}/{total_relationships} raw relationships."
1187
- )
1188
- if progress_callback:
1189
- progress_callback(
1190
- "building_relationships",
1191
- total_relationships,
1192
- total_relationships,
1193
- )
1194
-
1195
- if cancel_check:
1196
- cancel_check()
1197
- if progress_callback:
1198
- progress_callback("formatting_result", 0, 0)
1199
 
 
 
 
1200
  count=0
1201
- # --- PHASE 4 : Formatage des nœuds et des arêtes ---
1202
- for node_obj in nodes_data:
1203
  # Extraire et compléter les propriétés du nœud
1204
  node_properties = dict(node_obj["properties"])
1205
  node_properties.update({
@@ -1229,10 +450,9 @@ def process_gds_bfs_results(
1229
 
1230
  # Construction des arêtes, en évitant les doublons
1231
  added_edges_canonical_keys = set()
1232
- for relationship_data in relationships:
1233
- rel_obj = relationship_data["relationship"]
1234
- source_name = relationship_data["sourceName"]
1235
- target_name = relationship_data["targetName"]
1236
  if not source_name or not target_name:
1237
  continue
1238
 
@@ -1249,13 +469,13 @@ def process_gds_bfs_results(
1249
  })
1250
 
1251
 
1252
- atexit.register(lambda: search_executor.shutdown(wait=False, cancel_futures=True))
1253
- atexit.register(lambda: driver.close())
1254
- atexit.register(lambda: gds.close())
1255
-
1256
-
1257
  if __name__ == '__main__':
1258
- # Threaded mode lets the browser poll job status while the worker performs
1259
- # the search. The debug reloader is intentionally disabled because its
1260
- # second process would maintain a separate in-memory job registry.
1261
- app.run(host='0.0.0.0', port=7860, debug=False, threaded=True)
 
 
 
 
 
 
1
  # --- Import et initialisation Flask ---
2
  import os
3
  from flask import Flask, request, render_template, jsonify, session, redirect, url_for
4
+ from neo4j import GraphDatabase, basic_auth
5
  from graphdatascience import GraphDataScience
6
  import app_algorithms as algo
7
  from translations import t
8
  import pandas as pd
9
  from typing import Dict
10
  from collections import defaultdict
 
 
 
 
11
  import argparse
 
 
 
 
12
 
13
  app = Flask(__name__, static_url_path="/static/") # Application Flask
14
  app.secret_key = os.urandom(24)
 
16
  # --- Connexion à Neo4j et GDS ---
17
  NEO4J_URI = "bolt://localhost:7687"
18
  GDS_GRAPH_NAME = "genealogie_gds"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  # --- Configuration des arguments du script ---
21
  parser = argparse.ArgumentParser(description="Script pour lancer la web application.")
 
45
  exit() # Si la connexion échoue, l'application ne peut pas tourner
46
 
47
  # --- Projection du graphe pour GDS ---
48
+ def ensure_graph_projected(gds: GraphDataScience, graph_name):
 
 
 
 
 
49
  """
50
  Projette deux graphes dans :
51
  - Graphe "naturel" : relations descendant (source -> target)
 
56
  natural_graph_name = f"{graph_name}_natural"
57
  reverse_graph_name = f"{graph_name}_reverse"
58
 
59
+ # Suppression de toute projection existante
60
+ g_natural_exists = gds.graph.exists(natural_graph_name).exists
61
+ if g_natural_exists:
62
+ gds.graph.get(natural_graph_name).drop()
63
+
64
+ g_reverse_exists = gds.graph.exists(reverse_graph_name).exists
65
+ if g_reverse_exists:
66
+ gds.graph.get(reverse_graph_name).drop()
67
+
68
+
69
+ # --- 1. Projection pour les descendants (sens normal) ---
70
+ # On sélectionne les relations et on les projette en gardant source -> target
71
+ print("Projection du graphe naturel (descendant)...")
72
+ gds.run_cypher(f"""
73
+ MATCH (source)-[r:IS_IN|POSTED|USED_IN]->(target)
74
+ WITH gds.graph.project(
75
+ '{natural_graph_name}',
76
+ source,
77
+ target,
78
+ {{
79
+ relationshipType: type(r)
80
+ }}
81
+ ) AS g
82
+ RETURN g.graphName AS graph, g.nodeCount AS nodes, g.relationshipCount AS rels
83
+ """)
84
+ print(f"Graphe '{natural_graph_name}' projeté.")
85
+
86
+
87
+ # --- 2. Projection pour les ascendants (sens inversé) ---
88
+ # On sélectionne les mêmes relations, mais on inverse source et target dans l'appel
89
+ print("Projection du graphe inversé (ascendant)...")
90
+ gds.run_cypher(f"""
91
+ MATCH (source)-[r:IS_IN|POSTED|USED_IN]->(target)
92
+ WITH gds.graph.project(
93
+ '{reverse_graph_name}',
94
+ target, // <<< Le 'target' devient la source dans la projection
95
+ source, // <<< Le 'source' devient la cible dans la projection
96
+ {{
97
+ relationshipType: type(r)
98
+ }}
99
+ ) AS g
100
+ RETURN g.graphName AS graph, g.nodeCount AS nodes, g.relationshipCount AS rels
101
+ """)
102
+ print(f"Graphe '{reverse_graph_name}' projeté.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
  @app.before_request
105
  def set_language():
 
131
  def inject_i18n():
132
  """Inject t() and current_lang into all Jinja2 templates."""
133
  def _t(key, **kwargs):
134
+ return t(key, session.get("lang", "fr"), **kwargs)
 
 
 
135
  # Dictionnaire JS pour les clés utilisées côté client
136
  js_i18n_keys = [
137
  "js.node_info.name", "js.node_info.type", "js.node_info.followers",
 
139
  "js.node_info.dataset", "js.node_info.undefined", "js.node_info.unknown_date",
140
  "js.node_info.see_on_hf",
141
  "js.unknown", "js.unknown_f", "js.na",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  ]
143
  lang = session.get("lang", "fr")
144
  js_i18n_data = {k: t(k, lang) for k in js_i18n_keys}
 
170
  if node_filter and node_filter in ["Model", "Dataset"]: # Mesure de sécurité
171
  label_cypher = f":{node_filter}"
172
 
173
+ # Récupère les noms commençant par le préfixe fourni
 
 
174
  cypher = f"""
175
  MATCH (n{label_cypher})
176
  WHERE toLower(n.name) STARTS WITH toLower($prefix)
177
  AND n.name IS NOT NULL
178
+ RETURN n.name AS name, labels(n)[0] as label
179
+ ORDER BY size(n.name) ASC
 
180
  LIMIT 10
181
  """
182
  try:
 
189
  return jsonify([])
190
 
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  @app.route("/search", methods=["GET", "POST"]) # Recherche d'un noeud
193
  def findnode():
194
  """
195
  1. Récupère le nom à chercher et la profondeur
196
+ 2. Appelle ensure_graph_projected pour s'assurer que les graphes GDS existent
197
+ 3. Lance l'algorithme BFS via algo.run_gds_bfs
198
  4. Traite les résultats et construit le sous-graphe à afficher
199
  5. Met à jour les données pour le template Flask
200
  """
 
215
  search_info = {
216
  "name": request.form.get("name", ""),
217
  "depth": int(request.form.get("depth", 3)),
218
+ "unlimited": 'unlimited_depth' in request.form,
 
 
219
  "filters": current_filters # On passe la liste des filtres au template
220
  }
221
 
222
  if request.method == "POST" and request.form.get("submit") == "find_node":
223
  name = request.form.get("name", "").strip()
224
+ is_unlimited = 'unlimited_depth' in request.form
 
 
225
  depth = None if is_unlimited else int(request.form.get("depth", 3))
226
 
227
  search_info = {"name": name, "depth": request.form.get("depth", 3), "unlimited": is_unlimited}
 
231
  return render_template("search.html", message=message, search=search_info, graph_data=graph_data, highlights=highlights)
232
 
233
  try:
234
+ # Projection des graphes ascendant et descendant
235
  ensure_graph_projected(gds, GDS_GRAPH_NAME)
236
  natural_graph_name = f"{GDS_GRAPH_NAME}_natural"
237
  reverse_graph_name = f"{GDS_GRAPH_NAME}_reverse"
238
+
239
+ # Appeler la fonction GDS BFS
240
+ gds_result = algo.run_gds_bfs(gds, natural_graph_name,reverse_graph_name, name, depth,False)
 
 
 
 
 
 
241
  if not gds_result :
242
  message = t("error.model_not_found", session.get("lang", "fr"), name=name)
243
  return render_template("search.html", message=message, search=search_info, graph_data=graph_data,highlights=highlights)
244
 
245
+ process_gds_bfs_results(gds_result, graph_data, name)
246
  if gds_result["source_label"] == "Model" :
247
  highlights = algo.get_genealogy_highlights(gds, name, lang=session.get("lang", "fr"))
248
  return render_template("search.html", message=message, search=search_info, graph_data=graph_data,highlights=highlights)
 
268
  def findnode_expert():
269
  """
270
  1. Récupère le nom à chercher et la profondeur
271
+ 2. Appelle ensure_graph_projected pour s'assurer que les graphes GDS existent
272
+ 3. Lance l'algorithme BFS via algo.run_gds_bfs
273
  4. Traite les résultats et construit le sous-graphe à afficher
274
  5. Met à jour les données pour le template Flask
275
  """
 
288
  search_info = {
289
  "name": request.form.get("name", ""),
290
  "depth": int(request.form.get("depth", 3)),
291
+ "unlimited": 'unlimited_depth' in request.form,
 
 
292
  "filters": current_filters # On passe la liste des filtres au template
293
  }
294
 
295
  if request.method == "POST" and request.form.get("submit") == "findnode_expert":
296
  name = request.form.get("name", "").strip()
297
+ is_unlimited = 'unlimited_depth' in request.form
 
 
298
  depth = None if is_unlimited else int(request.form.get("depth", 3))
299
 
300
  search_info = {"name": name, "depth": request.form.get("depth", 3), "unlimited": is_unlimited,"filters": current_filters }
 
304
  return render_template("expert.html", message=message, search=search_info, graph_data=graph_data)
305
 
306
  try:
307
+ # Projection des graphes ascendant et descendant
308
  ensure_graph_projected(gds, GDS_GRAPH_NAME)
309
  natural_graph_name = f"{GDS_GRAPH_NAME}_natural"
310
  reverse_graph_name = f"{GDS_GRAPH_NAME}_reverse"
311
+
312
+ # Appeler la fonction GDS BFS
313
+ gds_result = algo.run_gds_bfs(gds, natural_graph_name,reverse_graph_name, name, depth,True)
 
 
 
 
 
 
314
  if not gds_result :
315
  message = t("error.node_not_found_expert", session.get("lang", "fr"), name=name)
316
  return render_template("expert.html", message=message, search=search_info, graph_data=graph_data)
317
 
318
 
319
+ process_gds_bfs_results(gds_result, graph_data, name)
320
 
321
  if not graph_data["nodes"] and not graph_data["edges"]:
322
  message = t("error.no_neighbors", session.get("lang", "fr"), name=name)
 
331
  return render_template("expert.html", message=message, search=search_info, graph_data=graph_data)
332
 
333
 
334
+ def process_gds_bfs_results(gds_result: Dict, graph_data: Dict, origin_name: str):
 
 
 
 
 
 
335
  """
336
  Transforme les résultats d'un BFS GDS en un sous-graphe utilisable pour le front-end.
337
 
 
341
  3. Formatage des nœuds et arêtes pour construire le dictionnaire `graph_data`.
342
  """
343
 
344
+ # --- PHASE 1 : Collecte des IDs de tous les nœuds visités ---
345
+ all_discovered_node_ids = set()
346
+ # Ajouter le nœud source
 
 
 
347
  source_id = gds_result.get("source_node")
348
  if source_id is not None:
349
+ all_discovered_node_ids.add(source_id)
350
 
351
+ # Ajouter les descendants (profondeur positive)
352
  desc_df = gds_result.get("descendant")
353
  if desc_df is not None and not desc_df.empty:
354
  node_ids = desc_df["nodeIds"].iloc[0]
355
+ all_discovered_node_ids.update(node_ids)
 
 
356
 
357
+ # Ajouter les ascendants (profondeur négative)
358
  asc_df = gds_result.get("ascendant")
359
  if asc_df is not None and not asc_df.empty:
360
  node_ids = asc_df["nodeIds"].iloc[0]
361
+ all_discovered_node_ids.update(node_ids)
 
 
 
 
 
 
 
362
 
363
+ if not all_discovered_node_ids:
364
  return # Aucun nœud découvert → rien à faire
365
 
366
+ # --- PHASE 2 : Récupération du sous-graphe réel dans Neo4j ---
 
 
 
 
 
 
 
 
 
 
 
 
367
  with driver.session() as session:
368
+ # Requête Cypher : récupère les nœuds, auteurs, datasets et relations entre eux
369
+ results = session.run("""
370
+ MATCH (n) WHERE id(n) IN $ids
371
+ OPTIONAL MATCH (author:Author)-[:POSTED]->(n)
372
+ OPTIONAL MATCH (dataset:Dataset)-[:USED_IN]->(n)
373
+ OPTIONAL MATCH (o:Model) WHERE o.name = $origin_name
374
+ CALL {
375
+ WITH n,o
376
+ OPTIONAL MATCH p = (n)-[:USED_IN*1..]->(o)
377
+ WITH n,o, length(p) AS rel_asc
378
+ OPTIONAL MATCH p= (n)<-[r:USED_IN*1..]-(o)
379
+ WITH n,o, rel_asc, length(p) AS rel_desc
380
+ OPTIONAL MATCH (ancestor:Model)-[:USED_IN*1..]->(n)
381
+ WITH n, rel_asc, rel_desc, count(DISTINCT ancestor) AS ascendantsCount
382
+ OPTIONAL MATCH (descendant:Model)<-[:USED_IN*1..]-(n)
383
+ WITH n,rel_asc, rel_desc, ascendantsCount, count(DISTINCT descendant) AS descendantsCount
384
+ OPTIONAL MATCH (citation:Model)<-[:USED_IN]-(n)
385
+ RETURN ascendantsCount, descendantsCount, count(DISTINCT citation) AS citationCount,rel_asc, rel_desc
386
+ }
387
+ WITH n, author,dataset, ascendantsCount, descendantsCount, citationCount,rel_asc, rel_desc
388
+ WITH collect({
389
+ id: id(n),
390
+ node: n,
391
+ dataset: properties(dataset),
392
+ author: properties(author),
393
+ task: n.task,
394
+ license: n.license,
395
+ createdAt:n.createdAt,
396
+ likes:n.likes,
397
+ properties: properties(n),
398
+ labels: labels(n),
399
+ ascendantsCount: ascendantsCount,
400
+ descendantsCount: descendantsCount,
401
+ citationCount: citationCount,
402
+ distance: CASE
403
+ WHEN rel_asc IS NOT NULL THEN -rel_asc
404
+ WHEN rel_desc IS NOT NULL THEN rel_desc
405
+ ELSE 0
406
+ END
407
+ }) AS nodes_data
408
+ CALL {
409
+ WITH nodes_data
410
+ UNWIND [item IN nodes_data | item.node] AS n1
411
+ UNWIND [item IN nodes_data | item.node] AS n2
412
+ MATCH (n1)-[r]-(n2)
413
+ RETURN collect(r) AS rels
414
+ }
415
+ RETURN nodes_data, rels
416
+ """, {"ids": list(all_discovered_node_ids), "origin_name": origin_name})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
 
418
+ subgraph = results.single()
419
+ if not subgraph:
420
+ return
421
  count=0
422
+ # --- PHASE 3 : Formatage des nœuds et des arêtes ---
423
+ for node_obj in subgraph["nodes_data"]:
424
  # Extraire et compléter les propriétés du nœud
425
  node_properties = dict(node_obj["properties"])
426
  node_properties.update({
 
450
 
451
  # Construction des arêtes, en évitant les doublons
452
  added_edges_canonical_keys = set()
453
+ for rel_obj in subgraph["rels"]:
454
+ source_name = rel_obj.start_node["name"]
455
+ target_name = rel_obj.end_node["name"]
 
456
  if not source_name or not target_name:
457
  continue
458
 
 
469
  })
470
 
471
 
 
 
 
 
 
472
  if __name__ == '__main__':
473
+ # Le script de démarrage n'a plus besoin de projeter le graphe.
474
+ # Il se contente de lancer l'application. La projection se fera à la demande.
475
+ app.run(host='0.0.0.0', port=7860, debug=True)
476
+
477
+ # Le driver doit être fermé quand l'application s'arrête.
478
+ # Une manière simple est d'utiliser `atexit`
479
+ import atexit
480
+ atexit.register(lambda: driver.close())
481
+ atexit.register(lambda: gds.close())
application_neo4j/app_algorithms.py CHANGED
@@ -3,164 +3,79 @@ from graphdatascience import GraphDataScience
3
  from typing import Dict, List, Any
4
  import pandas as pd
5
  from translations import t
6
- from neo4j import Query
7
 
8
- def run_gds_bfs(
9
- gds: GraphDataScience,
10
- natural_graph_name: str,
11
- reverse_graph_name: str,
12
- source_name: str,
13
- max_depth: int = None,
14
- expert=False,
15
- source_labels=None,
16
- job_id: str = None,
17
- progress_callback=None,
18
- neo4j_driver=None,
19
- cancel_check=None,
20
- ) -> Dict[str, Any]:
21
  """
22
- Parcourt les descendants et ascendants avec le fork OpenGDS.
23
 
24
- Le plugin personnalisé ajoute au résultat standard ``nodeIds`` une liste
25
- ``depths`` alignée. La profondeur minimale de chaque nœud est ainsi calculée
26
- pendant le BFS, sans second parcours Cypher.
 
 
 
 
 
 
 
27
 
28
  Returns:
29
- L'identifiant et le label de la source, ainsi que les résultats GDS
30
- des parcours descendant et ascendant.
31
  """
32
- requested_source_labels = [
33
- label
34
- for label in (source_labels or [])
35
- if label in ("Model", "Dataset", "Author")
36
- ]
37
  try:
38
  source_id_result = gds.run_cypher(
39
  """
40
- MATCH (n {name: $source_name})
41
- WHERE size($source_labels) = 0
42
- OR any(label IN labels(n) WHERE label IN $source_labels)
43
- RETURN id(n) AS id, labels(n) AS label
44
  """,
45
- {
46
- "source_name": source_name,
47
- "source_labels": requested_source_labels,
48
- },
49
  )
50
-
51
- if source_id_result.empty:
52
- print(f"Le modèle ou dataset avec le nom '{source_name}' n'a pas été trouvé.")
53
- return None
54
-
55
- label_preferences = requested_source_labels or [
56
- "Model",
57
- "Dataset",
58
- "Author",
59
- ]
60
- selected_source = None
61
- source_label = None
62
- for preferred_label in label_preferences:
63
- matching_sources = source_id_result[
64
- source_id_result["label"].apply(
65
- lambda node_labels: preferred_label in node_labels
66
- )
67
- ]
68
- if not matching_sources.empty:
69
- selected_source = matching_sources.iloc[0]
70
- source_label = preferred_label
71
- break
72
- if selected_source is None:
73
- selected_source = source_id_result.iloc[0]
74
- source_label = selected_source["label"][0]
75
-
76
- if source_label == "Author" and not expert:
77
  print(f"Le modèle ou dataset avec le nom '{source_name}' n'a pas été trouvé.")
78
- return None
79
-
80
- source_node_id = int(selected_source["id"])
 
81
  except Exception as e:
82
  print(f"Erreur lors de la recherche de l'ID du nœud source pour '{source_name}': {e}")
83
- return None
84
 
85
- bfs_params = {"sourceNode": source_node_id}
 
 
 
86
  if max_depth is not None:
87
- bfs_params["maxDepth"] = max_depth
88
-
89
- def run_bfs(graph_name, params):
90
- if cancel_check:
91
- cancel_check()
92
- if neo4j_driver is None:
93
- graph = gds.graph.get(graph_name)
94
- result = gds.bfs.stream(graph, **params)
95
- else:
96
- query = Query(
97
- """
98
- CALL gds.bfs.stream($graph_name, $configuration)
99
- YIELD nodeIds, depths
100
- RETURN nodeIds, depths
101
- """,
102
- metadata={"search_job_id": job_id},
103
- )
104
- with neo4j_driver.session() as neo4j_session:
105
- records = neo4j_session.run(
106
- query,
107
- {
108
- "graph_name": graph_name,
109
- "configuration": params,
110
- },
111
- )
112
- result = pd.DataFrame(
113
- record.data() for record in records
114
- )
115
- if cancel_check:
116
- cancel_check()
117
- return result
118
-
119
- desc_job_id = f"{job_id}-descendants" if job_id else None
120
- if desc_job_id:
121
- bfs_params["jobId"] = desc_job_id
122
- if progress_callback:
123
- progress_callback("searching_descendants", desc_job_id)
124
- desc_df = run_bfs(natural_graph_name, bfs_params)
125
- _validate_depths_result(desc_df)
126
- print("BFS descendants terminé avec les profondeurs.")
127
-
128
- asc_job_id = f"{job_id}-ancestors" if job_id else None
129
- if asc_job_id:
130
- bfs_params["jobId"] = asc_job_id
131
- elif "jobId" in bfs_params:
132
- del bfs_params["jobId"]
133
- if progress_callback:
134
- progress_callback("searching_ancestors", asc_job_id)
135
- asc_df = run_bfs(reverse_graph_name, bfs_params)
136
- _validate_depths_result(asc_df)
137
- print("BFS ascendants terminé avec les profondeurs.")
138
-
139
  return {
140
- "source_node": source_node_id,
141
- "source_label": source_label,
142
  "descendant": desc_df,
143
- "ascendant": asc_df,
144
  }
145
 
146
 
147
- def _validate_depths_result(result: pd.DataFrame) -> None:
148
- """Fail explicitly if Neo4j did not load the custom OpenGDS plugin."""
149
- if result.empty:
150
- return
151
- if "depths" not in result.columns:
152
- raise RuntimeError(
153
- "Le plugin OpenGDS personnalisé n'est pas chargé : "
154
- "gds.bfs.stream ne renvoie pas la colonne depths."
155
- )
156
- node_ids = result["nodeIds"].iloc[0]
157
- depths = result["depths"].iloc[0]
158
- if len(node_ids) != len(depths):
159
- raise RuntimeError(
160
- "Résultat BFS invalide : nodeIds et depths n'ont pas la même taille."
161
- )
162
-
163
-
164
 
165
 
166
 
@@ -186,50 +101,50 @@ def get_genealogy_highlights(gds: "GraphDataScience", model_name: str, num_highl
186
  # Les classes CSS sont directement des classes Bootstrap 5 pour simplifier le rendu dans le template HTML.
187
  badges_info = {
188
  'desc_cited_1': {
189
- 'text_key': 'badge.desc_cited_1.text',
190
  'class': 'bg-success',
191
- 'title_key': 'badge.desc_cited_1.title'
192
  },
193
  'desc_cited_2': {
194
- 'text_key': 'badge.desc_cited_2.text',
195
  'class': 'bg-success bg-opacity-75',
196
- 'title_key': 'badge.desc_cited_2.title'
197
  },
198
  'desc_downloaded_1': {
199
- 'text_key': 'badge.desc_downloaded_1.text',
200
  'class': 'beta',
201
- 'title_key': 'badge.desc_downloaded_1.title'
202
  },
203
  'desc_downloaded_2': {
204
- 'text_key': 'badge.desc_downloaded_2.text',
205
  'class': 'alpha',
206
- 'title_key': 'badge.desc_downloaded_2.title'
207
  },
208
 
209
  'asc_foundation': {
210
- 'text_key': 'badge.asc_foundation.text',
211
  'class': 'bg-warning text-dark',
212
- 'title_key': 'badge.asc_foundation.title'
213
  },
214
  'asc_cited_1': {
215
- 'text_key': 'badge.asc_cited_1.text',
216
  'class': 'bg-success',
217
- 'title_key': 'badge.asc_cited_1.title'
218
  },
219
  'asc_cited_2': {
220
- 'text_key': 'badge.asc_cited_2.text',
221
  'class': 'bg-success bg-opacity-75',
222
- 'title_key': 'badge.asc_cited_2.title'
223
  },
224
  'asc_downloaded_1': {
225
- 'text_key': 'badge.asc_downloaded_1.text',
226
  'class': 'beta',
227
- 'title_key': 'badge.asc_downloaded_1.title'
228
  },
229
  'asc_downloaded_2': {
230
- 'text_key': 'badge.asc_downloaded_2.text',
231
  'class': 'alpha',
232
- 'title_key': 'badge.asc_downloaded_2.title'
233
  },
234
  }
235
 
@@ -387,3 +302,4 @@ def create_node_data(node_props, label):
387
  }
388
 
389
  return { "id": node_props['name'], "label": label, **node_props }
 
 
3
  from typing import Dict, List, Any
4
  import pandas as pd
5
  from translations import t
 
6
 
7
+ def run_gds_bfs(gds: GraphDataScience, natural_graph_name: str, reverse_graph_name: str, source_name: str, max_depth: int = None, expert = False) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
+ Exécute un parcours en largeur (BFS) directionnel à l'aide de GDS pour trouver les descendants et les ascendants.
10
 
11
+ Cette fonction nécessite deux graphes pré-projetés en mémoire GDS :
12
+ - Un graphe "naturel" pour trouver les descendants (relations dans le sens source -> cible).
13
+ - Un graphe "inversé" pour trouver les ascendants (relations dans le sens cible -> source).
14
+
15
+ Args:
16
+ gds: L'objet de connexion à la bibliothèque Graph Data Science.
17
+ natural_graph_name: Le nom du graphe GDS projeté avec une orientation NATURELLE.
18
+ reverse_graph_name: Le nom du graphe GDS projeté avec une orientation INVERSÉE.
19
+ source_name: La propriété 'name' du nœud de départ de la recherche.
20
+ max_depth: La profondeur maximale de recherche. Si None, la recherche est illimitée.
21
 
22
  Returns:
23
+ Un dictionnaire contenant l'ID du nœud source et deux DataFrames pandas :
24
+ un pour les chemins des descendants et un pour les chemins des ascendants.
25
  """
26
+ # GDS fonctionne avec des identifiants de nœuds internes (des nombres), pas avec des noms.
27
+ # La première étape est donc de trouver l'ID numérique de notre nœud de départ à partir de son nom.
 
 
 
28
  try:
29
  source_id_result = gds.run_cypher(
30
  """
31
+ MATCH (n {name: $source_name})
32
+ RETURN id(n) AS id , labels(n) as label
33
+ LIMIT 1
 
34
  """,
35
+ {"source_name": source_name}
 
 
 
36
  )
37
+
38
+ if source_id_result.empty or (source_id_result["label"][0]==["Author"] and not expert):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  print(f"Le modèle ou dataset avec le nom '{source_name}' n'a pas été trouvé.")
40
+ return None # Retourne des DataFrames vides
41
+
42
+ # On récupère l'ID de la première ligne du résultat.
43
+ source_node_id = source_id_result['id'][0]
44
  except Exception as e:
45
  print(f"Erreur lors de la recherche de l'ID du nœud source pour '{source_name}': {e}")
46
+ return {"source_label": source_id_result["label"][0][0],"descendant": pd.DataFrame(), "ascendant": pd.DataFrame()}
47
 
48
+ # Préparation des paramètres pour l'algorithme BFS.
49
+ bfs_params = {'sourceNode': source_node_id}
50
+ print(bfs_params)
51
+ # Si une profondeur maximale est spécifiée, on l'ajoute aux paramètres.
52
  if max_depth is not None:
53
+ bfs_params['maxDepth'] = max_depth
54
+
55
+ # --- Exécution du BFS pour trouver les DESCENDANTS sur le graphe NATUREL ---
56
+ # On récupère l'objet graphe depuis GDS.
57
+ g_natural = gds.graph.get(natural_graph_name)
58
+ # On exécute l'algorithme BFS en mode `stream` pour obtenir les chemins.
59
+ desc_df = gds.bfs.stream(g_natural, **bfs_params)
60
+ print("BFS pour les descendants sur le graphe naturel terminé.")
61
+
62
+ # --- Exécution du BFS pour trouver les ASCENDANTS sur le graphe INVERSÉ ---
63
+ # Utiliser un graphe inversé est très efficace pour trouver les parents/ancêtres.
64
+ g_reverse = gds.graph.get(reverse_graph_name)
65
+ asc_df = gds.bfs.stream(g_reverse, **bfs_params)
66
+ print("BFS pour les ascendants sur le graphe inversé terminé.")
67
+ print("DESC",desc_df)
68
+ print("ASC",asc_df)
69
+ print(source_id_result["label"][0][0])
70
+
71
+ # Retourne les résultats sous forme d'un dictionnaire structuré.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  return {
73
+ "source_node": source_node_id,"source_label": source_id_result["label"][0][0],
 
74
  "descendant": desc_df,
75
+ "ascendant": asc_df
76
  }
77
 
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
 
81
 
 
101
  # Les classes CSS sont directement des classes Bootstrap 5 pour simplifier le rendu dans le template HTML.
102
  badges_info = {
103
  'desc_cited_1': {
104
+ 'text': t('badge.desc_cited_1.text', lang),
105
  'class': 'bg-success',
106
+ 'title': t('badge.desc_cited_1.title', lang)
107
  },
108
  'desc_cited_2': {
109
+ 'text': t('badge.desc_cited_2.text', lang),
110
  'class': 'bg-success bg-opacity-75',
111
+ 'title': t('badge.desc_cited_2.title', lang)
112
  },
113
  'desc_downloaded_1': {
114
+ 'text': t('badge.desc_downloaded_1.text', lang),
115
  'class': 'beta',
116
+ 'title': t('badge.desc_downloaded_1.title', lang)
117
  },
118
  'desc_downloaded_2': {
119
+ 'text': t('badge.desc_downloaded_2.text', lang),
120
  'class': 'alpha',
121
+ 'title': t('badge.desc_downloaded_2.title', lang)
122
  },
123
 
124
  'asc_foundation': {
125
+ 'text': t('badge.asc_foundation.text', lang),
126
  'class': 'bg-warning text-dark',
127
+ 'title': t('badge.asc_foundation.title', lang)
128
  },
129
  'asc_cited_1': {
130
+ 'text': t('badge.asc_cited_1.text', lang),
131
  'class': 'bg-success',
132
+ 'title': t('badge.asc_cited_1.title', lang)
133
  },
134
  'asc_cited_2': {
135
+ 'text': t('badge.asc_cited_2.text', lang),
136
  'class': 'bg-success bg-opacity-75',
137
+ 'title': t('badge.asc_cited_2.title', lang)
138
  },
139
  'asc_downloaded_1': {
140
+ 'text': t('badge.asc_downloaded_1.text', lang),
141
  'class': 'beta',
142
+ 'title': t('badge.asc_downloaded_1.title', lang)
143
  },
144
  'asc_downloaded_2': {
145
+ 'text': t('badge.asc_downloaded_2.text', lang),
146
  'class': 'alpha',
147
+ 'title': t('badge.asc_downloaded_2.title', lang)
148
  },
149
  }
150
 
 
302
  }
303
 
304
  return { "id": node_props['name'], "label": label, **node_props }
305
+
application_neo4j/static/css/style.css CHANGED
@@ -2,79 +2,13 @@
2
  * STYLE GLOBAL & GRAPHIQUE SIGMA
3
  * =================================================================== */
4
 
5
- /* Explicit sizing and minimum widths avoid engine-specific flexbox
6
- * expansion differences (notably between Blink and Gecko). */
7
- html {
8
- -webkit-text-size-adjust: 100%;
9
- text-size-adjust: 100%;
10
- }
11
-
12
- body {
13
- min-width: 320px;
14
- }
15
-
16
- .legacy-browser-warning {
17
- display: none;
18
- margin: 0;
19
- padding: 0.75rem 1rem;
20
- color: #664d03;
21
- background: #fff3cd;
22
- border-bottom: 1px solid #ffecb5;
23
- font-weight: 600;
24
- text-align: center;
25
- }
26
-
27
- img,
28
- svg {
29
- max-width: 100%;
30
- height: auto;
31
- }
32
-
33
- .row > *,
34
- .card,
35
- .card-body,
36
- .flex-grow-1 {
37
- min-width: 0;
38
- }
39
-
40
- .navbar .container {
41
- column-gap: 1rem;
42
- row-gap: 0.75rem;
43
- }
44
-
45
- .navbar-brand {
46
- min-width: 0;
47
- max-width: 100%;
48
- white-space: normal;
49
- }
50
-
51
- .navbar-brand small,
52
- #suggestions-list li {
53
- overflow-wrap: anywhere;
54
- word-break: break-word;
55
- }
56
-
57
- .navbar-actions {
58
- min-width: 0;
59
- margin-left: auto;
60
- }
61
-
62
  #sigma-container {
63
  width: 100%;
64
  height: 600px; /* Un peu plus de hauteur pour un meilleur confort */
65
- position: relative;
66
- overflow: hidden;
67
- border: 1px solid #dee2e6;
68
- border: 1px solid var(--bs-border-color, #dee2e6); /* Variable Bootstrap */
69
  margin-top: 1rem;
70
- border-radius: 0.375rem;
71
- border-radius: var(--bs-border-radius, 0.375rem); /* Variable Bootstrap */
72
- background-color: #f8f9fa;
73
- background-color: var(--bs-light-bg-subtle, #f8f9fa); /* Fond légèrement teinté */
74
- }
75
-
76
- #floating-legend {
77
- max-width: calc(100% - 1rem);
78
  }
79
 
80
  /* ===================================================================
@@ -98,62 +32,35 @@ svg {
98
  /* ===================================================================
99
  * STYLE DES TABLES (DATATABLES)
100
  * =================================================================== */
101
- /* A single, deterministic wrapping policy replaces the previous contradictory
102
- * normal/nowrap declarations. */
103
  #descendance-table th, #descendance-table td,
104
- #ascendance-table th, #ascendance-table td,
105
- #train-table th, #train-table td,
106
- #graph-models-table th, #graph-models-table td {
107
- white-space: nowrap;
108
- vertical-align: middle;
109
  }
110
 
111
- #descendance-table th,
112
- #ascendance-table th,
113
- #train-table th {
114
- min-width: 120px;
115
  }
116
 
117
- #descendance-table th:first-child, #descendance-table td:first-child,
118
- #ascendance-table th:first-child, #ascendance-table td:first-child,
119
- #train-table th:first-child, #train-table td:first-child,
120
- #graph-models-table th:first-child, #graph-models-table td:first-child {
121
  min-width: 200px;
122
- white-space: normal;
123
- overflow-wrap: anywhere;
124
- word-break: break-word;
125
  }
126
 
127
- .table-responsive,
128
- div.dataTables_wrapper,
129
- div.dataTables_wrapper div.dataTables_scroll {
130
- width: 100%;
131
- max-width: 100%;
132
- }
133
-
134
- .table-responsive,
135
- div.dataTables_wrapper div.dataTables_scrollBody {
136
- overflow-x: auto !important;
137
- -webkit-overflow-scrolling: touch;
138
- }
139
-
140
- div.dataTables_wrapper div.dataTables_scrollHeadInner {
141
- min-width: 100%;
142
- }
143
-
144
- div.dataTables_wrapper table.dataTable {
145
- margin-left: 0 !important;
146
- margin-right: 0 !important;
147
- table-layout: auto;
148
  }
149
 
150
  /* Personnalisation de l'input de recherche de DataTables pour qu'il ressemble à un champ Bootstrap */
151
  div.dataTables_wrapper div.dataTables_filter input {
152
- max-width: 100%;
153
- border: 1px solid #dee2e6;
154
- border: 1px solid var(--bs-border-color, #dee2e6);
155
- border-radius: 0.375rem;
156
- border-radius: var(--bs-border-radius, 0.375rem);
157
  padding: 0.375rem 0.75rem;
158
  margin-left: 0.5em;
159
  }
@@ -193,12 +100,8 @@ div.dataTables_wrapper .table thead th.sorting_desc::after {
193
  /* Ce style est pour si vous utilisez une <ul> simple.
194
  Si vous avez bien une <ul class="list-group">, vous pouvez supprimer ce bloc. */
195
  box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
196
- max-width: 100%;
197
- overflow-x: hidden;
198
- border-radius: 0.375rem;
199
- border-radius: var(--bs-border-radius, 0.375rem);
200
- background-color: #f8f9fa;
201
- background-color: var(--bs-light, #f8f9fa);
202
  }
203
  #suggestions-list li:hover {
204
  background-color: #94bfeb; ;
@@ -295,22 +198,11 @@ div.dataTables_wrapper .table thead th.sorting_desc::after {
295
  }
296
 
297
  /* Change la couleur de fond des cases à cocher lorsqu'elles sont cochées */
298
- .btn-primary {
299
  background-color: #6a11cb;
300
  border-color:#6a11cb;
301
  }
302
 
303
- .btn-outline-primary {
304
- color: #6a11cb;
305
- border-color: #6a11cb;
306
- }
307
-
308
- .btn-outline-primary:hover {
309
- color: #fff;
310
- background-color: #6a11cb;
311
- border-color: #6a11cb;
312
- }
313
-
314
  .stretched-link{
315
  color: #6a11cb;
316
  }
@@ -376,31 +268,4 @@ main {
376
  .btn-modern:hover {
377
  background: linear-gradient(135deg, #5a0fbf, #1d63e1);
378
  transform: translateY(-3px);
379
- }
380
-
381
- @media (max-width: 575.98px) {
382
- .navbar-brand,
383
- .navbar-actions {
384
- flex: 1 1 100%;
385
- margin-right: 0;
386
- }
387
-
388
- .navbar-actions {
389
- justify-content: flex-start !important;
390
- }
391
-
392
- #sigma-container {
393
- height: 70vh;
394
- min-height: 420px;
395
- }
396
-
397
- div.dataTables_wrapper div.dataTables_filter label {
398
- display: block;
399
- width: 100%;
400
- }
401
-
402
- div.dataTables_wrapper div.dataTables_filter input {
403
- width: 100%;
404
- margin: 0.375rem 0 0;
405
- }
406
- }
 
2
  * STYLE GLOBAL & GRAPHIQUE SIGMA
3
  * =================================================================== */
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  #sigma-container {
6
  width: 100%;
7
  height: 600px; /* Un peu plus de hauteur pour un meilleur confort */
8
+ border: 1px solid var(--bs-border-color); /* Variable Bootstrap */
 
 
 
9
  margin-top: 1rem;
10
+ border-radius: var(--bs-border-radius); /* Variable Bootstrap */
11
+ background-color: var(--bs-light-bg-subtle); /* Fond légèrement teinté */
 
 
 
 
 
 
12
  }
13
 
14
  /* ===================================================================
 
32
  /* ===================================================================
33
  * STYLE DES TABLES (DATATABLES)
34
  * =================================================================== */
35
+ /* Permet au texte de revenir à la ligne naturellement */
 
36
  #descendance-table th, #descendance-table td,
37
+ #ascendance-table th, #ascendance-table td {
38
+ white-space: normal; /* <-- La correction clé ! */
39
+ word-wrap: break-word; /* Force le retour à la ligne des mots longs */
40
+ vertical-align: middle; /* Garde le centrage vertical */
 
41
  }
42
 
43
+ /* On peut donner une largeur minimale aux colonnes pour garder une bonne structure */
44
+ #descendance-table th, #ascendance-table th {
45
+ min-width: 120px; /* Ajustez cette valeur selon vos besoins */
 
46
  }
47
 
48
+ /* Cas spécifique pour les colonnes potentiellement longues comme le nom du modèle */
49
+ #descendance-table th:first-child, #ascendance-table th:first-child {
 
 
50
  min-width: 200px;
 
 
 
51
  }
52
 
53
+ /* Empêche le texte de se couper dans les cellules, force le défilement horizontal */
54
+ #descendance-table th, #descendance-table td,
55
+ #ascendance-table th, #ascendance-table td {
56
+ white-space: nowrap;
57
+ vertical-align: middle; /* Centrage vertical pour un look plus propre */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  }
59
 
60
  /* Personnalisation de l'input de recherche de DataTables pour qu'il ressemble à un champ Bootstrap */
61
  div.dataTables_wrapper div.dataTables_filter input {
62
+ border: 1px solid var(--bs-border-color);
63
+ border-radius: var(--bs-border-radius);
 
 
 
64
  padding: 0.375rem 0.75rem;
65
  margin-left: 0.5em;
66
  }
 
100
  /* Ce style est pour si vous utilisez une <ul> simple.
101
  Si vous avez bien une <ul class="list-group">, vous pouvez supprimer ce bloc. */
102
  box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
103
+ border-radius: var(--bs-border-radius);
104
+ background-color: var(--bs-light);
 
 
 
 
105
  }
106
  #suggestions-list li:hover {
107
  background-color: #94bfeb; ;
 
198
  }
199
 
200
  /* Change la couleur de fond des cases à cocher lorsqu'elles sont cochées */
201
+ .btn {
202
  background-color: #6a11cb;
203
  border-color:#6a11cb;
204
  }
205
 
 
 
 
 
 
 
 
 
 
 
 
206
  .stretched-link{
207
  color: #6a11cb;
208
  }
 
268
  .btn-modern:hover {
269
  background: linear-gradient(135deg, #5a0fbf, #1d63e1);
270
  transform: translateY(-3px);
271
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
application_neo4j/static/js/browser_compatibility.js DELETED
@@ -1,14 +0,0 @@
1
- (function () {
2
- "use strict";
3
-
4
- // `document.documentMode` is exposed by Internet Explorer only. The rest of
5
- // the application deliberately targets maintained browsers supported by
6
- // Bootstrap 5.
7
- if (document.documentMode) {
8
- var warning = document.getElementById("legacy-browser-warning");
9
- if (warning) {
10
- warning.hidden = false;
11
- warning.style.display = "block";
12
- }
13
- }
14
- }());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
application_neo4j/static/js/script.js CHANGED
@@ -193,15 +193,12 @@ document.addEventListener("DOMContentLoaded", () => {
193
  const common_dt_options = {
194
  "language": { "url": datatablesLangUrl },
195
  "pageLength": 10,
196
- "responsive": true,
197
- "scrollX": true,
198
- // Afficher en premier les modèles les plus téléchargés.
199
- "order": [[2, "desc"]],
200
  "columnDefs": [
201
  {
202
  // Appliquer notre plugin 'numeric-string' aux colonnes cibles
203
  "type": "numeric-string",
204
- "targets": [2, 4, 8, 9, 10, 11] // Downloads, Likes, Distance, Ascendants, Descendants, Citations
205
  }
206
  ]
207
  // "searching" est activé par défaut, on peut le customiser
@@ -233,12 +230,12 @@ document.addEventListener("DOMContentLoaded", () => {
233
  node.id || "N/A",
234
  node.author || "Inconnu",
235
  // La conversion en string via la condition est parfaite ici
236
- String(node.downloads ?? (I18N['js.unknown'] || "Inconnu")),
237
- node.task || (I18N['js.unknown_f'] || "Inconnue"),
238
  String(node.likes ?? "0"),
239
- String(node.createdAt ?? (I18N['js.unknown_f'] || "Inconnue")),
240
- node.dataset || (I18N['js.unknown'] || "Inconnu"),
241
- node.license || (I18N['js.unknown_f'] || "Inconnue"),
242
  distance > 0 ? `+${distance}` : String(distance ?? 0),
243
  String(node.ascendantsCount ?? "0"),
244
  String(node.descendantsCount ?? "0"),
@@ -354,4 +351,4 @@ document.addEventListener("DOMContentLoaded", () => {
354
  checkbox.addEventListener('change', toggleDepthInput);
355
  toggleDepthInput();
356
  }
357
- });
 
193
  const common_dt_options = {
194
  "language": { "url": datatablesLangUrl },
195
  "pageLength": 10,
196
+ "responsive": true,"scrollX": true , "scrollY":true,
 
 
 
197
  "columnDefs": [
198
  {
199
  // Appliquer notre plugin 'numeric-string' aux colonnes cibles
200
  "type": "numeric-string",
201
+ "targets": [2, 4, 6, 7, 8, 9] // Indices des colonnes: Downloads, Likes, Distance, etc.
202
  }
203
  ]
204
  // "searching" est activé par défaut, on peut le customiser
 
230
  node.id || "N/A",
231
  node.author || "Inconnu",
232
  // La conversion en string via la condition est parfaite ici
233
+ String(node.downloads ?? (window.__I18N['js.unknown'] || "Inconnu")),
234
+ node.task || (window.__I18N['js.unknown_f'] || "Inconnue"),
235
  String(node.likes ?? "0"),
236
+ String(node.createdAt ?? (window.__I18N['js.unknown_f'] || "Inconnue")),
237
+ node.dataset || (window.__I18N['js.unknown'] || "Inconnu"),
238
+ node.license || (window.__I18N['js.unknown_f'] || "Inconnue"),
239
  distance > 0 ? `+${distance}` : String(distance ?? 0),
240
  String(node.ascendantsCount ?? "0"),
241
  String(node.descendantsCount ?? "0"),
 
351
  checkbox.addEventListener('change', toggleDepthInput);
352
  toggleDepthInput();
353
  }
354
+ });
application_neo4j/static/js/script_dataset.js CHANGED
@@ -209,8 +209,7 @@ document.addEventListener("DOMContentLoaded", () => {
209
  const common_dt_options = {
210
  "language": { "url": datatablesLangUrl },
211
  "pageLength": 10,
212
- "responsive": true,
213
- "scrollX": true,
214
  "columnDefs": [
215
  {
216
  // Appliquer notre plugin 'numeric-string' aux colonnes cibles
@@ -244,12 +243,12 @@ document.addEventListener("DOMContentLoaded", () => {
244
  node.id || "N/A",
245
  node.author || "Inconnu",
246
  // La conversion en string via la condition est parfaite ici
247
- String(node.downloads ?? (I18N['js.unknown'] || "Inconnu")),
248
- node.task || (I18N['js.unknown_f'] || "Inconnue"),
249
  String(node.likes ?? "0"),
250
- String(node.createdAt ?? (I18N['js.unknown_f'] || "Inconnue")),
251
- node.dataset || (I18N['js.unknown'] || "Inconnu"),
252
- node.license || (I18N['js.unknown_f'] || "Inconnue"),
253
  String(node.ascendantsCount ?? "0"),
254
  String(node.descendantsCount ?? "0"),
255
  String(node.citationCount ?? "0")
@@ -362,4 +361,4 @@ document.addEventListener("DOMContentLoaded", () => {
362
  checkbox.addEventListener('change', toggleDepthInput);
363
  toggleDepthInput();
364
  }
365
- });
 
209
  const common_dt_options = {
210
  "language": { "url": datatablesLangUrl },
211
  "pageLength": 10,
212
+ "responsive": true,"scrollX": true , "scrollY":true,
 
213
  "columnDefs": [
214
  {
215
  // Appliquer notre plugin 'numeric-string' aux colonnes cibles
 
243
  node.id || "N/A",
244
  node.author || "Inconnu",
245
  // La conversion en string via la condition est parfaite ici
246
+ String(node.downloads ?? (window.__I18N['js.unknown'] || "Inconnu")),
247
+ node.task || (window.__I18N['js.unknown_f'] || "Inconnue"),
248
  String(node.likes ?? "0"),
249
+ String(node.createdAt ?? (window.__I18N['js.unknown_f'] || "Inconnue")),
250
+ node.dataset || (window.__I18N['js.unknown'] || "Inconnu"),
251
+ node.license || (window.__I18N['js.unknown_f'] || "Inconnue"),
252
  String(node.ascendantsCount ?? "0"),
253
  String(node.descendantsCount ?? "0"),
254
  String(node.citationCount ?? "0")
 
361
  checkbox.addEventListener('change', toggleDepthInput);
362
  toggleDepthInput();
363
  }
364
+ });
application_neo4j/static/js/script_expert.js CHANGED
@@ -34,9 +34,9 @@ function populateGraphModelsTable(graphData, table) {
34
 
35
 
36
  // Construction de la ligne avec les nouvelles données
37
- const unknown = I18N['js.unknown'] || "Inconnu";
38
- const unknownF = I18N['js.unknown_f'] || "Inconnue";
39
- const na = I18N['js.na'] || "N/A";
40
  const rowData = [
41
  node.id || na,
42
  node.author || unknown,
@@ -380,3 +380,4 @@ document.addEventListener("DOMContentLoaded", () => {
380
 
381
 
382
  });
 
 
34
 
35
 
36
  // Construction de la ligne avec les nouvelles données
37
+ const unknown = window.__I18N['js.unknown'] || "Inconnu";
38
+ const unknownF = window.__I18N['js.unknown_f'] || "Inconnue";
39
+ const na = window.__I18N['js.na'] || "N/A";
40
  const rowData = [
41
  node.id || na,
42
  node.author || unknown,
 
380
 
381
 
382
  });
383
+
application_neo4j/static/js/search_progress.js DELETED
@@ -1,324 +0,0 @@
1
- (() => {
2
- const POLL_INTERVAL_MS = 750;
3
- let activeCancelUrl = null;
4
-
5
- function translate(key, fallback) {
6
- return window.__I18N_DATA?.[key] || fallback;
7
- }
8
-
9
- function format(template, values) {
10
- return Object.entries(values).reduce(
11
- (text, [key, value]) => text.replace(`{${key}}`, String(value)),
12
- template
13
- );
14
- }
15
-
16
- function stageLabel(stage) {
17
- const labels = {
18
- queued: ["search.progress_stage_queued", "En attente du serveur…"],
19
- preparing_graph: ["search.progress_stage_preparing", "Préparation du graphe…"],
20
- searching_descendants: ["search.progress_stage_descendants", "Recherche des descendants…"],
21
- searching_ancestors: ["search.progress_stage_ancestors", "Recherche des ascendants…"],
22
- building_result: ["search.progress_stage_result", "Construction du résultat…"],
23
- building_nodes: ["search.progress_stage_nodes", "Analyse des nœuds du résultat…"],
24
- building_relationships: ["search.progress_stage_relationships", "Chargement des relations…"],
25
- formatting_result: ["search.progress_stage_formatting", "Mise en forme du résultat…"],
26
- building_highlights: ["search.progress_stage_highlights", "Calcul des modèles importants…"],
27
- completed: ["search.progress_stage_completed", "Recherche terminée."],
28
- failed: ["search.progress_stage_failed", "La recherche a échoué."],
29
- cancelled: ["search.progress_stage_cancelled", "Recherche annulée."],
30
- };
31
- const [key, fallback] = labels[stage] || labels.queued;
32
- return translate(key, fallback);
33
- }
34
-
35
- function findProgressPanel(form) {
36
- return form.parentElement.querySelector(".search-progress");
37
- }
38
-
39
- function setSubmitting(form, submitting) {
40
- form.dataset.submitting = submitting ? "true" : "false";
41
- const submitButton = form.querySelector('button[type="submit"]');
42
- if (submitButton) submitButton.disabled = submitting;
43
- }
44
-
45
- function showError(form, progress, message) {
46
- const stage = progress.querySelector(".search-progress-stage");
47
- const status = progress.querySelector(".search-progress-status");
48
- const bar = progress.querySelector(".search-progress-bar");
49
- stage.textContent = translate(
50
- "search.progress_stage_failed",
51
- "La recherche a échoué."
52
- );
53
- status.textContent = message || translate(
54
- "search.progress_connection_error",
55
- "Impossible d’obtenir la progression depuis le serveur."
56
- );
57
- bar.style.width = "0%";
58
- progress.classList.remove("alert-info");
59
- progress.classList.add("alert-danger");
60
- setSubmitting(form, false);
61
- }
62
-
63
- function showCancelled(form, progress) {
64
- const stage = progress.querySelector(".search-progress-stage");
65
- const status = progress.querySelector(".search-progress-status");
66
- const note = progress.querySelector(".search-progress-note");
67
- const bar = progress.querySelector(".search-progress-bar");
68
- stage.textContent = stageLabel("cancelled");
69
- status.textContent = "";
70
- note.textContent = "";
71
- bar.style.width = "0%";
72
- progress.classList.remove("alert-info", "alert-danger");
73
- progress.classList.add("alert-secondary");
74
- setSubmitting(form, false);
75
- }
76
-
77
- function cancelActiveSearch() {
78
- if (!activeCancelUrl) return;
79
- const cancelUrl = activeCancelUrl;
80
- activeCancelUrl = null;
81
- if (
82
- navigator.sendBeacon
83
- && navigator.sendBeacon(
84
- cancelUrl,
85
- new Blob([], { type: "text/plain" })
86
- )
87
- ) {
88
- return;
89
- }
90
- fetch(cancelUrl, {
91
- method: "POST",
92
- credentials: "same-origin",
93
- keepalive: true,
94
- }).catch(() => {});
95
- }
96
-
97
- function renderStatus(progress, job) {
98
- const title = progress.querySelector(".search-progress-title");
99
- const stage = progress.querySelector(".search-progress-stage");
100
- const status = progress.querySelector(".search-progress-status");
101
- const note = progress.querySelector(".search-progress-note");
102
- const bar = progress.querySelector(".search-progress-bar");
103
-
104
- stage.textContent = stageLabel(job.stage);
105
-
106
- if (job.status === "queued" || job.stage === "queued") {
107
- title.textContent = translate(
108
- "search.progress_queue_title",
109
- "Recherche en file d’attente"
110
- );
111
- progress.classList.remove("alert-info", "alert-danger", "alert-secondary");
112
- progress.classList.add("alert-warning");
113
-
114
- const queueDetails = [];
115
- if (job.queue_position && job.queued_jobs) {
116
- queueDetails.push(
117
- format(
118
- translate(
119
- "search.progress_queue_position",
120
- "Position dans la file : {position}/{total}"
121
- ),
122
- {
123
- position: job.queue_position,
124
- total: job.queued_jobs,
125
- }
126
- )
127
- );
128
- const jobsAhead = job.queue_position - 1;
129
- if (jobsAhead === 0) {
130
- queueDetails.push(
131
- translate(
132
- "search.progress_queue_next",
133
- "Votre recherche est la prochaine."
134
- )
135
- );
136
- } else {
137
- queueDetails.push(
138
- format(
139
- translate(
140
- jobsAhead === 1
141
- ? "search.progress_queue_ahead_one"
142
- : "search.progress_queue_ahead_many",
143
- jobsAhead === 1
144
- ? "1 recherche devant la vôtre"
145
- : "{count} recherches devant la vôtre"
146
- ),
147
- { count: jobsAhead }
148
- )
149
- );
150
- }
151
- }
152
- if (job.running_jobs) {
153
- queueDetails.push(
154
- format(
155
- translate(
156
- job.running_jobs === 1
157
- ? "search.progress_queue_running_one"
158
- : "search.progress_queue_running_many",
159
- job.running_jobs === 1
160
- ? "1 recherche en cours"
161
- : "{count} recherches en cours"
162
- ),
163
- { count: job.running_jobs }
164
- )
165
- );
166
- }
167
- status.textContent = queueDetails.join(" · ");
168
- note.textContent = translate(
169
- "search.progress_queue_note",
170
- "La recherche démarrera automatiquement dès qu’une capacité sera disponible."
171
- );
172
- bar.style.width = "0%";
173
- bar.removeAttribute("aria-valuenow");
174
- return;
175
- }
176
-
177
- title.textContent = translate(
178
- "search.progress_title",
179
- "Recherche en cours…"
180
- );
181
- progress.classList.remove("alert-warning", "alert-danger", "alert-secondary");
182
- progress.classList.add("alert-info");
183
-
184
- const details = [
185
- format(
186
- translate("search.progress_elapsed", "Temps écoulé : {seconds} s"),
187
- { seconds: job.elapsed_seconds }
188
- ),
189
- ];
190
-
191
- if (job.completed_items !== null && job.total_items) {
192
- details.push(
193
- format(
194
- translate(
195
- "search.progress_items",
196
- "Éléments traités par le serveur : {completed}/{total}"
197
- ),
198
- {
199
- completed: job.completed_items,
200
- total: job.total_items,
201
- }
202
- )
203
- );
204
- }
205
-
206
- if (job.progress_percent !== null) {
207
- details.push(
208
- format(
209
- translate(
210
- "search.progress_server_percent",
211
- "Progression indiquée par le serveur : {percent} %"
212
- ),
213
- { percent: job.progress_percent }
214
- )
215
- );
216
- bar.style.width = `${job.progress_percent}%`;
217
- bar.setAttribute("aria-valuenow", String(job.progress_percent));
218
- } else {
219
- bar.style.width = "0%";
220
- bar.removeAttribute("aria-valuenow");
221
- }
222
-
223
- if (job.remaining_seconds !== null) {
224
- details.push(
225
- format(
226
- translate(
227
- "search.progress_remaining_step",
228
- "Temps restant estimé pour cette étape : {seconds} s"
229
- ),
230
- { seconds: job.remaining_seconds }
231
- )
232
- );
233
- }
234
-
235
- status.textContent = details.join(" · ");
236
- note.textContent = translate(
237
- "search.progress_server_note",
238
- "La progression repose sur les opérations réellement terminées par le serveur."
239
- );
240
- }
241
-
242
- async function readJson(response) {
243
- const body = await response.json().catch(() => ({}));
244
- if (!response.ok) {
245
- throw new Error(body.error || `${response.status} ${response.statusText}`);
246
- }
247
- return body;
248
- }
249
-
250
- async function pollJob(form, progress, statusUrl) {
251
- try {
252
- const job = await readJson(await fetch(statusUrl, {
253
- credentials: "same-origin",
254
- cache: "no-store",
255
- }));
256
- renderStatus(progress, job);
257
-
258
- if (job.status === "cancelled") {
259
- activeCancelUrl = null;
260
- showCancelled(form, progress);
261
- return;
262
- }
263
- if (job.result_url) {
264
- activeCancelUrl = null;
265
- window.location.assign(job.result_url);
266
- return;
267
- }
268
- window.setTimeout(() => pollJob(form, progress, statusUrl), POLL_INTERVAL_MS);
269
- } catch (error) {
270
- cancelActiveSearch();
271
- showError(form, progress, error.message);
272
- }
273
- }
274
-
275
- async function startSearch(form, progress) {
276
- const formData = new FormData(form);
277
- const endpoint = form.dataset.searchJobUrl || "/api/search-jobs";
278
-
279
- progress.classList.remove("d-none", "alert-danger");
280
- progress.classList.add("alert-info");
281
- progress.querySelector(".search-progress-title").textContent = translate(
282
- "search.progress_title",
283
- "Recherche en cours…"
284
- );
285
- renderStatus(progress, {
286
- stage: "queued",
287
- elapsed_seconds: 0,
288
- progress_percent: null,
289
- remaining_seconds: null,
290
- queue_position: null,
291
- queued_jobs: null,
292
- running_jobs: null,
293
- completed_items: null,
294
- total_items: null,
295
- });
296
- setSubmitting(form, true);
297
-
298
- try {
299
- const job = await readJson(await fetch(endpoint, {
300
- method: "POST",
301
- body: formData,
302
- credentials: "same-origin",
303
- headers: { Accept: "application/json" },
304
- }));
305
- activeCancelUrl = job.cancel_url;
306
- pollJob(form, progress, job.status_url);
307
- } catch (error) {
308
- showError(form, progress, error.message);
309
- }
310
- }
311
-
312
- document.addEventListener("DOMContentLoaded", () => {
313
- document.querySelectorAll("form[data-search-progress]").forEach((form) => {
314
- form.addEventListener("submit", (event) => {
315
- event.preventDefault();
316
- if (form.dataset.submitting === "true" || !form.reportValidity()) return;
317
- const progress = findProgressPanel(form);
318
- if (progress) startSearch(form, progress);
319
- });
320
- });
321
- });
322
-
323
- window.addEventListener("pagehide", cancelActiveSearch);
324
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
application_neo4j/static/js/utils.js CHANGED
@@ -231,9 +231,6 @@ function formatDateFr(dateString) {
231
 
232
  // Fonction pour parser la valeur : enlève les espaces et convertit en nombre si possible
233
  function parseNumericValue(value) {
234
- if (typeof value === 'number' && Number.isFinite(value)) {
235
- return { isNumber: true, value };
236
- }
237
  if (typeof value === 'string') {
238
  // Enlève les espaces (pour les nombres comme "12 345") et les virgules
239
  const cleanedValue = value.replace(/[\s,]/g, '');
@@ -247,7 +244,7 @@ function parseNumericValue(value) {
247
  }
248
 
249
  // Définition du tri ascendant
250
- jQuery.fn.dataTable.ext.type.order['numeric-string-asc'] = function (a, b) {
251
  const valA = parseNumericValue(a);
252
  const valB = parseNumericValue(b);
253
 
@@ -263,19 +260,9 @@ jQuery.fn.dataTable.ext.type.order['numeric-string-asc'] = function (a, b) {
263
  }
264
  };
265
 
266
- // Tri descendant, en conservant les valeurs inconnues après les nombres.
267
- jQuery.fn.dataTable.ext.type.order['numeric-string-desc'] = function (a, b) {
268
- const valA = parseNumericValue(a);
269
- const valB = parseNumericValue(b);
270
-
271
- if (valA.isNumber && valB.isNumber) {
272
- return valB.value - valA.value;
273
- } else if (valA.isNumber && !valB.isNumber) {
274
- return -1;
275
- } else if (!valA.isNumber && valB.isNumber) {
276
- return 1;
277
- }
278
- return String(valB.value).localeCompare(String(valA.value));
279
  };
280
 
281
  /**
 
231
 
232
  // Fonction pour parser la valeur : enlève les espaces et convertit en nombre si possible
233
  function parseNumericValue(value) {
 
 
 
234
  if (typeof value === 'string') {
235
  // Enlève les espaces (pour les nombres comme "12 345") et les virgules
236
  const cleanedValue = value.replace(/[\s,]/g, '');
 
244
  }
245
 
246
  // Définition du tri ascendant
247
+ jQuery.fn.dataTable.ext.order['numeric-string-asc'] = function (a, b) {
248
  const valA = parseNumericValue(a);
249
  const valB = parseNumericValue(b);
250
 
 
260
  }
261
  };
262
 
263
+ // Le tri descendant est simplement l'inverse de l'ascendant
264
+ jQuery.fn.dataTable.ext.order['numeric-string-desc'] = function (a, b) {
265
+ return jQuery.fn.dataTable.ext.order['numeric-string-asc'](a, b) * -1;
 
 
 
 
 
 
 
 
 
 
266
  };
267
 
268
  /**
application_neo4j/static/notice/generate_notice_en.py DELETED
@@ -1,503 +0,0 @@
1
- """Generate the English version of the CNIL GenMod information notice.
2
-
3
- This is a maintainer utility, not a runtime dependency of the Space. Run it
4
- from the repository root after installing ``pypdf`` and ``reportlab``:
5
-
6
- python application_neo4j/static/notice/generate_notice_en.py \
7
- --source application_neo4j/static/notice/notice.pdf
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import argparse
13
- import math
14
- import tempfile
15
- from pathlib import Path
16
-
17
- from PIL import Image as PillowImage
18
- from pypdf import PdfReader
19
- from reportlab.lib import colors
20
- from reportlab.lib.enums import TA_CENTER
21
- from reportlab.lib.pagesizes import A4
22
- from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
23
- from reportlab.lib.units import mm
24
- from reportlab.pdfbase import pdfmetrics
25
- from reportlab.pdfbase.ttfonts import TTFont
26
- from reportlab.platypus import (
27
- Flowable,
28
- Image,
29
- PageBreak,
30
- Paragraph,
31
- SimpleDocTemplate,
32
- Spacer,
33
- Table,
34
- TableStyle,
35
- )
36
-
37
-
38
- CNIL_BLUE = colors.HexColor("#0046A8")
39
- LIGHT_BLUE = colors.HexColor("#EAF2FB")
40
- RED = colors.HexColor("#E52A22")
41
- GOLD = colors.HexColor("#F7B500")
42
-
43
-
44
- class NeuralNetworkDiagram(Flowable):
45
- """Small English recreation of the neural-network diagram."""
46
-
47
- def __init__(self, width=170 * mm, height=54 * mm):
48
- super().__init__()
49
- self.width = width
50
- self.height = height
51
-
52
- def draw(self):
53
- canvas = self.canv
54
- canvas.saveState()
55
- scale_x = self.width / 480
56
- scale_y = self.height / 155
57
- canvas.scale(scale_x, scale_y)
58
-
59
- inputs = [(45, 115), (45, 78), (45, 41)]
60
- hidden_1 = [(190, 130), (190, 97), (190, 64), (190, 31)]
61
- hidden_2 = [(330, 130), (330, 97), (330, 64), (330, 31)]
62
- outputs = [(445, 113), (445, 78), (445, 43)]
63
-
64
- canvas.setStrokeColor(colors.HexColor("#222222"))
65
- canvas.setLineWidth(0.6)
66
- for x1, y1 in inputs:
67
- for x2, y2 in hidden_1:
68
- canvas.line(x1 + 32, y1, x2 - 10, y2)
69
- for x1, y1 in hidden_2:
70
- for x2, y2 in outputs:
71
- canvas.line(x1 + 10, y1, x2 - 32, y2)
72
-
73
- canvas.setFont("DejaVuSans", 7.5)
74
- for index, (x, y) in enumerate(inputs, 1):
75
- canvas.setFillColor(colors.HexColor("#DDEFD6"))
76
- canvas.roundRect(x - 32, y - 10, 64, 20, 4, fill=1, stroke=1)
77
- canvas.setFillColor(colors.black)
78
- canvas.drawCentredString(x, y - 3, f"Input {index}")
79
- for layer in (hidden_1, hidden_2):
80
- for x, y in layer:
81
- canvas.setFillColor(colors.HexColor("#FFF1C9"))
82
- canvas.circle(x, y, 10, fill=1, stroke=1)
83
- for index, (x, y) in enumerate(outputs, 1):
84
- canvas.setFillColor(colors.HexColor("#DCE9FF"))
85
- canvas.roundRect(x - 32, y - 10, 64, 20, 4, fill=1, stroke=1)
86
- canvas.setFillColor(colors.black)
87
- canvas.drawCentredString(x, y - 3, f"Output {index}")
88
-
89
- canvas.setFillColor(colors.white)
90
- path = canvas.beginPath()
91
- path.moveTo(255, 80)
92
- path.lineTo(275, 98)
93
- path.lineTo(295, 80)
94
- path.lineTo(275, 62)
95
- path.close()
96
- canvas.drawPath(path, fill=1, stroke=1)
97
- canvas.setFillColor(colors.black)
98
- canvas.setFont("DejaVuSans", 10)
99
- canvas.drawCentredString(275, 77, "f")
100
- canvas.setFont("DejaVuSans", 7.5)
101
- canvas.drawCentredString(190, 8, "Layer 1")
102
- canvas.drawCentredString(330, 8, "Layer 2")
103
- canvas.drawCentredString(275, 118, "Activation function")
104
- canvas.drawCentredString(405, 145, "Edge parameters")
105
- canvas.restoreState()
106
-
107
-
108
- class HuggingFaceDiagram(Flowable):
109
- """English recreation of the platform example diagram."""
110
-
111
- def __init__(self, width=170 * mm, height=55 * mm):
112
- super().__init__()
113
- self.width = width
114
- self.height = height
115
-
116
- def draw_arrow(self, canvas, x1, y1, x2, y2, color):
117
- canvas.setStrokeColor(color)
118
- canvas.setFillColor(color)
119
- canvas.setLineWidth(1.8)
120
- canvas.line(x1, y1, x2, y2)
121
- angle = math.atan2(y2 - y1, x2 - x1)
122
- for offset in (-0.5, 0.5):
123
- canvas.line(
124
- x2,
125
- y2,
126
- x2 - 7 * math.cos(angle + offset),
127
- y2 - 7 * math.sin(angle + offset),
128
- )
129
-
130
- def box(self, canvas, x, y, width, height, text, color):
131
- canvas.setStrokeColor(color)
132
- canvas.setFillColor(colors.white)
133
- canvas.rect(x, y, width, height, fill=1, stroke=1)
134
- canvas.setFillColor(color)
135
- canvas.setFont("DejaVuSans-Bold", 8)
136
- canvas.drawCentredString(x + width / 2, y + height / 2 - 3, text)
137
-
138
- def draw(self):
139
- canvas = self.canv
140
- canvas.saveState()
141
- scale_x = self.width / 500
142
- scale_y = self.height / 170
143
- canvas.scale(scale_x, scale_y)
144
- self.box(canvas, 5, 118, 70, 24, "USER A", CNIL_BLUE)
145
- self.box(canvas, 105, 105, 105, 48, "", RED)
146
- canvas.setFillColor(RED)
147
- canvas.setFont("DejaVuSans-Bold", 8)
148
- canvas.drawCentredString(157.5, 130, "MACHINE-LEARNING")
149
- canvas.drawCentredString(157.5, 116, "MODEL")
150
- self.box(canvas, 290, 105, 85, 48, "DATASET", RED)
151
- self.box(canvas, 420, 118, 75, 24, "USER B", CNIL_BLUE)
152
- self.box(canvas, 205, 15, 90, 26, "USER C", CNIL_BLUE)
153
- self.box(canvas, 207, 66, 86, 25, "NEW MODEL", RED)
154
-
155
- canvas.setFillColor(GOLD)
156
- canvas.circle(250, 130, 21, fill=1, stroke=0)
157
- canvas.setFillColor(colors.HexColor("#795500"))
158
- canvas.setFont("DejaVuSans-Bold", 8)
159
- canvas.drawCentredString(250, 127, "HF")
160
- canvas.setFont("DejaVuSans-Bold", 7)
161
- canvas.drawCentredString(250, 157, "Hugging Face")
162
-
163
- self.draw_arrow(canvas, 75, 130, 101, 130, colors.black)
164
- self.draw_arrow(canvas, 420, 130, 379, 130, colors.black)
165
- self.draw_arrow(canvas, 210, 130, 225, 130, RED)
166
- self.draw_arrow(canvas, 290, 130, 275, 130, RED)
167
- self.draw_arrow(canvas, 250, 65, 250, 43, colors.black)
168
- self.draw_arrow(canvas, 250, 93, 250, 106, RED)
169
- self.draw_arrow(canvas, 157, 103, 218, 42, colors.HexColor("#2385CC"))
170
- self.draw_arrow(canvas, 332, 103, 282, 42, colors.HexColor("#2385CC"))
171
-
172
- canvas.setFillColor(colors.black)
173
- canvas.setFont("DejaVuSans", 6.8)
174
- canvas.drawString(5, 106, "individual or organisation")
175
- canvas.drawString(402, 106, "individual or organisation")
176
- canvas.drawCentredString(250, 3, "individual or organisation")
177
- canvas.setFillColor(CNIL_BLUE)
178
- canvas.setFont("DejaVuSans-Bold", 13)
179
- canvas.drawString(12, 12, "CNIL")
180
- canvas.restoreState()
181
-
182
-
183
- def extract_figures(source: Path, work_dir: Path) -> tuple[Path, Path]:
184
- reader = PdfReader(str(source))
185
- fox_image = reader.pages[2].images[0].image
186
- fox_crop = fox_image.crop((0, 0, int(fox_image.width * 0.35), fox_image.height))
187
- fox_path = work_dir / "fox.jpg"
188
- fox_crop.save(fox_path, quality=92)
189
-
190
- memorisation_path = work_dir / "memorisation.jpg"
191
- reader.pages[5].images[0].image.save(memorisation_path, quality=92)
192
- return fox_path, memorisation_path
193
-
194
-
195
- def build_styles():
196
- pdfmetrics.registerFont(
197
- TTFont("DejaVuSans", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf")
198
- )
199
- pdfmetrics.registerFont(
200
- TTFont(
201
- "DejaVuSans-Bold",
202
- "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
203
- )
204
- )
205
- styles = getSampleStyleSheet()
206
- styles.add(
207
- ParagraphStyle(
208
- "NoticeTitle",
209
- fontName="DejaVuSans-Bold",
210
- fontSize=20,
211
- leading=24,
212
- textColor=CNIL_BLUE,
213
- spaceAfter=8 * mm,
214
- )
215
- )
216
- styles.add(
217
- ParagraphStyle(
218
- "Section",
219
- fontName="DejaVuSans-Bold",
220
- fontSize=14,
221
- leading=17,
222
- textColor=CNIL_BLUE,
223
- spaceBefore=3 * mm,
224
- spaceAfter=2.5 * mm,
225
- )
226
- )
227
- styles.add(
228
- ParagraphStyle(
229
- "Subsection",
230
- fontName="DejaVuSans-Bold",
231
- fontSize=11,
232
- leading=14,
233
- textColor=colors.HexColor("#222222"),
234
- spaceBefore=2 * mm,
235
- spaceAfter=1.5 * mm,
236
- )
237
- )
238
- styles.add(
239
- ParagraphStyle(
240
- "BodyNotice",
241
- fontName="DejaVuSans",
242
- fontSize=8.7,
243
- leading=11.3,
244
- alignment=4,
245
- spaceAfter=2.1 * mm,
246
- )
247
- )
248
- styles.add(
249
- ParagraphStyle(
250
- "Caption",
251
- fontName="DejaVuSans",
252
- fontSize=7.2,
253
- leading=9,
254
- alignment=TA_CENTER,
255
- textColor=colors.HexColor("#555555"),
256
- spaceAfter=2 * mm,
257
- )
258
- )
259
- return styles
260
-
261
-
262
- def generate(source: Path, output: Path) -> None:
263
- styles = build_styles()
264
- body = styles["BodyNotice"]
265
- title = styles["NoticeTitle"]
266
- section = styles["Section"]
267
- subsection = styles["Subsection"]
268
- caption = styles["Caption"]
269
-
270
- def p(text: str):
271
- return Paragraph(text, body)
272
-
273
- def bullet(text: str):
274
- style = ParagraphStyle(
275
- "BulletNotice",
276
- parent=body,
277
- leftIndent=5 * mm,
278
- firstLineIndent=-3.5 * mm,
279
- bulletIndent=1.5 * mm,
280
- spaceAfter=1.4 * mm,
281
- )
282
- return Paragraph(text, style, bulletText="•")
283
-
284
- with tempfile.TemporaryDirectory(prefix="genmod-notice-") as temp_dir:
285
- fox_path, memorisation_path = extract_figures(source, Path(temp_dir))
286
-
287
- doc = SimpleDocTemplate(
288
- str(output),
289
- pagesize=A4,
290
- rightMargin=18 * mm,
291
- leftMargin=18 * mm,
292
- topMargin=18 * mm,
293
- bottomMargin=16 * mm,
294
- title="A tool for exploring the genealogy of open-source AI models",
295
- author="CNIL",
296
- subject="English translation of the GenMod information notice",
297
- )
298
-
299
- def decorate_page(canvas, document):
300
- canvas.saveState()
301
- canvas.setStrokeColor(CNIL_BLUE)
302
- canvas.setLineWidth(1.2)
303
- canvas.line(18 * mm, 12 * mm, A4[0] - 18 * mm, 12 * mm)
304
- canvas.setFont("DejaVuSans-Bold", 8)
305
- canvas.setFillColor(CNIL_BLUE)
306
- canvas.drawString(18 * mm, 7.5 * mm, "CNIL · GenMod")
307
- canvas.setFont("DejaVuSans", 8)
308
- canvas.drawRightString(
309
- A4[0] - 18 * mm, 7.5 * mm, f"Page {document.page}"
310
- )
311
- canvas.restoreState()
312
-
313
- story = [
314
- Paragraph(
315
- "A tool for exploring the genealogy of open-source AI models",
316
- title,
317
- ),
318
- Paragraph("What is an AI model?", section),
319
- Paragraph("Training", subsection),
320
- p(
321
- "The fields in which AI can be used are vast and difficult to delimit, as they extend to many aspects of everyday life: online searches and purchases, targeted advertising, machine translation, personal digital assistants and connected cities, as well as transport, healthcare and many other areas."
322
- ),
323
- p(
324
- "Under Article 3 of the European Union Artificial Intelligence Act, an AI system is ‘a machine-based system that is designed to operate with varying levels of autonomy and that may exhibit adaptiveness after deployment, and that, for explicit or implicit objectives, infers, from the input it receives, how to generate outputs such as predictions, content, recommendations, or decisions that can influence physical or virtual environments.’"
325
- ),
326
- p(
327
- "These systems incorporate one or more AI models. Such models can be described as algorithms whose operation is determined by a set of attributes and which are designed to perform tasks such as prediction, classification, inference or generation. Deep neural-network models, for example, consist of nodes (neurons) arranged in layers and connected by edges, each of which has a parameter or ‘weight’. During training, these parameters are adjusted to learn the statistical distribution of the training data."
328
- ),
329
- p("For a simple neural network, the model’s attributes might include:"),
330
- bullet("the type and size of each layer (linear, convolutional, attention, etc.);"),
331
- bullet("the weights assigned to each edge (also called parameters);"),
332
- bullet("the activation functions between layers; and"),
333
- bullet("possibly other operations located within or between layers."),
334
- Spacer(1, 1.5 * mm),
335
- NeuralNetworkDiagram(),
336
- Paragraph("Figure 1 — Diagram of a neural network (authors)", caption),
337
- PageBreak(),
338
- Paragraph("Training from examples", subsection),
339
- p(
340
- "When a neural network is trained to recognise images, it is given examples in which the image pixels are associated with an annotation, or label. The model then adjusts its parameters—the weights—to learn to assign the correct label as often as possible."
341
- ),
342
- p(
343
- "The main difference between a deep-learning model and a conventional computer program is that the model learns inference rules autonomously from data. In a conventional program, a task is solved using explicit rules defined in advance by the developer. To sort a list of numbers, for example, the order in which elements are compared is precisely programmed. This works very well for clearly delimited tasks for which explicit rules can be established."
344
- ),
345
- p(
346
- "By contrast, the rules of a deep-learning model are not specified directly. Instead, the model is given a large volume of examples—its training data—so that, during the learning phase, it can identify the statistical regularities or strategies that solve the task. This makes it possible to automate much more complex tasks for which defining every rule by hand would be extremely difficult or impossible."
347
- ),
348
- Paragraph("Using a trained model", subsection),
349
- p(
350
- "Once trained, a model can be used as it is, without further modification, to perform specific tasks automatically. This is called the inference phase. The model receives an input—an image, text or an audio signal, for example—and produces an output based on what it learned during training. It then acts as a ‘black box’: it applies the regularities it has learned without changing its internal structure or learning anything new."
351
- ),
352
- p(
353
- "Consider machine translation. A neural-network model trained on millions of pairs of Spanish and English sentences can translate a new text from English into Spanish at inference time. Linguistic rules are not explicitly implemented in the model; it has learned to match sequences of words by drawing on statistical regularities in the training data."
354
- ),
355
- p(
356
- "Another example is image-to-text models, which generate image captions. A model can be trained to associate images with textual descriptions. Once trained, it can receive a new image—such as a photograph of a dog running in a park—and automatically produce a sentence such as ‘A dog is running on the grass in a park.’"
357
- ),
358
- Table(
359
- [[
360
- Image(str(fox_path), width=58 * mm, height=41 * mm),
361
- Paragraph(
362
- "The image is a close-up portrait of a red fox standing in the snow. The fox is in the centre, its vibrant orange fur lit by golden sunrise or sunset light. It stands alert, ears upright and looking off-camera. The pristine white snow has a bluish tint reflecting the cool colours of the sky. The softly blurred blue-grey background adds depth and highlights the fox as the subject.",
363
- body,
364
- ),
365
- ]],
366
- colWidths=[62 * mm, 105 * mm],
367
- style=TableStyle([
368
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
369
- ("LEFTPADDING", (0, 0), (-1, -1), 2),
370
- ("RIGHTPADDING", (0, 0), (-1, -1), 4),
371
- ("BOX", (0, 0), (-1, -1), 0.4, colors.HexColor("#BBBBBB")),
372
- ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F7F7F7")),
373
- ]),
374
- ),
375
- Paragraph(
376
- "Figure 2 — Example of an image’s textual description (source: imagedescriber.online)",
377
- caption,
378
- ),
379
- PageBreak(),
380
- p(
381
- "These use cases illustrate the power of advances in deep learning to automate complex, often subjective or ambiguous tasks for which writing explicit rules by hand would be difficult or impossible."
382
- ),
383
- Paragraph("Derivatives: fine-tuning, merging, quantisation, etc.", subsection),
384
- p(
385
- "To adapt a neural network more closely to a specific task, optimise its performance or reduce its running costs, several transformations can be applied to an initial pre-trained model. Such modifications are very common in the open-source ecosystem. They make it possible to create new models from one or more initial models, sometimes with additional data. Four common transformations between a target model and a source model are:"
386
- ),
387
- bullet(
388
- "<b>Fine-tuning:</b> a general-purpose source model continues training on a specific dataset to improve its performance on a more precise task. A large language model initially trained on freely accessible internet sources may, for example, be fine-tuned on a company’s business data so that it better understands the company’s specialist vocabulary and expressions."
389
- ),
390
- bullet(
391
- "<b>Quantisation:</b> the precision of the source model’s weights is reduced to lower its memory footprint. Weights initially encoded using 32 bits may, for example, be rounded to the nearest value that can be encoded using 16 bits."
392
- ),
393
- bullet(
394
- "<b>Adaptation:</b> the source model is adjusted so that it can be used with limited computing resources—for example, on a mobile phone—most often using a Low-Rank Adaptation (LoRA) technique."
395
- ),
396
- bullet(
397
- "<b>Merging:</b> layers from different models are combined to improve performance. For example, two LLMs, A and B, may both have been trained on general text corpora. Averaging the weights in their twelfth layers and replacing A’s twelfth layer with that average may produce a model C that performs better than either A or B."
398
- ),
399
- PageBreak(),
400
- Paragraph("A platform for open-source AI: Hugging Face", section),
401
- p(
402
- "To enable AI models to be shared and made available by and for as many people as possible, the Franco-American company Hugging Face, founded in 2016, developed a platform that centralises models and datasets. It also provides software tools for deploying AI models. Today it hosts more open-source models than any other platform—over two million were available in September 2025—and acts as a catalyst for the open-source AI ecosystem."
403
- ),
404
- p("The following example illustrates how the platform works:"),
405
- bullet("User C wants to create a model that automatically detects fraudulent emails."),
406
- bullet(
407
- "User A has published a natural-language-processing model on Hugging Face—for example, Google’s <i>google/gemma-3-27b-it</i>—and User B has published a dataset containing millions of emails labelled as fraudulent or non-fraudulent."
408
- ),
409
- bullet(
410
- "User C downloads the model and dataset, then trains the model on the dataset to specialise it for classification. Once the resulting classifier performs well, User C can publish it on Hugging Face so that anyone can use it as is or train it again on other datasets to improve its performance."
411
- ),
412
- HuggingFaceDiagram(),
413
- Paragraph("Figure 3 — Example of how Hugging Face is used", caption),
414
- p(
415
- "In short, Hugging Face provides tools for building, training and deploying deep-learning models based on open-source technologies and code. It also offers a space where researchers, engineers and enthusiasts can exchange ideas, obtain support and contribute to open-source projects."
416
- ),
417
- Paragraph("Benefits of open-source AI", section),
418
- p(
419
- "The rise of open-source AI shows that powerful and partly transparent models can compete with proprietary solutions while stimulating collective innovation. BLOOM (176 billion parameters, 2022), developed by the BigScience consortium, illustrates this dynamic: trained in 46 languages, it enabled multilingual conversational assistants to be developed in Africa, Latin America and the Arab world, where commercial models remained poorly adapted to local languages."
420
- ),
421
- PageBreak(),
422
- p(
423
- "Similarly, GPT-J and GPT-NeoX (EleutherAI) and Vicuna (LMSYS) provided the basis for open-source projects that enabled universities and start-ups to create specialised chatbots without relying on closed services. These models also made research into bias detection and the robustness of large language models possible."
424
- ),
425
- p(
426
- "In computer vision, Stable Diffusion (Stability AI) transformed visual creation. Its weights were made freely available, opening the way to applications in video games, advertising and audiovisual production, including concept images, storyboards and rapid design. Its open-source code enabled tools such as Automatic1111 and ComfyUI, used by hundreds of thousands of artists and researchers."
427
- ),
428
- p(
429
- "The impact is industrial as well. LLaMA (Meta), initially released to the research community, gave rise to a generation of derivatives—including Zephyr, Nous-Hermes and OpenChat—that are now used for customer support, summarising legal or medical documents and prototyping code."
430
- ),
431
- p(
432
- "In science, projects such as BioGPT (Microsoft Research) and OpenFold (inspired by AlphaFold) demonstrate how opening code and weights accelerates biomedical research by allowing independent laboratories to reproduce and improve results in protein-structure prediction and molecule discovery."
433
- ),
434
- p(
435
- "These achievements show that open source is not limited to reusing models: it enables technological ownership, local adaptation and open innovation in fields as varied as artistic creation, healthcare, education, data science and the cultural industries. Nevertheless, some opacity may remain as to how models are built: with what data and which training algorithm? A CNIL paper and a PEReN paper provide further discussion of this issue."
436
- ),
437
- Paragraph("Privacy issues", section),
438
- Paragraph("Memorisation by AI models", subsection),
439
- p(
440
- "The scientific community has long established that information about the data used to train an AI model can often be extracted from even partial access to the model. In generative AI, a model may reproduce text or images that are very close to examples in its training dataset. In the figure below, when Stable Diffusion is asked to generate an image matching the caption ‘Emma Watson to play Belle in Disney’s Beauty and the Beast’, its output closely resembles an image from the training database."
441
- ),
442
- p(
443
- "This is regurgitation—only one form of memorisation. Statistical methods can sometimes reveal other information, such as whether a particular record belonged to the training dataset, through membership-inference attacks."
444
- ),
445
- Image(str(memorisation_path), width=153 * mm, height=91 * mm),
446
- Paragraph(
447
- "Figure 4 — Source: Louis Hunt (LinkedIn). Original photograph: UN Women.",
448
- caption,
449
- ),
450
- PageBreak(),
451
- p(
452
- "For text models such as chatbots, prominent cases of regurgitation are already widely documented. One version of ChatGPT, for example, was reported to reproduce New York Times articles almost verbatim and to provide personal information such as a person’s name, address and telephone number."
453
- ),
454
- Paragraph("The GDPR and AI models", subsection),
455
- p(
456
- "If information about an AI model’s training database can generally be extracted from the model, what legal regime should apply when that database contains personal data? The European Data Protection Board clarified this question in Opinion 28/2024 on AI models, on which the CNIL’s latest recommendations are based. In particular, the Opinion concludes that the GDPR applies in many cases to AI models trained on personal data because of their capacity for memorisation."
457
- ),
458
- Paragraph("Exercising rights in relation to AI models", subsection),
459
- p(
460
- "For AI models subject to the GDPR, people affected by memorisation have rights in relation to their data, including the rights to object, access and erasure. These rights are not absolute: a controller may depart from them in several situations, for example where a request is manifestly unfounded or excessive (Article 12), or where the controller is unable to identify the person concerned. The CNIL’s guidance on exercising rights provides further details."
461
- ),
462
- p(
463
- "At a time when European bodies are confirming that data-protection law also applies to AI models, the CNIL wishes to study the conditions under which these rights could be exercised within the highly dynamic open-source AI ecosystem."
464
- ),
465
- Spacer(1, 8 * mm),
466
- Table(
467
- [[Paragraph(
468
- "This document is an English translation of the French information notice made available by the CNIL for the GenMod application. In the event of any discrepancy, the French version is the reference version.",
469
- ParagraphStyle(
470
- "TranslationNote",
471
- parent=body,
472
- textColor=CNIL_BLUE,
473
- backColor=LIGHT_BLUE,
474
- borderPadding=8,
475
- ),
476
- )]],
477
- colWidths=[170 * mm],
478
- ),
479
- ]
480
-
481
- doc.build(story, onFirstPage=decorate_page, onLaterPages=decorate_page)
482
-
483
-
484
- def main() -> None:
485
- parser = argparse.ArgumentParser()
486
- parser.add_argument(
487
- "--source",
488
- type=Path,
489
- default=Path("application_neo4j/static/notice/notice.pdf"),
490
- )
491
- parser.add_argument(
492
- "--output",
493
- type=Path,
494
- default=Path("application_neo4j/static/notice/notice_en.pdf"),
495
- )
496
- args = parser.parse_args()
497
- args.output.parent.mkdir(parents=True, exist_ok=True)
498
- generate(args.source, args.output)
499
- print(f"Generated {args.output}")
500
-
501
-
502
- if __name__ == "__main__":
503
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
application_neo4j/static/notice/notice.pdf CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:98c0455e1d8419a184a2471fc69960027976f9cb7d9ee07752516c3b216b4a79
3
- size 1166198
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dced59e9dc43bd1823fd18cee4005415e375e9f77c7a58bc6ffcf3a48e732a3c
3
+ size 409000
application_neo4j/static/notice/notice_en.pdf DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:abec965848c0fd334609151abd08615455a57872fbd0709961e6c8a8ddb2e93b
3
- size 383279
 
 
 
 
application_neo4j/templates/expert.html CHANGED
@@ -18,18 +18,13 @@
18
  </head>
19
 
20
  <body class="d-flex flex-column min-vh-100">
21
- <div id="legacy-browser-warning" class="legacy-browser-warning" role="alert" hidden>{{ t('site.legacy_browser_warning') }}</div>
22
- <script src="{{ url_for('static', filename='js/browser_compatibility.js') }}"></script>
23
  <header class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
24
  <div class="container">
25
  <a class="navbar-brand" href="{{ url_for('home', lang=current_lang) }}">
26
  <span class="fw-bold">{{ t('site.nav_brand_model_expert') }}</span>
27
  <small class="d-block text-muted">{{ t('site.nav_subtitle') }}</small>
28
  </a>
29
- <div class="navbar-actions d-flex align-items-center justify-content-end flex-wrap gap-2">
30
- <a href="{{ url_for('home', lang=current_lang) }}" class="btn btn-sm btn-dark me-3">
31
- <i class="bi bi-house-door-fill me-1" aria-hidden="true"></i>{{ t('site.home_button') }}
32
- </a>
33
  <a href="{{ url_for('set_language_route', lang='fr') }}" class="btn btn-sm {{ 'btn-primary' if current_lang == 'fr' else 'btn-outline-primary' }}">{{ t('lang.fr') }}</a>
34
  <a href="{{ url_for('set_language_route', lang='en') }}" class="btn btn-sm {{ 'btn-primary' if current_lang == 'en' else 'btn-outline-primary' }}">{{ t('lang.en') }}</a>
35
  </div>
@@ -45,9 +40,7 @@
45
  </div>
46
  <div class="row g-3 mb-4">
47
  <div class="col-12">
48
- <form method="POST" action="{{ url_for('findnode_expert', lang=current_lang) }}" autocomplete="off"
49
- data-search-progress data-search-job-url="{{ url_for('create_search_job') }}">
50
- <input type="hidden" name="search_mode" value="expert">
51
  <input type="hidden" name="filter" value="{{ search.filter or '' }}">
52
 
53
  <div class="row g-3 align-items-end">
@@ -57,7 +50,7 @@
57
  <div class="position-relative">
58
  <input type="text" name="name" id="search-input" class="form-control"
59
  placeholder="{{ t('expert.placeholder') }}"
60
- value="{{ search.name or '' }}" required autocomplete="off" />
61
  <ul id="suggestions-list" class="list-group position-absolute w-100" style="display: none; z-index: 1000;"></ul>
62
  </div>
63
  </div>
@@ -86,15 +79,13 @@
86
  <label class="form-label fw-bold">{{ t('search.label_depth') }}</label>
87
  <div class="d-flex align-items-center gap-3">
88
  <div class="form-check form-switch">
89
- <input class="form-check-input" type="checkbox" id="depth-unlimited" name="depth_unlimited"
90
- {% if search.unlimited %}checked{% endif %}>
91
  <label class="form-check-label" for="depth-unlimited">{{ t('search.depth_unlimited') }}</label>
92
  </div>
93
  <div class="input-group input-group-sm">
94
  <span class="input-group-text">{{ t('search.depth_limited') }}</span>
95
  <input class="form-control" type="number" name="depth" id="depth"
96
- value="{{ search.depth or 3 }}" min="1" max="5"
97
- {% if search.unlimited %}disabled{% endif %}>
98
  </div>
99
  </div>
100
  </div>
@@ -107,21 +98,6 @@
107
  </div>
108
  </div>
109
  </form>
110
- <div class="search-progress alert alert-info mt-3 d-none" role="status" aria-live="polite">
111
- <div class="d-flex align-items-start gap-3">
112
- <div class="spinner-border text-primary flex-shrink-0" aria-hidden="true"></div>
113
- <div class="flex-grow-1">
114
- <strong class="search-progress-title"></strong>
115
- <div class="search-progress-stage mt-1 fw-semibold"></div>
116
- <div class="search-progress-status small mt-1"></div>
117
- <div class="progress mt-2" style="height: 0.5rem;">
118
- <div class="search-progress-bar progress-bar progress-bar-striped progress-bar-animated"
119
- role="progressbar" aria-valuemin="0" aria-valuemax="100" style="width: 0%;"></div>
120
- </div>
121
- <small class="search-progress-note text-muted d-block mt-2"></small>
122
- </div>
123
- </div>
124
- </div>
125
  </div>
126
  </div>
127
 
@@ -269,12 +245,11 @@
269
  <script src="https://cdnjs.cloudflare.com/ajax/libs/sigma.js/2.4.0/sigma.min.js"></script>
270
  <script src="https://unpkg.com/graphology@0.25.1/dist/graphology.umd.min.js"></script>
271
  <script src="https://cdn.jsdelivr.net/npm/graphology-library/dist/graphology-library.min.js"></script>
 
272
  <script>
273
  window.__I18N_DATA = {{ js_i18n_data | tojson }};
274
  window.__I18N_LANG = "{{ current_lang }}";
275
  </script>
276
- <script src="{{ url_for('static', filename='js/utils.js') }}"></script>
277
- <script src="{{ url_for('static', filename='js/search_progress.js') }}"></script>
278
  <script src="{{ url_for('static', filename='js/script_expert.js') }}"></script>
279
  <script>
280
  document.addEventListener('DOMContentLoaded', function () {
 
18
  </head>
19
 
20
  <body class="d-flex flex-column min-vh-100">
 
 
21
  <header class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
22
  <div class="container">
23
  <a class="navbar-brand" href="{{ url_for('home', lang=current_lang) }}">
24
  <span class="fw-bold">{{ t('site.nav_brand_model_expert') }}</span>
25
  <small class="d-block text-muted">{{ t('site.nav_subtitle') }}</small>
26
  </a>
27
+ <div class="d-flex gap-2">
 
 
 
28
  <a href="{{ url_for('set_language_route', lang='fr') }}" class="btn btn-sm {{ 'btn-primary' if current_lang == 'fr' else 'btn-outline-primary' }}">{{ t('lang.fr') }}</a>
29
  <a href="{{ url_for('set_language_route', lang='en') }}" class="btn btn-sm {{ 'btn-primary' if current_lang == 'en' else 'btn-outline-primary' }}">{{ t('lang.en') }}</a>
30
  </div>
 
40
  </div>
41
  <div class="row g-3 mb-4">
42
  <div class="col-12">
43
+ <form method="POST" action="{{ url_for('findnode_expert', lang=current_lang) }}" autocomplete="off">
 
 
44
  <input type="hidden" name="filter" value="{{ search.filter or '' }}">
45
 
46
  <div class="row g-3 align-items-end">
 
50
  <div class="position-relative">
51
  <input type="text" name="name" id="search-input" class="form-control"
52
  placeholder="{{ t('expert.placeholder') }}"
53
+ value="{{ request.form.name or '' }}" required autocomplete="off" />
54
  <ul id="suggestions-list" class="list-group position-absolute w-100" style="display: none; z-index: 1000;"></ul>
55
  </div>
56
  </div>
 
79
  <label class="form-label fw-bold">{{ t('search.label_depth') }}</label>
80
  <div class="d-flex align-items-center gap-3">
81
  <div class="form-check form-switch">
82
+ <input class="form-check-input" type="checkbox" id="depth-unlimited" name="depth_unlimited" checked>
 
83
  <label class="form-check-label" for="depth-unlimited">{{ t('search.depth_unlimited') }}</label>
84
  </div>
85
  <div class="input-group input-group-sm">
86
  <span class="input-group-text">{{ t('search.depth_limited') }}</span>
87
  <input class="form-control" type="number" name="depth" id="depth"
88
+ value="{{ request.form.depth }}" min="1" max="5" disabled>
 
89
  </div>
90
  </div>
91
  </div>
 
98
  </div>
99
  </div>
100
  </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  </div>
102
  </div>
103
 
 
245
  <script src="https://cdnjs.cloudflare.com/ajax/libs/sigma.js/2.4.0/sigma.min.js"></script>
246
  <script src="https://unpkg.com/graphology@0.25.1/dist/graphology.umd.min.js"></script>
247
  <script src="https://cdn.jsdelivr.net/npm/graphology-library/dist/graphology-library.min.js"></script>
248
+ <script src="{{ url_for('static', filename='js/utils.js') }}"></script>
249
  <script>
250
  window.__I18N_DATA = {{ js_i18n_data | tojson }};
251
  window.__I18N_LANG = "{{ current_lang }}";
252
  </script>
 
 
253
  <script src="{{ url_for('static', filename='js/script_expert.js') }}"></script>
254
  <script>
255
  document.addEventListener('DOMContentLoaded', function () {
application_neo4j/templates/index.html CHANGED
@@ -5,7 +5,7 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>{{ t('site.title_home') }}</title>
7
  <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
8
- <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
9
 
10
  <style>/* Style simple pour la page d'accueil */
11
  body {
@@ -116,30 +116,11 @@
116
  background: #3498db;
117
  color: #fff;
118
  }
119
-
120
- @media (max-width: 575.98px) {
121
- .lang-switch {
122
- position: static;
123
- justify-content: flex-end;
124
- padding: 12px 15px 0;
125
- }
126
-
127
- .welcome-container {
128
- padding-top: 24px;
129
- }
130
-
131
- .content-card {
132
- padding: 22px;
133
- text-align: left;
134
- }
135
- }
136
 
137
  </style>
138
  </head>
139
 
140
  <body>
141
- <div id="legacy-browser-warning" class="legacy-browser-warning" role="alert" hidden>{{ t('site.legacy_browser_warning') }}</div>
142
- <script src="{{ url_for('static', filename='js/browser_compatibility.js') }}"></script>
143
  <!-- Language selector -->
144
  <div class="lang-switch">
145
  <a href="{{ url_for('set_language_route', lang='fr') }}" class="{{ 'active' if current_lang == 'fr' else '' }}">{{ t('lang.fr') }}</a>
@@ -148,7 +129,7 @@
148
 
149
  <div class="welcome-container">
150
  <h1>{{ t('home.welcome_title') }}</h1>
151
- <a href="{{ url_for('static', filename='notice/notice_en.pdf' if current_lang == 'en' else 'notice/notice.pdf') }}" class="btn-modern" target="_blank">{{ t('home.more_info') }}</a>
152
  </div>
153
  <div class="container content-section">
154
  <div class="row g-4">
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>{{ t('site.title_home') }}</title>
7
  <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
8
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
9
 
10
  <style>/* Style simple pour la page d'accueil */
11
  body {
 
116
  background: #3498db;
117
  color: #fff;
118
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
  </style>
121
  </head>
122
 
123
  <body>
 
 
124
  <!-- Language selector -->
125
  <div class="lang-switch">
126
  <a href="{{ url_for('set_language_route', lang='fr') }}" class="{{ 'active' if current_lang == 'fr' else '' }}">{{ t('lang.fr') }}</a>
 
129
 
130
  <div class="welcome-container">
131
  <h1>{{ t('home.welcome_title') }}</h1>
132
+ <a href="{{ url_for('static', filename='notice/notice.pdf') }}" class="btn-modern" target="_blank">{{ t('home.more_info') }}</a>
133
  </div>
134
  <div class="container content-section">
135
  <div class="row g-4">
application_neo4j/templates/infos.html CHANGED
@@ -1,43 +1,18 @@
1
  <!DOCTYPE html>
2
- <html lang="{{ current_lang }}">
3
  <head>
4
  <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>{{ t('home.more_info') }}</title>
7
  <style>
8
  body, html { margin: 0; padding: 0; height: 100%; overflow: hidden; }
9
- body { display: flex; flex-direction: column; font-family: sans-serif; }
10
- .legacy-browser-warning { display: none; padding: 0.75rem 1rem; color: #664d03; background: #fff3cd; border-bottom: 1px solid #ffecb5; font-weight: 600; text-align: center; }
11
- .toolbar {
12
- display: flex;
13
- align-items: center;
14
- padding: 0.75rem 1rem;
15
- background: #f8f9fa;
16
- border-bottom: 1px solid #dee2e6;
17
- }
18
- .home-button {
19
- color: #fff;
20
- background: #212529;
21
- border: 1px solid #212529;
22
- border-radius: 0.25rem;
23
- padding: 0.375rem 0.75rem;
24
- text-decoration: none;
25
- font-weight: 600;
26
- }
27
- .home-button:hover { color: #fff; background: #424649; }
28
- .pdf-container { width: 100%; height: calc(100% - 52px); flex: 1; min-height: 0; }
29
- .pdf-container iframe { display: block; border: none; width: 100%; height: 100%; }
30
  </style>
31
  </head>
32
  <body>
33
- <div id="legacy-browser-warning" class="legacy-browser-warning" role="alert" hidden>{{ t('site.legacy_browser_warning') }}</div>
34
- <script src="{{ url_for('static', filename='js/browser_compatibility.js') }}"></script>
35
- <nav class="toolbar" aria-label="{{ t('site.home_button') }}">
36
- <a class="home-button" href="{{ url_for('home', lang=current_lang) }}">⌂ {{ t('site.home_button') }}</a>
37
- </nav>
38
  <div class="pdf-container">
39
  <!-- Cette balise iframe va afficher votre PDF -->
40
  <iframe src="{{ url_for('static', filename='pdf/plus_infos_interface.pdf') }}"></iframe>
41
  </div>
42
  </body>
43
- </html>
 
1
  <!DOCTYPE html>
2
+ <html lang="fr">
3
  <head>
4
  <meta charset="UTF-8">
5
+ <title>Plus d'informations</title>
 
6
  <style>
7
  body, html { margin: 0; padding: 0; height: 100%; overflow: hidden; }
8
+ .pdf-container { width: 100%; height: 100%; }
9
+ .pdf-container iframe { border: none; width: 100%; height: 100%; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  </style>
11
  </head>
12
  <body>
 
 
 
 
 
13
  <div class="pdf-container">
14
  <!-- Cette balise iframe va afficher votre PDF -->
15
  <iframe src="{{ url_for('static', filename='pdf/plus_infos_interface.pdf') }}"></iframe>
16
  </div>
17
  </body>
18
+ </html>
application_neo4j/templates/search.html CHANGED
@@ -33,18 +33,13 @@
33
  </head>
34
 
35
  <body class="d-flex flex-column min-vh-100">
36
- <div id="legacy-browser-warning" class="legacy-browser-warning" role="alert" hidden>{{ t('site.legacy_browser_warning') }}</div>
37
- <script src="{{ url_for('static', filename='js/browser_compatibility.js') }}"></script>
38
  <header class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
39
  <div class="container">
40
  <a class="navbar-brand" href="{{ url_for('home', lang=current_lang) }}">
41
  <span class="fw-bold">{{ t('site.nav_brand') }}</span>
42
  <small class="d-block text-muted">{{ t('site.nav_subtitle_full') }}</small>
43
  </a>
44
- <div class="navbar-actions d-flex align-items-center justify-content-end flex-wrap gap-2">
45
- <a href="{{ url_for('home', lang=current_lang) }}" class="btn btn-sm btn-dark me-3">
46
- <i class="bi bi-house-door-fill me-1" aria-hidden="true"></i>{{ t('site.home_button') }}
47
- </a>
48
  <a href="{{ url_for('set_language_route', lang='fr') }}" class="btn btn-sm {{ 'btn-primary' if current_lang == 'fr' else 'btn-outline-primary' }}">{{ t('lang.fr') }}</a>
49
  <a href="{{ url_for('set_language_route', lang='en') }}" class="btn btn-sm {{ 'btn-primary' if current_lang == 'en' else 'btn-outline-primary' }}">{{ t('lang.en') }}</a>
50
  </div>
@@ -62,9 +57,7 @@
62
 
63
  <div class="row g-3 mb-4">
64
  <div class="col-12">
65
- <form method="POST" action="{{ url_for('findnode', lang=current_lang) }}" autocomplete="off"
66
- data-search-progress data-search-job-url="{{ url_for('create_search_job') }}">
67
- <input type="hidden" name="search_mode" value="standard">
68
  <input type="hidden" name="filter" value="{{ search.filter or '' }}">
69
 
70
  <div class="row g-3 align-items-end">
@@ -74,7 +67,7 @@
74
  <div class="position-relative">
75
  <input type="text" name="name" id="search-input" class="form-control"
76
  placeholder="{{ t('search.placeholder_model') }}"
77
- value="{{ search.name or '' }}" required autocomplete="off" />
78
  <ul id="suggestions-list" class="list-group position-absolute w-100" style="display: none; z-index: 1000;"></ul>
79
  </div>
80
  </div>
@@ -101,15 +94,13 @@
101
  <label class="form-label fw-bold">{{ t('search.label_depth') }}</label>
102
  <div class="d-flex align-items-center gap-3">
103
  <div class="form-check form-switch">
104
- <input class="form-check-input" type="checkbox" id="depth-unlimited" name="depth_unlimited"
105
- {% if search.unlimited %}checked{% endif %}>
106
  <label class="form-check-label" for="depth-unlimited">{{ t('search.depth_unlimited') }}</label>
107
  </div>
108
  <div class="input-group input-group-sm">
109
  <span class="input-group-text">{{ t('search.depth_limited') }}</span>
110
  <input class="form-control" type="number" name="depth" id="depth"
111
- value="{{ search.depth or 3 }}" min="1" max="5"
112
- {% if search.unlimited %}disabled{% endif %}>
113
  </div>
114
  </div>
115
  </div>
@@ -124,21 +115,6 @@
124
  </div>
125
  </div>
126
  </form>
127
- <div class="search-progress alert alert-info mt-3 d-none" role="status" aria-live="polite">
128
- <div class="d-flex align-items-start gap-3">
129
- <div class="spinner-border text-primary flex-shrink-0" aria-hidden="true"></div>
130
- <div class="flex-grow-1">
131
- <strong class="search-progress-title"></strong>
132
- <div class="search-progress-stage mt-1 fw-semibold"></div>
133
- <div class="search-progress-status small mt-1"></div>
134
- <div class="progress mt-2" style="height: 0.5rem;">
135
- <div class="search-progress-bar progress-bar progress-bar-striped progress-bar-animated"
136
- role="progressbar" aria-valuemin="0" aria-valuemax="100" style="width: 0%;"></div>
137
- </div>
138
- <small class="search-progress-note text-muted d-block mt-2"></small>
139
- </div>
140
- </div>
141
- </div>
142
  </div>
143
  </div>
144
 
@@ -179,8 +155,8 @@
179
  <span class="badge {{ badge.class }} on-top"
180
  data-bs-toggle="tooltip"
181
  data-bs-placement="top"
182
- title="{{ t(badge.title_key) }}">
183
- {{ t(badge.text_key) }}
184
  </span>
185
  {% endfor %}
186
  </div>
@@ -305,8 +281,8 @@
305
  <span class="badge {{ badge.class }} on-top"
306
  data-bs-toggle="tooltip"
307
  data-bs-placement="top"
308
- title="{{ t(badge.title_key) }}">
309
- {{ t(badge.text_key) }}
310
  </span>
311
  {% endfor %}
312
  </div>
@@ -453,12 +429,11 @@
453
  <script src="https://cdnjs.cloudflare.com/ajax/libs/sigma.js/2.4.0/sigma.min.js"></script>
454
  <script src="https://unpkg.com/graphology@0.25.1/dist/graphology.umd.min.js"></script>
455
  <script src="https://cdn.jsdelivr.net/npm/graphology-library/dist/graphology-library.min.js"></script>
 
456
  <script>
457
  window.__I18N_DATA = {{ js_i18n_data | tojson }};
458
  window.__I18N_LANG = "{{ current_lang }}";
459
  </script>
460
- <script src="{{ url_for('static', filename='js/utils.js') }}"></script>
461
- <script src="{{ url_for('static', filename='js/search_progress.js') }}"></script>
462
  <script src="{{ url_for('static', filename='js/script.js') }}"></script>
463
  <script>
464
  document.addEventListener('DOMContentLoaded', function () {
 
33
  </head>
34
 
35
  <body class="d-flex flex-column min-vh-100">
 
 
36
  <header class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
37
  <div class="container">
38
  <a class="navbar-brand" href="{{ url_for('home', lang=current_lang) }}">
39
  <span class="fw-bold">{{ t('site.nav_brand') }}</span>
40
  <small class="d-block text-muted">{{ t('site.nav_subtitle_full') }}</small>
41
  </a>
42
+ <div class="d-flex gap-2">
 
 
 
43
  <a href="{{ url_for('set_language_route', lang='fr') }}" class="btn btn-sm {{ 'btn-primary' if current_lang == 'fr' else 'btn-outline-primary' }}">{{ t('lang.fr') }}</a>
44
  <a href="{{ url_for('set_language_route', lang='en') }}" class="btn btn-sm {{ 'btn-primary' if current_lang == 'en' else 'btn-outline-primary' }}">{{ t('lang.en') }}</a>
45
  </div>
 
57
 
58
  <div class="row g-3 mb-4">
59
  <div class="col-12">
60
+ <form method="POST" action="{{ url_for('findnode', lang=current_lang) }}" autocomplete="off">
 
 
61
  <input type="hidden" name="filter" value="{{ search.filter or '' }}">
62
 
63
  <div class="row g-3 align-items-end">
 
67
  <div class="position-relative">
68
  <input type="text" name="name" id="search-input" class="form-control"
69
  placeholder="{{ t('search.placeholder_model') }}"
70
+ value="{{ request.form.name or '' }}" required autocomplete="off" />
71
  <ul id="suggestions-list" class="list-group position-absolute w-100" style="display: none; z-index: 1000;"></ul>
72
  </div>
73
  </div>
 
94
  <label class="form-label fw-bold">{{ t('search.label_depth') }}</label>
95
  <div class="d-flex align-items-center gap-3">
96
  <div class="form-check form-switch">
97
+ <input class="form-check-input" type="checkbox" id="depth-unlimited" name="depth_unlimited" checked>
 
98
  <label class="form-check-label" for="depth-unlimited">{{ t('search.depth_unlimited') }}</label>
99
  </div>
100
  <div class="input-group input-group-sm">
101
  <span class="input-group-text">{{ t('search.depth_limited') }}</span>
102
  <input class="form-control" type="number" name="depth" id="depth"
103
+ value="{{ request.form.depth }}" min="1" max="5" disabled>
 
104
  </div>
105
  </div>
106
  </div>
 
115
  </div>
116
  </div>
117
  </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  </div>
119
  </div>
120
 
 
155
  <span class="badge {{ badge.class }} on-top"
156
  data-bs-toggle="tooltip"
157
  data-bs-placement="top"
158
+ title="{{ badge.title }}">
159
+ {{ badge.text }}
160
  </span>
161
  {% endfor %}
162
  </div>
 
281
  <span class="badge {{ badge.class }} on-top"
282
  data-bs-toggle="tooltip"
283
  data-bs-placement="top"
284
+ title="{{ badge.title }}">
285
+ {{ badge.text }}
286
  </span>
287
  {% endfor %}
288
  </div>
 
429
  <script src="https://cdnjs.cloudflare.com/ajax/libs/sigma.js/2.4.0/sigma.min.js"></script>
430
  <script src="https://unpkg.com/graphology@0.25.1/dist/graphology.umd.min.js"></script>
431
  <script src="https://cdn.jsdelivr.net/npm/graphology-library/dist/graphology-library.min.js"></script>
432
+ <script src="{{ url_for('static', filename='js/utils.js') }}"></script>
433
  <script>
434
  window.__I18N_DATA = {{ js_i18n_data | tojson }};
435
  window.__I18N_LANG = "{{ current_lang }}";
436
  </script>
 
 
437
  <script src="{{ url_for('static', filename='js/script.js') }}"></script>
438
  <script>
439
  document.addEventListener('DOMContentLoaded', function () {
application_neo4j/templates/search_dataset.html CHANGED
@@ -33,18 +33,13 @@
33
  </head>
34
 
35
  <body class="d-flex flex-column min-vh-100">
36
- <div id="legacy-browser-warning" class="legacy-browser-warning" role="alert" hidden>{{ t('site.legacy_browser_warning') }}</div>
37
- <script src="{{ url_for('static', filename='js/browser_compatibility.js') }}"></script>
38
  <header class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
39
  <div class="container">
40
  <a class="navbar-brand" href="{{ url_for('home', lang=current_lang) }}">
41
  <span class="fw-bold">{{ t('site.nav_brand') }}</span>
42
  <small class="d-block text-muted">{{ t('site.nav_subtitle') }}</small>
43
  </a>
44
- <div class="navbar-actions d-flex align-items-center justify-content-end flex-wrap gap-2">
45
- <a href="{{ url_for('home', lang=current_lang) }}" class="btn btn-sm btn-dark me-3">
46
- <i class="bi bi-house-door-fill me-1" aria-hidden="true"></i>{{ t('site.home_button') }}
47
- </a>
48
  <a href="{{ url_for('set_language_route', lang='fr') }}" class="btn btn-sm {{ 'btn-primary' if current_lang == 'fr' else 'btn-outline-primary' }}">{{ t('lang.fr') }}</a>
49
  <a href="{{ url_for('set_language_route', lang='en') }}" class="btn btn-sm {{ 'btn-primary' if current_lang == 'en' else 'btn-outline-primary' }}">{{ t('lang.en') }}</a>
50
  </div>
@@ -62,9 +57,7 @@
62
 
63
  <div class="row g-3 mb-4">
64
  <div class="col-12">
65
- <form method="POST" action="{{ url_for('findnode', lang=current_lang) }}" autocomplete="off"
66
- data-search-progress data-search-job-url="{{ url_for('create_search_job') }}">
67
- <input type="hidden" name="search_mode" value="standard">
68
  <input type="hidden" name="filter" value="{{ search.filter or '' }}">
69
 
70
  <div class="row g-3 align-items-end">
@@ -74,7 +67,7 @@
74
  <div class="position-relative">
75
  <input type="text" name="name" id="search-input" class="form-control"
76
  placeholder="{{ t('search.placeholder_dataset') }}"
77
- value="{{ search.name or '' }}" required autocomplete="off" />
78
  <ul id="suggestions-list" class="list-group position-absolute w-100" style="display: none; z-index: 1000;"></ul>
79
  </div>
80
  </div>
@@ -101,15 +94,13 @@
101
  <label class="form-label fw-bold">{{ t('search.label_depth') }}</label>
102
  <div class="d-flex align-items-center gap-3">
103
  <div class="form-check form-switch">
104
- <input class="form-check-input" type="checkbox" id="depth-unlimited" name="depth_unlimited"
105
- {% if search.unlimited %}checked{% endif %}>
106
  <label class="form-check-label" for="depth-unlimited">{{ t('search.depth_unlimited') }}</label>
107
  </div>
108
  <div class="input-group input-group-sm">
109
  <span class="input-group-text">{{ t('search.depth_limited') }}</span>
110
  <input class="form-control" type="number" name="depth" id="depth"
111
- value="{{ search.depth or 3 }}" min="1" max="5"
112
- {% if search.unlimited %}disabled{% endif %}>
113
  </div>
114
  </div>
115
  </div>
@@ -124,21 +115,6 @@
124
  </div>
125
  </div>
126
  </form>
127
- <div class="search-progress alert alert-info mt-3 d-none" role="status" aria-live="polite">
128
- <div class="d-flex align-items-start gap-3">
129
- <div class="spinner-border text-primary flex-shrink-0" aria-hidden="true"></div>
130
- <div class="flex-grow-1">
131
- <strong class="search-progress-title"></strong>
132
- <div class="search-progress-stage mt-1 fw-semibold"></div>
133
- <div class="search-progress-status small mt-1"></div>
134
- <div class="progress mt-2" style="height: 0.5rem;">
135
- <div class="search-progress-bar progress-bar progress-bar-striped progress-bar-animated"
136
- role="progressbar" aria-valuemin="0" aria-valuemax="100" style="width: 0%;"></div>
137
- </div>
138
- <small class="search-progress-note text-muted d-block mt-2"></small>
139
- </div>
140
- </div>
141
- </div>
142
  </div>
143
  </div>
144
 
@@ -263,12 +239,11 @@
263
  <script src="https://cdnjs.cloudflare.com/ajax/libs/sigma.js/2.4.0/sigma.min.js"></script>
264
  <script src="https://unpkg.com/graphology@0.25.1/dist/graphology.umd.min.js"></script>
265
  <script src="https://cdn.jsdelivr.net/npm/graphology-library/dist/graphology-library.min.js"></script>
 
266
  <script>
267
  window.__I18N_DATA = {{ js_i18n_data | tojson }};
268
  window.__I18N_LANG = "{{ current_lang }}";
269
  </script>
270
- <script src="{{ url_for('static', filename='js/utils.js') }}"></script>
271
- <script src="{{ url_for('static', filename='js/search_progress.js') }}"></script>
272
  <script src="{{ url_for('static', filename='js/script_dataset.js') }}"></script>
273
  <script>
274
  document.addEventListener('DOMContentLoaded', function () {
 
33
  </head>
34
 
35
  <body class="d-flex flex-column min-vh-100">
 
 
36
  <header class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
37
  <div class="container">
38
  <a class="navbar-brand" href="{{ url_for('home', lang=current_lang) }}">
39
  <span class="fw-bold">{{ t('site.nav_brand') }}</span>
40
  <small class="d-block text-muted">{{ t('site.nav_subtitle') }}</small>
41
  </a>
42
+ <div class="d-flex gap-2">
 
 
 
43
  <a href="{{ url_for('set_language_route', lang='fr') }}" class="btn btn-sm {{ 'btn-primary' if current_lang == 'fr' else 'btn-outline-primary' }}">{{ t('lang.fr') }}</a>
44
  <a href="{{ url_for('set_language_route', lang='en') }}" class="btn btn-sm {{ 'btn-primary' if current_lang == 'en' else 'btn-outline-primary' }}">{{ t('lang.en') }}</a>
45
  </div>
 
57
 
58
  <div class="row g-3 mb-4">
59
  <div class="col-12">
60
+ <form method="POST" action="{{ url_for('findnode', lang=current_lang) }}" autocomplete="off">
 
 
61
  <input type="hidden" name="filter" value="{{ search.filter or '' }}">
62
 
63
  <div class="row g-3 align-items-end">
 
67
  <div class="position-relative">
68
  <input type="text" name="name" id="search-input" class="form-control"
69
  placeholder="{{ t('search.placeholder_dataset') }}"
70
+ value="{{ request.form.name or '' }}" required autocomplete="off" />
71
  <ul id="suggestions-list" class="list-group position-absolute w-100" style="display: none; z-index: 1000;"></ul>
72
  </div>
73
  </div>
 
94
  <label class="form-label fw-bold">{{ t('search.label_depth') }}</label>
95
  <div class="d-flex align-items-center gap-3">
96
  <div class="form-check form-switch">
97
+ <input class="form-check-input" type="checkbox" id="depth-unlimited" name="depth_unlimited" checked>
 
98
  <label class="form-check-label" for="depth-unlimited">{{ t('search.depth_unlimited') }}</label>
99
  </div>
100
  <div class="input-group input-group-sm">
101
  <span class="input-group-text">{{ t('search.depth_limited') }}</span>
102
  <input class="form-control" type="number" name="depth" id="depth"
103
+ value="{{ request.form.depth }}" min="1" max="5" disabled>
 
104
  </div>
105
  </div>
106
  </div>
 
115
  </div>
116
  </div>
117
  </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  </div>
119
  </div>
120
 
 
239
  <script src="https://cdnjs.cloudflare.com/ajax/libs/sigma.js/2.4.0/sigma.min.js"></script>
240
  <script src="https://unpkg.com/graphology@0.25.1/dist/graphology.umd.min.js"></script>
241
  <script src="https://cdn.jsdelivr.net/npm/graphology-library/dist/graphology-library.min.js"></script>
242
+ <script src="{{ url_for('static', filename='js/utils.js') }}"></script>
243
  <script>
244
  window.__I18N_DATA = {{ js_i18n_data | tojson }};
245
  window.__I18N_LANG = "{{ current_lang }}";
246
  </script>
 
 
247
  <script src="{{ url_for('static', filename='js/script_dataset.js') }}"></script>
248
  <script>
249
  document.addEventListener('DOMContentLoaded', function () {
application_neo4j/translations.py CHANGED
@@ -44,9 +44,7 @@ TRANSLATIONS = {
44
  "site.nav_brand": "Généalogie des Modèles",
45
  "site.nav_brand_model_expert": "Généalogie des Modèles",
46
  "site.nav_subtitle": "Exploration des relations entre modèles et datasets",
47
- "site.nav_subtitle_full": "Exploration des relations entre modèles et datasets (base de données actualisée le {date})",
48
- "site.home_button": "Accueil",
49
- "site.legacy_browser_warning": "Ce navigateur n’est plus pris en charge. Pour un affichage stable, utilisez une version récente de Firefox, Chrome, Edge ou Safari.",
50
  "site.footer": "Application de recherche et visualisation des relations entre modèles et jeux de données publiés sur la plateforme HuggingFace. © 2025",
51
  "site.footer_expert": "Application de recherche et visualisation... © 2025",
52
 
@@ -83,7 +81,7 @@ TRANSLATIONS = {
83
  "home.btn_unsure": "Je ne sais pas",
84
  "home.expert_label": "Vous êtes un chercheur",
85
  "home.btn_expert": "Mode expert",
86
- "home.download_date": "Date de téléchargement de la base de données : {date}",
87
  "home.notice_title": "Mentions d'information sur les traitements de données à caractère personnel",
88
  "home.notice_p1": "Afin d'étudier le développement de la communauté de l'IA open source, et de préparer la possibilité d'exercices de droits des citoyens, le projet vise à étudier la base de données des jeux de données et modèles présents sur la plateforme HuggingFace. Cette base de données permet d'établir un arbre généalogique des modèles.",
89
  "home.notice_p2": "Les données traitées sont le pseudonyme de l'auteur (quand il apparaît dans les métadonnées), le nom du modèle/jeu de données et plusieurs informations inhérentes à ce modèle/jeu de données telles que la date de publication, la licence utilisée ou encore le nombre de téléchargements.",
@@ -100,42 +98,15 @@ TRANSLATIONS = {
100
  "search.page_lead_dataset": "Explorez la généalogie des modèles et datasets",
101
  "search.label_name": "Nom à rechercher",
102
  "search.placeholder_model": "Taper le nom du modèle ou l'id de son repo Hugging Face.",
103
- "search.placeholder_dataset": "Taper le nom du dataset à investiguer.",
104
  "search.label_filters": "Filtres",
105
  "search.filter_model": "Modèle",
106
  "search.filter_dataset": "Dataset",
107
  "search.label_depth": "Profondeur de recherche",
108
  "search.depth_unlimited": "Illimitée",
109
  "search.depth_limited": "Limité à:",
110
- "search.btn_search": "Rechercher",
111
  "search.btn_search_simple": "Rechercher",
112
- "search.progress_title": "Recherche en cours…",
113
- "search.progress_stage_queued": "En attente d’une capacité de recherche…",
114
- "search.progress_stage_preparing": "Préparation du graphe…",
115
- "search.progress_stage_descendants": "Recherche des descendants…",
116
- "search.progress_stage_ancestors": "Recherche des ascendants…",
117
- "search.progress_stage_result": "Construction du résultat…",
118
- "search.progress_stage_nodes": "Analyse des nœuds du résultat…",
119
- "search.progress_stage_relationships": "Chargement des relations…",
120
- "search.progress_stage_formatting": "Mise en forme du résultat…",
121
- "search.progress_stage_highlights": "Calcul des modèles importants…",
122
- "search.progress_stage_completed": "Recherche terminée.",
123
- "search.progress_stage_failed": "La recherche a échoué.",
124
- "search.progress_stage_cancelled": "Recherche annulée.",
125
- "search.progress_elapsed": "Temps écoulé : {seconds} s",
126
- "search.progress_items": "Éléments traités par le serveur : {completed}/{total}",
127
- "search.progress_queue_title": "Recherche en file d’attente",
128
- "search.progress_queue_position": "Position dans la file : {position}/{total}",
129
- "search.progress_queue_ahead_one": "1 recherche devant la vôtre",
130
- "search.progress_queue_ahead_many": "{count} recherches devant la vôtre",
131
- "search.progress_queue_next": "Votre recherche est la prochaine.",
132
- "search.progress_queue_running_one": "1 recherche en cours",
133
- "search.progress_queue_running_many": "{count} recherches en cours",
134
- "search.progress_queue_note": "La recherche démarrera automatiquement dès qu’une capacité sera disponible.",
135
- "search.progress_server_percent": "Progression indiquée par le serveur : {percent} %",
136
- "search.progress_remaining_step": "Temps restant estimé pour cette étape : {seconds} s",
137
- "search.progress_server_note": "La progression repose sur les opérations réellement terminées par le serveur.",
138
- "search.progress_connection_error": "Impossible d’obtenir la progression depuis le serveur.",
139
 
140
  # ── search.html — vue highlights ──
141
  "search.asc_section": "Modèles importants de l'ascendance",
@@ -197,7 +168,7 @@ TRANSLATIONS = {
197
  # ── expert.html ──
198
  "expert.page_title": "Recherche Experte",
199
  "expert.page_lead": "Explorez et filtrez la généalogie des modèles",
200
- "expert.placeholder": "Taper le nom du modèle à investiguer.",
201
  "expert.filter_author": "Auteur",
202
  "expert.connected_component": "Composante connexe du noeud recherché",
203
  "expert.legend_title": "Légendes",
@@ -283,9 +254,7 @@ TRANSLATIONS = {
283
  "site.nav_brand": "Model Genealogy",
284
  "site.nav_brand_model_expert": "Model Genealogy",
285
  "site.nav_subtitle": "Exploration of relations between models and datasets",
286
- "site.nav_subtitle_full": "Exploration of relations between models and datasets (database updated on {date})",
287
- "site.home_button": "Home",
288
- "site.legacy_browser_warning": "This browser is no longer supported. For a stable display, use a recent version of Firefox, Chrome, Edge or Safari.",
289
  "site.footer": "Application for searching and visualizing relations between models and datasets published on the HuggingFace platform. © 2025",
290
  "site.footer_expert": "Application for searching and visualizing... © 2025",
291
 
@@ -322,7 +291,7 @@ TRANSLATIONS = {
322
  "home.btn_unsure": "I don't know",
323
  "home.expert_label": "You are a researcher",
324
  "home.btn_expert": "Expert mode",
325
- "home.download_date": "Database download date: {date}",
326
  "home.notice_title": "Information on the processing of personal data",
327
  "home.notice_p1": "In order to study the development of the open-source AI community and to prepare for the possibility of citizens exercising their rights, the project aims to study the database of datasets and models present on the HuggingFace platform. This database makes it possible to establish a genealogy tree of the models.",
328
  "home.notice_p2": "The data processed includes the author's pseudonym (when it appears in the metadata), the name of the model/dataset and several pieces of information inherent to this model/dataset such as the publication date, the license used or the number of downloads.",
@@ -346,35 +315,8 @@ TRANSLATIONS = {
346
  "search.label_depth": "Search depth",
347
  "search.depth_unlimited": "Unlimited",
348
  "search.depth_limited": "Limited to:",
349
- "search.btn_search": "Search",
350
  "search.btn_search_simple": "Search",
351
- "search.progress_title": "Search in progress…",
352
- "search.progress_stage_queued": "Waiting for search capacity…",
353
- "search.progress_stage_preparing": "Preparing the graph…",
354
- "search.progress_stage_descendants": "Searching descendants…",
355
- "search.progress_stage_ancestors": "Searching ancestors…",
356
- "search.progress_stage_result": "Building the result…",
357
- "search.progress_stage_nodes": "Analyzing result nodes…",
358
- "search.progress_stage_relationships": "Loading relationships…",
359
- "search.progress_stage_formatting": "Formatting the result…",
360
- "search.progress_stage_highlights": "Computing important models…",
361
- "search.progress_stage_completed": "Search completed.",
362
- "search.progress_stage_failed": "The search failed.",
363
- "search.progress_stage_cancelled": "Search cancelled.",
364
- "search.progress_elapsed": "Elapsed time: {seconds} s",
365
- "search.progress_items": "Items processed by the server: {completed}/{total}",
366
- "search.progress_queue_title": "Search queued",
367
- "search.progress_queue_position": "Queue position: {position}/{total}",
368
- "search.progress_queue_ahead_one": "1 search ahead of yours",
369
- "search.progress_queue_ahead_many": "{count} searches ahead of yours",
370
- "search.progress_queue_next": "Your search is next.",
371
- "search.progress_queue_running_one": "1 search currently running",
372
- "search.progress_queue_running_many": "{count} searches currently running",
373
- "search.progress_queue_note": "Your search will start automatically as soon as capacity becomes available.",
374
- "search.progress_server_percent": "Progress reported by the server: {percent}%",
375
- "search.progress_remaining_step": "Estimated time remaining for this step: {seconds} s",
376
- "search.progress_server_note": "Progress is based on operations actually completed by the server.",
377
- "search.progress_connection_error": "Unable to retrieve progress from the server.",
378
 
379
  # ── search.html — highlights view ──
380
  "search.asc_section": "Important ancestor models",
 
44
  "site.nav_brand": "Généalogie des Modèles",
45
  "site.nav_brand_model_expert": "Généalogie des Modèles",
46
  "site.nav_subtitle": "Exploration des relations entre modèles et datasets",
47
+ "site.nav_subtitle_full": "Exploration des relations entre modèles et datasets (base de données actualisée le 01/09/2025)",
 
 
48
  "site.footer": "Application de recherche et visualisation des relations entre modèles et jeux de données publiés sur la plateforme HuggingFace. © 2025",
49
  "site.footer_expert": "Application de recherche et visualisation... © 2025",
50
 
 
81
  "home.btn_unsure": "Je ne sais pas",
82
  "home.expert_label": "Vous êtes un chercheur",
83
  "home.btn_expert": "Mode expert",
84
+ "home.download_date": "Date de téléchargement de la base de donnée : 01/09/2025",
85
  "home.notice_title": "Mentions d'information sur les traitements de données à caractère personnel",
86
  "home.notice_p1": "Afin d'étudier le développement de la communauté de l'IA open source, et de préparer la possibilité d'exercices de droits des citoyens, le projet vise à étudier la base de données des jeux de données et modèles présents sur la plateforme HuggingFace. Cette base de données permet d'établir un arbre généalogique des modèles.",
87
  "home.notice_p2": "Les données traitées sont le pseudonyme de l'auteur (quand il apparaît dans les métadonnées), le nom du modèle/jeu de données et plusieurs informations inhérentes à ce modèle/jeu de données telles que la date de publication, la licence utilisée ou encore le nombre de téléchargements.",
 
98
  "search.page_lead_dataset": "Explorez la généalogie des modèles et datasets",
99
  "search.label_name": "Nom à rechercher",
100
  "search.placeholder_model": "Taper le nom du modèle ou l'id de son repo Hugging Face.",
101
+ "search.placeholder_dataset": "Taper le nom du dataset suspecté.",
102
  "search.label_filters": "Filtres",
103
  "search.filter_model": "Modèle",
104
  "search.filter_dataset": "Dataset",
105
  "search.label_depth": "Profondeur de recherche",
106
  "search.depth_unlimited": "Illimitée",
107
  "search.depth_limited": "Limité à:",
108
+ "search.btn_search": "Rechercher (le chargement peut prendre jusqu'à une minute pour les grandes généalogies)",
109
  "search.btn_search_simple": "Rechercher",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
  # ── search.html — vue highlights ──
112
  "search.asc_section": "Modèles importants de l'ascendance",
 
168
  # ── expert.html ──
169
  "expert.page_title": "Recherche Experte",
170
  "expert.page_lead": "Explorez et filtrez la généalogie des modèles",
171
+ "expert.placeholder": "Taper le nom du modèle suspecté.",
172
  "expert.filter_author": "Auteur",
173
  "expert.connected_component": "Composante connexe du noeud recherché",
174
  "expert.legend_title": "Légendes",
 
254
  "site.nav_brand": "Model Genealogy",
255
  "site.nav_brand_model_expert": "Model Genealogy",
256
  "site.nav_subtitle": "Exploration of relations between models and datasets",
257
+ "site.nav_subtitle_full": "Exploration of relations between models and datasets (database updated on 01/09/2025)",
 
 
258
  "site.footer": "Application for searching and visualizing relations between models and datasets published on the HuggingFace platform. © 2025",
259
  "site.footer_expert": "Application for searching and visualizing... © 2025",
260
 
 
291
  "home.btn_unsure": "I don't know",
292
  "home.expert_label": "You are a researcher",
293
  "home.btn_expert": "Expert mode",
294
+ "home.download_date": "Database download date: 01/09/2025",
295
  "home.notice_title": "Information on the processing of personal data",
296
  "home.notice_p1": "In order to study the development of the open-source AI community and to prepare for the possibility of citizens exercising their rights, the project aims to study the database of datasets and models present on the HuggingFace platform. This database makes it possible to establish a genealogy tree of the models.",
297
  "home.notice_p2": "The data processed includes the author's pseudonym (when it appears in the metadata), the name of the model/dataset and several pieces of information inherent to this model/dataset such as the publication date, the license used or the number of downloads.",
 
315
  "search.label_depth": "Search depth",
316
  "search.depth_unlimited": "Unlimited",
317
  "search.depth_limited": "Limited to:",
318
+ "search.btn_search": "Search (loading may take up to a minute for large genealogies)",
319
  "search.btn_search_simple": "Search",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
 
321
  # ── search.html — highlights view ──
322
  "search.asc_section": "Important ancestor models",
database_refresh/README.md DELETED
@@ -1,70 +0,0 @@
1
- # Actualisation hebdomadaire de la base
2
-
3
- La source est le dataset public
4
- [`cfahlgren1/hub-stats`](https://huggingface.co/datasets/cfahlgren1/hub-stats).
5
- Le pipeline télécharge ses configurations `models` et `datasets`, reconstruit
6
- hors ligne une base Neo4j, crée `neo4j.dump`, puis publie ce dump et son fichier
7
- `database_metadata.json` dans le dataset privé configuré.
8
-
9
- Le Space continue à utiliser le dump précédent pendant toute la reconstruction.
10
- Il ne voit la nouvelle base qu'après un redémarrage. La date affichée dans
11
- l'interface vient de `database_metadata.json`; elle n'est donc mise à jour que
12
- si la reconstruction et la publication ont réussi.
13
-
14
- ## Test sans modifier la production
15
-
16
- Le dépôt de dumps accepte une branche de test :
17
-
18
- ```bash
19
- export HF_TOKEN=hf_...
20
- bash database_refresh/run_weekly_refresh.sh \
21
- --dump-revision weekly-refresh
22
- ```
23
-
24
- Pour un test rapide du prétraitement :
25
-
26
- ```bash
27
- bash database_refresh/run_weekly_refresh.sh \
28
- --max-models 10000 \
29
- --max-datasets 10000 \
30
- --prepare-only
31
- ```
32
-
33
- ## Job Hugging Face hebdomadaire
34
-
35
- Le Job peut monter en lecture la branche du Space qui contient ce script et
36
- utiliser l'image Neo4j correspondant exactement à celle de l'application :
37
-
38
- Le compte qui crée le Job doit avoir au minimum le rôle `write` dans
39
- l'organisation CNIL. Le token utilisé par la CLI doit explicitement autoriser
40
- `Start and manage Jobs`. Puisque le même token est transmis au Job avec
41
- `--secrets HF_TOKEN`, il doit également pouvoir écrire dans
42
- `cnil/genmod-dump-neo4j` et redémarrer le Space ciblé. L'option
43
- `--namespace cnil` est indispensable pour créer et facturer le Job sous
44
- l'organisation plutôt que sous le compte personnel.
45
-
46
- ```bash
47
- hf jobs scheduled run "@weekly" \
48
- --namespace cnil \
49
- --name genmod-weekly-database-refresh \
50
- --flavor cpu-basic \
51
- --timeout 12h \
52
- --no-concurrency \
53
- --secrets HF_TOKEN \
54
- --env NEO4J_DUMP_REPO=cnil/genmod-dump-neo4j \
55
- --env NEO4J_DUMP_REVISION=weekly-refresh \
56
- --env SPACES_TO_RESTART=cnil/genmod-faster \
57
- --volume hf://spaces/cnil/genmod@refresh-hub-database:/workspace:ro \
58
- neo4j:2025.09.0-community \
59
- bash /workspace/database_refresh/run_weekly_refresh.sh
60
- ```
61
-
62
- La révision `weekly-refresh` permet de valider le nouveau dump avec
63
- `genmod-faster` sans remplacer le dump de production. Après validation, il
64
- suffit de programmer le même Job avec `NEO4J_DUMP_REVISION=main`.
65
- Pour la production, `SPACES_TO_RESTART` doit alors être remplacé par
66
- `cnil/genmod`.
67
-
68
- Les Hugging Face Jobs nécessitent un solde positif et sont facturés uniquement
69
- pendant leur exécution. Un Space CPU Basic ne constitue pas à lui seul un cron
70
- fiable : il peut être mis en veille et son disque est éphémère.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
database_refresh/refresh_database.py DELETED
@@ -1,484 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Build and publish a Neo4j dump from the public cfahlgren1/hub-stats dataset."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import json
8
- import os
9
- import shutil
10
- import subprocess
11
- import sys
12
- from datetime import datetime, timezone
13
- from pathlib import Path
14
-
15
- import duckdb
16
- from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download
17
-
18
-
19
- SOURCE_REPO = "cfahlgren1/hub-stats"
20
- DEFAULT_DUMP_REPO = "cnil/genmod-dump-neo4j"
21
- PARQUET_REVISION = "refs/convert/parquet"
22
-
23
-
24
- def log(message: str) -> None:
25
- print(f"[genmod-refresh] {message}", flush=True)
26
-
27
-
28
- def download_sources(work_dir: Path) -> tuple[Path, Path]:
29
- cache_dir = work_dir / "hf-cache"
30
- log(f"Downloading the model snapshot from {SOURCE_REPO}")
31
- models = Path(
32
- hf_hub_download(
33
- repo_id=SOURCE_REPO,
34
- repo_type="dataset",
35
- revision=PARQUET_REVISION,
36
- filename="models/train/0000.parquet",
37
- cache_dir=cache_dir,
38
- )
39
- )
40
- log(f"Downloading the dataset snapshot from {SOURCE_REPO}")
41
- datasets = Path(
42
- hf_hub_download(
43
- repo_id=SOURCE_REPO,
44
- repo_type="dataset",
45
- revision=PARQUET_REVISION,
46
- filename="datasets/train/0000.parquet",
47
- cache_dir=cache_dir,
48
- )
49
- )
50
- return models, datasets
51
-
52
-
53
- def sql_path(path: Path) -> str:
54
- return str(path).replace("'", "''")
55
-
56
-
57
- def create_views(
58
- connection: duckdb.DuckDBPyConnection,
59
- models_path: Path,
60
- datasets_path: Path,
61
- max_models: int | None,
62
- max_datasets: int | None,
63
- ) -> None:
64
- model_limit = f" LIMIT {max_models}" if max_models else ""
65
- dataset_limit = f" LIMIT {max_datasets}" if max_datasets else ""
66
- connection.execute(
67
- f"""
68
- CREATE VIEW source_models AS
69
- SELECT * EXCLUDE (_dedupe_rank)
70
- FROM (
71
- SELECT
72
- *,
73
- row_number() OVER (
74
- PARTITION BY id
75
- ORDER BY lastModified DESC NULLS LAST, _id DESC NULLS LAST
76
- ) AS _dedupe_rank
77
- FROM read_parquet('{sql_path(models_path)}')
78
- WHERE id IS NOT NULL AND trim(id) <> ''
79
- )
80
- WHERE _dedupe_rank = 1
81
- {model_limit}
82
- """
83
- )
84
- connection.execute(
85
- f"""
86
- CREATE VIEW source_datasets AS
87
- SELECT * EXCLUDE (_dedupe_rank)
88
- FROM (
89
- SELECT
90
- *,
91
- row_number() OVER (
92
- PARTITION BY id
93
- ORDER BY lastModified DESC NULLS LAST, _id DESC NULLS LAST
94
- ) AS _dedupe_rank
95
- FROM read_parquet('{sql_path(datasets_path)}')
96
- WHERE id IS NOT NULL AND trim(id) <> ''
97
- )
98
- WHERE _dedupe_rank = 1
99
- {dataset_limit}
100
- """
101
- )
102
- connection.execute(
103
- """
104
- CREATE VIEW base_model_edges AS
105
- SELECT DISTINCT
106
- base.id AS parent_id,
107
- child.id AS child_id,
108
- COALESCE(child.baseModels.relation, 'derived') AS relation_name
109
- FROM source_models AS child,
110
- UNNEST(child.baseModels.models) AS nested(base)
111
- WHERE child.baseModels IS NOT NULL
112
- AND base.id IS NOT NULL
113
- AND trim(base.id) <> ''
114
- AND child.id IS NOT NULL
115
- """
116
- )
117
- connection.execute(
118
- """
119
- CREATE VIEW model_dataset_edges AS
120
- SELECT DISTINCT
121
- substr(tag, 9) AS dataset_id,
122
- model.id AS model_id
123
- FROM source_models AS model,
124
- UNNEST(model.tags) AS nested(tag)
125
- WHERE starts_with(tag, 'dataset:')
126
- AND length(trim(substr(tag, 9))) > 0
127
- AND model.id IS NOT NULL
128
- """
129
- )
130
-
131
-
132
- def export_csv(
133
- connection: duckdb.DuckDBPyConnection,
134
- output_dir: Path,
135
- filename: str,
136
- header: str,
137
- query: str,
138
- ) -> Path:
139
- path = output_dir / filename
140
- header_path = output_dir / filename.replace(".csv", "-header.csv")
141
- header_path.write_text(header + "\n", encoding="utf-8")
142
- connection.execute(
143
- f"""
144
- COPY ({query})
145
- TO '{sql_path(path)}'
146
- (FORMAT CSV, HEADER false, DELIMITER ',', QUOTE '"', ESCAPE '"')
147
- """
148
- )
149
- log(f"Created {filename}")
150
- return path
151
-
152
-
153
- def prepare_csv_files(
154
- models_path: Path,
155
- datasets_path: Path,
156
- output_dir: Path,
157
- max_models: int | None = None,
158
- max_datasets: int | None = None,
159
- ) -> dict[str, Path]:
160
- output_dir.mkdir(parents=True, exist_ok=True)
161
- database_path = output_dir / "refresh.duckdb"
162
- connection = duckdb.connect(str(database_path))
163
- connection.execute("SET preserve_insertion_order = false")
164
- connection.execute("SET threads = 2")
165
- create_views(connection, models_path, datasets_path, max_models, max_datasets)
166
-
167
- files: dict[str, Path] = {}
168
- files["models"] = export_csv(
169
- connection,
170
- output_dir,
171
- "models.csv",
172
- "modelId:ID(Model),name,downloads:long,task,createdAt,parameters,likes:long,license",
173
- """
174
- WITH actual_models AS (
175
- SELECT
176
- id,
177
- id AS name,
178
- downloadsAllTime AS downloads,
179
- pipeline_tag AS task,
180
- CAST(createdAt AS VARCHAR) AS created_at,
181
- CASE
182
- WHEN safetensors.total >= 1000000000
183
- THEN printf('%.1fB', safetensors.total / 1000000000.0)
184
- WHEN safetensors.total >= 1000000
185
- THEN printf('%.1fM', safetensors.total / 1000000.0)
186
- WHEN safetensors.total >= 1000
187
- THEN printf('%.1fK', safetensors.total / 1000.0)
188
- WHEN safetensors.total IS NOT NULL
189
- THEN CAST(safetensors.total AS VARCHAR)
190
- END AS parameters,
191
- likes,
192
- json_extract_string(cardData, '$.license') AS license
193
- FROM source_models
194
- WHERE id IS NOT NULL AND trim(id) <> ''
195
- ),
196
- missing_parents AS (
197
- SELECT DISTINCT parent_id AS id
198
- FROM base_model_edges
199
- WHERE parent_id NOT IN (SELECT id FROM actual_models)
200
- )
201
- SELECT id, name, downloads, task, created_at, parameters, likes, license
202
- FROM actual_models
203
- UNION ALL
204
- SELECT id, id, NULL, NULL, NULL, NULL, NULL, NULL
205
- FROM missing_parents
206
- """,
207
- )
208
- files["datasets"] = export_csv(
209
- connection,
210
- output_dir,
211
- "datasets.csv",
212
- "datasetId:ID(Dataset),name,downloads:long,createdAt_dataset",
213
- """
214
- WITH actual_datasets AS (
215
- SELECT
216
- id,
217
- id AS name,
218
- downloadsAllTime AS downloads,
219
- CAST(createdAt AS VARCHAR) AS created_at
220
- FROM source_datasets
221
- WHERE id IS NOT NULL AND trim(id) <> ''
222
- ),
223
- missing_datasets AS (
224
- SELECT DISTINCT dataset_id AS id
225
- FROM model_dataset_edges
226
- WHERE dataset_id NOT IN (SELECT id FROM actual_datasets)
227
- )
228
- SELECT id, name, downloads, created_at
229
- FROM actual_datasets
230
- UNION ALL
231
- SELECT id, id, NULL, NULL
232
- FROM missing_datasets
233
- """,
234
- )
235
- files["authors"] = export_csv(
236
- connection,
237
- output_dir,
238
- "authors.csv",
239
- "authorId:ID(Author),name,type,followers:long",
240
- """
241
- SELECT author, author, 'unknown', NULL
242
- FROM (
243
- SELECT author FROM source_models
244
- UNION
245
- SELECT author FROM source_datasets
246
- )
247
- WHERE author IS NOT NULL AND trim(author) <> ''
248
- """,
249
- )
250
- files["base_model_edges"] = export_csv(
251
- connection,
252
- output_dir,
253
- "base-model-edges.csv",
254
- ":START_ID(Model),:END_ID(Model),name",
255
- "SELECT parent_id, child_id, relation_name FROM base_model_edges",
256
- )
257
- files["model_dataset_edges"] = export_csv(
258
- connection,
259
- output_dir,
260
- "model-dataset-edges.csv",
261
- ":START_ID(Dataset),:END_ID(Model),name",
262
- """
263
- SELECT dataset_id, model_id, 'A été utilisé dans ce modèle'
264
- FROM model_dataset_edges
265
- """,
266
- )
267
- files["author_model_edges"] = export_csv(
268
- connection,
269
- output_dir,
270
- "author-model-edges.csv",
271
- ":START_ID(Author),:END_ID(Model),name",
272
- """
273
- SELECT DISTINCT author, id, 'A publié'
274
- FROM source_models
275
- WHERE author IS NOT NULL AND trim(author) <> '' AND id IS NOT NULL
276
- """,
277
- )
278
- files["author_dataset_edges"] = export_csv(
279
- connection,
280
- output_dir,
281
- "author-dataset-edges.csv",
282
- ":START_ID(Author),:END_ID(Dataset),name",
283
- """
284
- SELECT DISTINCT author, id, 'A publié'
285
- FROM source_datasets
286
- WHERE author IS NOT NULL AND trim(author) <> '' AND id IS NOT NULL
287
- """,
288
- )
289
- connection.close()
290
- database_path.unlink(missing_ok=True)
291
- return files
292
-
293
-
294
- def header_for(path: Path) -> Path:
295
- return path.with_name(path.name.replace(".csv", "-header.csv"))
296
-
297
-
298
- def build_dump(files: dict[str, Path], output_dir: Path, neo4j_admin: str) -> Path:
299
- def group(name: str) -> str:
300
- return f"{header_for(files[name])},{files[name]}"
301
-
302
- command = [
303
- neo4j_admin,
304
- "database",
305
- "import",
306
- "full",
307
- "neo4j",
308
- "--overwrite-destination=true",
309
- "--id-type=string",
310
- "--threads=2",
311
- "--verbose",
312
- f"--nodes=Model={group('models')}",
313
- f"--nodes=Dataset={group('datasets')}",
314
- f"--nodes=Author={group('authors')}",
315
- f"--relationships=USED_IN={group('base_model_edges')}",
316
- f"--relationships=USED_IN={group('model_dataset_edges')}",
317
- f"--relationships=POSTED={group('author_model_edges')}",
318
- f"--relationships=POSTED={group('author_dataset_edges')}",
319
- ]
320
- log("Building the offline Neo4j database")
321
- subprocess.run(command, check=True)
322
-
323
- dump_dir = output_dir / "dump"
324
- dump_dir.mkdir(exist_ok=True)
325
- log("Creating neo4j.dump")
326
- subprocess.run(
327
- [
328
- neo4j_admin,
329
- "database",
330
- "dump",
331
- "neo4j",
332
- f"--to-path={dump_dir}",
333
- "--overwrite-destination=true",
334
- ],
335
- check=True,
336
- )
337
- return dump_dir / "neo4j.dump"
338
-
339
-
340
- def write_metadata(
341
- output_dir: Path,
342
- source_revision: str,
343
- model_count: int,
344
- dataset_count: int,
345
- ) -> Path:
346
- metadata = {
347
- "built_at": datetime.now(timezone.utc).isoformat(),
348
- "source_repo": SOURCE_REPO,
349
- "source_revision": source_revision,
350
- "model_count": model_count,
351
- "dataset_count": dataset_count,
352
- }
353
- path = output_dir / "database_metadata.json"
354
- path.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
355
- return path
356
-
357
-
358
- def parquet_unique_id_count(path: Path) -> int:
359
- connection = duckdb.connect()
360
- count = connection.execute(
361
- f"""
362
- SELECT count(DISTINCT id)
363
- FROM read_parquet('{sql_path(path)}')
364
- WHERE id IS NOT NULL AND trim(id) <> ''
365
- """
366
- ).fetchone()[0]
367
- connection.close()
368
- return int(count)
369
-
370
-
371
- def publish_dump(
372
- api: HfApi,
373
- dump_path: Path,
374
- metadata_path: Path,
375
- repo_id: str,
376
- revision: str,
377
- ) -> None:
378
- if revision != "main":
379
- api.create_branch(
380
- repo_id=repo_id,
381
- repo_type="dataset",
382
- branch=revision,
383
- exist_ok=True,
384
- )
385
- log(f"Publishing the dump to {repo_id}@{revision}")
386
- api.create_commit(
387
- repo_id=repo_id,
388
- repo_type="dataset",
389
- revision=revision,
390
- operations=[
391
- CommitOperationAdd(
392
- path_in_repo="neo4j.dump",
393
- path_or_fileobj=str(dump_path),
394
- ),
395
- CommitOperationAdd(
396
- path_in_repo="database_metadata.json",
397
- path_or_fileobj=str(metadata_path),
398
- ),
399
- ],
400
- commit_message="Refresh Neo4j graph from cfahlgren1/hub-stats",
401
- )
402
-
403
-
404
- def restart_spaces(api: HfApi, space_ids: list[str]) -> None:
405
- for space_id in space_ids:
406
- log(f"Restarting Space {space_id}")
407
- api.restart_space(repo_id=space_id)
408
-
409
-
410
- def parse_args() -> argparse.Namespace:
411
- parser = argparse.ArgumentParser()
412
- parser.add_argument("--work-dir", type=Path, default=Path("/tmp/genmod-refresh"))
413
- parser.add_argument("--dump-repo", default=os.getenv("NEO4J_DUMP_REPO", DEFAULT_DUMP_REPO))
414
- parser.add_argument("--dump-revision", default=os.getenv("NEO4J_DUMP_REVISION", "main"))
415
- parser.add_argument("--neo4j-admin", default=os.getenv("NEO4J_ADMIN", "neo4j-admin"))
416
- parser.add_argument("--models-parquet", type=Path)
417
- parser.add_argument("--datasets-parquet", type=Path)
418
- parser.add_argument("--max-models", type=int)
419
- parser.add_argument("--max-datasets", type=int)
420
- parser.add_argument("--prepare-only", action="store_true")
421
- parser.add_argument("--no-upload", action="store_true")
422
- parser.add_argument("--keep-work-dir", action="store_true")
423
- parser.add_argument("--restart-space", action="append", default=[])
424
- return parser.parse_args()
425
-
426
-
427
- def main() -> int:
428
- args = parse_args()
429
- if args.work_dir.exists() and not args.keep_work_dir:
430
- shutil.rmtree(args.work_dir)
431
- args.work_dir.mkdir(parents=True, exist_ok=True)
432
-
433
- api = HfApi()
434
- source_revision = api.dataset_info(SOURCE_REPO).sha
435
- if bool(args.models_parquet) != bool(args.datasets_parquet):
436
- raise SystemExit("Provide both --models-parquet and --datasets-parquet.")
437
- if args.models_parquet:
438
- models_path, datasets_path = args.models_parquet, args.datasets_parquet
439
- else:
440
- models_path, datasets_path = download_sources(args.work_dir)
441
-
442
- csv_dir = args.work_dir / "csv"
443
- files = prepare_csv_files(
444
- models_path,
445
- datasets_path,
446
- csv_dir,
447
- max_models=args.max_models,
448
- max_datasets=args.max_datasets,
449
- )
450
- if args.prepare_only:
451
- log(f"CSV preparation completed in {csv_dir}")
452
- return 0
453
-
454
- dump_path = build_dump(files, args.work_dir, args.neo4j_admin)
455
- model_count = args.max_models or parquet_unique_id_count(models_path)
456
- dataset_count = args.max_datasets or parquet_unique_id_count(datasets_path)
457
- metadata_path = write_metadata(
458
- args.work_dir,
459
- source_revision,
460
- model_count,
461
- dataset_count,
462
- )
463
- if not args.no_upload:
464
- if not os.getenv("HF_TOKEN"):
465
- raise SystemExit("HF_TOKEN is required to upload the refreshed dump.")
466
- publish_dump(
467
- api,
468
- dump_path,
469
- metadata_path,
470
- args.dump_repo,
471
- args.dump_revision,
472
- )
473
- configured_spaces = [
474
- value.strip()
475
- for value in os.getenv("SPACES_TO_RESTART", "").split(",")
476
- if value.strip()
477
- ]
478
- restart_spaces(api, list(dict.fromkeys(configured_spaces + args.restart_space)))
479
- log("Refresh completed successfully")
480
- return 0
481
-
482
-
483
- if __name__ == "__main__":
484
- sys.exit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
database_refresh/run_weekly_refresh.sh DELETED
@@ -1,7 +0,0 @@
1
- #!/bin/bash
2
- set -e
3
- apt-get update
4
- apt-get install -y python3-pip wget
5
- pip3 install duckdb==1.3.2 huggingface-hub==0.31.4
6
- wget -qO /tmp/r.py https://huggingface.co/spaces/cnil/genmod/resolve/refresh-hub-database/database_refresh/refresh_database.py
7
- exec python3 /tmp/r.py
 
 
 
 
 
 
 
 
opengds/README.md DELETED
@@ -1,46 +0,0 @@
1
- # OpenGDS personnalisé pour GenMod
2
-
3
- Cette image utilise un fork minimal d’OpenGDS afin que
4
- `gds.bfs.stream` renvoie la profondeur minimale de chaque nœud pendant
5
- le parcours, dans une nouvelle colonne `depths` alignée avec `nodeIds`.
6
-
7
- ## Source et version
8
-
9
- - dépôt amont : https://github.com/neo4j/graph-data-science
10
- - tag amont : `2.22.0`
11
- - licence amont : GNU General Public License v3.0
12
- - patch maintenu ici : `gds-2.22.0-bfs-depth.patch`
13
- - JAR construit : `open-gds-2.22.0-genmod.jar`
14
- - SHA-256 :
15
- `940c1f0c0cb6a9adbee84c9f15207740262e7c7c83464ad61b31000e4b7caf5e`
16
-
17
- Le patch modifie uniquement le mode `stream` du BFS. Les modes `stats` et
18
- `mutate` conservent leur résultat historique.
19
-
20
- Il ajoute aussi une procédure de compatibilité `gds.debug.arrow` indiquant
21
- qu’Arrow est désactivé. Le client Python GDS appelle cette procédure lors de
22
- sa connexion, alors que le build OpenGDS ne fournit pas le serveur Arrow.
23
-
24
- ## Reproduire le JAR
25
-
26
- Utiliser un JDK 21 :
27
-
28
- ```bash
29
- git clone https://github.com/neo4j/graph-data-science.git
30
- cd graph-data-science
31
- git checkout 2.22.0
32
- git apply /chemin/vers/gds-2.22.0-bfs-depth.patch
33
- ./gradlew :algo:test \
34
- --tests org.neo4j.gds.paths.traverse.BFSTest
35
- ./gradlew :proc-path-finding:test \
36
- --tests org.neo4j.gds.paths.traverse.BfsStreamProcTest
37
- ./gradlew :proc-sysinfo:test \
38
- --tests org.neo4j.gds.SysInfoProcTest
39
- ./gradlew :open-packaging:shadowCopy
40
- ```
41
-
42
- Le résultat est créé dans
43
- `build/distributions/open-gds-2.22.0.jar`.
44
-
45
- Le `Dockerfile` copie ce JAR dans le répertoire des plugins Neo4j et
46
- n’utilise donc pas le téléchargement automatique du plugin GDS officiel.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
opengds/gds-2.22.0-bfs-depth.patch DELETED
@@ -1,391 +0,0 @@
1
- diff --git a/algo/src/main/java/org/neo4j/gds/paths/traverse/BFS.java b/algo/src/main/java/org/neo4j/gds/paths/traverse/BFS.java
2
- index 1ef036b..7394194 100644
3
- --- a/algo/src/main/java/org/neo4j/gds/paths/traverse/BFS.java
4
- +++ b/algo/src/main/java/org/neo4j/gds/paths/traverse/BFS.java
5
- @@ -181,6 +181,10 @@ public final class BFS extends Algorithm<HugeLongArray> {
6
-
7
- @Override
8
- public HugeLongArray compute() {
9
- + return computeWithDepths().nodeIds();
10
- + }
11
- +
12
- + public BfsResult computeWithDepths() {
13
- progressTracker.beginSubTask(graph.relationshipCount());
14
-
15
- // This is used to read from `traversedNodes` in chunks, updated in `BFSTask`.
16
- @@ -259,7 +263,10 @@ public final class BFS extends Algorithm<HugeLongArray> {
17
- nodesLengthToRetain = targetFoundIndex.longValue() + 1;
18
- }
19
-
20
- - var result = traversedNodes.copyOf(nodesLengthToRetain);
21
- + var result = new BfsResult(
22
- + traversedNodes.copyOf(nodesLengthToRetain),
23
- + weights.copyOf(nodesLengthToRetain)
24
- + );
25
-
26
- progressTracker.endSubTask();
27
- return result;
28
- diff --git a/algo/src/main/java/org/neo4j/gds/paths/traverse/BFSTask.java b/algo/src/main/java/org/neo4j/gds/paths/traverse/BFSTask.java
29
- index 268dc92..dc24735 100644
30
- --- a/algo/src/main/java/org/neo4j/gds/paths/traverse/BFSTask.java
31
- +++ b/algo/src/main/java/org/neo4j/gds/paths/traverse/BFSTask.java
32
- @@ -164,7 +164,15 @@ class BFSTask implements Runnable {
33
- // In case `nodeId` is encountered in a later chunk,
34
- // the if check will be false and not added to traversedNodes again.
35
- if (!visited.getAndSet(nodeId)) {
36
- + long predecessorIndex = minimumChunk.get(nodeId);
37
- + long predecessorNodeId = traversedNodes.get(predecessorIndex);
38
- + double depth = aggregatorFunction.apply(
39
- + predecessorNodeId,
40
- + nodeId,
41
- + weights.get(predecessorIndex)
42
- + );
43
- traversedNodes.set(index, nodeId);
44
- + weights.set(index, depth);
45
- index++;
46
- nodesTraversed++;
47
- }
48
- diff --git a/algo/src/main/java/org/neo4j/gds/paths/traverse/BfsResult.java b/algo/src/main/java/org/neo4j/gds/paths/traverse/BfsResult.java
49
- new file mode 100644
50
- index 0000000..f200329
51
- --- /dev/null
52
- +++ b/algo/src/main/java/org/neo4j/gds/paths/traverse/BfsResult.java
53
- @@ -0,0 +1,20 @@
54
- +/*
55
- + * Copyright (c) "Neo4j"
56
- + * Neo4j Sweden AB [http://neo4j.com]
57
- + *
58
- + * This file is part of Neo4j.
59
- + *
60
- + * Neo4j is free software: you can redistribute it and/or modify
61
- + * it under the terms of the GNU General Public License as published by
62
- + * the Free Software Foundation, either version 3 of the License, or
63
- + * (at your option) any later version.
64
- + */
65
- +package org.neo4j.gds.paths.traverse;
66
- +
67
- +import org.neo4j.gds.collections.ha.HugeDoubleArray;
68
- +import org.neo4j.gds.collections.ha.HugeLongArray;
69
- +
70
- +/**
71
- + * Nodes visited by BFS and their minimum depth, aligned by array index.
72
- + */
73
- +public record BfsResult(HugeLongArray nodeIds, HugeDoubleArray depths) {}
74
- diff --git a/algo/src/test/java/org/neo4j/gds/paths/traverse/BFSTest.java b/algo/src/test/java/org/neo4j/gds/paths/traverse/BFSTest.java
75
- index 89b9074..6b4816a 100644
76
- --- a/algo/src/test/java/org/neo4j/gds/paths/traverse/BFSTest.java
77
- +++ b/algo/src/test/java/org/neo4j/gds/paths/traverse/BFSTest.java
78
- @@ -174,6 +174,30 @@ class BFSTest {
79
- );
80
- }
81
-
82
- + @ParameterizedTest
83
- + @ValueSource(ints = {1, 4})
84
- + void shouldReturnMinimumDepthAlongsideEveryVisitedNode(int concurrency) {
85
- + long source = naturalGraph.toMappedNodeId("a");
86
- + var result = BFS.create(
87
- + naturalGraph,
88
- + source,
89
- + (s, t, w) -> Result.FOLLOW,
90
- + new OneHopAggregator(),
91
- + TraversalParameters.NO_MAX_DEPTH,
92
- + DefaultPool.INSTANCE,
93
- + new Concurrency(concurrency),
94
- + ProgressTracker.NULL_TRACKER,
95
- + TerminationFlag.RUNNING_TRUE
96
- + ).computeWithDepths();
97
- +
98
- + assertThat(result.nodeIds().toArray()).isEqualTo(
99
- + Stream.of("a", "b", "c", "d", "e", "f", "g")
100
- + .mapToLong(naturalGraph::toMappedNodeId)
101
- + .toArray()
102
- + );
103
- + assertThat(result.depths().toArray()).containsExactly(0, 1, 1, 2, 3, 3, 4);
104
- + }
105
- +
106
- @ParameterizedTest
107
- @ValueSource(ints = {1, 4})
108
- void testBfsOnLoopGraph(int concurrency) {
109
- diff --git a/applications/algorithms/path-finding/src/main/java/org/neo4j/gds/applications/algorithms/pathfinding/PathFindingAlgorithms.java b/applications/algorithms/path-finding/src/main/java/org/neo4j/gds/applications/algorithms/pathfinding/PathFindingAlgorithms.java
110
- index 6313df2..edf59a3 100644
111
- --- a/applications/algorithms/path-finding/src/main/java/org/neo4j/gds/applications/algorithms/pathfinding/PathFindingAlgorithms.java
112
- +++ b/applications/algorithms/path-finding/src/main/java/org/neo4j/gds/applications/algorithms/pathfinding/PathFindingAlgorithms.java
113
- @@ -44,8 +44,10 @@ import org.neo4j.gds.paths.dijkstra.DijkstraFactory;
114
- import org.neo4j.gds.paths.dijkstra.DijkstraSourceTargetParameters;
115
- import org.neo4j.gds.paths.dijkstra.PathFindingResult;
116
- import org.neo4j.gds.paths.traverse.BFS;
117
- +import org.neo4j.gds.paths.traverse.BfsResult;
118
- import org.neo4j.gds.paths.traverse.DFS;
119
- import org.neo4j.gds.paths.traverse.ExitAndAggregation;
120
- +import org.neo4j.gds.paths.traverse.OneHopAggregator;
121
- import org.neo4j.gds.paths.yens.Yens;
122
- import org.neo4j.gds.paths.yens.YensParameters;
123
- import org.neo4j.gds.pcst.PCSTParameters;
124
- @@ -137,6 +139,30 @@ public class PathFindingAlgorithms {
125
- return bfs.compute();
126
- }
127
-
128
- + BfsResult breadthFirstSearchWithDepths(
129
- + Graph graph,
130
- + TraversalParameters parameters,
131
- + ProgressTracker progressTracker,
132
- + TerminationFlag terminationFlag
133
- + ) {
134
- + var exitAndAggregationConditions = ExitAndAggregation.create(graph, parameters);
135
- + var mappedStartNodeId = graph.toMappedNodeId(parameters.sourceNode());
136
- +
137
- + var bfs = BFS.create(
138
- + graph,
139
- + mappedStartNodeId,
140
- + exitAndAggregationConditions.exitFunction(),
141
- + new OneHopAggregator(),
142
- + parameters.maxDepth(),
143
- + DefaultPool.INSTANCE,
144
- + parameters.concurrency(),
145
- + progressTracker,
146
- + terminationFlag
147
- + );
148
- +
149
- + return bfs.computeWithDepths();
150
- + }
151
- +
152
- public PathFindingResult deltaStepping(
153
- Graph graph,
154
- DeltaSteppingParameters parameters,
155
- diff --git a/applications/algorithms/path-finding/src/main/java/org/neo4j/gds/applications/algorithms/pathfinding/PathFindingAlgorithmsBusinessFacade.java b/applications/algorithms/path-finding/src/main/java/org/neo4j/gds/applications/algorithms/pathfinding/PathFindingAlgorithmsBusinessFacade.java
156
- index 0753c65..ebcb9cc 100644
157
- --- a/applications/algorithms/path-finding/src/main/java/org/neo4j/gds/applications/algorithms/pathfinding/PathFindingAlgorithmsBusinessFacade.java
158
- +++ b/applications/algorithms/path-finding/src/main/java/org/neo4j/gds/applications/algorithms/pathfinding/PathFindingAlgorithmsBusinessFacade.java
159
- @@ -51,6 +51,7 @@ import org.neo4j.gds.paths.dijkstra.config.DijkstraBaseConfig;
160
- import org.neo4j.gds.paths.dijkstra.config.DijkstraSourceTargetsBaseConfig;
161
- import org.neo4j.gds.paths.traverse.BFSProgressTask;
162
- import org.neo4j.gds.paths.traverse.BfsBaseConfig;
163
- +import org.neo4j.gds.paths.traverse.BfsResult;
164
- import org.neo4j.gds.paths.traverse.DFSProgressTask;
165
- import org.neo4j.gds.paths.traverse.DfsBaseConfig;
166
- import org.neo4j.gds.paths.yens.YensProgressTask;
167
- @@ -141,6 +142,21 @@ public class PathFindingAlgorithmsBusinessFacade {
168
- );
169
- }
170
-
171
- + BfsResult breadthFirstSearchWithDepths(Graph graph, BfsBaseConfig configuration) {
172
- + var progressTracker = createProgressTracker(BFSProgressTask.create(), configuration);
173
- +
174
- + return algorithmMachinery.getResult(
175
- + () -> algorithms.breadthFirstSearchWithDepths(
176
- + graph,
177
- + configuration.toParameters(),
178
- + progressTracker,
179
- + requestScopedDependencies.terminationFlag()
180
- + ),
181
- + progressTracker,
182
- + configuration.concurrency()
183
- + );
184
- + }
185
- +
186
- public PathFindingResult deltaStepping(Graph graph, AllShortestPathsDeltaBaseConfig configuration) {
187
- var progressTracker = createProgressTracker(DeltaSteppingProgressTask.create(), configuration);
188
-
189
- diff --git a/applications/algorithms/path-finding/src/main/java/org/neo4j/gds/applications/algorithms/pathfinding/PathFindingAlgorithmsStreamModeBusinessFacade.java b/applications/algorithms/path-finding/src/main/java/org/neo4j/gds/applications/algorithms/pathfinding/PathFindingAlgorithmsStreamModeBusinessFacade.java
190
- index b14fde5..28f9141 100644
191
- --- a/applications/algorithms/path-finding/src/main/java/org/neo4j/gds/applications/algorithms/pathfinding/PathFindingAlgorithmsStreamModeBusinessFacade.java
192
- +++ b/applications/algorithms/path-finding/src/main/java/org/neo4j/gds/applications/algorithms/pathfinding/PathFindingAlgorithmsStreamModeBusinessFacade.java
193
- @@ -37,6 +37,7 @@ import org.neo4j.gds.paths.dijkstra.PathFindingResult;
194
- import org.neo4j.gds.paths.dijkstra.config.AllShortestPathsDijkstraStreamConfig;
195
- import org.neo4j.gds.paths.dijkstra.config.ShortestPathDijkstraStreamConfig;
196
- import org.neo4j.gds.paths.traverse.BfsStreamConfig;
197
- +import org.neo4j.gds.paths.traverse.BfsResult;
198
- import org.neo4j.gds.paths.traverse.DfsStreamConfig;
199
- import org.neo4j.gds.paths.yens.config.ShortestPathYensStreamConfig;
200
- import org.neo4j.gds.pcst.PCSTStreamConfig;
201
- @@ -117,14 +118,14 @@ public class PathFindingAlgorithmsStreamModeBusinessFacade {
202
- public <RESULT> Stream<RESULT> breadthFirstSearch(
203
- GraphName graphName,
204
- BfsStreamConfig configuration,
205
- - StreamResultBuilder<HugeLongArray, RESULT> resultBuilder
206
- + StreamResultBuilder<BfsResult, RESULT> resultBuilder
207
- ) {
208
- return convenience.processRegularAlgorithmInStreamMode(
209
- graphName,
210
- configuration,
211
- BFS,
212
- estimation::breadthFirstSearch,
213
- - (graph, __) -> algorithms.breadthFirstSearch(graph, configuration),
214
- + (graph, __) -> algorithms.breadthFirstSearchWithDepths(graph, configuration),
215
- resultBuilder
216
- );
217
- }
218
- diff --git a/proc/path-finding/src/test/java/org/neo4j/gds/paths/traverse/BfsStreamProcTest.java b/proc/path-finding/src/test/java/org/neo4j/gds/paths/traverse/BfsStreamProcTest.java
219
- index 8c3d35a..4c641a6 100644
220
- --- a/proc/path-finding/src/test/java/org/neo4j/gds/paths/traverse/BfsStreamProcTest.java
221
- +++ b/proc/path-finding/src/test/java/org/neo4j/gds/paths/traverse/BfsStreamProcTest.java
222
- @@ -125,7 +125,7 @@ class BfsStreamProcTest extends BaseProcTest {
223
- .streamMode()
224
- .addParameter("sourceNode", source)
225
- .addParameter("maxDepth", 2)
226
- - .yields("sourceNode", "nodeIds");
227
- + .yields("sourceNode", "nodeIds", "depths");
228
-
229
- runQueryWithRowConsumer(query, row -> {
230
- assertEquals(row.getNumber("sourceNode").longValue(), source);
231
- @@ -133,6 +133,7 @@ class BfsStreamProcTest extends BaseProcTest {
232
- assertThat(nodeIds).isEqualTo(
233
- Stream.of("a", "b", "c", "d").map(idFunction::of).collect(Collectors.toList())
234
- );
235
- + assertThat(row.get("depths")).isEqualTo(List.of(0L, 1L, 1L, 2L));
236
- });
237
- }
238
-
239
- @@ -175,7 +176,7 @@ class BfsStreamProcTest extends BaseProcTest {
240
- .algo("bfs")
241
- .streamMode()
242
- .addParameter("sourceNode", source)
243
- - .yields("sourceNode", "nodeIds");
244
- + .yields("sourceNode", "nodeIds", "depths");
245
- runQueryWithRowConsumer(query, row -> {
246
- assertThat(row.getNumber("sourceNode").longValue()).isEqualTo(source);
247
-
248
- @@ -188,6 +189,7 @@ class BfsStreamProcTest extends BaseProcTest {
249
- .map(idFunction::of)
250
- .collect(Collectors.toList())
251
- );
252
- + assertThat(row.get("depths")).isEqualTo(List.of(0L, 1L, 1L, 2L, 3L, 3L, 4L));
253
- });
254
- }
255
-
256
- diff --git a/proc/sysinfo/src/main/java/org/neo4j/gds/SysInfoProc.java b/proc/sysinfo/src/main/java/org/neo4j/gds/SysInfoProc.java
257
- index 9b729b5..bd0ca5f 100644
258
- --- a/proc/sysinfo/src/main/java/org/neo4j/gds/SysInfoProc.java
259
- +++ b/proc/sysinfo/src/main/java/org/neo4j/gds/SysInfoProc.java
260
- @@ -69,6 +69,27 @@ public class SysInfoProc {
261
- return debugValues(properties, Runtime.getRuntime(), config);
262
- }
263
-
264
- + @Procedure("gds.debug.arrow")
265
- + @SystemProcedure
266
- + @Description("Returns the status of the unavailable Arrow server in OpenGDS")
267
- + public Stream<ArrowInfo> arrow() {
268
- + return Stream.of(new ArrowInfo("", false, false, java.util.List.of()));
269
- + }
270
- +
271
- + public static final class ArrowInfo {
272
- + public final String listenAddress;
273
- + public final boolean enabled;
274
- + public final boolean running;
275
- + public final java.util.List<String> versions;
276
- +
277
- + private ArrowInfo(String listenAddress, boolean enabled, boolean running, java.util.List<String> versions) {
278
- + this.listenAddress = listenAddress;
279
- + this.enabled = enabled;
280
- + this.running = running;
281
- + this.versions = versions;
282
- + }
283
- + }
284
- +
285
- public static final class DebugValue {
286
- public final String key;
287
- public final Object value;
288
- diff --git a/proc/sysinfo/src/test/java/org/neo4j/gds/SysInfoProcTest.java b/proc/sysinfo/src/test/java/org/neo4j/gds/SysInfoProcTest.java
289
- index f1a3ddb..a9f44c5 100644
290
- --- a/proc/sysinfo/src/test/java/org/neo4j/gds/SysInfoProcTest.java
291
- +++ b/proc/sysinfo/src/test/java/org/neo4j/gds/SysInfoProcTest.java
292
- @@ -138,4 +138,19 @@ class SysInfoProcTest extends BaseProcTest {
293
- );
294
- assertThat(result).containsExactly(BuildInfoProperties.get().gdsVersion());
295
- }
296
- +
297
- + @Test
298
- + void shouldReportArrowAsDisabledForClientCompatibility() {
299
- + var result = runQuery(
300
- + "CALL gds.debug.arrow() YIELD listenAddress, enabled, running, versions "
301
- + + "RETURN listenAddress, enabled, running, versions",
302
- + cypherResult -> cypherResult.stream().findFirst().orElseThrow()
303
- + );
304
- +
305
- + assertThat(result)
306
- + .containsEntry("listenAddress", "")
307
- + .containsEntry("enabled", false)
308
- + .containsEntry("running", false)
309
- + .containsEntry("versions", List.of());
310
- + }
311
- }
312
- diff --git a/procedures/algorithms-facade/src/main/java/org/neo4j/gds/procedures/algorithms/pathfinding/BfsStreamResultBuilder.java b/procedures/algorithms-facade/src/main/java/org/neo4j/gds/procedures/algorithms/pathfinding/BfsStreamResultBuilder.java
313
- index 06e5426..b8e48f8 100644
314
- --- a/procedures/algorithms-facade/src/main/java/org/neo4j/gds/procedures/algorithms/pathfinding/BfsStreamResultBuilder.java
315
- +++ b/procedures/algorithms-facade/src/main/java/org/neo4j/gds/procedures/algorithms/pathfinding/BfsStreamResultBuilder.java
316
- @@ -23,16 +23,17 @@ import org.neo4j.gds.api.Graph;
317
- import org.neo4j.gds.api.GraphStore;
318
- import org.neo4j.gds.api.NodeLookup;
319
- import org.neo4j.gds.applications.algorithms.machinery.StreamResultBuilder;
320
- -import org.neo4j.gds.collections.ha.HugeLongArray;
321
- +import org.neo4j.gds.paths.traverse.BfsResult;
322
- import org.neo4j.gds.paths.traverse.BfsStreamConfig;
323
- import org.neo4j.graphdb.RelationshipType;
324
-
325
- +import java.util.Arrays;
326
- import java.util.Optional;
327
- import java.util.stream.Stream;
328
-
329
- import static org.neo4j.gds.procedures.algorithms.pathfinding.TraversalStreamResult.RELATIONSHIP_TYPE_NAME;
330
-
331
- -class BfsStreamResultBuilder implements StreamResultBuilder<HugeLongArray, TraversalStreamResult> {
332
- +class BfsStreamResultBuilder implements StreamResultBuilder<BfsResult, TraversalStreamResult> {
333
- private final NodeLookup nodeLookup;
334
- private final boolean pathRequested;
335
- private final BfsStreamConfig configuration;
336
- @@ -47,18 +48,30 @@ class BfsStreamResultBuilder implements StreamResultBuilder<HugeLongArray, Trave
337
- public Stream<TraversalStreamResult> build(
338
- Graph graph,
339
- GraphStore graphStore,
340
- - Optional<HugeLongArray> result
341
- + Optional<BfsResult> result
342
- ) {
343
- //noinspection OptionalIsPresent
344
- if (result.isEmpty()) return Stream.empty();
345
-
346
- - return TraverseStreamComputationResultConsumer.consume(
347
- - configuration.sourceNode(),
348
- - result.get(),
349
- - graph::toOriginalNodeId,
350
- - TraversalStreamResult::new,
351
- - PathFactoryFacade.create(pathRequested, nodeLookup, graphStore.capabilities().canWriteToLocalDatabase()),
352
- - RelationshipType.withName(RELATIONSHIP_TYPE_NAME)
353
- + var bfsResult = result.get();
354
- + var nodeList = Arrays.stream(bfsResult.nodeIds().toArray())
355
- + .map(graph::toOriginalNodeId)
356
- + .boxed()
357
- + .toList();
358
- + var depthList = Arrays.stream(bfsResult.depths().toArray())
359
- + .mapToObj(depth -> (long) depth)
360
- + .toList();
361
- + var path = PathFactoryFacade
362
- + .create(pathRequested, nodeLookup, graphStore.capabilities().canWriteToLocalDatabase())
363
- + .createPath(nodeList, RelationshipType.withName(RELATIONSHIP_TYPE_NAME));
364
- +
365
- + return Stream.of(
366
- + new TraversalStreamResult(
367
- + configuration.sourceNode(),
368
- + nodeList,
369
- + depthList,
370
- + path
371
- + )
372
- );
373
- }
374
- }
375
- diff --git a/procedures/facade-api/path-finding-facade-api/src/main/java/org/neo4j/gds/procedures/algorithms/pathfinding/TraversalStreamResult.java b/procedures/facade-api/path-finding-facade-api/src/main/java/org/neo4j/gds/procedures/algorithms/pathfinding/TraversalStreamResult.java
376
- index 35a4c21..8a64fd8 100644
377
- --- a/procedures/facade-api/path-finding-facade-api/src/main/java/org/neo4j/gds/procedures/algorithms/pathfinding/TraversalStreamResult.java
378
- +++ b/procedures/facade-api/path-finding-facade-api/src/main/java/org/neo4j/gds/procedures/algorithms/pathfinding/TraversalStreamResult.java
379
- @@ -23,6 +23,10 @@ import org.neo4j.graphdb.Path;
380
-
381
- import java.util.List;
382
-
383
- -public record TraversalStreamResult(long sourceNode, List<Long> nodeIds, Path path) {
384
- +public record TraversalStreamResult(long sourceNode, List<Long> nodeIds, List<Long> depths, Path path) {
385
- public static final String RELATIONSHIP_TYPE_NAME = "NEXT";
386
- +
387
- + public TraversalStreamResult(long sourceNode, List<Long> nodeIds, Path path) {
388
- + this(sourceNode, nodeIds, List.of(), path);
389
- + }
390
- }
391
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
opengds/open-gds-2.22.0-genmod.jar DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:940c1f0c0cb6a9adbee84c9f15207740262e7c7c83464ad61b31000e4b7caf5e
3
- size 32584145
 
 
 
 
start.sh CHANGED
@@ -1,37 +1,11 @@
1
- set -euo pipefail
2
-
3
- DUMP_REPO="${GENMOD_DUMP_REPO:-cnil/genmod-dump-neo4j}"
4
- DUMP_REVISION="${GENMOD_DUMP_REVISION:-main}"
5
- DUMP_BASE_URL="https://huggingface.co/datasets/${DUMP_REPO}/resolve/${DUMP_REVISION}"
6
- AUTH_HEADER="Authorization: Bearer ${HF_TOKEN}"
7
-
8
- # Load the database backup. Metadata is optional for compatibility with the
9
- # original dump; the application then falls back to its historical date.
10
- wget --header="${AUTH_HEADER}" "${DUMP_BASE_URL}/system.dump" -O /backups/system.dump
11
- wget --header="${AUTH_HEADER}" "${DUMP_BASE_URL}/neo4j.dump" -O /backups/neo4j.dump
12
- if ! wget --header="${AUTH_HEADER}" "${DUMP_BASE_URL}/database_metadata.json" \
13
- -O /backups/database_metadata.json; then
14
- rm -f /backups/database_metadata.json
15
- fi
16
  neo4j-admin database load --expand-commands system --from-path=/backups --overwrite-destination=true
17
  neo4j-admin database load --expand-commands neo4j --from-path=/backups --overwrite-destination=true
18
 
19
  # start database
20
  /startup/docker-entrypoint.sh neo4j &
21
-
22
- # The Community importer cannot create indexes through --schema. Ensure that
23
- # dumps produced by the refresh Job receive the required indexes before the
24
- # application starts serving searches.
25
- until cypher-shell -u neo4j -p genealogiemodeles \
26
- "RETURN 1;" >/dev/null 2>&1; do
27
- sleep 2
28
- done
29
- cypher-shell -u neo4j -p genealogiemodeles \
30
- "CREATE INDEX IF NOT EXISTS FOR (m:Model) ON (m.name);
31
- CREATE INDEX IF NOT EXISTS FOR (a:Author) ON (a.name);
32
- CREATE INDEX IF NOT EXISTS FOR (d:Dataset) ON (d.name);
33
- CALL db.awaitIndexes(600);"
34
-
35
  # start tool
36
  python3 /application_neo4j/app.py neo4j genealogiemodeles &
37
  # wait for any process to exit
 
1
+ # load database backup
2
+ wget --no-clobber --header="Authorization: Bearer $HF_TOKEN" https://huggingface.co/datasets/cnil/genmod-dump-neo4j/resolve/main/system.dump -P /backups/
3
+ wget --no-clobber --header="Authorization: Bearer $HF_TOKEN" https://huggingface.co/datasets/cnil/genmod-dump-neo4j/resolve/main/neo4j.dump -P /backups/
 
 
 
 
 
 
 
 
 
 
 
 
4
  neo4j-admin database load --expand-commands system --from-path=/backups --overwrite-destination=true
5
  neo4j-admin database load --expand-commands neo4j --from-path=/backups --overwrite-destination=true
6
 
7
  # start database
8
  /startup/docker-entrypoint.sh neo4j &
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  # start tool
10
  python3 /application_neo4j/app.py neo4j genealogiemodeles &
11
  # wait for any process to exit