Coverage for src/ai_lls_lib/core/processor.py: 99%

270 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-24 12:44 +0000

1""" 

2Bulk CSV processing for phone verification 

3""" 

4 

5import csv 

6import os 

7from collections.abc import Iterable, Iterator, Sequence 

8from concurrent.futures import ThreadPoolExecutor 

9from dataclasses import dataclass, field 

10from io import StringIO 

11 

12from aws_lambda_powertools import Logger 

13 

14from ..providers.exceptions import ProviderError, ProviderFailureError 

15from .models import PhoneVerification 

16from .verifier import PhoneVerifier 

17 

18logger = Logger() 

19 

20# Key under which csv.DictReader stores fields beyond the header count (ragged rows). 

21# Never a real header name, so it cannot collide with customer columns. 

22_SURPLUS_KEY = "__surplus_fields__" 

23 

24 

25def _dict_reader(source: StringIO, fieldnames: Sequence[str] | None = None) -> csv.DictReader: 

26 """DictReader that tolerates ragged rows. 

27 

28 Extra fields are kept under _SURPLUS_KEY instead of None (which crashes DictWriter), 

29 and missing fields read as "" instead of None (which crashes .strip()). 

30 """ 

31 return csv.DictReader(source, fieldnames=fieldnames, restkey=_SURPLUS_KEY, restval="") 

32 

33 

34def _fold_surplus(row: dict, headers: Sequence[str]) -> bool: 

35 """Move surplus fields from a ragged row into its last original column. 

36 

37 Customer data is never discarded: surplus values are re-joined with commas into the 

38 last header's cell so they survive into the results file. Surplus made only of empty 

39 strings (a trailing separator) is dropped as noise. Returns True if data was folded. 

40 Call this AFTER reading the phone value so the lookup key is never altered. 

41 """ 

42 surplus = row.pop(_SURPLUS_KEY, None) 

43 if not surplus or not headers: 

44 return False 

45 if not any(value != "" for value in surplus): 

46 return False 

47 last = headers[-1] 

48 row[last] = ",".join([row.get(last, ""), *surplus]) 

49 return True 

50 

51 

52# Output marker for rows the provider never answered for. Distinct from "unknown", 

53# which is a genuine classification returned by the provider. 

54VERIFICATION_FAILED = "verification_failed" 

55 

56 

57@dataclass 

58class ProcessingReport: 

59 """Outcome of a bulk run, split by failure kind. 

60 

61 invalid_rows are customer data problems (a per-row skip). provider_failures and 

62 unexpected_failures are rows the service never got an answer for; they are the 

63 caller's signal that a job must not be reported as fully verified. 

64 """ 

65 

66 results: list[PhoneVerification] = field(default_factory=list) 

67 total_rows: int = 0 

68 empty_rows: int = 0 

69 invalid_rows: int = 0 

70 provider_failures: int = 0 

71 unexpected_failures: int = 0 

72 failed_phones: set[str] = field(default_factory=set) 

73 

74 @property 

75 def verified(self) -> int: 

76 return len(self.results) 

77 

78 @property 

79 def failures(self) -> int: 

80 return self.provider_failures + self.unexpected_failures 

81 

82 @property 

83 def attempted(self) -> int: 

84 return self.verified + self.failures + self.invalid_rows 

85 

86 @property 

87 def failure_rate(self) -> float: 

88 return self.failures / self.attempted if self.attempted else 0.0 

89 

90 

91class BulkProcessor: 

92 """Process CSV files for bulk phone verification""" 

93 

94 def __init__( 

95 self, 

96 verifier: PhoneVerifier, 

97 max_failure_rate: float = 0.2, 

98 max_consecutive_failures: int = 20, 

99 max_workers: int | None = None, 

100 ): 

101 """ 

102 Args: 

103 verifier: PhoneVerifier used for each row. Its provider and cache must be 

104 thread-safe when max_workers > 1 (the shipped ones are). 

105 max_failure_rate: abort the job (ProviderFailureError) when the share of rows the 

106 provider never answered for exceeds this, measured over attempted rows 

107 max_consecutive_failures: abort immediately after this many provider failures 

108 in a row, so a total outage does not burn a call per row 

109 max_workers: bounded thread pool size for provider calls; defaults from 

110 LLS_BULK_MAX_WORKERS (10). 1 runs rows serially on the calling thread. 

111 Per-row blocking HTTP at ~150 ms gives ~7 rows/s serial; 10 workers 

112 raise that roughly tenfold, which is what makes 20k-row files fit a 

113 Lambda budget. Input order is preserved in the results. 

114 """ 

115 self.verifier = verifier 

116 self.max_failure_rate = max_failure_rate 

117 self.max_consecutive_failures = max_consecutive_failures 

118 if max_workers is None: 

119 raw = os.environ.get("LLS_BULK_MAX_WORKERS", "") 

120 max_workers = int(raw) if raw else 10 

121 self.max_workers = max(1, max_workers) 

122 

123 def _record_row_failure( 

124 self, exc: Exception, row_num: int, phone: str, report: ProcessingReport, consecutive: int 

125 ) -> int: 

126 """Classify a per-row exception into the report and return the new consecutive count. 

127 

128 Raises ProviderFailureError as soon as consecutive provider failures hit the limit. 

129 """ 

130 if isinstance(exc, ProviderError): 

131 report.provider_failures += 1 

132 report.failed_phones.add(phone) 

133 logger.error(f"Provider failure at row {row_num}: {exc}") 

134 consecutive += 1 

135 elif isinstance(exc, ValueError): 

136 report.invalid_rows += 1 

137 logger.warning(f"Invalid phone at row {row_num}: {exc}") 

138 return 0 

139 else: 

140 report.unexpected_failures += 1 

141 report.failed_phones.add(phone) 

142 logger.error(f"Verification failed at row {row_num}: {exc}") 

143 consecutive += 1 

144 

145 if consecutive >= self.max_consecutive_failures: 

146 raise ProviderFailureError( 

147 f"Aborting: provider failed {consecutive} rows in a row (last: {exc})", 

148 failures=report.failures, 

149 attempted=report.attempted, 

150 ) 

151 return consecutive 

152 

153 def _check_failure_rate(self, report: ProcessingReport) -> None: 

154 """Raise ProviderFailureError if too many rows were never verified.""" 

155 if report.failures and report.failure_rate > self.max_failure_rate: 

156 raise ProviderFailureError( 

157 f"Provider failed {report.failures} of {report.attempted} rows " 

158 f"({report.failure_rate:.0%}); job cannot be reported as verified", 

159 failures=report.failures, 

160 attempted=report.attempted, 

161 ) 

162 

163 def process_csv(self, csv_text: str, phone_column: str = "phone") -> list[PhoneVerification]: 

164 """ 

165 Process CSV text content. 

166 Returns list of verification results. 

167 

168 Raises ProviderFailureError when the provider failed for too many rows; see 

169 process_csv_report for the per-kind counts. 

170 """ 

171 return self.process_csv_report(csv_text, phone_column).results 

172 

173 def process_csv_report(self, csv_text: str, phone_column: str = "phone") -> ProcessingReport: 

174 """ 

175 Process CSV text content and return a ProcessingReport with results and counts. 

176 

177 Raises ProviderFailureError when the provider failed for more than max_failure_rate 

178 of attempted rows, or for max_consecutive_failures rows in a row. 

179 """ 

180 report = ProcessingReport() 

181 results = report.results 

182 consecutive = 0 

183 

184 try: 

185 # Strip UTF-8 BOM if present (Excel on Windows adds this) 

186 csv_text = csv_text.lstrip("\ufeff") 

187 

188 # Use StringIO to parse CSV text 

189 csv_file = StringIO(csv_text) 

190 reader = _dict_reader(csv_file) 

191 

192 # Find phone column (case-insensitive) 

193 headers = reader.fieldnames or [] 

194 phone_col = self._find_phone_column(headers, phone_column) 

195 

196 if not phone_col: 

197 raise ValueError(f"Phone column '{phone_column}' not found in CSV") 

198 

199 logger.info( 

200 f"Starting CSV processing using phone column '{phone_col}' " 

201 f"with {self.max_workers} worker(s)" 

202 ) 

203 

204 # Collect the work list first so the pool can be fed in input order 

205 work: list[tuple[int, str]] = [] 

206 for row_num, row in enumerate(reader, start=2): # Start at 2 (header is 1) 

207 report.total_rows += 1 

208 phone = row.get(phone_col, "").strip() 

209 if not phone: 

210 report.empty_rows += 1 

211 logger.warning(f"Empty phone at row {row_num}") 

212 continue 

213 work.append((row_num, phone)) 

214 

215 # Each outcome is either a PhoneVerification or the exception for that row. 

216 # Ordered iteration keeps results in input order regardless of completion order. 

217 for row_num, phone, outcome in self._verify_all(work): 

218 if isinstance(outcome, PhoneVerification): 

219 results.append(outcome) 

220 consecutive = 0 

221 if len(results) % 100 == 0: 

222 logger.info(f"Processed {len(results)} phones (at row {row_num})") 

223 else: 

224 consecutive = self._record_row_failure( 

225 outcome, row_num, phone, report, consecutive 

226 ) 

227 

228 self._check_failure_rate(report) 

229 logger.info( 

230 f"Completed processing: {report.verified} verified, " 

231 f"{report.invalid_rows} invalid, {report.failures} not verified" 

232 ) 

233 

234 except Exception as e: 

235 logger.error(f"CSV processing failed: {str(e)}") 

236 raise 

237 

238 return report 

239 

240 def _verify_one(self, phone: str) -> PhoneVerification | Exception: 

241 """Verify a single phone, returning the exception instead of raising.""" 

242 try: 

243 return self.verifier.verify(phone) 

244 except Exception as e: # classified by the caller 

245 return e 

246 

247 def _verify_all( 

248 self, work: list[tuple[int, str]] 

249 ) -> Iterator[tuple[int, str, PhoneVerification | Exception]]: 

250 """Yield (row_num, phone, outcome) in input order, using the bounded worker pool. 

251 

252 Serial when max_workers is 1. Otherwise rows are submitted in order and outcomes 

253 yielded in order; if the consumer stops early (a ProviderFailureError from the 

254 failure classifier), the remaining queued rows are cancelled instead of run. 

255 """ 

256 if self.max_workers == 1 or len(work) <= 1: 

257 for row_num, phone in work: 

258 yield row_num, phone, self._verify_one(phone) 

259 return 

260 

261 workers = min(self.max_workers, len(work)) 

262 executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bulk-verify") 

263 try: 

264 futures = [executor.submit(self._verify_one, phone) for _, phone in work] 

265 for (row_num, phone), future in zip(work, futures, strict=True): 

266 yield row_num, phone, future.result() 

267 finally: 

268 executor.shutdown(wait=True, cancel_futures=True) 

269 

270 def _find_phone_column(self, headers: list[str] | Sequence[str], preferred: str) -> str | None: 

271 """Find phone column in headers (case-insensitive)""" 

272 # First try exact match 

273 for header in headers: 

274 if header.lower() == preferred.lower(): 

275 return header 

276 

277 # Common phone column names 

278 phone_patterns = [ 

279 "phone", 

280 "phone_number", 

281 "phonenumber", 

282 "mobile", 

283 "cell", 

284 "telephone", 

285 "tel", 

286 "number", 

287 "contact", 

288 ] 

289 

290 for header in headers: 

291 header_lower = header.lower() 

292 for pattern in phone_patterns: 

293 if pattern in header_lower: 

294 logger.info(f"Using column '{header}' as phone column") 

295 return header 

296 

297 return None 

298 

299 def generate_results_csv( 

300 self, 

301 original_csv_text: str, 

302 results: list[PhoneVerification], 

303 failed_phones: set[str] | None = None, 

304 ) -> str: 

305 """ 

306 Generate CSV with original data plus verification results. 

307 Adds columns: line_type, dnc, known_litigator 

308 Rows listed in failed_phones (raw phone values the provider never answered for) 

309 are marked verification_failed rather than unknown. 

310 Returns CSV text string. 

311 """ 

312 failed_phones = failed_phones or set() 

313 # Create lookup dict 

314 results_map = {r.phone_number: r for r in results} 

315 

316 # Parse original CSV (strip UTF-8 BOM if present) 

317 original_csv_text = original_csv_text.lstrip("\ufeff") 

318 input_file = StringIO(original_csv_text) 

319 reader = _dict_reader(input_file) 

320 headers = list(reader.fieldnames or []) 

321 

322 # Add new columns 

323 output_headers = headers + ["line_type", "dnc", "known_litigator"] 

324 

325 # Create output CSV in memory 

326 output = StringIO() 

327 writer = csv.DictWriter(output, fieldnames=output_headers) 

328 writer.writeheader() 

329 

330 phone_col = self._find_phone_column(headers, "phone") 

331 folded_rows = 0 

332 

333 for row in reader: 

334 # Read the lookup key before folding so surplus never alters it 

335 phone = row.get(phone_col, "").strip() 

336 if _fold_surplus(row, headers): 

337 folded_rows += 1 

338 

339 # Try to normalize for lookup 

340 try: 

341 normalized = self.verifier.normalize_phone(phone) 

342 if phone in failed_phones: 

343 row["line_type"] = VERIFICATION_FAILED 

344 row["dnc"] = "" 

345 row["known_litigator"] = "" 

346 elif normalized in results_map: 

347 result = results_map[normalized] 

348 row["line_type"] = result.line_type.value 

349 row["dnc"] = "true" if result.dnc else "false" 

350 row["known_litigator"] = "true" if result.known_litigator else "false" 

351 else: 

352 row["line_type"] = "unknown" 

353 row["dnc"] = "" 

354 row["known_litigator"] = "" 

355 except Exception: 

356 row["line_type"] = "invalid" 

357 row["dnc"] = "" 

358 row["known_litigator"] = "" 

359 

360 writer.writerow(row) 

361 

362 if folded_rows: 

363 logger.warning(f"Folded surplus fields into last column on {folded_rows} ragged rows") 

364 

365 # Return CSV text 

366 return output.getvalue() 

367 

368 def process_csv_stream( 

369 self, lines: Iterable[str], phone_column: str = "phone", batch_size: int = 100 

370 ) -> Iterator[list[PhoneVerification]]: 

371 """ 

372 Process CSV lines as a stream, yielding batches of results. 

373 Memory-efficient for large files. 

374 

375 Args: 

376 lines: Iterator of CSV lines (including header) 

377 phone_column: Column name containing phone numbers 

378 batch_size: Number of results to accumulate before yielding 

379 

380 Yields: 

381 Batches of PhoneVerification results 

382 """ 

383 lines_list = list(lines) # Need to iterate twice - once for headers, once for data 

384 

385 if not lines_list: 

386 logger.error("Empty CSV stream") 

387 return 

388 

389 # Parse header (strip UTF-8 BOM if present) 

390 header_line = lines_list[0].lstrip("\ufeff") 

391 reader = _dict_reader(StringIO(header_line)) 

392 headers = reader.fieldnames or [] 

393 phone_col = self._find_phone_column(headers, phone_column) 

394 

395 if not phone_col: 

396 raise ValueError(f"Phone column '{phone_column}' not found in CSV") 

397 

398 batch = [] 

399 row_num = 2 # Start at 2 (header is 1) 

400 total_processed = 0 

401 report = ProcessingReport() 

402 consecutive = 0 

403 

404 # Process data lines 

405 for line in lines_list[1:]: 

406 if not line.strip(): 

407 continue 

408 

409 phone = "" 

410 try: 

411 # Parse single line 

412 row = next(_dict_reader(StringIO(line), fieldnames=headers)) 

413 phone = row.get(phone_col, "").strip() 

414 

415 if not phone: 

416 logger.warning(f"Empty phone at row {row_num}") 

417 row_num += 1 

418 continue 

419 

420 # Verify phone 

421 result = self.verifier.verify(phone) 

422 batch.append(result) 

423 total_processed += 1 

424 consecutive = 0 

425 

426 # Yield batch when full 

427 if len(batch) >= batch_size: 

428 logger.info( 

429 f"Processed batch of {len(batch)} phones (total: {total_processed}, at row {row_num})" 

430 ) 

431 yield batch 

432 batch = [] 

433 

434 except Exception as e: 

435 consecutive = self._record_row_failure(e, row_num, phone, report, consecutive) 

436 finally: 

437 row_num += 1 

438 

439 # Yield remaining results 

440 if batch: 

441 logger.info(f"Processed final batch of {len(batch)} phones (total: {total_processed})") 

442 yield batch 

443 

444 # Results were yielded, not kept; the report carries counts only 

445 report_attempted = total_processed + report.failures + report.invalid_rows 

446 if report.failures and report.failures / report_attempted > self.max_failure_rate: 

447 raise ProviderFailureError( 

448 f"Provider failed {report.failures} of {report_attempted} rows; " 

449 "job cannot be reported as verified", 

450 failures=report.failures, 

451 attempted=report_attempted, 

452 ) 

453 

454 logger.info(f"Stream processing completed. Total processed: {total_processed}") 

455 

456 def generate_results_csv_stream( 

457 self, 

458 original_lines: Iterable[str], 

459 results_stream: Iterator[list[PhoneVerification]], 

460 phone_column: str = "phone", 

461 failed_phones: set[str] | None = None, 

462 ) -> Iterator[str]: 

463 """ 

464 Generate CSV results as a stream, line by line. 

465 Memory-efficient for large files. 

466 

467 Args: 

468 original_lines: Iterator of original CSV lines 

469 results_stream: Iterator of batched PhoneVerification results 

470 phone_column: Column name containing phone numbers 

471 failed_phones: raw phone values the provider never answered for; marked 

472 verification_failed rather than unknown 

473 

474 Yields: 

475 CSV lines with verification results added 

476 """ 

477 failed_phones = failed_phones or set() 

478 lines_iter = iter(original_lines) 

479 

480 # Read and yield modified header 

481 try: 

482 header_line = next(lines_iter).lstrip("\ufeff") 

483 reader = _dict_reader(StringIO(header_line)) 

484 headers = list(reader.fieldnames or []) 

485 

486 # Add new columns 

487 output_headers = headers + ["line_type", "dnc", "known_litigator"] 

488 yield ",".join(output_headers) + "\n" 

489 

490 phone_col = self._find_phone_column(headers, phone_column) 

491 

492 except StopIteration: 

493 return 

494 

495 # Build results lookup from stream 

496 results_map = {} 

497 for batch in results_stream: 

498 for result in batch: 

499 results_map[result.phone_number] = result 

500 

501 # Reset lines iterator 

502 lines_iter = iter(original_lines) 

503 next(lines_iter) # Skip header 

504 

505 # Process and yield data lines 

506 for line in lines_iter: 

507 if not line.strip(): 

508 continue 

509 

510 row = next(_dict_reader(StringIO(line), fieldnames=headers)) 

511 # Read the lookup key before folding so surplus never alters it 

512 phone = row.get(phone_col, "").strip() 

513 _fold_surplus(row, headers) 

514 

515 # Add verification results 

516 try: 

517 normalized = self.verifier.normalize_phone(phone) 

518 if phone in failed_phones: 

519 row["line_type"] = VERIFICATION_FAILED 

520 row["dnc"] = "" 

521 row["known_litigator"] = "" 

522 elif normalized in results_map: 

523 result = results_map[normalized] 

524 row["line_type"] = result.line_type.value 

525 row["dnc"] = "true" if result.dnc else "false" 

526 row["known_litigator"] = "true" if result.known_litigator else "false" 

527 else: 

528 row["line_type"] = "unknown" 

529 row["dnc"] = "" 

530 row["known_litigator"] = "" 

531 except Exception: 

532 row["line_type"] = "invalid" 

533 row["dnc"] = "" 

534 row["known_litigator"] = "" 

535 

536 # Write row 

537 output = StringIO() 

538 writer = csv.DictWriter(output, fieldnames=output_headers) 

539 writer.writerow(row) 

540 yield output.getvalue()