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();
    });
  }
}