utilityInference layer 2

This commit is contained in:
Storme-bit
2026-08-17 07:41:41 -07:00
parent fab8f32395
commit 2bee4b23d6
4 changed files with 58 additions and 56 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ async function utilityComplete({
model: UTILITY_MODEL, model: UTILITY_MODEL,
messages, messages,
stream: false, stream: false,
...UTILITY(json && {format: 'json' }), ...(json && {format: 'json' }),
options: { options: {
temperature: temperature ?? UTILITY.TEMPERATURE, temperature: temperature ?? UTILITY.TEMPERATURE,
num_predict: maxTokens ?? UTILITY.MAX_TOKENS, num_predict: maxTokens ?? UTILITY.MAX_TOKENS,
@@ -1,9 +1,7 @@
const semantic = require('../semantic') const semantic = require('../semantic')
const { getEnv, SERVICES, formatEpisodeText, ENTITIES, logger } = require('@nexusai/shared'); const { getEnv, SERVICES, formatEpisodeText, ENTITIES, logger, utilityInference } = require('@nexusai/shared');
const { upsertEntity, upsertRelationship, linkEntityToEpisode } = require('./index'); const { upsertEntity, upsertRelationship, linkEntityToEpisode } = require('./index');
const EXTRACTION_URL = getEnv('EXTRACTION_URL', 'http://localhost:11434');
const EXTRACTION_MODEL = getEnv('EXTRACTION_MODEL', 'qwen2.5:3b'); // ChatML format — see buildExtractionPrompt
const EMBEDDING_SERVICE_URL = getEnv('EMBEDDING_SERVICE_URL', SERVICES.EMBEDDING_URL); const EMBEDDING_SERVICE_URL = getEnv('EMBEDDING_SERVICE_URL', SERVICES.EMBEDDING_URL);
const ENTITY_TYPES = ENTITIES.TYPES; const ENTITY_TYPES = ENTITIES.TYPES;
@@ -28,10 +26,9 @@ function mentionedIn(name, haystack) {
return norm(haystack).includes(norm(name)); return norm(haystack).includes(norm(name));
} }
// NOTE: This prompt uses ChatML format (<|im_start|> / <|im_end|> tags), which is // Returns { system, user } for utilityInference. The model's prompt template
// specific to qwen-family models. If EXTRACTION_MODEL is changed to a Llama-family // (ChatML for qwen, etc.) is applied by Ollama via the inference service's
// or other model, this format will need to change — most alternatives use either // /utility/complete route — no template tags belong in this file.
// plain text or [INST] / <<SYS>> tags. Silent degradation is likely if mismatched.
function buildExtractionPrompt(userMessage, aiResponse, knownEntities = []) { function buildExtractionPrompt(userMessage, aiResponse, knownEntities = []) {
const knownBlock = knownEntities.length > 0 const knownBlock = knownEntities.length > 0
? [ ? [
@@ -41,33 +38,30 @@ function buildExtractionPrompt(userMessage, aiResponse, knownEntities = []) {
].join('\n') ].join('\n')
: ''; : '';
return [ return {
'<|im_start|>system', system: 'You are a named entity and relationship extractor. You output only valid JSON.',
'You are a named entity and relationship extractor. You output only valid JSON.', user: [
'<|im_end|>', 'Read the conversation below and extract all named entities and the relationships between them.',
'<|im_start|>user', `Entity types: ${ENTITY_TYPES.join(', ')}`,
'Read the conversation below and extract all named entities and the relationships between them.', 'Use "character" for any fictional, game, or media characters (e.g. characters from anime, games, books, TV shows, movies)',
`Entity types: ${ENTITY_TYPES.join(', ')}`, 'Use "person" only for real people',
'Use "character" for any fictional, game, or media characters (e.g. characters from anime, games, books, TV shows, movies)', 'For each entity provide:',
'Use "person" only for real people', ' "name": short proper noun only (max 4 words)',
'For each entity provide:', ' "type": one of the valid types',
' "name": short proper noun only (max 4 words)', ' "notes": one specific sentence about this entity based on the conversation',
' "type": one of the valid types', 'For relationships, use snake_case verb labels (e.g. works_on, manages, uses, knows, located_in, part_of, created_by).',
' "notes": one specific sentence about this entity based on the conversation', 'Only include relationships between entities you have listed above.',
'For relationships, use snake_case verb labels (e.g. works_on, manages, uses, knows, located_in, part_of, created_by).', 'The known-entities list below is ONLY for consistent spelling and types. Do NOT output an entity unless it actually appears in the conversation.',
'Only include relationships between entities you have listed above.', 'Return this exact JSON structure:',
'The known-entities list below is ONLY for consistent spelling and types. Do NOT output an entity unless it actually appears in the conversation.', '{ "entities": [{"name": "...", "type": "...", "notes": "..."}], "relationships": [{"from": "...", "fromType": "...", "to": "...", "toType": "...", "label": "...", "notes": "..."}] }',
'Return this exact JSON structure:', '',
'{ "entities": [{"name": "...", "type": "...", "notes": "..."}], "relationships": [{"from": "...", "fromType": "...", "to": "...", "toType": "...", "label": "...", "notes": "..."}] }', knownBlock,
'', '--- CONVERSATION ---',
knownBlock, `User: ${userMessage}`,
'--- CONVERSATION ---', `Assistant: ${aiResponse}`,
`User: ${userMessage}`, '--- END CONVERSATION ---',
`Assistant: ${aiResponse}`, ].join('\n'),
'--- END CONVERSATION ---', };
'<|im_end|>',
'<|im_start|>assistant',
].join('\n');
} }
async function embedEntity(entity) { async function embedEntity(entity) {
@@ -91,30 +85,16 @@ async function extractAndStoreEntities(userMessage, aiResponse, episodeId=null,
// Fetch existing entities to guide the model toward consistent name/type pairs // Fetch existing entities to guide the model toward consistent name/type pairs
const db = require('../db').getDB(); const db = require('../db').getDB();
const knownEntities = db.prepare(`SELECT name, type FROM entities ORDER BY rowid DESC LIMIT 20`).all(); const knownEntities = db.prepare(`SELECT name, type FROM entities ORDER BY rowid DESC LIMIT 20`).all();
const prompt = buildExtractionPrompt(userMessage, aiResponse, knownEntities); const { system, user } = buildExtractionPrompt(userMessage, aiResponse, knownEntities);
const raw = await utilityInference({
const res = await fetch(`${EXTRACTION_URL}/api/generate`, { system,
method: 'POST', user,
headers: { 'Content-Type': 'application/json' }, json: true,
body: JSON.stringify({ temperature: ENTITIES.TEMPERATURE,
model: EXTRACTION_MODEL, maxTokens: ENTITIES.NUM_PREDICT,
prompt: prompt,
stream: false,
format: 'json',
options: {
temperature: ENTITIES.TEMPERATURE,
num_predict: ENTITIES.NUM_PREDICT,
},
}),
signal: AbortSignal.timeout(60_000),
}); });
if (!res.ok) throw new Error(`Ollama responded ${res.status}`);
const data = await res.json();
const raw = data.response?.trim() ?? '';
const jsonMatch = raw.match(/\{[\s\S]*\}/); const jsonMatch = raw.match(/\{[\s\S]*\}/);
if (!jsonMatch) { if (!jsonMatch) {
logger.warn('[entities] No JSON object found in response'); logger.warn('[entities] No JSON object found in response');
+2
View File
@@ -17,6 +17,7 @@ const {
} = require('./config/constants'); } = require('./config/constants');
const {parseRow, formatEpisodeText, isTrivialTurn} = require('./utils') const {parseRow, formatEpisodeText, isTrivialTurn} = require('./utils')
const logger = require('./utils/logger'); const logger = require('./utils/logger');
const utilityInference = require('./utils/utilityInference');
module.exports = { module.exports = {
getEnv, getEnv,
@@ -38,4 +39,5 @@ module.exports = {
logger, logger,
RETRIEVAL, RETRIEVAL,
UTILITY, UTILITY,
utilityInference,
}; };
@@ -0,0 +1,20 @@
const { getEnv } = require('../config/env');
const { SERVICES } = require('../config/constants');
// Client for the inference service's /utility/complete route.
// Resolved at call time (not module load) so each service's .env applies.
async function utilityInference({ system, user, json = false, temperature, maxTokens }) {
const base = getEnv('INFERENCE_SERVICE_URL', SERVICES.INFERENCE_URL);
const res = await fetch(`${base}/utility/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ system, user, json, temperature, maxTokens }),
});
if (!res.ok) throw new Error(`Utility inference error: ${res.status}`);
const data = await res.json();
return data.text;
}
module.exports = { utilityInference };