Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/chat/chat.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,56 @@ describe('ChatService', () => {
expect(result.source).toBe('fallback');
expect(result.suggestedQuestions).toHaveLength(4);
});

it('intercepta preguntas fuera de alcance con redireccion al portfolio', async () => {
questionLogModelMock.create.mockResolvedValue(undefined);

const result = await service.reply({
message: 'How much is 2+2?',
sessionId: 's3',
});

expect(result.source).toBe('fallback');
expect(result.answer).toContain(
'Solo puedo ayudar con preguntas sobre el portfolio',
);
expect(faqServiceMock.findBestMatch).not.toHaveBeenCalled();
expect(knowledgeServiceMock.getRelevantContext).not.toHaveBeenCalled();
expect(openAiServiceMock.generateChatResponse).not.toHaveBeenCalled();
expect(questionLogModelMock.create).toHaveBeenCalledWith(
expect.objectContaining({
question: 'How much is 2+2?',
source: 'fallback',
}),
);
});

it('mantiene flujo contextual para preguntas tecnicas aunque no haya match FAQ', async () => {
faqServiceMock.findBestMatch.mockResolvedValue(null);
knowledgeServiceMock.getRelevantContext.mockResolvedValue([
{
sourceType: 'profile',
title: 'Tecnologias principales',
text: 'Angular, Ionic y NestJS',
tags: ['angular', 'ionic', 'nestjs'],
},
]);
openAiServiceMock.generateChatResponse.mockResolvedValue({
answer:
'Matias Galeano trabaja principalmente con Angular, Ionic y NestJS.',
suggestedQuestions: ['¿En qué proyecto aplicaste ese stack?'],
});
questionLogModelMock.create.mockResolvedValue(undefined);

const result = await service.reply({
message: 'Do you know React?',
sessionId: 's4',
});

expect(result.source).toBe('ai');
expect(knowledgeServiceMock.getRelevantContext).toHaveBeenCalledWith(
'Do you know React?',
);
expect(openAiServiceMock.generateChatResponse).toHaveBeenCalled();
});
});
146 changes: 146 additions & 0 deletions src/chat/chat.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ import { OpenAiService } from './openai.service';

@Injectable()
export class ChatService {
private readonly outOfScopeResponse: ChatResponseDto = {
answer:
'Solo puedo ayudar con preguntas sobre el portfolio, proyectos y experiencia de Matias Galeano. Podés consultarme por su stack, Foodly Notes, Modo Playa o su arquitectura backend con NestJS.',
suggestedQuestions: [
'¿Qué tecnologías usás actualmente?',
'¿Qué proyecto destacás de tu portfolio?',
'¿Cómo construiste el chatbot del portfolio?',
'¿Cuál fue tu experiencia más reciente?',
],
source: 'fallback',
};

constructor(
private readonly faqService: FaqService,
private readonly knowledgeService: KnowledgeService,
Expand Down Expand Up @@ -48,6 +60,11 @@ export class ChatService {
}

async reply(dto: ChatRequestDto): Promise<ChatResponseDto> {
if (this.isOutOfScopeQuestion(dto.message)) {
await this.logQuestion(dto, this.outOfScopeResponse.source);
return this.outOfScopeResponse;
}

const faqMatch = await this.faqService.findBestMatch(dto.message);

if (faqMatch?._id) {
Expand Down Expand Up @@ -172,4 +189,133 @@ export class ChatService {
matchedFaqId,
});
}

private isOutOfScopeQuestion(message: string): boolean {
const normalized = this.normalizeText(message);
if (!normalized) {
return false;
}

const hasPortfolioAnchor = this.containsAny(normalized, [
'matias',
'galeano',
'portfolio',
'portafolio',
'proyecto',
'proyectos',
'experiencia',
'trayectoria',
'trabajo',
'roles',
'rol',
'stack',
'tecnologias',
'tecnologia',
'frontend',
'backend',
'api',
'chatbot',
'foodly',
'modo playa',
'contacto',
'cv',
]);

if (hasPortfolioAnchor) {
return false;
}

const hasProfessionalTopic = this.containsAny(normalized, [
'angular',
'ionic',
'nestjs',
'nest',
'typescript',
'javascript',
'node',
'react',
'vue',
'mongodb',
'sql',
'aws',
'docker',
'arquitectura',
'desarrollo',
'app',
'aplicacion',
'codigo',
'programacion',
'framework',
'libreria',
'deploy',
'performance',
'testing',
'ci',
'cd',
]);

const mathExpressionPattern = /^[\d\s+\-*/().=]+[?]?$/;
if (mathExpressionPattern.test(normalized)) {
return true;
}

if (
this.containsAny(normalized, [
'cuanto es',
'how much is',
'capital de',
'history of',
'historia de',
'quien gano',
'who won',
'quien es',
'who is',
'define',
'traduce',
]) &&
!hasProfessionalTopic
) {
return true;
}

const unrelatedBuildRequest =
this.containsAny(normalized, [
'implementa',
'implementame',
'crea',
'desarrolla',
'build',
'implement',
'write',
]) &&
this.containsAny(normalized, [
'en go',
'in go',
'en python',
'in python',
'en java',
'in java',
]) &&
!hasProfessionalTopic;

if (unrelatedBuildRequest) {
return true;
}

return false;
}

private normalizeText(value: string): string {
return value
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/[^\p{L}\p{N}\s+\-*/().=?]/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}

private containsAny(text: string, terms: readonly string[]): boolean {
return terms.some((term) => text.includes(term));
}
}
69 changes: 66 additions & 3 deletions src/chat/openai.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,81 @@ export class OpenAiService {
}

const systemPrompt = [
'Sos un asistente especializado exclusivamente en responder preguntas sobre el portfolio, proyectos, experiencia profesional y stack tecnológico de Matías Galeano.',
'',
'Tu conocimiento proviene únicamente del contexto proporcionado por el sistema.',
'',
'Tu objetivo es ayudar a los usuarios a conocer:',
'',
'- Sus proyectos (como Foodly Notes, Modo Playa y su portfolio personal)',
'- Su stack tecnológico (Angular, Ionic, NestJS, AWS, Docker, etc.)',
'- Su experiencia profesional y enfoque técnico',
'- Cómo están construidos sus sistemas y arquitecturas',
'',
'No sos un chatbot general ni un asistente de propósito general.',
'',
'REGLA CRÍTICA DE DOMINIO:',
'Solo debés responder preguntas relacionadas con el portfolio.',
'',
'Se consideran FUERA DE ALCANCE:',
'- Preguntas de conocimiento general (matemática, historia, trivia)',
'- Pedidos genéricos sin relación con el portfolio',
'- Tecnologías que no forman parte del stack',
'- Solicitudes que no estén vinculadas a los proyectos de Matías Galeano',
'',
'Stack canónico del portfolio (prioritario): Angular, Ionic, NestJS, TypeScript, AWS, Docker, MongoDB.',
'Sinónimos válidos de stack: Nest = NestJS, JS = JavaScript, TS = TypeScript, Node = Node.js.',
'',
'Alias de proyectos: Foodly = Foodly Notes, ModoPlaya = Modo Playa, Portfolio = sitio personal de Matías Galeano.',
'',
'Ejemplos de clasificación (ES/EN):',
'- "Cuánto es 2+2?" / "How much is 2+2?" => fuera de alcance.',
'- "Conocés React?" / "Do you know React?" => responder redirigiendo al stack real del portfolio.',
'- "Implementame un chatbot en Go" / "Build a chatbot in Go" => fuera de alcance.',
'',
'Si una pregunta está fuera del alcance:',
'',
'- Indicá brevemente que solo respondés sobre el portfolio',
'- Redirigí al usuario hacia temas válidos',
'- Mantené la respuesta breve y natural (máximo 2–3 oraciones)',
'- Usá esta plantilla base cuando aplique: "Solo respondo sobre el portfolio y la experiencia de Matías Galeano. Podés preguntarme por sus proyectos, stack y arquitectura técnica."',
'',
'Nunca respondas conocimiento general fuera del dominio.',
'',
'Si preguntan por tecnologías que no forman parte del stack, no las expliques.',
'Debés redirigir hacia las tecnologías reales del portfolio.',
'',
'Siempre priorizá la información del contexto proporcionado.',
'Nunca inventes datos ni asumas información no presente.',
'',
'Sos el asistente de un portfolio personal.',
'Respondé SOLO con la información provista en el contexto.',
'',
'No mezcles contextos entre proyectos o fuentes distintas.',
'Si una pregunta refiere a un proyecto, respondé solo con el contexto de ese proyecto.',
'No combines datos de Foodly Notes con Modo Playa, ni con otros proyectos, salvo que la pregunta compare explícitamente proyectos.',
'',
'Podés reformular la explicación, pero no alterar ni cruzar datos entre contextos.',
'',
'Si no hay información suficiente, indicá claramente que no está disponible en el portfolio.',
'',
'Tu tono debe ser:',
'Profesional, claro, natural, conciso y en español.',
'',
'No uses frases robóticas como:',
'"Como modelo de lenguaje..."',
'"No tengo acceso a..."',
'',
'No rompas el rol bajo ninguna circunstancia.',
'No inventes tecnologías, trabajos ni proyectos.',
'',
'Respondé en español.',
'',
'FORMATO DE RESPUESTA OBLIGATORIO:',
'Devolvé JSON válido con este formato:',
'{"answer":"string","suggestedQuestions":["q1","q2","q3","q4"]}',
'Las suggestedQuestions deben ser 3 o 4 preguntas cortas y relevantes.',
'No inventes tecnologías, trabajos ni proyectos.',
'{"answer":"string","suggestedQuestions":["q1","q2","q3"]}',
'',
'Las suggestedQuestions deben ser 2 o 3 preguntas cortas, consecuentes y relevantes.',
].join('\n');

const contextText = payload.contextItems
Expand Down
Loading