From b28f8eac63240f9ab2d349ad33a90f835c5f56fa Mon Sep 17 00:00:00 2001 From: Tykayn Date: Sun, 31 Aug 2025 01:33:22 +0200 Subject: [PATCH] use insee codes to generate all cities measures from osmium and osm pbf --- src/Controller/AdminController.php | 361 ++++++++++++++++++++++++++++ wiki_compare/README.md | 45 +++- wiki_compare/propose_translation.py | 233 ++++++++++++++++++ 3 files changed, 633 insertions(+), 6 deletions(-) create mode 100755 wiki_compare/propose_translation.py diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index dca54bd..1185b7d 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -1646,6 +1646,367 @@ final class AdminController extends AbstractController $this->addFlash('success', "Création des Stats manquantes terminée : $createdCount communes ajoutées, $skippedCount déjà existantes, $errorCount erreurs."); return $this->redirectToRoute('app_admin'); } + + #[Route('/admin/create-stats-from-insee-csv', name: 'app_admin_create_stats_from_insee_csv')] + public function createStatsFromInseeCsv(): Response + { + $this->actionLogger->log('admin/create_stats_from_insee_csv', []); + + $csvFile = 'communes_france.csv'; + if (!file_exists($csvFile)) { + $this->addFlash('error', 'Le fichier CSV des communes n\'existe pas. Veuillez exécuter le script fetch_communes.py pour le générer.'); + return $this->redirectToRoute('app_admin'); + } + + $statsRepo = $this->entityManager->getRepository(Stats::class); + $createdCount = 0; + $skippedCount = 0; + $errorCount = 0; + + // Ouvrir le fichier CSV + $handle = fopen($csvFile, 'r'); + if (!$handle) { + $this->addFlash('error', 'Impossible d\'ouvrir le fichier CSV des communes.'); + return $this->redirectToRoute('app_admin'); + } + + // Lire l'en-tête pour déterminer les indices des colonnes + $header = fgetcsv($handle); + $indices = array_flip($header); + + // Vérifier que les colonnes nécessaires existent + $requiredColumns = ['code', 'nom']; + foreach ($requiredColumns as $column) { + if (!isset($indices[$column])) { + $this->addFlash('error', "La colonne '$column' est manquante dans le fichier CSV."); + fclose($handle); + return $this->redirectToRoute('app_admin'); + } + } + + // Traiter chaque ligne du CSV + while (($data = fgetcsv($handle)) !== false) { + try { + $inseeCode = $data[$indices['code']]; + + // Vérifier si une Stats existe déjà pour ce code INSEE + $existingStat = $statsRepo->findOneBy(['zone' => $inseeCode]); + if ($existingStat) { + $skippedCount++; + continue; + } + + // Créer un nouvel objet Stats + $stat = new Stats(); + $stat->setZone($inseeCode) + ->setDateCreated(new \DateTime()) + ->setDateModified(new \DateTime()) + ->setKind('insee_csv'); // Utiliser 'insee_csv' comme source + + // Ajouter le nom si disponible + if (isset($indices['nom']) && !empty($data[$indices['nom']])) { + $stat->setName($data[$indices['nom']]); + } + + // Ajouter la population si disponible + if (isset($indices['population']) && !empty($data[$indices['population']])) { + $stat->setPopulation((int)$data[$indices['population']]); + } + + // Ajouter les codes postaux si disponibles + if (isset($indices['codesPostaux']) && !empty($data[$indices['codesPostaux']])) { + $stat->setCodesPostaux($data[$indices['codesPostaux']]); + } + + // Ajouter le SIREN si disponible + if (isset($indices['siren']) && !empty($data[$indices['siren']])) { + $stat->setSiren((int)$data[$indices['siren']]); + } + + // Ajouter le code EPCI si disponible + if (isset($indices['codeEpci']) && !empty($data[$indices['codeEpci']])) { + $stat->setCodeEpci((int)$data[$indices['codeEpci']]); + } + + // Ne pas faire de labourage des objets avant la sauvegarde + + // Persister l'objet Stats + $this->entityManager->persist($stat); + $createdCount++; + + // Flush tous les 100 objets pour éviter de surcharger la mémoire + if ($createdCount % 100 === 0) { + $this->entityManager->flush(); + $this->entityManager->clear(Stats::class); + } + } catch (\Exception $e) { + $errorCount++; + $this->actionLogger->log('error_create_stats_from_insee_csv', [ + 'insee_code' => $inseeCode ?? 'unknown', + 'error' => $e->getMessage() + ]); + } + } + + // Flush les derniers objets + $this->entityManager->flush(); + + fclose($handle); + + $this->addFlash('success', "Création des Stats depuis le CSV INSEE terminée : $createdCount communes ajoutées, $skippedCount déjà existantes, $errorCount erreurs."); + return $this->redirectToRoute('app_admin'); + } + + #[Route('/admin/retrieve-city-polygons', name: 'app_admin_retrieve_city_polygons')] + public function retrieveCityPolygons(): Response + { + $this->actionLogger->log('admin/retrieve_city_polygons', []); + + // Vérifier que le dossier polygons existe, sinon le créer + $polygonsDir = __DIR__ . '/../../counting_osm_objects/polygons'; + if (!is_dir($polygonsDir)) { + mkdir($polygonsDir, 0755, true); + } + + // Récupérer toutes les Stats + $statsRepo = $this->entityManager->getRepository(Stats::class); + $allStats = $statsRepo->findAll(); + + $totalCount = count($allStats); + $existingCount = 0; + $createdCount = 0; + $errorCount = 0; + + // Pour chaque Stats, récupérer le polygone si nécessaire + foreach ($allStats as $stat) { + $inseeCode = $stat->getZone(); + if (!$inseeCode) { + continue; + } + + $polygonFile = $polygonsDir . '/commune_' . $inseeCode . '.poly'; + + // Vérifier si le polygone existe déjà + if (file_exists($polygonFile)) { + $existingCount++; + continue; + } + + try { + // Utiliser le script Python existant pour récupérer le polygone + $command = 'cd ' . __DIR__ . '/../../counting_osm_objects && python3 get_poly.py ' . $inseeCode; + $output = []; + $returnVar = 0; + exec($command, $output, $returnVar); + + if ($returnVar === 0 && file_exists($polygonFile)) { + $createdCount++; + } else { + $errorCount++; + $this->actionLogger->log('error_retrieve_city_polygon', [ + 'insee_code' => $inseeCode, + 'error' => 'Failed to retrieve polygon: ' . implode("\n", $output) + ]); + } + } catch (\Exception $e) { + $errorCount++; + $this->actionLogger->log('error_retrieve_city_polygon', [ + 'insee_code' => $inseeCode, + 'error' => $e->getMessage() + ]); + } + } + + $this->addFlash('success', "Récupération des polygones terminée : $createdCount polygones créés, $existingCount déjà existants, $errorCount erreurs sur un total de $totalCount communes."); + return $this->redirectToRoute('app_admin'); + } + + #[Route('/admin/extract-insee-zones', name: 'app_admin_extract_insee_zones')] + public function extractInseeZones(): Response + { + $this->actionLogger->log('admin/extract_insee_zones', []); + + // Vérifier que le fichier france-latest.osm.pbf existe + $francePbfFile = __DIR__ . '/../../france-latest.osm.pbf'; + if (!file_exists($francePbfFile)) { + $this->addFlash('error', 'Le fichier france-latest.osm.pbf n\'existe pas. Veuillez le télécharger depuis https://download.geofabrik.de/europe/france.html'); + return $this->redirectToRoute('app_admin'); + } + + // Vérifier que le dossier polygons existe + $polygonsDir = __DIR__ . '/../../counting_osm_objects/polygons'; + if (!is_dir($polygonsDir)) { + $this->addFlash('error', 'Le dossier des polygones n\'existe pas. Veuillez d\'abord exécuter l\'action "Récupérer les polygones des villes".'); + return $this->redirectToRoute('app_admin'); + } + + // Créer le dossier pour les extractions JSON si nécessaire + $extractsDir = __DIR__ . '/../../insee_extracts'; + if (!is_dir($extractsDir)) { + mkdir($extractsDir, 0755, true); + } + + // Récupérer toutes les Stats + $statsRepo = $this->entityManager->getRepository(Stats::class); + $allStats = $statsRepo->findAll(); + + $totalCount = count($allStats); + $existingCount = 0; + $createdCount = 0; + $errorCount = 0; + + // Pour chaque Stats, extraire les données si nécessaire + foreach ($allStats as $stat) { + $inseeCode = $stat->getZone(); + if (!$inseeCode) { + continue; + } + + $polygonFile = $polygonsDir . '/commune_' . $inseeCode . '.poly'; + $extractPbfFile = $extractsDir . '/commune_' . $inseeCode . '.osm.pbf'; + $extractJsonFile = $extractsDir . '/commune_' . $inseeCode . '.json'; + + // Vérifier si le polygone existe + if (!file_exists($polygonFile)) { + $this->actionLogger->log('error_extract_insee_zone', [ + 'insee_code' => $inseeCode, + 'error' => 'Polygon file does not exist' + ]); + $errorCount++; + continue; + } + + // Vérifier si l'extraction JSON existe déjà + if (file_exists($extractJsonFile)) { + $existingCount++; + continue; + } + + try { + // Étape 1: Extraire les données de france-latest.osm.pbf vers un fichier PBF pour la zone + $extractCommand = 'osmium extract -p ' . $polygonFile . ' ' . $francePbfFile . ' -o ' . $extractPbfFile; + $output = []; + $returnVar = 0; + exec($extractCommand, $output, $returnVar); + + if ($returnVar !== 0 || !file_exists($extractPbfFile)) { + $this->actionLogger->log('error_extract_insee_zone', [ + 'insee_code' => $inseeCode, + 'error' => 'Failed to extract PBF: ' . implode("\n", $output) + ]); + $errorCount++; + continue; + } + + // Étape 2: Convertir le fichier PBF en JSON + $exportCommand = 'osmium export ' . $extractPbfFile . ' -f json -o ' . $extractJsonFile; + $output = []; + $returnVar = 0; + exec($exportCommand, $output, $returnVar); + + if ($returnVar === 0 && file_exists($extractJsonFile)) { + $createdCount++; + } else { + $this->actionLogger->log('error_extract_insee_zone', [ + 'insee_code' => $inseeCode, + 'error' => 'Failed to export to JSON: ' . implode("\n", $output) + ]); + $errorCount++; + } + + // Supprimer le fichier PBF intermédiaire pour économiser de l'espace + if (file_exists($extractPbfFile)) { + unlink($extractPbfFile); + } + } catch (\Exception $e) { + $errorCount++; + $this->actionLogger->log('error_extract_insee_zone', [ + 'insee_code' => $inseeCode, + 'error' => $e->getMessage() + ]); + } + } + + $this->addFlash('success', "Extraction des zones INSEE terminée : $createdCount extractions créées, $existingCount déjà existantes, $errorCount erreurs sur un total de $totalCount communes."); + return $this->redirectToRoute('app_admin'); + } + + #[Route('/admin/process-insee-extracts', name: 'app_admin_process_insee_extracts')] + public function processInseeExtracts(): Response + { + $this->actionLogger->log('admin/process_insee_extracts', []); + + // Vérifier que le dossier des extractions existe + $extractsDir = __DIR__ . '/../../insee_extracts'; + if (!is_dir($extractsDir)) { + $this->addFlash('error', 'Le dossier des extractions n\'existe pas. Veuillez d\'abord exécuter l\'action "Extraire les données des zones INSEE".'); + return $this->redirectToRoute('app_admin'); + } + + // Récupérer toutes les Stats + $statsRepo = $this->entityManager->getRepository(Stats::class); + $allStats = $statsRepo->findAll(); + + $totalCount = count($allStats); + $processedCount = 0; + $skippedCount = 0; + $errorCount = 0; + + // Pour chaque Stats, traiter les données si nécessaire + foreach ($allStats as $stat) { + $inseeCode = $stat->getZone(); + if (!$inseeCode) { + continue; + } + + $extractJsonFile = $extractsDir . '/commune_' . $inseeCode . '.json'; + + // Vérifier si l'extraction JSON existe + if (!file_exists($extractJsonFile)) { + $this->actionLogger->log('error_process_insee_extract', [ + 'insee_code' => $inseeCode, + 'error' => 'JSON extract file does not exist' + ]); + $errorCount++; + continue; + } + + try { + // Utiliser la Motocultrice pour traiter les données + $result = $this->motocultrice->labourer($inseeCode); + + if ($result) { + // Mettre à jour la date de labourage + $stat->setDateLabourageDone(new \DateTime()); + $this->entityManager->persist($stat); + $processedCount++; + + // Flush tous les 10 objets pour éviter de surcharger la mémoire + if ($processedCount % 10 === 0) { + $this->entityManager->flush(); + } + } else { + $this->actionLogger->log('error_process_insee_extract', [ + 'insee_code' => $inseeCode, + 'error' => 'Failed to process extract with Motocultrice' + ]); + $errorCount++; + } + } catch (\Exception $e) { + $errorCount++; + $this->actionLogger->log('error_process_insee_extract', [ + 'insee_code' => $inseeCode, + 'error' => $e->getMessage() + ]); + } + } + + // Flush les derniers objets + $this->entityManager->flush(); + + $this->addFlash('success', "Traitement des extractions INSEE terminé : $processedCount communes traitées, $skippedCount ignorées, $errorCount erreurs sur un total de $totalCount communes."); + return $this->redirectToRoute('app_admin'); + } /** * Complète les données manquantes d'un objet Stats (coordonnées, budget, etc.) diff --git a/wiki_compare/README.md b/wiki_compare/README.md index 519fc00..f86c59e 100644 --- a/wiki_compare/README.md +++ b/wiki_compare/README.md @@ -5,7 +5,7 @@ jour ou de traductions, et publier des suggestions sur Mastodon pour encourager ## Vue d'ensemble -Le projet comprend huit scripts principaux : +Le projet comprend neuf scripts principaux : 1. **wiki_compare.py** : Récupère les 10 clés OSM les plus utilisées, compare leurs pages wiki en anglais et en français, et identifie celles qui ont besoin de mises à jour. @@ -13,17 +13,19 @@ Le projet comprend huit scripts principaux : message sur Mastodon pour suggérer sa mise à jour. 3. **suggest_translation.py** : Identifie les pages wiki anglaises qui n'ont pas de traduction française et publie une suggestion de traduction sur Mastodon. -4. **detect_suspicious_deletions.py** : Analyse les changements récents du wiki OSM pour détecter les suppressions +4. **propose_translation.py** : Sélectionne une page wiki (par défaut la première) et utilise Ollama avec le modèle + "mistral:7b" pour proposer une traduction, qui est sauvegardée dans le fichier outdated_pages.json. +5. **detect_suspicious_deletions.py** : Analyse les changements récents du wiki OSM pour détecter les suppressions suspectes (plus de 20 caractères) et les enregistre dans un fichier JSON pour affichage sur le site web. -5. **fetch_proposals.py** : Récupère les propositions de tags OSM en cours de vote et les propositions récemment modifiées, +6. **fetch_proposals.py** : Récupère les propositions de tags OSM en cours de vote et les propositions récemment modifiées, et les enregistre dans un fichier JSON pour affichage sur le site web. Les données sont mises en cache pendant une heure pour éviter des requêtes trop fréquentes au serveur wiki. -6. **find_untranslated_french_pages.py** : Identifie les pages wiki françaises qui n'ont pas de traduction en anglais +7. **find_untranslated_french_pages.py** : Identifie les pages wiki françaises qui n'ont pas de traduction en anglais et les enregistre dans un fichier JSON pour affichage sur le site web. Les données sont mises en cache pendant une heure. -7. **find_pages_unavailable_in_french.py** : Scrape la catégorie des pages non disponibles en français, gère la pagination +8. **find_pages_unavailable_in_french.py** : Scrape la catégorie des pages non disponibles en français, gère la pagination pour récupérer toutes les pages, les groupe par préfixe de langue et priorise les pages commençant par "En:". Les données sont mises en cache pendant une heure. -8. **fetch_osm_fr_groups.py** : Récupère les informations sur les groupes de travail et les groupes locaux d'OSM-FR +9. **fetch_osm_fr_groups.py** : Récupère les informations sur les groupes de travail et les groupes locaux d'OSM-FR depuis la section #Pages_des_groupes_locaux et les enregistre dans un fichier JSON pour affichage sur le site web. Les données sont mises en cache pendant une heure. @@ -42,6 +44,15 @@ Installez les dépendances requises : pip install requests beautifulsoup4 ``` +Pour utiliser le script propose_translation.py, vous devez également installer Ollama : + +1. Installez Ollama en suivant les instructions sur [ollama.ai](https://ollama.ai/) +2. Téléchargez le modèle "mistral:7b" : + +```bash +ollama pull mistral:7b +``` + ## Configuration ### Mastodon API @@ -111,6 +122,28 @@ Pour simuler la publication sans réellement poster sur Mastodon (mode test) : ./suggest_translation.py --dry-run ``` +### Proposer une traduction avec Ollama + +Pour sélectionner une page wiki (par défaut la première du fichier outdated_pages.json) et générer une proposition de traduction avec Ollama : + +```bash +./propose_translation.py +``` + +Pour traduire une page spécifique en utilisant sa clé : + +```bash +./propose_translation.py --page type +``` + +Note : Ce script nécessite que Ollama soit installé et exécuté localement avec le modèle "mistral:7b" disponible. Pour installer Ollama, suivez les instructions sur [ollama.ai](https://ollama.ai/). Pour télécharger le modèle "mistral:7b", exécutez : + +```bash +ollama pull mistral:7b +``` + +Le script enregistre la traduction proposée dans la propriété "proposed_translation" de l'entrée correspondante dans le fichier outdated_pages.json. + ### Détecter les suppressions suspectes Pour analyser les changements récents du wiki OSM et détecter les suppressions suspectes : diff --git a/wiki_compare/propose_translation.py b/wiki_compare/propose_translation.py new file mode 100755 index 0000000..9a0af7b --- /dev/null +++ b/wiki_compare/propose_translation.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +propose_translation.py + +This script reads the outdated_pages.json file, selects a wiki page (by default the first one), +and uses Ollama with the "mistral:7b" model to propose a translation of the page. +The translation is saved in the "proposed_translation" property of the JSON file. + +Usage: + python propose_translation.py [--page KEY] + +Options: + --page KEY Specify the key of the page to translate (default: first page in the file) + +Output: + - Updated outdated_pages.json file with proposed translations +""" + +import json +import argparse +import logging +import requests +import os +import sys +from bs4 import BeautifulSoup + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger(__name__) + +# Constants +OUTDATED_PAGES_FILE = "outdated_pages.json" +OLLAMA_API_URL = "http://localhost:11434/api/generate" +OLLAMA_MODEL = "mistral:7b" + +def load_outdated_pages(): + """ + Load the outdated pages from the JSON file + + Returns: + list: List of dictionaries containing outdated page information + """ + try: + with open(OUTDATED_PAGES_FILE, 'r', encoding='utf-8') as f: + pages = json.load(f) + logger.info(f"Successfully loaded {len(pages)} pages from {OUTDATED_PAGES_FILE}") + return pages + except (IOError, json.JSONDecodeError) as e: + logger.error(f"Error loading pages from {OUTDATED_PAGES_FILE}: {e}") + return [] + +def save_to_json(data, filename): + """ + Save data to a JSON file + + Args: + data: Data to save + filename (str): Name of the file + """ + try: + with open(filename, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + logger.info(f"Data saved to {filename}") + except IOError as e: + logger.error(f"Error saving data to {filename}: {e}") + +def fetch_wiki_page_content(url): + """ + Fetch the content of a wiki page + + Args: + url (str): URL of the wiki page + + Returns: + str: Content of the wiki page + """ + try: + response = requests.get(url) + response.raise_for_status() + + soup = BeautifulSoup(response.text, 'html.parser') + + # Get the main content + content = soup.select_one('#mw-content-text') + if content: + # Remove script and style elements + for script in content.select('script, style'): + script.extract() + + # Remove .languages elements + for languages_elem in content.select('.languages'): + languages_elem.extract() + + # Get text + text = content.get_text(separator=' ', strip=True) + return text + else: + logger.warning(f"Could not find content in page: {url}") + return "" + + except requests.exceptions.RequestException as e: + logger.error(f"Error fetching wiki page content: {e}") + return "" + +def translate_with_ollama(text, model=OLLAMA_MODEL): + """ + Translate text using Ollama + + Args: + text (str): Text to translate + model (str): Ollama model to use + + Returns: + str: Translated text + """ + prompt = f""" +Tu es un traducteur professionnel spécialisé dans la traduction de documentation technique de l'anglais vers le français. +Traduis le texte suivant de l'anglais vers le français. Conserve le formatage et la structure du texte original. +Ne traduis pas les noms propres, les URLs, et les termes techniques spécifiques à OpenStreetMap. + +Texte à traduire: +{text} +""" + + try: + logger.info(f"Sending request to Ollama with model {model}") + + payload = { + "model": model, + "prompt": prompt, + "stream": False + } + + response = requests.post(OLLAMA_API_URL, json=payload) + response.raise_for_status() + + result = response.json() + translation = result.get('response', '') + + logger.info(f"Successfully received translation from Ollama") + return translation + + except requests.exceptions.RequestException as e: + logger.error(f"Error translating with Ollama: {e}") + return "" + +def select_page_for_translation(pages, key=None): + """ + Select a page for translation + + Args: + pages (list): List of dictionaries containing page information + key (str): Key of the page to select (if None, select the first page) + + Returns: + dict: Selected page or None if no suitable page found + """ + if not pages: + logger.warning("No pages found that need translation") + return None + + if key: + # Find the page with the specified key + for page in pages: + if page.get('key') == key: + logger.info(f"Selected page for key '{key}' for translation") + return page + + logger.warning(f"No page found with key '{key}'") + return None + else: + # Select the first page + selected_page = pages[0] + logger.info(f"Selected first page (key '{selected_page['key']}') for translation") + return selected_page + +def main(): + """Main function to execute the script""" + parser = argparse.ArgumentParser(description="Propose a translation for an OSM wiki page using Ollama") + parser.add_argument("--page", help="Key of the page to translate (default: first page in the file)") + args = parser.parse_args() + + logger.info("Starting propose_translation.py") + + # Load pages + pages = load_outdated_pages() + if not pages: + logger.error("No pages found. Run wiki_compare.py first.") + sys.exit(1) + + # Select a page for translation + selected_page = select_page_for_translation(pages, args.page) + if not selected_page: + logger.error("Could not select a page for translation.") + sys.exit(1) + + # Get the English page URL + en_url = selected_page.get('en_page', {}).get('url') + if not en_url: + logger.error(f"No English page URL found for key '{selected_page['key']}'") + sys.exit(1) + + # Fetch the content of the English page + logger.info(f"Fetching content from {en_url}") + content = fetch_wiki_page_content(en_url) + if not content: + logger.error(f"Could not fetch content from {en_url}") + sys.exit(1) + + # Translate the content + logger.info(f"Translating content for key '{selected_page['key']}'") + translation = translate_with_ollama(content) + if not translation: + logger.error("Could not translate content") + sys.exit(1) + + # Save the translation in the JSON file + logger.info(f"Saving translation for key '{selected_page['key']}'") + selected_page['proposed_translation'] = translation + + # Save the updated data back to the file + save_to_json(pages, OUTDATED_PAGES_FILE) + + logger.info("Script completed successfully") + +if __name__ == "__main__": + main() \ No newline at end of file