diff --git a/src/ai-v2/ai-v2-ask.mapper.spec.ts b/src/ai-v2/ai-v2-ask.mapper.spec.ts index 3bf9037..098b616 100644 --- a/src/ai-v2/ai-v2-ask.mapper.spec.ts +++ b/src/ai-v2/ai-v2-ask.mapper.spec.ts @@ -1,6 +1,8 @@ import { Sender } from '../common/types/sender.type'; import { + buildBotMessage, isEscalationOffered, + normalizeSuggestedQuestions, resolveAskText, resolveSessionId, toAskCompatiblePayload, @@ -37,6 +39,7 @@ describe('ai-v2 ask mapper', () => { messageId: '64f1a2b3c4d5e6f7a8b9c0d2', runId: 'bea53a8d-3745-420f-a50d-fb700de5611d', status: 'escalation_offered', + suggested_questions: ['الف', ' الف ', '', null, 'ب'] as any, escalation: { summary: 'not in docs', handoff_context: {} }, isNewSession: true, }); @@ -44,5 +47,29 @@ describe('ai-v2 ask mapper', () => { expect(payload.messageId).toBe('64f1a2b3c4d5e6f7a8b9c0d2'); expect(payload.runId).toBe('bea53a8d-3745-420f-a50d-fb700de5611d'); expect(payload.offerOnlineChat).toBe(true); + expect(payload.suggested_questions).toEqual(['الف', 'ب']); + }); + + it('normalizes missing suggested_questions to an empty array', () => { + expect(normalizeSuggestedQuestions(undefined)).toEqual([]); + expect(normalizeSuggestedQuestions(null)).toEqual([]); + expect(normalizeSuggestedQuestions([])).toEqual([]); + expect(normalizeSuggestedQuestions('nope')).toEqual([]); + }); + + it('stores suggested_questions on the bot message only when non-empty', () => { + const withQuestions = buildBotMessage({ + text: 'پاسخ', + now: Date.now() / 1000, + suggested_questions: [' الف ', 'الف', 'ب'], + }); + expect(withQuestions.suggested_questions).toEqual(['الف', 'ب']); + + const without = buildBotMessage({ + text: 'پاسخ', + now: Date.now() / 1000, + suggested_questions: [], + }); + expect(without.suggested_questions).toBeUndefined(); }); }); diff --git a/src/ai-v2/ai-v2-ask.mapper.ts b/src/ai-v2/ai-v2-ask.mapper.ts index b38acd3..709a5e8 100644 --- a/src/ai-v2/ai-v2-ask.mapper.ts +++ b/src/ai-v2/ai-v2-ask.mapper.ts @@ -21,6 +21,7 @@ export type AskCompatiblePayload = { runId: string | null; status: 'answered' | 'escalation_offered' | 'ai_unavailable'; offerOnlineChat: boolean; + suggested_questions: string[]; escalation: AiV2RunResult['escalation']; }; @@ -33,6 +34,20 @@ export function resolveSessionId(sessionId?: string): string | undefined { return trimmed || undefined; } +export function normalizeSuggestedQuestions(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + const out: string[] = []; + for (const item of raw) { + if (typeof item !== 'string') continue; + const text = item.trim(); + if (!text || seen.has(text)) continue; + seen.add(text); + out.push(text); + } + return out; +} + export function isEscalationOffered(result: Pick): boolean { return result.status === 'escalation_offered'; } @@ -53,8 +68,12 @@ export function buildBotMessage(params: { now: number; runId?: string | null; status?: AskCompatiblePayload['status']; + suggested_questions?: string[]; escalation?: AiV2RunResult['escalation']; }) { + const suggested_questions = normalizeSuggestedQuestions( + params.suggested_questions, + ); return { messageId: new Types.ObjectId(), text: params.text, @@ -64,6 +83,8 @@ export function buildBotMessage(params: { createdAt: TimeHelper.unix2PersianTimeAndDate(params.now), createdISO: Date.now(), aiStatus: params.status, + suggested_questions: + suggested_questions.length > 0 ? suggested_questions : undefined, escalation: params.escalation || undefined, }; } @@ -80,6 +101,7 @@ export function toAskCompatiblePayload(params: { messageId: string; runId?: string | null; status: AskCompatiblePayload['status']; + suggested_questions?: unknown; escalation?: AiV2RunResult['escalation']; isNewSession: boolean; }): AskCompatiblePayload { @@ -100,6 +122,7 @@ export function toAskCompatiblePayload(params: { runId: params.runId ?? null, status: params.status, offerOnlineChat, + suggested_questions: normalizeSuggestedQuestions(params.suggested_questions), escalation: params.escalation ?? null, }; diff --git a/src/ai-v2/ai-v2-threads.controller.ts b/src/ai-v2/ai-v2-threads.controller.ts index fe522d4..0413a38 100644 --- a/src/ai-v2/ai-v2-threads.controller.ts +++ b/src/ai-v2/ai-v2-threads.controller.ts @@ -10,7 +10,7 @@ import { import { Request, Response } from 'express'; import { CurrentIdentity } from 'src/common/decorators/Identity.decorator'; import { AiV2Service } from './ai-v2.service'; -import { CreateFeedbackDto, CreateRunDto } from './dto/threads.dto'; +import { AskRunDataDto, CreateFeedbackDto, CreateRunDto } from './dto/threads.dto'; @ApiTags('v2 AI') @ApiBearerAuth() @@ -24,13 +24,16 @@ export class AiV2ThreadsController { description: 'Body: `{ question, sessionId? }`. Empty/omitted sessionId starts a new session. ' + 'We generate our own messageId and store the AI run_id on the bot message. ' + - 'When AI_V2_STREAM=true, emits SSE `meta` → `token*` → `result`.', + 'When AI_V2_STREAM=true, emits SSE `meta` → `token*` → `result`. ' + + '`suggested_questions` is on the JSON/SSE result payload and on the stored Bot message.', }) @ApiBody({ type: CreateRunDto }) @ApiProduces('application/json', 'text/event-stream') @ApiResponse({ status: 200, - description: 'Ask-compatible JSON, or SSE when AI_V2_STREAM=true.', + description: + 'Ask-compatible JSON (`data` includes `suggested_questions`), or SSE when AI_V2_STREAM=true. In stream mode the same object is the `event: result` data (after tokens).', + type: AskRunDataDto, }) async createRun( @Body() body: CreateRunDto, diff --git a/src/ai-v2/ai-v2.client.ts b/src/ai-v2/ai-v2.client.ts index ec8d10a..21c317f 100644 --- a/src/ai-v2/ai-v2.client.ts +++ b/src/ai-v2/ai-v2.client.ts @@ -9,6 +9,7 @@ export type AiV2RunResult = { run_id: string; status: 'answered' | 'escalation_offered'; message: string; + suggested_questions?: string[] | null; escalation: { summary?: string; handoff_context?: Record } | null; }; diff --git a/src/ai-v2/ai-v2.service.ts b/src/ai-v2/ai-v2.service.ts index 178770a..533271d 100644 --- a/src/ai-v2/ai-v2.service.ts +++ b/src/ai-v2/ai-v2.service.ts @@ -110,6 +110,7 @@ export class AiV2Service { now, runId, status, + suggested_questions: result?.suggested_questions, escalation: result?.escalation, }); await this.session.updateOne( @@ -146,6 +147,7 @@ export class AiV2Service { messageId: String(botMessage.messageId), runId, status, + suggested_questions: botMessage.suggested_questions, escalation: result?.escalation, isNewSession, }); diff --git a/src/ai-v2/dto/threads.dto.ts b/src/ai-v2/dto/threads.dto.ts index ec2b4d9..e771eb7 100644 --- a/src/ai-v2/dto/threads.dto.ts +++ b/src/ai-v2/dto/threads.dto.ts @@ -57,3 +57,45 @@ export class CreateFeedbackDto { @MaxLength(2000) comment?: string | null; } + +export class AskRunDataDto { + @ApiProperty({ example: '64f1a2b3c4d5e6f7a8b9c0d1' }) + sessionId: string; + + @ApiProperty({ example: '1/20' }) + count: string; + + @ApiProperty() + question: string; + + @ApiProperty({ description: 'Full assistant markdown. Always render this.' }) + answer: string; + + @ApiProperty({ type: 'array', items: { type: 'object' } }) + history: unknown[]; + + @ApiProperty({ + description: 'Our Mongo ObjectId for the new bot message. Use for feedback.', + }) + messageId: string; + + @ApiProperty({ nullable: true, description: 'Upstream AI run id. Ignore in UI.' }) + runId: string | null; + + @ApiProperty({ enum: ['answered', 'escalation_offered', 'ai_unavailable'] }) + status: 'answered' | 'escalation_offered' | 'ai_unavailable'; + + @ApiProperty() + offerOnlineChat: boolean; + + @ApiProperty({ + type: [String], + example: ['چگونه وارد پورتال درمانت شوم؟', 'چطور درخواست معرفی‌نامه ثبت کنم؟'], + description: + 'Clickable follow-up questions for this assistant turn. Always an array; may be empty. Also stored on the Bot message in history as suggested_questions.', + }) + suggested_questions: string[]; + + @ApiProperty({ nullable: true }) + escalation: { summary?: string; handoff_context?: Record } | null; +} diff --git a/src/database/model/sessions.model.ts b/src/database/model/sessions.model.ts index 723a34b..4320b94 100644 --- a/src/database/model/sessions.model.ts +++ b/src/database/model/sessions.model.ts @@ -67,6 +67,7 @@ export class SessionModel extends Document { react: { type: String, default: 'Nothing' }, runId: { type: String, required: false }, aiStatus: { type: String, required: false }, + suggested_questions: { type: [String], required: false, default: undefined }, escalation: { type: Object, required: false }, createdAt: { type: [String], required: true }, // [time, date] createdISO: { type: Date, required: true }, @@ -104,6 +105,7 @@ export class SessionModel extends Document { edited: boolean; runId?: string; aiStatus?: string; + suggested_questions?: string[]; escalation?: { summary?: string; handoff_context?: Record;