
J’ai construit un pipeline d’IA pour les points forts du Kindle
Je lis, j’aime surligner des choses (j’utilise un Kindle). J’ai l’impression qu’en lisant, je n’arrive pas à retenir plus de 10 % des informations que je consomme, mais c’est en relisant les points forts ou en résumant le livre que les utiliser est ce qui me fait réellement comprendre ce que je lis.
Le problème est que, parfois, je finis par mettre beaucoup de choses en valeur.
Et par beaucoup, je veux dire BEAUCOUP. Nous ne pouvons même pas les appeler des « notes clés ».
Donc, dans ces cas-là, après avoir lu le livre, je finis par perdre beaucoup de temps à résumer ou simplement arrêter de le faire (cette dernière solution est la plus courante).
J’ai lu récemment un livre que j’ai beaucoup apprécié et j’aimerais retenir pleinement ce qui m’a le plus marqué. Mais, encore une fois, c’était l’un de ces livres que j’ai sur-accentués.
Et je ne voulais pas y consacrer une grande partie de mon peu de temps libre. J’ai donc décidé d’automatiser le processus et d’utiliser mes compétences en technologie/données. Parce que je suis satisfait du résultat, j’ai pensé le partager afin que toute personne intéressée puisse également profiter de cet outil.
Avertissement : mon Kindle est assez ancien, cela devrait donc également fonctionner sur les nouveaux. En fait, il existe une approche légèrement meilleure pour les nouvelles versions Kindle (expliquée également dans cet article).
Le projet
Définissons l’objectif : générer un résumé à partir de nos points forts Kindle.
En y réfléchissant, j’ai imaginé le pipeline simple suivant pour un seul livre :
- Obtenez les faits saillants du livre
- Créez un RAG ou quelque chose de similaire
- Exporter le résumé
Le résultat est différent sur la première partie, mais tout cela est dû au prétraitement nécessaire compte tenu de la façon dont les données sont structurées.
Je vais donc structurer cet article en deux sections principales :
- Récupération et traitement des données
- Modèle d’IA et résultat
1. Récupération et traitement des données
Mon intuition m’a dit qu’il existait un moyen d’extraire les points forts de mon Kindle. En fin de compte, ils sont stockés là-bas, donc j’ai juste besoin d’un moyen de les sortir.
Il existe plusieurs façons de procéder mais je voulais une approche qui fonctionne à la fois avec les livres achetés sur la boutique officielle Kindle mais aussi avec les PDF ou les fichiers que j’ai envoyés depuis mon ordinateur portable.
Et j’ai également décidé de n’utiliser aucun logiciel existant pour extraire les données. Juste mon ebook et mon ordinateur portable (et un port USB les connectant tous les deux).
Heureusement pour nous, aucun jailbreak n’est nécessaire et il existe deux manières de le faire en fonction de votre version Kindle :
- Tous les Kindle ont (vraisemblablement) un fichier dans le dossier de documents nommé Mes coupures.txt. Il contient littéralement toutes les coupures que vous avez faites à tout moment et dans n’importe quel livre.
- Les nouveaux Kindle ont également un fichier SQLite dans le répertoire système nommé annotations.db. Cela présente vos points forts de manière plus structurée.
Dans cet article, j’utiliserai la méthode 1 (My Clippings.txt) principalement parce que mon Kindle ne dispose pas de la base de données annotations.db. Mais si vous avez la chance de disposer de la base de données, utilisez-la car elle sera plus simple et de meilleure qualité (la plupart des prétraitements que nous verrons ensuite ne seront probablement pas nécessaires).
Obtenir les coupures est donc aussi simple que de lire le TXT. Voici quelques aspects et problèmes clés que j’ai rencontrés en utilisant cette méthode :
- Tous les livres sont sur le même fichier.
- Je ne suis pas sûr de la définition exacte du « détourage » du côté d’Amazon, mais d’après ce que j’ai vu, c’est : tout ce que vous mettez en évidence à tout moment. Même si vous le supprimez ou l’étendez, l’original restera dans le TXT. Je suppose que c’est le cas car, en effet, nous travaillons avec un fichier TXT et il est très difficile de supprimer des éléments qui ne sont en aucun cas indexés.
- Il y a une limite au détourage : je ne connais pas le seuil exact mais une fois qu’on le franchit, on ne peut plus récupérer de coupures. Ceci est dû au fait que quelqu’un pourrait autrement mettre en évidence le livre complet, l’extraire et le partager illégalement.
Et voici l’anatomie d’une coupure :
==========
Book Name (Author Name)
- Your Highlight on page 145 | Location 2212-2212 | Added on Sunday, August 30, 2020 11:25:29 PM
transparency problem ends up in the same place as
==========
La première étape consiste donc à analyser les points forts, et c’est là que nous commençons à voir le code Python :
def parse_clippings(file_path):
raw = Path(file_path).read_text(encoding="utf-8")
entries = raw.split("==========")
highlights = []
for entry in entries:
lines = [l.strip() for l in entry.strip().split("\n") if l.strip()]
if len(lines) < 3:
continue
book = lines[0]
if "Highlight" not in lines[1]:
continue
location_match = re.search(r"Location (\d+)", lines[1])
if not location_match:
continue
location = int(location_match.group(1))
text = " ".join(lines[2:]).strip()
highlights.append(
{
"book": book,
"location": location,
"text": text
}
)
return highlights
Étant donné le chemin du fichier de coupures, cette fonction ne fait que diviser le texte en différentes entrées, puis les parcourir en boucle. Pour chaque entrée, il extrait le nom du titre, l’emplacement et le texte en surbrillance.
Cette structure finale (une liste de dictionnaires) permet de filtrer facilement par livre :
[
h for h in highlights
if book_name.lower() in h["book"].lower()
]
Une fois filtrés, il faut ordonner les faits saillants. Étant donné que les coupures sont ajoutées au fichier TXT, l’ordre est basé sur le moment où nous mettons en surbrillance, et non sur l’emplacement du texte.
Et personnellement, je souhaite que mes résultats apparaissent tels qu’ils le font dans le livre, il est donc nécessaire de les classer :
sorted(highlights, key=lambda x: x["location"])
Maintenant, si vous vérifiez votre fichier de coupures, vous pourriez trouver des coupures dupliquées (ou des sous-coupures dupliquées). Cela se produit parce que chaque fois que vous modifiez un surlignage (que vous n’avez pas réussi à inclure tous les mots que vous visiez, par exemple), il est comptabilisé comme un nouveau. Il y aura donc deux coupures très similaires dans le TXT. Ou même plus si vous le modifiez plusieurs fois.
Nous devons gérer cela en appliquant une déduplication d’une manière ou d’une autre. C’est plus facile que prévu :
def deduplicate(highlights):
clean = []
for h in highlights:
text = h["text"]
duplicate = False
for c in clean:
if text == c["text"]:
duplicate = True
break
if text in c["text"]:
duplicate = True
break
if c["text"] in text:
c["text"] = text
duplicate = True
break
if not duplicate:
clean.append(h)
return clean
C’est très simple et pourrait être perfectionné, mais nous vérifions essentiellement s’il existe des coupures consécutives avec le même texte (ou une partie de celui-ci) et gardons le plus long.
À l’heure actuelle, les faits saillants du livre sont correctement triés et nous pourrions arrêter le prétraitement ici. Mais je ne peux pas faire ça. J’aime mettre en évidence les titres à chaque fois car, lors du résumé, j’attribue correctement une section à chaque surbrillance.
Mais notre code n’est pas capable de faire la différence entre un véritable point culminant et un titre de section… Pour l’instant. Voir ci-dessous:
def is_probable_title(text):
text = text.strip()
if len(text) > 120:
return False
if text.endswith("."):
return False
words = text.split()
if len(words) > 12:
return False
# chapter style prefix
if has_chapter_prefix(text):
return True
# capitalization ratio
capitalized = sum(
1 for w in words if w[0].isupper()
)
cap_ratio = capitalized / len(words)
# stopword ratio
stopword_count = sum(
1 for w in words if w.lower() in STOPWORDS
)
stop_ratio = stopword_count / len(words)
score = 0
if cap_ratio > 0.6:
score += 1
if stop_ratio < 0.3:
score += 1
if len(words) <= 6:
score += 1
return score >= 2
Cela peut sembler assez arbitraire et ce n’est pas la meilleure solution à ce problème, mais cela fonctionne plutôt bien. Il utilise une heuristique basée sur la majuscule, la longueur, les mots vides et les préfixes.
Cette fonction est appelée dans une boucle passant par toutes les surbrillance, comme nous l’avons vu dans les fonctions précédentes, pour vérifier si une surbrillance est un titre ou non. Le résultat est une liste de « sections » de dictionnaires où le dictionnaire a deux clés :
- Titre : le titre de la section.
- Highlights : les temps forts de la section.
Pour le moment, oui, nous sommes prêts à résumer.
Modèle d’IA et résultats
Je voulais que ce soit un projet gratuit, nous avons donc besoin d’un modèle d’IA open source.
Je pensais qu’Ollama [1] était l’une des meilleures options pour gérer un projet comme celui-ci (au moins localement). De plus, nos données restent toujours les nôtres et nous pouvons exécuter les modèles hors ligne.
Une fois installé, le code était simple. Je ne suis pas un ingénieur rapide, donc toute personne possédant le savoir-faire obtiendrait des résultats encore meilleurs, mais voici ce qui fonctionne pour moi :
def summarize_with_ollama(text, model):
prompt = f"""
You are summarizing a book from reader highlights.
Produce a structured summary with:
- Main thesis
- Brief summary
- Key ideas
- Important concepts
- Practical takeaways
Highlights:
{text}
"""
result = subprocess.run(
["ollama", "run", model],
input=prompt,
text=True,
capture_output=True
)
return result.stdout
Simple, je sais. Mais cela fonctionne en partie parce que le prétraitement des données a été intense, mais aussi parce que nous exploitons déjà les modèles élaborés.
Mais que fait-on du résumé ? J’aime utiliser Obsidian [2] donc exporter un fichier Markdown est ce qui a plus de sens. Voilà, vous l’avez :
def export_markdown(book, sections, summary, output):
md = f"# {book}\n\n"
for section in sections:
md += f"## {section['title']}\n\n"
for h in section["highlights"]:
md += f"- {h}\n"
md += "\n"
md += "\n---\n\n"
md += "## Book Summary\n\n"
md += summary
output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(md, encoding="utf-8")
print(f"\nSaved to {output_path}")
Et voilà.
Et c’est ainsi que je passe des faits saillants à un résumé Markdown complet (directement à Obsidian si je veux) avec moins de 300 lignes de code Python !
Code complet et test
Voici le code complet, juste au cas où vous souhaiteriez le copier-coller. Il contient ce que nous avons vu ainsi que quelques fonctions d’assistance et analyse d’arguments :
import re
import argparse
from pathlib import Path
import subprocess
# ---------- PARSE CLIPPINGS ----------
def parse_clippings(file_path):
raw = Path(file_path).read_text(encoding="utf-8")
entries = raw.split("==========")
highlights = []
for entry in entries:
lines = [l.strip() for l in entry.strip().split("\n") if l.strip()]
if len(lines) < 3:
continue
book = lines[0]
if "Highlight" not in lines[1]:
continue
location_match = re.search(r"Location (\d+)", lines[1])
if not location_match:
continue
location = int(location_match.group(1))
text = " ".join(lines[2:]).strip()
highlights.append(
{
"book": book,
"location": location,
"text": text
}
)
return highlights
# ---------- FILTER BOOK ----------
def filter_book(highlights, book_name):
return [
h for h in highlights
if book_name.lower() in h["book"].lower()
]
# ---------- SORT ----------
def sort_by_location(highlights):
return sorted(highlights, key=lambda x: x["location"])
# ---------- DEDUPLICATE ----------
def deduplicate(highlights):
clean = []
for h in highlights:
text = h["text"]
duplicate = False
for c in clean:
if text == c["text"]:
duplicate = True
break
if text in c["text"]:
duplicate = True
break
if c["text"] in text:
c["text"] = text
duplicate = True
break
if not duplicate:
clean.append(h)
return clean
# ---------- TITLE DETECTION ----------
STOPWORDS = {
"the","and","or","but","of","in","on","at","for","to",
"is","are","was","were","be","been","being",
"that","this","with","as","by","from"
}
def has_chapter_prefix(text):
return bool(
re.match(
r"^(chapter|part|section)\s+\d+|^\d+[\.\)]|^[ivxlcdm]+\.",
text.lower()
)
)
def is_probable_title(text):
text = text.strip()
if len(text) > 120:
return False
if text.endswith("."):
return False
words = text.split()
if len(words) > 12:
return False
# chapter style prefix
if has_chapter_prefix(text):
return True
# capitalization ratio
capitalized = sum(
1 for w in words if w[0].isupper()
)
cap_ratio = capitalized / len(words)
# stopword ratio
stopword_count = sum(
1 for w in words if w.lower() in STOPWORDS
)
stop_ratio = stopword_count / len(words)
score = 0
if cap_ratio > 0.6:
score += 1
if stop_ratio < 0.3:
score += 1
if len(words) <= 6:
score += 1
return score >= 2
# ---------- GROUP SECTIONS ----------
def group_by_sections(highlights):
sections = []
current = {
"title": "Introduction",
"highlights": []
}
for h in highlights:
text = h["text"]
if is_probable_title(text):
sections.append(current)
current = {
"title": text,
"highlights": []
}
else:
current["highlights"].append(text)
sections.append(current)
return sections
# ---------- SUMMARY ----------
# ---------- EXPORT MARKDOWN ----------
def export_markdown(book, sections, summary, output):
md = f"# {book}\n\n"
for section in sections:
md += f"## {section['title']}\n\n"
for h in section["highlights"]:
md += f"- {h}\n"
md += "\n"
md += "\n---\n\n"
md += "## Book Summary\n\n"
md += summary
output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(md, encoding="utf-8")
print(f"\nSaved to {output_path}")
# ---------- MAIN ----------
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--book", required=True)
parser.add_argument("--output", required=False, default=None)
parser.add_argument(
"--clippings",
default="Data/My Clippings.txt"
)
parser.add_argument(
"--model",
default="mistral"
)
args = parser.parse_args()
highlights = parse_clippings(args.clippings)
highlights = filter_book(highlights, args.book)
highlights = sort_by_location(highlights)
highlights = deduplicate(highlights)
sections = group_by_sections(highlights)
all_text = "\n".join(
h["text"] for h in highlights
)
summary = summarize_with_ollama(all_text, args.model)
if args.output:
export_markdown(
args.book,
sections,
summary,
args.output
)
else:
print("\n---- HIGHLIGHTS ----\n")
for h in highlights:
print(f"{h['text']}\n")
print("\n---- SUMMARY ----\n")
print(summary)
if __name__ == "__main__":
main()
Mais voyons comment ça marche ! Le code lui-même est utile mais je parie que vous êtes prêt à voir les résultats. C’est long, j’ai donc décidé de supprimer la première partie car elle ne fait que copier-coller les surbrillances.
J’ai choisi au hasard un livre que j’ai lu il y a environ 6 ans (2020) intitulé Talking to Strangers de Malcolm Gladwell (un best-seller, à lire assez agréablement). Voir la sortie imprimée du modèle (pas le Markdown) :
$ python3 kindle_summary.py --book "Talking to Strangers"
---- HIGHLIGHTS ----
...
---- SUMMARY ----
Title: Talking to Strangers: What We Should Know About Human Interaction
Main Thesis: The book explores the complexities and paradoxes of human
interaction, particularly in conversations with strangers, and emphasizes
the importance of caution, humility, and understanding the context in
which these interactions occur.
Brief Summary: The author delves into the misconceptions and shortcomings
in our dealings with strangers, focusing on how we often make incorrect
assumptions about others based on limited information or preconceived
notions. The book offers insights into why this happens, its consequences,
and strategies for improving our ability to understand and communicate
effectively with people we don't know.
Key Ideas:
1. The transparency problem and the default-to-truth problem: People often
assume that others are open books, sharing their true emotions and
intentions, when in reality this is not always the case.
2. Coupling: Behaviors are strongly linked to specific circumstances and
conditions, making it essential to understand the context in which a
stranger operates.
3. Limitations of understanding strangers: There is no perfect mechanism
for peering into the minds of those we do not know, emphasizing the need
for restraint and humility when interacting with strangers.
Important Concepts:
1. Emotional responses falling outside expectations
2. Defaulting to truth
3. Transparency as an illusion
4. Contextual understanding in dealing with strangers
5. The paradox of talking to strangers (need versus terribleness)
6. The phenomenon of coupling and its influence on behavior
7. Blaming the stranger when things go awry
Practical Takeaways:
1. Recognize that people may not always appear as they seem, both
emotionally and behaviorally.
2. Understand the importance of context in interpreting strangers'
behaviors and intentions.
3. Be cautious and humble when interacting with strangers, acknowledging
our limitations in understanding them fully.
4. Avoid jumping to conclusions about strangers based on limited
information or preconceived notions.
5. Accept that there will always be some degree of ambiguity and
complexity in dealing with strangers.
6. Avoid penalizing others for defaulting to truth as a defense mechanism.
7. When interactions with strangers go awry, consider the role one might
have played in contributing to the situation rather than solely blaming
the stranger.
Et tout cela en quelques secondes. Assez cool à mon avis.
Conclusion
Et c’est essentiellement ainsi que je gagne désormais beaucoup de temps libre (que je peux utiliser pour écrire des articles comme celui-ci) en tirant parti de mes compétences en matière de données et de l’IA.
J’espère que vous avez apprécié la lecture et que vous vous êtes senti motivé pour l’essayer ! Ce ne sera pas mieux que le résumé que vous rédigeriez avec votre propre perception du livre… Mais ce ne sera pas loin de là !
Merci de votre attention, n’hésitez pas à commenter si vous avez des idées ou des suggestions !
Ressources
[1] Ollama. (sd). Ollama. https://ollama.com
[2] Obsidienne. (sd). Obsidienne. https://obsidienne.md



