Spaces:
Sleeping
Sleeping
File size: 8,478 Bytes
6cd3ae1 9a951d7 fbcc12a 9a951d7 0d43de6 9a951d7 a1af4d9 9a951d7 6cd3ae1 abe21d0 9a951d7 6cd3ae1 9a951d7 6cd3ae1 9a951d7 6cd3ae1 2f666f7 6cd3ae1 9a951d7 fbcc12a 9a951d7 fbcc12a abe21d0 fbcc12a abe21d0 2f666f7 abe21d0 9a951d7 abe21d0 9a951d7 0d43de6 6cd3ae1 0d43de6 001ddc5 6cd3ae1 001ddc5 9a951d7 6cd3ae1 9a951d7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 | import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as http from 'http';
import * as https from 'https';
import axios from 'axios';
export interface SynthesizedSpeech {
stream: NodeJS.ReadableStream;
contentType: string;
}
export interface ExtractedResume {
text: string;
sections: {
contact?: Record<string, string>;
experience?: string[];
education?: string[];
skills?: string[];
summary?: string;
};
metadata: {
hasTables: boolean;
hasImages: boolean;
pageCount: number;
confidence: number;
hasMultipleColumns?: boolean;
fontCount?: number;
minFontSize?: number | null;
averageFontSize?: number | null;
averageCharsPerPage?: number;
longLineCount?: number;
bulletCount?: number;
};
}
/**
* Upper bound for extraction. The Python service is a remote dependency that
* can sleep (HF Spaces idle) or wedge; without a timeout axios waits forever
* and every upload pins a connection until the process dies.
*/
const EXTRACT_TIMEOUT_MS = 60_000;
/**
* The Python Space sleeps when idle, and waking it takes most of a minute — a
* cold start was measured answering its own root path in 58s. That means the
* first upload after an idle period reliably failed and the user was told the
* extraction service was unavailable, when in truth their request had just woken
* it up and the next one would have worked.
*
* The first attempt is therefore treated as the wake-up call: on an error that
* looks like "not awake yet" we wait and try once more.
*/
const EXTRACT_ATTEMPTS = 2;
const WAKE_RETRY_DELAY_MS = 15_000;
/**
* Does this failure look like a sleeping or still-booting service rather than a
* genuine rejection? A 4xx means the service answered and said no — retrying
* that would just waste the user's time.
*/
function looksAsleep(error: unknown): boolean {
if (!axios.isAxiosError(error)) return false;
const status = error.response?.status;
if (status !== undefined) {
// 503/504 from the Space itself or the gateway in front of it while booting.
return status === 502 || status === 503 || status === 504;
}
// No response at all: timeout, refused, reset, DNS not ready.
return ['ECONNABORTED', 'ETIMEDOUT', 'ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN']
.includes(error.code ?? '');
}
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
@Injectable()
export class PythonBridgeService {
private readonly logger = new Logger(PythonBridgeService.name);
private readonly baseUrl: string;
private readonly serviceKey: string | undefined;
constructor(private readonly configService: ConfigService) {
this.baseUrl =
this.configService.get<string>('PYTHON_SERVICE_URL') ??
'http://localhost:8000';
this.serviceKey = this.configService.get<string>('SERVICE_API_KEY');
if (!this.serviceKey) {
this.logger.warn(
'SERVICE_API_KEY is not set: calls to the Python service will be rejected.',
);
}
}
/** Shared-secret header the Python service checks on every compute route. */
private authHeaders(): Record<string, string> {
return this.serviceKey ? { 'x-service-key': this.serviceKey } : {};
}
/**
* Call Python service to extract text and structure from a PDF resume buffer
*/
async extractResume(fileBuffer: Buffer, originalFileName: string): Promise<ExtractedResume> {
const isDocx = originalFileName.toLowerCase().endsWith('.docx');
const contentType = isDocx
? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
: 'application/pdf';
for (let attempt = 1; attempt <= EXTRACT_ATTEMPTS; attempt++) {
try {
// Rebuilt per attempt: a FormData that has already been streamed cannot
// be replayed.
const formData = new FormData();
const blob = new Blob([new Uint8Array(fileBuffer)], { type: contentType });
formData.append('file', blob, originalFileName);
const response = await axios.post<ExtractedResume>(
`${this.baseUrl}/extract-resume`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
...this.authHeaders(),
},
timeout: EXTRACT_TIMEOUT_MS,
},
);
return response.data;
} catch (error) {
// Log status/message only — the full axios error carries the request
// config, including the service key and the uploaded resume body.
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
const message = error instanceof Error ? error.message : String(error);
const retryable = attempt < EXTRACT_ATTEMPTS && looksAsleep(error);
this.logger.error(
`Resume extraction failed (attempt ${attempt}/${EXTRACT_ATTEMPTS})` +
`${status ? ` (HTTP ${status})` : ''}: ${message}` +
`${retryable ? `: service looks asleep, retrying in ${WAKE_RETRY_DELAY_MS / 1000}s` : ''}`,
);
if (!retryable) {
throw new ServiceUnavailableException(
'PDF extraction service is currently unavailable. Please try again later.',
);
}
await sleep(WAKE_RETRY_DELAY_MS);
}
}
// Unreachable: the final attempt either returns or throws above.
throw new ServiceUnavailableException(
'PDF extraction service is currently unavailable. Please try again later.',
);
}
/**
* Call the Python service to synthesize speech (Kokoro TTS) for the given text.
* Returns the upstream audio as a stream (piped straight to the client instead
* of buffering the whole clip in memory, so first audio reaches the browser as
* soon as Python starts sending). Throws ServiceUnavailableException if TTS is
* down, so callers can fall back to browser speech synthesis.
*/
async synthesizeSpeech(text: string, voice?: string, format?: string): Promise<SynthesizedSpeech> {
try {
const response = await axios.post<NodeJS.ReadableStream>(
`${this.baseUrl}/tts`,
{ text, voice, format },
{ responseType: 'stream', timeout: 30000, headers: this.authHeaders() },
);
return {
stream: response.data,
contentType: (response.headers['content-type'] as string) ?? 'audio/wav',
};
} catch {
throw new ServiceUnavailableException('Speech synthesis is currently unavailable.');
}
}
/**
* Fire a synthesis request purely to warm the Python service's audio cache
* (e.g. pre-generating question audio right after /generate, so the browser's
* later requests are cache hits instead of multi-second live syntheses).
* The response body is discarded; failures propagate so callers can stop
* warming when TTS is down.
*/
async warmSpeech(text: string, format: string = 'ogg'): Promise<void> {
await axios.post(
`${this.baseUrl}/tts`,
{ text, format },
{ responseType: 'arraybuffer', timeout: 60000, headers: this.authHeaders() },
);
}
/**
* Generic POST helper — keeps all HTTP logic in one place
*/
private post<T>(path: string, body: Record<string, any>): Promise<T> {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const url = new URL(this.baseUrl + path);
const isHttps = url.protocol === 'https:';
const transport = isHttps ? https : http;
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
...this.authHeaders(),
},
};
const req = transport.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
resolve(JSON.parse(data) as T);
} catch {
reject(new Error('Invalid JSON response from Python service'));
}
});
});
req.on('error', reject);
req.setTimeout(30000, () => {
req.destroy();
reject(new Error('Python service request timed out'));
});
req.write(payload);
req.end();
});
}
}
|