trivial turn patch

This commit is contained in:
Storme-bit
2026-08-17 06:23:00 -07:00
parent 7e8aead917
commit ff1c0ab215
5 changed files with 119 additions and 19 deletions
+11 -3
View File
@@ -1,5 +1,5 @@
const {getDB} = require('../db'); 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 semantic = require('../semantic');
const { extractAndStoreEntities } = require('../entities/extraction') 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)); .catch(err => logger.error(`Failed to embed episode ${episode.id}:`, err.message));
extractAndStoreEntities(userMessage, aiResponse, episode.id, projectId) // Skip entity extraction on contentless social turns (greetings, sign-offs).
.catch(err => logger.error(`Failed to extract entities for episode ${episode.id}:`, err.message)); // 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; return episode;
@@ -2,7 +2,7 @@ const memory = require("../services/memory");
const inference = require("../services/inference"); const inference = require("../services/inference");
const embedding = require("../services/embedding"); const embedding = require("../services/embedding");
const qdrant = require("../services/qdrant"); 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 appSettings = require("../config/settings");
const {triggerSummary} = require('../services/summarization') const {triggerSummary} = require('../services/summarization')
const graph = require('../services/graph'); const graph = require('../services/graph');
@@ -283,20 +283,27 @@ async function assembleContext(externalId, userMessage) {
const isFirstMessage = recentEpisodes.length === 0; const isFirstMessage = recentEpisodes.length === 0;
const recentIds = new Set(recentEpisodes.map(e => e.id)); const recentIds = new Set(recentEpisodes.map(e => e.id));
// 4. Embed the query once — the vector is shared by semantic episode search // 4. Retrieval — skipped entirely on contentless social turns (greetings,
// and entity search, so embedding it twice was a wasted round-trip + Ollama call. // sign-offs). On those, recent history alone is the right context; running
let queryVector = null; // semantic/keyword/entity retrieval only surfaces marginal noise the model
try { // then confabulates around. Embed once (shared by episode + entity search).
queryVector = await embedding.embed(userMessage); let fusedWithScores = [];
} catch (err) { let entityResults = [];
logger.warn('[orchestration] Query embedding failed; semantic + entity search disabled this turn:', err.message); 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) [fusedWithScores, entityResults] = await Promise.all([
const [fusedWithScores, entityResults] = await Promise.all([ getFusedEpisodes(userMessage, session, recentIds, projectSessionIds, queryVector, { semanticLimit, scoreThreshold, semanticWeight, keywordWeight }),
getFusedEpisodes(userMessage, session, recentIds, projectSessionIds, queryVector, { semanticLimit, scoreThreshold, semanticWeight, keywordWeight }), getRelevantEntities(queryVector, session.project_id ?? null),
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 // 5. Entity-linked episode IDs for scoring bonus
const entityIds = entityResults.map(e => e.id); const entityIds = entityResults.map(e => e.id);
+2 -1
View File
@@ -1,6 +1,6 @@
const {getEnv} = require('./config/env'); const {getEnv} = require('./config/env');
const {QDRANT, COLLECTIONS, EPISODIC, SERVICES, OLLAMA, PORTS, LLAMACPP, INFERENCE_DEFAULTS, SQLITE, ORCHESTRATION, SUMMARIES, ENTITIES, RETRIEVAL } = require('./config/constants'); 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'); const logger = require('./utils/logger');
module.exports = { module.exports = {
@@ -17,6 +17,7 @@ module.exports = {
ORCHESTRATION, ORCHESTRATION,
parseRow, parseRow,
formatEpisodeText, formatEpisodeText,
isTrivialTurn,
SUMMARIES, SUMMARIES,
ENTITIES, ENTITIES,
logger, logger,
+43 -1
View File
@@ -10,4 +10,46 @@ function formatEpisodeText(userMessage, aiResponse) {
return `User: ${userMessage}\nAssistant: ${aiResponse}`; return `User: ${userMessage}\nAssistant: ${aiResponse}`;
} }
module.exports = { parseRow, formatEpisodeText }; // 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 };
+42
View File
@@ -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);
});