adding AI integration
This commit is contained in:
1 parent
068f84bf1d
commit
699cb3eea9
7 files changed
+764
-12
No files matched your search
@@ -64,15 +64,39 @@ et de l'accumuler dans un PDF, au fil de la navigation.
|
||||
cliquable vers chaque section — liens internes PDF standard (annotations
|
||||
`/Link` avec destination directe), utilisables dans n'importe quel
|
||||
lecteur PDF.
|
||||
- **Détection de liens de "suite"** : les liens dont le texte ressemble à
|
||||
*suivant / next / chapitre suivant / lire la suite / part 2* etc. (ou une
|
||||
balise `<link rel="next">`) sont surlignés en orange et listés dans le
|
||||
panneau, pour enchaîner facilement la lecture et la capture sans les
|
||||
chercher manuellement. La détection tourne en continu (`MutationObserver`
|
||||
JS, résultats poussés vers Python via le pont QWebChannel) plutôt qu'une
|
||||
seule fois au chargement — nécessaire car beaucoup de sites (SPA,
|
||||
pagination injectée après coup en JavaScript) ajoutent ce genre de lien
|
||||
bien après le signal de fin de chargement.
|
||||
- **Détection de liens de "suite"** : les liens dont le texte — ou le texte
|
||||
d'un conteneur voisin proche (icône + légende, ex. "Partie Suivante" à
|
||||
côté du lien) — ressemble à *suivant / next / chapitre suivant / lire la
|
||||
suite / part 2* etc. (ou une balise `<link rel="next">`) sont surlignés
|
||||
en orange et listés dans le panneau, pour enchaîner facilement la
|
||||
lecture et la capture sans les chercher manuellement. La détection
|
||||
tourne en continu (`MutationObserver` JS, résultats poussés vers Python
|
||||
via le pont QWebChannel) plutôt qu'une seule fois au chargement —
|
||||
nécessaire car beaucoup de sites (SPA, pagination injectée après coup en
|
||||
JavaScript) ajoutent ce genre de lien bien après le signal de fin de
|
||||
chargement.
|
||||
- **Scraping assisté par IA locale** (llama-cpp-python, aucune donnée
|
||||
envoyée en ligne) : configurez un modèle via *"⚙️ Configurer l'IA
|
||||
locale"* (fichier `.gguf` de votre choix — non fourni avec l'appli,
|
||||
liens de téléchargement vérifiés dans la section
|
||||
[Installation](#modèles-gguf-recommandés) ci-dessous). Trois usages,
|
||||
combinables :
|
||||
- **🤖 Extraire (IA)** : demande au modèle d'extraire le texte narratif
|
||||
principal de la page (menus/pubs/commentaires écartés) et l'ajoute au
|
||||
PDF — alternative à la sélection manuelle/au mode survol, utile sur
|
||||
les pages au contenu trop fragmenté pour un clic unique.
|
||||
- **🤖 Lien suivant (IA)** : demande au modèle de désigner, parmi *tous*
|
||||
les liens de la page, celui qui mène le plus probablement à la suite —
|
||||
complète la détection heuristique quand celle-ci ne trouve rien
|
||||
(structures de page trop inhabituelles pour un motif texte).
|
||||
- **🔁 Scraping automatique (IA)** : combine les deux en boucle —
|
||||
extrait, capture, trouve et suit le lien suivant, répète — jusqu'à un
|
||||
nombre de chapitres maximum fixé au démarrage (garde-fou demandé à
|
||||
chaque lancement) ou jusqu'à ce qu'aucune suite ne soit trouvée ; une
|
||||
détection de boucle (retour vers une page déjà visitée) arrête aussi
|
||||
le processus automatiquement. Bouton "⏹ Arrêter" disponible à tout
|
||||
moment. L'inférence tourne sur un thread dédié pour ne jamais geler
|
||||
l'interface.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -88,6 +112,41 @@ pip install -r requirements.txt
|
||||
python run.py
|
||||
```
|
||||
|
||||
`llama-cpp-python` compile une extension native au moment de l'installation
|
||||
(pas de wheel précompilé pour toutes les configurations) — l'installation
|
||||
peut donc prendre plusieurs minutes selon la machine. Les fonctionnalités
|
||||
IA restent optionnelles : sans modèle configuré, le reste de l'application
|
||||
fonctionne normalement (les boutons IA affichent un message clair invitant
|
||||
à configurer un modèle plutôt que d'échouer silencieusement).
|
||||
|
||||
### IA locale (optionnel)
|
||||
|
||||
1. Téléchargez un modèle au format **GGUF** (liens vérifiés ci-dessous —
|
||||
le fichier n'est pas fourni avec l'appli, à télécharger une seule fois).
|
||||
2. Dans PdfScrapper, ouvrez *"⚙️ Configurer l'IA locale"* et indiquez le
|
||||
chemin du fichier `.gguf`.
|
||||
3. Les boutons *"🤖 Extraire (IA)"*, *"🤖 Lien suivant (IA)"* et
|
||||
*"🔁 Scraping automatique (IA)"* sont alors utilisables.
|
||||
|
||||
#### Modèles GGUF recommandés
|
||||
|
||||
Existence, nom de fichier exact et taille vérifiés au moment de la
|
||||
rédaction. Prendre la variante **Q4_K_M** (bon compromis qualité/vitesse/
|
||||
taille pour du CPU) sauf indication contraire — c'est le lien direct
|
||||
donné ci-dessous.
|
||||
|
||||
| Modèle | Taille (Q4_K_M) | Pourquoi celui-ci | Téléchargement |
|
||||
|---|---|---|---|
|
||||
| **Qwen3-4B-Instruct-2507** (recommandé par défaut) | ~2,5 Go | Le plus récent de la liste, très bon suivi d'instructions (utile pour la sortie JSON du choix de lien) et bon en français, reste rapide sur CPU | [lien direct](https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF/resolve/main/Qwen3-4B-Instruct-2507-Q4_K_M.gguf) · [page du modèle](https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF) |
|
||||
| **Qwen2.5-3B-Instruct** (le plus léger) | ~1,9 Go | Le plus petit/rapide de la liste, correct en français, suffisant pour dépanner sur une machine modeste | [lien direct](https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF/resolve/main/Qwen2.5-3B-Instruct-Q4_K_M.gguf) · [page du modèle](https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF) |
|
||||
| **Mistral-7B-Instruct-v0.3** (meilleur français) | ~4,4 Go | Modèle Mistral AI (français), référence historique en qualité de français ; demande plus de RAM/CPU | [lien direct](https://huggingface.co/bartowski/Mistral-7B-Instruct-v0.3-GGUF/resolve/main/Mistral-7B-Instruct-v0.3-Q4_K_M.gguf) · [page du modèle](https://huggingface.co/bartowski/Mistral-7B-Instruct-v0.3-GGUF) |
|
||||
| **Llama-3.2-3B-Instruct** (alternative légère) | ~2,0 Go | Généraliste léger, bonne alternative si Qwen ne convient pas | [lien direct](https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf) · [page du modèle](https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF) |
|
||||
| **Qwen2.5-7B-Instruct** (meilleure qualité) | ~4,7 Go | Si ≥ 8 Go de RAM disponibles et qu'on veut la meilleure qualité d'extraction | [lien direct](https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf) · [page du modèle](https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF) |
|
||||
|
||||
Pour une machine avec peu de RAM, préférer les modèles 3-4B. Pour plus de
|
||||
qualité (et si le CPU/la RAM suit), les 7B donnent de meilleurs résultats
|
||||
sur l'extraction de contenu comme sur le choix du lien suivant.
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
@@ -100,8 +159,12 @@ pdfscrapper/
|
||||
pdf_style.py # dataclass PdfStyle (réglages de mise en forme du PDF)
|
||||
pdf_builder.py # génération du PDF (reportlab) : style, fond, sommaire/liens
|
||||
preview_window.py # fenêtre d'aperçu en direct (QtPdf), style et correction du texte
|
||||
models.py # dataclass Clip (un extrait capturé)
|
||||
run.py # lance l'application
|
||||
ai_config.py # dataclass AiConfig, persistée via QSettings
|
||||
ai_settings_dialog.py # fenêtre de configuration du modèle IA local (.gguf)
|
||||
ai_worker.py # exécution des appels IA sur un thread dédié (non bloquant)
|
||||
local_ai.py # inférence llama-cpp-python : extraction + choix du lien suivant
|
||||
models.py # dataclass Clip (un extrait capturé)
|
||||
run.py # lance l'application
|
||||
```
|
||||
|
||||
## Pistes d'évolution
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Configuration de l'IA locale, persistée via QSettings (registre Windows
|
||||
/ fichier ini selon l'OS — mécanisme standard Qt, pas de fichier à gérer
|
||||
nous-mêmes).
|
||||
|
||||
Le modèle (fichier .gguf) n'est jamais embarqué dans le dépôt : trop
|
||||
volumineux, et le choix dépend du matériel de chacun. L'utilisateur pointe
|
||||
vers son propre fichier via la fenêtre de configuration (voir
|
||||
ai_settings_dialog.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PySide6.QtCore import QSettings
|
||||
|
||||
_ORG = "PdfScrapper"
|
||||
_APP = "PdfScrapper"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AiConfig:
|
||||
model_path: str = ""
|
||||
n_ctx: int = 4096
|
||||
n_threads: int = 0 # 0 = auto (os.cpu_count())
|
||||
max_tokens: int = 800
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.model_path) and os.path.isfile(self.model_path)
|
||||
|
||||
|
||||
def load_ai_config() -> AiConfig:
|
||||
settings = QSettings(_ORG, _APP)
|
||||
settings.beginGroup("local_ai")
|
||||
try:
|
||||
config = AiConfig(
|
||||
model_path=settings.value("model_path", "", type=str),
|
||||
n_ctx=settings.value("n_ctx", 4096, type=int),
|
||||
n_threads=settings.value("n_threads", 0, type=int),
|
||||
max_tokens=settings.value("max_tokens", 800, type=int),
|
||||
)
|
||||
finally:
|
||||
settings.endGroup()
|
||||
return config
|
||||
|
||||
|
||||
def save_ai_config(config: AiConfig) -> None:
|
||||
settings = QSettings(_ORG, _APP)
|
||||
settings.beginGroup("local_ai")
|
||||
try:
|
||||
settings.setValue("model_path", config.model_path)
|
||||
settings.setValue("n_ctx", config.n_ctx)
|
||||
settings.setValue("n_threads", config.n_threads)
|
||||
settings.setValue("max_tokens", config.max_tokens)
|
||||
finally:
|
||||
settings.endGroup()
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Fenêtre de configuration de l'IA locale : chemin du modèle .gguf et
|
||||
quelques paramètres d'inférence de base."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .ai_config import AiConfig
|
||||
|
||||
|
||||
class AiSettingsDialog(QDialog):
|
||||
def __init__(self, config: AiConfig, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Configurer l'IA locale")
|
||||
self.resize(520, 260)
|
||||
self.config = config
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
info = QLabel(
|
||||
"Le scraping assisté par IA tourne entièrement en local via "
|
||||
"llama-cpp-python : aucune donnée n'est envoyée en ligne.\n"
|
||||
"Indiquez le chemin d'un modèle au format GGUF (par ex. un petit "
|
||||
"modèle instruct type Qwen2.5-3B-Instruct ou Llama-3.2-3B-Instruct, "
|
||||
"téléchargé au préalable depuis Hugging Face)."
|
||||
)
|
||||
info.setWordWrap(True)
|
||||
layout.addWidget(info)
|
||||
|
||||
form = QFormLayout()
|
||||
|
||||
path_row = QWidget()
|
||||
path_layout = QHBoxLayout(path_row)
|
||||
path_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.model_path_edit = QLineEdit(config.model_path)
|
||||
browse_btn = QPushButton("Parcourir…")
|
||||
browse_btn.clicked.connect(self._browse)
|
||||
path_layout.addWidget(self.model_path_edit)
|
||||
path_layout.addWidget(browse_btn)
|
||||
form.addRow("Fichier modèle (.gguf)", path_row)
|
||||
|
||||
self.n_ctx_spin = QSpinBox()
|
||||
self.n_ctx_spin.setRange(512, 32768)
|
||||
self.n_ctx_spin.setSingleStep(512)
|
||||
self.n_ctx_spin.setValue(config.n_ctx)
|
||||
self.n_ctx_spin.setToolTip("Taille de la fenêtre de contexte (tokens).")
|
||||
form.addRow("Contexte (n_ctx)", self.n_ctx_spin)
|
||||
|
||||
self.n_threads_spin = QSpinBox()
|
||||
self.n_threads_spin.setRange(0, 64)
|
||||
self.n_threads_spin.setValue(config.n_threads)
|
||||
self.n_threads_spin.setToolTip("0 = détection automatique du nombre de threads CPU.")
|
||||
form.addRow("Threads CPU (0 = auto)", self.n_threads_spin)
|
||||
|
||||
self.max_tokens_spin = QSpinBox()
|
||||
self.max_tokens_spin.setRange(64, 4096)
|
||||
self.max_tokens_spin.setSingleStep(64)
|
||||
self.max_tokens_spin.setValue(config.max_tokens)
|
||||
self.max_tokens_spin.setToolTip("Longueur maximale par défaut d'une réponse générée.")
|
||||
form.addRow("Tokens max. par réponse", self.max_tokens_spin)
|
||||
|
||||
layout.addLayout(form)
|
||||
layout.addStretch(1)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def _browse(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Choisir un modèle GGUF", self.model_path_edit.text(), "Modèles GGUF (*.gguf)"
|
||||
)
|
||||
if path:
|
||||
self.model_path_edit.setText(path)
|
||||
|
||||
def updated_config(self) -> AiConfig:
|
||||
return AiConfig(
|
||||
model_path=self.model_path_edit.text().strip(),
|
||||
n_ctx=self.n_ctx_spin.value(),
|
||||
n_threads=self.n_threads_spin.value(),
|
||||
max_tokens=self.max_tokens_spin.value(),
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Exécution des appels IA locale en arrière-plan.
|
||||
|
||||
L'inférence llama-cpp-python est bloquante et peut prendre plusieurs
|
||||
secondes (voire dizaines de secondes pour le premier appel, le temps de
|
||||
charger le modèle) — elle ne doit jamais tourner sur le thread Qt
|
||||
principal sous peine de geler toute l'interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
|
||||
class AiTaskThread(QThread):
|
||||
"""Exécute `fn(*args, **kwargs)` sur un thread dédié et remonte le
|
||||
résultat (ou l'erreur) via un signal Qt, thread-safe par construction."""
|
||||
|
||||
succeeded = Signal(object)
|
||||
failed = Signal(str)
|
||||
|
||||
def __init__(self, fn: Callable, *args, **kwargs) -> None:
|
||||
super().__init__()
|
||||
self._fn = fn
|
||||
self._args = args
|
||||
self._kwargs = kwargs
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
result = self._fn(*self._args, **self._kwargs)
|
||||
except Exception as exc: # noqa: BLE001 — remonté proprement à l'UI
|
||||
self.failed.emit(str(exc))
|
||||
else:
|
||||
self.succeeded.emit(result)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Intégration IA locale (llama-cpp-python) pour assister le scraping.
|
||||
|
||||
Deux capacités, combinables :
|
||||
- pick_next_link : désigne, parmi les liens candidats d'une page, celui
|
||||
qui mène le plus probablement à la suite de l'histoire/du chapitre —
|
||||
en complément (ou remplacement, sur les pages où il échoue) de
|
||||
l'heuristique regex de link_detector.py, qui reste rapide/gratuite
|
||||
mais fragile face aux structures de page inhabituelles.
|
||||
- extract_main_content : extrait le texte narratif principal d'une page
|
||||
(mode lecture), en écartant menus/pubs/commentaires — une alternative
|
||||
au mode "capture de bloc au survol" quand le contenu est trop
|
||||
fragmenté pour être capturé en un clic.
|
||||
|
||||
Le modèle (fichier .gguf) n'est pas embarqué dans ce dépôt — l'utilisateur
|
||||
fournit son propre fichier via la fenêtre de configuration IA (voir
|
||||
ai_settings_dialog.py). Le chargement est paresseux (au premier appel
|
||||
réel) et mis en cache : un modèle de quelques Go peut prendre plusieurs
|
||||
secondes à charger, d'où l'exécution systématique en arrière-plan côté
|
||||
appelant (voir ai_worker.py) pour ne jamais geler l'interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .ai_config import AiConfig
|
||||
|
||||
|
||||
class LocalAiError(RuntimeError):
|
||||
"""Erreur lisible, destinée à être affichée telle quelle à l'utilisateur."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkCandidate:
|
||||
text: str
|
||||
href: str
|
||||
|
||||
|
||||
_NEXT_LINK_SYSTEM_PROMPT = (
|
||||
"Tu aides à identifier, parmi une liste de liens présents sur une page web, "
|
||||
"celui qui mène le plus probablement à la SUITE de l'histoire ou du chapitre "
|
||||
"actuellement affiché (par ex. la partie/le chapitre suivant). Si aucun lien "
|
||||
"ne semble mener à une suite (ex. ce sont des liens de menu, de pub, ou vers "
|
||||
"d'autres histoires sans rapport), réponds qu'aucun ne convient.\n\n"
|
||||
"Réponds STRICTEMENT avec un objet JSON sur une seule ligne, de la forme "
|
||||
'{"index": <numéro du lien choisi>} ou {"index": null}. Aucun autre texte.'
|
||||
)
|
||||
|
||||
_EXTRACT_SYSTEM_PROMPT = (
|
||||
"Tu extrais le texte narratif principal (l'histoire/l'article) d'un texte brut "
|
||||
"issu d'une page web, qui contient probablement aussi du texte hors-sujet "
|
||||
"(menus, publicités, commentaires, liens de navigation, avertissements). "
|
||||
"Renvoie UNIQUEMENT ce texte narratif, tel quel, sans le résumer, le traduire "
|
||||
"ni le commenter, en conservant sa mise en forme en paragraphes. Ne renvoie "
|
||||
"rien d'autre : pas de préambule, pas d'explication."
|
||||
)
|
||||
|
||||
|
||||
class LocalAi:
|
||||
"""Enveloppe autour de llama-cpp-python, avec chargement paresseux du modèle."""
|
||||
|
||||
def __init__(self, config: AiConfig) -> None:
|
||||
self.config = config
|
||||
self._llm = None # instancié à la demande, voir _ensure_loaded()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Invalide le modèle chargé (ex. après changement de configuration)."""
|
||||
self._llm = None
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return self.config.is_configured
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Chargement du modèle
|
||||
# ------------------------------------------------------------------ #
|
||||
def _ensure_loaded(self):
|
||||
if self._llm is not None:
|
||||
return self._llm
|
||||
|
||||
if not self.config.model_path:
|
||||
raise LocalAiError(
|
||||
"Aucun modèle IA configuré. Ouvrez « ⚙️ Configurer l'IA locale » "
|
||||
"et indiquez le chemin d'un fichier .gguf."
|
||||
)
|
||||
|
||||
try:
|
||||
from llama_cpp import Llama
|
||||
except ImportError as exc:
|
||||
raise LocalAiError(
|
||||
"Le paquet llama-cpp-python n'est pas installé.\n"
|
||||
"Installez-le avec : pip install llama-cpp-python"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
self._llm = Llama(
|
||||
model_path=self.config.model_path,
|
||||
n_ctx=self.config.n_ctx,
|
||||
n_threads=self.config.n_threads or None,
|
||||
verbose=False,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — remonté proprement à l'UI
|
||||
raise LocalAiError(f"Échec du chargement du modèle IA :\n{exc}") from exc
|
||||
|
||||
return self._llm
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Capacités
|
||||
# ------------------------------------------------------------------ #
|
||||
def pick_next_link(
|
||||
self, candidates: list[LinkCandidate], page_title: str
|
||||
) -> LinkCandidate | None:
|
||||
"""Interroge le modèle pour désigner le lien menant à la suite,
|
||||
parmi `candidates`. Retourne None si aucun ne convient."""
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
llm = self._ensure_loaded()
|
||||
numbered = "\n".join(f"{i}. {c.text!r}" for i, c in enumerate(candidates))
|
||||
user_prompt = f"Titre de la page actuelle : {page_title!r}\n\nLiens candidats :\n{numbered}"
|
||||
|
||||
raw = self._complete(llm, _NEXT_LINK_SYSTEM_PROMPT, user_prompt, max_tokens=60)
|
||||
index = self._parse_index(raw, len(candidates))
|
||||
return candidates[index] if index is not None else None
|
||||
|
||||
def extract_main_content(self, page_title: str, raw_text: str, max_input_chars: int = 6000) -> str:
|
||||
"""Demande au modèle d'extraire le texte narratif principal d'un
|
||||
texte brut de page (déjà tronqué à `max_input_chars` caractères —
|
||||
les petits modèles locaux ont une fenêtre de contexte limitée)."""
|
||||
llm = self._ensure_loaded()
|
||||
trimmed = raw_text.strip()[:max_input_chars]
|
||||
if not trimmed:
|
||||
raise LocalAiError("Aucun texte à analyser sur cette page.")
|
||||
|
||||
user_prompt = f"Titre de la page : {page_title!r}\n\n--- TEXTE BRUT ---\n{trimmed}"
|
||||
result = self._complete(
|
||||
llm,
|
||||
_EXTRACT_SYSTEM_PROMPT,
|
||||
user_prompt,
|
||||
max_tokens=max(self.config.max_tokens, len(trimmed) // 3),
|
||||
)
|
||||
result = result.strip()
|
||||
if not result:
|
||||
raise LocalAiError("L'IA n'a renvoyé aucun contenu exploitable pour cette page.")
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
def _complete(self, llm, system_prompt: str, user_prompt: str, max_tokens: int) -> str:
|
||||
try:
|
||||
response = llm.create_chat_completion(
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.1,
|
||||
)
|
||||
return response["choices"][0]["message"]["content"] or ""
|
||||
except LocalAiError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — remonté proprement à l'UI
|
||||
raise LocalAiError(f"Échec de l'inférence IA :\n{exc}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _parse_index(raw: str, count: int) -> int | None:
|
||||
match = re.search(r'"index"\s*:\s*(null|\d+)', raw)
|
||||
if not match or match.group(1) == "null":
|
||||
return None
|
||||
try:
|
||||
index = int(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
return index if 0 <= index < count else None
|
||||
+326
-1
@@ -6,7 +6,7 @@ import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QStandardPaths, QUrl, Qt
|
||||
from PySide6.QtCore import QStandardPaths, QTimer, QUrl, Qt
|
||||
from PySide6.QtGui import QAction, QKeySequence
|
||||
from PySide6.QtWebChannel import QWebChannel
|
||||
from PySide6.QtWebEngineCore import QWebEnginePage, QWebEngineProfile, QWebEngineScript
|
||||
@@ -29,14 +29,52 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .ai_config import AiConfig, load_ai_config, save_ai_config
|
||||
from .ai_settings_dialog import AiSettingsDialog
|
||||
from .ai_worker import AiTaskThread
|
||||
from .block_picker import BLOCK_PICKER_INIT_JS, SET_STICKY_JS_TEMPLATE
|
||||
from .bridge import Bridge, build_bridge_init_script
|
||||
from .link_detector import LINK_DETECTOR_INIT_JS, SELECTION_JS
|
||||
from .local_ai import LinkCandidate, LocalAi, LocalAiError
|
||||
from .models import Clip
|
||||
from .pdf_builder import build_pdf
|
||||
from .pdf_style import PdfStyle
|
||||
from .preview_window import PdfPreviewWindow
|
||||
|
||||
# Texte brut de la page, tronqué et débarrassé des zones techniques
|
||||
# évidentes (script/style/nav) avant d'être envoyé au modèle local pour
|
||||
# extraction — réduit le bruit et la taille du prompt.
|
||||
PAGE_TEXT_FOR_AI_JS = """
|
||||
(function () {
|
||||
var clone = document.body.cloneNode(true);
|
||||
clone.querySelectorAll('script, style, nav, header, footer').forEach(function (el) {
|
||||
el.remove();
|
||||
});
|
||||
return (clone.innerText || clone.textContent || '').trim();
|
||||
})();
|
||||
"""
|
||||
|
||||
# Liste large de liens candidats (texte + href), pour la sélection IA du
|
||||
# lien "suivant" — plus permissive que link_detector.py (pas de filtrage
|
||||
# par mots-clés), puisque c'est justement ce que l'IA doit juger.
|
||||
ALL_LINKS_FOR_AI_JS = """
|
||||
(function () {
|
||||
var seen = {};
|
||||
var links = [];
|
||||
document.querySelectorAll('a[href]').forEach(function (a) {
|
||||
var text = (a.innerText || a.textContent || '').trim();
|
||||
if (!text || text.length > 100) { return; }
|
||||
if (seen[a.href]) { return; }
|
||||
seen[a.href] = true;
|
||||
links.push({ text: text, href: a.href });
|
||||
});
|
||||
return JSON.stringify(links.slice(0, 150));
|
||||
})();
|
||||
"""
|
||||
|
||||
AUTO_SCRAPE_DEFAULT_MAX_CHAPTERS = 20
|
||||
AUTO_SCRAPE_SETTLE_DELAY_MS = 1500 # laisse le temps au contenu dynamique de se charger
|
||||
|
||||
HOME_URL = "https://www.google.com/"
|
||||
|
||||
|
||||
@@ -60,6 +98,14 @@ class MainWindow(QMainWindow):
|
||||
self.pdf_style = PdfStyle()
|
||||
self.preview_window: PdfPreviewWindow | None = None
|
||||
|
||||
self.ai_config = load_ai_config()
|
||||
self.local_ai = LocalAi(self.ai_config)
|
||||
self._ai_thread: AiTaskThread | None = None
|
||||
self.auto_scrape_active = False
|
||||
self.auto_scrape_visited_urls: set[str] = set()
|
||||
self.auto_scrape_max_chapters = AUTO_SCRAPE_DEFAULT_MAX_CHAPTERS
|
||||
self.auto_scrape_count = 0
|
||||
|
||||
self._build_webview()
|
||||
self._build_toolbar()
|
||||
self._build_sidebar()
|
||||
@@ -238,6 +284,40 @@ class MainWindow(QMainWindow):
|
||||
preview_btn.clicked.connect(self.open_pdf_preview)
|
||||
layout.addWidget(preview_btn)
|
||||
|
||||
layout.addWidget(_separator_label("Scraping assisté par IA locale"))
|
||||
|
||||
ai_settings_btn = QPushButton("⚙️ Configurer l'IA locale")
|
||||
ai_settings_btn.clicked.connect(self.open_ai_settings)
|
||||
layout.addWidget(ai_settings_btn)
|
||||
|
||||
ai_buttons = QHBoxLayout()
|
||||
self.ai_extract_btn = QPushButton("🤖 Extraire (IA)")
|
||||
self.ai_extract_btn.setToolTip(
|
||||
"Demande à l'IA locale d'extraire le texte narratif principal de la page "
|
||||
"courante et de l'ajouter au PDF (alternative à la sélection manuelle / au "
|
||||
"mode survol)."
|
||||
)
|
||||
self.ai_extract_btn.clicked.connect(self.run_ai_extract)
|
||||
self.ai_next_link_btn = QPushButton("🤖 Lien suivant (IA)")
|
||||
self.ai_next_link_btn.setToolTip(
|
||||
"Demande à l'IA locale de désigner, parmi tous les liens de la page, celui "
|
||||
"qui mène le plus probablement à la suite — utile quand la détection "
|
||||
"automatique (mots-clés) ne trouve rien."
|
||||
)
|
||||
self.ai_next_link_btn.clicked.connect(self.run_ai_pick_next_link)
|
||||
ai_buttons.addWidget(self.ai_extract_btn)
|
||||
ai_buttons.addWidget(self.ai_next_link_btn)
|
||||
layout.addLayout(ai_buttons)
|
||||
|
||||
self.auto_scrape_btn = QPushButton("🔁 Démarrer le scraping automatique (IA)")
|
||||
self.auto_scrape_btn.setToolTip(
|
||||
"Boucle : extrait le contenu de la page courante (IA), capture le PDF, "
|
||||
"trouve et suit le lien suivant (IA), répète — jusqu'à la limite de "
|
||||
"chapitres fixée ou jusqu'à ce qu'aucune suite ne soit trouvée."
|
||||
)
|
||||
self.auto_scrape_btn.clicked.connect(self._toggle_auto_scrape)
|
||||
layout.addWidget(self.auto_scrape_btn)
|
||||
|
||||
layout.addWidget(_separator_label("Liens suggérés sur cette page"))
|
||||
self.suggested_list = QListWidget()
|
||||
self.suggested_list.itemDoubleClicked.connect(self._navigate_to_suggested)
|
||||
@@ -485,6 +565,251 @@ class MainWindow(QMainWindow):
|
||||
self.preview_window.raise_()
|
||||
self.preview_window.activateWindow()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# IA locale — configuration
|
||||
# ------------------------------------------------------------------ #
|
||||
def open_ai_settings(self) -> None:
|
||||
dialog = AiSettingsDialog(self.ai_config, self)
|
||||
if dialog.exec() == AiSettingsDialog.DialogCode.Accepted:
|
||||
self.ai_config = dialog.updated_config()
|
||||
save_ai_config(self.ai_config)
|
||||
self.local_ai.config = self.ai_config
|
||||
self.local_ai.reset() # force le rechargement si le modèle a changé
|
||||
self.statusBar().showMessage("Configuration IA enregistrée.", 3000)
|
||||
|
||||
def _require_ai_configured(self) -> bool:
|
||||
if self.local_ai.is_configured():
|
||||
return True
|
||||
confirm = QMessageBox.question(
|
||||
self,
|
||||
"IA locale non configurée",
|
||||
"Aucun modèle IA n'est configuré. Ouvrir les réglages maintenant ?",
|
||||
)
|
||||
if confirm == QMessageBox.StandardButton.Yes:
|
||||
self.open_ai_settings()
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# IA locale — extraction de contenu
|
||||
# ------------------------------------------------------------------ #
|
||||
def run_ai_extract(self) -> None:
|
||||
if not self._require_ai_configured():
|
||||
return
|
||||
self.statusBar().showMessage("IA : lecture de la page…", 3000)
|
||||
self.webview.page().runJavaScript(PAGE_TEXT_FOR_AI_JS, self._on_page_text_for_ai_extract)
|
||||
|
||||
def _on_page_text_for_ai_extract(self, page_text: str | None) -> None:
|
||||
if not page_text or not page_text.strip():
|
||||
QMessageBox.warning(self, "IA locale", "Aucun texte exploitable sur cette page.")
|
||||
return
|
||||
title = self.webview.title() or self.webview.url().toString()
|
||||
self._run_ai_task(
|
||||
self.local_ai.extract_main_content,
|
||||
args=(title, page_text),
|
||||
on_success=self._on_ai_extract_succeeded,
|
||||
busy_message="IA : extraction du contenu en cours (peut prendre du temps sur CPU)…",
|
||||
)
|
||||
|
||||
def _on_ai_extract_succeeded(self, extracted_text: object) -> None:
|
||||
self._add_clip(str(extracted_text))
|
||||
self.statusBar().showMessage("Extrait ajouté au PDF (IA).", 3000)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# IA locale — suggestion du lien suivant
|
||||
# ------------------------------------------------------------------ #
|
||||
def run_ai_pick_next_link(self) -> None:
|
||||
if not self._require_ai_configured():
|
||||
return
|
||||
self.statusBar().showMessage("IA : lecture des liens de la page…", 3000)
|
||||
self.webview.page().runJavaScript(ALL_LINKS_FOR_AI_JS, self._on_links_for_ai_pick)
|
||||
|
||||
def _on_links_for_ai_pick(self, links_json: str | None) -> None:
|
||||
try:
|
||||
raw_links = json.loads(links_json) if links_json else []
|
||||
except (TypeError, ValueError):
|
||||
raw_links = []
|
||||
if not raw_links:
|
||||
QMessageBox.warning(self, "IA locale", "Aucun lien exploitable sur cette page.")
|
||||
return
|
||||
|
||||
candidates = [LinkCandidate(text=l["text"], href=l["href"]) for l in raw_links]
|
||||
title = self.webview.title() or self.webview.url().toString()
|
||||
self._run_ai_task(
|
||||
self.local_ai.pick_next_link,
|
||||
args=(candidates, title),
|
||||
on_success=self._on_ai_pick_next_link_succeeded,
|
||||
busy_message="IA : recherche du lien suivant en cours…",
|
||||
)
|
||||
|
||||
def _on_ai_pick_next_link_succeeded(self, candidate: object) -> None:
|
||||
if candidate is None:
|
||||
QMessageBox.information(
|
||||
self, "IA locale", "L'IA n'a identifié aucun lien menant à une suite sur cette page."
|
||||
)
|
||||
return
|
||||
candidate = candidate # type: LinkCandidate
|
||||
confirm = QMessageBox.question(
|
||||
self,
|
||||
"Lien suivant suggéré par l'IA",
|
||||
f"L'IA suggère :\n\n« {candidate.text} »\n{candidate.href}\n\nY aller maintenant ?",
|
||||
)
|
||||
if confirm == QMessageBox.StandardButton.Yes:
|
||||
self.webview.setUrl(QUrl(candidate.href))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# IA locale — exécution en arrière-plan
|
||||
# ------------------------------------------------------------------ #
|
||||
def _run_ai_task(self, fn, args: tuple, on_success, busy_message: str) -> None:
|
||||
self.statusBar().showMessage(busy_message)
|
||||
self._set_ai_buttons_enabled(False)
|
||||
|
||||
self._ai_thread = AiTaskThread(fn, *args)
|
||||
self._ai_thread.succeeded.connect(lambda result: self._on_ai_task_succeeded(on_success, result))
|
||||
self._ai_thread.failed.connect(self._on_ai_task_failed)
|
||||
self._ai_thread.finished.connect(lambda: self._set_ai_buttons_enabled(True))
|
||||
self._ai_thread.start()
|
||||
|
||||
def _on_ai_task_succeeded(self, on_success, result: object) -> None:
|
||||
on_success(result)
|
||||
|
||||
def _on_ai_task_failed(self, message: str) -> None:
|
||||
self.statusBar().clearMessage()
|
||||
QMessageBox.warning(self, "IA locale", message)
|
||||
if self.auto_scrape_active:
|
||||
self._stop_auto_scrape(f"Scraping automatique arrêté : {message}")
|
||||
|
||||
def _set_ai_buttons_enabled(self, enabled: bool) -> None:
|
||||
self.ai_extract_btn.setEnabled(enabled)
|
||||
self.ai_next_link_btn.setEnabled(enabled)
|
||||
if enabled and not self.auto_scrape_active:
|
||||
self.auto_scrape_btn.setEnabled(True)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# IA locale — scraping automatique (boucle)
|
||||
# ------------------------------------------------------------------ #
|
||||
def _toggle_auto_scrape(self) -> None:
|
||||
if self.auto_scrape_active:
|
||||
self._stop_auto_scrape("Scraping automatique arrêté.")
|
||||
return
|
||||
|
||||
if not self._require_ai_configured():
|
||||
return
|
||||
|
||||
max_chapters, ok = QInputDialog.getInt(
|
||||
self,
|
||||
"Scraping automatique",
|
||||
"Nombre maximum de chapitres à capturer automatiquement :",
|
||||
self.auto_scrape_max_chapters,
|
||||
1,
|
||||
100,
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
|
||||
self.auto_scrape_max_chapters = max_chapters
|
||||
self.auto_scrape_active = True
|
||||
self.auto_scrape_visited_urls = set()
|
||||
self.auto_scrape_count = 0
|
||||
self.auto_scrape_btn.setText("⏹ Arrêter le scraping automatique")
|
||||
self.statusBar().showMessage("Scraping automatique démarré…", 3000)
|
||||
self._auto_scrape_extract_current_page()
|
||||
|
||||
def _stop_auto_scrape(self, message: str) -> None:
|
||||
self.auto_scrape_active = False
|
||||
self.auto_scrape_btn.setText("🔁 Démarrer le scraping automatique (IA)")
|
||||
self.statusBar().showMessage(message, 6000)
|
||||
|
||||
def _auto_scrape_extract_current_page(self) -> None:
|
||||
if not self.auto_scrape_active:
|
||||
return
|
||||
self.auto_scrape_visited_urls.add(self.webview.url().toString())
|
||||
self.statusBar().showMessage(
|
||||
f"Scraping automatique : extraction du chapitre {self.auto_scrape_count + 1}"
|
||||
f"/{self.auto_scrape_max_chapters}…"
|
||||
)
|
||||
self.webview.page().runJavaScript(PAGE_TEXT_FOR_AI_JS, self._auto_scrape_on_page_text)
|
||||
|
||||
def _auto_scrape_on_page_text(self, page_text: str | None) -> None:
|
||||
if not self.auto_scrape_active:
|
||||
return
|
||||
if not page_text or not page_text.strip():
|
||||
self._stop_auto_scrape("Scraping automatique arrêté : page sans texte exploitable.")
|
||||
return
|
||||
title = self.webview.title() or self.webview.url().toString()
|
||||
self._run_ai_task(
|
||||
self.local_ai.extract_main_content,
|
||||
args=(title, page_text),
|
||||
on_success=self._auto_scrape_on_extracted,
|
||||
busy_message=f"Scraping automatique : extraction IA du chapitre {self.auto_scrape_count + 1}…",
|
||||
)
|
||||
|
||||
def _auto_scrape_on_extracted(self, extracted_text: object) -> None:
|
||||
if not self.auto_scrape_active:
|
||||
return
|
||||
self._add_clip(str(extracted_text))
|
||||
self.auto_scrape_count += 1
|
||||
|
||||
if self.auto_scrape_count >= self.auto_scrape_max_chapters:
|
||||
self._stop_auto_scrape(
|
||||
f"Scraping automatique terminé : limite de {self.auto_scrape_max_chapters} "
|
||||
"chapitres atteinte."
|
||||
)
|
||||
return
|
||||
|
||||
self.webview.page().runJavaScript(ALL_LINKS_FOR_AI_JS, self._auto_scrape_on_links)
|
||||
|
||||
def _auto_scrape_on_links(self, links_json: str | None) -> None:
|
||||
if not self.auto_scrape_active:
|
||||
return
|
||||
try:
|
||||
raw_links = json.loads(links_json) if links_json else []
|
||||
except (TypeError, ValueError):
|
||||
raw_links = []
|
||||
if not raw_links:
|
||||
self._stop_auto_scrape("Scraping automatique terminé : plus aucun lien sur la page.")
|
||||
return
|
||||
|
||||
candidates = [LinkCandidate(text=l["text"], href=l["href"]) for l in raw_links]
|
||||
title = self.webview.title() or self.webview.url().toString()
|
||||
self._run_ai_task(
|
||||
self.local_ai.pick_next_link,
|
||||
args=(candidates, title),
|
||||
on_success=self._auto_scrape_on_next_link_found,
|
||||
busy_message="Scraping automatique : recherche du chapitre suivant (IA)…",
|
||||
)
|
||||
|
||||
def _auto_scrape_on_next_link_found(self, candidate: object) -> None:
|
||||
if not self.auto_scrape_active:
|
||||
return
|
||||
if candidate is None:
|
||||
self._stop_auto_scrape(
|
||||
f"Scraping automatique terminé : {self.auto_scrape_count} chapitre(s) capturé(s), "
|
||||
"aucune suite trouvée."
|
||||
)
|
||||
return
|
||||
|
||||
candidate = candidate # type: LinkCandidate
|
||||
if candidate.href in self.auto_scrape_visited_urls:
|
||||
self._stop_auto_scrape(
|
||||
"Scraping automatique arrêté : le lien suivant pointe vers une page déjà visitée "
|
||||
"(boucle détectée)."
|
||||
)
|
||||
return
|
||||
|
||||
def on_next_page_loaded(ok: bool) -> None:
|
||||
self.webview.loadFinished.disconnect(on_next_page_loaded)
|
||||
if not self.auto_scrape_active:
|
||||
return
|
||||
if not ok:
|
||||
self._stop_auto_scrape("Scraping automatique arrêté : échec du chargement de la page suivante.")
|
||||
return
|
||||
# Laisse le temps au contenu chargé en JS de s'installer avant
|
||||
# de relire la page (voir link_detector.py pour le même souci).
|
||||
QTimer.singleShot(AUTO_SCRAPE_SETTLE_DELAY_MS, self._auto_scrape_extract_current_page)
|
||||
|
||||
self.webview.loadFinished.connect(on_next_page_loaded)
|
||||
self.webview.setUrl(QUrl(candidate.href))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Export PDF
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
PySide6>=6.6
|
||||
reportlab>=4.0
|
||||
llama-cpp-python>=0.3
|
||||
Reference in new issue
Block a user