trivial turn patch
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user