diff --git a/.dev.vars.example b/.dev.vars.example index 3d04513..78cc06d 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -2,7 +2,3 @@ # Copy to .dev.vars (gitignored) and fill in real values. TELEGRAM_BOT_TOKEN= TELEGRAM_WEBHOOK_SECRET= - -# Optional: override the word2sim base URL for local/self-hosted instances -# (semantle module). Default is https://word2sim.sg.miti99.com. -# WORD2SIM_API_URL=http://localhost:8000 diff --git a/src/modules/semantle/README.md b/src/modules/semantle/README.md index df875d8..c6a758e 100644 --- a/src/modules/semantle/README.md +++ b/src/modules/semantle/README.md @@ -1,9 +1,9 @@ # Semantle Module -Word2vec similarity guessing game. A secret word is picked from our hosted -[word2sim](https://github.com/tiennm99/word2sim) instance; each guess is -scored by cosine similarity against the target. Unlimited guesses per round -— you play until you get the exact word (case-insensitive). +Semantic-similarity guessing game. A secret word is picked from a local +curated pool and validated against ConceptNet; each guess is scored by +ConceptNet's relatedness API against the target. Unlimited guesses per +round — you play until you get the exact word (case-insensitive). ## Commands @@ -20,28 +20,38 @@ ignored (no cost, no stat inflation). ## Data source -Target words and similarity scores come from **[word2sim](https://word2sim.sg.miti99.com)**, -our hosted FastAPI service over the GoogleNews pretrained word2vec model -(3M tokens × 300 dims). Two endpoints are used: +**[ConceptNet 5](https://api.conceptnet.io/)** — free public API, no auth, +~300k English concepts including multi-word phrases. Two endpoints: -- `GET /random` — round-start target pick, filtered to game-friendly words. -- `GET /similarity?a&b` — per-guess cosine similarity. +- `GET /relatedness?node1=/c/en/X&node2=/c/en/Y` — per-guess similarity, + returns `{ value: number ∈ [-1, 1] }`. +- `GET /c/en/{term}` — vocabulary check: term is in vocab iff the response + carries at least one edge. -No local model — every guess is a network round-trip. Typical latency -~200–400ms; `api-client.js` enforces a 5s timeout and surfaces a -"Upstream hiccup" message on failure. +Because ConceptNet has no random-word endpoint, the target pool ships in +`wordlist.js` (~250 curated English words, 4–10 letters, all alphabetic). +Each new round picks locally, verifies via the concept endpoint, and falls +back to an unverified pick after a few misses. + +Every guess costs **two** ConceptNet calls (concept edges + relatedness) +issued in parallel. Typical latency ~300–600ms round-trip from Cloudflare +Workers; `api-client.js` enforces a 5s timeout and surfaces a "Upstream +hiccup" message on failure. ## Architecture -- `api-client.js` — word2sim HTTP wrapper (`randomWord`, `similarity`) plus - `Word2SimError` with `{status, body, cause}` metadata. +- `api-client.js` — ConceptNet HTTP wrapper (`randomWord`, `similarity`, + plus lower-level `concept` / `relatedness`) with `UpstreamError` metadata. + Preserves the earlier word2sim response shape so the rest of the module + didn't need rewriting. +- `wordlist.js` — curated local target pool and `pickFromPool()`. - `state.js` — KV persistence for game + stats. Target stored lowercased. - `lookup.js` — guess normalization and shape validation. - `format.js` — warmth-percent and emoji-bucket formatters. - `render.js` — Telegram HTML `
` monospace board, sorted by similarity
desc, capped at top 15 rows to stay under Telegram's message-length limit.
- `handlers.js` — subject resolution (user in DMs, chat in groups) + the
- four command entry points.
+ three command entry points.
Subject resolution: private chats track per-user games; groups track
per-chat shared games. Mirrors `loldle`/`wordle`.
@@ -60,12 +70,9 @@ form is lowercased on write so the solve check is a single string compare.
## Config
-| Env var | Default | Meaning |
-|---------|---------|---------|
-| `WORD2SIM_API_URL` | `https://word2sim.sg.miti99.com` | word2sim base URL; override for local word2sim or self-hosted |
-
-Set in `wrangler.toml` `[vars]`. For local `wrangler dev`, optionally add
-to `.dev.vars` (gitignored).
+No env vars. ConceptNet's public API base (`https://api.conceptnet.io`) is
+hardcoded in `api-client.js`; pass an override to `createClient(url)` if you
+need to point at a mirror or test double.
## Why unlimited guesses?
@@ -76,6 +83,5 @@ across all rounds.
## Credits
-- Embedding model: Google's pretrained word2vec (3M tokens, 300 dims, trained on Google News).
-- Hosting layer: [tiennm99/word2sim](https://github.com/tiennm99/word2sim).
+- Similarity + vocabulary: [ConceptNet 5](https://conceptnet.io) by Robyn Speer et al.
- Game concept: [Semantle](https://semantle.com/) by David Turner.
diff --git a/src/modules/semantle/api-client.js b/src/modules/semantle/api-client.js
index a19c8f5..32c6daf 100644
--- a/src/modules/semantle/api-client.js
+++ b/src/modules/semantle/api-client.js
@@ -1,29 +1,38 @@
/**
- * @file word2sim HTTP API client.
+ * @file ConceptNet API client for the semantle module.
*
- * Wraps two endpoints:
- * GET /random → pick a secret word at round start
- * GET /similarity → cosine similarity between target and guess per turn
+ * ConceptNet endpoints:
+ * GET /relatedness?node1=/c/en/X&node2=/c/en/Y → { value: number ∈ [-1, 1] }
+ * GET /c/en/{term} → { edges: [...] } (empty ⇒ OOV)
*
- * Stateless. No caching layer — word2sim itself is cheap enough, and caching
- * per-pair scores in KV would pollute the namespace without measurable gain.
+ * There is no official random-word endpoint, so the client picks a candidate
+ * from our local `TARGET_POOL` and verifies it has a ConceptNet entry with
+ * edges. After a few failed attempts it falls back to an unverified pick —
+ * the curated pool is trusted enough that this should be rare.
+ *
+ * The returned `similarity(a, b)` shape mirrors the earlier word2sim contract
+ * so handlers/render/state don't have to change.
*/
+import { pickFromPool } from "./wordlist.js";
+
+const DEFAULT_API_BASE = "https://api.conceptnet.io";
const DEFAULT_TIMEOUT_MS = 5000;
const USER_AGENT = "miti99bot/semantle";
+const MAX_RANDOM_ATTEMPTS = 5;
-export class Word2SimError extends Error {
+export class UpstreamError extends Error {
/** @param {string} message @param {{status?: number, body?: string, cause?: unknown}} [meta] */
constructor(message, meta = {}) {
super(message);
- this.name = "Word2SimError";
+ this.name = "UpstreamError";
this.status = meta.status;
this.body = meta.body;
if (meta.cause !== undefined) this.cause = meta.cause;
}
}
-function buildUrl(base, path, params) {
+function buildUrl(base, path, params = {}) {
const normalized = String(base).replace(/\/+$/, "");
const url = new URL(`${normalized}${path}`);
for (const [k, v] of Object.entries(params)) {
@@ -44,12 +53,12 @@ async function fetchJson(url, timeoutMs) {
});
} catch (err) {
clearTimeout(timer);
- throw new Word2SimError("word2sim fetch failed", { cause: err });
+ throw new UpstreamError("conceptnet fetch failed", { cause: err });
}
clearTimeout(timer);
const text = await res.text();
if (!res.ok) {
- throw new Word2SimError(`word2sim HTTP ${res.status}`, {
+ throw new UpstreamError(`conceptnet HTTP ${res.status}`, {
status: res.status,
body: text.slice(0, 500),
});
@@ -57,37 +66,89 @@ async function fetchJson(url, timeoutMs) {
try {
return JSON.parse(text);
} catch (err) {
- throw new Word2SimError("word2sim non-JSON response", { cause: err });
+ throw new UpstreamError("conceptnet non-JSON response", { cause: err });
}
}
+function hasEdges(concept) {
+ return Array.isArray(concept?.edges) && concept.edges.length > 0;
+}
+
/**
- * @param {string} apiBase — e.g. "https://word2sim.sg.miti99.com"
+ * @param {string} [apiBase] — override for mirrors/tests (default api.conceptnet.io)
* @param {{ timeoutMs?: number }} [opts]
*/
-export function createClient(apiBase, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
+export function createClient(apiBase = DEFAULT_API_BASE, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
+ /** @param {string} term */
+ function concept(term) {
+ return fetchJson(buildUrl(apiBase, `/c/en/${encodeURIComponent(term)}`), timeoutMs);
+ }
+
+ /** @param {string} a @param {string} b */
+ function relatedness(a, b) {
+ return fetchJson(
+ buildUrl(apiBase, "/relatedness", {
+ node1: `/c/en/${a}`,
+ node2: `/c/en/${b}`,
+ }),
+ timeoutMs,
+ );
+ }
+
return {
+ concept,
+ relatedness,
+
/**
- * Pick a random vocab word matching filters.
- * @param {Record} [filters]
- * @returns {Promise<{ word: string, rank: number }>}
+ * Pick a target word from the local pool. Verifies each candidate has a
+ * ConceptNet entry; falls back to an unverified pick after a few tries.
+ * Shape matches the old word2sim `/random` response for handler reuse.
+ * @returns {Promise<{ word: string, verified: boolean }>}
*/
- randomWord(filters = {}) {
- return fetchJson(buildUrl(apiBase, "/random", filters), timeoutMs);
+ async randomWord() {
+ for (let i = 0; i < MAX_RANDOM_ATTEMPTS; i++) {
+ const candidate = pickFromPool();
+ try {
+ const c = await concept(candidate);
+ if (hasEdges(c)) return { word: candidate, verified: true };
+ } catch {
+ // swallow — try the next candidate
+ }
+ }
+ return { word: pickFromPool(), verified: false };
},
+
/**
- * Cosine similarity between two words.
+ * Cosine-like similarity between `a` (target) and `b` (guess). Runs the
+ * edge-check for `b` in parallel with the relatedness call so OOV guesses
+ * are identified on the same round-trip.
+ *
+ * Shape deliberately mirrors the old word2sim response.
* @param {string} a
* @param {string} b
* @returns {Promise<{
* a: string, b: string,
- * canonical_a: string|null, canonical_b: string|null,
+ * canonical_a: string, canonical_b: string,
* in_vocab_a: boolean, in_vocab_b: boolean,
- * similarity: number|null
+ * similarity: number | null,
* }>}
*/
- similarity(a, b) {
- return fetchJson(buildUrl(apiBase, "/similarity", { a, b }), timeoutMs);
+ async similarity(a, b) {
+ const [conceptB, rel] = await Promise.all([concept(b), relatedness(a, b)]);
+ const inVocabB = hasEdges(conceptB);
+ const value = typeof rel?.value === "number" ? rel.value : null;
+ return {
+ a,
+ b,
+ canonical_a: a,
+ canonical_b: b,
+ in_vocab_a: true, // target was verified at round start
+ in_vocab_b: inVocabB,
+ similarity: inVocabB ? value : null,
+ };
},
};
}
+
+// Backwards-compat alias — older imports referenced `Word2SimError`.
+export { UpstreamError as Word2SimError };
diff --git a/src/modules/semantle/handlers.js b/src/modules/semantle/handlers.js
index 26f7a53..ad5cf14 100644
--- a/src/modules/semantle/handlers.js
+++ b/src/modules/semantle/handlers.js
@@ -13,19 +13,12 @@
*/
import { escapeHtml } from "../../util/escape-html.js";
-import { Word2SimError } from "./api-client.js";
+import { UpstreamError } from "./api-client.js";
import { isValidShape, normalize } from "./lookup.js";
import { renderBoard, renderGuess } from "./render.js";
import { clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js";
const UPSTREAM_FAIL = "⚠️ Upstream hiccup — try again in a few seconds.";
-const RANDOM_FILTERS = {
- min_rank: 500,
- max_rank: 20000,
- alpha_only: true,
- min_len: 4,
- max_len: 10,
-};
function getSubject(ctx) {
const type = ctx.chat?.type;
@@ -44,15 +37,15 @@ function logFail(stage, err) {
JSON.stringify({
msg: "semantle_upstream_fail",
stage,
- err: err instanceof Word2SimError ? { status: err.status, body: err.body } : String(err),
+ err: err instanceof UpstreamError ? { status: err.status, body: err.body } : String(err),
}),
);
}
async function startFreshGame(db, client, subject) {
- const picked = await client.randomWord(RANDOM_FILTERS);
+ const picked = await client.randomWord();
const target = String(picked?.word ?? "").toLowerCase();
- if (!target) throw new Word2SimError("empty target from /random");
+ if (!target) throw new UpstreamError("empty target from randomWord");
const fresh = { target, startedAt: null, solved: false, guesses: [] };
await saveGame(db, subject, fresh);
return fresh;
diff --git a/src/modules/semantle/index.js b/src/modules/semantle/index.js
index 0a9dbbc..731c71d 100644
--- a/src/modules/semantle/index.js
+++ b/src/modules/semantle/index.js
@@ -1,16 +1,15 @@
/**
- * @file Semantle module — word2vec similarity guessing game.
+ * @file Semantle module — similarity guessing game backed by ConceptNet.
*
- * Target words come from our own hosted word2sim instance
- * (default: https://word2sim.sg.miti99.com). Override via env var
- * `WORD2SIM_API_URL` for local dev or self-hosting.
+ * Targets come from a curated local wordlist (ConceptNet has no /random).
+ * Similarity scores come from `api.conceptnet.io/relatedness`. The ConceptNet
+ * base URL is hardcoded in the client; tests can still override via
+ * `createClient(url)` if needed.
*/
import { createClient } from "./api-client.js";
import { handleGiveup, handleSemantle, handleStats } from "./handlers.js";
-const DEFAULT_API_URL = "https://word2sim.sg.miti99.com";
-
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
let db = null;
/** @type {ReturnType | null} */
@@ -19,10 +18,9 @@ let client = null;
/** @type {import("../registry.js").BotModule} */
const semantleModule = {
name: "semantle",
- init: async ({ db: store, env }) => {
+ init: async ({ db: store }) => {
db = store;
- const base = env?.WORD2SIM_API_URL || DEFAULT_API_URL;
- client = createClient(base);
+ client = createClient();
},
commands: [
{
diff --git a/src/modules/semantle/wordlist.js b/src/modules/semantle/wordlist.js
new file mode 100644
index 0000000..8680478
--- /dev/null
+++ b/src/modules/semantle/wordlist.js
@@ -0,0 +1,75 @@
+/**
+ * @file Curated target-word pool for the semantle module.
+ *
+ * ConceptNet has no random-word endpoint, so we ship a hand-picked list of
+ * common, game-friendly English nouns/verbs/adjectives (4–10 ASCII letters).
+ * The list is small on purpose — every entry is a reasonable Semantle target,
+ * which matters more than raw size. Expand freely as the game matures.
+ *
+ * Entries are lowercase and alphabetic only (the similarity endpoint accepts
+ * these directly as `/c/en/` concept IDs).
+ */
+
+// biome-ignore format: keep the list compact and grep-friendly
+export const TARGET_POOL = [
+ // nature / geography
+ "ocean", "mountain", "forest", "desert", "river", "valley", "garden", "island",
+ "beach", "cave", "meadow", "glacier", "volcano", "canyon", "jungle", "lake",
+ "hill", "plateau", "cliff", "harbor", "coast", "swamp", "prairie", "tundra",
+ "delta", "creek", "stream", "pebble", "boulder", "horizon", "iceberg", "dune",
+ // weather / time
+ "winter", "summer", "autumn", "spring", "morning", "evening", "midnight",
+ "sunrise", "sunset", "thunder", "rainbow", "blizzard", "breeze", "drought",
+ "storm", "shadow", "twilight", "decade",
+ // people / relations
+ "friend", "family", "mother", "father", "brother", "sister", "child",
+ "stranger", "neighbor", "partner", "sibling", "elder", "infant",
+ // arts
+ "music", "dance", "poem", "story", "painting", "theater", "cinema", "novel",
+ "symphony", "sculpture", "sketch", "ballet", "opera", "concert",
+ // objects
+ "computer", "phone", "camera", "robot", "engine", "wheel", "pencil", "hammer",
+ "mirror", "bicycle", "umbrella", "lantern", "compass", "anchor", "blanket",
+ "candle", "cushion", "kettle", "ladder", "needle", "paper", "pillow",
+ "scissors", "telescope", "throne", "vase", "window", "zipper", "bottle",
+ "basket", "bridge", "tower",
+ // animals
+ "eagle", "tiger", "dolphin", "rabbit", "snake", "salmon", "wolf", "horse",
+ "butterfly", "elephant", "panda", "falcon", "sparrow", "penguin", "octopus",
+ "beetle", "crow", "dragon", "hawk", "jaguar", "kangaroo", "lion", "monkey",
+ "otter", "parrot", "raccoon", "squirrel", "turtle", "whale", "bear", "fox",
+ "shark",
+ // food
+ "apple", "bread", "cheese", "coffee", "sugar", "pepper", "potato", "orange",
+ "honey", "chocolate", "cinnamon", "almond", "berry", "butter", "grape",
+ "lemon", "olive", "tomato", "walnut", "yogurt", "ginger",
+ // emotions / abstract
+ "love", "anger", "fear", "courage", "sorrow", "wonder", "dream", "memory",
+ "silence", "laughter", "hope", "delight", "regret", "trust", "justice",
+ "freedom", "honor", "peace", "victory", "promise", "secret", "truth",
+ "wisdom", "mystery", "destiny", "patience", "loyalty",
+ // professions
+ "teacher", "doctor", "artist", "farmer", "pilot", "soldier", "writer",
+ "sailor", "hunter", "engineer", "chef", "dentist", "nurse", "judge",
+ "scientist",
+ // body
+ "shoulder", "finger", "elbow", "throat", "pulse", "tongue", "ankle", "spine",
+ "muscle", "nerve", "eyelid", "beard", "tooth", "thumb", "knee",
+ // verbs / actions
+ "gather", "wonder", "linger", "stumble", "whisper", "shimmer", "wander",
+ "rescue", "gallop", "imagine", "pursue", "retreat", "thrive", "squeeze",
+ "shatter", "tremble", "travel", "borrow", "descend", "inherit", "vanish",
+ "forget", "invite", "resist", "settle",
+ // adjectives
+ "ancient", "gentle", "fragile", "massive", "glowing", "rugged", "silent",
+ "frozen", "steady", "brittle", "clever", "gloomy", "cheerful", "noisy",
+ "silver", "golden", "radiant", "distant", "graceful", "humble", "vivid",
+ "quiet", "sacred", "sudden", "tender", "wealthy", "generous", "majestic",
+];
+
+/**
+ * @returns {string} — a random lowercase word from the pool.
+ */
+export function pickFromPool() {
+ return TARGET_POOL[Math.floor(Math.random() * TARGET_POOL.length)];
+}
diff --git a/tests/modules/semantle/api-client.test.js b/tests/modules/semantle/api-client.test.js
index 72c7272..69a3ed0 100644
--- a/tests/modules/semantle/api-client.test.js
+++ b/tests/modules/semantle/api-client.test.js
@@ -1,76 +1,142 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import { Word2SimError, createClient } from "../../../src/modules/semantle/api-client.js";
+import {
+ UpstreamError,
+ Word2SimError,
+ createClient,
+} from "../../../src/modules/semantle/api-client.js";
+
+/**
+ * ConceptNet stubs — minimal shape the client cares about.
+ */
+function conceptResp(edgeCount = 5) {
+ return {
+ ok: true,
+ text: () =>
+ Promise.resolve(
+ JSON.stringify({
+ edges: Array.from({ length: edgeCount }, (_, i) => ({ id: `e${i}` })),
+ }),
+ ),
+ };
+}
+
+function relatednessResp(value) {
+ return {
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify({ value })),
+ };
+}
describe("semantle/api-client", () => {
afterEach(() => {
vi.restoreAllMocks();
});
- describe("Word2SimError", () => {
+ describe("UpstreamError", () => {
it("stores status and body metadata", () => {
- const err = new Word2SimError("test", { status: 404, body: "not found" });
+ const err = new UpstreamError("test", { status: 404, body: "not found" });
expect(err.message).toBe("test");
expect(err.status).toBe(404);
expect(err.body).toBe("not found");
- expect(err.name).toBe("Word2SimError");
+ expect(err.name).toBe("UpstreamError");
});
it("stores cause when provided", () => {
const cause = new Error("underlying");
- const err = new Word2SimError("wrapper", { cause });
+ const err = new UpstreamError("wrapper", { cause });
expect(err.cause).toBe(cause);
});
+
+ it("is re-exported as Word2SimError alias for legacy callers", () => {
+ expect(Word2SimError).toBe(UpstreamError);
+ });
});
describe("createClient", () => {
- it("randomWord builds correct URL with filters", async () => {
+ it("similarity runs concept + relatedness in parallel", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
+ const calls = [];
global.fetch = vi.fn((url) => {
- expect(url).toContain("/random");
- expect(url).toContain("min_rank=5");
- expect(url).toContain("alpha=true");
- return Promise.resolve({
- ok: true,
- text: () => Promise.resolve('{"word":"apple","rank":1234}'),
- });
- });
- const res = await client.randomWord({ min_rank: 5, alpha: true });
- expect(res.word).toBe("apple");
- expect(res.rank).toBe(1234);
- });
-
- it("similarity builds URL with both words", async () => {
- const client = createClient("https://api.test", { timeoutMs: 100 });
- global.fetch = vi.fn((url) => {
- expect(url).toContain("/similarity");
- expect(url).toContain("a=apple");
- expect(url).toContain("b=orange");
- return Promise.resolve({
- ok: true,
- text: () =>
- Promise.resolve(
- '{"a":"apple","b":"orange","in_vocab_a":true,"in_vocab_b":true,"similarity":0.45}',
- ),
- });
+ calls.push(String(url));
+ if (url.includes("/relatedness")) return Promise.resolve(relatednessResp(0.45));
+ return Promise.resolve(conceptResp(3));
});
const res = await client.similarity("apple", "orange");
expect(res.similarity).toBe(0.45);
+ expect(res.in_vocab_b).toBe(true);
+ expect(res.canonical_b).toBe("orange");
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ expect(calls.some((u) => u.includes("/c/en/orange"))).toBe(true);
+ expect(calls.some((u) => u.includes("node1=%2Fc%2Fen%2Fapple"))).toBe(true);
+ expect(calls.some((u) => u.includes("node2=%2Fc%2Fen%2Forange"))).toBe(true);
});
- it("URL-encodes special characters in params", async () => {
+ it("similarity flags OOV when the concept endpoint returns no edges", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn((url) => {
- expect(url).toMatch(/search=hello/);
- return Promise.resolve({
- ok: true,
- text: () => Promise.resolve('{"word":"test"}'),
- });
+ if (url.includes("/relatedness")) return Promise.resolve(relatednessResp(0.02));
+ return Promise.resolve(conceptResp(0));
});
- await client.randomWord({ search: "hello world" });
- expect(global.fetch).toHaveBeenCalled();
+ const res = await client.similarity("apple", "zzzfoo");
+ expect(res.in_vocab_b).toBe(false);
+ expect(res.similarity).toBe(null);
});
- it("throws Word2SimError on non-2xx response", async () => {
+ it("similarity returns null when relatedness payload lacks a numeric value", async () => {
+ const client = createClient("https://api.test", { timeoutMs: 100 });
+ global.fetch = vi.fn((url) => {
+ if (url.includes("/relatedness")) {
+ return Promise.resolve({ ok: true, text: () => Promise.resolve("{}") });
+ }
+ return Promise.resolve(conceptResp(5));
+ });
+ const res = await client.similarity("apple", "orange");
+ expect(res.similarity).toBe(null);
+ });
+
+ it("similarity distinguishes 0 from null", async () => {
+ const client = createClient("https://api.test", { timeoutMs: 100 });
+ global.fetch = vi.fn((url) => {
+ if (url.includes("/relatedness")) return Promise.resolve(relatednessResp(0));
+ return Promise.resolve(conceptResp(5));
+ });
+ const res = await client.similarity("apple", "orange");
+ expect(res.similarity).toBe(0);
+ expect(res.in_vocab_b).toBe(true);
+ });
+
+ it("randomWord returns a verified pick when edges present", async () => {
+ const client = createClient("https://api.test", { timeoutMs: 100 });
+ global.fetch = vi.fn(() => Promise.resolve(conceptResp(5)));
+ const res = await client.randomWord();
+ expect(typeof res.word).toBe("string");
+ expect(res.word.length).toBeGreaterThan(0);
+ expect(res.verified).toBe(true);
+ });
+
+ it("randomWord falls back to unverified pick after max attempts", async () => {
+ const client = createClient("https://api.test", { timeoutMs: 100 });
+ // Every concept lookup returns zero edges → exhausts retries.
+ global.fetch = vi.fn(() => Promise.resolve(conceptResp(0)));
+ const res = await client.randomWord();
+ expect(res.verified).toBe(false);
+ expect(typeof res.word).toBe("string");
+ });
+
+ it("randomWord swallows transient fetch errors during verification", async () => {
+ const client = createClient("https://api.test", { timeoutMs: 100 });
+ let n = 0;
+ global.fetch = vi.fn(() => {
+ n += 1;
+ // Error for the first few attempts, then succeed.
+ if (n <= 2) return Promise.reject(new Error("transient"));
+ return Promise.resolve(conceptResp(3));
+ });
+ const res = await client.randomWord();
+ expect(res.verified).toBe(true);
+ });
+
+ it("concept throws UpstreamError on non-2xx response", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn(() =>
Promise.resolve({
@@ -79,95 +145,75 @@ describe("semantle/api-client", () => {
text: () => Promise.resolve("Internal Server Error"),
}),
);
- await expect(client.randomWord()).rejects.toMatchObject({
- name: "Word2SimError",
+ await expect(client.concept("apple")).rejects.toMatchObject({
+ name: "UpstreamError",
status: 500,
body: "Internal Server Error",
});
});
- it("throws Word2SimError when response is not valid JSON", async () => {
+ it("concept throws UpstreamError when response is not valid JSON", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn(() =>
- Promise.resolve({
- ok: true,
- text: () => Promise.resolve("not json at all"),
- }),
+ Promise.resolve({ ok: true, text: () => Promise.resolve("not json") }),
);
- await expect(client.randomWord()).rejects.toMatchObject({
- name: "Word2SimError",
- });
+ await expect(client.concept("apple")).rejects.toMatchObject({ name: "UpstreamError" });
});
- it("throws Word2SimError on fetch failure", async () => {
+ it("concept throws UpstreamError on fetch failure", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn(() => Promise.reject(new Error("network error")));
- await expect(client.randomWord()).rejects.toThrow("word2sim fetch failed");
+ await expect(client.concept("apple")).rejects.toThrow("conceptnet fetch failed");
});
- it("uses custom timeout and truncates response body to 500 chars", async () => {
+ it("truncates response body to 500 chars in UpstreamError", async () => {
const client = createClient("https://api.test", { timeoutMs: 50 });
const longBody = "x".repeat(600);
global.fetch = vi.fn(() =>
- Promise.resolve({
- ok: false,
- status: 400,
- text: () => Promise.resolve(longBody),
- }),
+ Promise.resolve({ ok: false, status: 400, text: () => Promise.resolve(longBody) }),
);
try {
- await client.randomWord();
+ await client.concept("apple");
} catch (err) {
expect(err.body.length).toBe(500);
}
});
- it("includes User-Agent header", async () => {
+ it("sends User-Agent and Accept headers", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn((_, opts) => {
expect(opts.headers["User-Agent"]).toContain("miti99bot");
- return Promise.resolve({
- ok: true,
- text: () => Promise.resolve('{"word":"test"}'),
- });
- });
- await client.randomWord();
- });
-
- it("includes Accept header", async () => {
- const client = createClient("https://api.test", { timeoutMs: 100 });
- global.fetch = vi.fn((_, opts) => {
expect(opts.headers.Accept).toBe("application/json");
- return Promise.resolve({
- ok: true,
- text: () => Promise.resolve('{"word":"test"}'),
- });
+ return Promise.resolve(conceptResp(1));
});
- await client.randomWord();
+ await client.concept("apple");
});
- it("handles trailing slashes in API base URL", async () => {
+ it("strips trailing slashes from the API base URL", async () => {
const client = createClient("https://api.test///", { timeoutMs: 100 });
global.fetch = vi.fn((url) => {
- expect(url.startsWith("https://api.test/")).toBe(true);
- return Promise.resolve({
- ok: true,
- text: () => Promise.resolve('{"word":"test"}'),
- });
+ expect(url.startsWith("https://api.test/c/en/")).toBe(true);
+ return Promise.resolve(conceptResp(1));
});
- await client.randomWord();
+ await client.concept("apple");
});
- it("filters out undefined/null params", async () => {
+ it("URL-encodes the term path segment", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn((url) => {
- expect(url).not.toContain("min_rank=");
- return Promise.resolve({
- ok: true,
- text: () => Promise.resolve('{"word":"test"}'),
- });
+ expect(url).toContain("/c/en/hello%20world");
+ return Promise.resolve(conceptResp(1));
});
- await client.randomWord({ min_rank: undefined, max_rank: null });
+ await client.concept("hello world");
+ });
+
+ it("defaults to the public ConceptNet base URL when none provided", async () => {
+ const client = createClient();
+ global.fetch = vi.fn((url) => {
+ expect(url.startsWith("https://api.conceptnet.io/")).toBe(true);
+ return Promise.resolve(conceptResp(1));
+ });
+ await client.concept("apple");
});
});
});
diff --git a/wrangler.toml b/wrangler.toml
index 590f77a..7893261 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -6,8 +6,6 @@ compatibility_date = "2025-10-01"
# Also duplicate this value into .env.deploy so scripts/register.js derives the same public command list.
[vars]
MODULES = "util,wordle,loldle,misc,trading,lolschedule,semantle"
-# Base URL for the hosted word2sim similarity API (semantle module).
-WORD2SIM_API_URL = "https://word2sim.sg.miti99.com"
# KV namespace holding all module state. Each module auto-prefixes its keys via createStore().
# Production-only — no preview namespace. Create with: