mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-12 08:18:53 +00:00
refactor(semantle): swap word2sim backend for ConceptNet
ConceptNet provides a free public /relatedness endpoint (returns cosine-like
[-1, 1]) and /c/en/{term} for vocabulary check. No random-word endpoint, so
we ship a curated local target pool in wordlist.js (~250 words) and verify
each pick via the concept endpoint with a fallback to an unverified pick.
Each guess now makes two parallel ConceptNet calls (concept + relatedness)
instead of a single word2sim call. Slightly higher latency but zero hosting
cost and no dependency on the self-hosted word2sim instance.
- api-client.js rewritten; UpstreamError replaces Word2SimError (aliased
for backwards compat with older imports).
- wordlist.js added (curated target pool + pickFromPool).
- handlers.js: drops RANDOM_FILTERS (no filtering needed; pool is curated).
- index.js: drops WORD2SIM_API_URL env var; ConceptNet base hardcoded.
- wrangler.toml + .dev.vars.example: drop WORD2SIM_API_URL.
- api-client tests rewritten for ConceptNet shape; total tests 336 → 341.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 `<pre>` 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.
|
||||
|
||||
@@ -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<string, string|number|boolean>} [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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof createClient> | 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: [
|
||||
{
|
||||
|
||||
@@ -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/<word>` 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)];
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user