forked from Chatbot/v3-api
Merge pull request 'main' (#2) from s.hajizadeh/v3-api:main into main
Reviewed-on: Chatbot/v3-api#2
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import { Sender } from '../common/types/sender.type';
|
import { Sender } from '../common/types/sender.type';
|
||||||
import {
|
import {
|
||||||
|
buildBotMessage,
|
||||||
isEscalationOffered,
|
isEscalationOffered,
|
||||||
|
normalizeSuggestedQuestions,
|
||||||
resolveAskText,
|
resolveAskText,
|
||||||
resolveSessionId,
|
resolveSessionId,
|
||||||
toAskCompatiblePayload,
|
toAskCompatiblePayload,
|
||||||
@@ -37,6 +39,7 @@ describe('ai-v2 ask mapper', () => {
|
|||||||
messageId: '64f1a2b3c4d5e6f7a8b9c0d2',
|
messageId: '64f1a2b3c4d5e6f7a8b9c0d2',
|
||||||
runId: 'bea53a8d-3745-420f-a50d-fb700de5611d',
|
runId: 'bea53a8d-3745-420f-a50d-fb700de5611d',
|
||||||
status: 'escalation_offered',
|
status: 'escalation_offered',
|
||||||
|
suggested_questions: ['الف', ' الف ', '', null, 'ب'] as any,
|
||||||
escalation: { summary: 'not in docs', handoff_context: {} },
|
escalation: { summary: 'not in docs', handoff_context: {} },
|
||||||
isNewSession: true,
|
isNewSession: true,
|
||||||
});
|
});
|
||||||
@@ -44,5 +47,29 @@ describe('ai-v2 ask mapper', () => {
|
|||||||
expect(payload.messageId).toBe('64f1a2b3c4d5e6f7a8b9c0d2');
|
expect(payload.messageId).toBe('64f1a2b3c4d5e6f7a8b9c0d2');
|
||||||
expect(payload.runId).toBe('bea53a8d-3745-420f-a50d-fb700de5611d');
|
expect(payload.runId).toBe('bea53a8d-3745-420f-a50d-fb700de5611d');
|
||||||
expect(payload.offerOnlineChat).toBe(true);
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type AskCompatiblePayload = {
|
|||||||
runId: string | null;
|
runId: string | null;
|
||||||
status: 'answered' | 'escalation_offered' | 'ai_unavailable';
|
status: 'answered' | 'escalation_offered' | 'ai_unavailable';
|
||||||
offerOnlineChat: boolean;
|
offerOnlineChat: boolean;
|
||||||
|
suggested_questions: string[];
|
||||||
escalation: AiV2RunResult['escalation'];
|
escalation: AiV2RunResult['escalation'];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,6 +34,20 @@ export function resolveSessionId(sessionId?: string): string | undefined {
|
|||||||
return trimmed || 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 {
|
export function isEscalationOffered(result: Pick<AiV2RunResult, 'status'>): boolean {
|
||||||
return result.status === 'escalation_offered';
|
return result.status === 'escalation_offered';
|
||||||
}
|
}
|
||||||
@@ -53,8 +68,12 @@ export function buildBotMessage(params: {
|
|||||||
now: number;
|
now: number;
|
||||||
runId?: string | null;
|
runId?: string | null;
|
||||||
status?: AskCompatiblePayload['status'];
|
status?: AskCompatiblePayload['status'];
|
||||||
|
suggested_questions?: string[];
|
||||||
escalation?: AiV2RunResult['escalation'];
|
escalation?: AiV2RunResult['escalation'];
|
||||||
}) {
|
}) {
|
||||||
|
const suggested_questions = normalizeSuggestedQuestions(
|
||||||
|
params.suggested_questions,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
messageId: new Types.ObjectId(),
|
messageId: new Types.ObjectId(),
|
||||||
text: params.text,
|
text: params.text,
|
||||||
@@ -64,6 +83,8 @@ export function buildBotMessage(params: {
|
|||||||
createdAt: TimeHelper.unix2PersianTimeAndDate(params.now),
|
createdAt: TimeHelper.unix2PersianTimeAndDate(params.now),
|
||||||
createdISO: Date.now(),
|
createdISO: Date.now(),
|
||||||
aiStatus: params.status,
|
aiStatus: params.status,
|
||||||
|
suggested_questions:
|
||||||
|
suggested_questions.length > 0 ? suggested_questions : undefined,
|
||||||
escalation: params.escalation || undefined,
|
escalation: params.escalation || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -80,6 +101,7 @@ export function toAskCompatiblePayload(params: {
|
|||||||
messageId: string;
|
messageId: string;
|
||||||
runId?: string | null;
|
runId?: string | null;
|
||||||
status: AskCompatiblePayload['status'];
|
status: AskCompatiblePayload['status'];
|
||||||
|
suggested_questions?: unknown;
|
||||||
escalation?: AiV2RunResult['escalation'];
|
escalation?: AiV2RunResult['escalation'];
|
||||||
isNewSession: boolean;
|
isNewSession: boolean;
|
||||||
}): AskCompatiblePayload {
|
}): AskCompatiblePayload {
|
||||||
@@ -100,6 +122,7 @@ export function toAskCompatiblePayload(params: {
|
|||||||
runId: params.runId ?? null,
|
runId: params.runId ?? null,
|
||||||
status: params.status,
|
status: params.status,
|
||||||
offerOnlineChat,
|
offerOnlineChat,
|
||||||
|
suggested_questions: normalizeSuggestedQuestions(params.suggested_questions),
|
||||||
escalation: params.escalation ?? null,
|
escalation: params.escalation ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { CurrentIdentity } from 'src/common/decorators/Identity.decorator';
|
import { CurrentIdentity } from 'src/common/decorators/Identity.decorator';
|
||||||
import { AiV2Service } from './ai-v2.service';
|
import { AiV2Service } from './ai-v2.service';
|
||||||
import { CreateFeedbackDto, CreateRunDto } from './dto/threads.dto';
|
import { AskRunDataDto, CreateFeedbackDto, CreateRunDto } from './dto/threads.dto';
|
||||||
|
|
||||||
@ApiTags('v2 AI')
|
@ApiTags('v2 AI')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -24,13 +24,16 @@ export class AiV2ThreadsController {
|
|||||||
description:
|
description:
|
||||||
'Body: `{ question, sessionId? }`. Empty/omitted sessionId starts a new session. ' +
|
'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. ' +
|
'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 })
|
@ApiBody({ type: CreateRunDto })
|
||||||
@ApiProduces('application/json', 'text/event-stream')
|
@ApiProduces('application/json', 'text/event-stream')
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
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(
|
async createRun(
|
||||||
@Body() body: CreateRunDto,
|
@Body() body: CreateRunDto,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type AiV2RunResult = {
|
|||||||
run_id: string;
|
run_id: string;
|
||||||
status: 'answered' | 'escalation_offered';
|
status: 'answered' | 'escalation_offered';
|
||||||
message: string;
|
message: string;
|
||||||
|
suggested_questions?: string[] | null;
|
||||||
escalation: { summary?: string; handoff_context?: Record<string, unknown> } | null;
|
escalation: { summary?: string; handoff_context?: Record<string, unknown> } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export class AiV2Service {
|
|||||||
now,
|
now,
|
||||||
runId,
|
runId,
|
||||||
status,
|
status,
|
||||||
|
suggested_questions: result?.suggested_questions,
|
||||||
escalation: result?.escalation,
|
escalation: result?.escalation,
|
||||||
});
|
});
|
||||||
await this.session.updateOne(
|
await this.session.updateOne(
|
||||||
@@ -146,6 +147,7 @@ export class AiV2Service {
|
|||||||
messageId: String(botMessage.messageId),
|
messageId: String(botMessage.messageId),
|
||||||
runId,
|
runId,
|
||||||
status,
|
status,
|
||||||
|
suggested_questions: botMessage.suggested_questions,
|
||||||
escalation: result?.escalation,
|
escalation: result?.escalation,
|
||||||
isNewSession,
|
isNewSession,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,3 +57,45 @@ export class CreateFeedbackDto {
|
|||||||
@MaxLength(2000)
|
@MaxLength(2000)
|
||||||
comment?: string | null;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export class SessionModel extends Document {
|
|||||||
react: { type: String, default: 'Nothing' },
|
react: { type: String, default: 'Nothing' },
|
||||||
runId: { type: String, required: false },
|
runId: { type: String, required: false },
|
||||||
aiStatus: { type: String, required: false },
|
aiStatus: { type: String, required: false },
|
||||||
|
suggested_questions: { type: [String], required: false, default: undefined },
|
||||||
escalation: { type: Object, required: false },
|
escalation: { type: Object, required: false },
|
||||||
createdAt: { type: [String], required: true }, // [time, date]
|
createdAt: { type: [String], required: true }, // [time, date]
|
||||||
createdISO: { type: Date, required: true },
|
createdISO: { type: Date, required: true },
|
||||||
@@ -104,6 +105,7 @@ export class SessionModel extends Document {
|
|||||||
edited: boolean;
|
edited: boolean;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
aiStatus?: string;
|
aiStatus?: string;
|
||||||
|
suggested_questions?: string[];
|
||||||
escalation?: {
|
escalation?: {
|
||||||
summary?: string;
|
summary?: string;
|
||||||
handoff_context?: Record<string, unknown>;
|
handoff_context?: Record<string, unknown>;
|
||||||
|
|||||||
Reference in New Issue
Block a user