@hed-hog/core 0.0.400 → 0.0.401
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/ai.controller.d.ts +2 -0
- package/dist/ai/ai.controller.d.ts.map +1 -1
- package/dist/ai/ai.service.d.ts +86 -2
- package/dist/ai/ai.service.d.ts.map +1 -1
- package/dist/ai/ai.service.js +304 -23
- package/dist/ai/ai.service.js.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +3 -0
- package/dist/core.module.js.map +1 -1
- package/dist/document-kit/document-kit.module.d.ts +3 -0
- package/dist/document-kit/document-kit.module.d.ts.map +1 -0
- package/dist/document-kit/document-kit.module.js +24 -0
- package/dist/document-kit/document-kit.module.js.map +1 -0
- package/dist/document-kit/document-kit.registry.d.ts +55 -0
- package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
- package/dist/document-kit/document-kit.registry.js +91 -0
- package/dist/document-kit/document-kit.registry.js.map +1 -0
- package/dist/document-kit/document-kit.service.d.ts +49 -0
- package/dist/document-kit/document-kit.service.d.ts.map +1 -0
- package/dist/document-kit/document-kit.service.js +135 -0
- package/dist/document-kit/document-kit.service.js.map +1 -0
- package/dist/file/file.service.d.ts +15 -1
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +52 -24
- package/dist/file/file.service.js.map +1 -1
- package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
- package/dist/file/mcp-file-source.schema.js +8 -5
- package/dist/file/mcp-file-source.schema.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +21 -0
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
- package/dist/integration-profile/testers/ai.testers.js +46 -8
- package/dist/integration-profile/testers/ai.testers.js.map +1 -1
- package/dist/mcp/mcp.module.d.ts.map +1 -1
- package/dist/mcp/mcp.module.js +2 -0
- package/dist/mcp/mcp.module.js.map +1 -1
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
- package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
- package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
- package/dist/meeting/meeting-provider.registry.d.ts +72 -0
- package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
- package/dist/meeting/meeting-provider.registry.js.map +1 -1
- package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
- package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
- package/dist/oauth/oauth-token-broker.service.js +105 -3
- package/dist/oauth/oauth-token-broker.service.js.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
- package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
- package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
- package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-bar.js +117 -0
- package/dist/pdf/document/charts/chart-bar.js.map +1 -0
- package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
- package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-donut.js +89 -0
- package/dist/pdf/document/charts/chart-donut.js.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.js +60 -0
- package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
- package/dist/pdf/document/charts/chart-line.d.ts +29 -0
- package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-line.js +89 -0
- package/dist/pdf/document/charts/chart-line.js.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.js +110 -0
- package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
- package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
- package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-radar.js +83 -0
- package/dist/pdf/document/charts/chart-radar.js.map +1 -0
- package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
- package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-scale.js +118 -0
- package/dist/pdf/document/charts/chart-scale.js.map +1 -0
- package/dist/pdf/document/charts/index.d.ts +8 -0
- package/dist/pdf/document/charts/index.d.ts.map +1 -0
- package/dist/pdf/document/charts/index.js +24 -0
- package/dist/pdf/document/charts/index.js.map +1 -0
- package/dist/pdf/document/index.d.ts +8 -0
- package/dist/pdf/document/index.d.ts.map +1 -0
- package/dist/pdf/document/index.js +24 -0
- package/dist/pdf/document/index.js.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.js +36 -0
- package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
- package/dist/pdf/document/pdf-document.css.d.ts +20 -0
- package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.css.js +239 -0
- package/dist/pdf/document/pdf-document.css.js.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.js +153 -0
- package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
- package/dist/pdf/document/pdf-document.service.d.ts +27 -0
- package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.service.js +81 -0
- package/dist/pdf/document/pdf-document.service.js.map +1 -0
- package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
- package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.theme.js +105 -0
- package/dist/pdf/document/pdf-document.theme.js.map +1 -0
- package/dist/pdf/document/pdf-document.types.d.ts +108 -0
- package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.types.js +18 -0
- package/dist/pdf/document/pdf-document.types.js.map +1 -0
- package/dist/pdf/pdf.module.d.ts.map +1 -1
- package/dist/pdf/pdf.module.js +3 -2
- package/dist/pdf/pdf.module.js.map +1 -1
- package/dist/pdf/pdf.service.d.ts +69 -1
- package/dist/pdf/pdf.service.d.ts.map +1 -1
- package/dist/pdf/pdf.service.js +164 -63
- package/dist/pdf/pdf.service.js.map +1 -1
- package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
- package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
- package/dist/pdf/templates/commercial-proposal.template.js +276 -0
- package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
- package/dist/setting/favicon.controller.d.ts +26 -0
- package/dist/setting/favicon.controller.d.ts.map +1 -0
- package/dist/setting/favicon.controller.js +130 -0
- package/dist/setting/favicon.controller.js.map +1 -0
- package/dist/setting/setting.module.d.ts.map +1 -1
- package/dist/setting/setting.module.js +2 -1
- package/dist/setting/setting.module.js.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.js +25 -2
- package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
- package/hedhog/data/integration_type.yaml +6 -0
- package/hedhog/data/route.yaml +8 -0
- package/hedhog/data/setting_group.yaml +52 -0
- package/hedhog/frontend/messages/en.json +54 -1
- package/hedhog/frontend/messages/pt.json +54 -1
- package/package.json +4 -4
- package/src/ai/ai-attachment.fragments.spec.ts +56 -3
- package/src/ai/ai.service.responses.spec.ts +429 -0
- package/src/ai/ai.service.ts +409 -27
- package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
- package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
- package/src/core.module.ts +3 -0
- package/src/document-kit/document-kit.module.ts +12 -0
- package/src/document-kit/document-kit.registry.ts +125 -0
- package/src/document-kit/document-kit.service.spec.ts +156 -0
- package/src/document-kit/document-kit.service.ts +173 -0
- package/src/file/file.service.spec.ts +168 -1
- package/src/file/file.service.ts +72 -34
- package/src/file/mcp-file-source.schema.ts +8 -5
- package/src/index.ts +5 -0
- package/src/integration-profile/integration-profile.secrets.ts +21 -0
- package/src/integration-profile/testers/ai.testers.ts +70 -5
- package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
- package/src/integration-profile/testers/tester-coverage.spec.ts +10 -0
- package/src/mcp/mcp.module.ts +2 -0
- package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
- package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
- package/src/meeting/meeting-provider.registry.ts +80 -0
- package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
- package/src/oauth/oauth-token-broker.service.ts +129 -2
- package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
- package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
- package/src/pdf/document/charts/chart-bar.ts +217 -0
- package/src/pdf/document/charts/chart-donut.ts +163 -0
- package/src/pdf/document/charts/chart-gauge.ts +94 -0
- package/src/pdf/document/charts/chart-line.ts +159 -0
- package/src/pdf/document/charts/chart-primitives.ts +188 -0
- package/src/pdf/document/charts/chart-radar.ts +136 -0
- package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
- package/src/pdf/document/charts/chart-scale.ts +141 -0
- package/src/pdf/document/charts/charts.spec.ts +157 -0
- package/src/pdf/document/charts/index.ts +7 -0
- package/src/pdf/document/index.ts +7 -0
- package/src/pdf/document/pdf-document.chrome.ts +38 -0
- package/src/pdf/document/pdf-document.css.ts +238 -0
- package/src/pdf/document/pdf-document.renderer.spec.ts +129 -0
- package/src/pdf/document/pdf-document.renderer.ts +215 -0
- package/src/pdf/document/pdf-document.service.ts +77 -0
- package/src/pdf/document/pdf-document.theme.ts +146 -0
- package/src/pdf/document/pdf-document.types.ts +97 -0
- package/src/pdf/pdf.module.ts +3 -2
- package/src/pdf/pdf.service.spec.ts +131 -0
- package/src/pdf/pdf.service.ts +245 -82
- package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
- package/src/pdf/templates/commercial-proposal.template.ts +400 -0
- package/src/setting/favicon.controller.spec.ts +92 -0
- package/src/setting/favicon.controller.ts +135 -0
- package/src/setting/setting.module.ts +2 -1
- package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +73 -0
- package/src/whatsapp-sender/whatsapp-sender.service.ts +32 -2
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { AiService } from './ai.service';
|
|
3
|
+
|
|
4
|
+
jest.mock('axios');
|
|
5
|
+
|
|
6
|
+
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* PDF como documento.
|
|
10
|
+
*
|
|
11
|
+
* O caminho antigo achatava todo PDF em texto: sem camada de texto, o modelo
|
|
12
|
+
* recebia o marcador `[PDF attachment with unreadable text: ...]` e não tinha
|
|
13
|
+
* como extrair coisa alguma — era esse o motivo de uma nota fiscal escaneada
|
|
14
|
+
* voltar com todos os campos vazios. Estes testes fixam o caminho que entrega
|
|
15
|
+
* o documento ao modelo, e os limites em que ele não se aplica.
|
|
16
|
+
*/
|
|
17
|
+
describe('AiService — PDF como documento (Responses API)', () => {
|
|
18
|
+
let service: AiService;
|
|
19
|
+
|
|
20
|
+
const aiProfile = {
|
|
21
|
+
apiKey: 'sk-test',
|
|
22
|
+
model: undefined as string | undefined,
|
|
23
|
+
timeoutMs: undefined as number | undefined,
|
|
24
|
+
baseUrl: undefined as string | undefined,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const PDF = {
|
|
28
|
+
filename: 'nf.pdf',
|
|
29
|
+
mimeType: 'application/pdf',
|
|
30
|
+
buffer: Buffer.from('%PDF-1.7 conteudo'),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const IMAGE = {
|
|
34
|
+
filename: 'nf.png',
|
|
35
|
+
mimeType: 'image/png',
|
|
36
|
+
buffer: Buffer.from('png-bytes'),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
const aiExecution = {
|
|
41
|
+
start: jest.fn().mockResolvedValue(1),
|
|
42
|
+
recordStep: jest.fn().mockResolvedValue(undefined),
|
|
43
|
+
finish: jest.fn().mockResolvedValue(undefined),
|
|
44
|
+
} as any;
|
|
45
|
+
|
|
46
|
+
service = new AiService(
|
|
47
|
+
{} as any,
|
|
48
|
+
{ getSettingValues: jest.fn().mockResolvedValue({}) } as any,
|
|
49
|
+
{} as any,
|
|
50
|
+
aiExecution,
|
|
51
|
+
{ resolveMany: jest.fn() } as any,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
jest.spyOn(service as any, 'getAiProfile').mockResolvedValue(aiProfile);
|
|
55
|
+
jest.spyOn(service as any, 'getDefaultProvider').mockResolvedValue('openai');
|
|
56
|
+
jest.spyOn(service as any, 'resolveAttachment').mockResolvedValue(PDF);
|
|
57
|
+
jest.spyOn(service as any, 'countPdfPages').mockResolvedValue(3);
|
|
58
|
+
jest.spyOn(service as any, 'extractPdfText').mockResolvedValue('');
|
|
59
|
+
|
|
60
|
+
mockedAxios.post.mockResolvedValue({
|
|
61
|
+
data: {
|
|
62
|
+
status: 'completed',
|
|
63
|
+
output: [
|
|
64
|
+
{ type: 'message', content: [{ type: 'output_text', text: 'ok' }] },
|
|
65
|
+
],
|
|
66
|
+
choices: [{ message: { content: 'ok' } }],
|
|
67
|
+
},
|
|
68
|
+
} as any);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
jest.restoreAllMocks();
|
|
73
|
+
mockedAxios.post.mockReset();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const lastBody = () =>
|
|
77
|
+
mockedAxios.post.mock.calls.at(-1)?.[1] as Record<string, any>;
|
|
78
|
+
const lastUrl = () => mockedAxios.post.mock.calls.at(-1)?.[0] as string;
|
|
79
|
+
const lastConfig = () =>
|
|
80
|
+
mockedAxios.post.mock.calls.at(-1)?.[2] as Record<string, any>;
|
|
81
|
+
|
|
82
|
+
it('manda o PDF em input_file, com a instrução depois do arquivo', async () => {
|
|
83
|
+
await service.chat({ message: 'Extraia.', provider: 'openai' } as any);
|
|
84
|
+
|
|
85
|
+
expect(lastUrl()).toBe('https://api.openai.com/v1/responses');
|
|
86
|
+
|
|
87
|
+
const [file, text] = lastBody().input[0].content;
|
|
88
|
+
expect(file).toEqual({
|
|
89
|
+
type: 'input_file',
|
|
90
|
+
filename: 'nf.pdf',
|
|
91
|
+
file_data: `data:application/pdf;base64,${PDF.buffer.toString('base64')}`,
|
|
92
|
+
});
|
|
93
|
+
expect(text).toEqual({ type: 'input_text', text: 'Extraia.' });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('sem parâmetros, o corpo é exatamente { input, model }', async () => {
|
|
97
|
+
await service.chat({ message: 'Extraia.', provider: 'openai' } as any);
|
|
98
|
+
|
|
99
|
+
expect(Object.keys(lastBody()).sort()).toEqual(['input', 'model']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('o system prompt vira instructions de topo, não uma mensagem', async () => {
|
|
103
|
+
await service.chat({
|
|
104
|
+
message: 'Extraia.',
|
|
105
|
+
provider: 'openai',
|
|
106
|
+
systemPrompt: 'Você é um extrator.',
|
|
107
|
+
} as any);
|
|
108
|
+
|
|
109
|
+
expect(lastBody().instructions).toBe('Você é um extrator.');
|
|
110
|
+
expect(lastBody().input.some((item: any) => item.role === 'system')).toBe(
|
|
111
|
+
false,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('renomeia max_tokens e response_format para os nomes da Responses API', async () => {
|
|
116
|
+
await service.chat({
|
|
117
|
+
message: 'Extraia.',
|
|
118
|
+
provider: 'openai',
|
|
119
|
+
max_tokens: 500,
|
|
120
|
+
response_format: 'json_object',
|
|
121
|
+
} as any);
|
|
122
|
+
|
|
123
|
+
expect(lastBody().max_output_tokens).toBe(500);
|
|
124
|
+
expect(lastBody()).not.toHaveProperty('max_tokens');
|
|
125
|
+
expect(lastBody().text).toEqual({ format: { type: 'json_object' } });
|
|
126
|
+
expect(lastBody()).not.toHaveProperty('response_format');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('não trunca nem trava num corpo de megabytes', async () => {
|
|
130
|
+
await service.chat({ message: 'Extraia.', provider: 'openai' } as any);
|
|
131
|
+
|
|
132
|
+
expect(lastConfig().maxBodyLength).toBe(Infinity);
|
|
133
|
+
expect(lastConfig().maxContentLength).toBe(Infinity);
|
|
134
|
+
expect(lastConfig().timeout).toBe(120_000);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// `output_text` é conveniência do SDK e costuma faltar no JSON cru; ler só
|
|
138
|
+
// ele é a forma mais provável de entregar isto quebrado.
|
|
139
|
+
it('lê o texto do array output, ignorando o reasoning sem content', async () => {
|
|
140
|
+
mockedAxios.post.mockResolvedValue({
|
|
141
|
+
data: {
|
|
142
|
+
output: [
|
|
143
|
+
{ type: 'reasoning', summary: [] },
|
|
144
|
+
{
|
|
145
|
+
type: 'message',
|
|
146
|
+
content: [{ type: 'output_text', text: '{"valor":9500}' }],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
} as any);
|
|
151
|
+
|
|
152
|
+
const result = await service.chat({
|
|
153
|
+
message: 'Extraia.',
|
|
154
|
+
provider: 'openai',
|
|
155
|
+
} as any);
|
|
156
|
+
|
|
157
|
+
expect(result.content).toBe('{"valor":9500}');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('usa output_text quando ele vem preenchido', async () => {
|
|
161
|
+
mockedAxios.post.mockResolvedValue({
|
|
162
|
+
data: { output_text: '{"valor":1}', output: [] },
|
|
163
|
+
} as any);
|
|
164
|
+
|
|
165
|
+
const result = await service.chat({
|
|
166
|
+
message: 'Extraia.',
|
|
167
|
+
provider: 'openai',
|
|
168
|
+
} as any);
|
|
169
|
+
|
|
170
|
+
expect(result.content).toBe('{"valor":1}');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('mapeia o usage da Responses API, inclusive os tokens em cache', async () => {
|
|
174
|
+
mockedAxios.post.mockResolvedValue({
|
|
175
|
+
data: {
|
|
176
|
+
output: [
|
|
177
|
+
{ type: 'message', content: [{ type: 'output_text', text: 'ok' }] },
|
|
178
|
+
],
|
|
179
|
+
usage: {
|
|
180
|
+
input_tokens: 1200,
|
|
181
|
+
output_tokens: 80,
|
|
182
|
+
total_tokens: 1280,
|
|
183
|
+
input_tokens_details: { cached_tokens: 400 },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
} as any);
|
|
187
|
+
|
|
188
|
+
const recordStep = jest.fn().mockResolvedValue(undefined);
|
|
189
|
+
(service as any).aiExecution.recordStep = recordStep;
|
|
190
|
+
|
|
191
|
+
await service.chat({ message: 'Extraia.', provider: 'openai' } as any);
|
|
192
|
+
|
|
193
|
+
expect(recordStep.mock.calls[0][0].usage).toEqual({
|
|
194
|
+
input: 1200,
|
|
195
|
+
output: 80,
|
|
196
|
+
total: 1280,
|
|
197
|
+
cached: 400,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('reporta que o documento chegou ao modelo', async () => {
|
|
202
|
+
const result = await service.chat({
|
|
203
|
+
message: 'Extraia.',
|
|
204
|
+
provider: 'openai',
|
|
205
|
+
} as any);
|
|
206
|
+
|
|
207
|
+
expect(result.attachmentDelivery).toBe('pdf-document');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('degrada para chat/completions quando a Responses API devolve 400', async () => {
|
|
211
|
+
mockedAxios.post
|
|
212
|
+
.mockRejectedValueOnce({
|
|
213
|
+
response: { status: 400, data: { error: { message: 'unsupported' } } },
|
|
214
|
+
})
|
|
215
|
+
.mockResolvedValue({
|
|
216
|
+
data: { choices: [{ message: { content: 'ok' } }] },
|
|
217
|
+
} as any);
|
|
218
|
+
|
|
219
|
+
const result = await service.chat({
|
|
220
|
+
message: 'Extraia.',
|
|
221
|
+
provider: 'openai',
|
|
222
|
+
} as any);
|
|
223
|
+
|
|
224
|
+
expect(mockedAxios.post).toHaveBeenCalledTimes(2);
|
|
225
|
+
expect(lastUrl()).toBe('https://api.openai.com/v1/chat/completions');
|
|
226
|
+
expect(result.content).toBe('ok');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Degradar aqui só reproduziria o marcador de PDF ilegível e devolveria
|
|
230
|
+
// "não achei nada" — escondendo uma credencial inválida.
|
|
231
|
+
it('não mascara um 401 com uma segunda chamada mais fraca', async () => {
|
|
232
|
+
mockedAxios.post.mockRejectedValue({
|
|
233
|
+
response: { status: 401, data: { error: { message: 'bad key' } } },
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await expect(
|
|
237
|
+
service.chat({ message: 'Extraia.', provider: 'openai' } as any),
|
|
238
|
+
).rejects.toMatchObject({ response: { status: 401 } });
|
|
239
|
+
|
|
240
|
+
expect(mockedAxios.post).toHaveBeenCalledTimes(1);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('PDF acima do teto de páginas volta para o chat/completions', async () => {
|
|
244
|
+
jest.spyOn(service as any, 'countPdfPages').mockResolvedValue(50);
|
|
245
|
+
|
|
246
|
+
await service.chat({ message: 'Extraia.', provider: 'openai' } as any);
|
|
247
|
+
|
|
248
|
+
expect(lastUrl()).toBe('https://api.openai.com/v1/chat/completions');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('imagem continua pelo bloco de visão do chat/completions', async () => {
|
|
252
|
+
jest.spyOn(service as any, 'resolveAttachment').mockResolvedValue(IMAGE);
|
|
253
|
+
|
|
254
|
+
const result = await service.chat({
|
|
255
|
+
message: 'Extraia.',
|
|
256
|
+
provider: 'openai',
|
|
257
|
+
} as any);
|
|
258
|
+
|
|
259
|
+
expect(lastUrl()).toBe('https://api.openai.com/v1/chat/completions');
|
|
260
|
+
expect(lastBody().messages[0].content[1].type).toBe('image_url');
|
|
261
|
+
expect(result.attachmentDelivery).toBe('image');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// O guarda dos ~25 pontos do repo que chamam IA sem anexo nenhum.
|
|
265
|
+
it('sem anexo, nada muda: chat/completions com o corpo de sempre', async () => {
|
|
266
|
+
jest.spyOn(service as any, 'resolveAttachment').mockResolvedValue(null);
|
|
267
|
+
|
|
268
|
+
const result = await service.chat({
|
|
269
|
+
message: 'oi',
|
|
270
|
+
provider: 'openai',
|
|
271
|
+
} as any);
|
|
272
|
+
|
|
273
|
+
expect(lastUrl()).toBe('https://api.openai.com/v1/chat/completions');
|
|
274
|
+
expect(Object.keys(lastBody()).sort()).toEqual(['messages', 'model']);
|
|
275
|
+
expect(result.attachmentDelivery).toBe('none');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('o DeepSeek não tem /v1/responses: PDF segue no chat/completions', async () => {
|
|
279
|
+
await service.chat({ message: 'Extraia.', provider: 'deepseek' } as any);
|
|
280
|
+
|
|
281
|
+
expect(lastUrl()).toContain('/chat/completions');
|
|
282
|
+
expect(lastUrl()).not.toContain('api.openai.com');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('uma base URL de gateway não recebe /v1/responses', async () => {
|
|
286
|
+
jest
|
|
287
|
+
.spyOn(service as any, 'getAiProfile')
|
|
288
|
+
.mockResolvedValue({ ...aiProfile, baseUrl: 'https://gateway.interno/v1' });
|
|
289
|
+
|
|
290
|
+
await service.chat({ message: 'Extraia.', provider: 'openai' } as any);
|
|
291
|
+
|
|
292
|
+
expect(lastUrl()).toBe('https://gateway.interno/v1/chat/completions');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('modelo sem visão não usa a Responses API', async () => {
|
|
296
|
+
await service.chat({
|
|
297
|
+
message: 'Extraia.',
|
|
298
|
+
provider: 'openai',
|
|
299
|
+
model: 'gpt-3.5-turbo',
|
|
300
|
+
} as any);
|
|
301
|
+
|
|
302
|
+
expect(lastUrl()).toBe('https://api.openai.com/v1/chat/completions');
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Provider x perfil.
|
|
308
|
+
*
|
|
309
|
+
* O setting `ai-gemini-profile-id` apontando para um perfil OpenAI fazia o
|
|
310
|
+
* serviço postar a chave da OpenAI no endpoint do Google e engolir o erro de
|
|
311
|
+
* cada modelo tentado. A trava já existia para o perfil explícito; faltava no
|
|
312
|
+
* caminho que vem do setting global.
|
|
313
|
+
*/
|
|
314
|
+
describe('AiService — perfil do provider', () => {
|
|
315
|
+
let service: AiService;
|
|
316
|
+
let integrationProfile: { findUnique: jest.Mock };
|
|
317
|
+
let settingService: { getSettingValues: jest.Mock };
|
|
318
|
+
|
|
319
|
+
const geminiProfile = {
|
|
320
|
+
slug: 'gemini',
|
|
321
|
+
config: { api_key: 'AIza-test' },
|
|
322
|
+
integration_provider: { slug: 'gemini' },
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const openaiProfile = {
|
|
326
|
+
slug: 'openia-hub',
|
|
327
|
+
config: { api_key: 'sk-test' },
|
|
328
|
+
integration_provider: { slug: 'openai' },
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
beforeEach(() => {
|
|
332
|
+
integrationProfile = { findUnique: jest.fn() };
|
|
333
|
+
settingService = { getSettingValues: jest.fn() };
|
|
334
|
+
|
|
335
|
+
service = new AiService(
|
|
336
|
+
{ integration_profile: integrationProfile } as any,
|
|
337
|
+
settingService as any,
|
|
338
|
+
{} as any,
|
|
339
|
+
{
|
|
340
|
+
start: jest.fn().mockResolvedValue(1),
|
|
341
|
+
recordStep: jest.fn().mockResolvedValue(undefined),
|
|
342
|
+
finish: jest.fn().mockResolvedValue(undefined),
|
|
343
|
+
} as any,
|
|
344
|
+
{ resolveMany: jest.fn() } as any,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
mockedAxios.post.mockResolvedValue({
|
|
348
|
+
data: { candidates: [{ content: { parts: [{ text: 'ok' }] } }] },
|
|
349
|
+
} as any);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
afterEach(() => {
|
|
353
|
+
jest.restoreAllMocks();
|
|
354
|
+
mockedAxios.post.mockReset();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('recusa quando o setting do Gemini aponta para um perfil OpenAI', async () => {
|
|
358
|
+
settingService.getSettingValues.mockResolvedValue({
|
|
359
|
+
'ai-gemini-profile-id': 'openia-hub',
|
|
360
|
+
});
|
|
361
|
+
integrationProfile.findUnique.mockResolvedValue(openaiProfile);
|
|
362
|
+
|
|
363
|
+
await expect(
|
|
364
|
+
(service as any).getAiProfile('gemini'),
|
|
365
|
+
).rejects.toThrow(/ai-gemini-profile-id/);
|
|
366
|
+
|
|
367
|
+
// O ponto todo: nenhuma requisição condenada sai.
|
|
368
|
+
expect(mockedAxios.post).not.toHaveBeenCalled();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('aceita quando o provider do perfil bate com o pedido', async () => {
|
|
372
|
+
settingService.getSettingValues.mockResolvedValue({
|
|
373
|
+
'ai-gemini-profile-id': 'gemini',
|
|
374
|
+
});
|
|
375
|
+
integrationProfile.findUnique.mockResolvedValue(geminiProfile);
|
|
376
|
+
|
|
377
|
+
await expect((service as any).getAiProfile('gemini')).resolves.toMatchObject(
|
|
378
|
+
{ apiKey: 'AIza-test' },
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('isProviderConfigured responde sem lançar', async () => {
|
|
383
|
+
settingService.getSettingValues.mockResolvedValue({
|
|
384
|
+
'ai-gemini-profile-id': 'openia-hub',
|
|
385
|
+
});
|
|
386
|
+
integrationProfile.findUnique.mockResolvedValue(openaiProfile);
|
|
387
|
+
|
|
388
|
+
const result = await service.isProviderConfigured('gemini' as any);
|
|
389
|
+
|
|
390
|
+
expect(result.ok).toBe(false);
|
|
391
|
+
expect(result.reason).toMatch(/ai-gemini-profile-id/);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('isProviderConfigured devolve ok quando o perfil está correto', async () => {
|
|
395
|
+
settingService.getSettingValues.mockResolvedValue({
|
|
396
|
+
'ai-gemini-profile-id': 'gemini',
|
|
397
|
+
});
|
|
398
|
+
integrationProfile.findUnique.mockResolvedValue(geminiProfile);
|
|
399
|
+
|
|
400
|
+
await expect(service.isProviderConfigured('gemini' as any)).resolves.toEqual(
|
|
401
|
+
{ ok: true },
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('setting vazio continua sendo "não configurado", não incompatibilidade', async () => {
|
|
406
|
+
settingService.getSettingValues.mockResolvedValue({
|
|
407
|
+
'ai-gemini-profile-id': '',
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const result = await service.isProviderConfigured('gemini' as any);
|
|
411
|
+
|
|
412
|
+
expect(result.ok).toBe(false);
|
|
413
|
+
expect(result.reason).toMatch(/No gemini AI profile configured/);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// O caminho do perfil explícito deriva o provider DO perfil, então nunca cai
|
|
417
|
+
// na trava nova — é o que o SAC, o CMS e o campaign usam.
|
|
418
|
+
it('perfil explícito segue derivando o provider do próprio perfil', async () => {
|
|
419
|
+
integrationProfile.findUnique.mockResolvedValue(openaiProfile);
|
|
420
|
+
|
|
421
|
+
const resolved = await (service as any).resolveProviderAndProfile(
|
|
422
|
+
undefined,
|
|
423
|
+
'openia-hub',
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
expect(resolved.provider).toBe('openai');
|
|
427
|
+
expect(resolved.profile.apiKey).toBe('sk-test');
|
|
428
|
+
});
|
|
429
|
+
});
|