Skip to content

Commit 2964dee

Browse files
authored
Merge pull request #60 from donvito/feature/pdf-translate
Add PDF translation feature with new endpoint and demo
2 parents ec49788 + 35c38f2 commit 2964dee

File tree

11 files changed

+1558
-32
lines changed

11 files changed

+1558
-32
lines changed

src/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ function configureApiSecurity(app: OpenAPIHono, tokenConfig: string) {
5555
path === '/api/v1/project-planner-demo' ||
5656
path === '/api/v1/outline-demo' ||
5757
path === '/api/v1/pdf-summarizer-demo' ||
58+
path === '/api/v1/pdf-translate-demo' ||
5859
path === '/api/models' ||
5960
path === '/api/jsoneditor' ||
6061
// Public read-only service catalog for demos

src/config/models.json

Lines changed: 29 additions & 29 deletions
Large diffs are not rendered by default.

src/config/models.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as path from 'path'
44
export type ModelCapability =
55
| 'summarize'
66
| 'pdf-summarizer'
7+
| 'pdf-translate'
78
| 'rewrite'
89
| 'compose'
910
| 'keywords'
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { OpenAPIHono, createRoute } from '@hono/zod-openapi'
2+
import { readFileSync } from 'fs'
3+
import { join } from 'path'
4+
5+
const router = new OpenAPIHono()
6+
7+
const demoRoute = createRoute({
8+
method: 'get',
9+
path: '/',
10+
responses: {
11+
200: {
12+
description: 'Returns the PDF Translation demo page.',
13+
content: {
14+
'text/html': {
15+
schema: { type: 'string' }
16+
}
17+
}
18+
}
19+
},
20+
tags: ['Demos']
21+
})
22+
23+
function getPdfTranslateDemoHtml() {
24+
const templatePath = join(process.cwd(), 'src', 'templates', 'pdfTranslateDemo.html')
25+
return readFileSync(templatePath, 'utf-8')
26+
}
27+
28+
router.openapi(demoRoute, (c) => c.html(getPdfTranslateDemoHtml()))
29+
30+
export default {
31+
handler: router,
32+
mountPath: 'pdf-translate-demo'
33+
}

src/routes/v1/pdf-translate.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'
2+
import { Context } from 'hono'
3+
import { streamSSE } from 'hono/streaming'
4+
import { pdfTranslatePrompt } from '../../utils/prompts'
5+
import { handleError } from '../../utils/errorHandler'
6+
import { pdfTranslateRequestSchema, pdfTranslateResponseSchema, createPdfTranslateResponse } from '../../schemas/v1/pdf-translate'
7+
import { processTextOutputRequest } from '../../services/ai'
8+
import { apiVersion } from './versionConfig'
9+
import { createFinalResponse } from './finalResponse'
10+
import { writeTextStreamSSE } from './streamUtils'
11+
import { extractPDF, truncateText } from '../../utils/pdfExtractor'
12+
13+
const router = new OpenAPIHono()
14+
15+
// Maximum text length to send to the LLM (to avoid token limits)
16+
const MAX_PDF_TEXT_LENGTH = 50000
17+
18+
async function handlePdfTranslateRequest(c: Context) {
19+
try {
20+
const { payload, config } = await c.req.json()
21+
const provider = config.provider
22+
const model = config.model
23+
const isStreaming = config.stream || false
24+
25+
// Extract PDF content
26+
const pdfData = await extractPDF(payload.url)
27+
28+
if (!pdfData.text || pdfData.text.length === 0) {
29+
throw new Error('No text content found in the PDF')
30+
}
31+
32+
// Truncate text if it's too long
33+
const textToTranslate = truncateText(pdfData.text, MAX_PDF_TEXT_LENGTH)
34+
35+
// Create the prompt
36+
const prompt = pdfTranslatePrompt(textToTranslate, payload.targetLanguage)
37+
38+
// Handle streaming response
39+
if (isStreaming) {
40+
const result = await processTextOutputRequest(prompt, config)
41+
42+
// Set SSE headers
43+
c.header('Content-Type', 'text/event-stream')
44+
c.header('Cache-Control', 'no-cache')
45+
c.header('Connection', 'keep-alive')
46+
47+
return streamSSE(c, async (stream) => {
48+
try {
49+
await writeTextStreamSSE(
50+
stream,
51+
result,
52+
{
53+
provider,
54+
model,
55+
version: apiVersion
56+
},
57+
{
58+
extraDone: {
59+
pdfMetadata: {
60+
title: pdfData.title,
61+
author: pdfData.author,
62+
pages: pdfData.pages,
63+
extractedTextLength: pdfData.text.length,
64+
}
65+
}
66+
}
67+
)
68+
} catch (error) {
69+
console.error('Streaming error:', error)
70+
try {
71+
await stream.writeSSE({
72+
data: JSON.stringify({
73+
error: error instanceof Error ? error.message : 'Streaming error',
74+
done: true
75+
})
76+
})
77+
} catch (writeError) {
78+
console.error('Error writing error message to stream:', writeError)
79+
}
80+
} finally {
81+
try {
82+
await stream.close()
83+
} catch (closeError) {
84+
console.error('Error closing stream:', closeError)
85+
}
86+
}
87+
})
88+
}
89+
90+
// Handle non-streaming response
91+
const result = await processTextOutputRequest(prompt, config)
92+
const finalResponse = createPdfTranslateResponse(
93+
result.text,
94+
provider,
95+
model,
96+
{
97+
title: pdfData.title,
98+
author: pdfData.author,
99+
pages: pdfData.pages,
100+
extractedTextLength: pdfData.text.length,
101+
},
102+
{
103+
input_tokens: result.usage.promptTokens,
104+
output_tokens: result.usage.completionTokens,
105+
total_tokens: result.usage.totalTokens,
106+
}
107+
)
108+
109+
const finalResponseWithVersion = createFinalResponse(finalResponse, apiVersion)
110+
111+
return c.json(finalResponseWithVersion, 200)
112+
} catch (error) {
113+
return handleError(c, error, 'Failed to translate PDF')
114+
}
115+
}
116+
117+
router.openapi(
118+
createRoute({
119+
path: '/',
120+
method: 'post',
121+
security: [ { BearerAuth: [] } ],
122+
request: {
123+
body: {
124+
content: {
125+
'application/json': {
126+
schema: pdfTranslateRequestSchema
127+
}
128+
}
129+
}
130+
},
131+
responses: {
132+
200: {
133+
description: 'Returns the translated PDF content.',
134+
content: {
135+
'application/json': {
136+
schema: pdfTranslateResponseSchema
137+
}
138+
}
139+
},
140+
401: {
141+
description: 'Unauthorized - Bearer token required',
142+
content: {
143+
'application/json': {
144+
schema: z.object({
145+
error: z.string()
146+
})
147+
}
148+
}
149+
},
150+
400: {
151+
description: 'Bad request - Invalid URL or PDF cannot be processed',
152+
content: {
153+
'application/json': {
154+
schema: z.object({
155+
error: z.string()
156+
})
157+
}
158+
}
159+
}
160+
},
161+
summary: 'Translate PDF document',
162+
description: 'This endpoint receives a PDF URL and uses an LLM to translate the document\'s content to the target language.',
163+
tags: ['API']
164+
}),
165+
handlePdfTranslateRequest as any
166+
)
167+
168+
export default {
169+
handler: router,
170+
mountPath: 'pdf-translate'
171+
}

src/routes/v1/services.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ const modelsSchema = z.object({
7070
available: z.boolean()
7171
})
7272

73-
const capabilityEnum = z.enum(['summarize', 'pdf-summarizer', 'rewrite', 'compose', 'keywords', 'sentiment', 'emailReply', 'vision', 'askText', 'translate', 'meetingNotes', 'planning', 'outline'])
73+
const capabilityEnum = z.enum(['summarize', 'pdf-summarizer', 'pdf-translate', 'rewrite', 'compose', 'keywords', 'sentiment', 'emailReply', 'vision', 'askText', 'translate', 'meetingNotes', 'planning', 'outline'])
7474
const byProviderSchema = z.record(z.array(z.string()))
7575
const providerViewSchema = z.record(z.array(z.object({
7676
name: z.string(),
@@ -82,6 +82,7 @@ const providerViewSchema = z.record(z.array(z.object({
8282
byCapability: z.object({
8383
summarize: byProviderSchema,
8484
'pdf-summarizer': byProviderSchema,
85+
'pdf-translate': byProviderSchema,
8586
rewrite: byProviderSchema,
8687
compose: byProviderSchema,
8788
keywords: byProviderSchema,
@@ -115,6 +116,7 @@ async function handleGetModels(c: Context) {
115116
const byCapability = {
116117
summarize: getModelsByCapability('summarize'),
117118
'pdf-summarizer': getModelsByCapability('pdf-summarizer'),
119+
'pdf-translate': getModelsByCapability('pdf-translate'),
118120
rewrite: getModelsByCapability('rewrite'),
119121
compose: getModelsByCapability('compose'),
120122
keywords: getModelsByCapability('keywords'),

src/schemas/v1/pdf-translate.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { z } from 'zod'
2+
import { llmRequestSchema } from './llm'
3+
4+
/**
5+
* Body sent by the client.
6+
*/
7+
export const payloadSchema = z.object({
8+
url: z.string().url('URL must be valid'),
9+
targetLanguage: z.string().describe('Target language e.g. "english", "tagalog", "japanese", "korean", "chinese", "french", "spanish"'),
10+
})
11+
12+
export const pdfTranslateRequestSchema = z.object({
13+
payload: payloadSchema,
14+
config: llmRequestSchema
15+
})
16+
17+
/**
18+
* Successful response returned to the client.
19+
*/
20+
export const pdfTranslateResponseSchema = z.object({
21+
translation: z.string(),
22+
provider: z.string().optional().describe('The AI service that was actually used'),
23+
model: z.string().optional().describe('The model that was actually used'),
24+
pdfMetadata: z.object({
25+
title: z.string().optional(),
26+
author: z.string().optional(),
27+
pages: z.number().optional(),
28+
extractedTextLength: z.number().describe('Number of characters extracted from the PDF'),
29+
}).optional(),
30+
usage: z.object({
31+
input_tokens: z.number(),
32+
output_tokens: z.number(),
33+
total_tokens: z.number(),
34+
})
35+
})
36+
37+
export function createPdfTranslateResponse(
38+
translation: string,
39+
provider?: string,
40+
model?: string,
41+
pdfMetadata?: {
42+
title?: string,
43+
author?: string,
44+
pages?: number,
45+
extractedTextLength: number,
46+
},
47+
usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
48+
): z.infer<typeof pdfTranslateResponseSchema> {
49+
return {
50+
translation,
51+
provider,
52+
model,
53+
pdfMetadata,
54+
usage,
55+
};
56+
}
57+
58+
export type PdfTranslateReq = z.infer<typeof pdfTranslateRequestSchema>
59+
export type PdfTranslateRes = z.infer<typeof pdfTranslateResponseSchema>

src/templates/demos.html

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,52 @@ <h3 class="text-2xl font-bold text-gray-900 mb-3">PDF Summarization</h3>
204204
</div>
205205
</div>
206206

207+
<!-- PDF Translation Demo Card -->
208+
<div class="group relative overflow-hidden bg-white rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 h-full flex flex-col">
209+
<div class="absolute top-0 right-0 w-32 h-32 bg-gradient-brand opacity-10 rounded-full -mr-16 -mt-16 group-hover:scale-150 transition-transform duration-500"></div>
210+
<div class="p-8 flex flex-col h-full">
211+
<div class="w-12 h-12 bg-gradient-brand rounded-xl flex items-center justify-center text-white mb-4">
212+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
213+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
214+
</svg>
215+
</div>
216+
217+
<h3 class="text-2xl font-bold text-gray-900 mb-3">PDF Translation</h3>
218+
<p class="text-gray-600 mb-6 leading-relaxed">
219+
Extract and translate content from PDF documents to any language using AI models.
220+
</p>
221+
222+
<div class="space-y-3 mb-6">
223+
<div class="flex items-center text-sm text-gray-600">
224+
<svg class="w-4 h-4 mr-2 text-green-500" fill="currentColor" viewBox="0 0 20 20">
225+
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
226+
</svg>
227+
PDF text extraction
228+
</div>
229+
<div class="flex items-center text-sm text-gray-600">
230+
<svg class="w-4 h-4 mr-2 text-green-500" fill="currentColor" viewBox="0 0 20 20">
231+
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
232+
</svg>
233+
Multi-language support
234+
</div>
235+
<div class="flex items-center text-sm text-gray-600">
236+
<svg class="w-4 h-4 mr-2 text-green-500" fill="currentColor" viewBox="0 0 20 20">
237+
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
238+
</svg>
239+
Multiple AI providers
240+
</div>
241+
</div>
242+
243+
<a href="/api/v1/pdf-translate-demo"
244+
class="mt-auto inline-flex items-center justify-center relative px-6 pr-10 py-3 bg-gradient-brand text-white rounded-lg font-medium hover:opacity-90 transition-opacity duration-200 group">
245+
Try Demo
246+
<svg class="w-4 h-4 absolute right-3 top-1/2 -translate-y-1/2 group-hover:translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
247+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
248+
</svg>
249+
</a>
250+
</div>
251+
</div>
252+
207253
<!-- Sentiment Analysis Demo Card -->
208254
<div class="group relative overflow-hidden bg-white rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 h-full flex flex-col">
209255
<div class="absolute top-0 right-0 w-32 h-32 bg-gradient-brand opacity-10 rounded-full -mr-16 -mt-16 group-hover:scale-150 transition-transform duration-500"></div>

0 commit comments

Comments
 (0)