fix: reanalyze edited messages

This commit is contained in:
MythEclipse
2026-05-14 20:03:02 +07:00
parent dbfc6866f9
commit 44368e646f
16 changed files with 2233 additions and 23 deletions

Submodule .claude/worktrees/agent-a3d811850036f998a added at 49d4bbf781

Submodule .claude/worktrees/agent-aa8b5c864c11af9a4 added at 49d4bbf781

Submodule .claude/worktrees/agent-aaadb24c021c240fb added at 49d4bbf781

Submodule .claude/worktrees/agent-abdbb74fab15cf706 added at 49d4bbf781

Submodule .claude/worktrees/agent-ace5d9f61d75c84e6 added at 49d4bbf781

Submodule .claude/worktrees/agent-ad5e79034c2a9c199 added at e3c48365c7

Submodule .claude/worktrees/agent-af0a2804fbe1b727c added at 49d4bbf781

30
.env.test Normal file
View File

@@ -0,0 +1,30 @@
DISCORD_TOKEN=test_token_for_testing
RECORDINGS_DIR=./recordings
RECORDING_SEGMENT_MS=5000
VERBOSE=false
DECODER_ROTATE_MS=5000
DECODER_COOLDOWN_MS=30000
AUDIO_STREAM_SILENCE_DURATION_MS=3000
PACKET_FILTER_MIN_SIZE=8
OPUS_FRAME_SIZE=960
AUDIO_SAMPLE_RATE=48000
AUDIO_CHANNELS=2
AVATAR_SIZE=64
WEBSERVER_PORT=3000
VOICE_CONNECTION_TIMEOUT_MS=15000
RECONNECT_TIMEOUT_MS=5000
LOG_LEVEL=info
NODE_ENV=test
MONITOR_GUILD_ID=test_guild_id
PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
ATTACHMENT_MAX_SIZE_MB=100
ATTACHMENT_RETRY_ATTEMPTS=3
BACKLOG_SYNC_HOURS=24
BACKLOG_SYNC_BATCH_SIZE=100
AI_ANALYSIS_ENABLED=false
AI_LLM_API_KEY=test_key
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
AI_LLM_MODEL=free
AI_ANALYSIS_TIMEOUT_MS=30000
DATABASE_TYPE=sqlite

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,264 @@
# AI Message Flow + React Dashboard Redesign
## Goal
Rebuild the moderation watcher flow so message capture is fast, AI analysis is contextual and reliable, APIs are split by concern, and the dashboard is maintainable. The implementation may fully replace the current static frontend and reorganize backend modules, while preserving existing voice functionality.
## Current Problems
- Message capture, AI queuing, DB access, and WebSocket broadcasting are tightly coupled.
- AI analysis batches depend on array ordering, which can mismatch results when the model returns malformed or partial JSON.
- Pending analysis is polled globally, not grouped by conversation, so context is weak and request efficiency is inconsistent.
- `/api/messages` mixes text/image concerns and uses offset pagination, which gets slower and less stable as rows grow.
- Frontend is a large static HTML file with inline state, API, WebSocket, rendering, and audio code.
- WebSocket broadcast uses untyped `globalThis` hooks across modules.
## Backend Architecture
### Message ingestion
`messageCapture` becomes a narrow ingestion layer:
1. Filter Discord events by guild and author.
2. Normalize message payload and metadata.
3. Upsert message/attachment records.
4. Set or reset `ai_status` to `pending` for new or edited text.
5. Emit typed domain events for WebSocket broadcasting and analysis queueing.
It should not build prompts, manage AI batches, or query unrelated DB state.
### Store/repository layer
`messageStore` becomes the single message/attachment query boundary. It should expose focused functions:
- `upsertMessage`
- `markMessageEdited`
- `markMessageDeleted`
- `listMessages`
- `listReviewMessages`
- `getConversationContext`
- `claimPendingMessagesForChannel`
- `saveAnalysisResults`
- `insertAttachments`
Queries should use cursor pagination based on `(created_at, id)` instead of offset pagination. Common filters: `guildId`, `channelId`, `threadId`, `status`, `userId`, `q`, `limit`, `cursor`.
### Analysis queue
Add an `analysisQueue` module. It owns async AI processing and keeps capture fast.
- Queue key: `thread_id ?? channel_id`.
- Debounce: 13 seconds per key to group nearby messages.
- Batch pending messages by conversation key and token budget.
- Only one or a small fixed number of active LLM requests.
- Backlog worker feeds the same queue; no separate analysis path.
- Edits reset a message to `pending` and enqueue its conversation key.
If the process restarts, pending rows are recovered by a periodic lightweight scanner grouped by conversation key.
### Conversation context builder
Add `conversationContext` module:
- Input: conversation key + target pending messages.
- Fetch context before the first target message, normally 20 prior messages.
- Include target messages and close neighboring messages when within budget.
- Mark target messages explicitly in the prompt.
- Keep context scoped to one channel/thread to avoid irrelevant noise.
### LLM moderation client
Add `llmModerationClient` module:
- Own request shape, timeout, retry, JSON extraction, and validation.
- Prompt returns JSON keyed by `message_id`, not positional arrays.
- Expected response shape:
```json
{
"results": [
{
"message_id": "string",
"status": "clean|warn|flagged",
"flags": ["string"],
"score": 0.0,
"analysis": "Bahasa Indonesia summary + reason + suggested action"
}
]
}
```
- Reject unknown IDs, invalid statuses, invalid scores, and missing target IDs.
- On partial model failure, retry once with smaller batch. If still invalid, mark only affected target messages as `error`.
- Store raw batch request/response in one run record if the DB migration is included; otherwise store compact raw metadata per message.
## API Design
Split API by use case so reads remain fast and obvious.
### Message read APIs
- `GET /api/messages`
- Query: `guildId`, `channelId`, `threadId`, `cursor`, `limit`, `status`, `userId`, `q`.
- Returns: `{ data, nextCursor }`.
- Uses indexed cursor pagination.
- `GET /api/messages/:id`
- Returns one message with attachments and AI analysis.
- `GET /api/review`
- Query: `guildId`, optional `channelId`, `status=warn,flagged,error`, `cursor`, `limit`.
- Optimized for moderator review panel.
- `GET /api/attachments`
- Query: `channelId`, `threadId`, `cursor`, `limit`, `type`.
- Replaces image mode inside `/api/messages`.
### Analysis APIs
- `POST /api/messages/:id/reanalyze`
- Sets message to `pending` and queues its conversation.
- Returns `202 Accepted` with current message status.
- `POST /api/analysis/requeue-pending`
- Admin/manual recovery endpoint for pending/error rows.
- Returns count queued.
- `GET /api/analysis/status`
- Returns queue depth, active requests, last error, and pending counts.
### Discord sync APIs
- `POST /api/backlog-sync`
- Stays async-friendly: starts sync for guild/channel/thread and returns `202` with a job id or immediate summary if small.
- Sync inserts messages, then queues analysis through the same `analysisQueue`.
### Voice/control APIs
Keep existing voice APIs working, but move route registration into route modules:
- `routes/voiceRoutes.ts`
- `routes/messageRoutes.ts`
- `routes/analysisRoutes.ts`
- `routes/syncRoutes.ts`
- `routes/uiStateRoutes.ts`
`webserver.ts` should only create Express/WS server, install middleware, register routes, and start listening.
## WebSocket Design
Replace ad-hoc globals with a typed broadcaster module.
Events:
- `ui_state`
- `user_state`
- `message_created`
- `message_updated`
- `message_deleted`
- `message_analyzed`
- `attachment_created`
- `analysis_queue_status`
Backend modules call broadcaster functions; they do not touch WebSocket clients directly.
## Database Changes
Add or verify indexes:
- messages `(channel_id, created_at, id)`
- messages `(thread_id, created_at, id)`
- messages `(ai_status, created_at, id)`
- messages `(guild_id, ai_status, created_at, id)`
- attachments `(channel_id, created_at, id)`
- attachments `(thread_id, created_at, id)`
Optional but preferred:
- `ai_analysis_runs` table:
- `id`
- `conversation_key`
- `target_message_ids`
- `model`
- `request_tokens_estimate`
- `response_raw`
- `status`
- `error`
- `created_at`
- `completed_at`
This avoids duplicating large raw LLM responses into every message row.
## React/Vite Frontend
Replace static inline dashboard code with a TypeScript React app.
Suggested structure:
- `frontend/src/api/` — typed REST clients
- `frontend/src/ws/` — WebSocket client and event types
- `frontend/src/state/` — small hooks for selected guild/channel, messages, review queue, voice state
- `frontend/src/components/voice/` — existing voice control/audio components
- `frontend/src/components/messages/` — feed, message card, filters, detail drawer
- `frontend/src/components/review/` — needs-review list and analysis status
- `frontend/src/components/layout/` — shell/sidebar/status cards
UI layout:
- Left sidebar: guild, voice channel, text channel/thread, connection state.
- Main area: message feed with filters and load-more cursor pagination.
- Right panel: review queue for `warn`, `flagged`, and `error` messages.
- Detail drawer/modal: message metadata, attachments, AI rationale, raw flags, reanalyze action.
Voice features stay functionally equivalent. Audio capture/playback code can be moved into React hooks but should not be behaviorally rewritten unless needed.
Build integration:
- Add Vite dev/build scripts.
- Express serves the built app from a stable public directory in production.
- During development, either run Vite separately or proxy API/WS to Express.
## Performance Rules
- Message capture must not wait on AI.
- Read APIs use cursor pagination and indexes.
- AI batches are bounded by token estimate and message count.
- UI fetches initial pages, then patches via WebSocket.
- Backlog sync should not block dashboard interactions.
- Avoid storing full raw LLM response per message when a batch table is available.
## Error Handling
- Capture errors log and do not crash Discord client event handlers.
- AI request failures mark target messages `error` with a short reason.
- Invalid LLM JSON triggers retry/split before marking errors.
- API validation returns 400 with structured error code.
- WebSocket reconnect logic stays client-side.
- Manual reanalysis provides recovery for bad AI results.
## Testing
Backend:
- Unit tests for conversation context selection.
- Unit tests for LLM response parser and validation.
- Unit tests for queue batching/debounce behavior.
- Integration tests for message cursor pagination and review filters.
- Existing voice tests remain unchanged.
Frontend:
- Typecheck and Vite build.
- Component-level smoke tests may be added if test tooling is already practical.
- Manual browser verification: channel select, message feed, review panel, WebSocket updates, reanalyze action, voice controls.
## Implementation Scope
This is a full redesign of the message/AI/dashboard path. Voice recording and live audio behavior should be preserved unless a change is required to integrate the React dashboard.
Implementation should proceed incrementally:
1. Backend boundaries and typed broadcaster.
2. Store/query improvements and indexes.
3. Analysis queue/context/client rewrite.
4. Split API routes.
5. React/Vite dashboard.
6. Verification and cleanup of old static dashboard code.

View File

@@ -28,7 +28,7 @@ export async function runMigrations(): Promise<void> {
} catch (error) { } catch (error) {
logger.error( logger.error(
{ error: error instanceof Error ? error.message : String(error) }, { error: error instanceof Error ? error.message : String(error) },
"Migration failed" "Migration failed",
); );
throw error; throw error;
} }

View File

@@ -117,7 +117,7 @@ async function processBatch(
// Broadcast analyzed messages // Broadcast analyzed messages
for (const row of analyzedRows) { for (const row of analyzedRows) {
(globalThis as any).broadcastMessageAnalyzed?.(row); (globalThis as any).moderationBroadcaster?.messageAnalyzed(row);
} }
// Clear error cooldown on success // Clear error cooldown on success
@@ -147,7 +147,7 @@ async function processBatch(
error: lastError, error: lastError,
}); });
if (row) { if (row) {
(globalThis as any).broadcastMessageAnalyzed?.(row); (globalThis as any).moderationBroadcaster?.messageAnalyzed(row);
} }
} }

View File

@@ -102,6 +102,13 @@ export async function updateMessageAsEdited(
edited_content: editedContent, edited_content: editedContent,
edited_at: editedAt, edited_at: editedAt,
type: "edited", type: "edited",
ai_status: "pending",
ai_moderation_flags: null,
ai_moderation_score: null,
ai_moderation_raw: null,
ai_analysis: null,
ai_analyzed_at: null,
ai_error: null,
}) })
.where(eq(messagesTable.id, messageId)); .where(eq(messagesTable.id, messageId));

View File

@@ -220,26 +220,6 @@ export async function startWebserver(
broadcaster.userState(users); broadcaster.userState(users);
} }
(global as any).broadcastMessageCreated = (data: any) => {
broadcaster.messageCreated(data);
};
(global as any).broadcastMessageUpdated = (data: any) => {
broadcaster.messageUpdated(data);
};
(global as any).broadcastMessageDeleted = (data: any) => {
broadcaster.messageDeleted(data);
};
(global as any).broadcastAttachmentUploaded = (data: any) => {
broadcaster.attachmentCreated(data);
};
(global as any).broadcastMessageAnalyzed = (data: any) => {
broadcaster.messageAnalyzed(data);
};
// --- Outbound: browser PCM (24kHz mono) → Opus → Discord --- // --- Outbound: browser PCM (24kHz mono) → Opus → Discord ---
const RATE = 48000; const RATE = 48000;
const CHANNELS = 2; const CHANNELS = 2;

View File

@@ -5,6 +5,8 @@ import {
insertMessage, insertMessage,
listMessages, listMessages,
listReviewMessages, listReviewMessages,
updateMessageAsEdited,
getMessageById,
} from "../../src/moderation/messageStore"; } from "../../src/moderation/messageStore";
import { import {
getDatabase, getDatabase,
@@ -506,4 +508,67 @@ describe("message query integration tests", () => {
expect(overlap).toHaveLength(0); expect(overlap).toHaveLength(0);
}); });
}); });
describe("updateMessageAsEdited", () => {
const createTestMessage = (
overrides: Partial<MessageRecord> = {},
): MessageRecord => ({
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2)}`,
guild_id: "guild-123",
channel_id: "channel-456",
thread_id: null,
user_id: "user-789",
username: "testuser",
avatar_url: null,
content: "Test message",
edited_content: null,
created_at: Date.now(),
edited_at: null,
deleted_at: null,
type: "text",
metadata: null,
ai_status: "pending",
...overrides,
});
it("resets ai_status to pending and clears AI fields when message is edited", async () => {
const messageId = `msg-edit-test-${Date.now()}`;
const msg = createTestMessage({
id: messageId,
content: "original content",
ai_status: "clean",
ai_moderation_flags: "test_flag",
ai_moderation_score: 0.5,
ai_moderation_raw: '{"test": "data"}',
ai_analysis: "This is clean",
ai_analyzed_at: Date.now() - 10000,
ai_error: null,
});
// Insert message with AI analysis already done
await insertMessage(msg);
// Verify initial state
let retrieved = await getMessageById(messageId);
expect(retrieved?.ai_status).toBe("clean");
expect(retrieved?.ai_moderation_flags).toBe("test_flag");
expect(retrieved?.ai_moderation_score).toBe(0.5);
expect(retrieved?.ai_analysis).toBe("This is clean");
// Edit the message
await updateMessageAsEdited(messageId, "edited content", Date.now());
// Verify AI fields are reset
retrieved = await getMessageById(messageId);
expect(retrieved?.edited_content).toBe("edited content");
expect(retrieved?.type).toBe("edited");
expect(retrieved?.ai_status).toBe("pending");
expect(retrieved?.ai_moderation_flags).toBeNull();
expect(retrieved?.ai_moderation_score).toBeNull();
expect(retrieved?.ai_moderation_raw).toBeNull();
expect(retrieved?.ai_analysis).toBeNull();
expect(retrieved?.ai_analyzed_at).toBeNull();
expect(retrieved?.ai_error).toBeNull();
});
});
}); });

View File

@@ -1,4 +1,7 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
import { config } from "dotenv";
config({ path: ".env.test" });
export default defineConfig({ export default defineConfig({
test: { test: {