Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import logging | |
| from dataclasses import dataclass, field | |
| logger = logging.getLogger(__name__) | |
| # Each doc type maps keywords to their individual signal weight (0.0–1.0). | |
| # Higher weight = stronger evidence for that doc type when matched. | |
| _KEYWORD_MAP: dict[str, dict[str, float]] = { | |
| "mtr": { | |
| "material test": 1.0, | |
| "mill test": 1.0, | |
| " mtr ": 1.0, | |
| "material remarks": 0.7, | |
| "inspection certificate": 0.7, | |
| "inspection document": 0.7, | |
| "certificate no.": 0.7, | |
| "certificate number": 0.7, | |
| "test specimen": 0.6, | |
| "test certificate": 0.6, | |
| "specimen": 0.4, | |
| "product test": 0.4, | |
| "heat test": 0.4, | |
| "hardness test": 0.4, | |
| "heat treatment": 0.4, | |
| "chemical composition": 0.4, | |
| "chemical analysis": 0.4, | |
| "flang test": 0.4, | |
| "flattening test": 0.4, | |
| "flaring test": 0.4, | |
| "material": 0.2, | |
| # "certificate": 0.2, | |
| }, | |
| "po": { | |
| "purchase order": 1.0, | |
| "sales order": 1.0, | |
| "total sales order amount": 1.0, | |
| "p.o.": 0.6, | |
| " po ": 0.6, | |
| "po date": 0.5, | |
| "po number": 0.5, | |
| " po#": 0.5, | |
| " po.": 0.5, | |
| " so#": 0.5, | |
| " so.": 0.5, | |
| "total due": 0.5, | |
| }, | |
| "invoice": { | |
| "invoice": 0.6, | |
| "customer statement": 1.0, | |
| "invoice #": 1.0, | |
| "invoice date": 0.6, | |
| "paid to": 0.5, | |
| "payment type": 0.5, | |
| "bill payment": 0.5, | |
| }, | |
| "quote": { | |
| "quotation": 1.0, | |
| "request for quote": 1.0, | |
| "rfq": 0.7, | |
| "quote": 0.8, | |
| "bid": 0.5, | |
| }, | |
| } | |
| # quote is classified but intentionally not routed to Dropbox | |
| _ROUTABLE: frozenset[str] = frozenset({"po", "invoice", "mtr"}) | |
| class KeywordMatch: | |
| keyword: str | |
| weight: float | |
| filename_hits: int | |
| ocr_hits: int | |
| filename_contrib: float | |
| ocr_contrib: float | |
| class ClassifyResult: | |
| doc_type: str | |
| reason: str | |
| scores: dict[str, float] = field(default_factory=dict) | |
| # Only doc types with at least one keyword hit are present. | |
| breakdown: dict[str, list[KeywordMatch]] = field(default_factory=dict) | |
| class ScoringWeights: | |
| filename: float | |
| ocr: float | |
| freq_multiplier: float | |
| min_threshold: float | |
| def classify( | |
| filename: str, | |
| file_bytes: bytes, | |
| content_type: str, | |
| weights: ScoringWeights, | |
| ) -> ClassifyResult: | |
| if not content_type.startswith("application/pdf"): | |
| return ClassifyResult(doc_type="skipped", reason="non_pdf", scores={}) | |
| filename_lower = filename.lower() if filename else "" | |
| ocr_text = _ocr_pdf(file_bytes) | |
| scores: dict[str, float] = {} | |
| breakdown: dict[str, list[KeywordMatch]] = {} | |
| for doc_type, keywords in _KEYWORD_MAP.items(): | |
| score = 0.0 | |
| matches: list[KeywordMatch] = [] | |
| for keyword, kw_weight in keywords.items(): | |
| fn_hits = filename_lower.count(keyword) | |
| ocr_hits = ocr_text.count(keyword) if ocr_text else 0 | |
| fn_contrib = 0.0 | |
| ocr_contrib = 0.0 | |
| if fn_hits > 0: | |
| fn_contrib = weights.filename * kw_weight * (1 + (fn_hits - 1) * weights.freq_multiplier) | |
| score += fn_contrib | |
| if ocr_hits > 0: | |
| ocr_contrib = weights.ocr * kw_weight * (1 + (ocr_hits - 1) * weights.freq_multiplier) | |
| score += ocr_contrib | |
| if fn_hits > 0 or ocr_hits > 0: | |
| matches.append(KeywordMatch( | |
| keyword=keyword, | |
| weight=kw_weight, | |
| filename_hits=fn_hits, | |
| ocr_hits=ocr_hits, | |
| filename_contrib=round(fn_contrib, 4), | |
| ocr_contrib=round(ocr_contrib, 4), | |
| )) | |
| scores[doc_type] = round(score, 4) | |
| if matches: | |
| breakdown[doc_type] = matches | |
| max_score = max(scores.values(), default=0.0) | |
| if max_score < weights.min_threshold: | |
| return ClassifyResult(doc_type="unknown", reason="below_threshold", scores=scores, breakdown=breakdown) | |
| above_threshold = [t for t, s in scores.items() if s >= weights.min_threshold] | |
| if len(above_threshold) > 1: | |
| return ClassifyResult(doc_type="ambiguous", reason="ambiguous", scores=scores, breakdown=breakdown) | |
| winner = above_threshold[0] | |
| if winner not in _ROUTABLE: | |
| return ClassifyResult(doc_type=winner, reason="not_routable", scores=scores, breakdown=breakdown) | |
| return ClassifyResult(doc_type=winner, reason="routed", scores=scores, breakdown=breakdown) | |
| def _ocr_pdf(file_bytes: bytes) -> str: | |
| from pdf2image import convert_from_bytes | |
| import pytesseract | |
| images = convert_from_bytes(file_bytes, first_page=1, last_page=2) | |
| return "\n".join(pytesseract.image_to_string(img).lower() for img in images) | |