devsite/strapi_extraction/build-vault.py
2026-04-23 19:41:58 +02:00

775 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Génère le vault Obsidian `vault-grasbot/` à partir de `strapi_extraction/docs/`.
Lit les `project-*.md` et `competence-*.md` produits par `generate-docs.js`,
et les réécrit sous forme de notes Obsidian structurées :
- Frontmatter YAML (type, source, domains, tags, linked, related, updated, visibility)
- Wikilinks [[...]] vers les MOCs et notes frères
- Section "Liens" en pied de note
Génère aussi les MOCs (00-MOC/) qui servent de hubs thématiques.
Usage :
python build-vault.py # régénère tout le vault
python build-vault.py --dry-run # liste sans écrire
Dépendances : stdlib seule. Optionnel : `pypdf` pour convertir le CV PDF
(absent → le PDF est ignoré, conversion manuelle possible après coup).
"""
from __future__ import annotations
import argparse
import re
import shutil
import sys
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
# ---------------------------------------------------------------------------
# Chemins
# ---------------------------------------------------------------------------
ROOT = Path(__file__).resolve().parent.parent
DOCS_DIR = ROOT / "strapi_extraction" / "docs"
VAULT_DIR = ROOT / "vault-grasbot"
PDF_CV = DOCS_DIR / "nouveauCV_grascalvet.pdf"
SUBDIRS = (
"00-MOC",
"10-Projets",
"20-Competences",
"30-Parcours",
"40-Glossaire",
"50-Technique",
)
# ---------------------------------------------------------------------------
# Inférence de domaines / tags à partir de mots-clés.
# Première version volontairement simple : on cherche des sous-chaînes (case-insensitive)
# dans le titre + corps de la note. Affinable au fil de l'enrichissement du vault.
# ---------------------------------------------------------------------------
DOMAIN_KEYWORDS: dict[str, list[str]] = {
"algorithmique": ["tri", "pile", "algorithm", "complexité", "push_swap", "fractal"],
"c": ["langage c", "printf", "libft", "get_next_line", "minitalk", "philosopher"],
"cpp": ["c++", "cpp", "poo", "polymorphisme", "template", "stl"],
"systeme": ["unix", "signal", "processus", "mutex", "thread", "ipc", "bash"],
"reseau": ["tcp", "ip", "socket", "irc", "netpractice", "routage"],
"web": ["next.js", "nextjs", "react", "django", "api rest", "websocket", "strapi"],
"devops": ["docker", "nginx", "mariadb", "wordpress", "inception", "conteneur"],
"securite": ["born2beroot", "ssh", "fail2ban", "ufw", "lvm", "cybersécurité"],
"ia": [
"llm", "ollama", "ia locale", "intelligence artificielle", "chatbot", "embedding",
# Spécialisation Data / IA (ft_linear_regression, piscine Python data, etc.)
"machine learning", "régression", "régression linéaire", "descente de gradient",
"numpy", "pandas", "scikit-learn", "data science", "dataframe",
],
"graphique": ["minilibx", "raycasting", "cub3d", "fract-ol", "wolfenstein"],
"3d": ["impression 3d", "3d printing", "prusa", "slicer", "filament"],
"domotique": ["domotique", "home assistant", "zigbee", "iot"],
"ecole-42": ["école 42", "42 perpignan", "42 paris", "projet pédagogique"],
}
TAG_KEYWORDS: dict[str, list[str]] = {
"42-commun": ["libft", "get_next_line", "push_swap", "minitalk", "philosopher"],
"42-piscine": ["piscine"],
"42-tronc": ["minishell", "inception", "cub3d", "netpractice"],
"tri": ["tri", "push_swap"],
"concurrence": ["thread", "mutex", "philosopher"],
"docker": ["docker", "inception"],
"makefile": ["makefile"],
"data-ia": [
"ft_linear_regression", "ft-linear-regression",
"régression linéaire", "descente de gradient",
"piscine python", "numpy", "pandas", "scikit-learn",
],
"projet-perso": [], # drapeau manuel (futur)
}
# Aliases par domaine : synonymes / acronymes utilisés par les visiteurs.
# Injectés automatiquement dans les notes du domaine pour booster le retrieval
# (voir llm-api/search.py). Complémentaires aux aliases manuels du frontmatter.
DOMAIN_ALIASES: dict[str, list[str]] = {
"algorithmique": ["algo", "algorithme", "algorithmes", "complexité"],
"c": ["langage c", "ansi c", "c 42"],
"cpp": ["c++", "cpp 42", "poo", "programmation orientée objet"],
"systeme": ["système", "unix", "linux", "processus", "threads"],
"reseau": ["réseau", "tcp", "ip", "sockets", "routage"],
"web": ["développement web", "site web", "frontend", "backend", "full stack"],
"devops": ["devops", "conteneurs", "ci/cd", "infrastructure"],
"securite": ["sécurité", "hardening", "cybersécurité", "audit"],
"ia": ["ia", "intelligence artificielle", "llm", "llms", "modèles de langage",
"chatbot", "chatbots", "machine learning", "deep learning", "data science",
"ollama", "agent", "agents", "rag"],
"graphique": ["rendu", "raycasting", "minilibx", "graphisme", "2d", "game dev"],
"3d": ["impression 3d", "3d printing", "fdm", "slicer", "prusa"],
"domotique": ["domotique", "home assistant", "iot", "smart home", "zigbee"],
"ecole-42": ["42", "école 42", "42 perpignan", "42 paris", "piscine 42", "tronc commun"],
"parcours": ["parcours", "cv", "profil", "carrière", "reconversion", "trajectoire"],
}
def infer(text: str, catalog: dict[str, list[str]]) -> list[str]:
"""Retourne les clés du catalog dont au moins un mot-clé apparaît dans text."""
lowered = text.lower()
return sorted(k for k, keywords in catalog.items() if any(kw in lowered for kw in keywords))
def slug_variants(slug: str, title: str) -> list[str]:
"""Retourne des variantes utiles d'un slug/titre pour les aliases.
Ex. slug="push-swap", title="push_swap" → ["push swap", "push-swap", "push_swap"]
"""
variants: set[str] = set()
for base in (slug, title):
if not base:
continue
b = base.strip()
variants.add(b.lower())
variants.add(b.replace("-", " ").lower())
variants.add(b.replace("_", " ").lower())
variants.add(b.replace("-", "_").lower())
variants.add(b.replace("_", "-").lower())
# Retire les vides et les doublons, trie par longueur décroissante (plus spécifiques d'abord)
out = sorted((v for v in variants if v), key=lambda s: (-len(s), s))
return out[:4]
def _order_domains_by_slug(slug: str, domains: list[str]) -> list[str]:
"""Remonte en tête le domaine qui correspond au slug (ou préfixe).
Ex. slug='ia' + domains=['algorithmique','ecole-42','ia'] → ['ia','algorithmique','ecole-42'].
"""
s = slug.lower()
if not domains:
return []
exact = [d for d in domains if d.lower() == s]
rest = [d for d in domains if d.lower() != s]
return exact + rest
def build_aliases(title: str, slug: str, domains: list[str]) -> list[str]:
"""Génère une liste d'aliases à partir du titre, du slug et des domaines.
On priorise dans l'ordre : slug-variants > domaine match avec slug > autres domaines.
Coupe à 12 pour éviter de trop disperser le scoring, en gardant les plus
spécifiques (slug du domaine d'abord).
"""
aliases: list[str] = []
aliases.extend(slug_variants(slug, title))
ordered_domains = _order_domains_by_slug(slug, domains)
for d in ordered_domains:
aliases.extend(DOMAIN_ALIASES.get(d, []))
# Dé-doublonne tout en préservant l'ordre
seen: set[str] = set()
out: list[str] = []
for a in aliases:
a_norm = a.lower().strip()
if a_norm and a_norm not in seen:
seen.add(a_norm)
out.append(a)
return out[:12]
# Courts libellés parlants pour les answers de compétences (plutôt que le titre brut).
# Priorité : slug > premier domaine significatif > fallback sur le titre.
COMPETENCE_SHORT_LABELS: dict[str, str] = {
"ia": "IA",
"domotique": "domotique",
"3d": "impression 3D",
"web": "développement web",
"securite": "sécurité",
"reseau": "réseaux",
"systeme": "systèmes",
"devops": "DevOps",
"graphique": "programmation graphique",
"cpp": "C++",
"c": "langage C",
"algorithmique": "algorithmique",
"ecole-42": "l'École 42",
}
def _competence_label(title: str, slug: str, domains: list[str]) -> str:
"""Retourne un libellé court et parlant pour une compétence."""
if slug in COMPETENCE_SHORT_LABELS:
return COMPETENCE_SHORT_LABELS[slug]
for d in domains:
if d in COMPETENCE_SHORT_LABELS and d != "ecole-42":
return COMPETENCE_SHORT_LABELS[d]
# Fallback : titre tronqué aux 5 premiers mots
words = title.split()
return " ".join(words[:5]).rstrip(".?!")
def build_answers(title: str, type_: str, slug: str = "", domains: list[str] | None = None) -> list[str]:
"""Génère 2-3 questions-types auxquelles cette note répond naturellement."""
domains = domains or []
t = title.strip().rstrip(".?!")
if type_ == "projet":
return [
f"Parle-moi de {t}",
f"Qu'est-ce que {t} ?",
f"Comment fonctionne {t} ?",
]
if type_ == "competence":
label = _competence_label(title, slug, domains)
return [
f"Quelles sont ses compétences en {label} ?",
f"A-t-il de l'expérience en {label} ?",
f"Parle-moi de son expérience en {label}",
]
if type_ == "moc":
domain_name = t.replace("MOC —", "").replace("MOC -", "").strip()
return [
f"Que fait-il en {domain_name} ?",
f"Quels projets en {domain_name} ?",
]
if type_ == "parcours":
return [
f"Quel est son parcours ?",
f"Que peux-tu me dire sur Fernand ?",
f"Cherche-t-il une alternance ?",
]
return []
def compute_priority(type_: str, domains: list[str]) -> int:
"""Priorité heuristique : MOCs > compétences > projets emblématiques > autres."""
if type_ == "parcours":
return 10
if type_ == "moc":
return 7
if type_ == "competence":
return 7
# Projets : boost léger si domaine "ia" (stratégique pour l'alternance visée)
if type_ == "projet" and "ia" in domains:
return 6
return 5
# ---------------------------------------------------------------------------
# Structures
# ---------------------------------------------------------------------------
@dataclass
class Note:
"""Note Obsidian prête à être sérialisée."""
filename: str # "push-swap.md"
title: str # "push_swap"
type: str # "projet" | "competence" | "parcours" | ...
slug: str
source: str # "strapi/projects" ou similaire
domains: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
aliases: list[str] = field(default_factory=list)
answers: list[str] = field(default_factory=list)
priority: int = 5
linked: list[str] = field(default_factory=list) # wikilinks (sans les [[ ]])
related: list[str] = field(default_factory=list)
extra: dict[str, str] = field(default_factory=dict) # champs spécifiques (link, etc.)
body: str = ""
def serialize(self) -> str:
"""Retourne le contenu complet de la note Obsidian, frontmatter + corps."""
yaml_lines = ["---"]
yaml_lines.append(f"title: {self._yaml_str(self.title)}")
yaml_lines.append(f"slug: {self.slug}")
yaml_lines.append(f"type: {self.type}")
yaml_lines.append(f"source: {self.source}")
yaml_lines.append(f"domains: {self._yaml_list(self.domains)}")
yaml_lines.append(f"tags: {self._yaml_list(self.tags)}")
if self.aliases:
yaml_lines.append("aliases:")
for alias in self.aliases:
yaml_lines.append(f" - {self._yaml_str(alias)}")
if self.answers:
yaml_lines.append("answers:")
for answer in self.answers:
yaml_lines.append(f" - {self._yaml_str(answer)}")
yaml_lines.append(f"priority: {self.priority}")
yaml_lines.append("linked:")
for link in self.linked:
yaml_lines.append(f" - \"[[{link}]]\"")
if self.related:
yaml_lines.append("related:")
for rel in self.related:
yaml_lines.append(f" - \"[[{rel}]]\"")
for key, val in self.extra.items():
yaml_lines.append(f"{key}: {self._yaml_str(val)}")
yaml_lines.append(f"updated: {date.today().isoformat()}")
yaml_lines.append("visibility: public")
yaml_lines.append("---")
yaml_lines.append("")
return "\n".join(yaml_lines) + self.body
@staticmethod
def _yaml_str(value: str) -> str:
if value is None:
return '""'
if any(c in value for c in ":#&*!|>'\"%@`"):
escaped = value.replace('"', '\\"')
return f'"{escaped}"'
return value
@staticmethod
def _yaml_list(values: list[str]) -> str:
if not values:
return "[]"
return "[" + ", ".join(values) + "]"
# ---------------------------------------------------------------------------
# Parsing des .md sources
# ---------------------------------------------------------------------------
SLUG_RE = re.compile(r"^\*\*Slug :\*\*\s*`([^`]+)`", re.MULTILINE)
LINK_RE = re.compile(r"^\*\*Lien GitHub :\*\*\s*\[.+?\]\((.+?)\)", re.MULTILINE)
H1_RE = re.compile(r"^# (.+)$", re.MULTILINE)
H2_RE = re.compile(r"^## (.+)$", re.MULTILINE)
def parse_project(filepath: Path) -> Note | None:
"""Transforme un project-*.md en Note projet."""
raw = filepath.read_text(encoding="utf-8")
title_match = H1_RE.search(raw)
slug_match = SLUG_RE.search(raw)
if not title_match or not slug_match:
print(f"{filepath.name} : titre ou slug introuvable, ignoré", file=sys.stderr)
return None
title = title_match.group(1).strip()
slug = slug_match.group(1).strip()
link_match = LINK_RE.search(raw)
body_start = title_match.end()
body = raw[body_start:].strip()
# Retire la section "Informations techniques" dupliquée que le générateur ajoute
# (on la reconstruit dans le footer).
body = re.sub(r"\n## Informations techniques\n[\s\S]*$", "", body).strip()
extra: dict[str, str] = {}
if link_match:
extra["link"] = link_match.group(1).strip()
domains = infer(raw, DOMAIN_KEYWORDS)
tags = infer(raw, TAG_KEYWORDS)
if "ecole-42" not in domains:
domains.append("ecole-42")
domains.sort()
linked = ["MOC-Projets", "MOC-Ecole-42"]
footer = "\n\n---\n\n## Liens\n\n"
footer += "- [[MOC-Projets]] — vue d'ensemble des projets\n"
footer += "- [[MOC-Ecole-42]] — contexte pédagogique\n"
for d in domains:
if d != "ecole-42":
footer += f"- [[MOC-{d.capitalize()}]] — domaine *{d}*\n"
return Note(
filename=f"{slug}.md",
title=title,
type="projet",
slug=slug,
source="strapi/projects",
domains=domains,
tags=tags,
aliases=build_aliases(title, slug, domains),
answers=build_answers(title, "projet", slug, domains),
priority=compute_priority("projet", domains),
linked=linked,
extra=extra,
body=body + footer,
)
def parse_competence(filepath: Path) -> Note | None:
"""Transforme un competence-*.md en Note compétence."""
raw = filepath.read_text(encoding="utf-8")
title_match = H1_RE.search(raw)
slug_match = SLUG_RE.search(raw)
if not title_match or not slug_match:
print(f"{filepath.name} : titre ou slug introuvable, ignoré", file=sys.stderr)
return None
title = title_match.group(1).strip()
slug = slug_match.group(1).strip()
body_start = title_match.end()
body = raw[body_start:].strip()
domains = infer(raw, DOMAIN_KEYWORDS)
tags = infer(raw, TAG_KEYWORDS)
linked = ["MOC-Competences"]
footer = "\n\n---\n\n## Liens\n\n"
footer += "- [[MOC-Competences]] — vue d'ensemble des compétences\n"
for d in domains:
footer += f"- [[MOC-{d.capitalize()}]] — domaine *{d}*\n"
return Note(
filename=f"{slug}.md",
title=title,
type="competence",
slug=slug,
source="strapi/competences",
domains=domains,
tags=tags,
aliases=build_aliases(title, slug, domains),
answers=build_answers(title, "competence", slug, domains),
priority=compute_priority("competence", domains),
linked=linked,
body=body + footer,
)
# ---------------------------------------------------------------------------
# Génération des MOCs
# ---------------------------------------------------------------------------
def build_moc(
title: str,
description: str,
notes: list[Note],
*,
moc_slug: str,
type_filter: str | None = None,
domain_filter: str | None = None,
) -> Note:
selected = [
n for n in notes
if (type_filter is None or n.type == type_filter)
and (domain_filter is None or domain_filter in n.domains)
]
selected.sort(key=lambda n: n.title.lower())
body = f"\n\n{description}\n\n## Notes liées\n\n"
if not selected:
body += "*Aucune note pour l'instant.*\n"
else:
for n in selected:
body += f"- [[{n.slug}|{n.title}]]"
if n.domains:
body += f" — _{', '.join(n.domains)}_"
body += "\n"
moc_domains = [domain_filter] if domain_filter else []
moc_aliases = build_aliases(title, moc_slug, moc_domains)
# Un MOC répond bien aux questions "quels projets en X", "domaine Y"
domain_label = domain_filter or title.replace("MOC —", "").replace("MOC -", "").strip()
moc_answers = [
f"Quels projets en {domain_label} ?",
f"Que fait-il en {domain_label} ?",
]
return Note(
filename=f"{moc_slug}.md",
title=title,
type="moc",
slug=moc_slug,
source="vault/generated",
domains=moc_domains,
tags=["moc"],
aliases=moc_aliases,
answers=moc_answers,
priority=7,
linked=[],
body=body,
)
def build_mocs(projects: list[Note], competences: list[Note]) -> list[tuple[str, Note]]:
"""Construit la liste des MOCs à écrire. Chaque item = (sous-dossier, Note)."""
all_notes = projects + competences
mocs: list[Note] = []
mocs.append(build_moc(
"MOC — Projets",
"Hub des projets de Fernand Gras-Calvet, triés par titre.",
all_notes,
moc_slug="MOC-Projets",
type_filter="projet",
))
mocs.append(build_moc(
"MOC — Compétences",
"Hub des domaines de compétences.",
all_notes,
moc_slug="MOC-Competences",
type_filter="competence",
))
mocs.append(build_moc(
"MOC — École 42",
"Tout ce qui est rattaché à la formation 42 Perpignan.",
all_notes,
moc_slug="MOC-Ecole-42",
domain_filter="ecole-42",
))
# MOCs par domaine significatif (s'il y a au moins 2 notes).
domain_counts: dict[str, int] = {}
for n in all_notes:
for d in n.domains:
domain_counts[d] = domain_counts.get(d, 0) + 1
for d, count in sorted(domain_counts.items()):
if count < 2 or d == "ecole-42":
continue
mocs.append(build_moc(
f"MOC — {d.capitalize()}",
f"Notes du domaine *{d}* ({count} au total).",
all_notes,
moc_slug=f"MOC-{d.capitalize()}",
domain_filter=d,
))
mocs.append(build_moc(
"MOC — Parcours",
"Parcours atypique de Fernand Gras-Calvet, du CV aux projets.",
all_notes,
moc_slug="MOC-Parcours",
type_filter="parcours",
))
return [("00-MOC", m) for m in mocs]
# ---------------------------------------------------------------------------
# PDF CV → Markdown (optionnel, requiert pypdf)
# ---------------------------------------------------------------------------
def try_build_cv(vault_dir: Path, dry_run: bool = False) -> Note | None:
if not PDF_CV.exists():
print(f" PDF CV absent ({PDF_CV}), étape ignorée")
return None
try:
from pypdf import PdfReader
except ImportError:
print(
" ⚠ `pypdf` non installé — la conversion du CV est ignorée.\n"
" Installer : pip install pypdf\n"
" Ou fournir une version déjà convertie dans 30-Parcours/"
)
return None
print(f" 🔄 Conversion PDF → MD : {PDF_CV.name}")
reader = PdfReader(str(PDF_CV))
pages_text = [page.extract_text() or "" for page in reader.pages]
raw = "\n\n".join(pages_text).strip()
body = "\n\n> [!info] Source\n> Extrait automatiquement depuis `nouveauCV_grascalvet.pdf`.\n> Structurer manuellement dans Obsidian si besoin.\n\n"
body += "## Contenu brut\n\n"
body += raw + "\n"
note = Note(
filename="cv-grascalvet-fernand.md",
title="CV — Fernand Gras-Calvet",
type="parcours",
slug="cv-grascalvet-fernand",
source="pdf/nouveauCV_grascalvet",
domains=["ecole-42"],
tags=["cv", "parcours"],
linked=["MOC-Parcours"],
body=body,
)
return note
# ---------------------------------------------------------------------------
# Écriture du vault
# ---------------------------------------------------------------------------
_FRONTMATTER_SOURCE_RE = re.compile(r"^---\s*\n(?:.*?\n)*?source:\s*([^\n]+)\n(?:.*?\n)*?---\s*\n", re.DOTALL)
def _existing_source(path: Path) -> str | None:
"""Retourne la valeur `source:` du frontmatter existant, ou None."""
if not path.exists():
return None
try:
head = path.read_text(encoding="utf-8")[:2000]
except OSError:
return None
m = _FRONTMATTER_SOURCE_RE.match(head)
if not m:
return None
return m.group(1).strip().strip('"').strip("'")
def write_notes(pairs: list[tuple[str, Note]], dry_run: bool) -> None:
"""Écrit chaque note sur disque, sauf celles dont le frontmatter local a
`source: manual` — dans ce cas on préserve la version curatée humainement.
"""
skipped = 0
for subdir, note in pairs:
target = VAULT_DIR / subdir / note.filename
existing = _existing_source(target)
if existing == "manual":
print(f"{target.relative_to(ROOT)} (source: manual, préservé)")
skipped += 1
continue
if dry_run:
print(f" [dry] {target.relative_to(ROOT)}")
continue
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(note.serialize(), encoding="utf-8")
if skipped:
print(f" {skipped} note(s) préservée(s) (source: manual).")
def write_readme(projects: list[Note], competences: list[Note], dry_run: bool) -> None:
readme = f"""# Vault GrasBot — Base de connaissances
Vault Obsidian généré par `strapi_extraction/build-vault.py` à partir des
contenus Strapi du site (projets + compétences) et du CV PDF. Alimente
directement le pipeline de recherche de GrasBot (`llm-api/search.py`) :
graph + BM25, sans embeddings.
**Dernière génération :** {date.today().isoformat()}
## Structure
- `00-MOC/` — Maps of Content (hubs thématiques)
- `10-Projets/` — {len(projects)} projets extraits de Strapi
- `20-Competences/` — {len(competences)} compétences extraites de Strapi
- `30-Parcours/` — Parcours personnel, CV, bio (version curatée `source: manual`)
- `40-Glossaire/` — Termes techniques (vide, à remplir manuellement ou depuis Strapi plus tard)
- `50-Technique/` — Auto-documentation (architecture, retrieval, vault)
- `TAXONOMIE.md` — Vocabulaire contrôlé (domaines, tags, aliases, answers, priority)
## Conventions
Chaque note porte un frontmatter YAML enrichi :
```yaml
---
title: ...
slug: ...
type: projet | competence | parcours | glossaire | moc | technique
source: strapi/... | pdf/... | manual | vault/generated
domains: [ia, web, systeme, ...] # taxonomie contrôlée
tags: [tag-1, tag-2]
aliases: # synonymes pour le retrieval
- "alias court"
- "autre formulation"
answers: # questions-types auxquelles répond la note
- "Question formulée naturellement ?"
priority: 5 # 1..10, boost léger au scoring
linked: ["[[MOC-...]]"] # voisins du graphe (sortants)
related: ["[[autre-note]]"]
updated: YYYY-MM-DD
visibility: public | private # `private` exclu du retrieval
---
```
Voir `TAXONOMIE.md` pour le vocabulaire contrôlé des domaines/tags et les
règles de rédaction des aliases/answers.
**Règle de régénération** : le script `build-vault.py` **écrase** sans prévenir
les notes dont le frontmatter a `source: strapi/*` ou `source: pdf/*`. Il ne
touche **jamais** aux notes `source: manual` que tu ajoutes toi-même. Les
aliases, answers et priority des notes générées sont calculés automatiquement
à partir du titre, du slug et des domaines ; les notes stratégiques méritent
un enrichissement manuel en passant `source: manual`.
## Fusion avec un vault personnel
Pour agrémenter ce vault avec ton vault Obsidian perso :
1. Copier `vault-grasbot/` dans ton vault existant comme sous-dossier, ou
2. Ouvrir `vault-grasbot/` comme vault séparé dans Obsidian (plus simple pour démarrer).
Les wikilinks `[[nom]]` restent valides tant que les noms de notes sont uniques
dans le vault courant. Les notes `source: manual` que tu crées ne seront jamais
écrasées par une régénération. Pour une note privée qui ne doit pas apparaître
côté chatbot, ajouter `visibility: private` : elle sera exclue de `load_vault()`.
"""
target = VAULT_DIR / "README.md"
if dry_run:
print(f" [dry] {target.relative_to(ROOT)}")
return
target.write_text(readme, encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true", help="N'écrit rien, affiche juste ce qui serait fait.")
parser.add_argument("--clean", action="store_true", help="Supprime le vault avant de le régénérer (les notes manuelles seront perdues !).")
args = parser.parse_args()
print(f"🏗 Vault → {VAULT_DIR.relative_to(ROOT)}")
if args.clean and VAULT_DIR.exists() and not args.dry_run:
print(f" 🧹 Suppression du vault existant (--clean)")
shutil.rmtree(VAULT_DIR)
if not args.dry_run:
for subdir in SUBDIRS:
(VAULT_DIR / subdir).mkdir(parents=True, exist_ok=True)
# Parsing
print("\n📦 Parsing des projets…")
project_notes = []
for fp in sorted(DOCS_DIR.glob("project-*.md")):
note = parse_project(fp)
if note:
project_notes.append(note)
print(f"{note.slug}")
print(f"\n📦 Parsing des compétences…")
competence_notes = []
for fp in sorted(DOCS_DIR.glob("competence-*.md")):
note = parse_competence(fp)
if note:
competence_notes.append(note)
print(f"{note.slug}")
# Related : pour chaque note, trouve les 3 notes les plus similaires (intersection domains)
print("\n🔗 Calcul des notes connexes…")
for note in project_notes + competence_notes:
note.related = _find_related(note, project_notes + competence_notes, limit=3)
# Réinjecte les related dans le footer : on réécrit la section Liens.
# (Le footer est déjà inclus dans note.body — on le laisse tel quel, les
# wikilinks related apparaîtront via le frontmatter. Simplicité >.)
# MOCs
print("\n🗺 Génération des MOCs…")
moc_pairs = build_mocs(project_notes, competence_notes)
for _, m in moc_pairs:
print(f"{m.slug}")
# Assemble l'ensemble à écrire
pairs: list[tuple[str, Note]] = []
pairs += [("10-Projets", n) for n in project_notes]
pairs += [("20-Competences", n) for n in competence_notes]
pairs += moc_pairs
# PDF CV
print("\n📄 CV PDF…")
cv_note = try_build_cv(VAULT_DIR, dry_run=args.dry_run)
if cv_note:
pairs.append(("30-Parcours", cv_note))
# Écriture
print(f"\n✍ Écriture ({len(pairs)} notes)…")
write_notes(pairs, dry_run=args.dry_run)
write_readme(project_notes, competence_notes, dry_run=args.dry_run)
print(f"\n🎯 Terminé — {len(project_notes)} projets, {len(competence_notes)} compétences, {len(moc_pairs)} MOCs" + (", 1 CV" if cv_note else ""))
print(f"📁 {VAULT_DIR.relative_to(ROOT)}")
return 0
def _find_related(note: Note, all_notes: list[Note], limit: int = 3) -> list[str]:
"""Ordre simple : notes qui partagent le plus de domains avec `note`."""
scored: list[tuple[int, Note]] = []
for other in all_notes:
if other.slug == note.slug:
continue
shared = len(set(note.domains) & set(other.domains))
if shared >= 1:
scored.append((shared, other))
scored.sort(key=lambda x: (-x[0], x[1].title.lower()))
return [n.slug for _, n in scored[:limit]]
if __name__ == "__main__":
sys.exit(main())