Download backend/parser.py from Sanjam19/FinSight: direct link, hf CLI and curl.
- Browser
- Download file 6.47 kB
-
https://huggingface.co/spaces/Sanjam19/FinSight/resolve/main/backend/parser.py
- Command line
-
hf download hf://spaces/Sanjam19/FinSight/backend/parser.py
-
curl -L -o parser.py https://huggingface.co/spaces/Sanjam19/FinSight/resolve/main/backend/parser.py
6.47 kB
| # backend/parser.py | |
| from backend.metrics_extractor import extract_metrics, get_spacy_nlp | |
| from backend.sector import detect_sector | |
| import fitz # PyMuPDF | |
| import re | |
| nlp = None | |
| def get_parser_nlp(): | |
| global nlp | |
| if nlp is not None: | |
| return nlp | |
| nlp = get_spacy_nlp() | |
| return nlp | |
| def _clean(text: str) -> str: | |
| text = re.sub(r"\n{3,}", "\n\n", text) | |
| text = re.sub(r" {2,}", " ", text) | |
| return text.strip() | |
| def pdf_to_pages(pdf_path: str) -> list: | |
| """Extract text per page (1-based page numbers preserved by index+1). | |
| HTML files count as a single 'page'. Each page is cleaned individually | |
| so char offsets computed AFTER joining stay valid for page mapping.""" | |
| path = str(pdf_path) | |
| # HTML file | |
| if path.endswith(".html") or path.endswith(".htm"): | |
| with open(path, "r", encoding="utf-8", errors="ignore") as f: | |
| raw = f.read() | |
| # strip HTML tags | |
| text = re.sub(r"<[^>]+>", " ", raw) | |
| text = re.sub(r" ", " ", text) | |
| text = re.sub(r"&", "&", text) | |
| text = re.sub(r"<", "<", text) | |
| text = re.sub(r">", ">", text) | |
| return [_clean(text)] | |
| # PDF file | |
| doc = fitz.open(path) | |
| pages = [_clean(page.get_text()) for page in doc] | |
| doc.close() | |
| return pages | |
| def pdf_to_text(pdf_path: str) -> str: | |
| return "\n".join(pdf_to_pages(pdf_path)) | |
| def page_offsets(pages: list) -> list: | |
| """Start char offset of each page in the '\\n'.join(pages) text.""" | |
| offsets = [] | |
| pos = 0 | |
| for p in pages: | |
| offsets.append(pos) | |
| pos += len(p) + 1 # +1 for the joining \n | |
| return offsets | |
| def char_pos_to_page(char_pos: int, offsets: list) -> int | None: | |
| """Map a char position in the joined text to a 1-based page number.""" | |
| if char_pos is None or not offsets: | |
| return None | |
| page = 1 | |
| for i, start in enumerate(offsets): | |
| if char_pos >= start: | |
| page = i + 1 | |
| else: | |
| break | |
| return page | |
| def extract_entities(text: str) -> list: | |
| parser_nlp = get_parser_nlp() | |
| if parser_nlp is None: | |
| print("WARNING: spaCy model 'en_core_web_sm' not installed; extracted entities will be empty.") | |
| return [] | |
| doc = parser_nlp(text[:50000]) # cap for speed | |
| entities = [] | |
| seen = set() | |
| for ent in doc.ents: | |
| if ent.label_ in {"ORG", "GPE", "MONEY", "DATE", "PRODUCT", "PERSON"}: | |
| key = (ent.text.strip(), ent.label_) | |
| if key not in seen: | |
| seen.add(key) | |
| entities.append({"text": ent.text.strip(), "label": ent.label_}) | |
| return entities | |
| def chunk_text(text: str, size: int = 512, overlap: int = 50) -> list: | |
| words = text.split() | |
| chunks = [] | |
| start = 0 | |
| while start < len(words): | |
| end = min(start + size, len(words)) | |
| chunks.append(" ".join(words[start:end])) | |
| if end == len(words): | |
| break | |
| start += size - overlap | |
| return chunks | |
| def chunk_pages(pages: list, size: int = 512, overlap: int = 50) -> list: | |
| """Chunk page-by-page so every chunk carries the page it came from. | |
| Chunks never span pages — a slight retrieval-quality trade for exact | |
| provenance, which is the point. Most report pages are under 512 words | |
| so this usually means one chunk per page anyway. | |
| Returns list of {"text": str, "page": int} (page is 1-based).""" | |
| out = [] | |
| for page_no, page_text in enumerate(pages, start=1): | |
| if not page_text.strip(): | |
| continue | |
| for piece in chunk_text(page_text, size=size, overlap=overlap): | |
| out.append({"text": piece, "page": page_no}) | |
| return out | |
| def _attach_pages(metrics: dict, offsets: list) -> dict: | |
| """Convert each money-metric's char_pos (and its alternatives') into a | |
| 1-based PDF page number. Ratio metrics are bare floats — passed through.""" | |
| for value in metrics.values(): | |
| if not isinstance(value, dict): | |
| continue | |
| value["page"] = char_pos_to_page(value.pop("char_pos", None), offsets) | |
| for alt in value.get("alternatives", []): | |
| if isinstance(alt, dict): | |
| alt["page"] = char_pos_to_page(alt.pop("char_pos", None), offsets) | |
| return metrics | |
| def parse_document(pdf_path: str, company: str, year: str) -> dict: | |
| print(f"Parsing {pdf_path}...") | |
| pages = pdf_to_pages(pdf_path) | |
| offsets = page_offsets(pages) | |
| text = "\n".join(pages) | |
| print(f"Text length: {len(text)} chars across {len(pages)} pages") | |
| # ascii-safe: PDF text often contains ligatures/₹ glyphs that crash | |
| # print() on Windows cp1252 consoles | |
| print("Sample:", text[:200].encode("ascii", "replace").decode("ascii")) | |
| # NOTE: company is now passed through to extract_metrics so the | |
| # entity-boundary detection in metrics_extractor.py can demote | |
| # numbers found inside a bundled subsidiary's section instead of | |
| # treating every match in the document as equally valid for `company`. | |
| metrics = _attach_pages(extract_metrics(text, company=company), offsets) | |
| # metric dicts carry raw PDF context strings — ascii-safe for the same | |
| # cp1252 console reason as the sample print above | |
| print("Metrics found:", str(metrics).encode("ascii", "replace").decode("ascii")) | |
| sector = detect_sector(text) | |
| print(f"Sector detected: {sector}") | |
| entities = extract_entities(text) | |
| print(f"Entities found: {len(entities)}") | |
| chunks = chunk_pages(pages) | |
| return { | |
| "company": company, | |
| "year": year, | |
| "file": pdf_path, | |
| "sector": sector, | |
| "char_count": len(text), | |
| "chunk_count": len(chunks), | |
| "metrics": metrics, | |
| "entities": entities, | |
| "chunks": [ | |
| { | |
| "chunk_id": f"{company}_{year}_{i:04d}", | |
| "company": company, | |
| "year": year, | |
| "page": c["page"], | |
| "text": c["text"] | |
| } | |
| for i, c in enumerate(chunks) | |
| ] | |
| } | |
| if __name__ == "__main__": | |
| sample = """ | |
| Apple Inc. reported total revenues of $394.3 billion for fiscal year 2022. | |
| Net income was $99.8 billion. Earnings per share reached $6.15. | |
| Total assets stood at $352.6 billion. Cash and cash equivalents were $23.6 billion. | |
| """ | |
| print("Metrics:", extract_metrics(sample, company="Apple")) | |
| print("Entities:", extract_entities(sample)) | |
| print("Chunks:", len(chunk_text(sample))) | |