Skip to content

Commit 616af33

Browse files
committed
refactor(gmi): extract SentimentTracker from GMI.ts
1 parent c949978 commit 616af33

2 files changed

Lines changed: 444 additions & 310 deletions

File tree

src/cognitive_substrate/GMI.ts

Lines changed: 23 additions & 310 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,12 @@ import { IToolOrchestrator } from '../core/tools/IToolOrchestrator';
5757
import { ToolExecutionRequestDetails } from '../core/tools/ToolExecutor';
5858
import { ConversationMessage } from '../core/conversation/ConversationMessage';
5959
import { GMIError, GMIErrorCode, createGMIErrorFromError } from '@framers/agentos/utils/errors';
60-
import { GMIEventType, GMIEvent, SentimentHistoryState, createGMIEvent } from './GMIEvent.js';
60+
import { GMIEventType, SentimentHistoryState } from './GMIEvent.js';
6161
import type { ICognitiveMemoryManager } from '../memory/CognitiveMemoryManager.js';
6262
import type { AssembledMemoryContext } from '../memory/types.js';
6363
import { ConversationHistoryManager } from './ConversationHistoryManager';
6464
import { CognitiveMemoryBridge } from './CognitiveMemoryBridge';
65+
import { SentimentTracker } from './SentimentTracker';
6566

6667
const DEFAULT_MAX_CONVERSATION_HISTORY_TURNS = 20;
6768
const DEFAULT_SELF_REFLECTION_INTERVAL_TURNS = 5;
@@ -106,8 +107,7 @@ export class GMI implements IGMI {
106107
private memoryBridge: CognitiveMemoryBridge | null = null;
107108

108109
// Sentiment & Event Tracking
109-
private pendingGMIEvents: Set<GMIEventType> = new Set();
110-
private eventHistory: GMIEvent[] = []; // Last 20 events for debugging
110+
private sentimentTracker!: SentimentTracker;
111111
private metaPromptTriggerCounters: Map<string, number> = new Map();
112112

113113
/**
@@ -174,6 +174,22 @@ export class GMI implements IGMI {
174174

175175
await this.loadStateFromMemoryAndPersona();
176176

177+
// Initialize sentiment tracker
178+
this.sentimentTracker = new SentimentTracker(
179+
this.utilityAI,
180+
this.workingMemory,
181+
() => this.activePersona,
182+
() => this.conversationHistoryManager.history,
183+
() => this.reasoningTrace.entries,
184+
() => this.currentUserContext,
185+
async (ctx) => {
186+
this.currentUserContext = ctx;
187+
await this.workingMemory.set('currentUserContext', this.currentUserContext);
188+
},
189+
(type, message, details) => this.addTraceEntry(type as ReasoningEntryType, message, details),
190+
() => this.gmiId,
191+
);
192+
177193
const reflectionMetaPrompt = this.activePersona.metaPrompts?.find(mp => mp.id === 'gmi_self_trait_adjustment');
178194
this.selfReflectionIntervalTurns = reflectionMetaPrompt?.trigger?.type === 'turn_interval' && typeof reflectionMetaPrompt.trigger.intervalTurns === 'number'
179195
? reflectionMetaPrompt.trigger.intervalTurns
@@ -537,7 +553,7 @@ export class GMI implements IGMI {
537553
const userInputText = typeof lastMsg.content === 'string'
538554
? lastMsg.content
539555
: JSON.stringify(lastMsg.content);
540-
await this.analyzeTurnSentiment(turnId, userInputText);
556+
await this.sentimentTracker.analyzeTurnSentiment(turnId, userInputText);
541557
}
542558
}
543559

@@ -1117,309 +1133,6 @@ export class GMI implements IGMI {
11171133
}
11181134
}
11191135

1120-
/**
1121-
* Analyzes sentiment of user input and updates sentiment history.
1122-
* Triggers event detection based on sentiment patterns.
1123-
*
1124-
* @param turnId - Current turn identifier
1125-
* @param userInput - User's input text
1126-
* @private
1127-
*/
1128-
private async analyzeTurnSentiment(turnId: string, userInput: string): Promise<void> {
1129-
// Skip if input is not a string or is empty
1130-
if (!userInput || typeof userInput !== 'string') {
1131-
return;
1132-
}
1133-
1134-
try {
1135-
// Analyze sentiment using configurable method from sentimentTracking config
1136-
const stConfig = this.activePersona.sentimentTracking;
1137-
const sentimentResult = await this.utilityAI.analyzeSentiment(userInput, {
1138-
method: stConfig?.method || 'lexicon_based',
1139-
modelId: stConfig?.modelId || this.activePersona.defaultModelId,
1140-
providerId: stConfig?.providerId || this.activePersona.defaultProviderId,
1141-
language: (this.currentUserContext.language as string) || 'en',
1142-
});
1143-
1144-
// Update UserContext with current sentiment
1145-
this.currentUserContext.currentSentiment = sentimentResult.polarity;
1146-
await this.workingMemory.set('currentUserContext', this.currentUserContext);
1147-
1148-
// Get or initialize sentiment history
1149-
let sentimentHistory = await this.workingMemory.get<SentimentHistoryState>(
1150-
'gmi_sentiment_history'
1151-
);
1152-
1153-
if (!sentimentHistory) {
1154-
sentimentHistory = {
1155-
trends: [],
1156-
consecutiveFrustration: 0,
1157-
consecutiveConfusion: 0,
1158-
consecutiveSatisfaction: 0,
1159-
};
1160-
}
1161-
1162-
// Add to sentiment trends
1163-
const trend = {
1164-
turnId,
1165-
timestamp: new Date(),
1166-
score: sentimentResult.score,
1167-
polarity: sentimentResult.polarity,
1168-
intensity: sentimentResult.intensity || 0,
1169-
context: userInput.substring(0, 100), // First 100 chars
1170-
};
1171-
1172-
sentimentHistory.trends.push(trend);
1173-
1174-
// Keep only last N trends (configurable sliding window)
1175-
const historyWindow = stConfig?.historyWindow || 10;
1176-
if (sentimentHistory.trends.length > historyWindow) {
1177-
sentimentHistory.trends.shift();
1178-
}
1179-
1180-
// Update consecutive counters based on configurable thresholds
1181-
const frustrationThreshold = stConfig?.frustrationThreshold ?? -0.3;
1182-
const satisfactionThreshold = stConfig?.satisfactionThreshold ?? 0.3;
1183-
1184-
if (sentimentResult.score < frustrationThreshold) {
1185-
// Negative sentiment
1186-
sentimentHistory.consecutiveFrustration++;
1187-
sentimentHistory.consecutiveConfusion = 0;
1188-
sentimentHistory.consecutiveSatisfaction = 0;
1189-
} else if (sentimentResult.score > satisfactionThreshold) {
1190-
// Positive sentiment
1191-
sentimentHistory.consecutiveSatisfaction++;
1192-
sentimentHistory.consecutiveFrustration = 0;
1193-
sentimentHistory.consecutiveConfusion = 0;
1194-
} else {
1195-
// Neutral sentiment (or close to it)
1196-
sentimentHistory.consecutiveConfusion++;
1197-
sentimentHistory.consecutiveFrustration = 0;
1198-
sentimentHistory.consecutiveSatisfaction = 0;
1199-
}
1200-
1201-
sentimentHistory.lastAnalyzedTurnId = turnId;
1202-
1203-
// Store updated sentiment history
1204-
await this.workingMemory.set('gmi_sentiment_history', sentimentHistory);
1205-
1206-
this.addTraceEntry(
1207-
ReasoningEntryType.DEBUG,
1208-
'Turn sentiment analyzed',
1209-
{
1210-
sentiment: {
1211-
score: sentimentResult.score,
1212-
polarity: sentimentResult.polarity,
1213-
intensity: sentimentResult.intensity,
1214-
},
1215-
consecutiveCounters: {
1216-
frustration: sentimentHistory.consecutiveFrustration,
1217-
confusion: sentimentHistory.consecutiveConfusion,
1218-
satisfaction: sentimentHistory.consecutiveSatisfaction,
1219-
},
1220-
}
1221-
);
1222-
1223-
// Detect and emit events based on sentiment patterns
1224-
await this.detectAndEmitEvents(turnId, userInput, sentimentResult, sentimentHistory);
1225-
1226-
} catch (error: any) {
1227-
// Don't fail the turn if sentiment analysis fails - log and continue
1228-
console.error(`GMI (ID: ${this.gmiId}): Sentiment analysis error:`, error);
1229-
this.addTraceEntry(
1230-
ReasoningEntryType.WARNING,
1231-
'Sentiment analysis failed',
1232-
{ error: error.message }
1233-
);
1234-
}
1235-
}
1236-
1237-
/**
1238-
* Detects emotional patterns and emits appropriate GMI events.
1239-
*
1240-
* @param turnId - Current turn identifier
1241-
* @param userInput - User's input text
1242-
* @param sentimentResult - Sentiment analysis result
1243-
* @param sentimentHistory - Historical sentiment data
1244-
* @private
1245-
*/
1246-
private async detectAndEmitEvents(
1247-
turnId: string,
1248-
userInput: string,
1249-
sentimentResult: { score: number; polarity: 'positive' | 'negative' | 'neutral'; intensity?: number; negativeTokens?: any[] },
1250-
sentimentHistory: SentimentHistoryState
1251-
): Promise<void> {
1252-
// Read configurable thresholds
1253-
const stConfig = this.activePersona.sentimentTracking;
1254-
const frustThreshold = stConfig?.frustrationThreshold ?? -0.3;
1255-
const satisThreshold = stConfig?.satisfactionThreshold ?? 0.3;
1256-
const consecutiveRequired = stConfig?.consecutiveTurnsForTrigger ?? 2;
1257-
1258-
// Frustration detection
1259-
if (
1260-
(sentimentResult.score < frustThreshold && (sentimentResult.intensity || 0) > 0.6) ||
1261-
sentimentHistory.consecutiveFrustration >= consecutiveRequired
1262-
) {
1263-
this.emitEvent(
1264-
createGMIEvent(
1265-
GMIEventType.USER_FRUSTRATED,
1266-
turnId,
1267-
sentimentHistory.consecutiveFrustration >= 2 ? 'high' : 'medium',
1268-
{
1269-
sentimentScore: sentimentResult.score,
1270-
sentimentPolarity: sentimentResult.polarity,
1271-
sentimentIntensity: sentimentResult.intensity,
1272-
consecutiveTurns: sentimentHistory.consecutiveFrustration,
1273-
triggeredBy: 'sentiment',
1274-
}
1275-
)
1276-
);
1277-
}
1278-
1279-
// Confusion detection (keyword-based + sentiment)
1280-
const confusionKeywords = [
1281-
'confused',
1282-
"don't understand",
1283-
"dont understand",
1284-
'unclear',
1285-
'what do you mean',
1286-
'explain',
1287-
'clarify',
1288-
'huh',
1289-
'??',
1290-
"doesn't make sense",
1291-
"doesnt make sense",
1292-
'not sure',
1293-
];
1294-
1295-
const lowerInput = userInput.toLowerCase();
1296-
const hasConfusionKeyword = confusionKeywords.some((keyword) =>
1297-
lowerInput.includes(keyword)
1298-
);
1299-
const triggerKeywords = hasConfusionKeyword
1300-
? confusionKeywords.filter((keyword) => lowerInput.includes(keyword))
1301-
: [];
1302-
1303-
if (
1304-
hasConfusionKeyword ||
1305-
(sentimentResult.polarity === 'neutral' &&
1306-
sentimentResult.negativeTokens &&
1307-
sentimentResult.negativeTokens.length > 2)
1308-
) {
1309-
this.emitEvent(
1310-
createGMIEvent(
1311-
GMIEventType.USER_CONFUSED,
1312-
turnId,
1313-
sentimentHistory.consecutiveConfusion >= 2 ? 'high' : 'medium',
1314-
{
1315-
triggeredBy: hasConfusionKeyword ? 'keyword' : 'sentiment',
1316-
consecutiveTurns: sentimentHistory.consecutiveConfusion,
1317-
evidencePreview: userInput.substring(0, 100),
1318-
triggerKeywords: hasConfusionKeyword ? triggerKeywords : undefined,
1319-
}
1320-
)
1321-
);
1322-
}
1323-
1324-
// Satisfaction detection
1325-
if (
1326-
(sentimentResult.score > satisThreshold && (sentimentResult.intensity || 0) > 0.5) ||
1327-
sentimentHistory.consecutiveSatisfaction >= (consecutiveRequired + 1)
1328-
) {
1329-
this.emitEvent(
1330-
createGMIEvent(
1331-
GMIEventType.USER_SATISFIED,
1332-
turnId,
1333-
'low',
1334-
{
1335-
sentimentScore: sentimentResult.score,
1336-
sentimentPolarity: sentimentResult.polarity,
1337-
sentimentIntensity: sentimentResult.intensity,
1338-
consecutiveTurns: sentimentHistory.consecutiveSatisfaction,
1339-
triggeredBy: 'sentiment',
1340-
}
1341-
)
1342-
);
1343-
}
1344-
1345-
// Error threshold detection (check reasoning trace for recent errors)
1346-
const recentErrors = this.reasoningTrace.entries
1347-
.slice(-10)
1348-
.filter((entry) => entry.type === ReasoningEntryType.ERROR);
1349-
1350-
if (recentErrors.length >= 2) {
1351-
this.emitEvent(
1352-
createGMIEvent(
1353-
GMIEventType.ERROR_THRESHOLD_EXCEEDED,
1354-
turnId,
1355-
'high',
1356-
{
1357-
triggeredBy: 'error',
1358-
errorCount: recentErrors.length,
1359-
consecutiveTurns: recentErrors.length,
1360-
}
1361-
)
1362-
);
1363-
}
1364-
1365-
// Low engagement detection (consecutive neutral with short responses)
1366-
const recentUserMessages = this.conversationHistoryManager.history
1367-
.slice(-5)
1368-
.filter((msg) => msg.role === 'user');
1369-
1370-
const avgLength = recentUserMessages.length > 0
1371-
? recentUserMessages.reduce(
1372-
(sum, msg) => sum + String(msg.content).length,
1373-
0
1374-
) / recentUserMessages.length
1375-
: 0;
1376-
1377-
if (sentimentHistory.consecutiveConfusion >= 4 && avgLength < 50) {
1378-
this.emitEvent(
1379-
createGMIEvent(
1380-
GMIEventType.LOW_ENGAGEMENT,
1381-
turnId,
1382-
'medium',
1383-
{
1384-
triggeredBy: 'pattern',
1385-
consecutiveTurns: sentimentHistory.consecutiveConfusion,
1386-
evidencePreview: `Avg response length: ${avgLength.toFixed(0)} chars`,
1387-
}
1388-
)
1389-
);
1390-
}
1391-
}
1392-
1393-
/**
1394-
* Emits a GMI event and stores it in event history.
1395-
*
1396-
* @param event - The event to emit
1397-
* @private
1398-
*/
1399-
private emitEvent(event: GMIEvent): void {
1400-
// Add to pending events (will be consumed by trigger checking)
1401-
this.pendingGMIEvents.add(event.eventType);
1402-
1403-
// Add to event history for debugging (circular buffer, max 20)
1404-
this.eventHistory.push(event);
1405-
if (this.eventHistory.length > 20) {
1406-
this.eventHistory.shift();
1407-
}
1408-
1409-
this.addTraceEntry(
1410-
ReasoningEntryType.DEBUG,
1411-
`GMI Event Emitted: ${event.eventType}`,
1412-
{
1413-
event: {
1414-
eventType: event.eventType,
1415-
turnId: event.turnId,
1416-
severity: event.severity,
1417-
metadata: event.metadata,
1418-
},
1419-
}
1420-
);
1421-
}
1422-
14231136
/**
14241137
* Checks all metaprompt triggers (turn_interval, event_based, manual) and executes triggered ones.
14251138
*
@@ -1448,10 +1161,10 @@ export class GMI implements IGMI {
14481161
} else if (metaPrompt.trigger.type === 'event_based') {
14491162
// NEW: Event-based trigger checking
14501163
const eventName = metaPrompt.trigger.eventName;
1451-
if (this.pendingGMIEvents.has(eventName as GMIEventType)) {
1164+
if (this.sentimentTracker.pendingEvents.has(eventName as GMIEventType)) {
14521165
triggeredMetaPrompts.push(metaPrompt);
14531166
// Consume the event (remove from pending)
1454-
this.pendingGMIEvents.delete(eventName as GMIEventType);
1167+
this.sentimentTracker.pendingEvents.delete(eventName as GMIEventType);
14551168
}
14561169
} else if (metaPrompt.trigger.type === 'manual') {
14571170
// NEW: Manual trigger checking
@@ -1703,7 +1416,7 @@ export class GMI implements IGMI {
17031416
);
17041417

17051418
// Get the last event for confusion keywords
1706-
const lastConfusionEvent = this.eventHistory
1419+
const lastConfusionEvent = this.sentimentTracker.events
17071420
.slice()
17081421
.reverse()
17091422
.find((e) => e.eventType === GMIEventType.USER_CONFUSED);

0 commit comments

Comments
 (0)