@hed-hog/core 0.0.400 → 0.0.402
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/access-log/access-log.interceptor.d.ts +10 -0
- package/dist/access-log/access-log.interceptor.d.ts.map +1 -1
- package/dist/access-log/access-log.interceptor.js +15 -1
- package/dist/access-log/access-log.interceptor.js.map +1 -1
- 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-upload-ticket.service.d.ts +1 -1
- package/dist/file/file.controller.d.ts +3 -3
- package/dist/file/file.service.d.ts +136 -13
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +297 -27
- 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/file/provider/abstract.provider.d.ts +33 -0
- package/dist/file/provider/abstract.provider.d.ts.map +1 -1
- package/dist/file/provider/abstract.provider.js +9 -0
- package/dist/file/provider/abstract.provider.js.map +1 -1
- package/dist/file/provider/s3-multipart.util.d.ts +17 -0
- package/dist/file/provider/s3-multipart.util.d.ts.map +1 -0
- package/dist/file/provider/s3-multipart.util.js +88 -0
- package/dist/file/provider/s3-multipart.util.js.map +1 -0
- package/dist/file/provider/s3.provider.d.ts +9 -1
- package/dist/file/provider/s3.provider.d.ts.map +1 -1
- package/dist/file/provider/s3.provider.js +16 -0
- package/dist/file/provider/s3.provider.js.map +1 -1
- package/dist/file/provider/spaces.provider.d.ts +10 -1
- package/dist/file/provider/spaces.provider.d.ts.map +1 -1
- package/dist/file/provider/spaces.provider.js +17 -0
- package/dist/file/provider/spaces.provider.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.controller.d.ts +1 -0
- package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +26 -0
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/integration-profile.service.d.ts +1 -0
- package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.service.js +3 -3
- package/dist/integration-profile/integration-profile.service.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/integration-profile/testers/http-probe.d.ts.map +1 -1
- package/dist/integration-profile/testers/http-probe.js +5 -3
- package/dist/integration-profile/testers/http-probe.js.map +1 -1
- package/dist/integration-profile/testers/integration-tester.types.d.ts +5 -0
- package/dist/integration-profile/testers/integration-tester.types.d.ts.map +1 -1
- package/dist/mail/mail.service.d.ts.map +1 -1
- package/dist/mail/mail.service.js +16 -1
- package/dist/mail/mail.service.js.map +1 -1
- package/dist/mcp/mcp-server.service.d.ts.map +1 -1
- package/dist/mcp/mcp-server.service.js +10 -1
- package/dist/mcp/mcp-server.service.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 +327 -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 +226 -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 +164 -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 +75 -1
- package/dist/pdf/pdf.service.d.ts.map +1 -1
- package/dist/pdf/pdf.service.js +167 -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/setting/setting.service.d.ts.map +1 -1
- package/dist/setting/setting.service.js +3 -0
- package/dist/setting/setting.service.js.map +1 -1
- package/dist/task/task.service.d.ts +6 -0
- package/dist/task/task.service.d.ts.map +1 -1
- package/dist/task/task.service.js +44 -13
- package/dist/task/task.service.js.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts +35 -2
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.js +107 -22
- 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/app/configurations/[slug]/components/setting-field.test.tsx.ejs +85 -2
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +576 -491
- package/hedhog/frontend/app/users/simulate-access-dialog.test.tsx.ejs +29 -50
- package/hedhog/frontend/app/users/simulate-access-dialog.tsx.ejs +146 -50
- package/hedhog/frontend/messages/en.json +81 -2
- package/hedhog/frontend/messages/pt.json +81 -2
- package/hedhog/table/file.yaml +3 -1
- package/package.json +5 -5
- package/src/access-log/access-log.interceptor.spec.ts +50 -0
- package/src/access-log/access-log.interceptor.ts +18 -0
- 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-direct-upload.spec.ts +344 -0
- package/src/file/file.service.spec.ts +168 -1
- package/src/file/file.service.ts +435 -36
- package/src/file/mcp-file-source.schema.ts +8 -5
- package/src/file/provider/abstract.provider.ts +57 -0
- package/src/file/provider/s3-multipart.spec.ts +123 -0
- package/src/file/provider/s3-multipart.util.ts +126 -0
- package/src/file/provider/s3.provider.ts +65 -1
- package/src/file/provider/spaces.provider.ts +67 -1
- package/src/index.ts +5 -0
- package/src/integration-profile/integration-profile.secrets.ts +26 -0
- package/src/integration-profile/integration-profile.service.ts +1 -0
- package/src/integration-profile/testers/ai.testers.ts +70 -5
- package/src/integration-profile/testers/http-probe.ts +5 -3
- package/src/integration-profile/testers/integration-tester.types.ts +5 -0
- package/src/integration-profile/testers/oauth.testers.spec.ts +13 -0
- package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
- package/src/integration-profile/testers/tester-coverage.spec.ts +12 -0
- package/src/mail/mail.service.spec.ts +25 -0
- package/src/mail/mail.service.ts +18 -1
- package/src/mcp/mcp-server.service.spec.ts +16 -0
- package/src/mcp/mcp-server.service.ts +10 -1
- 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 +326 -0
- package/src/pdf/document/pdf-document.renderer.spec.ts +172 -0
- package/src/pdf/document/pdf-document.renderer.ts +317 -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 +149 -0
- package/src/pdf/pdf.module.ts +3 -2
- package/src/pdf/pdf.service.spec.ts +131 -0
- package/src/pdf/pdf.service.ts +256 -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/setting/setting.service.ts +3 -0
- package/src/task/task.service.spec.ts +77 -1
- package/src/task/task.service.ts +58 -14
- package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +263 -0
- package/src/whatsapp-sender/whatsapp-sender.service.ts +154 -16
package/src/ai/ai.service.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
AiPromptResolverService,
|
|
18
18
|
applyGeminiModelParams,
|
|
19
19
|
applyOpenAiModelParams,
|
|
20
|
+
applyOpenAiResponsesParams,
|
|
20
21
|
mergeModelParams,
|
|
21
22
|
type PromptSource,
|
|
22
23
|
} from '../ai-instruction/ai-prompt-resolver.service';
|
|
@@ -97,6 +98,56 @@ type AiAttachment = {
|
|
|
97
98
|
buffer: Buffer;
|
|
98
99
|
};
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Como o anexo efetivamente chegou ao modelo na última chamada.
|
|
103
|
+
*
|
|
104
|
+
* Sem isso quem chama só consegue adivinhar — foi exatamente o que o extrator
|
|
105
|
+
* de notas fiscais fazia, deduzindo pelo tamanho do texto do PDF, e por isso
|
|
106
|
+
* insistia em reperguntar ao modelo sobre um anexo que ele nunca recebeu.
|
|
107
|
+
* `unreadable-pdf` e `binary` são os dois casos em que o modelo recebeu um
|
|
108
|
+
* marcador no lugar do documento.
|
|
109
|
+
*/
|
|
110
|
+
export type AiAttachmentDelivery =
|
|
111
|
+
| 'none'
|
|
112
|
+
| 'image'
|
|
113
|
+
| 'pdf-document'
|
|
114
|
+
| 'pdf-text'
|
|
115
|
+
| 'text'
|
|
116
|
+
| 'unreadable-pdf'
|
|
117
|
+
| 'binary';
|
|
118
|
+
|
|
119
|
+
/** Anexo que o modelo realmente leu, em vez de um marcador dizendo que não deu. */
|
|
120
|
+
const DELIVERED_TO_MODEL: ReadonlySet<AiAttachmentDelivery> = new Set([
|
|
121
|
+
'image',
|
|
122
|
+
'pdf-document',
|
|
123
|
+
'pdf-text',
|
|
124
|
+
'text',
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/** Entregas em que o modelo enxergou os pixels da página, não só texto. */
|
|
128
|
+
const DELIVERED_AS_PIXELS: ReadonlySet<AiAttachmentDelivery> = new Set([
|
|
129
|
+
'image',
|
|
130
|
+
'pdf-document',
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
export function isAttachmentDeliveredToModel(
|
|
134
|
+
delivery?: AiAttachmentDelivery | null,
|
|
135
|
+
): boolean {
|
|
136
|
+
return !!delivery && DELIVERED_TO_MODEL.has(delivery);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isAttachmentDeliveredAsPixels(
|
|
140
|
+
delivery?: AiAttachmentDelivery | null,
|
|
141
|
+
): boolean {
|
|
142
|
+
return !!delivery && DELIVERED_AS_PIXELS.has(delivery);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type AiChatResult = {
|
|
146
|
+
content: string;
|
|
147
|
+
usage?: { input: number; output: number; total: number; cached?: number };
|
|
148
|
+
delivery?: AiAttachmentDelivery;
|
|
149
|
+
};
|
|
150
|
+
|
|
100
151
|
type AiProfileCredentials = {
|
|
101
152
|
apiKey: string;
|
|
102
153
|
organization?: string;
|
|
@@ -105,6 +156,29 @@ type AiProfileCredentials = {
|
|
|
105
156
|
baseUrl?: string;
|
|
106
157
|
};
|
|
107
158
|
|
|
159
|
+
/**
|
|
160
|
+
* O setting de um provider aponta para o perfil de outro provider.
|
|
161
|
+
*
|
|
162
|
+
* Classe própria, e não um `BadRequestException` solto, para uma escada de
|
|
163
|
+
* fallback conseguir parar antes de disparar chamadas que vão dar 4xx com
|
|
164
|
+
* certeza — era o que o extrator de notas fiscais fazia oito vezes por upload,
|
|
165
|
+
* engolindo cada uma delas.
|
|
166
|
+
*/
|
|
167
|
+
export class AiProfileProviderMismatchError extends BadRequestException {
|
|
168
|
+
constructor(
|
|
169
|
+
readonly requestedProvider: string,
|
|
170
|
+
readonly profileSlug: string,
|
|
171
|
+
readonly profileProviderSlug: string,
|
|
172
|
+
readonly settingSlug: string,
|
|
173
|
+
) {
|
|
174
|
+
super(
|
|
175
|
+
`AI profile "${profileSlug}" (setting "${settingSlug}") belongs to provider ` +
|
|
176
|
+
`"${profileProviderSlug}", not "${requestedProvider}". ` +
|
|
177
|
+
`Point Settings → AI → "${settingSlug}" at a ${requestedProvider} profile, or leave it empty.`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
108
182
|
@Injectable()
|
|
109
183
|
export class AiService implements OnModuleInit {
|
|
110
184
|
private readonly logger = new Logger(AiService.name);
|
|
@@ -148,7 +222,7 @@ export class AiService implements OnModuleInit {
|
|
|
148
222
|
const stepStart = Date.now();
|
|
149
223
|
|
|
150
224
|
try {
|
|
151
|
-
const { content, usage } = await this.withTimeout(
|
|
225
|
+
const { content, usage, delivery } = await this.withTimeout(
|
|
152
226
|
this.chatWithProvider(provider, {
|
|
153
227
|
message: data.message,
|
|
154
228
|
model,
|
|
@@ -157,6 +231,7 @@ export class AiService implements OnModuleInit {
|
|
|
157
231
|
apiKey: aiProfile.apiKey,
|
|
158
232
|
baseUrl: aiProfile.baseUrl,
|
|
159
233
|
params,
|
|
234
|
+
timeoutMs,
|
|
160
235
|
}),
|
|
161
236
|
timeoutMs,
|
|
162
237
|
);
|
|
@@ -173,7 +248,12 @@ export class AiService implements OnModuleInit {
|
|
|
173
248
|
});
|
|
174
249
|
await this.aiExecution.finish(executionId, 'completed');
|
|
175
250
|
|
|
176
|
-
return {
|
|
251
|
+
return {
|
|
252
|
+
provider,
|
|
253
|
+
model,
|
|
254
|
+
content,
|
|
255
|
+
attachmentDelivery: delivery ?? 'none',
|
|
256
|
+
};
|
|
177
257
|
} catch (err) {
|
|
178
258
|
await this.aiExecution.finish(executionId, 'failed', String(err));
|
|
179
259
|
throw err;
|
|
@@ -501,21 +581,30 @@ export class AiService implements OnModuleInit {
|
|
|
501
581
|
// Assistants API — que a OpenAI desligou, e que já ignorava model e
|
|
502
582
|
// instructions do banco porque só mandava o `assistant_id`.
|
|
503
583
|
const aiProfile = await this.getAiProfile(agent.provider);
|
|
584
|
+
const timeoutMs = aiProfile.timeoutMs ?? DEFAULT_AI_CALL_TIMEOUT_MS;
|
|
504
585
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
586
|
+
// Sem o `withTimeout` este era o único ponto público sem prazo nenhum: uma
|
|
587
|
+
// chamada travada no provider segurava a requisição HTTP de quem chamou
|
|
588
|
+
// indefinidamente.
|
|
589
|
+
const { content, delivery } = await this.withTimeout(
|
|
590
|
+
this.chatWithProvider(agent.provider, {
|
|
591
|
+
message: data.message,
|
|
592
|
+
model: agent.model || this.getDefaultModel(agent.provider),
|
|
593
|
+
systemPrompt: agent.instructions || undefined,
|
|
594
|
+
attachment,
|
|
595
|
+
apiKey: aiProfile.apiKey,
|
|
596
|
+
baseUrl: aiProfile.baseUrl,
|
|
597
|
+
timeoutMs,
|
|
598
|
+
}),
|
|
599
|
+
timeoutMs,
|
|
600
|
+
);
|
|
513
601
|
|
|
514
602
|
return {
|
|
515
603
|
slug: agent.slug,
|
|
516
604
|
provider: agent.provider,
|
|
517
605
|
model: agent.model,
|
|
518
606
|
content,
|
|
607
|
+
attachmentDelivery: delivery ?? 'none',
|
|
519
608
|
};
|
|
520
609
|
}
|
|
521
610
|
|
|
@@ -587,7 +676,7 @@ export class AiService implements OnModuleInit {
|
|
|
587
676
|
sha256: createHash('sha256').update(attachment.buffer).digest('hex'),
|
|
588
677
|
};
|
|
589
678
|
|
|
590
|
-
this.logger.
|
|
679
|
+
this.logger.debug(
|
|
591
680
|
`[AI-ATTACHMENT-DEBUG] fileId=${fileId || 'upload'} filename="${result.filename}" mime=${result.mimeType} mode=${result.mode} bytes=${result.bytes} textLength=${result.textLength}`,
|
|
592
681
|
);
|
|
593
682
|
|
|
@@ -656,8 +745,9 @@ export class AiService implements OnModuleInit {
|
|
|
656
745
|
apiKey?: string;
|
|
657
746
|
baseUrl?: string;
|
|
658
747
|
params?: AiChatModelParams;
|
|
748
|
+
timeoutMs?: number;
|
|
659
749
|
},
|
|
660
|
-
) {
|
|
750
|
+
): Promise<AiChatResult> {
|
|
661
751
|
if (provider === 'gemini') {
|
|
662
752
|
return this.chatWithGemini(input);
|
|
663
753
|
}
|
|
@@ -728,7 +818,49 @@ export class AiService implements OnModuleInit {
|
|
|
728
818
|
);
|
|
729
819
|
}
|
|
730
820
|
|
|
731
|
-
|
|
821
|
+
const loaded = await this.loadAiProfileBySlug(profileSlug);
|
|
822
|
+
const mapped = PROVIDER_BY_INTEGRATION_SLUG[loaded.providerSlug];
|
|
823
|
+
|
|
824
|
+
// `resolveProviderAndProfile` já protegia o caminho do perfil explícito
|
|
825
|
+
// ("pedir openai e apontar um perfil Claude bateria na API errada com a
|
|
826
|
+
// chave errada"); o caminho do setting global ficara de fora. Sem isso, um
|
|
827
|
+
// perfil OpenAI no campo do Gemini fazia o serviço postar a chave da OpenAI
|
|
828
|
+
// no endpoint do Google e engolir o 4xx de cada modelo tentado.
|
|
829
|
+
if (mapped !== provider) {
|
|
830
|
+
this.logger.error(
|
|
831
|
+
`[AI-PROFILE] "${slug}" = "${profileSlug}" é um perfil ${loaded.providerSlug}, ` +
|
|
832
|
+
`mas ${provider} foi pedido.`,
|
|
833
|
+
);
|
|
834
|
+
throw new AiProfileProviderMismatchError(
|
|
835
|
+
provider,
|
|
836
|
+
profileSlug,
|
|
837
|
+
loaded.providerSlug,
|
|
838
|
+
slug,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return loaded.config;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* "Este provider dá para usar?", sem lançar.
|
|
847
|
+
*
|
|
848
|
+
* Existe para uma escada de fallback perguntar antes de tentar, em vez de
|
|
849
|
+
* gastar uma requisição HTTP por modelo só para descobrir que a credencial
|
|
850
|
+
* nem é daquele provider.
|
|
851
|
+
*/
|
|
852
|
+
async isProviderConfigured(
|
|
853
|
+
provider: AiProvider,
|
|
854
|
+
): Promise<{ ok: boolean; reason?: string }> {
|
|
855
|
+
try {
|
|
856
|
+
await this.getAiProfile(provider);
|
|
857
|
+
return { ok: true };
|
|
858
|
+
} catch (error) {
|
|
859
|
+
return {
|
|
860
|
+
ok: false,
|
|
861
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
862
|
+
};
|
|
863
|
+
}
|
|
732
864
|
}
|
|
733
865
|
|
|
734
866
|
private async loadAiProfileBySlug(profileSlug: string): Promise<{
|
|
@@ -783,6 +915,7 @@ export class AiService implements OnModuleInit {
|
|
|
783
915
|
provider = 'openai',
|
|
784
916
|
baseUrl = 'https://api.openai.com/v1',
|
|
785
917
|
supportsImages = true,
|
|
918
|
+
timeoutMs,
|
|
786
919
|
}: {
|
|
787
920
|
message: string;
|
|
788
921
|
model: string;
|
|
@@ -794,16 +927,44 @@ export class AiService implements OnModuleInit {
|
|
|
794
927
|
provider?: AiProvider;
|
|
795
928
|
baseUrl?: string;
|
|
796
929
|
supportsImages?: boolean;
|
|
797
|
-
|
|
930
|
+
timeoutMs?: number;
|
|
931
|
+
}): Promise<AiChatResult> {
|
|
798
932
|
const openai = apiKey ?? (await this.getAiApiKey(provider));
|
|
799
933
|
|
|
934
|
+
if (this.shouldUseOpenAiResponses({ provider, baseUrl, model, attachment })) {
|
|
935
|
+
try {
|
|
936
|
+
return await this.chatWithOpenAiResponses({
|
|
937
|
+
message,
|
|
938
|
+
model,
|
|
939
|
+
systemPrompt,
|
|
940
|
+
attachment: attachment!,
|
|
941
|
+
apiKey: openai,
|
|
942
|
+
baseUrl,
|
|
943
|
+
params,
|
|
944
|
+
timeoutMs,
|
|
945
|
+
});
|
|
946
|
+
} catch (error) {
|
|
947
|
+
const status = Number((error as any)?.response?.status);
|
|
948
|
+
// 400/404/422 = este modelo ou esta conta não aceita `input_file`;
|
|
949
|
+
// cair para o chat/completions é melhor que falhar. Qualquer outra
|
|
950
|
+
// coisa (401, 429, 5xx, timeout) é falha de verdade e não pode ser
|
|
951
|
+
// mascarada por uma segunda chamada mais fraca, que só reproduziria o
|
|
952
|
+
// marcador de PDF ilegível e devolveria "não achei nada".
|
|
953
|
+
if (![400, 404, 422].includes(status)) throw error;
|
|
954
|
+
this.logger.warn(
|
|
955
|
+
`[AI-RESPONSES] /v1/responses recusou (status=${status}); usando chat/completions. ` +
|
|
956
|
+
`apiError=${(error as any)?.response?.data?.error?.message ?? 'n/a'}`,
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
800
961
|
const messages: Array<any> = [];
|
|
801
962
|
|
|
802
963
|
if (systemPrompt) {
|
|
803
964
|
messages.push({ role: 'system', content: systemPrompt });
|
|
804
965
|
}
|
|
805
966
|
|
|
806
|
-
const userContent = await this.buildOpenAiUserContent(
|
|
967
|
+
const { content: userContent, delivery } = await this.buildOpenAiUserContent(
|
|
807
968
|
message,
|
|
808
969
|
attachment,
|
|
809
970
|
supportsImages,
|
|
@@ -830,10 +991,200 @@ export class AiService implements OnModuleInit {
|
|
|
830
991
|
const u = response.data?.usage;
|
|
831
992
|
return {
|
|
832
993
|
content: response.data?.choices?.[0]?.message?.content || '',
|
|
994
|
+
delivery,
|
|
833
995
|
usage: u ? { input: u.prompt_tokens ?? 0, output: u.completion_tokens ?? 0, total: u.total_tokens ?? 0 } : undefined,
|
|
834
996
|
};
|
|
835
997
|
}
|
|
836
998
|
|
|
999
|
+
/**
|
|
1000
|
+
* Bytes crus de PDF acima disto não vão inline: base64 infla ~1,37x e a
|
|
1001
|
+
* OpenAI limita a requisição a 50 MB.
|
|
1002
|
+
*/
|
|
1003
|
+
private static readonly OPENAI_RESPONSES_MAX_PDF_BYTES = 20 * 1024 * 1024;
|
|
1004
|
+
|
|
1005
|
+
/** Acima disto a conta de tokens das páginas renderizadas deixa de compensar. */
|
|
1006
|
+
private static readonly OPENAI_RESPONSES_MAX_PDF_PAGES = 20;
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Modelos que renderizam as páginas do PDF como imagem na Responses API.
|
|
1010
|
+
*
|
|
1011
|
+
* Um modelo sem visão recebendo `input_file` só recebe a camada de texto de
|
|
1012
|
+
* volta — o mesmo nada do chat/completions, num endpoint diferente. Deixá-lo
|
|
1013
|
+
* no caminho antigo mantém a falha conhecida em vez de criar uma nova.
|
|
1014
|
+
*/
|
|
1015
|
+
private static readonly OPENAI_DOCUMENT_MODEL_RE =
|
|
1016
|
+
/^(gpt-4o|gpt-4\.1|gpt-4-turbo|gpt-5|o3|o4)/i;
|
|
1017
|
+
|
|
1018
|
+
private shouldUseOpenAiResponses(input: {
|
|
1019
|
+
provider: AiProvider;
|
|
1020
|
+
baseUrl: string;
|
|
1021
|
+
model: string;
|
|
1022
|
+
attachment?: AiAttachment | null;
|
|
1023
|
+
}): boolean {
|
|
1024
|
+
// O DeepSeek reusa o formato da OpenAI, mas não tem /v1/responses; e um
|
|
1025
|
+
// gateway apontado por `base_url` pode simplesmente não repassar a rota.
|
|
1026
|
+
if (input.provider !== 'openai') return false;
|
|
1027
|
+
if (!input.baseUrl.startsWith('https://api.openai.com')) return false;
|
|
1028
|
+
if (!input.attachment) return false;
|
|
1029
|
+
// Imagem já funciona pelo bloco de visão do chat/completions.
|
|
1030
|
+
if (!this.isPdfMime(input.attachment.mimeType)) return false;
|
|
1031
|
+
if (
|
|
1032
|
+
input.attachment.buffer.length > AiService.OPENAI_RESPONSES_MAX_PDF_BYTES
|
|
1033
|
+
) {
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
return AiService.OPENAI_DOCUMENT_MODEL_RE.test(input.model);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
private buildOpenAiResponsesBody(input: {
|
|
1040
|
+
message: string;
|
|
1041
|
+
model: string;
|
|
1042
|
+
systemPrompt?: string;
|
|
1043
|
+
attachment: AiAttachment;
|
|
1044
|
+
params?: AiChatModelParams;
|
|
1045
|
+
}): Record<string, unknown> {
|
|
1046
|
+
const base64 = input.attachment.buffer.toString('base64');
|
|
1047
|
+
|
|
1048
|
+
// Arquivo antes da instrução, como no exemplo da própria OpenAI: o modelo
|
|
1049
|
+
// lê o documento antes de saber o que tirar dele.
|
|
1050
|
+
const body: Record<string, unknown> = {
|
|
1051
|
+
input: [
|
|
1052
|
+
{
|
|
1053
|
+
role: 'user',
|
|
1054
|
+
content: [
|
|
1055
|
+
{
|
|
1056
|
+
type: 'input_file',
|
|
1057
|
+
filename: input.attachment.filename,
|
|
1058
|
+
file_data: `data:${input.attachment.mimeType};base64,${base64}`,
|
|
1059
|
+
},
|
|
1060
|
+
{ type: 'input_text', text: input.message },
|
|
1061
|
+
],
|
|
1062
|
+
},
|
|
1063
|
+
],
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
// `instructions` é o equivalente do `messages[0].role === 'system'`.
|
|
1067
|
+
if (input.systemPrompt) body.instructions = input.systemPrompt;
|
|
1068
|
+
|
|
1069
|
+
return applyOpenAiResponsesParams(
|
|
1070
|
+
body,
|
|
1071
|
+
{
|
|
1072
|
+
model: null,
|
|
1073
|
+
temperature: input.params?.temperature ?? null,
|
|
1074
|
+
maxTokens: input.params?.maxTokens ?? null,
|
|
1075
|
+
},
|
|
1076
|
+
{
|
|
1077
|
+
model: input.model,
|
|
1078
|
+
jsonObject: input.params?.responseFormat === 'json_object',
|
|
1079
|
+
},
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Texto da resposta da Responses API.
|
|
1085
|
+
*
|
|
1086
|
+
* `output_text` é conveniência do SDK — o JSON HTTP cru normalmente não traz.
|
|
1087
|
+
* O texto mora no array `output`, ao lado de itens `reasoning` que não têm
|
|
1088
|
+
* `content` nenhum. Percorrer o array é o caminho real; o atalho pelo
|
|
1089
|
+
* `output_text` só vale para quando o campo por acaso estiver lá.
|
|
1090
|
+
*/
|
|
1091
|
+
private readOpenAiResponsesText(data: any): string {
|
|
1092
|
+
if (typeof data?.output_text === 'string' && data.output_text.trim()) {
|
|
1093
|
+
return data.output_text;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const output: any[] = Array.isArray(data?.output) ? data.output : [];
|
|
1097
|
+
const chunks: string[] = [];
|
|
1098
|
+
|
|
1099
|
+
for (const item of output) {
|
|
1100
|
+
if (item?.type && item.type !== 'message') continue;
|
|
1101
|
+
for (const part of item?.content ?? []) {
|
|
1102
|
+
if (part?.type === 'output_text' && typeof part.text === 'string') {
|
|
1103
|
+
chunks.push(part.text);
|
|
1104
|
+
} else if (part?.type === 'refusal' && typeof part.refusal === 'string') {
|
|
1105
|
+
this.logger.warn(
|
|
1106
|
+
`[AI-RESPONSES] modelo recusou: ${part.refusal.slice(0, 200)}`,
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
return chunks.join('');
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* PDF como documento, via `POST /v1/responses`.
|
|
1117
|
+
*
|
|
1118
|
+
* É o único caminho em que um PDF escaneado chega ao modelo: para modelos com
|
|
1119
|
+
* visão a API extrai o texto e renderiza cada página como imagem. O
|
|
1120
|
+
* chat/completions, mesmo aceitando um content part de PDF, só extrai texto —
|
|
1121
|
+
* não serviria para um scan.
|
|
1122
|
+
*/
|
|
1123
|
+
private async chatWithOpenAiResponses(input: {
|
|
1124
|
+
message: string;
|
|
1125
|
+
model: string;
|
|
1126
|
+
systemPrompt?: string;
|
|
1127
|
+
attachment: AiAttachment;
|
|
1128
|
+
apiKey: string;
|
|
1129
|
+
baseUrl: string;
|
|
1130
|
+
params?: AiChatModelParams;
|
|
1131
|
+
timeoutMs?: number;
|
|
1132
|
+
}): Promise<AiChatResult> {
|
|
1133
|
+
const pages = await this.countPdfPages(input.attachment.buffer);
|
|
1134
|
+
|
|
1135
|
+
if (pages > AiService.OPENAI_RESPONSES_MAX_PDF_PAGES) {
|
|
1136
|
+
this.logger.warn(
|
|
1137
|
+
`[AI-RESPONSES] "${input.attachment.filename}" tem ${pages} páginas ` +
|
|
1138
|
+
`(máx. ${AiService.OPENAI_RESPONSES_MAX_PDF_PAGES}); usando chat/completions.`,
|
|
1139
|
+
);
|
|
1140
|
+
// 422 é o status que o chamador trata como "degrade", e não como falha.
|
|
1141
|
+
throw Object.assign(new Error('PDF acima do limite de páginas'), {
|
|
1142
|
+
response: { status: 422, data: { error: { message: 'too many pages' } } },
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const response = await axios.post(
|
|
1147
|
+
`${input.baseUrl.replace(/\/+$/, '')}/responses`,
|
|
1148
|
+
this.buildOpenAiResponsesBody(input),
|
|
1149
|
+
{
|
|
1150
|
+
headers: {
|
|
1151
|
+
Authorization: `Bearer ${input.apiKey}`,
|
|
1152
|
+
'Content-Type': 'application/json',
|
|
1153
|
+
},
|
|
1154
|
+
// O corpo é o PDF em base64, na casa dos megabytes, e a renderização
|
|
1155
|
+
// das páginas é lenta: com os padrões do axios isso trunca ou trava.
|
|
1156
|
+
timeout: input.timeoutMs ?? DEFAULT_AI_CALL_TIMEOUT_MS,
|
|
1157
|
+
maxBodyLength: Infinity,
|
|
1158
|
+
maxContentLength: Infinity,
|
|
1159
|
+
},
|
|
1160
|
+
);
|
|
1161
|
+
|
|
1162
|
+
if (response.data?.status && response.data.status !== 'completed') {
|
|
1163
|
+
this.logger.warn(
|
|
1164
|
+
`[AI-RESPONSES] status=${response.data.status} ` +
|
|
1165
|
+
`reason=${response.data?.incomplete_details?.reason ?? 'n/a'} ` +
|
|
1166
|
+
`model=${input.model} file="${input.attachment.filename}"`,
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const u = response.data?.usage;
|
|
1171
|
+
return {
|
|
1172
|
+
content: this.readOpenAiResponsesText(response.data),
|
|
1173
|
+
delivery: 'pdf-document',
|
|
1174
|
+
usage: u
|
|
1175
|
+
? {
|
|
1176
|
+
input: u.input_tokens ?? 0,
|
|
1177
|
+
output: u.output_tokens ?? 0,
|
|
1178
|
+
total:
|
|
1179
|
+
u.total_tokens ?? (u.input_tokens ?? 0) + (u.output_tokens ?? 0),
|
|
1180
|
+
...(u.input_tokens_details?.cached_tokens
|
|
1181
|
+
? { cached: u.input_tokens_details.cached_tokens }
|
|
1182
|
+
: {}),
|
|
1183
|
+
}
|
|
1184
|
+
: undefined,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
|
|
837
1188
|
/**
|
|
838
1189
|
* Anthropic (Claude).
|
|
839
1190
|
*
|
|
@@ -858,7 +1209,7 @@ export class AiService implements OnModuleInit {
|
|
|
858
1209
|
systemPrompt?: string;
|
|
859
1210
|
apiKey?: string;
|
|
860
1211
|
params?: AiChatModelParams;
|
|
861
|
-
}): Promise<
|
|
1212
|
+
}): Promise<AiChatResult> {
|
|
862
1213
|
const key = apiKey ?? (await this.getAiApiKey('claude'));
|
|
863
1214
|
|
|
864
1215
|
const body: Record<string, unknown> = {
|
|
@@ -908,24 +1259,27 @@ export class AiService implements OnModuleInit {
|
|
|
908
1259
|
attachment?: AiAttachment | null;
|
|
909
1260
|
apiKey?: string;
|
|
910
1261
|
params?: AiChatModelParams;
|
|
911
|
-
}): Promise<
|
|
1262
|
+
}): Promise<AiChatResult> {
|
|
912
1263
|
const gemini = apiKey ?? (await this.getAiApiKey('gemini'));
|
|
913
1264
|
|
|
914
1265
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${gemini}`;
|
|
915
1266
|
|
|
916
1267
|
const userParts: any[] = [{ text: message }];
|
|
1268
|
+
let delivery: AiAttachmentDelivery = 'none';
|
|
917
1269
|
|
|
918
1270
|
if (attachment) {
|
|
919
1271
|
if (this.isTextMime(attachment.mimeType)) {
|
|
920
1272
|
userParts.push({
|
|
921
1273
|
text: attachedFileBlock(attachment.filename, this.toUtf8Text(attachment.buffer)),
|
|
922
1274
|
});
|
|
1275
|
+
delivery = 'text';
|
|
923
1276
|
} else if (this.isPdfMime(attachment.mimeType)) {
|
|
924
1277
|
const pdfText = await this.extractPdfText(attachment.buffer);
|
|
925
1278
|
if (pdfText) {
|
|
926
1279
|
userParts.push({
|
|
927
1280
|
text: attachedFileBlock(attachment.filename, pdfText),
|
|
928
1281
|
});
|
|
1282
|
+
delivery = 'pdf-text';
|
|
929
1283
|
} else {
|
|
930
1284
|
userParts.push({
|
|
931
1285
|
inline_data: {
|
|
@@ -933,6 +1287,7 @@ export class AiService implements OnModuleInit {
|
|
|
933
1287
|
data: attachment.buffer.toString('base64'),
|
|
934
1288
|
},
|
|
935
1289
|
});
|
|
1290
|
+
delivery = 'pdf-document';
|
|
936
1291
|
}
|
|
937
1292
|
} else {
|
|
938
1293
|
userParts.push({
|
|
@@ -941,6 +1296,7 @@ export class AiService implements OnModuleInit {
|
|
|
941
1296
|
data: attachment.buffer.toString('base64'),
|
|
942
1297
|
},
|
|
943
1298
|
});
|
|
1299
|
+
delivery = this.isImageMime(attachment.mimeType) ? 'image' : 'binary';
|
|
944
1300
|
}
|
|
945
1301
|
}
|
|
946
1302
|
|
|
@@ -971,6 +1327,7 @@ export class AiService implements OnModuleInit {
|
|
|
971
1327
|
const u = response.data?.usageMetadata;
|
|
972
1328
|
return {
|
|
973
1329
|
content,
|
|
1330
|
+
delivery,
|
|
974
1331
|
usage: u ? { input: u.promptTokenCount ?? 0, output: u.candidatesTokenCount ?? 0, total: u.totalTokenCount ?? 0 } : undefined,
|
|
975
1332
|
};
|
|
976
1333
|
}
|
|
@@ -1007,40 +1364,56 @@ export class AiService implements OnModuleInit {
|
|
|
1007
1364
|
return null;
|
|
1008
1365
|
}
|
|
1009
1366
|
|
|
1367
|
+
/**
|
|
1368
|
+
* Conteúdo da mensagem do usuário no chat/completions, mais como o anexo foi
|
|
1369
|
+
* parar lá dentro.
|
|
1370
|
+
*
|
|
1371
|
+
* O `delivery` sai daqui porque é aqui que a decisão acontece: um PDF sem
|
|
1372
|
+
* camada de texto vira o marcador de ilegível, e só este método sabe disso.
|
|
1373
|
+
*/
|
|
1010
1374
|
private async buildOpenAiUserContent(
|
|
1011
1375
|
message: string,
|
|
1012
1376
|
attachment?: AiAttachment | null,
|
|
1013
1377
|
supportsImages = true,
|
|
1014
|
-
) {
|
|
1378
|
+
): Promise<{ content: unknown; delivery: AiAttachmentDelivery }> {
|
|
1015
1379
|
if (!attachment) {
|
|
1016
|
-
return message;
|
|
1380
|
+
return { content: message, delivery: 'none' };
|
|
1017
1381
|
}
|
|
1018
1382
|
|
|
1019
1383
|
if (supportsImages && this.isImageMime(attachment.mimeType)) {
|
|
1020
1384
|
const dataUri = `data:${attachment.mimeType};base64,${attachment.buffer.toString('base64')}`;
|
|
1021
|
-
return
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1385
|
+
return {
|
|
1386
|
+
content: [
|
|
1387
|
+
{ type: 'text', text: message },
|
|
1388
|
+
{ type: 'image_url', image_url: { url: dataUri } },
|
|
1389
|
+
],
|
|
1390
|
+
delivery: 'image',
|
|
1391
|
+
};
|
|
1025
1392
|
}
|
|
1026
1393
|
|
|
1027
1394
|
let text: string;
|
|
1395
|
+
let delivery: AiAttachmentDelivery;
|
|
1028
1396
|
|
|
1029
1397
|
if (this.isTextMime(attachment.mimeType)) {
|
|
1030
1398
|
text = this.toUtf8Text(attachment.buffer);
|
|
1399
|
+
delivery = 'text';
|
|
1031
1400
|
} else if (this.isPdfMime(attachment.mimeType)) {
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1401
|
+
const pdfText = await this.extractPdfText(attachment.buffer);
|
|
1402
|
+
text = pdfText || unreadablePdfMarker(attachment.filename);
|
|
1403
|
+
delivery = pdfText ? 'pdf-text' : 'unreadable-pdf';
|
|
1035
1404
|
} else {
|
|
1036
1405
|
text = binaryAttachmentMarker(
|
|
1037
1406
|
attachment.filename,
|
|
1038
1407
|
attachment.mimeType,
|
|
1039
1408
|
attachment.buffer.length,
|
|
1040
1409
|
);
|
|
1410
|
+
delivery = 'binary';
|
|
1041
1411
|
}
|
|
1042
1412
|
|
|
1043
|
-
return
|
|
1413
|
+
return {
|
|
1414
|
+
content: `${message}${attachedFileBlock(attachment.filename, text)}`,
|
|
1415
|
+
delivery,
|
|
1416
|
+
};
|
|
1044
1417
|
}
|
|
1045
1418
|
|
|
1046
1419
|
private isImageMime(mimeType: string) {
|
|
@@ -1100,6 +1473,15 @@ export class AiService implements OnModuleInit {
|
|
|
1100
1473
|
}
|
|
1101
1474
|
}
|
|
1102
1475
|
|
|
1476
|
+
/** Páginas do PDF, ou 0 quando ele não abre — nesse caso o teto não barra. */
|
|
1477
|
+
private async countPdfPages(buffer: Buffer): Promise<number> {
|
|
1478
|
+
try {
|
|
1479
|
+
return (await pdfParse(buffer))?.numpages ?? 0;
|
|
1480
|
+
} catch {
|
|
1481
|
+
return 0;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1103
1485
|
private toUtf8Text(buffer: Buffer) {
|
|
1104
1486
|
return buffer.toString('utf8');
|
|
1105
1487
|
}
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
applyClaudeModelParams,
|
|
3
3
|
applyGeminiModelParams,
|
|
4
4
|
applyOpenAiModelParams,
|
|
5
|
+
applyOpenAiResponsesParams,
|
|
5
6
|
AiPromptResolverService,
|
|
6
7
|
mergeModelParams,
|
|
7
8
|
type ResolvedPrompt,
|
|
@@ -279,6 +280,49 @@ describe('mergeModelParams', () => {
|
|
|
279
280
|
});
|
|
280
281
|
});
|
|
281
282
|
|
|
283
|
+
describe('applyOpenAiResponsesParams', () => {
|
|
284
|
+
const input = [{ role: 'user', content: [{ type: 'input_text', text: 'oi' }] }];
|
|
285
|
+
|
|
286
|
+
it('não adiciona nada quando o registro e os defaults estão vazios', () => {
|
|
287
|
+
const body = applyOpenAiResponsesParams({ input }, resolved(), {
|
|
288
|
+
model: 'gpt-4o',
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(body).toEqual({ model: 'gpt-4o', input });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Os três renames do endpoint moram só nesta função.
|
|
295
|
+
it('usa max_output_tokens, nunca max_tokens', () => {
|
|
296
|
+
const body = applyOpenAiResponsesParams({ input }, resolved({ maxTokens: 500 }), {
|
|
297
|
+
model: 'gpt-4o',
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(body.max_output_tokens).toBe(500);
|
|
301
|
+
expect(body).not.toHaveProperty('max_tokens');
|
|
302
|
+
expect(body).not.toHaveProperty('max_completion_tokens');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('põe o json_object sob text.format', () => {
|
|
306
|
+
const body = applyOpenAiResponsesParams({ input }, resolved(), {
|
|
307
|
+
model: 'gpt-4o',
|
|
308
|
+
jsonObject: true,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(body.text).toEqual({ format: { type: 'json_object' } });
|
|
312
|
+
expect(body).not.toHaveProperty('response_format');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('o modelo do registro vence o default do código', () => {
|
|
316
|
+
const body = applyOpenAiResponsesParams(
|
|
317
|
+
{ input },
|
|
318
|
+
resolved({ model: 'gpt-4.1', temperature: 0.3 }),
|
|
319
|
+
{ model: 'gpt-4o' },
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(body).toMatchObject({ model: 'gpt-4.1', temperature: 0.3 });
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
282
326
|
describe('applyOpenAiModelParams', () => {
|
|
283
327
|
const messages = [{ role: 'user', content: 'oi' }];
|
|
284
328
|
|