Merge pull request 'main' (#2) from s.hajizadeh/v3-api:main into main

Reviewed-on: Chatbot/v3-api#2
This commit is contained in:
2026-09-09 16:42:50 +03:30
7 changed files with 103 additions and 3 deletions

View File

@@ -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();
});
});

View File

@@ -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<string>();
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<AiV2RunResult, 'status'>): 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,
};

View File

@@ -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,

View File

@@ -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<string, unknown> } | null;
};

View File

@@ -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,
});

View File

@@ -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<string, unknown> } | null;
}

View File

@@ -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<string, unknown>;