- TypeScript 78.7%
- MDX 19.1%
- Shell 1.5%
- CSS 0.4%
- JavaScript 0.3%
Blog corpo-fake de mononcles québécois retraités, généré par LLM local. Inclut la migration LM Studio → Ollama (Qwen3.6 35B A3B), le routing d'host par horaire (config/llm-schedule.yaml), le fix reasoning_effort=none et le repair des control-chars dans extractJson. Snapshot complet avec env prod (deploy/env.prod) — repo privé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| .claude | ||
| .config/nextjs-nodejs | ||
| .npm | ||
| app | ||
| branding | ||
| components | ||
| config | ||
| content/posts | ||
| deploy | ||
| docs | ||
| lib | ||
| scripts | ||
| .gitignore | ||
| CLAUDE.md | ||
| middleware.ts | ||
| next-env.d.ts | ||
| next.config.mjs | ||
| package-lock.json | ||
| package.json | ||
| postcss.config.mjs | ||
| PROMPT.md | ||
| README.md | ||
| tailwind.config.ts | ||
| tsconfig.json | ||
vieuxmononcle.com
Site "corpo fake" / blog de 6 mononcles québécois retraités, 100% généré par LLM local via Ollama. Le vernis visuel imite un cabinet de consultation mauricien ("Groupe Vieux Mononcle · Excellence · Tradition · Intégrité · depuis 1952"), le contenu c'est Gérald qui mélange PowerShell pis gypse, Yvon qui rapporte de Hollywood FL, Denis qui chie sur les EVs.
Self-hosted sur Debian. Zéro Docker, zéro cloud, zéro service payant.
Pour l'état live du déploiement (topologie réelle, hôtes, runbook ops, URLs, mots de passe), voir deploy/DEPLOYMENT.md. Le présent README couvre l'installation "from scratch" générique.
Architecture
VM Debian 13 (Proxmox LXC)
┌──────────────────────────────────────────────────────────────────────────┐
│ │
│ Caddy :443 ──TLS auto──▶ Next.js 15 :3000 │
│ │ │
│ ├──▶ SQLite /var/lib/vieuxmononcle/ │
│ │ (comments · views · posts_log · │
│ │ scheduled_comments) │
│ │ │
│ └──▶ MDX content/posts/*.mdx │
│ (read) │
│ │
│ scripts/tick.ts ◀── systemd timer (06..21:30, toutes les 30 min) │
│ │ │
│ ├─▶ écrit MDX ────────────▶ content/posts/ │
│ ├─▶ insère scheduled_comments │
│ └─▶ exécute scheduled_comments dus (insert comments) │
│ │
└────────────────────────────┬─────────────────────────────────────────────┘
│ HTTP via Tailscale
▼
Ollama :11434
Qwen3.6 35B A3B uncensored @ Q4_K_M
Stack
- Next.js 15 App Router · React 19 · TypeScript strict ·
output: standalone - Tailwind v3 (palette corpo: navy
#0C2340, paper#faf8f3, accent#7a1e1e, serif Georgia) - MDX via
next-mdx-remote/rsc· frontmatter YAML parsé pargray-matter - better-sqlite3 (WAL, foreign_keys ON, singleton)
- Ollama OpenAI-compatible —
response_format: json_schemastrict, fallbackjson_object - systemd — un service web, un tick oneshot, un timer
- Caddy reverse proxy avec auto-TLS
Les 6 mononcles
| id | Nom | Surnom | Ville | Vibe |
|---|---|---|---|---|
| gerald | Gérald Tremblay | Mononc IT | Shawinigan | Ex-IT Desjardins/CGI, IT + rénos mélangés |
| rejean | Réjean Bouchard | Ti-Jean | Chicoutimi | Ex-Alcan, veuf, histoires qui partent partout |
| marcel | Marcel Gagnon | Le Professeur | Québec | Ex-prof cégep, condescendant, corrige le français |
| denis | Denis Pelletier | Le Gars du Garage | Trois-Rivières | Ex-garagiste, Mustang 1989, anti-EV |
| yvon | Yvon Lavoie | Snowbird | Sherbrooke / Hollywood FL | Ex-Canadian Tire, Jayco, Publix vs Maxi |
| claude_b | Claude Bergeron | Mon'Oncle Claude | Drummondville | Ex-rep pharma, Cadillac, croisières |
Les personas sont intégralement définis dans config/personas/*.yaml.
Tout se mange par YAML — pas de persona hardcodé en TypeScript. Éditer le YAML,
redémarrer le service, c'est joué.
Rivalités configurables par YAML (champ rivalries). Les rivaux commentent à 60 %
sur les posts de leurs rivaux, les non-rivaux à 20 %. Yvon n'a pas de rivaux — c'est
un vendeur, il s'entend avec tout le monde.
Structure
vieuxmononcle/
├── app/
│ ├── layout.tsx header corpo + footer · nav
│ ├── page.tsx home: hero + metrics + divisions + 6 pubs récentes
│ ├── globals.css tailwind + .prose-corpo pour le MDX
│ ├── blog/
│ │ ├── page.tsx archive
│ │ └── [slug]/page.tsx post + CommentSection + ViewTracker
│ ├── admin/
│ │ └── comments/page.tsx modération (protégé par middleware)
│ └── api/
│ ├── comments/route.ts POST (rate limit 5/h/IP, anti-liens)
│ ├── comments/[id]/route.ts PATCH/DELETE (admin)
│ └── views/[slug]/route.ts UPSERT +1
├── components/
│ ├── CommentSection.tsx server: reads from DB, distinct style confrère
│ ├── CommentForm.tsx client: submit → /api/comments
│ ├── AdminActions.tsx client: approuver/rejeter/supprimer
│ └── ViewTracker.tsx client: POST /api/views (1x/session)
├── lib/
│ ├── db.ts sqlite singleton + schema
│ ├── posts.ts MDX reader + frontmatter
│ ├── auth.ts basic auth, rate limiter, anti-spam helpers
│ ├── llm.ts Ollama client + routing horaire + extractJson résilient
│ ├── personas.ts loaders YAML + pickPersona (bias saisonnier) + checkIfTraveling
│ ├── mononcle.ts shouldPost + generateAndSavePost
│ └── chicane.ts schedulePotentialComments + runDueComments
├── scripts/
│ ├── tick.ts systemd timer entrypoint — jamais crash
│ └── force-post.ts CLI: forcer persona / topic / travel
├── middleware.ts basic auth sur /admin/* et /api/comments/[id]
├── config/ ← starter kit, ne pas modifier gratuitement
│ ├── personas/*.yaml
│ ├── destinations.yaml
│ └── base-system-prompt.md
├── content/posts/ MDX écrits par le tick (ou force-post)
└── deploy/
├── vieuxmononcle.service
├── vieuxmononcle-tick.service
├── vieuxmononcle-tick.timer
├── Caddyfile
└── env.example
Prérequis
- Debian 13 (trixie) ou équivalent (Ubuntu 24.04, autre distro systemd récente)
- Node.js ≥ 20 (trixie ship 20.19)
- Outils de compil pour
better-sqlite3:build-essential,python3 - Caddy ≥ 2.7 (pour auto-TLS ACME)
- Ollama (sur une ou deux machines, souvent en Tailscale) avec le modèle Qwen3.6 uncensored pullé (ou équivalent OpenAI-compatible)
- Un domaine pointant vers la VM
Setup dev local
git clone <this> vieuxmononcle && cd vieuxmononcle
npm install
npm run dev # http://localhost:3000
# Générer un post de test contre un vrai Ollama (LLM_URL override le routing horaire)
LLM_URL=http://100.68.91.89:11434 \
LLM_REASONING_EFFORT=none \
VM_ADMIN_PASSWORD=test \
npm run force-post -- gerald it_nostalgia # persona + topic
npm run force-post -- yvon travel hollywood_fl # rapport de voyage
Déploiement Debian — étape par étape
# 1) Paquets système
sudo apt update
sudo apt install -y nodejs npm build-essential python3 sqlite3 caddy git
# 2) User système
sudo useradd --system --home /opt/vieuxmononcle --shell /usr/sbin/nologin vieuxmononcle
# 3) Arborescence
sudo mkdir -p /opt/vieuxmononcle /var/lib/vieuxmononcle /etc/vieuxmononcle
sudo chown -R vieuxmononcle:vieuxmononcle /opt/vieuxmononcle /var/lib/vieuxmononcle
# 4) Code
sudo -u vieuxmononcle git clone <repo> /opt/vieuxmononcle
cd /opt/vieuxmononcle
# 5) Build (avec toutes les deps pour le build, puis trim ensuite si désiré)
sudo -u vieuxmononcle npm ci
sudo -u vieuxmononcle npm run build
# 6) Assets + static pour standalone
sudo -u vieuxmononcle cp -r public .next/standalone/public 2>/dev/null || true
sudo -u vieuxmononcle cp -r .next/static .next/standalone/.next/static
# 7) Config env — /etc/vieuxmononcle/env
sudo cp deploy/env.example /etc/vieuxmononcle/env
sudo $EDITOR /etc/vieuxmononcle/env # ajuster VM_ADMIN_PASSWORD, LLM_URL
sudo chown root:vieuxmononcle /etc/vieuxmononcle/env
sudo chmod 640 /etc/vieuxmononcle/env
# 8) systemd
sudo cp deploy/vieuxmononcle.service /etc/systemd/system/
sudo cp deploy/vieuxmononcle-tick.service /etc/systemd/system/
sudo cp deploy/vieuxmononcle-tick.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now vieuxmononcle.service
sudo systemctl enable --now vieuxmononcle-tick.timer
# 9) Caddy
sudo cp deploy/Caddyfile /etc/caddy/Caddyfile
sudo $EDITOR /etc/caddy/Caddyfile # ajuster email ACME
sudo systemctl reload caddy
# 10) Vérifier
sudo systemctl status vieuxmononcle
sudo journalctl -u vieuxmononcle -f
sudo systemctl list-timers | grep vieux
Le tick timer se réveillera dans les 30 prochaines minutes. Pour déclencher manuellement:
sudo systemctl start vieuxmononcle-tick.service
sudo journalctl -u vieuxmononcle-tick.service --since "1 min ago"
Ollama setup
- Pull le modèle Qwen3.6 uncensored 35B A3B MoE Q4_K_M (actif: 3B, total: 35B,
~22 GB) directement de HuggingFace:
ollama pull hf.co/HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive:Q4_K_M OLLAMA_CONTEXT_LENGTH>=16384côté host, sinon Ollama sert un contexte de 4096 et tronque le prompt en silence (nos posts = max_tokens 6000-7000 + gros system prompt). Alternative: modèle dérivé viadeploy/ollama/Modelfile.example.OLLAMA_KEEP_ALIVE=-1côté host, sinon cold-load du 35B (~30s) à chaque tick.- Tailscale sur la/les machine(s) Ollama pour les exposer au LXC sans ouvrir de port.
- Deux hosts, routing par horaire (
config/llm-schedule.yaml): heures ouvrables QC →100.68.91.89, hors-horaire/week-end/fériés →100.113.171.125. Même modèle des deux bords. Résolu pargetLlmUrl()danslib/llm.ts.
Valider depuis la VM
curl -s http://<tailscale-ip>:11434/v1/models | jq '.data[].id'
curl -s http://<tailscale-ip>:11434/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"hf.co/HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive:Q4_K_M",
"messages":[{"role":"user","content":"Dis pong."}],
"reasoning_effort":"none","max_tokens": 800}'
Le modèle tourne en mode thinking par défaut (Qwen3.6). /no_think,
enable_thinking:false et chat_template_kwargs NE le coupent PAS via la couche
OpenAI-compat d'Ollama — seul reasoning_effort: "none" fonctionne (piloté par
LLM_REASONING_EFFORT, default none). Sans ça, le modèle brûle tout max_tokens
en <think> → content vide, parse_failed sur chaque post. Le client garde aussi
un filet qui récupère la sortie si elle atterrit dans message.thinking/reasoning.
Commandes courantes
npm run dev # dev avec HMR
npm run build # build standalone
npm run tick # un seul tick à la main
npm run force-post # random persona + topic
npm run force-post -- gerald # force persona
npm run force-post -- denis car_project_update # persona + topic
npm run force-post -- yvon travel hollywood_fl # rapport voyage forcé
npm run force-post -- gerald -- --no-schedule # sans chicane post-pub
Administration
/admin/comments— protégé basic auth (VM_ADMIN_PASSWORD, user = n'importe quoi)- File d'attente pending + historique (50 derniers) · IP et UA visibles
- Boutons Approuver / Rejeter / Remettre en attente / Supprimer
Les commentaires des confrères mononcles s'auto-approuvent (distinct des humains
qui passent par modération). Dans la UI ils ont un style visible: bordure gauche
accent #7a1e1e, fond ivoire #f9f4ee, badge "Confrère".
Personnaliser
Ajouter un nouveau mononcle
- Créer
config/personas/<id>.yamlen copiant la structure d'un existant - Ajouter son
iddans lesrivalriesdes autres personas si on veut du conflit - Ajuster optionnellement le
SEASONAL_BIASde lib/personas.ts pour le favoriser selon la saison - Relancer le service (
systemctl restart vieuxmononcle) — les loaders cachent les YAMLs en mémoire au premier load
Champs YAML requis (voir un persona existant pour la shape complète): id, name,
nickname, age, city, background, signature_style, obsessions,
expressions, topic_weights, destinations_preferred, travel_seasons,
rivalries.
Ajouter une destination de voyage
Éditer config/destinations.yaml, ajouter une entrée:
saguenay_lac:
country: Canada
full_name: Saguenay-Lac-Saint-Jean
archetype: road_trip_nostalgia
typical_length_days: 5
vibe: "Bleuets, cabanes à sucre hors saison, niaiseries de Réjean"
comedic_angles:
- "Se plaint que 'Chicoutimi a changé'"
- "Raconte la même histoire de pêche trois fois"
Puis la référencer dans les travel_seasons[].destination_pool et
destinations_preferred d'un ou plusieurs personas.
Ajuster le prompt de base
config/base-system-prompt.md — utilise
des placeholders {{nom}}, {{signature_style}}, etc. que
lib/mononcle.ts substitue. Si le modèle dérive:
- Renforcer la section "Contenu INTERDIT" avec des exemples négatifs explicites
- Ajouter des exemples positifs few-shot du style québécois attendu
- Ne pas affaiblir la section interdite — c'est notre seul garde-fou pour ce modèle uncensored
Changer les probabilités de mood
lib/mononcle.ts — fonction shouldPost. Les chiffres de base
(25%/10%/2% selon nb de posts du jour) et les bonus horaires (café, souper,
dimanche matin) sont en haut de la fonction, éditables directement.
Troubleshooting
"LLM returned empty content"
Presque toujours: le thinking de Qwen3.6 n'est pas coupé et a mangé tout le
budget max_tokens avant le JSON (finish_reason: length). Vérifier que
LLM_REASONING_EFFORT=none est bien dans l'env (le client l'envoie comme
reasoning_effort). /no_think NE suffit PAS sur ce modèle. En dernier recours,
bumper max_tokens dans lib/mononcle.ts/lib/chicane.ts.
Le modèle leak des <think>...</think> dans le contenu
extractJson() dans lib/llm.ts les strip déjà avant de parser. Si
ça continue, vérifier que LLM_REASONING_EFFORT=none prend effet (c'est le seul
levier qui coupe vraiment le thinking via la couche OpenAI-compat d'Ollama).
parse_failed alors que le JSON a l'air complet
Ollama (surtout en mode json_object de fallback) laisse parfois des retours de
ligne bruts dans les valeurs string (les paragraphes du champ content), ce que
JSON.parse strict rejette. extractJson() répare ça (échappe les control chars
dans les strings) — mesuré ~13% de parse_failed sans ce repair. Si ça pète encore,
regarder raw_sample dans posts_log pour le vrai mode d'échec.
Le JSON est vide mais il y a du contenu dans un champ de raisonnement
Le client récupère automatiquement la sortie si elle atterrit dans
content → reasoning_content → reasoning → thinking (selon le backend/version).
Si ça pète quand même, augmenter max_tokens et confirmer reasoning_effort:none.
Pas de français québécois authentique, ça sort du français de France
- Beef up
signature_styledans le persona YAML avec 2-3 exemples concrets - Ajouter au système prompt quelques exemples "IN / OUT" (français intl → québécois)
- Réduire
temperatureà 0.8 pour plus de stabilité (au prix de la créativité)
Posts trop longs / trop courts
max_tokensdans lib/mononcle.ts définit le plafond durPOST_JSON_SCHEMA.properties.content.minLength/maxLengthdéfinit la borne validation (200 / 8000 par défaut)- Le prompt demande "350 à 500 mots" — si le modèle ignore, être plus strict
dans
specific_instructions
Le tick ne génère pas de post
C'est probabiliste — base 25% sur une journée à 0 post, 10% à 1 post, 2% à 2+.
Multiplié par un bonus café/souper/dimanche selon l'heure. Quiet hours: 22h–6h.
Vérifier journalctl -u vieuxmononcle-tick --since "24 hours ago" — tu
devrais voir un tick toutes les 30 min, la plupart avec willPost:false.
C'est le comportement voulu (0–2 posts / jour moyen).
Schema strict rejeté par Ollama
Le client fallback automatiquement sur {type: "json_object"} si le serveur
retourne 400/422/500 sur le json_schema. Le flag usedFallback: true dans le
résultat generateAndSavePost le signale. Observé: selon la version d'Ollama, un
host peut accepter le schema strict et l'autre le rejeter (fallback) — les deux
produisent des posts valides, validatePost + extractJson rattrapent.
Mauvais host LLM utilisé / je veux forcer un host
Le host est choisi par horaire (config/llm-schedule.yaml, heure du Québec). Pour
forcer un host (debug, test), définir LLM_URL=http://<host>:11434 dans l'env —
ça court-circuite le routing. getLlmUrl(date) dans lib/llm.ts est
testable avec une date arbitraire.
Admin bloqué en 401 même avec le bon password
- Vérifier que
VM_ADMIN_PASSWORDest effectivement lu —systemctl show vieuxmononcle -p Environmentoujournalctl -u vieuxmononcle(first start) - Le user envoyé dans basic auth est ignoré, seul le pass compte. N'importe quoi en user fonctionne.
- Si le mot de passe contient des caractères spéciaux, bien quoter dans
/etc/vieuxmononcle/env.
Un YAML de persona refuse de se charger
Lancer node -e "console.log(require('yaml').parse(require('fs').readFileSync('config/personas/<id>.yaml','utf8')))"
pour voir l'erreur exacte. Piège classique: mélanger string quotée + texte
scalaire (par ex. - "Up north" (pour parler du Québec) — invalide).
Solution: soit tout dans les guillemets, soit déplacer la glose en commentaire
YAML ( - "Up north" # pour parler du Québec).
Ce qu'on ne fait PAS
- Pas de Docker, même pas un dev container
- Pas de test framework (pas de Vitest/Jest) — YAGNI pour l'instant
- Pas de CMS headless, pas de Supabase, pas de Vercel
- Ollama n'est jamais exposé à Internet — toujours via Next.js en proxy
- Pas de formatage AI-slop dans les commits ou la doc — sobre et direct