From ff1c0ab2153c52a65e035c89c899fe14705e9735 Mon Sep 17 00:00:00 2001 From: Storme-bit Date: Mon, 17 Aug 2026 06:23:00 -0700 Subject: [PATCH] trivial turn patch --- packages/memory-service/src/episodic/index.js | 14 ++++-- .../orchestration-service/src/chat/index.js | 35 +++++++++------ packages/shared/src/index.js | 3 +- packages/shared/src/utils.js | 44 ++++++++++++++++++- test/trivial-turn.test.js | 42 ++++++++++++++++++ 5 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 test/trivial-turn.test.js diff --git a/packages/memory-service/src/episodic/index.js b/packages/memory-service/src/episodic/index.js index 61679c3..64c03cd 100644 --- a/packages/memory-service/src/episodic/index.js +++ b/packages/memory-service/src/episodic/index.js @@ -1,5 +1,5 @@ const {getDB} = require('../db'); -const { EPISODIC, getEnv, SERVICES, parseRow, formatEpisodeText, SUMMARIES, logger } = require('@nexusai/shared'); +const { EPISODIC, getEnv, SERVICES, parseRow, formatEpisodeText, SUMMARIES, logger, isTrivialTurn } = require('@nexusai/shared'); const semantic = require('../semantic'); const { extractAndStoreEntities } = require('../entities/extraction') @@ -162,8 +162,16 @@ async function createEpisode(sessionId, userMessage, aiResponse, tokenCount = nu })) .catch(err => logger.error(`Failed to embed episode ${episode.id}:`, err.message)); - extractAndStoreEntities(userMessage, aiResponse, episode.id, projectId) - .catch(err => logger.error(`Failed to extract entities for episode ${episode.id}:`, err.message)); + // Skip entity extraction on contentless social turns (greetings, sign-offs). + // They carry nothing worth storing, and running extraction on them was a + // source of junk/confabulated entities. The informational content lives in + // substantive turns, which still extract normally. + if (isTrivialTurn(userMessage)) { + logger.debug(`[entities] Skipping extraction for episode ${episode.id} — trivial turn`); + } else { + extractAndStoreEntities(userMessage, aiResponse, episode.id, projectId) + .catch(err => logger.error(`Failed to extract entities for episode ${episode.id}:`, err.message)); + } return episode; diff --git a/packages/orchestration-service/src/chat/index.js b/packages/orchestration-service/src/chat/index.js index f0d5b41..fc8736a 100644 --- a/packages/orchestration-service/src/chat/index.js +++ b/packages/orchestration-service/src/chat/index.js @@ -2,7 +2,7 @@ const memory = require("../services/memory"); const inference = require("../services/inference"); const embedding = require("../services/embedding"); const qdrant = require("../services/qdrant"); -const { ORCHESTRATION, RETRIEVAL, logger } = require("@nexusai/shared"); +const { ORCHESTRATION, RETRIEVAL, logger, isTrivialTurn } = require("@nexusai/shared"); const appSettings = require("../config/settings"); const {triggerSummary} = require('../services/summarization') const graph = require('../services/graph'); @@ -283,20 +283,27 @@ async function assembleContext(externalId, userMessage) { const isFirstMessage = recentEpisodes.length === 0; const recentIds = new Set(recentEpisodes.map(e => e.id)); - // 4. Embed the query once — the vector is shared by semantic episode search - // and entity search, so embedding it twice was a wasted round-trip + Ollama call. - let queryVector = null; - try { - queryVector = await embedding.embed(userMessage); - } catch (err) { - logger.warn('[orchestration] Query embedding failed; semantic + entity search disabled this turn:', err.message); - } + // 4. Retrieval — skipped entirely on contentless social turns (greetings, + // sign-offs). On those, recent history alone is the right context; running + // semantic/keyword/entity retrieval only surfaces marginal noise the model + // then confabulates around. Embed once (shared by episode + entity search). + let fusedWithScores = []; + let entityResults = []; + if (!isTrivialTurn(userMessage)) { + let queryVector = null; + try { + queryVector = await embedding.embed(userMessage); + } catch (err) { + logger.warn('[orchestration] Query embedding failed; semantic + entity search disabled this turn:', err.message); + } - // 4b. Fused retrieval + entity search in parallel (both are independent) - const [fusedWithScores, entityResults] = await Promise.all([ - getFusedEpisodes(userMessage, session, recentIds, projectSessionIds, queryVector, { semanticLimit, scoreThreshold, semanticWeight, keywordWeight }), - getRelevantEntities(queryVector, session.project_id ?? null), - ]); + [fusedWithScores, entityResults] = await Promise.all([ + getFusedEpisodes(userMessage, session, recentIds, projectSessionIds, queryVector, { semanticLimit, scoreThreshold, semanticWeight, keywordWeight }), + getRelevantEntities(queryVector, session.project_id ?? null), + ]); + } else { + logger.debug('[orchestration] Trivial turn — skipping semantic/keyword/entity retrieval'); + } // 5. Entity-linked episode IDs for scoring bonus const entityIds = entityResults.map(e => e.id); diff --git a/packages/shared/src/index.js b/packages/shared/src/index.js index 4a321b7..896dc3c 100644 --- a/packages/shared/src/index.js +++ b/packages/shared/src/index.js @@ -1,6 +1,6 @@ const {getEnv} = require('./config/env'); const {QDRANT, COLLECTIONS, EPISODIC, SERVICES, OLLAMA, PORTS, LLAMACPP, INFERENCE_DEFAULTS, SQLITE, ORCHESTRATION, SUMMARIES, ENTITIES, RETRIEVAL } = require('./config/constants'); -const {parseRow, formatEpisodeText} = require('./utils') +const {parseRow, formatEpisodeText, isTrivialTurn} = require('./utils') const logger = require('./utils/logger'); module.exports = { @@ -17,6 +17,7 @@ module.exports = { ORCHESTRATION, parseRow, formatEpisodeText, + isTrivialTurn, SUMMARIES, ENTITIES, logger, diff --git a/packages/shared/src/utils.js b/packages/shared/src/utils.js index 68e97ee..6df0f66 100644 --- a/packages/shared/src/utils.js +++ b/packages/shared/src/utils.js @@ -10,4 +10,46 @@ function formatEpisodeText(userMessage, aiResponse) { return `User: ${userMessage}\nAssistant: ${aiResponse}`; } -module.exports = { parseRow, formatEpisodeText }; \ No newline at end of file +// Contentless "social" turns — greetings, sign-offs, acknowledgements — that +// carry no information to store or recall. Used to skip entity extraction and +// noisy retrieval on such turns. +const TRIVIAL_TURNS = new Set([ + 'good morning', 'good night', 'good evening', 'good afternoon', 'morning', 'evening', + 'hello', 'hi', 'hey', 'hey there', 'yo', 'sup', 'whats up', 'hiya', 'howdy', + 'goodbye', 'bye', 'see you', 'see ya', 'see you later', 'talk later', 'talk soon', + 'later', 'catch you later', 'gtg', 'gotta go', 'im off', 'heading out', + 'thanks', 'thank you', 'thanks again', 'thank you so much', 'ty', 'thx', 'cheers', + 'no worries', 'no problem', 'np', 'youre welcome', 'my pleasure', + 'ok', 'okay', 'k', 'kk', 'alright', 'sure', 'sounds good', 'got it', 'gotcha', + 'cool', 'nice', 'great', 'awesome', 'perfect', 'lol', 'haha', + 'just saying hi', 'just saying hello', 'just checking in', 'just dropping in', + 'stopping by', 'just stopping by', 'just wanted to say hi', +]); + +// Trailing filler words that don't change a phrase's social nature, so +// "good morning again" / "thanks everyone" collapse to a base phrase. +const TRIVIAL_FILLER = new Set(['again', 'there', 'everyone', 'all', 'yall', 'folks', 'man', 'dude', 'friend', 'buddy']); + +// Conservative, HIGH-PRECISION check: is this a contentless social turn? +// It exists to skip retrieval/extraction on turns with nothing to remember or +// recall, and deliberately errs toward "substantive" — a real short query like +// "capital of France" must NOT be treated as trivial. Genuine intent +// classification is a separate, later concern (confidence-based routing), not +// this heuristic. Extend TRIVIAL_TURNS as new pure-social phrases show up. +function isTrivialTurn(message) { + if (!message) return true; + const norm = String(message) + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, ' ') // punctuation → space (unicode-aware) + .replace(/\s+/g, ' ') + .trim(); + if (!norm) return true; // empty / punctuation-only + if (TRIVIAL_TURNS.has(norm)) return true; + + // Drop trailing filler and re-check ("good morning again" → "good morning") + const words = norm.split(' '); + while (words.length > 1 && TRIVIAL_FILLER.has(words[words.length - 1])) words.pop(); + return TRIVIAL_TURNS.has(words.join(' ')); +} + +module.exports = { parseRow, formatEpisodeText, isTrivialTurn }; \ No newline at end of file diff --git a/test/trivial-turn.test.js b/test/trivial-turn.test.js new file mode 100644 index 0000000..ace17db --- /dev/null +++ b/test/trivial-turn.test.js @@ -0,0 +1,42 @@ +// Trivial-turn guard — tests the REAL isTrivialTurn from @nexusai/shared, which +// gates whether a turn skips retrieval + entity extraction. The critical property +// is high precision: greetings/pleasantries are caught, but real short queries +// must NOT be (a false positive would suppress retrieval on a genuine question). +const { test } = require('node:test'); +const assert = require('node:assert'); +const { isTrivialTurn } = require('@nexusai/shared'); + +test('plain greetings and pleasantries are trivial', () => { + for (const m of ['good morning', 'Good morning!', 'hello', 'hey there', 'hi', + 'thanks', 'thank you so much', 'bye', 'ok', 'sounds good', + 'just dropping in', 'just checking in', 'cheers']) { + assert.strictEqual(isTrivialTurn(m), true, `"${m}" should be trivial`); + } +}); + +test('trailing filler collapses to a base greeting', () => { + assert.strictEqual(isTrivialTurn('good morning again'), true); + assert.strictEqual(isTrivialTurn('thanks everyone'), true); + assert.strictEqual(isTrivialTurn('hello there'), true); +}); + +test('empty or punctuation-only input is trivial', () => { + assert.strictEqual(isTrivialTurn(''), true); + assert.strictEqual(isTrivialTurn(' '), true); + assert.strictEqual(isTrivialTurn('!!!'), true); + assert.strictEqual(isTrivialTurn(null), true); +}); + +test('real queries are NOT trivial — even short ones (the precision guarantee)', () => { + for (const m of ['what is the capital of France', 'France capital', 'capital of France?', + 'how do I configure Qdrant', 'One Piece', 'help me debug this', + 'what did we decide about the schema', 'morning routine ideas']) { + assert.strictEqual(isTrivialTurn(m), false, `"${m}" should be substantive`); + } +}); + +test('a greeting prefix does not make a substantive message trivial', () => { + // "good morning, can you help with X" carries a real request — must not be skipped + assert.strictEqual(isTrivialTurn('good morning, can you help me with the migration'), false); + assert.strictEqual(isTrivialTurn('hey, what is the One Piece'), false); +});