diff --git a/Makefile b/Makefile index 902e050..416a324 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,9 @@ DEPLOY_PATH := /var/www/aitestbank/dist/ # Zorgt zelf dat Node 22 (uit .nvmrc) actief is via nvm, ongeacht welke node # er in de huidige shell actief staat — anders faalt --env-file-if-exists # stilletjes op een oudere systeem-node (zoals het standaard nvm-alias 20). +# +# Publicatiedatum wordt automatisch gezet op: laatste reviewdatum + 1 week, +# afgerond naar vrijdag (wekelijkse cadans). Geen eerdere review? → eerstvolgende vrijdag. review: @if [ -z "$(URL)" ]; then \ echo "Gebruik: make review URL=https://voorbeeld.nl [SLUG=voorbeeld]"; \ diff --git a/astro.config.mjs b/astro.config.mjs index 9c36fc3..0019849 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,11 +1,13 @@ import { defineConfig } from "astro/config"; import remarkEmoji from "remark-emoji"; import remarkFlagEmoji from "./src/lib/remark-flag-emoji.mjs"; +import rehypeWrapTables from "./src/lib/rehype-wrap-tables.mjs"; export default defineConfig({ site: "https://aitestbank.nl", output: "static", markdown: { remarkPlugins: [remarkEmoji, remarkFlagEmoji], + rehypePlugins: [rehypeWrapTables], }, }); diff --git a/public/logos/loes.svg b/public/logos/loes.svg new file mode 100644 index 0000000..f39b59a --- /dev/null +++ b/public/logos/loes.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/logos/scribbr.png b/public/logos/scribbr.png new file mode 100644 index 0000000..b8bff21 Binary files /dev/null and b/public/logos/scribbr.png differ diff --git a/public/logos/verble.svg b/public/logos/verble.svg new file mode 100644 index 0000000..1b28838 --- /dev/null +++ b/public/logos/verble.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/scripts/new-review.mjs b/scripts/new-review.mjs index 07eb2ad..65e6ea5 100644 --- a/scripts/new-review.mjs +++ b/scripts/new-review.mjs @@ -29,7 +29,7 @@ */ import { chromium } from "playwright"; import Anthropic from "@anthropic-ai/sdk"; -import { mkdir, readFile, writeFile, access } from "node:fs/promises"; +import { mkdir, readFile, writeFile, access, readdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -45,6 +45,54 @@ function slugify(hostname) { .replace(/(^-|-$)/g, ""); } +/** YYYY-MM-DD in lokale tijd (middaguur om DST-randen te vermijden). */ +function formatYmd(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function parseYmd(s) { + const [y, m, d] = s.split("-").map(Number); + return new Date(y, m - 1, d, 12, 0, 0, 0); +} + +/** Schuif door tot de eerstvolgende vrijdag (blijft staan als 't al vrijdag is). */ +function toFriday(d) { + const out = new Date(d); + const day = out.getDay(); // 0 zo … 5 vr + if (day !== 5) out.setDate(out.getDate() + ((5 - day + 7) % 7)); + return out; +} + +/** + * Publicatiedatum voor een nieuw concept: laatste reviewdatum + 1 week, + * afgerond naar vrijdag (wekelijkse cadans). Geen eerdere datum? → eerstvolgende vrijdag. + */ +async function nextPublishDate() { + const dir = path.join(projectRoot, "src", "content", "reviews"); + const files = await readdir(dir); + let latest = null; + + for (const file of files) { + if (!file.endsWith(".md") || file.startsWith("_")) continue; + const text = await readFile(path.join(dir, file), "utf-8"); + // Alleen een echte frontmatter-date: "date: 2026-07-31" (geen commentaarregel) + const match = text.match(/^---\r?\n[\s\S]*?^date:\s*(\d{4}-\d{2}-\d{2})\b/m); + if (!match) continue; + if (!latest || match[1] > latest) latest = match[1]; + } + + if (latest) { + const d = parseYmd(latest); + d.setDate(d.getDate() + 7); + return formatYmd(toFriday(d)); + } + + return formatYmd(toFriday(new Date())); +} + function fillField(template, field, value) { // Laat de regel (met het voorbeeld in de comment) ongewijzigd als er niets gevonden is, // zodat de gebruiker nog steeds ziet wat er verwacht wordt en het zelf kan invullen. @@ -222,17 +270,17 @@ async function main() { // instructie-comment bovenaan het template hoort niet in een echt reviewbestand content = content.replace(/# Kopieer dit bestand[\s\S]*?koppeltekens\.\n\n/, ""); - // date/score horen pas een echte waarde te krijgen bij het publiceren. Het - // template zet ze op respectievelijk "" en 0 als leesbaar voorbeeld voor de - // handmatige workflow, maar allebei zijn dat *ongeldige* waarden voor het - // Zod-schema (date: "" faalt de date-coercion, score: 0 faalt min(1)) — dus - // hier uitcommentariëren i.p.v. laten staan, zodat het veld echt afwezig is - // (de .optional() in het schema kan er dan mee uit de voeten) maar er wél - // zichtbaar blijft staan HOE je 'm moet invullen zodra je gaat publiceren. + // Publicatiedatum: laatste bestaande review + 1 week, op vrijdag (cadans). + // draft blijft true tot je zelf klaar bent; de datum staat al klaar voor planning. + const publishDate = await nextPublishDate(); content = content.replace( /^date: ""(\s*#.*)?$/m, - "# date: 2026-01-01 # invullen zodra je draft: false zet", + `date: ${publishDate} # auto: laatste review + 1 week (vrijdag)`, ); + console.error(`Publicatiedatum: ${publishDate} (laatste review + 1 week, vrijdag)`); + + // score: 0 is ongeldig voor het Zod-schema (min(1)) — uitcommentariëren zodat + // het veld afwezig is (optional zolang draft) maar de invulinstructie blijft. content = content.replace( /^score: 0(\s*#.*)?$/m, "# score: 3 # geheel getal 1-5 (aantal robots) — invullen vóórdat je draft: false zet", diff --git a/src/assets/reviews/loes/screenshot.png b/src/assets/reviews/loes/screenshot.png new file mode 100644 index 0000000..0c853f6 Binary files /dev/null and b/src/assets/reviews/loes/screenshot.png differ diff --git a/src/assets/reviews/verble/screenshot.png b/src/assets/reviews/verble/screenshot.png new file mode 100644 index 0000000..1bef8cb Binary files /dev/null and b/src/assets/reviews/verble/screenshot.png differ diff --git a/src/content/reviews/fiskr.md b/src/content/reviews/fiskr.md index 803d7c4..4713599 100644 --- a/src/content/reviews/fiskr.md +++ b/src/content/reviews/fiskr.md @@ -4,7 +4,7 @@ company: "Fiskr" website: "https://fiskr.nl/" location: "Den Haag" category: "Boekhouding" -date: 2026-07-31 +date: 2026-08-07 draft: false # --- Specs-balkje bovenaan de review --- diff --git a/src/content/reviews/loes.md b/src/content/reviews/loes.md new file mode 100644 index 0000000..acb395d --- /dev/null +++ b/src/content/reviews/loes.md @@ -0,0 +1,130 @@ +--- +title: "Loes: Nederlandse AI-chatassistent, gratis en volledig Europees" # AI-concept, controleren! +company: "Loes" +website: "https://loes.ai/" +location: "Groningen" +category: "AI" +date: 2026-07-31 +draft: false + +# --- Specs-balkje bovenaan de review --- +for_who: "Iedereen die een Nederlandse AI-chatassistent wil gebruiken, voor ontwikkelaars ook beschikbaar via API" +price_from: "Gratis" +hosting: ":flag_eu:" + +# --- Eindoordeel --- +score: 3 +verdict: "Nog net niet zo ver" + +# --- Cover --- +logo: "/logos/loes.svg" +--- + +## Wat is het + +De Europese Unie loopt mijlenver achter op de Verenigde Staten en China als 't om +AI gaat. De huidige AVG (GDPR)-wetgeving maakt 't ook niet makkelijker om iets binnen +de grenzen van de EU te ontwikkelen. Dat weerhoudt `HostYourAI B.V.` echter niet +om wel een eigen open Nederlandse LLM aan te bieden middels een chatbot +(zoals we die kennen van ChatGPT) en een API (voor ontwikkelaars). + +## Voor wie + +Loes is bedoeld voor iedereen die de Nederlandse taal gebruikt. De chat is +gratis beschikbaar voor alle gebruikers. Voor ontwikkelaars biedt Loes ook een API +met een betaal-per-gebruikmodel. + +## Wie zit erachter + +Loes is een dienst van HostYourAI B.V. uit Groningen. + +## Prijs + +### Chat + +De Loes-chat is gratis te gebruiken en biedt 50 berichten per dag aan. Wil je +meer dan dat (en snellere toegang), dan heb je `Chat Pro` nodig, wat €18 per maand +kost. Vergelijken we dat met de andere grote spelers, dan zien we: + +| Dienst | Kosten per maand | +| ------ | ---------------: | +| Loes Pro | €18 | +| Mistral Pro | €14 | +| Claude Pro | $20 | +| ChatGPT Plus | $20 | + +_(Let op: valutaverschil)_ + +### API + +De API-versies alsook de prijzen veranderen erg snel in de AI-wereld. De genoemde +prijzen zijn dan ook de prijzen op het moment van schrijven. + +| Provider | Model | Input / 1M | Output / 1M | +| -------- | ---- | ---------: | ----------: | +| Loes | Loes Large (~22–27B) | €0,08 | €0,25 | +| Loes | Loes World 14B (1e gen) | €0,06 | €0,15 | +| Loes | 8B open (Llama/Qwen e.d.) | €0,05 | €0,12 | +| Mistral | Small 4 | $0,15 | $0,60 | +| Mistral | Large 3 | $0,50 | $1,50 | +| Mistral | Medium 3.5 | $1,50 | $7,50 | +| OpenAI | GPT-4.1 Nano (budget) | ~$0,10 | ~$0,40 | +| OpenAI | GPT-4.1 / mid flagship | ~$2,00 | ~$8,00 | +| OpenAI | GPT-5.x / Terra-achtig | ~$1,75–2,50 | ~$14–15 | +| OpenAI | GPT-5.6 Sol e.d. | ~$5,00 | ~$30,00 | +| Claude | Haiku 4.5 | $1,00 | $5,00 | +| Claude | Sonnet 4.6 | $3,00 | $15,00 | +| Claude | Sonnet 5 | $2,00 | $10,00 | Daarna $3 / $15 | +| Claude | Opus 4.x / 5 | $5,00 | $25,00 | + +Prijstechnisch is *Loes* dus uitermate competitief! + +## Hands-on indruk + +### De test + +De API biedt wrappers aan zodat het een drop-in replacement is voor huidige tools. +Ik heb een aantal tests afgevuurd waarvoor ik normaliter Claude Sonnet gebruik. De +resultaten waren niet erg goed, maar dat is waarschijnlijk omdat `Loes Large EU` geen +tool-use ondersteunt, noch caching. De output die er kwam negeerde specifieke +instructies en was kwalitatief niet te vergelijken met die van Claude. + +Na mijn nogal specifieke tests heb ik nog wat meer generieke tests gedaan met wisselend +resultaat: + +| # | Test | Oordeel | +| --- | -------------------------------------- | ---------------------------------------- | +| 1 | Feitenkennis (hoofdstad Australië) | Goed, correct antwoord + correcte reden | +| 2 | Logica-puzzel (leeftijdsvolgorde) | Faalt volledig | +| 3 | Python-palindroomfunctie | Bug | +| 4 | Samenvatten in exact 2 zinnen | Faalt op 2 vlakken | +| 5 | Exact 3 bullets, geen intro/afsluiting | Goed | +| 6 | Nobelprijs 2031 (bestaat niet) | Goed, geen hallucinatie | +| 7 | Reken-woordprobleem (trein) | Interne inconsistentie | +| 8 | Nederlandse schrijfstijl (e-mail) | Grotendeels goed | + +Bij de logicapuzzel werd het volgende gevraagd: +> Anna is ouder dan Bram. Bram is ouder dan Carla. Carla is jonger dan Anna maar ouder dan Daan. Wie is het jongst? + +Er kwam geen antwoord, slechts het verzoek om meer data aan te leveren. + +De palindroomfunctie vergat hoofdlettergebruik, zo zou "Level" onterecht niet +als palindroom gezien worden. De prompt was echter: + +> Schrijf een Python-functie die controleert of een string een palindroom is, ongevoelig voor hoofdletters en spaties. + +Bij de 4e test werd gevraagd om exact twee zinnen te gebruiken. De API negeerde dit en +gaf een paragraaf van 6 zinnen. Tevens traden hallucinaties op. + +Rekenen ging Loes ook niet goed af. 2u45 werd geïnterpreteerd als 3,75 uur, wat +natuurlijk 2,75 uur moet zijn, waardoor het antwoord fout was. + + +## Conclusie + +Ik sta te popelen om al mijn AI-abonnementen over te zetten naar een Europese speler, +helemaal als die uit Nederland komt. Helaas is *Loes* op dit moment nog geen +serieuze concurrent voor bijv. Claude. Voor dagelijks chatgebruik fungeert het +prima. Voor iets meer complexe zaken moeten we toch weer terug naar de concurrenten. + +We blijven het op de voet volgen! diff --git a/src/content/reviews/verble.md b/src/content/reviews/verble.md new file mode 100644 index 0000000..f099768 --- /dev/null +++ b/src/content/reviews/verble.md @@ -0,0 +1,91 @@ +--- +title: "Verble: AI-assistent voor preken, redes en presentaties" +company: "Verble" +website: "https://www.verble.app/" +location: "Rotterdam" +category: "Schrijven" +date: 2026-08-14 +draft: false + +for_who: "Predikanten en andere sprekers" +price_from: "$8,00 /maand" +hosting: ":question:" + +# --- Eindoordeel --- +score: 4 +verdict: "Handig voor mensen die veel spreken" + +# --- Cover --- +logo: "/logos/verble.svg" +--- + +## Wat is het + +De meeste mensen die wel eens een speech hebben moeten schrijven lopen op +een gegeven moment vast. Wat is de boodschap die ik wil overbrengen, is het +niet te lang, niet te kort, niet te saai? Verble belooft hierbij te helpen. + +## Voor wie + +Verble richt zich op predikanten, gastheren, sprekers en leiders die toespraken, +preken, troostredes of toasts moeten voorbereiden. De tool is bedoeld voor +iedereen die in een belangrijk moment de juiste woorden nodig heeft: van +zondagse preken en bruiloftspreken tot presentaties op het werk. + +## Wie zit erachter + +Verble B.V. zegt een klein gedreven team te zijn uit Rotterdam. + +## Prijs + +De dienst hanteert verschillende prijzen voor de verschillende takken die ze +aanbieden. + + +| Tak | Basis | Pro | Enkele speech | +| --- | ----: | --: | ------------: | +| Preken | $12,50 | $19,00 | - | +| Bruiloft | $8,00 | - | $30,99 | +| Werk/leven | $12,50 | - | - | + +_(Prijzen per maand, op basis van jaarlijkse facturering. Maandelijkse facturering +kan ook, maar is iets duurder)_ + +## Hands-on indruk + +### De test + +Voor de test heb ik gevraagd een rede te schrijven voor een team van software +engineers die door een moeilijke periode heen gegaan zijn, maar nu op volle +sterkte werken aan het herschrijven van oude software in een moderne tech stack, +klaar voor AI-agents. + +Na het invoeren van de beginnende tekst stelt het de gebruiker vragen. Wat is het +doel? Wil je motiveren? Waar moet de focus op liggen? Zijn de nieuwe mensen die +je noemt aanwezig? De vragen zijn geen standaard vragen, maar hebben daadwerkelijk +betrekking op je input. + +Als je klaar bent, kun je een draft genereren. Deze kun je vervolgens oefenen, +laten uitspreken of aanpassen met AI. Als je klaar bent, kun je de tekst +downloaden, uiteraard tegen betaling. + +Ik vroeg om een motiverende, energieke tekst en die heb ik gekregen. Het is oprecht +motiverend en zit professioneel in elkaar. + +## Conclusie + +Als je veel presentaties of speeches geeft, lijkt me dit een uitstekende tool +om te gebruiken. Maar aangezien het een abonnementsvorm kent, lijkt het me +enkel nuttig als je het veel gebruikt. Voor een enkele speech lijkt me dat je +ook wel wegkomt met standaard ChatGPT, Claude of Grok. Een enkele trouwrede +kun je overigens ook eenmalig afrekenen, zonder abonnement. + +De output is professioneel en erg aanpasbaar. Of het werkelijk veel toevoegt +ten opzichte van een chatbot als ChatGPT/Claude/Grok weet ik niet. Het werkt +wel erg snel, makkelijk en fijn. + +De predikantversie heb ik niet getest, aangezien ik (als ongelovige) geen idee +heb hoe ik de output zou moeten beoordelen. + +Jammer dat de taal (van de website) alleen in het Engels en Spaans weergegeven +kan worden. diff --git a/src/lib/publish.ts b/src/lib/publish.ts index 734f020..5bc33c0 100644 --- a/src/lib/publish.ts +++ b/src/lib/publish.ts @@ -1,8 +1,14 @@ // Static site: "nu" is bevroren op het moment van de laatste build. Een // review met draft: false en een datum in de toekomst is dus pas écht // zichtbaar zodra er ná die datum opnieuw gebouwd + gedeployed wordt. +// +// In `npm run dev` mag je een geplande review gewoon alvast zien (handig om +// 'm te proppen zonder de systeemklok te verzetten) — in een echte build +// (import.meta.env.DEV is dan false) geldt de datumcheck onverkort. export function isPublished(data: { draft: boolean; date?: Date }): boolean { - return !data.draft && !!data.date && data.date.getTime() <= Date.now(); + if (data.draft) return false; + if (import.meta.env.DEV) return true; + return !!data.date && data.date.getTime() <= Date.now(); } export function isScheduled(data: { draft: boolean; date?: Date }): boolean { diff --git a/src/lib/rehype-wrap-tables.mjs b/src/lib/rehype-wrap-tables.mjs new file mode 100644 index 0000000..890d6b3 --- /dev/null +++ b/src/lib/rehype-wrap-tables.mjs @@ -0,0 +1,27 @@ +/** + * Wikkel elke in
zodat brede markdown-tabellen + * horizontaal kunnen scrollen zonder de table-layout te breken. + */ +export default function rehypeWrapTables() { + return (tree) => { + walk(tree); + }; +} + +function walk(node) { + if (!node?.children?.length) return; + + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + if (child.type === "element" && child.tagName === "table") { + node.children[i] = { + type: "element", + tagName: "div", + properties: { className: ["table-wrap"] }, + children: [child], + }; + continue; + } + walk(child); + } +} diff --git a/src/pages/index.astro b/src/pages/index.astro index b643250..e6b8f00 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -50,7 +50,7 @@ const dateFormatter = new Intl.DateTimeFormat("nl-NL", { size="thumb" />
- {live ? dateFormatter.format(post.data.date) : "—"} + {live && post.data.date ? dateFormatter.format(post.data.date) : "—"} · {emojify(post.data.category)} {live ? "gepubliceerd" : "binnenkort"} diff --git a/src/pages/reviews/[slug].astro b/src/pages/reviews/[slug].astro index 7da3849..68b178c 100644 --- a/src/pages/reviews/[slug].astro +++ b/src/pages/reviews/[slug].astro @@ -208,9 +208,99 @@ const dateFormatter = new Intl.DateTimeFormat("nl-NL", { } .body :global(blockquote p) { margin: 14px 0; } + /* Markdown-tabellen: zelfde breedte als lopende tekst (64ch) */ + .body :global(.table-wrap) { + max-width: 64ch; + margin: 0 0 24px; + border: 1px solid var(--line); + border-radius: var(--radius); + overflow-x: auto; + -webkit-overflow-scrolling: touch; + background: var(--paper); + } + .body :global(.table-wrap table) { + width: 100%; + margin: 0; + border-collapse: separate; + border-spacing: 0; + font-size: 15px; + line-height: 1.45; + border: none; + background: transparent; + } + .body :global(caption) { + caption-side: bottom; + padding: 10px 14px 0; + font-family: var(--mono); + font-size: 12.5px; + color: var(--muted); + text-align: left; + } + .body :global(thead) { + background: var(--paper-raised); + } + .body :global(th), + .body :global(td) { + padding: 11px 14px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--line); + } + .body :global(th) { + font-family: var(--mono); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + white-space: nowrap; + } + .body :global(th:not(:last-child)), + .body :global(td:not(:last-child)) { + border-right: 1px solid var(--line); + } + .body :global(tbody tr:last-child th), + .body :global(tbody tr:last-child td) { + border-bottom: none; + } + .body :global(tbody tr:nth-child(even)) { + background: color-mix(in srgb, var(--paper-raised) 55%, transparent); + } + .body :global(tbody tr:hover) { + background: var(--accent-soft); + } + /* Eerste kolom vaak een label (tool / plan / model) */ + .body :global(tbody th), + .body :global(tbody td:first-child) { + font-weight: 600; + color: var(--ink); + } + .body :global(td .tabnum), + .body :global(th .tabnum) { + font-variant-numeric: tabular-nums; + } + .body :global(td code), + .body :global(th code) { + font-family: var(--mono); + font-size: 0.9em; + font-weight: 500; + background: var(--paper-raised); + border: 1px solid var(--line); + border-radius: 3px; + padding: 0.1em 0.35em; + } + @media (max-width: 620px) { .specs { grid-template-columns: 1fr 1fr; } .verdict { grid-template-columns: 1fr; } .verdict .score-block { text-align: left; } + .body :global(th), + .body :global(td) { + padding: 9px 11px; + font-size: 14px; + } + .body :global(th) { + white-space: normal; + } }