Skip to content

Commit 4e80c37

Browse files
Fix chat name generation (#1719)
<!-- Ensure the title clearly reflects what was changed. Provide a clear and concise description of the changes made. The PR should only contain the changes related to the issue, and no other unrelated changes. --> Fixes OPS-3173. Deployed on UX The idea is to let LLM decided whether it has enough context or no. In case AI decides that name is generated we set it as chat name. In case there is no enough data, we wait for the next LLM response and try to generate the name once again <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Chat name generation now includes an `isGenerated` flag to indicate whether a name was successfully generated or a default was used. * **Bug Fixes** * Improved chat name generation reliability with enhanced validation and structured output handling. * **Chores** * Updated chat name API response format from `chatName` to include `name` and `isGenerated` fields for better clarity on generation status. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent e8da859 commit 4e80c37

File tree

10 files changed

+310
-86
lines changed

10 files changed

+310
-86
lines changed

ai-prompts/chat-name.txt

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
You are an AI assistant on the OpenOps platform, where users interact about FinOps, cloud providers (AWS, Azure, GCP), OpenOps features, and workflow automation.
22

3-
Your task:
4-
Given the following conversation, suggest a short, descriptive name (max five words) that best summarizes the main topic, question, or action discussed in this chat.
3+
Task:
4+
Analyze the provided conversation and attempt to produce a concise chat name describing the main topic, question, or action.
55

6-
Guidelines:
7-
- The name should be specific (not generic like "Chat" or "Conversation"), and reflect the user's intent (e.g., "AWS Cost Optimization", "Create Budget Workflow", "OpenOps Integration Help").
8-
- Limit the name to five words or less.
9-
- Respond with only the chat name.
6+
Rules:
7+
- If you can confidently produce a specific, helpful name (not generic like "Chat" or "Conversation"), set `isGenerated` to true and provide `name`.
8+
- The `name` must be five words or fewer
9+
- If there is insufficient information, the content is unclear, or you cannot determine a good name, set `isGenerated` to false.
10+
11+
Notes:
12+
- Keep the name short and specific.
13+
- Avoid quotes, punctuation-heavy outputs, or trailing spaces in the name.

packages/react-ui/src/app/features/ai/lib/ai-assistant-chat-history-api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { api } from '@/app/lib/api';
2-
import { ListChatsResponse } from '@openops/shared';
2+
import { GeneratedChatName, ListChatsResponse } from '@openops/shared';
33

44
export const aiAssistantChatHistoryApi = {
55
list() {
@@ -9,7 +9,7 @@ export const aiAssistantChatHistoryApi = {
99
return api.delete<void>(`/v1/ai/conversation/${chatId}`);
1010
},
1111
generateName(chatId: string) {
12-
return api.post<{ chatName: string }>('/v1/ai/conversation/chat-name', {
12+
return api.post<GeneratedChatName>('/v1/ai/conversation/chat-name', {
1313
chatId,
1414
});
1515
},

packages/react-ui/src/app/features/ai/lib/assistant-ui-chat-hook.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,10 @@ export const useAssistantChat = ({
243243
if (messagesRef.current.length >= MIN_MESSAGES_BEFORE_NAME_GENERATION) {
244244
try {
245245
hasAttemptedNameGenerationRef.current[chatId] = true;
246-
await aiAssistantChatHistoryApi.generateName(chatId);
246+
const result = await aiAssistantChatHistoryApi.generateName(chatId);
247+
if (!result.isGenerated) {
248+
hasAttemptedNameGenerationRef.current[chatId] = false;
249+
}
247250
qc.invalidateQueries({ queryKey: [QueryKeys.assistantHistory] });
248251
} catch (error) {
249252
console.error('Failed to generate chat name', error);

packages/server/api/src/app/ai/chat/ai-chat.service.ts

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,35 @@ import {
22
AiAuth,
33
getAiModelFromConnection,
44
getAiProviderLanguageModel,
5+
isLLMTelemetryEnabled,
56
} from '@openops/common';
67
import {
78
AppSystemProp,
89
cacheWrapper,
910
hashUtils,
11+
logger,
1012
system,
1113
} from '@openops/server-shared';
1214
import {
1315
AiConfigParsed,
1416
ApplicationError,
1517
CustomAuthConnectionValue,
1618
ErrorCode,
19+
GeneratedChatName,
20+
isEmpty,
1721
removeConnectionBrackets,
1822
} from '@openops/shared';
19-
import { LanguageModel, ModelMessage, UIMessage, generateText } from 'ai';
23+
import { generateObject, LanguageModel, ModelMessage, UIMessage } from 'ai';
24+
import { z } from 'zod';
2025
import { appConnectionService } from '../../app-connection/app-connection-service/app-connection-service';
2126
import { aiConfigService } from '../config/ai-config.service';
27+
import { findFirstKeyInObject } from '../mcp/llm-query-router';
2228
import { loadPrompt } from './prompts.service';
2329
import { Conversation } from './types';
24-
import { mergeToolResultsIntoMessages } from './utils';
30+
import {
31+
mergeToolResultsIntoMessages,
32+
sanitizeMessagesForChatName,
33+
} from './utils';
2534

2635
const chatContextKey = (
2736
chatId: string,
@@ -84,28 +93,74 @@ export const generateChatIdForMCP = (params: {
8493
});
8594
};
8695

96+
const generatedChatNameSchema = z.object({
97+
name: z
98+
.string()
99+
.max(100)
100+
.nullable()
101+
.describe('Conversation name or null if it was not generated'),
102+
isGenerated: z.boolean().describe('Whether the name was generated or not'),
103+
});
104+
105+
/**
106+
* Attempts to repair a malformed JSON string produced by the model for chat name generation.
107+
* It extracts only the expected fields according to generatedChatNameSchema.
108+
* Returns null if the input cannot be parsed or repaired (so the AI SDK can retry/throw).
109+
*/
110+
const repairText = (text: string): string | null => {
111+
try {
112+
const parsed = JSON.parse(text);
113+
114+
const nameRaw = findFirstKeyInObject(parsed, 'name');
115+
let name: string | null = null;
116+
if (typeof nameRaw === 'string') {
117+
const trimmed = nameRaw.trim();
118+
name = trimmed.length > 0 ? trimmed.slice(0, 100) : null;
119+
}
120+
121+
const isGeneratedRaw = findFirstKeyInObject(parsed, 'isGenerated');
122+
const isGenerated =
123+
typeof isGeneratedRaw === 'boolean' ? isGeneratedRaw : Boolean(name);
124+
125+
return JSON.stringify({ name, isGenerated });
126+
} catch {
127+
return null;
128+
}
129+
};
130+
87131
export async function generateChatName(
88132
messages: ModelMessage[],
89133
projectId: string,
90-
): Promise<string> {
91-
const { languageModel } = await getLLMConfig(projectId);
134+
): Promise<GeneratedChatName> {
135+
const { languageModel, aiConfig } = await getLLMConfig(projectId);
92136
const systemPrompt = await loadPrompt('chat-name.txt');
93137
if (!systemPrompt.trim()) {
94138
throw new Error('Failed to load prompt to generate the chat name.');
95139
}
96-
const prompt: ModelMessage[] = [
97-
{
98-
role: 'system',
99-
content: systemPrompt,
100-
} as const,
101-
...messages,
102-
];
103-
const response = await generateText({
104-
model: languageModel,
105-
messages: prompt,
106-
maxRetries: 2,
107-
});
108-
return response.text.trim();
140+
141+
const sanitizedMessages: ModelMessage[] =
142+
sanitizeMessagesForChatName(messages);
143+
144+
if (isEmpty(sanitizedMessages)) {
145+
return { name: null, isGenerated: false };
146+
}
147+
148+
try {
149+
const result = await generateObject({
150+
model: languageModel,
151+
system: systemPrompt,
152+
messages: sanitizedMessages,
153+
schema: generatedChatNameSchema,
154+
...aiConfig.modelSettings,
155+
experimental_telemetry: { isEnabled: isLLMTelemetryEnabled() },
156+
experimental_repairText: async ({ text }) => repairText(text),
157+
maxRetries: 2,
158+
});
159+
return result.object;
160+
} catch (error) {
161+
logger.error('Failed to generate chat name', { error });
162+
return { name: null, isGenerated: false };
163+
}
109164
}
110165

111166
export const updateChatName = async (

packages/server/api/src/app/ai/chat/ai-mcp-chat.controller.ts

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
UpdateChatModelRequest,
2323
UpdateChatModelResponse,
2424
} from '@openops/shared';
25-
import { ModelMessage, UserModelMessage } from 'ai';
25+
import { ModelMessage } from 'ai';
2626
import { FastifyReply } from 'fastify';
2727
import { StatusCodes } from 'http-status-codes';
2828
import removeMarkdown from 'markdown-to-text';
@@ -248,27 +248,30 @@ export const aiMCPChatController: FastifyPluginAsyncTypebox = async (app) => {
248248
const { chatHistory } = await getConversation(chatId, userId, projectId);
249249

250250
if (chatHistory.length === 0) {
251-
return await reply.code(200).send({ chatName: DEFAULT_CHAT_NAME });
251+
return await reply
252+
.code(200)
253+
.send({ name: DEFAULT_CHAT_NAME, isGenerated: false });
252254
}
253255

254-
const userMessages = chatHistory.filter(
255-
(msg): msg is UserModelMessage =>
256-
msg &&
257-
typeof msg === 'object' &&
258-
'role' in msg &&
259-
msg.role === 'user',
260-
);
256+
const generated = await generateChatName(chatHistory, projectId);
261257

262-
if (userMessages.length === 0) {
263-
return await reply.code(200).send({ chatName: DEFAULT_CHAT_NAME });
258+
if (!generated.isGenerated) {
259+
return await reply
260+
.code(200)
261+
.send({ name: DEFAULT_CHAT_NAME, isGenerated: false });
264262
}
265263

266-
const rawChatName = await generateChatName(userMessages, projectId);
267-
const chatName = removeMarkdown(rawChatName).trim() || DEFAULT_CHAT_NAME;
264+
const chatName = generated?.name
265+
? removeMarkdown(generated.name).trim()
266+
: DEFAULT_CHAT_NAME;
268267

269-
await updateChatName(chatId, userId, projectId, chatName);
268+
if (generated.isGenerated && generated.name) {
269+
await updateChatName(chatId, userId, projectId, chatName);
270+
}
270271

271-
return await reply.code(200).send({ chatName });
272+
return await reply
273+
.code(200)
274+
.send({ name: chatName, isGenerated: generated.isGenerated });
272275
} catch (error) {
273276
return handleError(error, reply, 'generate chat name');
274277
}

packages/server/api/src/app/ai/chat/utils.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,57 @@ export function mergeToolResultsIntoMessages(
5252
return uiMessages;
5353
}
5454

55+
/**
56+
* Sanitize chat history for secondary tasks like naming/summarization.
57+
* - keeps only 'user' and 'assistant' roles
58+
* - strips tool calls and non-text parts
59+
* - merges multiple text parts into a single string with newlines
60+
*/
61+
export function sanitizeMessagesForChatName(
62+
messages: ModelMessage[],
63+
): ModelMessage[] {
64+
const isSupportedRole = (m: ModelMessage) =>
65+
m.role === 'user' || m.role === 'assistant';
66+
67+
const extractText = (content: ModelMessage['content']): string | null => {
68+
if (typeof content === 'string') {
69+
const text = content.trim();
70+
return text ?? null;
71+
}
72+
73+
if (Array.isArray(content)) {
74+
const merged = (content as Array<unknown>)
75+
.reduce<string[]>((acc, part) => {
76+
if (
77+
part &&
78+
typeof part === 'object' &&
79+
'type' in (part as Record<string, unknown>)
80+
) {
81+
const p = part as { type?: string; text?: string };
82+
if (p.type === 'text' && typeof p.text === 'string') {
83+
acc.push(p.text);
84+
}
85+
}
86+
return acc;
87+
}, [])
88+
.join('\n')
89+
.trim();
90+
91+
return merged ?? null;
92+
}
93+
94+
return null;
95+
};
96+
97+
return messages
98+
.filter(isSupportedRole)
99+
.map((m) => {
100+
const text = extractText(m.content);
101+
return text ? ({ role: m.role, content: text } as ModelMessage) : null;
102+
})
103+
.filter((m): m is ModelMessage => m !== null);
104+
}
105+
55106
function isToolMessage(msg: ModelMessage): boolean {
56107
return (
57108
msg.role === 'tool' && Array.isArray(msg.content) && msg.content.length > 0

packages/server/api/src/app/ai/mcp/llm-query-router.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ const getSystemPrompt = async (
222222
* @param targetKey - The key to search for.
223223
* @returns The value of the first key in the object.
224224
*/
225-
function findFirstKeyInObject(
225+
export function findFirstKeyInObject(
226226
obj: Record<string, unknown>,
227227
targetKey: string,
228228
): unknown {

0 commit comments

Comments
 (0)