Follow-up to d0453881: the pgUserReputationsTable definition and the
UserReputation/UserReputationInsert type aliases were the only remaining
references to the removed user reputation feature. Nothing imports them
(verified via grep across src/ + tests/), so they're pure dead code that
could mislead future devs into re-joining a dropped table. Railed out to
close the 42P01 failure class completely.
Root cause: gateway migration 0016 removed the per-user reputation feature
(DROP TABLE user_reputations), but the backend still LEFT JOINed
pgUserReputationsTable in dashboard listUsers/getUserDetail and the chatbot
get_user_reputation tool. Against the live DB these queries raised 42P01
(undefined_table) -> the new /users page could never load (oRPC WS error).
Fix:
- dashboard.repository.ts: remove reputation columns/join from listUsers and
getUserDetail (data sourced only from messages + user_profiles).
- chatbot.tools.ts: get_user_reputation now returns honest 'unavailable'
(feature removed) instead of querying the dropped table.
- chatbot.toolDefs.ts: update tool description so the LLM doesn't advertise
a dead feature.
- frontend types/users view: drop trust_score/clean_message_streak/
total_infractions/last_infraction_at; derive risk label from real flagged%
and add warn_count to the inspector (real available data).
Verified: backend+frontend tsc clean, biome clean, 37 unit tests pass,
query tested against live DB (returns real members), build green.
- New /users route: member roster (trust score, clean/flagged counts, message volume)
+ inspector detail (trust breakdown, clean streak, infractions, AI profile,
recent messages) with live search
- Backend dashboard.listUsers/getUserDetail now JOIN user_reputations to expose
trust_score, clean_message_streak, total_infractions, last_infraction_at +
warn_count/clean_count breakdowns
- Frontend types updated to match; useUsers refactored to PaginatedUsers shape,
added useUserSearch for the old search behavior
- Nav rail + mobile nav: add Users entry
Gateway archive embedder now parses metadata.channel.{channelName,threadName}
from each message and stores channel_name/thread_name in the Qdrant payload.
Backend exposes them; the semantic results card renders the thread name (or
channel name) instead of a raw #snowflake, with the ID as a last-resort
fallback for legacy points. Matches the message feed's channel-label logic
(getMessageChannelLabel).
Archive payload now stores username, channel_id, guild_id, thread_id and the
real message created_at (not embed time). Backend searchArray accepts an
optional guildId and applies a Qdrant payload filter so results can be scoped
to the guild being viewed. API/frontend expose the new fields and the
semantic results card shows who said it, in which channel, and when —
turning bare text blobs into contextual results. Old points fall back to
analyzed_at and omit the new fields gracefully.
- Normalize text before embedding (strip mentions/URLs/emoji/markdown/control chars, lowercase, truncate) on both write and query sides so vectors aren't diluted and tokens aren't wasted
- embeddingClient: retry embeddings (maxRetries 2), validate batch dimension consistency, preserve index alignment for empty-normalized texts
- archiveEmbedder: store normalized text in archive payload, skip empty-normalized content
- backend: normalize search queries, make archive search similarity threshold configurable (AI_LLM_EMBEDDING_ARCHIVE_MIN_SIMILARITY, default 0.6)
Switch GMW's AI LLM base URL from 9router (https://9router.asepharyana.my.id/v1)
to omniroute on imrnes (http://100.121.180.82:20128/api/v1).
- Update default AI_LLM_BASE_URL in discord-gateway + backend config schemas
- Update .env.example documentation
- Update all 9router references in comments/docs/tests to omniroute
- Production BWS secret gmw_ai_llm_base_url already updated
Omniroute uses /api/v1 prefix (not /v1 like 9router), so the base URL
now correctly points at the right API path for the OpenAI SDK.
getRecentEdits SELECT ... m.content AS new_content returned the message's
ORIGINAL content (never updated on edit) instead of the post-edit content.
messages.content stores the original body; the current/last-edited body lives
in messages.edited_content. So before===after in the Message Edits diff.
Fix: COALESCE(m.edited_content, m.content) AS new_content so 'After' shows the
edited text and diffs against the captured before-content are meaningful.
Extract server_nick from metadata.member.displayName in backend messageMapper,
add server_nick to frontend MessageRecord type, and update messages view +
analysis view to display the member's server-specific nickname (with @username
as secondary context) instead of the global username.
Rename server_name (guild name) to server_nick and populate it from
the member's server-specific display name (metadata.member.displayName)
at write time. This is what the moderation dashboard should show as
TARGET — e.g. server nick 'Bandar Togel「✔ ᵛᵉʳᶦᶠᶦᵉᵈ 」' for global
username '.nichiyobi'. Backfilled 210 existing actions from messages
metadata (reset_nickname rows now show 'Sarjana .jav', 'Penindas
Minoritas', etc). Frontend TARGET shows server nick with global
username as secondary context.
Denormalize guild name alongside username so the moderation dashboard
shows both TARGET and server even after message table purges.
Migration 0018. Frontend displays 'username · server_name' in TARGET.
- getMessageById: run findById and getEditHistory in parallel via
Promise.all instead of sequential awaits (halves latency for
message detail view)
- Drop 3 unused indexes on messages table: idx_messages_guild_ai_status_created
(0 scans), idx_messages_guild_ai_status_analyzed (97 scans),
idx_messages_guild_created_deleted (95 scans, superseded by covering index)
— saves ~5.9MB index space + reduces write amplification
- Add covering index idx_messages_guild_created_covering for the primary
findMany query pattern (guild_id + created_at DESC with INCLUDE columns)
tsc reported 'Property some does not exist on type {}' on doc.tags
because Drizzle jsonb inference returns a generic object. Cast explicitly.
This unblocks the GMW GitHub Actions deploy (test job).
The Drizzle schema used pgText('tags').array() which emits a Postgres
text[] column, but the migration defines tags as jsonb. The mismatch caused
INSERT/LIST on materi_documents to throw INTERNAL_SERVER_ERROR (500) because
Drizzle sent a text[] where the column expected jsonb.
- schema.ts: tags → pgJsonb('tags').notNull().default('[]')
- migration: dropped+recreated to match schema (jsonb, ms epoch defaults,
owner_user_id default 'anonymous')
The fix-imports.mjs script blindly appended '.js' to every @/ alias
import, even when the source specifier already carried a .js
extension (e.g. '@/shared/config/index.js'). This produced
'index.js.js' in the emitted dist/, causing ERR_MODULE_NOT_FOUND
at startup.
This was latent: only triggered once digestScheduler.ts (which
uses @/shared/config/index.js with explicit extension) was built.
The user-reputation removal (2a8f6d9) was also blocked by this
bug — stale binary kept crashing with 'user_reputations' query
errors because it was never redeployed.
Fix: only append .js when the @/ specifier has no existing
extension. Applied to both gateway and backend scripts.
- Persist structured verdict (flags/severity/confidence/evidence) on
moderation_actions so the public web can show WHY a message was moderated.
- Add a persistent Qdrant archive collection (gmw_message_archive); embed
every captured message at capture time (fire-and-forget, best-effort).
- Public semantic search over the archive (backend oRPC + FE toggle on the
messages view). Both features are read-only/public and fully automatic.
Migration: 0015_add_moderation_explainability.sql
- Add wikipediaClient.ts: native fetch to Wikipedia REST/Action APIs
(search + summary), no extra npm dependency.
- Extract shared Redis cache into cacheStore.ts (decoupled from search).
- Term glossary now uses wikipediaSummary for direct article lookup.
- Remove searxngSearch.ts entirely; drop SEARXNG_BASE_URL config,
add WIKIPEDIA_LANG / WIKIPEDIA_TIMEOUT_MS.
- Rename backend searxngCalls metric to webSearchCalls.
- Set stream:false on the /chat/completions request so the bot gets one
complete response instead of an SSE token stream.
- Add reasoning_effort:"none" to suppress extended-thinking/reasoning tokens
(ignored by non-reasoning models like gemini-flash-lite).
- Add parseResponse(): handles both the JSON object 9router returns for
stream:false and the SSE text it may still emit, delegating SSE to parseSse.
Verified live: omniroute returns 200 application/json with message.content.
flake.nix only rewrote @/ aliases but left extensionless relative imports
(./router) in compiled dist/. node dist/index.js (how prod runs) cannot
resolve extensionless ESM specifiers -> ERR_MODULE_NOT_FOUND -> backend
crashlooped (444 restarts, port 4001 dead). Extract the fixer into a shared
scripts/fix-imports.mjs that appends .js to extensionless relative imports and
rewrites @/ aliases, and wire it into backend + discord-gateway build phases.
Verified: fresh tsc + fixer -> node dist/index.js boots; oRPC over /trpc
serves both HTTP POST and WebSocket (config/dashboard/voice/moderation/
media/chatbot/analysis) end-to-end against Postgres + Redis. next build
passes with the oRPC client + partysocket.
Replace REST module routers with a single typed tRPC appRouter served over
/trpc (HTTP + WebSocket), and rewire the frontend to call it via
@trpc/client wsLink (browser) and httpLink (RSC data layer). Existing
/api/health + /api/metrics stay as plain Express for infra scraping.
Notable fixes surfaced by the live smoke test:
- Express 5 / path-to-regexp v8 rejects the /trpc/* wildcard route; use a
prefix middleware that computes opts.path from the URL instead.
- nodeHTTPRequestHandler treats opts.path as the literal procedure path, so
it is derived per-request from req.url.
- Two ws servers on one http.Server (the /ws voice socket + /trpc) collided
and returned 400 on upgrade; both now use noServer + a manually routed
server.on('upgrade') keyed by path.
Verified: BE tsc+biome+40 vitest green; FE tsc+biome green; live
HTTP and WebSocket calls returned real prod data.
Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
The chatbot agent now has 14 tools (was 4) so it can answer about ANY
server situation from live data instead of a static snapshot:
- get_server_stats (now also returns clean count)
- get_top_channels, get_recent_activity, get_top_flagged
- search_messages (LIKE keyword search)
- get_user_messages, get_user_profile, get_user_reputation
- get_channel_culture
- get_message_detail (full AI analysis of one message)
- get_message_reviews (human moderation queue by status)
- get_voice_recordings (with transcriptions)
- get_moderation_timeline (daily flagged/warn/clean trend)
- get_corrections (AI false-positive correction history)
Security/quality:
- Every executor now uses parameterized drizzle queries (eq/like/and).
The old code interpolated model-supplied IDs into sql.raw() — a SQL
injection vector. Removed.
- Split static tool *definitions* into chatbot.toolDefs.ts (no DB import)
so the LLM-facing schema can be unit-tested without loading the
database/config layer. chatbot.tools.ts keeps only the executor.
Verified: tsc + biome clean, 40 backend tests pass (4 new covering the
tool-contract: names unique, required args declared, full situation
coverage).
Co-Authored-By: Claude Opus 5 (Nous Research)
The chatbot already had an agentic tool loop (get_server_stats,
get_top_channels, get_recent_activity, get_top_flagged), but processMessage
still baked a serverInsights snapshot into the system prompt and told the
model to "answer from that data". That defeats the tools: the model answered
from a stale snapshot instead of living numbers, and the guild/channel scope
the frontend sends was never forwarded to the tools.
Changes (services/backend/src/modules/chatbot):
- Remove getServerInsights() + ServerInsights (dead after this change).
- buildSystemPrompt(): drop the hardcoded stats block; instruct the model it
has NO memorized server numbers and MUST call a tool for any server-data
question, answering only from tool results.
- processMessage(): stop fetching insights; pass the request guildId/channelId
scope through to callLLM.
- callLLM(): accept scope; auto-fill empty guildId/channelId on tool calls from
the request scope so the model never has to guess IDs and tools always query
the right server.
Behavior: answers now come from live DB data via tools, scoped to the server
the user is chatting in. tsc + biome + 36 backend tests green.
Co-Authored-By: Claude Opus 5 (Nous Research)
Adds per-message AI moderation analysis time (ai_analysis_duration_ms)
so operators can see how long the LLM took to moderate each message.
Gateway:
- messagesTable: new ai_analysis_duration_ms (bigint) column.
- AIAnalysisUpdate + buildAIAnalysisSet: carry analysisDurationMs through
both single and bulk update paths.
- ai-analysis-worker: measure wall-clock time around runModerationAnalysis
and attach it to every result in the batch.
Backend:
- Mirror schema column; messageMapper maps ai_analysis_duration_ms;
moderation-types + MappedMessage expose it.
Frontend:
- message.ts type gains ai_analysis_duration_ms.
- AiBadge (messages view) shows 'status · 1.2s' when duration is present;
analysis view badge mirrors the same formatting.
DB:
- scripts/add-ai-analysis-duration.sql (idempotent ADD COLUMN IF NOT EXISTS).
No behavior change for moderation logic; null until new gateway build
records values.