SPB Git forge

spb/trouve-ka

Public ★ Pinned

Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com

40commits 1branches 0releases
9.2 MBsize
maindefault branch
22 days agolast push
Python 51.9% TypeScript 32.7% JavaScript 4.8% CSS 4.1% Shell 2.9% HTML 1.8% SQL 1.5%

Upgrade majeur UI + options de recherche + réparation d'encodage

UI : thème clair style Lou·Ka (blanc/navy/orange, ombres douces), mode sombre
navy avec toggle persistant, Fraunces en accents, suggestions instantanées
navigables au clavier (/api/suggest), filtres fraîcheur + Éducation, panneau
de filtres compact sur mobile, recherches liées, pagination numérotée,
raccourci « / », badge recherche sémantique/IA, meter de couverture
sémantique sur /status.

Encodage : decode_html privilégie l'UTF-8 strict sur le charset HTTP menteur
(10 000+ docs avaient « é » au lieu de « é »), fix_mojibake répare le double
encodage (y compris contenu mixte, token par token), script de réparation du
stock avec curseur search_after.

API : /api/suggest (titres + requêtes populaires), related dans /api/search,
embedding_coverage dans /api/status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 16, 2026) parent 708a9ba

24 changed files +1,137 −172

modified apps/api/main.py +41 −1
@@ -87,6 +87,7 @@ BADGE_LABELS = {"government": "Gouvernement", "news": "Actualités", "education"
87 87 # et dégradent le top-10 au lieu de l'améliorer.
88 88 KNN_MIN_COVERAGE = 0.6
89 89 _knn_ready_cache: dict = {"at": 0.0, "ready": False}
90 +_coverage_cache: dict = {"at": -120.0, "value": 0.0}
90 91
91 92
92 93 async def _knn_ready() -> bool:
@@ -106,6 +107,31 @@ async def health():
106 107 return {"ok": True, "search_ok": await search.ping()}
107 108
108 109
110 +@app.get("/api/suggest")
111 +async def api_suggest(q: str = Query(..., min_length=1, max_length=120)):
112 + """Autocomplete : requêtes populaires réelles + complétions de titres indexés.
113 +
114 + Best-effort et rapide : chaque source est optionnelle, l'échec de l'une
115 + n'empêche pas l'autre (§13)."""
116 + prefix = re.sub(r"\s+", " ", q).strip()[:100]
117 + if len(prefix) < 2:
118 + return {"suggestions": []}
119 + popular_task = asyncio.create_task(db.suggest_queries(prefix, limit=4))
120 + titles_task = asyncio.create_task(search.suggest_titles(prefix, limit=6))
121 + suggestions: list[str] = []
122 + seen: set[str] = set()
123 + for task in (popular_task, titles_task):
124 + try:
125 + for s in await asyncio.wait_for(task, timeout=0.4):
126 + key = s.lower()
127 + if key != prefix.lower() and key not in seen:
128 + seen.add(key)
129 + suggestions.append(s)
130 + except Exception:
131 + continue
132 + return {"suggestions": suggestions[:8]}
133 +
134 +
109 135 @app.get("/api/search")
110 136 async def api_search(
111 137 q: str = Query(..., min_length=1, max_length=200),
@@ -222,8 +248,17 @@ async def api_search(
222 248 except Exception:
223 249 log.exception("analytics de recherche sautées")
224 250
251 + # Recherches liées (page 1 seulement) — best-effort, jamais bloquant
252 + related: list[str] = []
253 + if page == 1:
254 + try:
255 + terms = re.findall(r"[\w'-]{3,}", q.lower(), re.UNICODE)
256 + related = await asyncio.wait_for(db.related_queries(terms, exclude=q), timeout=0.3)
257 + except Exception:
258 + related = []
259 +
225 260 return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit,
226 − "semantic": semantic, "reranked": reranked, "results": results}
261 + "semantic": semantic, "reranked": reranked, "related": related, "results": results}
227 262
228 263
229 264 @app.get("/api/status")
@@ -238,6 +273,10 @@ async def api_status():
238 273 paused = await coord.is_paused()
239 274 except Exception:
240 275 paused = False
276 + # Couverture sémantique (part de l'index avec embedding) — cache 60 s
277 + now = time.monotonic()
278 + if now - _coverage_cache["at"] > 60:
279 + _coverage_cache.update(at=now, value=await search.embedding_coverage())
241 280 return {
242 281 "pages_indexed": snapshot.get("pages_indexed", 0),
243 282 "domains_count": snapshot.get("domains_count", 0),
@@ -248,6 +287,7 @@ async def api_status():
248 287 "frontier_in_progress": snapshot.get("frontier_in_progress", 0),
249 288 "crawler_state": "paused" if paused else "running",
250 289 "search_ok": search_ok,
290 + "embedding_coverage": round(_coverage_cache["value"], 4),
251 291 }
252 292
253 293
modified apps/web/app/globals.css +232 −35
@@ -13,34 +13,85 @@
13 13 @tailwind utilities;
14 14
15 15 :root {
16 − --paper: #f5f3ee;
16 + /* Thème clair « Lou·Ka » : blanc, encre navy, accent orange, liens bleus,
17 + coins doux et ombres diffuses (fini les ombres décalées en clair). */
18 + --paper: #ffffff;
17 19 --surface: #ffffff;
18 − --surface-2: #faf9f5;
19 − --ink: #141814;
20 − --ink-2: #4d5551;
21 − --ink-3: #8b928c;
22 − --line: rgba(20, 24, 20, 0.14);
23 − --line-strong: rgba(20, 24, 20, 0.85);
24 − --green: #1c5c41;
25 − --green-deep: #123f2e;
26 − --lime: #b9cfee;
27 − --lime-soft: #e6eef9;
28 − --amber: #e8a33d;
29 − --amber-soft: #fdf3e2;
30 − --danger: #b3423a;
31 − --danger-soft: #fbe9e7;
32 −
33 − --r-card: 10px;
34 − --r-ctl: 6px;
35 − --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
36 − --shadow-off: 6px 6px 0 var(--ink);
37 − --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
20 + --surface-2: #f4f5f8;
21 + --ink: #0b1330;
22 + --ink-2: #3e4560;
23 + --ink-3: #8a90a5;
24 + --line: rgba(11, 19, 48, 0.1);
25 + --line-strong: rgba(11, 19, 48, 0.16);
26 + --edge: rgba(11, 19, 48, 0.14);
27 + --green: #2258ff;
28 + --green-deep: #0b1330;
29 + --lime: #ff6a00;
30 + --lime-soft: #fff1e6;
31 + --blue-soft: #e9efff;
32 + --amber: #ff6a00;
33 + --amber-soft: #fff1e6;
34 + --danger: #d64545;
35 + --danger-soft: #fdecec;
36 +
37 + --paper-glass: rgba(255, 255, 255, 0.88);
38 +
39 + --r-card: 16px;
40 + --r-ctl: 10px;
41 + --shadow-flat: 0 1px 3px rgba(11, 19, 48, 0.06);
42 + --shadow-off: 0 16px 40px rgba(11, 19, 48, 0.14);
43 + --shadow-off-soft: 0 8px 24px rgba(11, 19, 48, 0.08);
44 + --shadow-btn: 0 4px 14px rgba(11, 19, 48, 0.18);
45 + --shadow-field: 0 2px 10px rgba(11, 19, 48, 0.06);
46 +
47 + --btn-primary-bg: var(--ink);
48 + --btn-primary-fg: #ffffff;
49 + --btn-primary-hover: #22315e;
38 50
39 51 --font-display: var(--font-space-grotesk), system-ui, sans-serif;
52 + --font-serif: var(--font-fraunces), Georgia, serif;
40 53 --font-body: var(--font-inter), system-ui, sans-serif;
41 54 --font-mono: var(--font-jetbrains), ui-monospace, monospace;
42 55 }
43 56
57 +/* ---------- Mode sombre — navy profond, mêmes accents ---------- */
58 +[data-theme="dark"] {
59 + --paper: #0a0f22;
60 + --surface: #121834;
61 + --surface-2: #0e142c;
62 + --ink: #edf0f7;
63 + --ink-2: #b3bacf;
64 + --ink-3: #7b83a0;
65 + --line: rgba(237, 240, 247, 0.12);
66 + --line-strong: rgba(237, 240, 247, 0.24);
67 + --edge: rgba(237, 240, 247, 0.16);
68 + --green: #7da2ff;
69 + --green-deep: #0a0f22;
70 + --lime: #ff7d1f;
71 + --lime-soft: #3a2408;
72 + --blue-soft: #16224a;
73 + --amber: #ff7d1f;
74 + --amber-soft: #3a2408;
75 + --danger: #e57373;
76 + --danger-soft: #3a1414;
77 +
78 + --paper-glass: rgba(10, 15, 34, 0.86);
79 +
80 + --shadow-flat: 0 1px 3px rgba(0, 0, 0, 0.45);
81 + --shadow-off: 0 16px 40px rgba(0, 0, 0, 0.5);
82 + --shadow-off-soft: 0 8px 24px rgba(0, 0, 0, 0.4);
83 + --shadow-btn: 0 4px 14px rgba(0, 0, 0, 0.5);
84 + --shadow-field: 0 2px 10px rgba(0, 0, 0, 0.4);
85 +
86 + /* Bouton primaire inversé : accent orange, texte sombre */
87 + --btn-primary-bg: var(--lime);
88 + --btn-primary-fg: #0a0f22;
89 + --btn-primary-hover: #ff9346;
90 +}
91 +[data-theme="dark"] body::before {
92 + opacity: 0.22;
93 +}
94 +
44 95 @layer base {
45 96 html {
46 97 -webkit-text-size-adjust: 100%;
@@ -144,7 +195,7 @@
144 195
145 196 .gk-card {
146 197 background: var(--surface);
147 − border: 1.5px solid var(--ink);
198 + border: 1px solid var(--edge);
148 199 border-radius: var(--r-card);
149 200 box-shadow: var(--shadow-off-soft);
150 201 }
@@ -167,7 +218,7 @@
167 218 gap: 8px;
168 219 min-height: 44px;
169 220 padding: 10px 20px;
170 − border: 1.5px solid var(--ink);
221 + border: 1px solid var(--edge);
171 222 border-radius: var(--r-ctl);
172 223 font-family: var(--font-display);
173 224 font-size: 14px;
@@ -184,12 +235,144 @@
184 235 box-shadow: none !important;
185 236 }
186 237 .btn-primary {
187 − background: var(--ink);
188 − color: var(--lime);
189 − box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.25);
238 + background: var(--btn-primary-bg);
239 + color: var(--btn-primary-fg);
240 + box-shadow: var(--shadow-btn);
190 241 }
191 242 .btn-primary:hover {
192 − background: var(--green-deep);
243 + background: var(--btn-primary-hover);
244 + }
245 +
246 + /* Bouton « Chercher » incrusté dans le champ */
247 + .btn-search {
248 + font-family: var(--font-display);
249 + font-weight: 700;
250 + border: 1px solid var(--edge);
251 + border-radius: 5px;
252 + background: var(--btn-primary-bg);
253 + color: var(--btn-primary-fg);
254 + transition: background 0.15s ease;
255 + }
256 + .btn-search:hover {
257 + background: var(--btn-primary-hover);
258 + }
259 +
260 + /* ---------- Pastilles de filtre (thème-aware) ---------- */
261 + .pill {
262 + font-family: var(--font-mono);
263 + white-space: nowrap;
264 + border: 1px solid var(--edge);
265 + border-radius: 999px;
266 + padding: 6px 14px;
267 + font-size: 11px;
268 + font-weight: 700;
269 + text-transform: uppercase;
270 + letter-spacing: 0.08em;
271 + background: var(--surface);
272 + color: var(--ink-2);
273 + box-shadow: var(--shadow-flat);
274 + transition:
275 + background 0.15s ease,
276 + color 0.15s ease,
277 + transform 0.15s ease,
278 + box-shadow 0.15s ease;
279 + }
280 + .pill:hover {
281 + background: var(--lime-soft);
282 + color: var(--ink);
283 + transform: translateY(-1px);
284 + box-shadow: var(--shadow-field);
285 + }
286 + .pill:active {
287 + transform: translateY(0);
288 + box-shadow: none;
289 + }
290 + .pill-active {
291 + background: var(--btn-primary-bg);
292 + color: var(--btn-primary-fg);
293 + }
294 + .pill-active:hover {
295 + background: var(--btn-primary-hover);
296 + color: var(--btn-primary-fg);
297 + }
298 +
299 + /* ---------- Suggestions (combobox) ---------- */
300 + .suggest-panel {
301 + position: absolute;
302 + left: 0;
303 + right: 0;
304 + top: calc(100% + 8px);
305 + z-index: 50;
306 + overflow: hidden;
307 + background: var(--surface);
308 + border: 1px solid var(--edge);
309 + border-radius: var(--r-card);
310 + box-shadow: var(--shadow-off-soft);
311 + animation: rise 0.18s cubic-bezier(0.2, 0.8, 0.2, 1) both;
312 + }
313 + .suggest-item {
314 + display: flex;
315 + align-items: center;
316 + gap: 10px;
317 + width: 100%;
318 + padding: 10px 16px;
319 + font-size: 14px;
320 + text-align: left;
321 + color: var(--ink-2);
322 + cursor: pointer;
323 + }
324 + .suggest-item[aria-selected="true"],
325 + .suggest-item:hover {
326 + background: var(--lime-soft);
327 + color: var(--ink);
328 + }
329 + .suggest-item mark {
330 + background: none;
331 + color: var(--ink);
332 + font-weight: 700;
333 + }
334 +
335 + /* ---------- Divers ---------- */
336 + .kbd {
337 + display: inline-block;
338 + min-width: 20px;
339 + padding: 1px 6px;
340 + border: 1px solid var(--edge);
341 + border-bottom-width: 3px;
342 + border-radius: 5px;
343 + font-family: var(--font-mono);
344 + font-size: 11px;
345 + font-weight: 700;
346 + text-align: center;
347 + color: var(--ink-2);
348 + background: var(--surface);
349 + }
350 + .serif-accent {
351 + font-family: var(--font-serif);
352 + font-style: italic;
353 + font-weight: 500;
354 + letter-spacing: -0.01em;
355 + }
356 + .chip-link {
357 + display: inline-flex;
358 + align-items: center;
359 + gap: 7px;
360 + border: 1px solid var(--edge);
361 + border-radius: 999px;
362 + padding: 7px 15px;
363 + font-size: 13px;
364 + font-weight: 550;
365 + color: var(--ink-2);
366 + background: var(--surface);
367 + transition:
368 + background 0.15s ease,
369 + color 0.15s ease,
370 + transform 0.15s ease;
371 + }
372 + .chip-link:hover {
373 + background: var(--lime-soft);
374 + color: var(--ink);
375 + transform: translateY(-1px);
193 376 }
194 377 .btn-ghost {
195 378 background: transparent;
@@ -201,7 +384,7 @@
201 384 .btn-lime {
202 385 background: var(--lime);
203 386 color: var(--ink);
204 − box-shadow: 4px 4px 0 rgba(185, 207, 238, 0.25);
387 + box-shadow: var(--shadow-btn);
205 388 }
206 389 .btn-lime:hover {
207 390 background: var(--lime-soft);
@@ -214,12 +397,12 @@
214 397 width: 100%;
215 398 padding: 11px 14px;
216 399 background: var(--surface);
217 − border: 1.5px solid var(--ink);
400 + border: 1px solid var(--edge);
218 401 border-radius: var(--r-ctl);
219 402 font-family: var(--font-body);
220 403 font-size: 14px;
221 404 color: var(--ink);
222 − box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.08);
405 + box-shadow: var(--shadow-field);
223 406 transition: box-shadow 0.15s ease;
224 407 }
225 408 .field::placeholder {
@@ -228,7 +411,7 @@
228 411 .field:focus {
229 412 outline: 2px solid var(--green);
230 413 outline-offset: 1px;
231 − box-shadow: 3px 3px 0 rgba(28, 92, 65, 0.25);
414 + box-shadow: 0 0 0 3px var(--blue-soft);
232 415 }
233 416
234 417 /* ---------- Pilules de stats ---------- */
@@ -240,12 +423,12 @@
240 423 white-space: nowrap;
241 424 padding: 8px 14px;
242 425 background: var(--surface);
243 − border: 1.5px solid var(--ink);
426 + border: 1px solid var(--edge);
244 427 border-radius: 999px;
245 428 font-family: var(--font-mono);
246 429 font-size: 12px;
247 430 font-weight: 700;
248 − box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);
431 + box-shadow: var(--shadow-flat);
249 432 }
250 433
251 434 .pulse-dot {
@@ -268,7 +451,7 @@
268 451 /* ---------- Tableau éditorial ---------- */
269 452
270 453 .src-wrap {
271 − border: 1.5px solid var(--ink);
454 + border: 1px solid var(--edge);
272 455 border-radius: var(--r-card);
273 456 box-shadow: var(--shadow-off-soft);
274 457 overflow-x: auto;
@@ -289,7 +472,7 @@
289 472 color: var(--ink-3);
290 473 background: var(--surface-2);
291 474 padding: 10px 14px;
292 − border-bottom: 1.5px solid var(--ink);
475 + border-bottom: 1px solid var(--line-strong);
293 476 }
294 477 .src-table td {
295 478 padding: 12px 14px;
@@ -354,3 +537,17 @@
354 537 scroll-behavior: auto;
355 538 }
356 539 }
540 +
541 +/* Entrée en cascade des résultats (respecte prefers-reduced-motion) */
542 +@media (prefers-reduced-motion: no-preference) {
543 + .stagger > * {
544 + opacity: 0;
545 + animation: rise 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
546 + }
547 + .stagger > *:nth-child(1) { animation-delay: 0.02s; }
548 + .stagger > *:nth-child(2) { animation-delay: 0.06s; }
549 + .stagger > *:nth-child(3) { animation-delay: 0.1s; }
550 + .stagger > *:nth-child(4) { animation-delay: 0.14s; }
551 + .stagger > *:nth-child(5) { animation-delay: 0.18s; }
552 + .stagger > *:nth-child(n + 6) { animation-delay: 0.22s; }
553 +}
modified apps/web/app/layout.tsx +24 −11
@@ -5,10 +5,11 @@
5 5 */
6 6
7 7 import type { Metadata, Viewport } from "next";
8 −import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google";
8 +import { Fraunces, Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google";
9 9 import Link from "next/link";
10 10 import { Wordmark } from "@/components/wordmark";
11 11 import { LiveCrawl } from "@/components/live-crawl";
12 +import { ThemeToggle } from "@/components/theme-toggle";
12 13 import "./globals.css";
13 14
14 15 const spaceGrotesk = Space_Grotesk({
@@ -20,6 +21,14 @@ const jetbrains = JetBrains_Mono({
20 21 subsets: ["latin"],
21 22 variable: "--font-jetbrains",
22 23 });
24 +const fraunces = Fraunces({
25 + subsets: ["latin"],
26 + style: ["italic", "normal"],
27 + variable: "--font-fraunces",
28 +});
29 +
30 +/** Applique le thème avant le premier rendu (aucun flash clair/sombre). */
31 +const THEME_INIT = `(function(){try{var t=localStorage.getItem("tk-theme");if(!t)t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=t;}catch(e){}})();`;
23 32
24 33 export const metadata: Metadata = {
25 34 metadataBase: new URL(process.env.PUBLIC_URL ?? "https://www.trouve-ka.com"),
@@ -32,7 +41,7 @@ export const metadata: Metadata = {
32 41 };
33 42
34 43 export const viewport: Viewport = {
35 − themeColor: "#141814",
44 + themeColor: "#0b1330",
36 45 width: "device-width",
37 46 initialScale: 1,
38 47 };
@@ -56,10 +65,11 @@ export default function RootLayout({
56 65 children,
57 66 }: Readonly<{ children: React.ReactNode }>) {
58 67 return (
59 − <html lang="fr">
68 + <html lang="fr" suppressHydrationWarning>
60 69 <body
61 − className={`${spaceGrotesk.variable} ${inter.variable} ${jetbrains.variable} flex min-h-screen flex-col bg-paper font-body text-ink antialiased`}
70 + className={`${spaceGrotesk.variable} ${inter.variable} ${jetbrains.variable} ${fraunces.variable} flex min-h-screen flex-col bg-paper font-body text-ink antialiased`}
62 71 >
72 + <script dangerouslySetInnerHTML={{ __html: THEME_INIT }} />
63 73 <a
64 74 href="#contenu"
65 75 className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-ctl focus:border-[1.5px] focus:border-ink focus:bg-ink focus:px-4 focus:py-2 focus:font-display focus:text-sm focus:font-bold focus:text-lime"
@@ -67,7 +77,7 @@ export default function RootLayout({
67 77 Aller au contenu principal
68 78 </a>
69 79
70 − <header className="sticky top-0 z-40 border-b-2 border-ink bg-paper md:bg-[rgba(245,243,238,0.9)] md:backdrop-blur-xl">
80 + <header className="sticky top-0 z-40 border-b border-line bg-paper md:bg-[var(--paper-glass)] md:backdrop-blur-xl">
71 81 <div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6">
72 82 <Link href="/" className="group rounded-sm" aria-label="Trouve-KA — accueil">
73 83 <Wordmark />
@@ -90,6 +100,9 @@ export default function RootLayout({
90 100 Soumettre un site
91 101 </Link>
92 102 </li>
103 + <li>
104 + <ThemeToggle />
105 + </li>
93 106 </ul>
94 107 </nav>
95 108 </div>
@@ -97,11 +110,11 @@ export default function RootLayout({
97 110
98 111 <div className="flex flex-1 flex-col">{children}</div>
99 112
100 − <footer className="border-t-2 border-ink bg-ink py-9 text-[13px] text-[rgba(245,243,238,0.75)]">
113 + <footer className="border-t border-[#0b1330] bg-[#0b1330] py-9 text-[13px] text-[rgba(237,240,247,0.75)]">
101 114 <div className="mx-auto max-w-6xl px-4 sm:px-6">
102 115 <div className="flex flex-wrap items-baseline justify-between gap-x-8 gap-y-3">
103 116 <Wordmark variant="footer" />
104 − <p className="gk-display text-[15px] font-bold text-[rgba(245,243,238,0.85)]">
117 + <p className="gk-display text-[15px] font-bold text-[rgba(237,240,247,0.85)]">
105 118 Un moteur de recherche par{" "}
106 119 <a
107 120 href="https://www.groupe-ka.com"
@@ -109,7 +122,7 @@ export default function RootLayout({
109 122 rel="noopener noreferrer"
110 123 className="whitespace-nowrap underline-offset-4 hover:underline hover:decoration-lime"
111 124 >
112 − <span className="text-paper">Groupe</span>{" "}
125 + <span className="text-white">Groupe</span>{" "}
113 126 <span className="text-lime">KA</span>
114 127 </a>
115 128 </p>
@@ -124,7 +137,7 @@ export default function RootLayout({
124 137 <li key={link.href}>
125 138 <Link
126 139 href={link.href}
127 − className="rounded-sm text-[rgba(245,243,238,0.75)] underline-offset-4 transition-colors hover:text-lime hover:underline hover:decoration-lime"
140 + className="rounded-sm text-[rgba(237,240,247,0.75)] underline-offset-4 transition-colors hover:text-lime hover:underline hover:decoration-lime"
128 141 >
129 142 {link.label}
130 143 </Link>
@@ -139,14 +152,14 @@ export default function RootLayout({
139 152 href={href}
140 153 target="_blank"
141 154 rel="noopener noreferrer"
142 − className="text-[rgba(245,243,238,0.6)] underline-offset-4 transition-colors hover:text-lime hover:underline hover:decoration-lime"
155 + className="text-[rgba(237,240,247,0.6)] underline-offset-4 transition-colors hover:text-lime hover:underline hover:decoration-lime"
143 156 >
144 157 {label}
145 158 </a>
146 159 </li>
147 160 ))}
148 161 </ul>
149 − <p className="gk-mono mt-7 border-t border-[rgba(245,243,238,0.15)] pt-5 text-[11px] text-[rgba(245,243,238,0.45)]">
162 + <p className="gk-mono mt-7 border-t border-[rgba(237,240,247,0.15)] pt-5 text-[11px] text-[rgba(237,240,247,0.45)]">
150 163 © 2026 Trouve-KA — Simon-Pierre Boucher ·{" "}
151 164 <a
152 165 href="mailto:contact@spboucher.ai"
modified apps/web/app/page.tsx +27 −3
@@ -12,13 +12,28 @@ export default function HomePage() {
12 12 return (
13 13 <main
14 14 id="contenu"
15 − className="flex flex-1 flex-col items-center justify-center px-4 py-14"
15 + className="relative flex flex-1 flex-col items-center justify-center overflow-hidden px-4 py-14"
16 16 >
17 − <div className="rise w-full max-w-xl text-center">
17 + {/* Halo éditorial discret derrière le héros */}
18 + <div
19 + aria-hidden="true"
20 + className="pointer-events-none absolute left-1/2 top-1/2 h-[540px] w-[540px] -translate-x-1/2 -translate-y-[58%] rounded-full opacity-60 blur-3xl"
21 + style={{
22 + background:
23 + "radial-gradient(closest-side, var(--lime-soft), transparent 72%)",
24 + }}
25 + />
26 +
27 + <div className="rise relative w-full max-w-xl text-center">
18 28 <h1>
19 29 <Wordmark variant="hero" />
20 30 </h1>
21 − <div className="mt-10">
31 + <p className="serif-accent mt-4 text-[19px] text-ink-2 sm:text-[21px]">
32 + Le moteur de recherche <span className="text-ink">indépendant</span> du
33 + web québécois.
34 + </p>
35 +
36 + <div className="mt-9">
22 37 <SearchBar
23 38 size="lg"
24 39 autoFocus
@@ -29,15 +44,24 @@ export default function HomePage() {
29 44 "cégep Sherbrooke",
30 45 "marché public Trois-Rivières",
31 46 "programme Hydro-Québec",
47 + "cabane à sucre Lanaudière",
32 48 ]}
33 49 />
34 50 </div>
51 +
35 52 <p className="gk-display mt-9 text-[17px] font-bold tracking-[-0.02em] text-ink">
36 53 Cherche <span className="hl">le Québec</span>.
37 54 </p>
55 +
38 56 <div className="mt-9">
39 57 <IndexCounter />
40 58 </div>
59 +
60 + <p className="mt-8 hidden text-xs text-ink-3 sm:block">
61 + Astuce&nbsp;: appuie sur <kbd className="kbd">/</kbd> pour chercher,{" "}
62 + <kbd className="kbd">↑</kbd>
63 + <kbd className="kbd">↓</kbd> pour naviguer dans les suggestions.
64 + </p>
41 65 </div>
42 66 </main>
43 67 );
modified apps/web/app/search/search-content.tsx +98 −18
@@ -15,6 +15,7 @@ import { ResultItem } from "@/components/result-item";
15 15 import { SearchBar } from "@/components/search-bar";
16 16 import { Button } from "@/components/ui/button";
17 17 import { formatNumber } from "@/lib/format";
18 +import { cn } from "@/lib/utils";
18 19 import type { SearchResponse } from "@/lib/types";
19 20
20 21 const PAGE_SIZE = 10;
@@ -31,9 +32,24 @@ function buildSearchUrl(
31 32 if (filters.language) params.set("language", filters.language);
32 33 if (filters.quebecOnly) params.set("quebec_only", "true");
33 34 if (filters.images) params.set("images", "true");
35 + if (filters.freshness) params.set("freshness", filters.freshness);
34 36 return `/search?${params.toString()}`;
35 37 }
36 38
39 +/** Pages affichées dans la pagination numérotée : 1 … autour de la courante … n. */
40 +function pageNumbers(current: number, total: number): (number | "…")[] {
41 + const wanted = new Set<number>([1, total, current - 1, current, current + 1]);
42 + const pages = [...wanted]
43 + .filter((p) => p >= 1 && p <= total)
44 + .sort((a, b) => a - b);
45 + const out: (number | "…")[] = [];
46 + for (const [index, p] of pages.entries()) {
47 + if (index > 0 && p - pages[index - 1] > 1) out.push("…");
48 + out.push(p);
49 + }
50 + return out;
51 +}
52 +
37 53 function ResultsSkeleton() {
38 54 return (
39 55 <div aria-hidden="true" className="divide-y divide-line">
@@ -43,17 +59,17 @@ function ResultsSkeleton() {
43 59 className="flex animate-pulse items-start gap-4 py-6 sm:gap-6"
44 60 >
45 61 <div className="min-w-0 flex-1 space-y-2.5">
46 − <div className="h-3.5 w-1/3 rounded bg-[rgba(20,24,20,0.07)]" />
47 − <div className="h-5 w-2/3 rounded bg-line" />
48 − <div className="h-3.5 w-full rounded bg-[rgba(20,24,20,0.07)]" />
49 − <div className="h-3.5 w-5/6 rounded bg-[rgba(20,24,20,0.07)]" />
62 + <div className="h-3.5 w-1/3 rounded bg-[var(--line)]" />
63 + <div className="h-5 w-2/3 rounded bg-[var(--line)]" />
64 + <div className="h-3.5 w-full rounded bg-[var(--line)]" />
65 + <div className="h-3.5 w-5/6 rounded bg-[var(--line)]" />
50 66 <div className="flex gap-2 pt-0.5">
51 − <div className="h-[22px] w-[72px] rounded-[5px] bg-[rgba(20,24,20,0.07)]" />
52 − <div className="h-[22px] w-24 rounded-[5px] bg-[rgba(20,24,20,0.07)]" />
67 + <div className="h-[22px] w-[72px] rounded-[5px] bg-[var(--line)]" />
68 + <div className="h-[22px] w-24 rounded-[5px] bg-[var(--line)]" />
53 69 </div>
54 70 </div>
55 71 {i % 2 === 0 ? (
56 − <div className="h-[76px] w-[76px] shrink-0 rounded-card bg-[rgba(20,24,20,0.07)] sm:h-[110px] sm:w-[110px]" />
72 + <div className="h-[76px] w-[76px] shrink-0 rounded-card bg-[var(--line)] sm:h-[110px] sm:w-[110px]" />
57 73 ) : null}
58 74 </div>
59 75 ))}
@@ -72,6 +88,7 @@ export function SearchContent() {
72 88 language: searchParams.get("language") ?? undefined,
73 89 quebecOnly: searchParams.get("quebec_only") === "true",
74 90 images: searchParams.get("images") === "true",
91 + freshness: searchParams.get("freshness") ?? undefined,
75 92 };
76 93 const imagesMode = Boolean(filters.images);
77 94
@@ -104,6 +121,8 @@ export function SearchContent() {
104 121 params.set("quebec_only", "true");
105 122 }
106 123 if (gallery) params.set("images", "true");
124 + const freshness = searchParams.get("freshness");
125 + if (freshness) params.set("freshness", freshness);
107 126
108 127 fetch(`/api/search?${params.toString()}`, { signal: controller.signal })
109 128 .then((res) => {
@@ -135,10 +154,10 @@ export function SearchContent() {
135 154 return (
136 155 <div className="flex-1">
137 156 {/* Barre persistante sous l'en-tête global (h-14) : recherche + filtres. */}
138 − <div className="sticky top-14 z-30 border-b border-line bg-paper md:bg-[rgba(245,243,238,0.92)] md:backdrop-blur">
157 + <div className="sticky top-14 z-30 border-b border-line bg-paper md:bg-[var(--paper-glass)] md:backdrop-blur">
139 158 <div className="mx-auto max-w-3xl px-4 pt-3">
140 159 <SearchBar key={q} initialQuery={q} className="max-w-xl" />
141 − <div className="overflow-x-auto py-3">
160 + <div className="py-3">
142 161 <FilterPills
143 162 filters={filters}
144 163 onChange={(next) => navigate(1, next)}
@@ -187,28 +206,66 @@ export function SearchContent() {
187 206 </div>
188 207 ) : data ? (
189 208 <>
190 − <p aria-live="polite" className="klabel">
191 − <span className="mr-1 rounded-[4px] bg-lime px-1.5 py-0.5 text-[11px] text-ink">
209 + <p aria-live="polite" className="klabel flex flex-wrap items-center gap-x-2 gap-y-1">
210 + <span className="rounded-[4px] bg-lime px-1.5 py-0.5 text-[11px] text-white">
192 211 {formatNumber(data.total)}
193 − </span>{" "}
194 − {data.total === 1 ? "résultat" : "résultats"} (
195 − {formatNumber(data.took_ms)}&nbsp;ms)
212 + </span>
213 + <span>
214 + {data.total === 1 ? "résultat" : "résultats"} (
215 + {formatNumber(data.took_ms)}&nbsp;ms)
216 + </span>
217 + {data.semantic || data.reranked ? (
218 + <span
219 + title={
220 + data.semantic
221 + ? "Recherche hybride : mots-clés + compréhension sémantique (FR ↔ EN), réordonnée par IA"
222 + : "Top des résultats réordonné par IA"
223 + }
224 + className="inline-flex items-center gap-1 rounded-full border border-[var(--line-strong)] px-2 py-0.5 text-[9px] text-ink-2"
225 + >
226 + <span aria-hidden="true" className="text-[8px]">✦</span>
227 + {data.semantic ? "Recherche sémantique" : "Classement IA"}
228 + </span>
229 + ) : null}
196 230 </p>
197 231 {imagesMode ? (
198 232 <div className="mt-4">
199 233 <ImageGallery results={data.results} />
200 234 </div>
201 235 ) : (
202 − <div className="mt-2 divide-y divide-line">
236 + <div className="stagger mt-2 divide-y divide-line">
203 237 {data.results.map((result) => (
204 238 <ResultItem key={result.url} result={result} />
205 239 ))}
206 240 </div>
207 241 )}
242 +
243 + {!imagesMode && data.related && data.related.length > 0 ? (
244 + <section aria-label="Recherches liées" className="mt-8 border-t border-line pt-6">
245 + <p className="klabel">Recherches liées</p>
246 + <ul className="mt-3 flex flex-wrap gap-2">
247 + {data.related.map((related) => (
248 + <li key={related}>
249 + <Link
250 + href={`/search?q=${encodeURIComponent(related)}`}
251 + className="chip-link"
252 + >
253 + <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="h-3 w-3 opacity-50">
254 + <circle cx="11" cy="11" r="8" />
255 + <path d="m21 21-4.35-4.35" />
256 + </svg>
257 + {related}
258 + </Link>
259 + </li>
260 + ))}
261 + </ul>
262 + </section>
263 + ) : null}
264 +
208 265 {hasPrevious || hasNext ? (
209 266 <nav
210 267 aria-label="Pagination des résultats"
211 − className="mt-8 flex items-center justify-between border-t border-line pt-5"
268 + className="mt-8 flex flex-wrap items-center justify-center gap-2 border-t border-line pt-6"
212 269 >
213 270 <Button
214 271 variant="ghost"
@@ -218,8 +275,31 @@ export function SearchContent() {
218 275 >
219 276 ← Précédent
220 277 </Button>
221 − <span className="gk-mono text-xs font-medium text-ink-2">
222 − Page {formatNumber(page)} de {formatNumber(totalPages)}
278 + <span className="mx-1 flex items-center gap-1.5">
279 + {pageNumbers(page, totalPages).map((p, index) =>
280 + p === "…" ? (
281 + <span key={`gap-${index}`} aria-hidden="true" className="px-1 text-ink-3">
282 + …
283 + </span>
284 + ) : (
285 + <button
286 + key={p}
287 + type="button"
288 + aria-label={`Page ${p}`}
289 + aria-current={p === page ? "page" : undefined}
290 + disabled={p === page}
291 + onClick={() => navigate(p, filters)}
292 + className={cn(
293 + "gk-mono h-8 min-w-8 rounded-ctl border-[1.5px] px-2 text-xs font-bold transition-colors",
294 + p === page
295 + ? "border-ink bg-[var(--btn-primary-bg)] text-[var(--btn-primary-fg)]"
296 + : "border-transparent text-ink-2 hover:border-ink hover:text-ink",
297 + )}
298 + >
299 + {formatNumber(p)}
300 + </button>
301 + ),
302 + )}
223 303 </span>
224 304 <Button
225 305 variant="ghost"
modified apps/web/app/status/status-panel.tsx +31 −0
@@ -101,6 +101,37 @@ export function StatusPanel() {
101 101 </div>
102 102 </div>
103 103
104 + {typeof status.embedding_coverage === "number" ? (
105 + <div className="gk-card mt-5 p-5">
106 + <div className="flex items-baseline justify-between gap-4">
107 + <p className="klabel">Couverture sémantique de l'index</p>
108 + <p className="gk-mono text-sm font-bold text-ink">
109 + {Math.round(status.embedding_coverage * 100)}&nbsp;%
110 + </p>
111 + </div>
112 + {/* Meter : remplissage accent, piste = pas pâle du même hue */}
113 + <div
114 + role="meter"
115 + aria-valuemin={0}
116 + aria-valuemax={100}
117 + aria-valuenow={Math.round(status.embedding_coverage * 100)}
118 + aria-label="Part des pages comprises par la recherche sémantique"
119 + className="mt-3 h-2.5 overflow-hidden rounded-full"
120 + style={{ background: "color-mix(in srgb, var(--green) 14%, var(--surface))" }}
121 + >
122 + <div
123 + className="h-full rounded-full bg-green transition-[width] duration-700"
124 + style={{ width: `${Math.round(status.embedding_coverage * 100)}%` }}
125 + />
126 + </div>
127 + <p className="mt-2.5 text-xs leading-relaxed text-ink-2">
128 + Les pages couvertes sont aussi trouvables par sens («&nbsp;heat pump&nbsp;»
129 + trouve «&nbsp;thermopompe&nbsp;»), pas seulement par mots-clés. La
130 + couverture progresse en continu, en arrière-plan.
131 + </p>
132 + </div>
133 + ) : null}
134 +
104 135 <div className="mt-5 grid gap-4 sm:grid-cols-3">
105 136 <Stat
106 137 label="Domaines connus"
modified apps/web/app/trouveka-bot/page.tsx +2 −2
@@ -57,7 +57,7 @@ export default function TrouvekaBotPage() {
57 57 <p className="mt-2 leading-relaxed">
58 58 Le robot s'identifie toujours avec ce user-agent exact&nbsp;:
59 59 </p>
60 − <pre className="gk-mono mt-3 overflow-x-auto rounded-ctl border-[1.5px] border-ink bg-surface-2 p-4 text-[13px] text-ink shadow-off-field">
60 + <pre className="gk-mono mt-3 overflow-x-auto rounded-ctl border border-[var(--edge)] bg-surface-2 p-4 text-[13px] text-ink shadow-off-field">
61 61 <code>{USER_AGENT}</code>
62 62 </pre>
63 63 </section>
@@ -82,7 +82,7 @@ export default function TrouvekaBotPage() {
82 82 Pour empêcher TrouveKABot de visiter votre site, ajoutez ceci à
83 83 votre fichier <code>robots.txt</code>&nbsp;:
84 84 </p>
85 − <pre className="gk-mono mt-3 overflow-x-auto rounded-ctl border-[1.5px] border-ink bg-surface-2 p-4 text-[13px] text-ink shadow-off-field">
85 + <pre className="gk-mono mt-3 overflow-x-auto rounded-ctl border border-[var(--edge)] bg-surface-2 p-4 text-[13px] text-ink shadow-off-field">
86 86 <code>{"User-agent: TrouveKABot\nDisallow: /"}</code>
87 87 </pre>
88 88 <p className="mt-2 leading-relaxed">
modified apps/web/components/admin/dashboard.tsx +1 −1
@@ -93,7 +93,7 @@ export function AdminDashboard({
93 93
94 94 <main id="contenu" className="mx-auto max-w-6xl space-y-7 px-4 py-6">
95 95 {error && !overview ? (
96 − <p className="rounded-ctl border-[1.5px] border-ink bg-danger-soft px-4 py-3 text-sm font-medium text-danger shadow-off-field">
96 + <p className="rounded-ctl border border-[var(--edge)] bg-danger-soft px-4 py-3 text-sm font-medium text-danger shadow-off-field">
97 97 Impossible de joindre l'API d'administration. Vérifiez que le
98 98 backend tourne, puis rechargez la page.
99 99 </p>
modified apps/web/components/filter-pills.tsx +123 −45
@@ -1,11 +1,15 @@
1 1 /**
2 − * Trouve-KA — filtres de recherche en pastilles
2 + * Trouve-KA — filtres de recherche (pastilles + panneau compact sur mobile)
3 3 * Author: Simon-Pierre Boucher
4 4 * Contact: contact@spboucher.ai
5 + *
6 + * Desktop : tout est visible. Mobile : une rangée défilante de catégories +
7 + * un bouton « Filtres (n) » qui replie langue / fraîcheur / Québec seulement.
5 8 */
6 9
7 10 "use client";
8 11
12 +import { useState } from "react";
9 13 import { cn } from "@/lib/utils";
10 14
11 15 /** Filtres actifs de la page de résultats. */
@@ -14,6 +18,7 @@ export interface SearchFilters {
14 18 language?: string;
15 19 quebecOnly: boolean;
16 20 images?: boolean;
21 + freshness?: string;
17 22 }
18 23
19 24 interface FilterPillsProps {
@@ -21,84 +26,157 @@ interface FilterPillsProps {
21 26 onChange: (next: SearchFilters) => void;
22 27 }
23 28
24 −interface Pill {
25 − label: string;
26 − active: boolean;
27 − next: SearchFilters;
28 −}
29 +const FRESHNESS_OPTIONS = [
30 + ["day", "24 h"],
31 + ["week", "Semaine"],
32 + ["month", "Mois"],
33 + ["year", "Année"],
34 +] as const;
29 35
30 36 export function FilterPills({ filters, onChange }: FilterPillsProps) {
37 + const [moreOpen, setMoreOpen] = useState(false);
38 +
31 39 const allActive =
32 − !filters.category && !filters.language && !filters.quebecOnly && !filters.images;
40 + !filters.category &&
41 + !filters.language &&
42 + !filters.quebecOnly &&
43 + !filters.images &&
44 + !filters.freshness;
33 45
34 − const pills: Pill[] = [
35 − { label: "Tout", active: allActive, next: { quebecOnly: false } },
46 + function toggleCategory(category: string): SearchFilters {
47 + return {
48 + ...filters,
49 + category: filters.category === category ? undefined : category,
50 + };
51 + }
52 +
53 + const primary = [
54 + { label: "Tout", active: allActive, next: { quebecOnly: false } as SearchFilters },
36 55 {
37 56 label: "Images",
38 57 active: Boolean(filters.images),
39 58 next: { ...filters, images: !filters.images },
40 59 },
41 − {
42 − label: "Actualités",
43 − active: filters.category === "news",
44 − next: {
45 − ...filters,
46 − category: filters.category === "news" ? undefined : "news",
47 − },
48 − },
60 + { label: "Actualités", active: filters.category === "news", next: toggleCategory("news") },
49 61 {
50 62 label: "Gouvernement",
51 63 active: filters.category === "government",
52 − next: {
53 − ...filters,
54 − category: filters.category === "government" ? undefined : "government",
55 − },
64 + next: toggleCategory("government"),
65 + },
66 + {
67 + label: "Éducation",
68 + active: filters.category === "education",
69 + next: toggleCategory("education"),
56 70 },
71 + ];
72 +
73 + const secondary = [
57 74 {
58 75 label: "Français",
59 76 active: filters.language === "fr",
60 − next: {
61 − ...filters,
62 − language: filters.language === "fr" ? undefined : "fr",
63 − },
77 + next: { ...filters, language: filters.language === "fr" ? undefined : "fr" } as SearchFilters,
64 78 },
65 79 {
66 80 label: "English",
67 81 active: filters.language === "en",
68 − next: {
69 − ...filters,
70 − language: filters.language === "en" ? undefined : "en",
71 − },
82 + next: { ...filters, language: filters.language === "en" ? undefined : "en" } as SearchFilters,
72 83 },
73 84 {
74 85 label: "Québec seulement",
75 86 active: filters.quebecOnly,
76 − next: { ...filters, quebecOnly: !filters.quebecOnly },
87 + next: { ...filters, quebecOnly: !filters.quebecOnly } as SearchFilters,
77 88 },
89 + ...FRESHNESS_OPTIONS.map(([value, label]) => ({
90 + label,
91 + active: filters.freshness === value,
92 + next: {
93 + ...filters,
94 + freshness: filters.freshness === value ? undefined : value,
95 + } as SearchFilters,
96 + })),
78 97 ];
79 98
99 + const activeSecondary = secondary.filter((p) => p.active).length;
100 +
80 101 return (
81 − <div
82 − role="group"
83 − aria-label="Filtres de recherche"
84 − className="flex flex-wrap gap-2"
85 − >
86 − {pills.map((pill) => (
102 + <div>
103 + {/* Rangée principale : défilante sur mobile, complète sur desktop */}
104 + <div
105 + role="group"
106 + aria-label="Filtres de recherche"
107 + className="flex items-center gap-2 overflow-x-auto pb-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:flex-wrap sm:overflow-visible"
108 + >
109 + {primary.map((pill) => (
110 + <button
111 + key={pill.label}
112 + type="button"
113 + aria-pressed={pill.active}
114 + onClick={() => onChange(pill.next)}
115 + className={cn("pill shrink-0", pill.active && "pill-active")}
116 + >
117 + {pill.label}
118 + </button>
119 + ))}
120 +
121 + {/* Mobile : bouton d'ouverture des filtres secondaires */}
87 122 <button
88 − key={pill.label}
89 123 type="button"
90 − aria-pressed={pill.active}
91 − onClick={() => onChange(pill.next)}
124 + aria-expanded={moreOpen}
125 + aria-controls="filtres-avances"
126 + onClick={() => setMoreOpen((open) => !open)}
92 127 className={cn(
93 − "gk-mono whitespace-nowrap rounded-full border-[1.5px] border-ink px-3.5 py-1.5 text-[11px] font-bold uppercase tracking-[0.08em] shadow-[2px_2px_0_rgba(20,24,20,0.12)] transition-[background,color,transform,box-shadow] duration-150 hover:-translate-y-px hover:shadow-[3px_3px_0_rgba(20,24,20,0.2)] active:translate-y-0 active:shadow-none",
94 − pill.active
95 − ? "bg-ink text-lime"
96 − : "bg-surface text-ink-2 hover:bg-lime hover:text-ink",
128 + "pill shrink-0 sm:hidden",
129 + (moreOpen || activeSecondary > 0) && "pill-active",
97 130 )}
98 131 >
99 − {pill.label}
132 + Filtres{activeSecondary > 0 ? ` (${activeSecondary})` : ""}
133 + <span aria-hidden="true" className="ml-1">
134 + {moreOpen ? "▴" : "▾"}
135 + </span>
100 136 </button>
101 − ))}
137 +
138 + {/* Desktop : filtres secondaires en ligne */}
139 + <span aria-hidden="true" className="mx-1 hidden h-5 w-px shrink-0 bg-line sm:block" />
140 + <div
141 + role="group"
142 + aria-label="Langue, portée et fraîcheur"
143 + className="hidden gap-2 sm:flex sm:flex-wrap"
144 + >
145 + {secondary.map((pill) => (
146 + <button
147 + key={pill.label}
148 + type="button"
149 + aria-pressed={pill.active}
150 + onClick={() => onChange(pill.next)}
151 + className={cn("pill", pill.active && "pill-active")}
152 + >
153 + {pill.label}
154 + </button>
155 + ))}
156 + </div>
157 + </div>
158 +
159 + {/* Mobile : panneau repliable des filtres secondaires */}
160 + {moreOpen ? (
161 + <div
162 + id="filtres-avances"
163 + role="group"
164 + aria-label="Langue, portée et fraîcheur"
165 + className="mt-2 flex flex-wrap gap-2 border-t border-line pt-2.5 sm:hidden"
166 + >
167 + {secondary.map((pill) => (
168 + <button
169 + key={pill.label}
170 + type="button"
171 + aria-pressed={pill.active}
172 + onClick={() => onChange(pill.next)}
173 + className={cn("pill", pill.active && "pill-active")}
174 + >
175 + {pill.label}
176 + </button>
177 + ))}
178 + </div>
179 + ) : null}
102 180 </div>
103 181 );
104 182 }
modified apps/web/components/image-gallery.tsx +1 −1
@@ -22,7 +22,7 @@ function GalleryTile({ result }: { result: SearchResult }) {
22 22 rel="noopener noreferrer"
23 23 className="group block rounded-card focus-visible:outline focus-visible:outline-2 focus-visible:outline-green"
24 24 >
25 − <span className="block overflow-hidden rounded-card border-[1.5px] border-ink bg-surface shadow-[3px_3px_0_rgba(20,24,20,0.1)] transition-[transform,box-shadow] duration-150 group-hover:-translate-y-0.5 group-hover:shadow-[5px_5px_0_rgba(20,24,20,0.55)]">
25 + <span className="block overflow-hidden rounded-card border border-[var(--edge)] bg-surface shadow-off-soft transition-[transform,box-shadow] duration-150 group-hover:-translate-y-0.5 group-hover:shadow-off-hard">
26 26 <img
27 27 src={result.image}
28 28 alt={result.title}
modified apps/web/components/result-thumbnail.tsx +1 −1
@@ -38,7 +38,7 @@ export function ResultThumbnail({ src, href }: ResultThumbnailProps) {
38 38 decoding="async"
39 39 referrerPolicy="no-referrer"
40 40 onError={() => setFailed(true)}
41 − className="h-[76px] w-[76px] rounded-card border-[1.5px] border-ink bg-surface-2 object-cover shadow-[3px_3px_0_rgba(20,24,20,0.12)] transition-[transform,box-shadow] duration-150 group-hover:-translate-x-px group-hover:-translate-y-px group-hover:shadow-[4px_4px_0_rgba(20,24,20,0.2)] sm:h-[110px] sm:w-[110px]"
41 + className="h-[76px] w-[76px] rounded-card border border-[var(--edge)] bg-surface-2 object-cover shadow-off-soft transition-[transform,box-shadow] duration-150 group-hover:-translate-x-px group-hover:-translate-y-px group-hover:shadow-off-hard sm:h-[110px] sm:w-[110px]"
42 42 />
43 43 </a>
44 44 );
modified apps/web/components/search-bar.tsx +181 −20
@@ -1,13 +1,24 @@
1 1 /**
2 − * Trouve-KA — barre de recherche (homepage et en-tête des résultats)
2 + * Trouve-KA — barre de recherche avec suggestions instantanées
3 3 * Author: Simon-Pierre Boucher
4 4 * Contact: contact@spboucher.ai
5 + *
6 + * Combobox accessible : suggestions live (/api/suggest, debounce 150 ms),
7 + * navigation ↑/↓, Entrée pour chercher, Échap pour fermer. Le raccourci
8 + * global « / » ramène le focus sur le champ depuis n'importe où.
5 9 */
6 10
7 11 "use client";
8 12
9 13 import { useRouter } from "next/navigation";
10 −import { useEffect, useState, type FormEvent } from "react";
14 +import {
15 + useCallback,
16 + useEffect,
17 + useId,
18 + useRef,
19 + useState,
20 + type FormEvent,
21 +} from "react";
11 22 import { cn } from "@/lib/utils";
12 23
13 24 interface SearchBarProps {
@@ -21,7 +32,8 @@ interface SearchBarProps {
21 32 className?: string;
22 33 }
23 34
24 −const SUGGESTION_INTERVAL_MS = 4000;
35 +const PLACEHOLDER_INTERVAL_MS = 4000;
36 +const SUGGEST_DEBOUNCE_MS = 150;
25 37
26 38 export function SearchBar({
27 39 initialQuery = "",
@@ -32,33 +44,135 @@ export function SearchBar({
32 44 className,
33 45 }: SearchBarProps) {
34 46 const router = useRouter();
47 + const listboxId = useId();
48 + const inputRef = useRef<HTMLInputElement>(null);
49 + const rootRef = useRef<HTMLDivElement>(null);
50 + const debounceRef = useRef<number>(0);
51 + const abortRef = useRef<AbortController | null>(null);
52 +
35 53 const [query, setQuery] = useState(initialQuery);
36 − const [suggestionIndex, setSuggestionIndex] = useState(0);
54 + const [placeholderIndex, setPlaceholderIndex] = useState(0);
55 + const [suggestions, setSuggestions] = useState<string[]>([]);
56 + const [open, setOpen] = useState(false);
57 + const [selected, setSelected] = useState(-1);
37 58
38 − const suggestionCount = placeholderSuggestions?.length ?? 0;
59 + const placeholderCount = placeholderSuggestions?.length ?? 0;
39 60
61 + // Placeholder rotatif (respecte prefers-reduced-motion)
40 62 useEffect(() => {
41 − if (suggestionCount < 2) return;
63 + if (placeholderCount < 2) return;
42 64 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
43 65 const timer = window.setInterval(
44 − () => setSuggestionIndex((index) => (index + 1) % suggestionCount),
45 − SUGGESTION_INTERVAL_MS,
66 + () => setPlaceholderIndex((index) => (index + 1) % placeholderCount),
67 + PLACEHOLDER_INTERVAL_MS,
46 68 );
47 69 return () => window.clearInterval(timer);
48 − }, [suggestionCount]);
70 + }, [placeholderCount]);
49 71
50 − const placeholder =
51 − suggestionCount > 0
52 − ? `Essaie « ${placeholderSuggestions![suggestionIndex % suggestionCount]} »…`
53 − : "Rechercher…";
72 + // Raccourci global « / » : focus sur la recherche
73 + useEffect(() => {
74 + function onKey(event: KeyboardEvent) {
75 + if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
76 + const target = event.target as HTMLElement | null;
77 + if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
78 + if (target?.isContentEditable) return;
79 + event.preventDefault();
80 + inputRef.current?.focus();
81 + inputRef.current?.select();
82 + }
83 + window.addEventListener("keydown", onKey);
84 + return () => window.removeEventListener("keydown", onKey);
85 + }, []);
54 86
55 − function onSubmit(event: FormEvent) {
56 − event.preventDefault();
57 − const q = query.trim();
87 + // Fermer les suggestions au clic extérieur
88 + useEffect(() => {
89 + function onPointerDown(event: PointerEvent) {
90 + if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
91 + }
92 + window.addEventListener("pointerdown", onPointerDown);
93 + return () => window.removeEventListener("pointerdown", onPointerDown);
94 + }, []);
95 +
96 + const fetchSuggestions = useCallback((value: string) => {
97 + window.clearTimeout(debounceRef.current);
98 + abortRef.current?.abort();
99 + const trimmed = value.trim();
100 + if (trimmed.length < 2) {
101 + setSuggestions([]);
102 + setOpen(false);
103 + return;
104 + }
105 + debounceRef.current = window.setTimeout(() => {
106 + const controller = new AbortController();
107 + abortRef.current = controller;
108 + fetch(`/api/suggest?q=${encodeURIComponent(trimmed)}`, {
109 + signal: controller.signal,
110 + })
111 + .then((res) => (res.ok ? res.json() : { suggestions: [] }))
112 + .then((json: { suggestions?: string[] }) => {
113 + const list = (json.suggestions ?? []).slice(0, 8);
114 + setSuggestions(list);
115 + setSelected(-1);
116 + setOpen(list.length > 0);
117 + })
118 + .catch(() => {
119 + /* suggestions best-effort : le champ reste pleinement fonctionnel */
120 + });
121 + }, SUGGEST_DEBOUNCE_MS);
122 + }, []);
123 +
124 + function go(value: string) {
125 + const q = value.trim();
58 126 if (!q) return;
127 + setOpen(false);
128 + abortRef.current?.abort();
129 + window.clearTimeout(debounceRef.current);
59 130 router.push(`/search?q=${encodeURIComponent(q)}`);
60 131 }
61 132
133 + function onSubmit(event: FormEvent) {
134 + event.preventDefault();
135 + go(selected >= 0 && suggestions[selected] ? suggestions[selected] : query);
136 + }
137 +
138 + function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
139 + if (!open || suggestions.length === 0) {
140 + if (event.key === "Escape") (event.target as HTMLInputElement).blur();
141 + return;
142 + }
143 + if (event.key === "ArrowDown") {
144 + event.preventDefault();
145 + setSelected((i) => (i + 1) % suggestions.length);
146 + } else if (event.key === "ArrowUp") {
147 + event.preventDefault();
148 + setSelected((i) => (i <= 0 ? suggestions.length - 1 : i - 1));
149 + } else if (event.key === "Escape") {
150 + setOpen(false);
151 + setSelected(-1);
152 + } else if (event.key === "Tab") {
153 + setOpen(false);
154 + }
155 + }
156 +
157 + /** Surligne la portion déjà tapée dans la suggestion. */
158 + function highlight(suggestion: string) {
159 + const q = query.trim().toLowerCase();
160 + const index = suggestion.toLowerCase().indexOf(q);
161 + if (q.length === 0 || index === -1) return suggestion;
162 + return (
163 + <>
164 + {suggestion.slice(0, index)}
165 + <mark>{suggestion.slice(index, index + q.length)}</mark>
166 + {suggestion.slice(index + q.length)}
167 + </>
168 + );
169 + }
170 +
171 + const placeholder =
172 + placeholderCount > 0
173 + ? `Essaie « ${placeholderSuggestions![placeholderIndex % placeholderCount]} »…`
174 + : "Rechercher…";
175 +
62 176 const isLarge = size === "lg";
63 177
64 178 return (
@@ -66,7 +180,7 @@ export function SearchBar({
66 180 <label htmlFor="champ-recherche" className="sr-only">
67 181 Rechercher sur le web québécois
68 182 </label>
69 − <div className="relative">
183 + <div ref={rootRef} className="relative">
70 184 <svg
71 185 aria-hidden="true"
72 186 viewBox="0 0 24 24"
@@ -76,7 +190,7 @@ export function SearchBar({
76 190 strokeLinecap="round"
77 191 strokeLinejoin="round"
78 192 className={cn(
79 − "pointer-events-none absolute top-1/2 -translate-y-1/2 text-ink-3",
193 + "pointer-events-none absolute top-1/2 z-10 -translate-y-1/2 text-ink-3",
80 194 isLarge ? "left-5 h-5 w-5" : "left-3.5 h-4 w-4",
81 195 )}
82 196 >
@@ -84,11 +198,26 @@ export function SearchBar({
84 198 <path d="m21 21-4.35-4.35" />
85 199 </svg>
86 200 <input
201 + ref={inputRef}
87 202 id="champ-recherche"
88 203 type="search"
89 204 name="q"
205 + role="combobox"
206 + aria-expanded={open}
207 + aria-controls={listboxId}
208 + aria-activedescendant={
209 + selected >= 0 ? `${listboxId}-${selected}` : undefined
210 + }
211 + aria-autocomplete="list"
90 212 value={query}
91 − onChange={(event) => setQuery(event.target.value)}
213 + onChange={(event) => {
214 + setQuery(event.target.value);
215 + fetchSuggestions(event.target.value);
216 + }}
217 + onFocus={() => {
218 + if (suggestions.length > 0 && query.trim().length >= 2) setOpen(true);
219 + }}
220 + onKeyDown={onKeyDown}
92 221 placeholder={placeholder}
93 222 autoComplete="off"
94 223 spellCheck={false}
@@ -110,13 +239,45 @@ export function SearchBar({
110 239 <button
111 240 type="submit"
112 241 className={cn(
113 − "gk-display absolute right-1.5 top-1/2 -translate-y-1/2 rounded-[5px] border-[1.5px] border-ink bg-ink font-bold text-lime transition-colors hover:bg-green-deep active:translate-y-[calc(-50%+1px)]",
242 + "btn-search absolute right-1.5 top-1/2 z-10 -translate-y-1/2 active:translate-y-[calc(-50%+1px)]",
114 243 isLarge ? "h-11 px-6 text-base" : "h-7 px-4 text-[13px]",
115 244 )}
116 245 >
117 246 Chercher
118 247 </button>
119 248 ) : null}
249 +
250 + {open && suggestions.length > 0 ? (
251 + <ul id={listboxId} role="listbox" aria-label="Suggestions" className="suggest-panel">
252 + {suggestions.map((suggestion, index) => (
253 + <li key={suggestion} role="presentation">
254 + <button
255 + type="button"
256 + role="option"
257 + id={`${listboxId}-${index}`}
258 + aria-selected={index === selected}
259 + className="suggest-item"
260 + onMouseEnter={() => setSelected(index)}
261 + onClick={() => go(suggestion)}
262 + >
263 + <svg
264 + aria-hidden="true"
265 + viewBox="0 0 24 24"
266 + fill="none"
267 + stroke="currentColor"
268 + strokeWidth="2"
269 + strokeLinecap="round"
270 + className="h-3.5 w-3.5 shrink-0 opacity-50"
271 + >
272 + <circle cx="11" cy="11" r="8" />
273 + <path d="m21 21-4.35-4.35" />
274 + </svg>
275 + <span className="truncate">{highlight(suggestion)}</span>
276 + </button>
277 + </li>
278 + ))}
279 + </ul>
280 + ) : null}
120 281 </div>
121 282 {buttonPlacement === "below" ? (
122 283 <button type="submit" className="btn btn-primary mt-8 px-9">
added apps/web/components/theme-toggle.tsx +59 −0
@@ -0,0 +1,59 @@
1 +/**
2 + * Trouve-KA — bascule clair/sombre (persistée, défaut système)
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + */
6 +
7 +"use client";
8 +
9 +import { useEffect, useState } from "react";
10 +
11 +type Theme = "light" | "dark";
12 +
13 +function applyTheme(theme: Theme) {
14 + document.documentElement.dataset.theme = theme;
15 + try {
16 + localStorage.setItem("tk-theme", theme);
17 + } catch {
18 + /* stockage indisponible (navigation privée) : le choix vaut pour la session */
19 + }
20 +}
21 +
22 +export function ThemeToggle() {
23 + // null jusqu'au montage : le SSR ne connaît pas le thème → pas d'hydration mismatch
24 + const [theme, setTheme] = useState<Theme | null>(null);
25 +
26 + useEffect(() => {
27 + setTheme(
28 + (document.documentElement.dataset.theme as Theme) === "dark"
29 + ? "dark"
30 + : "light",
31 + );
32 + }, []);
33 +
34 + const next: Theme = theme === "dark" ? "light" : "dark";
35 +
36 + return (
37 + <button
38 + type="button"
39 + aria-label={theme === "dark" ? "Passer au mode clair" : "Passer au mode sombre"}
40 + title={theme === "dark" ? "Mode clair" : "Mode sombre"}
41 + onClick={() => {
42 + setTheme(next);
43 + applyTheme(next);
44 + }}
45 + className="inline-flex h-8 w-8 items-center justify-center rounded-full border-[1.5px] border-transparent text-ink-2 transition-colors hover:border-ink hover:text-ink"
46 + >
47 + {theme === "dark" ? (
48 + <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="h-4 w-4">
49 + <circle cx="12" cy="12" r="4" />
50 + <path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
51 + </svg>
52 + ) : (
53 + <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
54 + <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
55 + </svg>
56 + )}
57 + </button>
58 + );
59 +}
modified apps/web/components/ui/badge.tsx +1 −1
@@ -11,7 +11,7 @@ import { cn } from "@/lib/utils";
11 11 const badgeVariants = cva(
12 12 // Chip éditorial signature : mono uppercase, bordure encre, micro-ombre
13 13 // dure qui grandit au survol (translation -1px, comme les cartes KA).
14 − "inline-flex items-center gap-1 whitespace-nowrap rounded-[5px] border border-ink px-2 py-[3px] font-mono text-[10px] font-bold uppercase tracking-[0.09em] shadow-[2px_2px_0_rgba(20,24,20,0.16)] transition-[transform,box-shadow] duration-150 hover:-translate-y-px hover:shadow-[3px_3px_0_rgba(20,24,20,0.24)]",
14 + "inline-flex items-center gap-1 whitespace-nowrap rounded-[5px] border border-[var(--edge)] px-2 py-[3px] font-mono text-[10px] font-bold uppercase tracking-[0.09em] shadow-[var(--shadow-flat)] transition-[transform,box-shadow] duration-150 hover:-translate-y-px hover:shadow-[var(--shadow-field)]",
15 15 {
16 16 variants: {
17 17 variant: {
modified apps/web/components/ui/button.tsx +1 −1
@@ -16,7 +16,7 @@ const buttonVariants = cva(
16 16 default:
17 17 "border-ink bg-ink text-lime shadow-off-btn hover:bg-green-deep",
18 18 secondary:
19 − "border-ink bg-lime text-ink shadow-[4px_4px_0_rgba(217,242,107,0.25)] hover:bg-lime-soft",
19 + "border-ink bg-lime text-ink shadow-off-btn hover:bg-lime-soft",
20 20 outline: "border-ink bg-surface text-ink shadow-off-field hover:bg-lime",
21 21 ghost: "border-transparent text-ink-2 hover:border-ink hover:text-ink",
22 22 danger:
modified apps/web/components/wordmark.tsx +4 −4
@@ -19,7 +19,7 @@ interface WordmarkProps {
19 19 export function Wordmark({ variant = "header", className }: WordmarkProps) {
20 20 const base =
21 21 variant === "footer"
22 − ? "text-[26px] text-paper"
22 + ? "text-[26px] text-white"
23 23 : variant === "hero"
24 24 ? "text-5xl text-ink sm:text-6xl"
25 25 : "text-[21px] text-ink sm:text-[24px]";
@@ -34,15 +34,15 @@ export function Wordmark({ variant = "header", className }: WordmarkProps) {
34 34 >
35 35 Trouve
36 36 {variant === "footer" ? (
37 − <span className="inline-block -rotate-2 rounded-md bg-lime px-[7px] pb-[2px] text-ink">
37 + <span className="inline-block -rotate-2 rounded-md bg-lime px-[7px] pb-[2px] text-white">
38 38 KA
39 39 </span>
40 40 ) : variant === "hero" ? (
41 − <span className="inline-block -rotate-2 rounded-lg bg-ink px-3 pb-1 text-lime">
41 + <span className="inline-block -rotate-2 rounded-lg bg-[var(--btn-primary-bg)] px-3 pb-1 text-[var(--btn-primary-fg)]">
42 42 KA
43 43 </span>
44 44 ) : (
45 − <span className="inline-block -rotate-2 rounded-md bg-ink px-[7px] pb-[2px] text-lime transition-transform duration-200 group-hover:rotate-0">
45 + <span className="inline-block -rotate-2 rounded-md bg-[var(--btn-primary-bg)] px-[7px] pb-[2px] text-[var(--btn-primary-fg)] transition-transform duration-200 group-hover:rotate-0">
46 46 KA
47 47 </span>
48 48 )}
modified apps/web/lib/types.ts +8 −0
@@ -27,6 +27,12 @@ export interface SearchResponse {
27 27 took_ms: number;
28 28 page: number;
29 29 limit: number;
30 + /** Recherche hybride (BM25 + kNN) active pour cette requête. */
31 + semantic?: boolean;
32 + /** Top-20 réordonné par le reranker cross-encoder. */
33 + reranked?: boolean;
34 + /** Recherches liées (requêtes populaires partageant des termes). */
35 + related?: string[];
30 36 results: SearchResult[];
31 37 }
32 38
@@ -34,6 +40,8 @@ export interface SearchResponse {
34 40 export interface StatusResponse {
35 41 pages_indexed: number;
36 42 domains_count: number;
43 + /** Part de l'index couverte par la recherche sémantique, ∈ [0,1]. */
44 + embedding_coverage?: number;
37 45 indexed_last_hour: number;
38 46 fetched_last_hour: number;
39 47 crawler_state: string;
modified apps/web/tailwind.config.ts +24 −20
@@ -2,6 +2,10 @@
2 2 * Trouve-KA — configuration Tailwind CSS (tokens « éditorial sharp » Groupe KA)
3 3 * Author: Simon-Pierre Boucher
4 4 * Contact: contact@spboucher.ai
5 + *
6 + * Toutes les couleurs pointent vers les variables CSS de globals.css :
7 + * le mode sombre ([data-theme="dark"]) rethème l'app entière sans toucher
8 + * aux classes utilitaires.
5 9 */
6 10
7 11 import type { Config } from "tailwindcss";
@@ -15,33 +19,32 @@ const config: Config = {
15 19 theme: {
16 20 extend: {
17 21 colors: {
18 − // Palette éditoriale partagée avec l'écosystème Groupe KA.
19 − paper: "#f5f3ee",
22 + paper: "var(--paper)",
20 23 surface: {
21 − DEFAULT: "#ffffff",
22 − 2: "#faf9f5",
24 + DEFAULT: "var(--surface)",
25 + 2: "var(--surface-2)",
23 26 },
24 27 ink: {
25 − DEFAULT: "#141814",
26 − 2: "#4d5551",
27 − 3: "#8b928c",
28 + DEFAULT: "var(--ink)",
29 + 2: "var(--ink-2)",
30 + 3: "var(--ink-3)",
28 31 },
29 − line: "rgba(20, 24, 20, 0.14)",
32 + line: "var(--line)",
30 33 green: {
31 − DEFAULT: "#1c5c41",
32 − deep: "#123f2e",
34 + DEFAULT: "var(--green)",
35 + deep: "var(--green-deep)",
33 36 },
34 37 lime: {
35 − DEFAULT: "#b9cfee",
36 − soft: "#e6eef9",
38 + DEFAULT: "var(--lime)",
39 + soft: "var(--lime-soft)",
37 40 },
38 41 amber: {
39 − DEFAULT: "#e8a33d",
40 − soft: "#fdf3e2",
42 + DEFAULT: "var(--amber)",
43 + soft: "var(--amber-soft)",
41 44 },
42 45 danger: {
43 − DEFAULT: "#b3423a",
44 − soft: "#fbe9e7",
46 + DEFAULT: "var(--danger)",
47 + soft: "var(--danger-soft)",
45 48 },
46 49 // Palette de statut (réservée aux états — jamais utilisée comme
47 50 // couleur de série; toujours accompagnée d'un libellé texte).
@@ -58,6 +61,7 @@ const config: Config = {
58 61 },
59 62 fontFamily: {
60 63 display: ["var(--font-space-grotesk)", "system-ui", "sans-serif"],
64 + serif: ["var(--font-fraunces)", "Georgia", "serif"],
61 65 body: ["var(--font-inter)", "system-ui", "sans-serif"],
62 66 sans: ["var(--font-inter)", "system-ui", "sans-serif"],
63 67 mono: ["var(--font-jetbrains)", "ui-monospace", "monospace"],
@@ -67,10 +71,10 @@ const config: Config = {
67 71 ctl: "6px",
68 72 },
69 73 boxShadow: {
70 − "off-hard": "6px 6px 0 #141814",
71 − "off-soft": "8px 8px 0 rgba(20, 24, 20, 0.08)",
72 − "off-btn": "4px 4px 0 rgba(20, 24, 20, 0.25)",
73 − "off-field": "3px 3px 0 rgba(20, 24, 20, 0.08)",
74 + "off-hard": "var(--shadow-off)",
75 + "off-soft": "var(--shadow-off-soft)",
76 + "off-btn": "var(--shadow-btn)",
77 + "off-field": "var(--shadow-field)",
74 78 },
75 79 },
76 80 },
modified packages/database/db.py +36 −0
@@ -498,6 +498,42 @@ class Database:
498 498 took_ms,
499 499 )
500 500
501 + async def suggest_queries(self, prefix: str, limit: int = 5) -> list[str]:
502 + """Requêtes réelles populaires commençant par le préfixe (autocomplete)."""
503 + rows = await self.pool.fetch(
504 + """
505 + SELECT lower(query) AS q, count(*) AS n
506 + FROM search_queries
507 + WHERE NOT zero_result AND lower(query) LIKE $1 || '%' AND length(query) > length($1)
508 + GROUP BY lower(query)
509 + ORDER BY n DESC, max(created_at) DESC
510 + LIMIT $2
511 + """,
512 + prefix.lower(),
513 + limit,
514 + )
515 + return [r["q"] for r in rows]
516 +
517 + async def related_queries(self, terms: list[str], exclude: str, limit: int = 6) -> list[str]:
518 + """Requêtes populaires partageant au moins un terme (recherches liées)."""
519 + if not terms:
520 + return []
521 + rows = await self.pool.fetch(
522 + """
523 + SELECT lower(query) AS q, count(*) AS n
524 + FROM search_queries
525 + WHERE NOT zero_result AND lower(query) <> $2
526 + AND EXISTS (SELECT 1 FROM unnest($1::text[]) t WHERE lower(query) LIKE '%' || t || '%')
527 + GROUP BY lower(query)
528 + ORDER BY n DESC, max(created_at) DESC
529 + LIMIT $3
530 + """,
531 + [t.lower() for t in terms if len(t) >= 3][:6],
532 + exclude.lower(),
533 + limit,
534 + )
535 + return [r["q"] for r in rows]
536 +
501 537 async def top_zero_result_queries(self, limit: int = 50) -> list[asyncpg.Record]:
502 538 """Requêtes zéro-résultat les plus fréquentes (§12 : « de l'or ») —
503 539 elles pilotent les seeds, les synonymes et les priorités de crawl."""
modified packages/search-core/client.py +36 −0
@@ -92,6 +92,42 @@ class SearchCore:
92 92 except NotFoundError:
93 93 return 0
94 94
95 + async def suggest_titles(self, prefix: str, limit: int = 6) -> list[str]:
96 + """Complétions tirées des titres indexés (match_phrase_prefix bilingue)."""
97 + try:
98 + res = await self.client.search(
99 + index=self.index,
100 + body={
101 + "size": limit * 3,
102 + "query": {
103 + "bool": {
104 + "should": [
105 + {"match_phrase_prefix": {"title": {"query": prefix, "max_expansions": 20}}},
106 + {"match_phrase_prefix": {"title.en": {"query": prefix, "max_expansions": 20}}},
107 + ]
108 + }
109 + },
110 + "_source": ["title"],
111 + },
112 + )
113 + except Exception:
114 + return []
115 + seen: set[str] = set()
116 + out: list[str] = []
117 + for hit in res["hits"]["hits"]:
118 + title = (hit["_source"].get("title") or "").strip()
119 + # Tronquer au segment utile : les titres de pages traînent des
120 + # suffixes de site (« … | Ville de Québec ») inutiles en suggestion.
121 + short = title.split(" | ")[0].split(" — ")[0].split(" – ")[0].strip()
122 + key = short.lower()
123 + if not short or len(short) > 90 or key in seen:
124 + continue
125 + seen.add(key)
126 + out.append(short)
127 + if len(out) >= limit:
128 + break
129 + return out
130 +
95 131 async def embedding_coverage(self) -> float:
96 132 """Fraction des documents ayant un embedding ∈ [0,1].
97 133
added scripts/fix-mojibake/repair.py +116 −0
@@ -0,0 +1,116 @@
1 +# Trouve-KA — réparation du mojibake dans l'index
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +"""Répare les documents indexés avec du double encodage UTF-8→cp1252
6 +(« é » → « é ») produit avant le fix de decode_html.
7 +
8 +Pour chaque doc contenant des signatures : répare title/description/body/headings
9 +via fix_mojibake (réparation strictement non destructive), réécrit le doc et
10 +efface son embedding (recalculé par le backfill sur le texte propre).
11 +Synchronise aussi title/description dans Postgres (table documents).
12 +
13 +Usage : python scripts/fix-mojibake/repair.py
14 +"""
15 +
16 +import asyncio
17 +
18 +from opensearchpy import AsyncOpenSearch
19 +from opensearchpy.helpers import async_bulk
20 +
21 +from trouveka.config import get_settings
22 +from trouveka.database import Database
23 +from trouveka.parser import fix_mojibake
24 +
25 +FIELDS = ["title", "description", "body", "headings"]
26 +QUERY = {
27 + "bool": {
28 + "should": [
29 + {"match_phrase": {"body": "Ã"}},
30 + {"match_phrase": {"title": "Ã"}},
31 + {"match_phrase": {"description": "Ã"}},
32 + {"query_string": {"query": 'description:"é" OR body:"é" OR title:"é" OR body:"â€" OR body:"è"'}},
33 + ],
34 + "minimum_should_match": 1,
35 + }
36 +}
37 +
38 +
39 +async def main() -> None:
40 + s = get_settings()
41 + client = AsyncOpenSearch(hosts=[s.search_url], timeout=120)
42 + db = Database(s.database_url, pool_min=1, pool_max=3)
43 + await db.connect()
44 + repaired = scanned = 0
45 + search_after = None
46 + try:
47 + while True:
48 + body = {
49 + "size": 200,
50 + "query": QUERY,
51 + "_source": FIELDS + ["url"],
52 + # Curseur stable : on balaie TOUT le stock une fois, même si
53 + # certains documents matchent sans être réparables.
54 + "sort": [{"_id": "asc"}],
55 + }
56 + if search_after is not None:
57 + body["search_after"] = search_after
58 + res = await client.search(index=s.search_index, body=body)
59 + hits = res["hits"]["hits"]
60 + if not hits:
61 + break
62 + search_after = hits[-1]["sort"]
63 + actions = []
64 + for hit in hits:
65 + scanned += 1
66 + src = hit["_source"]
67 + changes = {}
68 + for f in FIELDS:
69 + val = src.get(f)
70 + if isinstance(val, str):
71 + fixed = fix_mojibake(val)
72 + if fixed != val:
73 + changes[f] = fixed
74 + elif isinstance(val, list):
75 + fixed_list = [fix_mojibake(v) if isinstance(v, str) else v for v in val]
76 + if fixed_list != val:
77 + changes[f] = fixed_list
78 + if not changes:
79 + continue # signatures détectées mais irréparables : recrawl s'en chargera
80 + repaired += 1
81 + actions.append({
82 + "_op_type": "update",
83 + "_index": s.search_index,
84 + "_id": hit["_id"],
85 + # embedding effacé : le backfill le recalcule sur le texte propre
86 + "script": {"source": "ctx._source.remove('embedding')"},
87 + })
88 + actions.append({
89 + "_op_type": "update",
90 + "_index": s.search_index,
91 + "_id": hit["_id"],
92 + "doc": changes,
93 + })
94 + if "title" in changes or "description" in changes:
95 + await db.pool.execute(
96 + """
97 + UPDATE documents SET
98 + title = COALESCE($2, title),
99 + description = COALESCE($3, description)
100 + WHERE url_id = (SELECT id FROM urls WHERE url = $1 OR canonical_url = $1 LIMIT 1)
101 + """,
102 + src.get("url"),
103 + changes.get("title"),
104 + changes.get("description"),
105 + )
106 + if actions:
107 + await async_bulk(client, actions, raise_on_error=False)
108 + print(f"→ scannés {scanned}, réparés {repaired}")
109 + finally:
110 + print(f"✓ terminé : {repaired} documents réparés sur {scanned} scannés")
111 + await db.close()
112 + await client.close()
113 +
114 +
115 +if __name__ == "__main__":
116 + asyncio.run(main())
modified services/parser/__init__.py +2 −2
@@ -8,6 +8,6 @@ Chaque page est traitée comme non fiable : texte extrait puis assaini,
8 8 URLs re-canonicalisées, MIME types jamais crus sur parole.
9 9 """
10 10
11 −from .html_parser import parse_html, decode_html, looks_like_garbage
11 +from .html_parser import parse_html, decode_html, fix_mojibake, looks_like_garbage
12 12
13 −__all__ = ["parse_html", "decode_html", "looks_like_garbage"]
13 +__all__ = ["parse_html", "decode_html", "fix_mojibake", "looks_like_garbage"]
modified services/parser/html_parser.py +61 −6
@@ -102,23 +102,78 @@ _META_CHARSET_RE = re.compile(
102 102 _CHARSET_ALIASES = {"iso-8859-1": "cp1252", "latin-1": "cp1252", "latin1": "cp1252", "ansi": "cp1252"}
103 103
104 104
105 −def decode_html(body: bytes, declared_charset: str | None = None) -> str:
106 − """Décode le HTML : charset HTTP déclaré → meta charset → UTF-8 strict → cp1252.
105 +# Signatures de mojibake UTF-8→cp1252 (« é »→« é », nbsp→«  », « ’ »→« ’ »…)
106 +_MOJIBAKE_RE = re.compile(r"Ã[©¨ªàâ´¹»‰€§®¯²¼½¾]|â€[™œ“”«»\x93\x94]|Â[ °«»]")
107 +
107 108
108 − Les vieux sites québécois sont souvent en ISO-8859-1/cp1252; décoder
109 − aveuglément en UTF-8 produit du charabia qui finirait dans l'index.
109 +def fix_mojibake(text: str) -> str:
110 + """Répare le double encodage UTF-8 lu comme cp1252, si détecté.
111 +
112 + Ne touche au texte que si (1) les signatures sont présentes, (2) l'aller-
113 + retour cp1252→utf-8 réussit strictement et (3) le résultat contient moins
114 + de signatures qu'avant — jamais de réparation destructive.
110 115 """
116 + if not text or not _MOJIBAKE_RE.search(text):
117 + return text
118 + before = len(_MOJIBAKE_RE.findall(text))
119 + # 2e candidat : « à »/nbsp mojibakés produisent « Ã »/« Â » + nbsp, que la
120 + # normalisation d'espaces a pu réduire en espace simple — on le restaure
121 + # pour que l'aller-retour cp1252→utf-8 redevienne possible.
122 + # « Ã » vient de « à » (dont le 2e octet 0xA0 s'affiche en nbsp) suivi du
123 + # vrai espace du texte : on restaure le nbsp SANS manger l'espace.
124 + candidates = (text, text.replace("Ã ", "Ã\xa0 ").replace("Â ", "Â\xa0"))
125 + for cand in candidates:
126 + try:
127 + repaired = cand.encode("cp1252").decode("utf-8")
128 + except (UnicodeEncodeError, UnicodeDecodeError):
129 + continue
130 + if len(_MOJIBAKE_RE.findall(repaired)) < before:
131 + return re.sub(r" {2,}", " ", repaired.replace("\xa0", " "))
132 + # Contenu MIXTE (vrais accents + mojibake dans le même champ) : l'aller-
133 + # retour global échoue sur les vrais accents. Réparation token par token —
134 + # un mot mélange rarement les deux.
135 + tokens = text.replace("Ã ", "Ã\xa0 ").replace("Â ", "Â\xa0").split(" ")
136 + changed = False
137 + for i, tok in enumerate(tokens):
138 + if not _MOJIBAKE_RE.search(tok):
139 + continue
140 + try:
141 + fixed = tok.encode("cp1252").decode("utf-8")
142 + except (UnicodeEncodeError, UnicodeDecodeError):
143 + continue
144 + if len(_MOJIBAKE_RE.findall(fixed)) < len(_MOJIBAKE_RE.findall(tok)):
145 + tokens[i] = fixed
146 + changed = True
147 + if changed:
148 + return re.sub(r" {2,}", " ", " ".join(tokens).replace("\xa0", " "))
149 + return text
150 +
151 +
152 +def decode_html(body: bytes, declared_charset: str | None = None) -> str:
153 + """Décode le HTML : UTF-8 strict d'abord, puis charsets déclarés, puis cp1252.
154 +
155 + L'UTF-8 strict passe AVANT le charset HTTP déclaré : quantité de serveurs
156 + annoncent iso-8859-1 (défaut Apache) en servant de l'UTF-8, et cp1252
157 + « réussit » toujours → « é » devenait « é » dans 10 000+ documents.
158 + Un décodage UTF-8 strict qui réussit sur des octets multi-octets n'est
159 + pratiquement jamais du vrai cp1252. Les vrais sites legacy cp1252 (dont le
160 + décodage UTF-8 strict échoue) restent correctement gérés ensuite.
161 + """
162 + try:
163 + return fix_mojibake(body.decode("utf-8"))
164 + except UnicodeDecodeError:
165 + pass
111 166 candidates: list[str] = []
112 167 if declared_charset:
113 168 candidates.append(declared_charset.lower())
114 169 meta = _META_CHARSET_RE.search(body[:4096])
115 170 if meta:
116 171 candidates.append(meta.group(1).decode("ascii", errors="ignore").lower())
117 − candidates.extend(["utf-8", "cp1252"])
172 + candidates.append("cp1252")
118 173 for charset in candidates:
119 174 charset = _CHARSET_ALIASES.get(charset, charset)
120 175 try:
121 − return body.decode(charset)
176 + return fix_mojibake(body.decode(charset))
122 177 except (UnicodeDecodeError, LookupError):
123 178 continue
124 179 return body.decode("utf-8", errors="replace")
added tests/test_parser_encoding.py +27 −0
@@ -0,0 +1,27 @@
1 +# Trouve-KA — tests de décodage et réparation d'encodage
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +from trouveka.parser import decode_html, fix_mojibake
6 +
7 +
8 +def test_fix_mojibake_repare_double_encodage():
9 + assert fix_mojibake("Une équipe donnée à Trois-Rivières") == "Une équipe donnée à Trois-Rivières"
10 + assert fix_mojibake("décédé à l'âge de 87 ans") == "décédé à l'âge de 87 ans"
11 +
12 +
13 +def test_fix_mojibake_ne_touche_pas_au_texte_sain():
14 + sain = "Une équipe professionnelle à Trois-Rivières — déjà là."
15 + assert fix_mojibake(sain) == sain
16 + assert fix_mojibake("plain ascii text") == "plain ascii text"
17 +
18 +
19 +def test_decode_html_utf8_prioritaire_sur_charset_menteur():
20 + # Serveur qui déclare iso-8859-1 mais sert de l'UTF-8 (défaut Apache)
21 + body = "Ville de Trois-Rivières — déneigement".encode("utf-8")
22 + assert decode_html(body, "iso-8859-1") == "Ville de Trois-Rivières — déneigement"
23 +
24 +
25 +def test_decode_html_vrai_cp1252_toujours_gere():
26 + body = "Ville de Québec — déneigement".encode("cp1252")
27 + assert decode_html(body, "iso-8859-1") == "Ville de Québec — déneigement"
28