Skip to content

feat: DB migration — pgvector, Postgres caches, leaderboard, security#199

Merged
Hugo0 merged 58 commits into
mainfrom
feat/db-migration-caches
Apr 12, 2026
Merged

feat: DB migration — pgvector, Postgres caches, leaderboard, security#199
Hugo0 merged 58 commits into
mainfrom
feat/db-migration-caches

Conversation

@Hugo0

@Hugo0 Hugo0 commented Apr 12, 2026

Copy link
Copy Markdown
Owner

Summary

Major server architecture overhaul: migrate from disk-based caches + in-memory embeddings to Postgres, add leaderboard, fix security issues, and improve word explorer performance.

Database Migration

  • Embeddings to pgvector: 50K embeddings, 70 axes, 4.4M neighbor ranks
  • Caches to Postgres: 253K definitions, 2.8K word stats, wiktionary, hints
  • Source provenance: model column (gpt-5.2, wiktionary-kaikki-2024)
  • Data already seeded in prod DB — no post-deploy steps needed

Memory and Startup

  • Memory: ~1.9GB to ~0.6-0.8GB
  • Startup: 3-30s to ~200ms
  • Zero disk I/O for caches/semantic

Code Cleanup

  • semantic.ts: 982 to 236 lines (dead code removed)
  • definitions.ts: kaikki in-memory cache + disk paths removed
  • word-stats.ts: disk + lockfile removed (156 to 65 lines)
  • wiktionary.ts: disk cache removed (109 to 53 lines)
  • 4MB semantic data files untracked from git

Word Explorer Performance

  • Eliminated 12-request waterfall — 1 enriched request with neighbor projections
  • Before: 13 requests, ~195KB, 200-500ms. After: 1 request, ~59KB, instant

Security (full audit)

  • Path traversal fix, rate limiting on LLM/DALL-E, open redirect fix
  • bcrypt DoS prevention, displayName validation, debug mode restricted

Cost Control

  • LLM definitions restricted to game words only ($31/day to minimal)
  • Request deduplication for concurrent LLM/embedding calls

Leaderboard

  • Daily/weekly/monthly rankings, streaks, hall of fame records
  • Historical data from localStorage sync (2.7K results, 72 users)

SEO and Content

  • Semantic mode meta templates, best-starting-words section, llms.txt

Test Plan

  • Equivalence tested against production
  • Security audit: 6 high/medium findings fixed
  • /simplify review: 16 findings fixed
  • CodeRabbit comments addressed
  • Post-deploy: monitor memory + semantic explorer

Hugo0 added 7 commits April 12, 2026 11:33
Phase 1 migration (0002): definitions, word_stats, wiktionary_cache,
semantic_hints — replacing disk-based JSON files with atomic DB operations.

Phase 2 migration (0003): pgvector extension + word_embeddings table
with HNSW index, semantic_axes, and target_neighbors for precomputed
rank lookups. Replaces the 98-230MB in-memory embedding matrix.

New db-cache.ts utility: DRY module for all cache operations with
DB-first + disk fallback pattern.
- semantic-db.ts: pgvector-backed rank lookup, kNN, embedding fetch
- definitions.ts: DB-first with disk fallback (4-tier cache)
- word-stats.ts: atomic DB increment (no lockfile), disk fallback
- semantic-warmup.ts: conditional loading (SEMANTIC_DB=1 skips 98MB)
- prisma.ts: pool max 10→20 for pgvector query concurrency
- migrate-caches-to-db.ts: import disk cache files into Postgres
- seed-semantic-db.ts: import embeddings + precompute target_neighbors
- wiktionary.ts: DB-first with disk fallback
- semantic/hint.post.ts: DB cache for LLM hints
- semantic/guess.post.ts: SEMANTIC_DB=1 flag enables pgvector rank
  lookup via target_neighbors table, with in-memory fallback
- semantic/reveal.post.ts: pgvector kNN for neighbor reveal
- seed-semantic-db.ts: sanitize NaN/Infinity float coordinates
- Add per-mode slot offsets to getWordsForDay() so daily words differ
  between classic, dordle, quordle, etc. (previously each mode was a
  strict subset of the next larger mode)
- Track used words in speed streak with a Set to prevent repeats
  within a session (previously only prevented consecutive repeats)
- Parallelize independent DB queries in semantic guess endpoint
- Cache getTotalRanked() result in memory (changes only at seed time)
- Add await to loadWordStats caller (sync→async fix)
@coderabbitai

coderabbitai Bot commented Apr 12, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces in-memory/disk caches with Postgres-backed caches and pgvector semantic storage; adds Prisma schema/migrations, DB utilities, migration/seed/benchmark scripts, inflight deduping, DB-first server endpoints with disk fallbacks, UI scrollbar utility, and multiple frontend/layout adjustments.

Changes

Cohort / File(s) Summary
Prisma schema & migrations
prisma/schema.prisma, prisma/migrations/0002_cache_tables/migration.sql, prisma/migrations/0003_semantic_embeddings/migration.sql
Adds cache tables and pgvector-backed semantic tables (embeddings, axes, target_neighbors), unique constraints, indexes, and enables pgvector.
Server DB utilities & pool
server/utils/db-cache.ts, server/utils/semantic-db.ts, server/utils/prisma.ts
New Prisma/SQL-backed cache API and semantic DB wrapper (axes, embeddings, KNN, rank, on-demand store); increases DB pool max connections (10→20).
Server logic & helpers
server/utils/definitions.ts, server/utils/wiktionary.ts, server/utils/word-stats.ts, server/utils/word-selection.ts, server/utils/semantic.ts, server/utils/inflight.ts
Switches to DB-first flows with disk fallbacks, adds inflight dedup (dedup), makes word-stats async/DB-first, adds MODE_SLOT_OFFSETS and mode-aware word selection.
API handlers
server/api/[lang]/semantic/guess.post.ts, server/api/[lang]/semantic/hint.post.ts, server/api/[lang]/semantic/reveal.post.ts, server/api/[lang]/data.get.ts, server/api/[lang]/words.get.ts, server/api/[lang]/word-stats.post.ts, server/api/[lang]/word-image/[word].get.ts
Rewires semantic endpoints to use semanticDb (embeddings, ranks, neighbors, axis normalization), adds DB hint caching and inflight dedup for images, threads mode and async stats handling through endpoints.
Migration, seed & benchmark scripts
scripts/migrate-caches-to-db.ts, scripts/seed-semantic-db.ts, scripts/benchmark-semantic-db.ts
Adds disk→DB migration script, embedding/axes/neighbor seeding with batching/upserts, and a benchmarking script for semantic DB hot paths.
Startup / warmup plugin
server/plugins/semantic-warmup.ts
Removes embedding warmup/regeneration; loads axes from DB and exposes getValidWords() for runtime word validation.
UI styling & components
assets/css/design-system.css, components/app/AppHeader.vue, components/semantic/SemanticLeaderboard.vue, components/shared/BaseModal.vue, components/app/AppSidebar.vue, components/game/MultiBoardLayout.vue
Adds global .editorial-scroll utility, removes scoped scrollbar rules, applies utility to sidebar/modal/leaderboard/multiboard, and tweaks header spacing/padding.
Semantic page & layout
pages/[lang]/semantic.vue, components/shared/MeaningMap.vue, components/shared/MapFrame.vue, TODO.md
Adjusts map sizing (dvh, --map-chrome, short-viewport rules), hardcodes non-expanded map size, adds ResizeObserver and camera transform refactor in MeaningMap.
Game store & selection
stores/game.ts
Adds Speed-mode used-word tracking and cycle-reset behavior.
Tests
tests/db-cache.test.ts, tests/semantic-db.test.ts
Adds Vitest suites for DB cache and semantic DB behavior (semantic tests gated on DATABASE_URL).

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant API as Server API
    participant SD as SemanticDB
    participant DB as Postgres
    participant Disk as Disk/In-Memory

    Client->>API: POST /[lang]/semantic/guess {guess,target,...}
    API->>SD: request embeddings, rank, neighbors, axes (parallel)
    SD->>DB: SELECT embedding / semantic_axes / target_neighbors / knn
    DB-->>SD: vectors, axes, neighbors, counts
    SD-->>API: rank, similarity, 2D position, normalized axis scores
    API-->>Client: response (rank, similarity, projections)
    alt DB miss or missing embedding
      API->>Disk: fallback to in-memory/LLM (embed on-demand)
      Disk-->>API: computed embedding/hint
      API-->>Client: fallback response
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I hopped through schemas and rows, with whiskers all aflutter,
Vectors tucked in Postgres beds, no more JSON clutter,
Scrollbars tiny, maps that stretch — I thumped a happy beat,
Seeds and migrations, deduped calls — tasty carrots to eat! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: DB migration — pgvector, Postgres caches, leaderboard, security' is highly specific and directly covers the main additions: database migrations, pgvector support, moving caches to Postgres, and related security/gameplay changes. It clearly summarizes the primary objectives of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/db-migration-caches

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Hugo0 added 2 commits April 12, 2026 12:24
- Remove dead .sidebar-scroll CSS from AppSidebar (replaced by global editorial-scroll)
- Remove :deep(.canvas-wrap) !important hack from semantic page — parent
  max-height + component's own mobile responsive rules handle this
- Extract magic 310px into --map-chrome CSS custom property

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🧹 Nitpick comments (2)
assets/css/design-system.css (1)

233-250: Consolidate remaining duplicate scrollbar utilities into this global class.

This new .editorial-scroll is a good source of truth, but components/app/AppSidebar.vue still has a duplicated .sidebar-scroll block (Lines 546-565). Consider switching that component to editorial-scroll and removing the duplicate scoped CSS to prevent style drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@assets/css/design-system.css` around lines 233 - 250, Replace the duplicated
scrollbar CSS in the AppSidebar component by removing the scoped .sidebar-scroll
block and updating the template to use the global .editorial-scroll class
instead: locate the .sidebar-scroll CSS in components/app/AppSidebar.vue, delete
that duplicated CSS, and change any element/class references from
"sidebar-scroll" to "editorial-scroll" so the component uses the global
editorial-scroll utility.
server/utils/word-selection.ts (1)

337-337: Avoid silent fallback to classic offset for unmapped modes.

At Line 337, ?? 0 hides missing mode mappings and can silently reintroduce collisions. Prefer failing fast for unknown modes.

💡 Suggested guard
-    const modeOffset = MODE_SLOT_OFFSETS[mode] ?? 0;
+    const modeOffset = MODE_SLOT_OFFSETS[mode];
+    if (modeOffset === undefined) {
+        throw new Error(`[word-selection] Missing MODE_SLOT_OFFSETS entry for mode "${mode}"`);
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/word-selection.ts` at line 337, The code silently falls back to
0 by using MODE_SLOT_OFFSETS[mode] ?? 0 which can hide missing mappings; change
this to explicitly guard for unknown modes by checking that MODE_SLOT_OFFSETS
has the key for the given mode (e.g., using
Object.prototype.hasOwnProperty.call(MODE_SLOT_OFFSETS, mode) or mode in
MODE_SLOT_OFFSETS) and if missing throw an informative Error (including the mode
value) instead of defaulting to 0 so callers fail fast; update the assignment of
modeOffset (currently using MODE_SLOT_OFFSETS[mode] ?? 0) to use the guarded
lookup and thrown error when unmapped.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@components/shared/BaseModal.vue`:
- Line 30: Add an optional prop to BaseModal.vue (e.g., scrollable: boolean =
true or disableScroll: boolean = false) and use it to conditionally apply or
remove the outer modal scroll classes (the class string containing
"max-h-[calc(100dvh-2rem)] overflow-y-auto" on the modal container) so consumers
like LanguagePickerModal can opt out of the outer scrolling; update the
component props and the template to toggle those classes based on the new prop
and document the prop usage for consumers.

In `@pages/`[lang]/semantic.vue:
- Around line 727-728: The `@media` (max-width: 380px) rule currently sets a
larger min-height than the `@media` (max-width: 520px) rule, which reduces usable
space on smallest screens; update the smaller-screen media query to use a
no-larger min-height (e.g., change its min-height to min(180px, 40dvh) or a
smaller value) so it is not greater than the min-height used at max-width:520px,
and ensure max-height remains 40dvh; modify the CSS rule that sets min-height
and max-height (look for the `@media` (max-width: 380px) block and the min-height
/ max-height properties) accordingly.
- Around line 649-654: The inline width on the MeaningMap element prevents the
square from scaling on short viewports; update the CSS selector .map-canvas-wrap
:deep(.canvas-wrap) to also override the inline width (e.g., set width: auto
!important or width: 100% !important) so width and height scale together and
respect the aspect-ratio/max-height rules used for mobile. Target the same
selector used for height and ensure the override uses !important to beat the
inline style coming from the MeaningMap component.

In `@scripts/migrate-caches-to-db.ts`:
- Around line 34-35: The script hard-codes WORD_DEFS_DIR and WORD_STATS_DIR to
process.cwd(), causing migrations to miss caches when the app uses a different
persistent root; change the script to resolve these directories from the same
PERSISTENT_DIR used by server/utils/data-loader.ts (i.e., import or require the
PERSISTENT_DIR symbol and set WORD_DEFS_DIR = join(PERSISTENT_DIR, 'word-defs')
and WORD_STATS_DIR = join(PERSISTENT_DIR, 'word-stats')), optionally keeping
process.cwd() as a fallback if PERSISTENT_DIR is undefined.

In `@scripts/seed-semantic-db.ts`:
- Around line 225-283: The code currently ranks every embedding in
words/embeddings (using variables words, embeddings, wordIndex, cosines,
indices, TOP_K_NEIGHBORS) but must only seed target_neighbors from vocab
entries; change the loop to first build a filtered list of vocab-only indices
(e.g. vocabIndices) based on your is_vocab marker for each word, compute cosines
only against that vocabIndices set (or compute for all but then map to
vocabIndices for ranking), sort that filtered index list by cosine, set k =
Math.min(TOP_K_NEIGHBORS, vocabIndices.length), and insert neighbors using
words[vocabIdx] and cosines[vocabIdx] so DB rows and
getTotalRanked()/computeGuessRank() see the same vocab-only leaderboard.

In `@server/api/`[lang]/semantic/guess.post.ts:
- Around line 89-102: The code stores raw dot products into
normalizedGuessProjections using
semanticDb.getCachedAxesVectors()/getCachedAxesNames(), but it must normalize
each axis like the legacy path: for each axis (loop over axesNames), compute the
axis vector norm (sqrt of sum of squares of axesVectors for that axis using
dims), guard against zero norm, then divide the dot product (computed from
guessVec and axesVectors) by that norm before assigning into
normalizedGuessProjections; implement either a precomputed axisNorms array or
compute the norm inline in the same loop so normalizedGuessProjections and the
legacy branch use the same scale.

In `@server/api/`[lang]/semantic/hint.post.ts:
- Around line 145-158: The disk fallback cache is only keyed by target, causing
cross-language collisions; update the cache key used when creating cacheFile so
it includes lang (e.g., incorporate lang into the filename or a language
subdirectory) so the disk tier matches the DB tier's (lang, target) key; adjust
any related uses of CACHE_DIR/cacheFile (in this file's disk read/write logic)
to use the new lang-scoped path and ensure directories are created with
mkdirSync as needed.

In `@server/api/`[lang]/semantic/reveal.post.ts:
- Around line 43-71: Wrap the DB-backed branch (guarded by USE_DB) in a
try/catch and validate results from semanticDb calls (e.g.,
semanticDb.get2dPosition, semanticDb.knnNearest, semanticDb.getTotalRanked,
semanticDb.computeGuessRank); if any call throws or returns
null/undefined/incomplete data (empty neighbours, missing totalRanked, or no
targetUmap), log the error and fall back to the existing legacy in-memory path
(the code below this branch) instead of returning a degraded payload—ensure the
catch returns control to the legacy flow rather than returning a DB result.
- Around line 50-62: The code sets allProjectionsNormalized to an empty object
which breaks the legacy contract; when enriching neighbours inside the
Promise.all (the mapping over neighbours where you call
semanticDb.computeGuessRank and use rankToDisplay), populate
allProjectionsNormalized from the neighbour record (use
n.allProjectionsNormalized if present) or compute it the same way the legacy
path does (e.g., call the existing normalization helper or derive from
n.projections and the same projection ranges used elsewhere) so the response
includes the normalized axis projections for each neighbour instead of {}.

In `@server/utils/definitions.ts`:
- Around line 276-279: The dynamic import of './db-cache' should be guarded so a
failing DB module doesn't abort fetchDefinition and prevent disk/LLM/Kaikki
fallbacks; wrap the await import('./db-cache') in a try/catch, assign safe
defaults (e.g., getDefinition = async ()=>null and upsertDefinition = async
()=>{}) when the import fails, and only call getDefinition/upsertDefinition if
they are the real functions; update the code locations referencing getDefinition
and upsertDefinition to handle null/placeholder responses so the remaining tiers
still execute.

In `@server/utils/semantic-db.ts`:
- Around line 114-116: In get2dPosition (server/utils/semantic-db.ts) replace
the invalid prisma.$raw(...) fragments with Prisma.raw(...) by importing Prisma
from '@prisma/client' at the top; keep using prisma.$queryRaw<Array<{ x: number;
y: number }>> for execution but interpolate dynamic column fragments via
Prisma.raw(col1) and Prisma.raw(col2) (and any other dynamic SQL fragments), and
update the import section to include "Prisma" so the code uses the correct
static helper instead of the non-existent prisma.$raw method.
- Around line 34-85: The cache currently holds a single {lang, axes,
axesVectors} and races across languages; change it to a per-language cache
(e.g., Map<string, { axes: AxisData[]; axesVectors: Float32Array }>) in place of
_axesCache, store entries keyed by lang inside loadAxes, and update the getters
getCachedAxesVectors and getCachedAxesNames to accept a lang parameter and
return the cached entry for that lang (or null/empty). Also search for and
update any call sites that call getCachedAxesVectors() / getCachedAxesNames() to
pass the current language so callers get the correct per-language data.

In `@server/utils/word-stats.ts`:
- Around line 50-57: updateWordStats() never reaches the disk fallback because
incrementWordStats() in db-cache.ts swallows Prisma errors and returns void;
change the contract so failures propagate: modify incrementWordStats (in
server/utils/db-cache.ts) to either rethrow caught errors or return a failure
boolean, and update updateWordStats (in server/utils/word-stats.ts) to detect
the failure (catch the rethrown error or inspect the boolean) and only return
early on success; if failure, fall through to call _writeStats() so the disk
fallback runs.
- Around line 20-25: The exported function loadWordStats is now async but some
GET handlers still call it synchronously; update each caller to await
loadWordStats and propagate async: mark the handler functions as async, replace
direct uses of loadWordStats(...) with await loadWordStats(...), and when
loading stats for multiple words use Promise.all (e.g., await
Promise.all(words.map(w => loadWordStats(...)))) to resolve all Promises before
building the response payload so the handlers return real WordStats instead of
Promises.

In `@stores/game.ts`:
- Around line 1913-1923: The selection loop can hang if `list` contains
duplicates because `_speedUsedWords.size < list.length` uses the raw list
length; compute the set of unique words (e.g., const unique = Array.from(new
Set(list)) or const uniqueCount = new Set(list).size) and use that unique
array/count for the size comparison and for random sampling; update the clear
check, the while condition, and the random pick to reference the deduplicated
collection (`unique` / `uniqueCount`) while still adding the chosen `word` to
`_speedUsedWords`.

---

Nitpick comments:
In `@assets/css/design-system.css`:
- Around line 233-250: Replace the duplicated scrollbar CSS in the AppSidebar
component by removing the scoped .sidebar-scroll block and updating the template
to use the global .editorial-scroll class instead: locate the .sidebar-scroll
CSS in components/app/AppSidebar.vue, delete that duplicated CSS, and change any
element/class references from "sidebar-scroll" to "editorial-scroll" so the
component uses the global editorial-scroll utility.

In `@server/utils/word-selection.ts`:
- Line 337: The code silently falls back to 0 by using MODE_SLOT_OFFSETS[mode]
?? 0 which can hide missing mappings; change this to explicitly guard for
unknown modes by checking that MODE_SLOT_OFFSETS has the key for the given mode
(e.g., using Object.prototype.hasOwnProperty.call(MODE_SLOT_OFFSETS, mode) or
mode in MODE_SLOT_OFFSETS) and if missing throw an informative Error (including
the mode value) instead of defaulting to 0 so callers fail fast; update the
assignment of modeOffset (currently using MODE_SLOT_OFFSETS[mode] ?? 0) to use
the guarded lookup and thrown error when unmapped.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 190b3d00-6087-44f5-b6b7-5e2b8fc86cc8

📥 Commits

Reviewing files that changed from the base of the PR and between 528eedc and ee70a74.

📒 Files selected for processing (25)
  • assets/css/design-system.css
  • components/app/AppHeader.vue
  • components/semantic/SemanticLeaderboard.vue
  • components/shared/BaseModal.vue
  • pages/[lang]/semantic.vue
  • prisma/migrations/0002_cache_tables/migration.sql
  • prisma/migrations/0003_semantic_embeddings/migration.sql
  • prisma/schema.prisma
  • scripts/migrate-caches-to-db.ts
  • scripts/seed-semantic-db.ts
  • server/api/[lang]/data.get.ts
  • server/api/[lang]/semantic/guess.post.ts
  • server/api/[lang]/semantic/hint.post.ts
  • server/api/[lang]/semantic/reveal.post.ts
  • server/api/[lang]/word-stats.post.ts
  • server/api/[lang]/words.get.ts
  • server/plugins/semantic-warmup.ts
  • server/utils/db-cache.ts
  • server/utils/definitions.ts
  • server/utils/prisma.ts
  • server/utils/semantic-db.ts
  • server/utils/wiktionary.ts
  • server/utils/word-selection.ts
  • server/utils/word-stats.ts
  • stores/game.ts

:aria-label="ariaLabel || undefined"
tabindex="-1"
class="relative w-full border border-rule bg-paper text-ink shadow-xl z-10 modal-animate"
class="relative w-full border border-rule bg-paper text-ink shadow-xl z-10 modal-animate max-h-[calc(100dvh-2rem)] overflow-y-auto editorial-scroll"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -name "BaseModal.vue" -o -name "LanguagePickerModal.vue" | head -20

Repository: Hugo0/wordle

Length of output: 130


🏁 Script executed:

wc -l ./components/shared/BaseModal.vue ./components/app/LanguagePickerModal.vue

Repository: Hugo0/wordle

Length of output: 151


🏁 Script executed:

cat -n ./components/shared/BaseModal.vue | head -50

Repository: Hugo0/wordle

Length of output: 2607


🏁 Script executed:

cat -n ./components/app/LanguagePickerModal.vue | head -50

Repository: Hugo0/wordle

Length of output: 2505


🏁 Script executed:

rg "BaseModal" --type vue -A 5 | grep -E "(BaseModal|overflow|max-h)" | head -40

Repository: Hugo0/wordle

Length of output: 83


🏁 Script executed:

rg "BaseModal" --glob "*.vue" -B 2 -A 8 | head -80

Repository: Hugo0/wordle

Length of output: 4869


🏁 Script executed:

cat -n ./components/shared/BaseModal.vue | sed -n '15,35p'

Repository: Hugo0/wordle

Length of output: 1298


Line 30: Nested modal scrolling is intentional but review constraints.

The modal dialog has overflow-y-auto alongside an inner max-h-[60vh] scrollable area (e.g., LanguagePickerModal, line 29). However, the modal's max-h-[calc(100dvh-2rem)] constraint and outer container's py-4 padding create a managed scroll hierarchy where the outer container only scrolls on very constrained viewports. The current design handles most cases without actual double-scroll issues. If consumers need full control over modal scrolling behavior, consider adding an optional disableScroll or scrollable prop to BaseModal, though this is not critical given existing constraints.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/shared/BaseModal.vue` at line 30, Add an optional prop to
BaseModal.vue (e.g., scrollable: boolean = true or disableScroll: boolean =
false) and use it to conditionally apply or remove the outer modal scroll
classes (the class string containing "max-h-[calc(100dvh-2rem)] overflow-y-auto"
on the modal container) so consumers like LanguagePickerModal can opt out of the
outer scrolling; update the component props and the template to toggle those
classes based on the new prop and document the prop usage for consumers.

Comment thread pages/[lang]/semantic.vue Outdated
Comment thread pages/[lang]/semantic.vue
Comment thread scripts/migrate-caches-to-db.ts Outdated
Comment on lines +225 to +283
const { words, embeddings } = loadEmbeddings();
const targetsRaw = loadJson<{ targets?: string[] } | string[]>('targets.json');
const targets = Array.isArray(targetsRaw) ? targetsRaw : targetsRaw.targets ?? [];
const N = words.length;

// Build word → index map
const wordIndex = new Map<string, number>();
for (let i = 0; i < N; i++) wordIndex.set(words[i]!, i);

console.log(
`[seed] Computing target neighbors: ${targets.length} targets × top ${TOP_K_NEIGHBORS}...`
);
console.log(' (This takes ~30 minutes for 879 targets × 50k vocab)');

const client = await pool.connect();
try {
await client.query('DELETE FROM wordle.target_neighbors WHERE lang = $1', [LANG]);

for (let t = 0; t < targets.length; t++) {
const target = targets[t]!;
const targetIdx = wordIndex.get(target);
if (targetIdx === undefined) {
console.warn(` [skip] target "${target}" not in vocab`);
continue;
}

// Compute cosine to all vocab words
const cosines = new Float32Array(N);
for (let i = 0; i < N; i++) {
let dot = 0;
for (let j = 0; j < DIMS; j++) {
dot +=
embeddings[targetIdx * DIMS + j]! *
embeddings[i * DIMS + j]!;
}
cosines[i] = dot;
}

// Get top K indices by cosine (descending)
const indices = Array.from({ length: N }, (_, i) => i);
indices.sort((a, b) => cosines[b]! - cosines[a]!);

// Batch insert top K neighbors
const k = Math.min(TOP_K_NEIGHBORS, N);
const batchValues: string[] = [];
const batchParams: any[] = [];
let pIdx = 1;

for (let rank = 0; rank < k; rank++) {
const idx = indices[rank]!;
batchValues.push(
`($${pIdx++}, $${pIdx++}, $${pIdx++}, $${pIdx++}, $${pIdx++})`
);
batchParams.push(
LANG,
target,
words[idx]!,
rank + 1,
cosines[idx]!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only seed target_neighbors from vocab entries.

This loop ranks against every embedding in words, but runtime getTotalRanked() counts only is_vocab = true rows and computeGuessRank() treats target_neighbors as the vocab leaderboard. If the embedding corpus contains non-vocab rows, DB mode will return ranks/display values that are inconsistent with the legacy cosine ranking.

Suggested fix
 async function seedTargetNeighbors() {
     const { words, embeddings } = loadEmbeddings();
     const targetsRaw = loadJson<{ targets?: string[] } | string[]>('targets.json');
     const targets = Array.isArray(targetsRaw) ? targetsRaw : targetsRaw.targets ?? [];
+    const vocabData = loadJson<{ words?: string[] } | string[]>('vocabulary.json');
+    const vocabulary = new Set<string>(Array.isArray(vocabData) ? vocabData : vocabData.words ?? []);
     const N = words.length;
+    const vocabIndices = words.flatMap((word, i) => (vocabulary.has(word) ? [i] : []));
 
     // Build word → index map
     const wordIndex = new Map<string, number>();
     for (let i = 0; i < N; i++) wordIndex.set(words[i]!, i);
@@
-            const cosines = new Float32Array(N);
-            for (let i = 0; i < N; i++) {
+            const cosines = new Float32Array(N);
+            for (const i of vocabIndices) {
                 let dot = 0;
                 for (let j = 0; j < DIMS; j++) {
                     dot +=
                         embeddings[targetIdx * DIMS + j]! *
                         embeddings[i * DIMS + j]!;
                 }
                 cosines[i] = dot;
             }
 
             // Get top K indices by cosine (descending)
-            const indices = Array.from({ length: N }, (_, i) => i);
+            const indices = [...vocabIndices];
             indices.sort((a, b) => cosines[b]! - cosines[a]!);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/seed-semantic-db.ts` around lines 225 - 283, The code currently ranks
every embedding in words/embeddings (using variables words, embeddings,
wordIndex, cosines, indices, TOP_K_NEIGHBORS) but must only seed
target_neighbors from vocab entries; change the loop to first build a filtered
list of vocab-only indices (e.g. vocabIndices) based on your is_vocab marker for
each word, compute cosines only against that vocabIndices set (or compute for
all but then map to vocabIndices for ranking), sort that filtered index list by
cosine, set k = Math.min(TOP_K_NEIGHBORS, vocabIndices.length), and insert
neighbors using words[vocabIdx] and cosines[vocabIdx] so DB rows and
getTotalRanked()/computeGuessRank() see the same vocab-only leaderboard.

Comment thread server/utils/_semantic-db.ts Outdated
Comment on lines +34 to +85
let _axesCache: { lang: string; axes: AxisData[]; axesVectors: Float32Array } | null = null;

/**
* Load axis data from DB. Called once at startup, cached forever.
* Total size: ~140KB (70 axes × 512 dims × 4 bytes). Negligible.
*/
export async function loadAxes(lang: string = 'en'): Promise<AxisData[]> {
if (_axesCache?.lang === lang) return _axesCache.axes;

const rows = await prisma.$queryRaw<
Array<{
name: string;
low_anchor: string;
high_anchor: string;
vector: string; // pgvector returns as string "[0.1,0.2,...]"
auc: number | null;
range_p5: number | null;
range_p95: number | null;
}>
>`SELECT name, low_anchor, high_anchor, vector::text, auc, range_p5, range_p95
FROM wordle.semantic_axes WHERE lang = ${lang} ORDER BY name`;

const axes: AxisData[] = rows.map((r) => ({
name: r.name,
lowAnchor: r.low_anchor,
highAnchor: r.high_anchor,
vector: parseVector(r.vector),
auc: r.auc ?? 0,
rangeP5: r.range_p5 ?? 0,
rangeP95: r.range_p95 ?? 0,
}));

// Build concatenated axes vector array for fast dot products
const dims = axes[0]?.vector.length ?? 512;
const axesVectors = new Float32Array(axes.length * dims);
for (let a = 0; a < axes.length; a++) {
axesVectors.set(axes[a]!.vector, a * dims);
}

_axesCache = { lang, axes, axesVectors };
return axes;
}

/** Get cached axes vectors for dot product computation. */
export function getCachedAxesVectors(): Float32Array | null {
return _axesCache?.axesVectors ?? null;
}

/** Get cached axis names in order. */
export function getCachedAxesNames(): string[] {
return _axesCache?.axes.map((a) => a.name) ?? [];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Scope the axes cache by language.

_axesCache only remembers one { lang, axes } bundle, but callers serve arbitrary route languages and read it back through lang-less getters. After another language loads, whichever request warmed the cache last wins, so later responses can return the wrong axis names/vectors.

Suggested fix
-let _axesCache: { lang: string; axes: AxisData[]; axesVectors: Float32Array } | null = null;
+const _axesCache = new Map<string, { axes: AxisData[]; axesVectors: Float32Array }>();

 export async function loadAxes(lang: string = 'en'): Promise<AxisData[]> {
-    if (_axesCache?.lang === lang) return _axesCache.axes;
+    const cached = _axesCache.get(lang);
+    if (cached) return cached.axes;
@@
-    _axesCache = { lang, axes, axesVectors };
+    _axesCache.set(lang, { axes, axesVectors });
     return axes;
 }

-export function getCachedAxesVectors(): Float32Array | null {
-    return _axesCache?.axesVectors ?? null;
+export function getCachedAxesVectors(lang: string): Float32Array | null {
+    return _axesCache.get(lang)?.axesVectors ?? null;
 }

-export function getCachedAxesNames(): string[] {
-    return _axesCache?.axes.map((a) => a.name) ?? [];
+export function getCachedAxesNames(lang: string): string[] {
+    return _axesCache.get(lang)?.axes.map((a) => a.name) ?? [];
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let _axesCache: { lang: string; axes: AxisData[]; axesVectors: Float32Array } | null = null;
/**
* Load axis data from DB. Called once at startup, cached forever.
* Total size: ~140KB (70 axes × 512 dims × 4 bytes). Negligible.
*/
export async function loadAxes(lang: string = 'en'): Promise<AxisData[]> {
if (_axesCache?.lang === lang) return _axesCache.axes;
const rows = await prisma.$queryRaw<
Array<{
name: string;
low_anchor: string;
high_anchor: string;
vector: string; // pgvector returns as string "[0.1,0.2,...]"
auc: number | null;
range_p5: number | null;
range_p95: number | null;
}>
>`SELECT name, low_anchor, high_anchor, vector::text, auc, range_p5, range_p95
FROM wordle.semantic_axes WHERE lang = ${lang} ORDER BY name`;
const axes: AxisData[] = rows.map((r) => ({
name: r.name,
lowAnchor: r.low_anchor,
highAnchor: r.high_anchor,
vector: parseVector(r.vector),
auc: r.auc ?? 0,
rangeP5: r.range_p5 ?? 0,
rangeP95: r.range_p95 ?? 0,
}));
// Build concatenated axes vector array for fast dot products
const dims = axes[0]?.vector.length ?? 512;
const axesVectors = new Float32Array(axes.length * dims);
for (let a = 0; a < axes.length; a++) {
axesVectors.set(axes[a]!.vector, a * dims);
}
_axesCache = { lang, axes, axesVectors };
return axes;
}
/** Get cached axes vectors for dot product computation. */
export function getCachedAxesVectors(): Float32Array | null {
return _axesCache?.axesVectors ?? null;
}
/** Get cached axis names in order. */
export function getCachedAxesNames(): string[] {
return _axesCache?.axes.map((a) => a.name) ?? [];
}
const _axesCache = new Map<string, { axes: AxisData[]; axesVectors: Float32Array }>();
/**
* Load axis data from DB. Called once at startup, cached forever.
* Total size: ~140KB (70 axes × 512 dims × 4 bytes). Negligible.
*/
export async function loadAxes(lang: string = 'en'): Promise<AxisData[]> {
const cached = _axesCache.get(lang);
if (cached) return cached.axes;
const rows = await prisma.$queryRaw<
Array<{
name: string;
low_anchor: string;
high_anchor: string;
vector: string; // pgvector returns as string "[0.1,0.2,...]"
auc: number | null;
range_p5: number | null;
range_p95: number | null;
}>
>`SELECT name, low_anchor, high_anchor, vector::text, auc, range_p5, range_p95
FROM wordle.semantic_axes WHERE lang = ${lang} ORDER BY name`;
const axes: AxisData[] = rows.map((r) => ({
name: r.name,
lowAnchor: r.low_anchor,
highAnchor: r.high_anchor,
vector: parseVector(r.vector),
auc: r.auc ?? 0,
rangeP5: r.range_p5 ?? 0,
rangeP95: r.range_p95 ?? 0,
}));
// Build concatenated axes vector array for fast dot products
const dims = axes[0]?.vector.length ?? 512;
const axesVectors = new Float32Array(axes.length * dims);
for (let a = 0; a < axes.length; a++) {
axesVectors.set(axes[a]!.vector, a * dims);
}
_axesCache.set(lang, { axes, axesVectors });
return axes;
}
/** Get cached axes vectors for dot product computation. */
export function getCachedAxesVectors(lang: string): Float32Array | null {
return _axesCache.get(lang)?.axesVectors ?? null;
}
/** Get cached axis names in order. */
export function getCachedAxesNames(lang: string): string[] {
return _axesCache.get(lang)?.axes.map((a) => a.name) ?? [];
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/semantic-db.ts` around lines 34 - 85, The cache currently holds
a single {lang, axes, axesVectors} and races across languages; change it to a
per-language cache (e.g., Map<string, { axes: AxisData[]; axesVectors:
Float32Array }>) in place of _axesCache, store entries keyed by lang inside
loadAxes, and update the getters getCachedAxesVectors and getCachedAxesNames to
accept a lang parameter and return the cached entry for that lang (or
null/empty). Also search for and update any call sites that call
getCachedAxesVectors() / getCachedAxesNames() to pass the current language so
callers get the correct per-language data.

Comment thread server/utils/_semantic-db.ts Outdated
Comment thread server/utils/word-stats.ts Outdated
Comment thread server/utils/word-stats.ts Outdated
Comment thread stores/game.ts Outdated
- Removed SEMANTIC_DB/SEMANTIC_LEGACY feature flags — DB is the
  only path now. No in-memory 98MB embedding matrix.
- Warmup plugin loads only axes (140KB) + valid words (2MB)
- guess.post.ts: clean single-path, Promise.all, no duplication
- reveal.post.ts: batch rank query (1 query instead of N+1)
- tests/db-cache.test.ts: interface tests, type safety, graceful null
- tests/semantic-db.test.ts: rank correctness, kNN, axes, caching
- scripts/benchmark-semantic-db.ts: p50/p95/p99 latency benchmarks
- Fixed remaining loadWordStats await callers

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/utils/db-cache.ts`:
- Around line 121-152: The incrementWordStats function currently swallows DB
errors in its try/catch (around the prisma.$executeRaw call) which loses
updates; change the function to surface failures so callers can retry or fall
back: update the signature of incrementWordStats to return Promise<boolean>
(true on success, false on failure) or throw the caught error instead of just
console.warn; inside the catch for prisma.$executeRaw, log the error with
context and then either return false or rethrow the error so the caller can
trigger the legacy JSON path or retry logic.
- Around line 61-73: The upsert currently spreads ...data into the create/update
objects (in prisma.definition.upsert and the similar upsert that sets model
metadata), which omits undefined fields and leaves stale nullable columns;
update the upsert payloads in db-cache.ts (the prisma.definition.upsert call and
the upsert that handles model metadata) to explicitly set each nullable column
to either its value or null (e.g., definitionText: data.definitionText ?? null,
model: modelValue ?? null) instead of relying on ...data so nulls are written
and old values are cleared; ensure cachedAt and isNegative remain set as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dbdd1b28-80c8-4b0f-abdf-148033b9931d

📥 Commits

Reviewing files that changed from the base of the PR and between ee70a74 and 3fa6555.

📒 Files selected for processing (5)
  • components/app/AppSidebar.vue
  • components/game/MultiBoardLayout.vue
  • pages/[lang]/semantic.vue
  • server/utils/db-cache.ts
  • server/utils/semantic-db.ts
✅ Files skipped from review due to trivial changes (1)
  • server/utils/semantic-db.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • pages/[lang]/semantic.vue

Comment thread server/utils/db-cache.ts
Comment on lines +61 to +73
await prisma.definition.upsert({
where: { lang_word: { lang, word } },
create: {
lang,
word,
...data,
isNegative,
},
update: {
...data,
isNegative,
cachedAt: new Date(),
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In Prisma Client 7.x, when an updateorupsertfield isundefined, is that field omitted from the SQL statement while null writes SQL NULL?

💡 Result:

Yes, in Prisma Client 7.x, when an update or upsert field's value is undefined, that field is omitted from the generated SQL statement. When the value is null, Prisma writes SQL NULL to explicitly set the field to null in the database. This behavior is the default ("current behavior") in Prisma 7.x and has not changed from previous major versions unless the strictUndefinedChecks preview feature is explicitly enabled in the schema. With strictUndefinedChecks enabled, explicitly passing undefined throws a runtime error, and Prisma.skip must be used instead to omit fields. Official Prisma documentation confirms: "In Prisma ORM, if undefined is passed as a value, it is not included in the generated query" and "Prisma Client differentiates between null and undefined: - null is a value - undefined means do nothing". This applies to update operations, as shown in the GraphQL resolver example where setting a field to undefined in update.data omits it, while null sets it to SQL NULL. No changes to this core behavior appear in Prisma 7.x release notes or upgrade guides.

Citations:


🏁 Script executed:

# Check Prisma version in package.json or package-lock.json
if [ -f package.json ]; then
  echo "=== package.json ===" 
  grep -A 2 '"prisma"' package.json || echo "Not found in package.json"
fi

if [ -f package-lock.json ]; then
  echo "=== package-lock.json ===" 
  grep -A 2 '"prisma"' package-lock.json | head -20
fi

Repository: Hugo0/wordle

Length of output: 164


🏁 Script executed:

# Check the actual db-cache.ts file
find . -name "db-cache.ts" -type f | head -5

Repository: Hugo0/wordle

Length of output: 82


🏁 Script executed:

# Read the db-cache.ts file
cat -n server/utils/db-cache.ts | head -100

Repository: Hugo0/wordle

Length of output: 3672


🏁 Script executed:

# Get more context around lines 61-73 and 208-211
cat -n server/utils/db-cache.ts | sed -n '50,80p'

Repository: Hugo0/wordle

Length of output: 1019


🏁 Script executed:

# Check lines around 208-211
cat -n server/utils/db-cache.ts | sed -n '200,220p'

Repository: Hugo0/wordle

Length of output: 575


Explicitly clear nullable fields on cache overwrites.

When spreading ...data with undefined values in line 70, those fields are omitted from the SQL statement. This causes a positive→negative definition transition to retain the old definition text. Similarly, line 211 omits model when undefined, leaving stale model metadata. Explicitly set nullable fields to null (or their values) to ensure they're included in the update clause:

Proposed fix
 export async function upsertDefinition(
     lang: string,
     word: string,
     data: DefinitionData,
     isNegative = false
 ): Promise<void> {
+    const definitionFields = isNegative
+        ? {
+              definition: null,
+              definitionNative: null,
+              definitionEn: null,
+              partOfSpeech: null,
+              confidence: null,
+              source: null,
+              url: null,
+          }
+        : {
+              definition: data.definition ?? null,
+              definitionNative: data.definitionNative ?? null,
+              definitionEn: data.definitionEn ?? null,
+              partOfSpeech: data.partOfSpeech ?? null,
+              confidence: data.confidence ?? null,
+              source: data.source ?? null,
+              url: data.url ?? null,
+          };
     try {
         await prisma.definition.upsert({
             where: { lang_word: { lang, word } },
             create: {
                 lang,
                 word,
-                ...data,
+                ...definitionFields,
                 isNegative,
             },
             update: {
-                ...data,
+                ...definitionFields,
                 isNegative,
                 cachedAt: new Date(),
             },
         });
     } catch {
         // Non-critical — disk fallback still works
     }
 }
@@
 export async function setSemanticHint(
     lang: string,
     word: string,
     hint: string,
     model?: string
 ): Promise<void> {
     try {
         await prisma.semanticHint.upsert({
             where: { lang_word: { lang, word } },
-            create: { lang, word, hint, model },
-            update: { hint, model, cachedAt: new Date() },
+            create: { lang, word, hint, model: model ?? null },
+            update: { hint, model: model ?? null, cachedAt: new Date() },
         });
     } catch {
         // Non-critical
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/db-cache.ts` around lines 61 - 73, The upsert currently spreads
...data into the create/update objects (in prisma.definition.upsert and the
similar upsert that sets model metadata), which omits undefined fields and
leaves stale nullable columns; update the upsert payloads in db-cache.ts (the
prisma.definition.upsert call and the upsert that handles model metadata) to
explicitly set each nullable column to either its value or null (e.g.,
definitionText: data.definitionText ?? null, model: modelValue ?? null) instead
of relying on ...data so nulls are written and old values are cleared; ensure
cachedAt and isNegative remain set as before.

Comment thread server/utils/db-cache.ts
Hugo0 added 6 commits April 12, 2026 12:46
…, disk fallback

- guess.post.ts: normalize axis projections using p5/p95 ranges
  (was returning raw dot products, breaking client rendering)
- definitions.ts: wrap db-cache import in try/catch so disk/LLM
  fallback tiers still run when DB module fails to load
- db-cache.ts: incrementWordStats returns boolean for success/failure
- word-stats.ts: falls back to disk when DB write returns false
- semantic-db.ts: export getCachedAxes() for normalization data
MeaningMap:
- Remove inline width/height on .canvas-wrap and <svg> — CSS controls
  display size, viewBox (520) controls coordinate space
- width:520px in CSS (shrinkable), aspect-ratio:1, max-width:100%
- Expanded mode: :global(.map-expanded) fills 90dvh/95dvw
- SVG uses width:100% height:100% — scales from container

Semantic page:
- :deep(.canvas-wrap) max-width constrains map to available height
  on short viewports (the square sizes from min of width/height)
- Hide "Find the hidden word" header on viewports <700px tall
  to give map more space (--map-chrome adjusts from 310→210px)
- editorial-scroll on .semantic-body

TODO:
- #16: viewport-locked semantic layout (full refactor, deferred)
Previously pan offset was mixed into the polar projection computation,
causing every dot's angle to change when dragging. Zoom divided the
radius, changing dot spacing. Now:

- Dot positions computed with fixed center (no pan/zoom in math)
- Pan/zoom applied as SVG <g transform="..."> wrapping all content
- Grid, dots, target, connectors all move as a rigid unit
- Target always at canvas center (transform handles the shift)

This fixes: dots repositioning during drag, zoom changing relative
positions, all words clustering in same spot.
…duced regressions

The clumping bug is a DATA issue (umapPosition fallback to [0.5,0.5]),
not a rendering issue. My changes to CSS sizing and polar projection
math introduced: grid drifting from dots, viewport transform breaking
the coordinate system, and expanded view not sizing correctly.

Proper map refactor deferred to TODO #16 with full architecture plan.
- Extract readCachedWord() helper, reuse in getWordForDay + recency loop
- Expand disk cache guard to accept mode slot indices (not just classic)
  so cached words feed the 60-day recency exclusion window
- All modes (dordle, quordle, speed, etc.) now avoid repeating words
  within RECENCY_WINDOW days, same as classic

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/utils/word-selection.ts (1)

364-373: ⚠️ Potential issue | 🟠 Major

Distinctness is not guaranteed when probe budget is exhausted.

If MAX_PROBE is reached, the current flow still pushes word even when it is already in used, so duplicates can leak into modes that require unique words.

Suggested fix
         while (used.has(word) && probe < MAX_PROBE) {
             word = getWordForDay(langCode, slotIdx + probe);
             probe++;
         }

+        if (used.has(word)) {
+            throw new Error(
+                `[word-selection] Could not find unique word for mode=${mode}, slotIdx=${slotIdx}, count=${count}`
+            );
+        }
+
         used.add(word);
         words.push(word);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/word-selection.ts` around lines 364 - 373, The dedup probe loop
around getWordForDay can exhaust MAX_PROBE and still push a duplicate into
words; update the logic in the word selection routine so after the while loop
you check whether used.has(word) is still true (i.e., probe >= MAX_PROBE) and
handle it explicitly: either throw a clear error, log and skip this slot, or
continue a fallback linear scan for the next unused word; reference the
variables/functions used, getWordForDay, probe, MAX_PROBE, used.add(word), and
words.push(word) and ensure you never call used.add or words.push with a word
that is already in used.
♻️ Duplicate comments (1)
server/utils/db-cache.ts (1)

203-217: ⚠️ Potential issue | 🟡 Minor

Optional model field may retain stale values.

When model is undefined (line 212-213), Prisma omits it from both create and update. On update, this means the old model value persists even if the caller intended to clear it.

Proposed fix
     try {
         await prisma.semanticHint.upsert({
             where: { lang_word: { lang, word } },
-            create: { lang, word, hint, model },
-            update: { hint, model, cachedAt: new Date() },
+            create: { lang, word, hint, model: model ?? null },
+            update: { hint, model: model ?? null, cachedAt: new Date() },
         });
     } catch {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/db-cache.ts` around lines 203 - 217, The upsert in
setSemanticHint can leave an old model value when model is undefined; change the
create/update payloads to explicitly set model to null when the caller passes
undefined (e.g., use model: typeof model === "undefined" ? null : model in both
the create and update objects) so an intended clear will overwrite any prior
value; update the references inside setSemanticHint to use this explicit
nulling.
🧹 Nitpick comments (3)
scripts/benchmark-semantic-db.ts (1)

14-17: Remove unused Prisma imports.

Prisma and PrismaPg are imported but never used in this script. The benchmark uses raw pg queries directly.

Proposed fix
 import pg from 'pg';
-import Prisma from '@prisma/client';
-import { PrismaPg } from '@prisma/adapter-pg';
-
-const { PrismaClient } = Prisma;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/benchmark-semantic-db.ts` around lines 14 - 17, Remove the unused
Prisma-related imports: delete the import of "Prisma" and "PrismaPg" and the
destructuring of "PrismaClient" since this script uses raw pg queries only;
specifically remove the lines that import Prisma from '@prisma/client', import {
PrismaPg } from '@prisma/adapter-pg', and the const { PrismaClient } = Prisma
statement so only the actual pg-based imports remain.
tests/db-cache.test.ts (2)

67-80: Test duplicates logic instead of testing the function.

This test replicates the conditional logic with hardcoded values (true && 1 === 1) rather than exercising the actual incrementWordStats function. The self-comparisons are always true, making this test effectively a no-op.

Consider either:

  1. Testing incrementWordStats directly against the DB (in the conditional DB-available block)
  2. Removing this test since it validates nothing about the real implementation
Alternative: Extract and test the logic as a pure function
-    it('incrementWordStats computes correct dist columns', () => {
-        // Verify the logic: won=true + attempts=3 should increment dist_3
-        // This tests the function's internal logic without DB
-        const d1 = true && 1 === 1 ? 1 : 0; // attempts=1
-        const d3 = true && 3 === 3 ? 1 : 0; // attempts=3
-        const d6 = true && 6 === 6 ? 1 : 0; // attempts=6
-        const d7 = true && 7 === 7 ? 1 : 0; // attempts=7 (out of range)
-        expect(d1).toBe(1);
-        expect(d3).toBe(1);
-        expect(d6).toBe(1);
-        // attempts=7 should still be 1 by the conditional, but the
-        // actual function clamps to 1-6 via the individual checks
-        expect(d7).toBe(1); // The guard is in the function, not here
-    });
+    it('incrementWordStats computes correct dist columns', () => {
+        // Helper to compute dist flags (mirrors function logic)
+        const computeDist = (won: boolean, attempts: number) => ({
+            d1: won && attempts === 1 ? 1 : 0,
+            d2: won && attempts === 2 ? 1 : 0,
+            d3: won && attempts === 3 ? 1 : 0,
+            d4: won && attempts === 4 ? 1 : 0,
+            d5: won && attempts === 5 ? 1 : 0,
+            d6: won && attempts === 6 ? 1 : 0,
+        });
+
+        expect(computeDist(true, 1)).toEqual({ d1: 1, d2: 0, d3: 0, d4: 0, d5: 0, d6: 0 });
+        expect(computeDist(true, 3)).toEqual({ d1: 0, d2: 0, d3: 1, d4: 0, d5: 0, d6: 0 });
+        expect(computeDist(true, 7)).toEqual({ d1: 0, d2: 0, d3: 0, d4: 0, d5: 0, d6: 0 }); // out of range
+        expect(computeDist(false, 3)).toEqual({ d1: 0, d2: 0, d3: 0, d4: 0, d5: 0, d6: 0 }); // lost
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/db-cache.test.ts` around lines 67 - 80, The test currently duplicates
the conditional logic with hardcoded expressions instead of invoking the real
code; replace the no-op assertions by either calling incrementWordStats with
representative inputs and asserting the produced dist columns (or DB-updated
values) inside the existing DB-available test block, or remove this test
entirely; alternatively extract the dist-calculation into a pure helper (e.g.,
computeDistBucket or the internal logic used by incrementWordStats), import and
unit-test that helper directly—use the function name incrementWordStats (or the
new helper) to locate and update the test.

10-18: Simplify DATABASE_URL check.

The try/catch is unnecessary since accessing process.env.DATABASE_URL doesn't throw.

Proposed simplification
 // Check if DB is available
-let dbAvailable = false;
-try {
-    if (process.env.DATABASE_URL) {
-        dbAvailable = true;
-    }
-} catch {
-    dbAvailable = false;
-}
+const dbAvailable = !!process.env.DATABASE_URL;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/db-cache.test.ts` around lines 10 - 18, The try/catch around checking
process.env.DATABASE_URL is unnecessary; simplify the logic that sets
dbAvailable by directly assigning it based on the presence/truthiness of
process.env.DATABASE_URL (replace the try/catch block that manipulates
dbAvailable with a direct boolean assignment), referencing the dbAvailable
variable and the environment lookup process.env.DATABASE_URL to locate and
update the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/utils/word-selection.ts`:
- Around line 352-355: The code currently defaults unknown/typo modes to offset
0 via MODE_SLOT_OFFSETS[mode] ?? 0 causing silent overlap with classic; update
the function to validate that mode exists in MODE_SLOT_OFFSETS before computing
baseIdx (using the mode parameter and MODE_SLOT_OFFSETS lookup) and throw a
clear Error (including the invalid mode) when it's not present so callers fail
closed instead of silently using the classic offset.

---

Outside diff comments:
In `@server/utils/word-selection.ts`:
- Around line 364-373: The dedup probe loop around getWordForDay can exhaust
MAX_PROBE and still push a duplicate into words; update the logic in the word
selection routine so after the while loop you check whether used.has(word) is
still true (i.e., probe >= MAX_PROBE) and handle it explicitly: either throw a
clear error, log and skip this slot, or continue a fallback linear scan for the
next unused word; reference the variables/functions used, getWordForDay, probe,
MAX_PROBE, used.add(word), and words.push(word) and ensure you never call
used.add or words.push with a word that is already in used.

---

Duplicate comments:
In `@server/utils/db-cache.ts`:
- Around line 203-217: The upsert in setSemanticHint can leave an old model
value when model is undefined; change the create/update payloads to explicitly
set model to null when the caller passes undefined (e.g., use model: typeof
model === "undefined" ? null : model in both the create and update objects) so
an intended clear will overwrite any prior value; update the references inside
setSemanticHint to use this explicit nulling.

---

Nitpick comments:
In `@scripts/benchmark-semantic-db.ts`:
- Around line 14-17: Remove the unused Prisma-related imports: delete the import
of "Prisma" and "PrismaPg" and the destructuring of "PrismaClient" since this
script uses raw pg queries only; specifically remove the lines that import
Prisma from '@prisma/client', import { PrismaPg } from '@prisma/adapter-pg', and
the const { PrismaClient } = Prisma statement so only the actual pg-based
imports remain.

In `@tests/db-cache.test.ts`:
- Around line 67-80: The test currently duplicates the conditional logic with
hardcoded expressions instead of invoking the real code; replace the no-op
assertions by either calling incrementWordStats with representative inputs and
asserting the produced dist columns (or DB-updated values) inside the existing
DB-available test block, or remove this test entirely; alternatively extract the
dist-calculation into a pure helper (e.g., computeDistBucket or the internal
logic used by incrementWordStats), import and unit-test that helper directly—use
the function name incrementWordStats (or the new helper) to locate and update
the test.
- Around line 10-18: The try/catch around checking process.env.DATABASE_URL is
unnecessary; simplify the logic that sets dbAvailable by directly assigning it
based on the presence/truthiness of process.env.DATABASE_URL (replace the
try/catch block that manipulates dbAvailable with a direct boolean assignment),
referencing the dbAvailable variable and the environment lookup
process.env.DATABASE_URL to locate and update the code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 85f0cb84-ba30-46de-a7ca-553ea281faed

📥 Commits

Reviewing files that changed from the base of the PR and between 3fa6555 and b4b21b6.

📒 Files selected for processing (14)
  • TODO.md
  • pages/[lang]/semantic.vue
  • scripts/benchmark-semantic-db.ts
  • server/api/[lang]/data.get.ts
  • server/api/[lang]/semantic/guess.post.ts
  • server/api/[lang]/semantic/reveal.post.ts
  • server/plugins/semantic-warmup.ts
  • server/utils/db-cache.ts
  • server/utils/definitions.ts
  • server/utils/semantic-db.ts
  • server/utils/word-selection.ts
  • server/utils/word-stats.ts
  • tests/db-cache.test.ts
  • tests/semantic-db.test.ts
✅ Files skipped from review due to trivial changes (1)
  • TODO.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • server/api/[lang]/data.get.ts
  • server/utils/word-stats.ts
  • pages/[lang]/semantic.vue
  • server/api/[lang]/semantic/guess.post.ts

Comment on lines +352 to +355
mode: string = 'classic'
): string[] {
const modeOffset = MODE_SLOT_OFFSETS[mode] ?? 0;
const baseIdx = todaysIdx + modeOffset;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fail closed on unknown mode instead of defaulting to classic offset.

At Line 354, unsupported/typo modes silently map to 0, which can unintentionally overlap with the classic region and mask config/caller bugs.

Suggested fix
 export function getWordsForDay(
     langCode: string,
     todaysIdx: number,
     count: number,
     mode: string = 'classic'
 ): string[] {
-    const modeOffset = MODE_SLOT_OFFSETS[mode] ?? 0;
+    const modeOffset = MODE_SLOT_OFFSETS[mode];
+    if (modeOffset === undefined) {
+        throw new Error(`[word-selection] Unsupported mode "${mode}"`);
+    }
     const baseIdx = todaysIdx + modeOffset;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
mode: string = 'classic'
): string[] {
const modeOffset = MODE_SLOT_OFFSETS[mode] ?? 0;
const baseIdx = todaysIdx + modeOffset;
export function getWordsForDay(
langCode: string,
todaysIdx: number,
count: number,
mode: string = 'classic'
): string[] {
const modeOffset = MODE_SLOT_OFFSETS[mode];
if (modeOffset === undefined) {
throw new Error(`[word-selection] Unsupported mode "${mode}"`);
}
const baseIdx = todaysIdx + modeOffset;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/word-selection.ts` around lines 352 - 355, The code currently
defaults unknown/typo modes to offset 0 via MODE_SLOT_OFFSETS[mode] ?? 0 causing
silent overlap with classic; update the function to validate that mode exists in
MODE_SLOT_OFFSETS before computing baseIdx (using the mode parameter and
MODE_SLOT_OFFSETS lookup) and throw a clear Error (including the invalid mode)
when it's not present so callers fail closed instead of silently using the
classic offset.

Add server/utils/inflight.ts — a lightweight in-flight request
coalescing utility. When multiple concurrent requests need the same
expensive result, only the first caller runs the function; others
await the same Promise.

Wired into all three LLM call paths:
- definitions: concurrent requests for same lang:word share one GPT call
- semantic hints: concurrent hint requests share one generation+validation
- on-demand embeddings: concurrent OOV word lookups share one API call

Also includes other agents' work:
- MeaningMap ResizeObserver for proper display scaling
- DB migration script cleanup + DEPRECATED warnings on disk fallback paths
- TODO.md tracking for disk fallback removal

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (1)
server/api/[lang]/semantic/hint.post.ts (1)

157-159: ⚠️ Potential issue | 🟠 Major

Scope the disk fallback by language too.

The DB and in-flight tiers are keyed by (lang, target), but the disk tier is still keyed only by target. When the DB path misses or is unavailable, this can return a hint generated for another language and reintroduces the same cross-language collision as before.

Suggested fix
-    mkdirSync(CACHE_DIR, { recursive: true });
-    const cacheFile = join(CACHE_DIR, `${target}.json`);
+    const langCacheDir = join(CACHE_DIR, lang);
+    mkdirSync(langCacheDir, { recursive: true });
+    const cacheFile = join(langCacheDir, `${target}.json`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/`[lang]/semantic/hint.post.ts around lines 157 - 159, Disk cache
is currently keyed only by `target` (CACHE_DIR + `${target}.json`) causing
cross-language collisions; change disk cache keys to include `lang` as well.
Update the creation and lookup of `cacheFile` in
server/api/[lang]/semantic/hint.post.ts (where `mkdirSync`, `CACHE_DIR`, and
`join` are used) to scope by language (e.g., use a language subdirectory or
include `lang` in the filename like `${lang}-${target}.json`), ensure the
directory for that language is created with `mkdirSync`, and adjust all
reads/writes that reference `cacheFile` so they use the new language-scoped
path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@components/shared/MeaningMap.vue`:
- Around line 114-129: The pan math is still using raw screen pixels so dragging
is wrong when the SVG is scaled; update all conversions between screen/pointer
pixels and viewBox pixels to use the computed displayScale (multiply/divide by
displayScale as appropriate) wherever panOffset is set or applied (search for
panOffset assignments/uses and pointer handlers like onPointerDown/onPointerMove
or functions that read svgRef event coords around the previously mentioned
spots), ensuring pointer-derived deltas are divided by displayScale to convert
to viewBox units and that any viewBox->screen calculations multiply by
displayScale.
- Line 396: The component calls onMounted(() => _initResizeObserver()) but
onMounted was not imported; add onMounted to the Vue imports in MeaningMap.vue
(i.e., include onMounted in the existing import { ... } from 'vue' so the
onMounted symbol is available for use with the _initResizeObserver call).

In `@scripts/migrate-caches-to-db.ts`:
- Around line 35-39: The pg.Pool is forcing TLS by setting ssl: {
rejectUnauthorized: false } unconditionally, which breaks connections to servers
that don't support SSL; update the pool creation (new pg.Pool / pool) to only
set the ssl option when configured (e.g., driven by an env var or parsed from
DATABASE_URL) so local/CI setups can omit SSL and production can enable
it—change the logic that builds the Pool config to conditionally include ssl
instead of hard-coding it.

In `@server/utils/definitions.ts`:
- Around line 348-390: The dedup closure currently ignores options.cacheOnly so
a cache-only miss can be negative-cached and block concurrent full lookups; fix
by including the cacheOnly flag in the dedup key (e.g., append
`:cacheOnly=${options.cacheOnly}` to `dedupKey`) or, alternatively, avoid
persisting negative results when `options.cacheOnly` is true by skipping the
dbCache.upsertDefinition / disk write for `isNeg` if `options.cacheOnly` is set;
update the usage around dedup('definition', dedupKey, ...), options.cacheOnly,
and dbCache.upsertDefinition/writeFileSync accordingly.
- Around line 317-321: The console.warn for "[DEPRECATED] definitions disk
fallback hit" is emitted before checking whether the disk file actually exists;
move the warning so it runs only when the disk fallback is real by checking
existsSync(cachePath) first (use the same cachePath variable created from
WORD_DEFS_DIR, join(langCode, ...) and word.toLowerCase()), and only if
existsSync(cachePath) returns true emit the warning and proceed with reading the
file; ensure no warning is logged when the DB miss occurs and no disk file is
present.

In `@server/utils/word-stats.ts`:
- Around line 30-33: The deprecated warning is logged on every DB miss even if
the disk file doesn't exist; update the code around WORD_STATS_DIR/statsPath so
you first compute statsPath and check existsSync(statsPath) and only then log
the `[DEPRECATED] word-stats disk fallback hit for` message (and proceed to
read/return the file); ensure console.warn is moved to after the existsSync
check (or only executed when a successful disk read occurs) so the warning fires
only when the disk tier is actually used.

---

Duplicate comments:
In `@server/api/`[lang]/semantic/hint.post.ts:
- Around line 157-159: Disk cache is currently keyed only by `target` (CACHE_DIR
+ `${target}.json`) causing cross-language collisions; change disk cache keys to
include `lang` as well. Update the creation and lookup of `cacheFile` in
server/api/[lang]/semantic/hint.post.ts (where `mkdirSync`, `CACHE_DIR`, and
`join` are used) to scope by language (e.g., use a language subdirectory or
include `lang` in the filename like `${lang}-${target}.json`), ensure the
directory for that language is created with `mkdirSync`, and adjust all
reads/writes that reference `cacheFile` so they use the new language-scoped
path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f35e8926-ae72-415b-88e8-303c797dbbb0

📥 Commits

Reviewing files that changed from the base of the PR and between b4b21b6 and c219f48.

📒 Files selected for processing (9)
  • TODO.md
  • components/shared/MeaningMap.vue
  • scripts/migrate-caches-to-db.ts
  • server/api/[lang]/semantic/hint.post.ts
  • server/utils/definitions.ts
  • server/utils/inflight.ts
  • server/utils/semantic.ts
  • server/utils/wiktionary.ts
  • server/utils/word-stats.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/utils/wiktionary.ts

Comment thread components/shared/MeaningMap.vue
Comment thread components/shared/MeaningMap.vue
Comment on lines +35 to +39
const pool = new pg.Pool({
connectionString: DATABASE_URL,
ssl: { rejectUnauthorized: false },
max: 5,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

With node-postgres (pg), does setting ssl: { rejectUnauthorized: false }onnew Pool() force TLS, and how does it behave when the PostgreSQL server does not support SSL?

💡 Result:

No, setting ssl: { rejectUnauthorized: false } on new Pool does not force TLS. It configures the TLS options to be used if a TLS connection is established, specifically disabling certificate verification. However, node-postgres only attempts a TLS connection if the PostgreSQL server supports SSL/TLS. If the server does not support SSL (i.e., ssl = off in PostgreSQL config), node-postgres first establishes a plain TCP connection, sends an SSL request byte during the startup protocol handshake, and the server responds with a rejection. This results in the error "The server does not support SSL connections", and the connection fails without falling back to non-TLS.

Citations:


🏁 Script executed:

# Check if the file exists and read the relevant lines
if [ -f "scripts/migrate-caches-to-db.ts" ]; then
  echo "=== File found, checking lines 30-45 ==="
  sed -n '30,45p' scripts/migrate-caches-to-db.ts | cat -n
else
  echo "File not found at scripts/migrate-caches-to-db.ts"
  # Try to find it
  fd -t f "migrate-caches-to-db" 2>/dev/null || echo "File not found in repository"
fi

Repository: Hugo0/wordle

Length of output: 720


Don't hard-code TLS requirement for every migration run.

Setting ssl: { rejectUnauthorized: false } forces node-postgres to attempt a TLS connection on every run. If the PostgreSQL server doesn't support SSL, the connection fails with "The server does not support SSL connections"—no graceful fallback. This breaks the migration against valid local or CI databases that don't require TLS, making the tool environment-fragile. Gate SSL behind configuration instead.

Suggested fix
-const pool = new pg.Pool({
-    connectionString: DATABASE_URL,
-    ssl: { rejectUnauthorized: false },
-    max: 5,
-});
+const useSsl =
+    /\bsslmode=require\b/i.test(DATABASE_URL) || process.env.PGSSLMODE === 'require';
+
+const pool = new pg.Pool({
+    connectionString: DATABASE_URL,
+    ssl: useSsl ? { rejectUnauthorized: false } : undefined,
+    max: 5,
+});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const pool = new pg.Pool({
connectionString: DATABASE_URL,
ssl: { rejectUnauthorized: false },
max: 5,
});
const useSsl =
/\bsslmode=require\b/i.test(DATABASE_URL) || process.env.PGSSLMODE === 'require';
const pool = new pg.Pool({
connectionString: DATABASE_URL,
ssl: useSsl ? { rejectUnauthorized: false } : undefined,
max: 5,
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/migrate-caches-to-db.ts` around lines 35 - 39, The pg.Pool is forcing
TLS by setting ssl: { rejectUnauthorized: false } unconditionally, which breaks
connections to servers that don't support SSL; update the pool creation (new
pg.Pool / pool) to only set the ssl option when configured (e.g., driven by an
env var or parsed from DATABASE_URL) so local/CI setups can omit SSL and
production can enable it—change the logic that builds the Pool config to
conditionally include ssl instead of hard-coding it.

Comment thread server/utils/definitions.ts Outdated
Comment thread server/utils/definitions.ts Outdated
Comment thread server/utils/word-stats.ts Outdated
Hugo0 added 2 commits April 12, 2026 14:10
Adopt standard web-map pattern: world coordinates + rigid camera <g>.

- screenDots computes positions at zoom=1, pan=[0,0] (world coords)
- Single <g :transform="translate(...) scale(...)"> wraps all SVG content
- Pan/zoom applied as rigid viewport transform — dots never reposition
  relative to each other during drag or zoom
- Semantic zoom: dots counter-scale (scale(1/zoom)) for constant visual size
- Grid in world coordinates, extends beyond [0,1] for panning coverage
- vector-effect="non-scaling-stroke" on grid/connector lines
- ResizeObserver tracks actual CSS display width for accurate pan conversion
- Target always at (S/2, S/2) in world space
- Border moved from MeaningMap to MapFrame (controls inside border)
- Expanded mode passes frameSize for larger viewBox
- Add dedup() to word-image endpoint, replacing .pending file lock
  (cleaner, no orphaned lock files on crash)
- Add dedup() to wiktionary HEAD probe
- All 5 external API paths now use inflight dedup:
  definitions, hints, embeddings, images, wiktionary
- Include other agents' DB cache fixes and semantic improvements

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (2)
server/utils/db-cache.ts (1)

61-73: ⚠️ Potential issue | 🟡 Minor

Normalize nullable fields before these Prisma upserts.

Both calls rely on Prisma receiving undefined for omitted fields, which means old nullable values can survive an overwrite. That leaves stale definition columns and stale model metadata behind when the new payload intentionally clears them.

Proposed fix
 export async function upsertDefinition(
     lang: string,
     word: string,
     data: DefinitionData,
     isNegative = false
 ): Promise<void> {
+    const definitionFields = {
+        definition: isNegative ? null : (data.definition ?? null),
+        definitionNative: isNegative ? null : (data.definitionNative ?? null),
+        definitionEn: isNegative ? null : (data.definitionEn ?? null),
+        partOfSpeech: isNegative ? null : (data.partOfSpeech ?? null),
+        confidence: isNegative ? null : (data.confidence ?? null),
+        source: isNegative ? null : (data.source ?? null),
+        url: isNegative ? null : (data.url ?? null),
+    };
     try {
         await prisma.definition.upsert({
             where: { lang_word: { lang, word } },
             create: {
                 lang,
                 word,
-                ...data,
+                ...definitionFields,
                 isNegative,
             },
             update: {
-                ...data,
+                ...definitionFields,
                 isNegative,
                 cachedAt: new Date(),
             },
         });
     } catch {
@@
 export async function setSemanticHint(
     lang: string,
     word: string,
     hint: string,
     model?: string
 ): Promise<void> {
     try {
         await prisma.semanticHint.upsert({
             where: { lang_word: { lang, word } },
-            create: { lang, word, hint, model },
-            update: { hint, model, cachedAt: new Date() },
+            create: { lang, word, hint, model: model ?? null },
+            update: { hint, model: model ?? null, cachedAt: new Date() },
         });
     } catch {
         // Non-critical
     }
 }

Also applies to: 210-213

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/db-cache.ts` around lines 61 - 73, The upsert calls to
prisma.definition.upsert (the where: { lang_word: { lang, word } } blocks using
create/update) must normalize nullable fields to undefined so intentional clears
don't leave old values; before calling prisma.definition.upsert (and the other
upsert at the same file region), map any nullable properties (e.g., model, any
optional metadata fields, and other nullable columns included in ...data) and
convert explicit nulls or missing keys to undefined, then pass that sanitized
object into the create and update payloads so Prisma will remove stale values
instead of preserving them.
server/utils/definitions.ts (1)

339-381: ⚠️ Potential issue | 🟠 Major

Keep cacheOnly misses out of the shared negative cache.

The dedup key still ignores cacheOnly, and this closure still persists a miss. A cache-only request can therefore negative-cache the word and make later full lookups return null without ever trying the LLM.

Proposed fix
-    const dedupKey = `${langCode}:${word.toLowerCase()}`;
+    const cacheKey = word.toLowerCase();
+    const allowLlm = !options.cacheOnly;
+    const dedupKey = `${langCode}:${cacheKey}:${allowLlm ? 'llm' : 'cache-only'}`;
     const result = await dedup('definition', dedupKey, async () => {
         let llmResult: Record<string, any> | null = null;
-        if (!options.cacheOnly) {
+        if (allowLlm) {
             llmResult = await callLlmDefinition(word, langCode);
         }
         if (!llmResult) {
             llmResult =
                 lookupKaikki(word, langCode, 'native') || lookupKaikki(word, langCode, 'en');
         }
 
         // Cache result to DB (primary) and disk (backup)
-        const isNeg = !llmResult;
-        dbCache?.upsertDefinition(
-            langCode,
-            word.toLowerCase(),
-            llmResult
-                ? {
-                      definition: llmResult.definition,
-                      definitionNative: llmResult.definition_native,
-                      definitionEn: llmResult.definition_en,
-                      partOfSpeech: llmResult.part_of_speech,
-                      confidence: llmResult.confidence,
-                      source: llmResult.source,
-                      url: llmResult.url,
-                  }
-                : {},
-            isNeg
-        );
+        const shouldPersistNegative = allowLlm;
+        const isNeg = !llmResult && shouldPersistNegative;
+        if (llmResult || shouldPersistNegative) {
+            await dbCache?.upsertDefinition(
+                langCode,
+                cacheKey,
+                llmResult
+                    ? {
+                          definition: llmResult.definition,
+                          definitionNative: llmResult.definition_native,
+                          definitionEn: llmResult.definition_en,
+                          partOfSpeech: llmResult.part_of_speech,
+                          confidence: llmResult.confidence,
+                          source: llmResult.source,
+                          url: llmResult.url,
+                      }
+                    : {},
+                isNeg
+            );
+        }
         // DEPRECATED: disk write — remove after confirming DB migration is stable
         try {
             mkdirSync(langCacheDir, { recursive: true });
-            writeFileSync(
-                cachePath,
-                JSON.stringify(llmResult || { not_found: true, ts: Math.floor(Date.now() / 1000) }),
-                'utf-8'
-            );
+            if (llmResult || shouldPersistNegative) {
+                writeFileSync(
+                    cachePath,
+                    JSON.stringify(
+                        llmResult || { not_found: true, ts: Math.floor(Date.now() / 1000) }
+                    ),
+                    'utf-8'
+                );
+            }
         } catch {
             // Non-critical
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/definitions.ts` around lines 339 - 381, The closure currently
uses a dedup key that ignores options.cacheOnly and then persists misses, so
cache-only requests can negative-cache results; fix by (1) incorporating
options.cacheOnly into the dedup key (e.g., include `${options.cacheOnly}` when
building dedupKey used in dedup('definition', dedupKey,...)) so cache-only
lookups are deduped separately, and (2) avoid writing negative cache entries for
cache-only requests by skipping the negative upsert/backup write when
options.cacheOnly is true (check options.cacheOnly before calling
dbCache.upsertDefinition with isNeg or before the disk write).
🧹 Nitpick comments (1)
server/api/[lang]/word-image/[word].get.ts (1)

138-145: Consider making cache write failures non-fatal.

If mkdirSync or writeFileSync throws (disk full, permissions issue), the successfully generated image is lost and the client gets a 500. The image would need to be regenerated on retry (additional DALL-E cost).

You could optionally wrap the cache write in a try/catch to still return the successful image even when caching fails:

♻️ Optional: Return image even if caching fails
         const sharp = (await import('sharp')).default;
         const buf = await sharp(imageBuffer).webp({ quality: 80 }).toBuffer();

-        mkdirSync(cacheDir, { recursive: true });
-        writeFileSync(cachePath, buf);
+        try {
+            mkdirSync(cacheDir, { recursive: true });
+            writeFileSync(cachePath, buf);
+        } catch (e) {
+            console.error(`Failed to cache image for ${lang}:${word}`, e);
+        }
         return buf;
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/`[lang]/word-image/[word].get.ts around lines 138 - 145, The cache
write using mkdirSync(cacheDir, { recursive: true }) and
writeFileSync(cachePath, buf) can throw and make the request return 500; wrap
the cache write in a try/catch so any filesystem errors are swallowed (or
logged) and do not prevent returning the generated image (webpBuffer); ensure
you still set the response headers (setResponseHeader calls) and return
webpBuffer even if the catch is hit, optionally logging the error with context
(cacheDir/cachePath) for debugging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/utils/db-cache.ts`:
- Around line 121-149: The function incrementWordStats currently increments wins
even when won===true but attempts is outside 1..6; add an early validation in
incrementWordStats that if won is true and attempts is not an integer between 1
and 6, the call is rejected (either return false or throw an error) before
computing d1..d6 or executing the SQL so wins cannot diverge from the dist_*
sum; update any callers/tests if they expect a throw vs false. Ensure the guard
references the function name incrementWordStats and the won/attempts parameters
so the SQL block (the prisma.$executeRaw call and d1..d6 computation) never runs
for invalid attempts.

In `@tests/db-cache.test.ts`:
- Around line 66-79: The test currently asserts static constants instead of
exercising the real logic; update tests to call incrementWordStats (or extract
and call the pure bucket-mapping helper, e.g., computeDistBucket or similar) and
assert outcomes rather than constants. Either (A) import and invoke
incrementWordStats with won=true/attempts=1/3/6/7 and mock prisma.$executeRaw to
verify the generated SQL/parameters update the correct dist_1..dist_6 columns,
or (B) extract the bucket mapping into a pure function (name it clearly) in the
production module, call that function from the test with those attempt values
and assert the returned bucket index/clamped value; use jest mocks/spies on
prisma.$executeRaw when testing increments so the test actually verifies
production behavior.
- Around line 81-86: The current assertion is tautological; instead, in the test
for getDefinition ensure you simulate a DB failure and assert the function
returns null. Modify the test that imports ../server/utils/db-cache and before
calling getDefinition('test','nonexistent') mock/stub the underlying DB client
used by that module (the same dependency that db-cache calls) to throw or return
an error, then await mod.getDefinition(...) and assert expect(def).toBeNull();
keep the test name and flow but replace the current lax assertion with this
explicit null check.

---

Duplicate comments:
In `@server/utils/db-cache.ts`:
- Around line 61-73: The upsert calls to prisma.definition.upsert (the where: {
lang_word: { lang, word } } blocks using create/update) must normalize nullable
fields to undefined so intentional clears don't leave old values; before calling
prisma.definition.upsert (and the other upsert at the same file region), map any
nullable properties (e.g., model, any optional metadata fields, and other
nullable columns included in ...data) and convert explicit nulls or missing keys
to undefined, then pass that sanitized object into the create and update
payloads so Prisma will remove stale values instead of preserving them.

In `@server/utils/definitions.ts`:
- Around line 339-381: The closure currently uses a dedup key that ignores
options.cacheOnly and then persists misses, so cache-only requests can
negative-cache results; fix by (1) incorporating options.cacheOnly into the
dedup key (e.g., include `${options.cacheOnly}` when building dedupKey used in
dedup('definition', dedupKey,...)) so cache-only lookups are deduped separately,
and (2) avoid writing negative cache entries for cache-only requests by skipping
the negative upsert/backup write when options.cacheOnly is true (check
options.cacheOnly before calling dbCache.upsertDefinition with isNeg or before
the disk write).

---

Nitpick comments:
In `@server/api/`[lang]/word-image/[word].get.ts:
- Around line 138-145: The cache write using mkdirSync(cacheDir, { recursive:
true }) and writeFileSync(cachePath, buf) can throw and make the request return
500; wrap the cache write in a try/catch so any filesystem errors are swallowed
(or logged) and do not prevent returning the generated image (webpBuffer);
ensure you still set the response headers (setResponseHeader calls) and return
webpBuffer even if the catch is hit, optionally logging the error with context
(cacheDir/cachePath) for debugging.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b74b19fb-b0d7-4a82-a313-b1f0b805a769

📥 Commits

Reviewing files that changed from the base of the PR and between c219f48 and b869872.

📒 Files selected for processing (12)
  • components/shared/MapFrame.vue
  • components/shared/MeaningMap.vue
  • nuxt.config.ts
  • pages/[lang]/semantic.vue
  • server/api/[lang]/semantic/guess.post.ts
  • server/api/[lang]/semantic/hint.post.ts
  • server/api/[lang]/word-image/[word].get.ts
  • server/utils/db-cache.ts
  • server/utils/definitions.ts
  • server/utils/wiktionary.ts
  • server/utils/word-stats.ts
  • tests/db-cache.test.ts
✅ Files skipped from review due to trivial changes (1)
  • components/shared/MapFrame.vue
🚧 Files skipped from review as they are similar to previous changes (5)
  • server/utils/wiktionary.ts
  • server/utils/word-stats.ts
  • server/api/[lang]/semantic/hint.post.ts
  • components/shared/MeaningMap.vue
  • server/api/[lang]/semantic/guess.post.ts

Comment thread server/utils/db-cache.ts
Comment thread tests/db-cache.test.ts
Comment on lines +66 to +79
it('incrementWordStats computes correct dist columns', () => {
// Verify the logic: won=true + attempts=3 should increment dist_3
// This tests the function's internal logic without DB
const d1 = true && 1 === 1 ? 1 : 0; // attempts=1
const d3 = true && 3 === 3 ? 1 : 0; // attempts=3
const d6 = true && 6 === 6 ? 1 : 0; // attempts=6
const d7 = true && 7 === 7 ? 1 : 0; // attempts=7 (out of range)
expect(d1).toBe(1);
expect(d3).toBe(1);
expect(d6).toBe(1);
// attempts=7 should still be 1 by the conditional, but the
// actual function clamps to 1-6 via the individual checks
expect(d7).toBe(1); // The guard is in the function, not here
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This test never exercises incrementWordStats().

It only asserts constants, so it would still pass if the real bucket logic regressed, and Biome already flags the comparisons here. Please drive the production code instead—e.g. extract the bucket mapping into a pure helper or mock prisma.$executeRaw and assert a real incrementWordStats() call.

🧰 Tools
🪛 Biome (2.4.10)

[error] 69-69: Comparing to itself is potentially pointless.

(lint/suspicious/noSelfCompare)


[error] 70-70: Comparing to itself is potentially pointless.

(lint/suspicious/noSelfCompare)


[error] 71-71: Comparing to itself is potentially pointless.

(lint/suspicious/noSelfCompare)


[error] 72-72: Comparing to itself is potentially pointless.

(lint/suspicious/noSelfCompare)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/db-cache.test.ts` around lines 66 - 79, The test currently asserts
static constants instead of exercising the real logic; update tests to call
incrementWordStats (or extract and call the pure bucket-mapping helper, e.g.,
computeDistBucket or similar) and assert outcomes rather than constants. Either
(A) import and invoke incrementWordStats with won=true/attempts=1/3/6/7 and mock
prisma.$executeRaw to verify the generated SQL/parameters update the correct
dist_1..dist_6 columns, or (B) extract the bucket mapping into a pure function
(name it clearly) in the production module, call that function from the test
with those attempt values and assert the returned bucket index/clamped value;
use jest mocks/spies on prisma.$executeRaw when testing increments so the test
actually verifies production behavior.

Comment thread tests/db-cache.test.ts
Comment on lines +81 to +86
it('returns null gracefully when DB is unavailable', async () => {
// db-cache functions should return null (not throw) when DB errors
const mod = await import('../server/utils/db-cache');
// These will fail silently if no DB — that's the expected behavior
const def = await mod.getDefinition('test', 'nonexistent');
expect(def === null || def !== undefined).toBe(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The assertion here is effectively tautological.

def === null || def !== undefined passes for every resolved value except undefined, so it does not verify the “DB unavailable => null” contract. Mock a DB failure and assert the call resolves to null.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/db-cache.test.ts` around lines 81 - 86, The current assertion is
tautological; instead, in the test for getDefinition ensure you simulate a DB
failure and assert the function returns null. Modify the test that imports
../server/utils/db-cache and before calling getDefinition('test','nonexistent')
mock/stub the underlying DB client used by that module (the same dependency that
db-cache calls) to throw or return an error, then await mod.getDefinition(...)
and assert expect(def).toBeNull(); keep the test name and flow but replace the
current lax assertion with this explicit null check.

Hugo0 added 5 commits April 12, 2026 14:27
Fire-and-forget writes silently lose cache entries on DB errors,
causing the next request to re-call the LLM. Now awaited with
console.warn on failure. Only affects the cold path (inside dedup
callbacks after LLM/API response), so zero latency impact on
cache hits.
… load

- Migrate start.post.ts, word/[slug].get.ts, word-explore/[slug].get.ts,
  vocab.get.ts, words.get.ts to use _semantic-db.ts (pgvector) instead of
  loadSemanticData() which loaded the 98MB embedding matrix into memory
- Add fetchOnDemandEmbedding() to _semantic-db.ts — calls OpenAI directly,
  stores in DB, no SemanticData dependency
- Rename semantic-db.ts → _semantic-db.ts to prevent Nitro auto-import
  collision with semantic.ts exports
- Add model column to definitions table for source provenance tracking
- Fix DEPRECATED warn placement (only log when disk is actually read)
- Fix WordStats distribution type (Record<string> not Record<number>)
- Build lightweight axes shim in guess.post.ts for compass (no heavy import)
- Seed scripts: kaikki definitions, UMAP/PCA2D coordinates
- Server startup: ~200ms (axes + valid words) instead of 3-30s (98MB parse)
prisma.$raw was silently failing, causing all UMAP positions to return
null. Replaced with a single query that fetches all 4 coordinate columns,
then picks the right pair in JS.
…semantic.ts

definitions.ts: Remove kaikki in-memory cache (_kaikkiCache, loadKaikkiFile,
lookupKaikki, resolveDefinitionsDir, DEFINITIONS_DIR), disk read/write paths,
and fs imports. Kaikki definitions are pre-seeded in Postgres (253K rows).
Flow is now: DB cache → LLM → done. No Tier 3, no disk I/O.

semantic.ts: 982 → 230 lines. Remove loadSemanticData, loadSemanticDataSafe,
getEmbedding, get2dPosition, knnNearest, getTargetDistribution, computeGuessRank,
fetchEmbeddingOnDemand, projectOntoAxis, projectAllAxes, normalizeProjection,
calibrateMagnitudeTiers, all file I/O, SemanticData type, EmbeddingsFile type,
and all related types/constants. Keep only: cosineSimilarity, rankToDisplay,
createSession, getSessionTarget, computeCompass. Fixes duplicate Nitro
auto-import warnings.

computeCompass now takes a lightweight typed parameter instead of SemanticData,
removing the `as any` cast from guess.post.ts.
Hugo0 added 20 commits April 12, 2026 15:56
Non-game words (semantic vocab neighbors, bot-crawled paths) now use
cacheOnly mode — served from DB (kaikki/cached LLM) but never trigger
new OpenAI calls. Only 5-letter words in the game's wordList get fresh
LLM definitions. This closes the leak that caused 9,290 unnecessary
LLM definitions for English alone (~$31/day OpenAI spend).
…nsive sizing

The HTML width/height attributes set intrinsic size that fights CSS.
With only viewBox, the SVG is fully responsive: CSS width:100% +
height:auto sizes from parent, viewBox handles the coordinate system.
displayScale (ResizeObserver) converts screen pixels to viewBox coords.
- --map-chrome CSS variable (310px default, 210px on short viewports)
- max-width on .map-canvas-wrap constrains the square map to available height
- min-height uses min() so it never exceeds max-height
- Header hidden on viewports < 700px
- Input border: remove margin-top:-1px and border-top:none
- Mobile: dvh instead of vh, min() on min-height
HIGH:
- Path traversal in word-image endpoint — reject ../ and slashes
- Rate limit LLM/DALL-E endpoints (definition 20/min, hint 10/min,
  DALL-E 5/hr per IP) to prevent OpenAI credit exhaustion

MEDIUM:
- Open redirect via auth-redirect cookie — validate relative path
- bcrypt DoS via unlimited password length — cap at 128 chars
- Registration displayName not validated — trim + cap at 30 chars
- Debug mode leaks target word in production — restrict to dev only
… prematurely

Labels are counter-scaled (constant visual size) but the overlap check
was using world-space dimensions. At high zoom, world-space labels
appeared huge to the algorithm and it hid most muted labels. Now scales
CHAR_W and LABEL_H by invCameraScale so overlap detection matches
actual visual size.
The word explorer had a 3-phase data waterfall:
  Phase 1 (SSR): basic word data
  Phase 2 (mount): word-explore → 80 neighbors (muted dots appear)
  Phase 3 (watch): 12× word-explore → individual foreground dot data

Phase 3 fired 12 parallel HTTP requests (36-60 DB queries) just to get
axis projections for the foreground dots. The UMAP coords and similarity
scores were already in the phase 2 response.

Fix: include axis projections for the top 15 neighbors in the explore
response. One batch embedding fetch (1 DB query) + pure math on cached
axis vectors. Foreground dots now render instantly from phase 2 data.

- Add getEmbeddings() batch fetch to _semantic-db.ts
- word-explore endpoint includes projections for top 15 neighbors
- NearbyInMeaning uses neighbor projections directly (no contextData wait)
- loadContextData only fetches for user-added custom words

Result: 59KB single request replaces 15KB + 12×15KB = 195KB waterfall.
Map renders in 1 phase instead of 3.
Includes other agents' SEO content, component updates, and
language config changes alongside DB migration branch work.
…uring transitions

Also early-return zoom=1 when <2 foreground dots, preventing huge
zoom from single-dot min-distance calculation.
Counter-scaling (scale(1/zoom) on each dot) created timing bugs where
the camera transform updated before the dot transforms, causing one
frame of giant text. Without counter-scale, dots scale naturally with
the camera. Labels and circles get bigger when zoomed in, smaller when
zoomed out — standard map behavior (like Google Maps labels).
Counter-scale via CSS var --inv-zoom on camera <g>. WAAPI animations
include invCameraScale in their keyframes so the counter-scale is
never lost during bounce-in or FLIP transitions. Auto-zoom restored
for absolute mode (capped at 8x). Label overlap accounts for scale.
…et word)

When a session expires mid-game, gameOver=true gets saved to localStorage
but finalTargetWord stays null. On reload, restoreState loads this corrupt
state and the stats modal shows 'Out of guesses' with '?' even though the
user can still play. Now skips restore if gameOver=true but no target word.
Semantic Explorer:
- Title: "Semantic Word Game — Find the Hidden Word by Meaning"
- Description targeting "contexto", "semantle", "semantic word game"
- 6 FAQ items (what is it, vs Contexto/Semantle, ranks, compass, daily, guesses)
- 6 HowTo steps
- Tips section
- Mode description for on-page SEO content

Speed Streak:
- Title + description
- 2 FAQ items, 5 HowTo steps, tips

Multiboard (dordle through duotrigordle):
- Descriptions for all 5 sizes
- 2 shared FAQ items

Missing: og-semantic.png (needs design — currently falls back to generic)
From other agents:
- Leaderboard: refactored API (streaks, records, aggregate rankings),
  removed standalone page (embedded in sidebar)
- SEO: semantic mode meta templates in default_language_config.json,
  best-starting-words semantic section, llms.txt update
- Semantic: max guesses 15→20, compass/input/leaderboard polish
- Speed: results display improvements
- Profile: simplified install CTA, streak calendar fixes
- Game modes: label updates, type additions
@Hugo0 Hugo0 changed the title DB-backed caches, pgvector semantic ops, and game mode word dedup feat: DB migration — pgvector, Postgres caches, leaderboard, security Apr 12, 2026
@Hugo0

Hugo0 commented Apr 12, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 12, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Hugo0 added 5 commits April 12, 2026 17:52
Gameplay:
- Semantic maxGuesses increased from 15 to 20
- Single source of truth: GAME_MODE_CONFIG.semantic.maxGuesses
  Server and client both import from utils/game-modes.ts

SEO:
- Meta title: "Semantic Word Game — Like Contexto | Wordle English"
- Meta description mentions meaning, compass, 275K words, Contexto/Semantle
- 6 FAQ items (including "vs Contexto/Semantle"), 6 HowTo steps, tips
- Speed mode: title, description, FAQ, HowTo, tips
- Multiboard modes: descriptions for all 5 sizes, FAQ
- llms.txt: expanded Semantic Explorer description with competitor comparison

Fixes:
- All "15 guesses" → "20 guesses" in config, descriptions, comments
- Compass hint FAQ: "after every guess" (was "after 5 guesses")
- Valid words: "275,000+" (was "50,000")
@Hugo0 Hugo0 merged commit 33663fc into main Apr 12, 2026
3 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant