markdown_parser/modules/metadata.nim
Bruno Charest 18ee8a1cfd feat: Add report generation module for tracking metadata changes
- Implemented `report.nim` to create structured reports on metadata modifications.
- Added functionality to merge reports and convert them to formatted strings.

docs: Create prompt documentation for Markdown parser project

- Added `prompt.md` detailing the requirements and functionalities for the Markdown parser.
- Included specifications, usage examples, and testing guidelines.

docs: Generate code review report for Markdown parser

- Created `rapport_revue_code.md` outlining security vulnerabilities, code quality issues, and suggested improvements.
- Provided a detailed analysis of the codebase with actionable recommendations.

test: Add test data for Markdown parser

- Included various Markdown files and a JPG image in `test_data` to simulate different scenarios.
- Ensured that the parser can handle both valid and invalid metadata.

chore: Add version management file

- Created `version.nim` for automatic versioning of the Markdown parser.
- Established constants for major, minor, patch, and build versions.
2026-04-19 12:56:55 -04:00

693 lines
24 KiB
Nim

# metadata.nim
# Module responsable de l'extraction et de la manipulation des métadonnées YAML
import std/[strutils, strformat, options, times, os, httpclient, json, sequtils, tables, uri]
import config
# Fonctions utilitaires
proc isEmptyOrWhitespace*(s: string): bool =
## Vérifie si une chaîne est vide ou contient uniquement des espaces blancs
if s.len == 0:
return true
for c in s:
if not c.isSpaceAscii():
return false
return true
proc parseYamlValue(line: string): string =
## Extrait la valeur d'une ligne YAML au format "clé: valeur"
let parts = line.split(':', 1)
if parts.len > 1:
return parts[1].strip()
return ""
proc isValidTag*(tag: string): bool =
## Vérifie si un tag est valide (contient au moins un caractère alphanumérique)
## Retourne false pour les tags qui ne contiennent que des caractères spéciaux comme "--"
if tag.len == 0:
return false
# Vérifier si le tag contient au moins une lettre ou un chiffre
for c in tag:
if c.isAlphaNumeric():
return true
return false # Tag ne contient que des caractères spéciaux
proc cleanTagText*(tag: string): string =
## Nettoie un tag en remplaçant les espaces par des underscores et en éliminant les caractères indésirables
var resultString = tag.strip()
# Supprimer les guillemets s'ils encadrent le tag
if resultString.len >= 2 and resultString[0] == '"' and resultString[^1] == '"':
resultString = resultString[1..^2]
# Supprimer les apostrophes s'ils encadrent le tag
if resultString.len >= 2 and resultString[0] == '\'' and resultString[^1] == '\'':
resultString = resultString[1..^2]
# Convertir en minuscules
resultString = resultString.toLowerAscii()
# Remplacer les espaces par des underscores
resultString = resultString.replace(" ", "_")
# Remplacer certains caractères spéciaux par des underscores
for c in ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '+', '=', '{', '}', '[', ']', '|', '\\', ':', ';', '"', '\'', '<', '>', ',', '.', '?', '/']:
resultString = resultString.replace($c, "_")
# Limiter les underscores consécutifs à un seul
while "__" in resultString:
resultString = resultString.replace("__", "_")
# Supprimer les underscores au début et à la fin
resultString = resultString.strip(chars={'_'})
return resultString
proc parseYamlSeq(lines: seq[string], startIndex: int): tuple[tags: seq[string], endIndex: int] =
## Parse une séquence YAML commençant par des tirets
var
tags: seq[string] = @[]
i = startIndex
while i < lines.len:
let line = lines[i].strip()
if line.startsWith("-"):
let tagText = line[1..^1].strip()
let cleanedTag = cleanTagText(tagText)
if isValidTag(cleanedTag): # Ne garder que les tags valides
tags.add(cleanedTag)
i += 1
else:
break
return (tags: tags, endIndex: i)
type
AIAnalysisResult* = object
title*: string
description*: string
tags*: seq[string]
Metadata* = object
title*: string
description*: string
tags*: seq[string]
creationDate*: string
creationTime*: string
modificationDate*: string
modificationTime*: string
auteur*: string
url*: string
lang*: string
category*: string
isNew*: bool # Indique si les métadonnées ont été générées ou étaient déjà présentes
proc newMetadata*(): Metadata =
## Crée un nouvel objet Metadata vide
result = Metadata(
title: "",
description: "",
tags: @[],
creationDate: "",
creationTime: "",
modificationDate: "",
modificationTime: "",
auteur: "Non spécifié",
url: "",
lang: "fr", # Langue par défaut
category: "",
isNew: true
)
proc formatCurrentDateTime*(): tuple[date: string, time: string] =
## Retourne la date et l'heure actuelles formatées selon le format requis
let now = now()
return (
date: now.format("yyyy-MM-dd"),
time: now.format("HH:mm:ss")
)
proc formatFileTime*(time: Time): tuple[date: string, time: string] =
## Formate un objet Time en tuple (date, heure) selon le format requis
return (
date: time.format("yyyy-MM-dd"),
time: time.format("HH:mm:ss")
)
proc getFileTimeInfo*(filePath: string): tuple[creation: tuple[date: string, time: string],
modification: tuple[date: string, time: string]] =
## Obtient les informations de date/heure de création et modification du fichier
try:
let
fileInfo = getFileInfo(filePath)
creationTime = fileInfo.creationTime
modificationTime = fileInfo.lastWriteTime
return (
creation: formatFileTime(creationTime),
modification: formatFileTime(modificationTime)
)
except:
let current = formatCurrentDateTime()
return (
creation: current,
modification: current
)
proc extractTitle*(content: string, filePath: string = ""): string =
## Tente d'extraire un titre à partir du contenu Markdown
## Regarde la première ligne commençant par # ou utilise le nom du fichier
let lines = content.splitLines()
for line in lines:
let trimmed = line.strip()
if trimmed.startsWith("# "):
return trimmed[2..^1]
# Si pas de titre trouvé, utiliser le nom du fichier sans extension
if filePath != "":
let fileName = extractFilename(filePath)
if fileName.contains('.'):
return fileName.split('.')[0]
else:
return fileName
return "Document Markdown"
proc extractYamlValue(yaml: string, key: string): string =
## Extrait la valeur d'une clé spécifique d'un bloc YAML
let lines = yaml.splitLines()
for line in lines:
let trimmedLine = line.strip()
# Cherche une ligne qui commence par la clé suivie de deux points
if trimmedLine.startsWith(key & ":"):
# Extrait la partie après les deux points
let value = trimmedLine.substr(key.len + 1).strip()
return value
return ""
proc extractYamlList(yaml: string, key: string): seq[string] =
## Extrait une liste de valeurs pour une clé spécifique d'un bloc YAML
var resultList: seq[string] = @[]
let lines = yaml.splitLines()
var i = 0
# Chercher la ligne avec la clé
while i < lines.len:
if lines[i].strip().startsWith(key & ":"):
# Si la clé est suivie directement de valeurs sur la même ligne
let restOfLine = lines[i].substr(key.len + 1).strip()
if restOfLine.len > 0 and not restOfLine.startsWith("-"):
# Format compact: key: [val1, val2, val3]
if restOfLine.startsWith("[") and restOfLine.endsWith("]"):
let items = restOfLine[1..^2].split(",")
for item in items:
let cleaned = item.strip()
if cleaned.len > 0:
resultList.add(cleaned)
break
# Sinon, chercher les éléments de liste sur les lignes suivantes
i += 1
while i < lines.len:
let line = lines[i].strip()
if line.startsWith("-"):
let value = line[1..^1].strip()
if value.len > 0:
resultList.add(value)
else:
if not line.isEmptyOrWhitespace():
break
i += 1
break
i += 1
return resultList
proc extractYamlBlock*(content: string): Option[string] =
## Extrait un bloc YAML d'une chaîne de caractères, délimité par des marqueurs "---"
let lines = content.splitLines()
# Vérifier si le contenu commence par un délimiteur YAML
if lines.len < 3 or lines[0] != "---":
return none(string) # Pas de section YAML
var endYamlIndex = -1
# Chercher la fin de la section YAML
for j in 1..<lines.len:
if lines[j] == "---":
endYamlIndex = j
break
if endYamlIndex == -1:
return none(string) # Section YAML mal formatée
# Extraire le bloc YAML sans les délimiteurs
let yamlBlock = lines[1..<endYamlIndex].join("\n")
return some(yamlBlock)
proc extractMetadataFromYaml*(content: string): Option[Metadata] =
## Tente d'extraire les métadonnées YAML du début d'un fichier Markdown
let lines = content.splitLines()
if lines.len < 3 or lines[0] != "---":
return none(Metadata) # Pas de section YAML
var
endYamlIndex = -1
metadata = newMetadata()
i = 1
# Chercher la fin de la section YAML
for j in 1..<lines.len:
if lines[j] == "---":
endYamlIndex = j
break
if endYamlIndex == -1:
return none(Metadata) # Section YAML mal formatée
metadata.isNew = false
# Analyser chaque ligne YAML
i = 1
while i < endYamlIndex:
let line = lines[i].strip()
if line.startsWith("Titre:"):
metadata.title = parseYamlValue(line)
elif line.startsWith("Description:"):
metadata.description = parseYamlValue(line)
elif line.startsWith("tags:"):
i += 1
let res = parseYamlSeq(lines, i)
metadata.tags = res.tags
i = res.endIndex
continue # On a déjà incrémenté i
elif line.startsWith("Date de création:"):
metadata.creationDate = parseYamlValue(line)
elif line.startsWith("Heure de création:"):
metadata.creationTime = parseYamlValue(line)
elif line.startsWith("Date de modification:"):
metadata.modificationDate = parseYamlValue(line)
elif line.startsWith("Heure de modification:"):
metadata.modificationTime = parseYamlValue(line)
elif line.startsWith("Author:"):
metadata.auteur = parseYamlValue(line)
elif line.startsWith("URL:"):
metadata.url = parseYamlValue(line)
elif line.startsWith("Lang:"):
metadata.lang = parseYamlValue(line)
elif line.startsWith("Catégorie:"):
metadata.category = parseYamlValue(line)
i += 1
return some(metadata)
proc analyzeWithAI*(content: string, configPath = "config.json"): AIAnalysisResult =
## Analyse le contenu Markdown avec l'IA et retourne les métadonnées générées
var
appConfig: AppConfig
activeModel: LLMModelConfig
try:
appConfig = loadConfig(configPath)
activeModel = appConfig.getActiveModel()
# Afficher les informations de debug si le mode debug est activé
if appConfig.debugModeActive:
echo fmt"[DEBUG] API URL: {appConfig.apiUrl}"
echo fmt"[DEBUG] Modèle actif: {activeModel.name}"
echo fmt"[DEBUG] activeModelName dans config: {appConfig.activeModelName}"
except Exception as e:
echo fmt"Erreur lors du chargement de la configuration: {e.msg}"
return AIAnalysisResult(title: "", description: "", tags: @[])
let client = newHttpClient()
client.headers = newHttpHeaders({
"Content-Type": "application/json",
"Accept": "application/json"
})
try:
# Construction du corps de la requête
var requestBody = %* {
"model": activeModel.name,
"messages": [
{
"role": activeModel.systemRole,
"content": activeModel.systemContent
},
{
"role": activeModel.userRole,
"content": formatUserContent(activeModel, content)
}
],
"max_tokens": 2048,
"temperature": 0.7,
"frequency_penalty": 0.0,
"presence_penalty": 0.0,
"stream": false,
"stop": []
}
# Afficher la requête complète en mode débogage
if appConfig.debugModeActive:
echo fmt"[DEBUG] Requête: {$requestBody}"
echo fmt"[DEBUG] Envoi de la requête à: {appConfig.apiUrl}"
let
response = client.post(appConfig.apiUrl, body = $requestBody)
responseJson = parseJson(response.body)
if appConfig.debugModeActive:
echo fmt"[DEBUG] Code de réponse: {response.code}"
if response.code == Http200:
if appConfig.debugModeActive:
echo fmt"[DEBUG] Réponse reçue avec succès"
var content = responseJson["choices"][0]["message"]["content"].getStr()
# Nettoyage explicite des caractères d'échappement \n dans la réponse complète
content = content.replace("\\n", "\n")
var
title = ""
description = ""
tags: seq[string] = @[]
# Extraction du bloc YAML
let yamlMatch = content.extractYamlBlock()
if yamlMatch.isSome:
let yaml = yamlMatch.get()
if yaml.len > 0:
# Extraction des valeurs
title = extractYamlValue(yaml, "title")
description = extractYamlValue(yaml, "description")
tags = extractYamlList(yaml, "tags")
if appConfig.debugModeActive:
echo fmt"[DEBUG] Titre extrait: {title}"
echo fmt"[DEBUG] Description extraite: {description}"
echo fmt"[DEBUG] Tags extraits: {tags.len} tags"
return AIAnalysisResult(title: title, description: description, tags: tags)
else:
echo fmt"Erreur lors de l'appel à l'API: {response.code} - {response.body}"
return AIAnalysisResult(title: "", description: "", tags: @[])
except Exception as e:
echo fmt"Exception lors de l'appel à l'API: {e.msg}"
return AIAnalysisResult(title: "", description: "", tags: @[])
finally:
client.close()
proc extractCategoryFromPath*(filePath: string): string =
## Extrait une catégorie basée sur le chemin du fichier
## Format: Parent/Enfant/SousEnfant
if filePath.len == 0:
return "Non classé"
# Normaliser le chemin en utilisant / plutôt que \
var normalizedPath = filePath.replace('\\', '/')
# Obtenir le chemin relatif en supprimant les chemins absolus
let
driveLetterPos = normalizedPath.find(":/")
pathStart = if driveLetterPos != -1: driveLetterPos + 2 else: 0
relativePath = normalizedPath[pathStart..^1]
# Diviser le chemin en parties
var parts = relativePath.split('/')
# Retirer le nom du fichier (dernier élément)
if parts.len > 0 and parts[^1].contains('.'):
discard parts.pop()
# Filtrer les parties vides et les noms de répertoires spéciaux
parts = parts.filterIt(it.len > 0 and it != "." and it != "..")
# Si après filtrage le chemin est vide, renvoyer catégorie par défaut
if parts.len == 0:
return "Non classé"
# Rejoindre les parties avec un /
result = parts.join("/")
proc updateMetadata*(metadata: var Metadata, content: string, filePath: string, useAI: bool = true, forceUpdateCategory: bool = true): bool =
## Complète les métadonnées manquantes
## Retourne vrai si des modifications ont été apportées
##
## Paramètres:
## - metadata: Les métadonnées à mettre à jour
## - content: Le contenu du fichier Markdown
## - filePath: Chemin complet vers le fichier
## - useAI: Utiliser l'IA pour compléter les métadonnées manquantes
## - forceUpdateCategory: Si vrai, remplace toujours la catégorie par celle extraite du chemin
var modified = false
# Utiliser l'IA pour compléter les métadonnées manquantes
if useAI:
if metadata.title == "" or metadata.description == "" or metadata.tags.len == 0:
# Analyser le contenu avec l'IA
let aiResult = analyzeWithAI(content)
# Compléter le titre s'il est manquant
if metadata.title == "" and aiResult.title != "":
metadata.title = aiResult.title
modified = true
# Compléter la description si elle est manquante
if metadata.description == "" and aiResult.description != "":
metadata.description = aiResult.description
modified = true
# Compléter les tags s'ils sont manquants
if metadata.tags.len == 0 and aiResult.tags.len > 0:
# Filtrer pour ne garder que les tags valides et nettoyer les espaces
var cleanedTags: seq[string] = @[]
for tag in aiResult.tags:
let cleanedTag = cleanTagText(tag)
if isValidTag(cleanedTag):
cleanedTags.add(cleanedTag)
metadata.tags = cleanedTags
if metadata.tags.len > 0:
modified = true
# Méthodes traditionnelles si l'IA n'est pas utilisée ou n'a pas fourni de données
if metadata.title == "":
metadata.title = extractTitle(content, filePath)
modified = true
# Obtenir la date de création réelle du fichier
let
fileExists = fileExists(filePath)
currentDateTime = formatCurrentDateTime() # Date/heure actuelle
# Utiliser la date de création réelle du fichier si elle existe
if metadata.creationDate == "" or metadata.creationTime == "":
if fileExists:
try:
let creationTime = getCreationTime(filePath)
let formattedCreationDate = format(creationTime, "yyyy-MM-dd")
let formattedCreationTime = format(creationTime, "HH:mm:ss")
if metadata.creationDate == "":
metadata.creationDate = formattedCreationDate
modified = true
if metadata.creationTime == "":
metadata.creationTime = formattedCreationTime
modified = true
except:
# En cas d'erreur, utiliser la date/heure actuelle comme fallback
if metadata.creationDate == "":
metadata.creationDate = currentDateTime.date
modified = true
if metadata.creationTime == "":
metadata.creationTime = currentDateTime.time
modified = true
else:
# Pour les nouveaux fichiers, utiliser la date/heure actuelle
if metadata.creationDate == "":
metadata.creationDate = currentDateTime.date
modified = true
if metadata.creationTime == "":
metadata.creationTime = currentDateTime.time
modified = true
if metadata.auteur == "":
metadata.auteur = "Non spécifié"
modified = true
if metadata.lang == "":
metadata.lang = "fr"
modified = true
if forceUpdateCategory or metadata.category == "" or metadata.category == "Non classé":
# Utiliser la structure des répertoires comme catégorie
let pathCategory = extractCategoryFromPath(filePath)
echo fmt">>> extractCategoryFromPath pour {filePath} a retourné: {pathCategory}"
# Toujours mettre à jour la catégorie si forceUpdateCategory est true
echo fmt">>> Mise à jour de la catégorie: {metadata.category} -> {pathCategory}"
metadata.category = pathCategory
modified = true
# Nettoyer les tags existants pour éliminer les tags non valides
if metadata.tags.len > 0:
let originalTags = metadata.tags # Garder une copie des tags originaux
var cleanedTags: seq[string] = @[]
for tag in metadata.tags:
let cleanedTag = cleanTagText(tag)
if isValidTag(cleanedTag):
cleanedTags.add(cleanedTag)
# Vérifier si les tags ont été modifiés (nombre différent ou contenu différent)
if originalTags.len != cleanedTags.len or originalTags != cleanedTags:
modified = true
metadata.tags = cleanedTags
# Si des modifications ont été apportées, mettre à jour la date et l'heure de modification
# pour refléter le moment actuel du traitement
if modified:
metadata.modificationDate = currentDateTime.date
metadata.modificationTime = currentDateTime.time
else:
# Si aucune modification n'a été apportée, vérifier si la date/heure de modification est définie
# Si non, utiliser la date de dernière modification du fichier ou la date actuelle
if metadata.modificationDate == "" or metadata.modificationTime == "":
if fileExists:
try:
let modificationTime = getLastModificationTime(filePath)
let formattedModDate = format(modificationTime, "yyyy-MM-dd")
let formattedModTime = format(modificationTime, "HH:mm:ss")
if metadata.modificationDate == "":
metadata.modificationDate = formattedModDate
modified = true # Marquer comme modifié même si on utilise juste la date du fichier
if metadata.modificationTime == "":
metadata.modificationTime = formattedModTime
modified = true # Marquer comme modifié même si on utilise juste l'heure du fichier
except:
# En cas d'erreur, utiliser la date/heure actuelle
if metadata.modificationDate == "":
metadata.modificationDate = currentDateTime.date
modified = true
if metadata.modificationTime == "":
metadata.modificationTime = currentDateTime.time
modified = true
else:
# Pour les nouveaux fichiers, utiliser la date/heure actuelle
if metadata.modificationDate == "":
metadata.modificationDate = currentDateTime.date
modified = true
if metadata.modificationTime == "":
metadata.modificationTime = currentDateTime.time
modified = true
return modified
proc metadataToYaml*(metadata: Metadata): string =
## Convertit les métadonnées en YAML formaté
# Créer les lignes YAML individuellement sans l'opérateur &=
var lines: seq[string] = @["---"]
lines.add(fmt"Titre: {metadata.title}")
lines.add(fmt"Description: {metadata.description}")
lines.add("tags:")
for tag in metadata.tags:
lines.add(fmt" - {tag}")
lines.add(fmt"Date de création: {metadata.creationDate}")
lines.add(fmt"Heure de création: {metadata.creationTime}")
lines.add(fmt"Date de modification: {metadata.modificationDate}")
lines.add(fmt"Heure de modification: {metadata.modificationTime}")
lines.add(fmt"Auteur: {metadata.auteur}")
lines.add(fmt"URL: {metadata.url}")
lines.add(fmt"Lang: {metadata.lang}")
lines.add(fmt"Catégorie: {metadata.category}")
lines.add("---")
# Joindre toutes les lignes avec des retours à la ligne
return lines.join("\n")
proc getContentWithoutYaml*(content: string): string =
## Retourne le contenu sans la section YAML initiale, s'il y en a une
let lines = content.splitLines()
if lines.len >= 2 and lines[0] == "---":
# Chercher la ligne de fin de la section YAML
for i in 1..<lines.len:
if lines[i] == "---":
# Retourner le contenu après la section YAML
return lines[i+1..^1].join("\n")
# Pas de section YAML, retourner le contenu tel quel
return content
proc addMetadataToContent*(metadata: Metadata, content: string): string =
## Ajoute les métadonnées formatées en YAML au début du contenu
## Supprime d'abord toute section YAML existante
let cleanContent = getContentWithoutYaml(content)
return metadataToYaml(metadata) & "\n" & cleanContent
proc isModelAvailable*(config: AppConfig): bool =
## Vérifie si le modèle actif est disponible sur le serveur LM Studio
var client = newHttpClient()
defer: client.close()
client.headers = newHttpHeaders({
"Accept": "application/json"
})
try:
if config.debugModeActive:
echo fmt"[DEBUG] Vérification de la disponibilité des modèles sur: {config.apiMonitorUrl}"
let
response = client.get(config.apiMonitorUrl)
responseJson = parseJson(response.body)
if response.code == Http200:
# Vérifier si le modèle actif figure dans la liste des modèles disponibles
var activeModel = config.activeModelName
# Vérifier si la réponse contient une clé "data" avec un tableau de modèles
if responseJson.hasKey("data") and responseJson["data"].kind == JArray:
let modelsArray = responseJson["data"]
for modelObj in modelsArray:
if modelObj.hasKey("id") and modelObj["id"].getStr() == activeModel:
echo fmt"Modèle '{activeModel}' disponible sur le serveur"
return true
# Si nous arrivons ici, le modèle n'a pas été trouvé
echo fmt"Modèle '{activeModel}' non disponible sur le serveur."
return false
else:
echo "Format de réponse inattendu de l'API."
return false
else:
echo fmt"Erreur lors de la connexion à l'API: {response.code}"
return false
except Exception as e:
echo fmt"Exception lors de la vérification du modèle: {e.msg}"
return false