Spaces:
Running
Running
| """ | |
| VI Portal PDF Data Extractor | |
| Extracts structured data from MarshBerry VI Portal reports. | |
| Each PDF is a ZIP of page images + OCR text files. | |
| """ | |
| import zipfile | |
| import json | |
| import re | |
| import os | |
| import csv | |
| from pathlib import Path | |
| from datetime import datetime | |
| def extract_text_pages(pdf_path): | |
| """Extract all text pages from a VI Portal PDF (ZIP of images + text).""" | |
| pages = {} | |
| with zipfile.ZipFile(pdf_path, 'r') as zf: | |
| # Read manifest for page count | |
| manifest = json.loads(zf.read('manifest.json')) | |
| num_pages = manifest['num_pages'] | |
| for i in range(1, num_pages + 1): | |
| txt_name = f"{i}.txt" | |
| try: | |
| text = zf.read(txt_name).decode('utf-8', errors='replace') | |
| pages[i] = text.replace('\r\n', '\n').replace('\r', '\n') | |
| except KeyError: | |
| pages[i] = "" | |
| return pages | |
| def find_page_by_header(pages, header_text): | |
| """Find a page whose first line contains a specific header.""" | |
| for page_num, text in pages.items(): | |
| first_line = text.strip().split('\n')[0] if text.strip() else "" | |
| if header_text.lower() in first_line.lower(): | |
| return page_num, text | |
| # Fallback: search anywhere in first 3 lines | |
| for page_num, text in pages.items(): | |
| lines = text.strip().split('\n')[:3] | |
| combined = ' '.join(lines).lower() | |
| if header_text.lower() in combined: | |
| return page_num, text | |
| return None, None | |
| def extract_effective_date(pages): | |
| """Extract effective date from page 1.""" | |
| text = pages.get(1, "") | |
| match = re.search(r'Effective Date:\s*(\w+ \d+,?\s*\d{4})', text) | |
| if match: | |
| date_str = match.group(1).replace(',', '') | |
| try: | |
| return datetime.strptime(date_str, "%B %d %Y") | |
| except: | |
| pass | |
| # Try alternate format | |
| match = re.search(r'Effective Date:\s*(\d{1,2}/\d{1,2}/\d{4})', text) | |
| if match: | |
| try: | |
| return datetime.strptime(match.group(1), "%m/%d/%Y") | |
| except: | |
| pass | |
| return None | |
| def parse_perspectives(text): | |
| """Parse CEO Perspectives / Profit-Growth-Operational-Equity perspectives page.""" | |
| data = {} | |
| lines = text.strip().split('\n') | |
| current_section = None | |
| for line in lines: | |
| line = line.strip() | |
| if 'PROFIT PERSPECTIVES' in line: | |
| current_section = 'profit' | |
| elif 'GROWTH PERSPECTIVES' in line: | |
| current_section = 'growth' | |
| elif 'OPERATIONAL PERSPECTIVES' in line: | |
| current_section = 'operational' | |
| elif 'EQUITY PERSPECTIVES' in line: | |
| current_section = 'equity' | |
| # Parse metric lines: "Metric Name value1 value2 value3 value4 value5 percentile" | |
| # e.g. "Reward Ratio 0.51 0.49 0.46 0.55 0.63 71" | |
| patterns = [ | |
| (r'Reward Ratio\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)', 'reward_ratio'), | |
| (r'Servicing Costs per \$ of Comm\*?\s+\$([\d.]+)\s+\$([\d.]+)\s+\$([\d.]+)\s+\$([\d.]+)\s+\$([\d.]+)\s+(\d+)', 'servicing_cost_per_comm'), | |
| (r'Employee Marginal Profitability\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+(\d+)', 'employee_marginal_profitability'), | |
| (r'Contingent & Override Consistency Ratio\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)', 'contingent_consistency_ratio'), | |
| (r'Sales Velocity\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+(\d+)', 'sales_velocity'), | |
| (r'Total Comm & Fees Growth Rate\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+(\d+)', 'total_cf_growth_rate'), | |
| (r'New Business Dollars per Production Person\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+(\d+)', 'new_biz_per_prod_person'), | |
| (r'Net Unvalidated Producer Payroll \(NUPP\)\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+(\d+)', 'nupp'), | |
| (r'Revenue Per Employee\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+(\d+)', 'revenue_per_employee'), | |
| (r'Total Comm & Fees Per Production Person\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+(\d+)', 'cf_per_prod_person'), | |
| (r'Total Comm & Fees Per Service Person\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+\$([\d,]+)\s+(\d+)', 'cf_per_service_person'), | |
| (r'Support Staff as % of Total Employees\*?\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+(\d+)', 'support_staff_pct'), | |
| (r'Debt Service Coverage Ratio\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)', 'debt_service_coverage'), | |
| (r'Trust Ratio\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)', 'trust_ratio'), | |
| (r'Defensive Interval.*?\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)', 'defensive_interval'), | |
| (r'Weighted Average Shareholder Age\*?\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)', 'weighted_avg_shareholder_age'), | |
| ] | |
| for pattern, key in patterns: | |
| m = re.search(pattern, line) | |
| if m: | |
| groups = m.groups() | |
| data[key] = { | |
| 'last_year': clean_number(groups[0]), | |
| 'this_year': clean_number(groups[1]), | |
| 'average': clean_number(groups[2]), | |
| 'best_25': clean_number(groups[3]), | |
| 'peak': clean_number(groups[4]), | |
| 'percentile': int(groups[5]), | |
| } | |
| return data | |
| def parse_key_return_metrics(text): | |
| """Parse Key Return Metrics page.""" | |
| data = {} | |
| lines = text.strip().split('\n') | |
| patterns = [ | |
| (r'Reward Ratio\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)', 'reward_ratio'), | |
| (r'Rule of (?:40|20)\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+(\d+)', 'rule_of_40'), | |
| (r'Owner Return Rate\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+(\d+)', 'owner_return_rate'), | |
| (r'Net Revenue Growth Rate\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+(\d+)', 'net_revenue_growth_rate'), | |
| ] | |
| for line in lines: | |
| line = line.strip() | |
| for pattern, key in patterns: | |
| m = re.search(pattern, line) | |
| if m: | |
| groups = m.groups() | |
| data[key] = { | |
| 'last_year': clean_number(groups[0]), | |
| 'this_year': clean_number(groups[1]), | |
| 'average': clean_number(groups[2]), | |
| 'best_25': clean_number(groups[3]), | |
| 'peak': clean_number(groups[4]), | |
| 'percentile': int(groups[5]), | |
| } | |
| # Also extract EBITDA and compensation ratios from the chart text | |
| ebitda_pattern = r'(\d+\.?\d*)%\s+(\d+\.?\d*)%\s+(\d+\.?\d*)%\s+(\d+\.?\d*)%\s*\nOperational EBITDA' | |
| m = re.search(ebitda_pattern, text) | |
| if m: | |
| data['operational_ebitda_margin'] = { | |
| 'last_year': float(m.group(1)), | |
| 'this_year': float(m.group(2)), | |
| 'average': float(m.group(3)), | |
| 'best_25': float(m.group(4)), | |
| } | |
| comp_pattern = r'(\d+\.?\d*)%\s+(\d+\.?\d*)%\s+(\d+\.?\d*)%\s+(\d+\.?\d*)%\s*\nCompensation as % of Net Revenue' | |
| m = re.search(comp_pattern, text) | |
| if m: | |
| data['compensation_pct_net_rev'] = { | |
| 'last_year': float(m.group(1)), | |
| 'this_year': float(m.group(2)), | |
| 'average': float(m.group(3)), | |
| 'best_25': float(m.group(4)), | |
| } | |
| return data | |
| def parse_growth_metrics(text): | |
| """Parse Growth of Insurance Income page.""" | |
| data = {} | |
| lines = text.strip().split('\n') | |
| current_section = None | |
| for line in lines: | |
| line = line.strip() | |
| if 'Total Growth Rate' in line: | |
| current_section = 'total_growth' | |
| elif 'Sales Velocity' in line and 'Total' not in line: | |
| current_section = 'sales_velocity' | |
| elif 'Leakage/Lift' in line: | |
| current_section = 'leakage_lift' | |
| elif 'Organic Growth' in line: | |
| current_section = 'organic_growth' | |
| if current_section: | |
| # Match lines like: "Total Commissions and Fees (TCF) 12.4% 12.1% 11.4% 21.1% 30.2% 60" | |
| patterns = [ | |
| (r'Total Commissions and Fees \(TCF\)\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(\d+)', 'tcf'), | |
| (r'Property & Casualty.*\(P&C\)\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(\d+)', 'pc'), | |
| (r'Commercial Lines.*\(CL\)\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(\d+)', 'cl'), | |
| (r'Personal Lines.*\(PL\)\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(\d+)', 'pl'), | |
| (r'Life & Health.*\(L&H\)\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(\d+)', 'lh'), | |
| (r'Group Commission.*\(GRP\)\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(\d+)', 'grp'), | |
| # For Total Commissions under Sales Velocity | |
| (r'Total Commissions\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(\d+)', 'total'), | |
| ] | |
| # For leakage/lift lines with NRPP | |
| leakage_patterns = [ | |
| (r'Total Commissions\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%', 'total'), | |
| (r'Property & Casualty.*\(P&C\)\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%', 'pc'), | |
| (r'Commercial Lines.*\(CL\)\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%', 'cl'), | |
| (r'Personal Lines.*\(PL\)\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%', 'pl'), | |
| (r'Life & Health.*\(L&H\)\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%', 'lh'), | |
| (r'Group Commission.*\(GRP\)\s+(-?[\d.]+)%\s+(-?[\d.]+)%\s+(-?[\d.]+)%', 'grp'), | |
| ] | |
| if current_section == 'leakage_lift': | |
| for pattern, key in leakage_patterns: | |
| m = re.search(pattern, line) | |
| if m: | |
| metric_key = f"{current_section}_{key}" | |
| data[metric_key] = { | |
| 'last_year': float(m.group(1)), | |
| 'this_year': float(m.group(2)), | |
| 'average': float(m.group(3)), | |
| } | |
| else: | |
| for pattern, key in patterns: | |
| m = re.search(pattern, line) | |
| if m: | |
| groups = m.groups() | |
| metric_key = f"{current_section}_{key}" | |
| data[metric_key] = { | |
| 'last_year': float(groups[0]), | |
| 'this_year': float(groups[1]), | |
| 'average': float(groups[2]), | |
| 'best_25': float(groups[3]), | |
| 'peak': float(groups[4]), | |
| 'percentile': int(groups[5]), | |
| } | |
| return data | |
| def parse_income_statement(text): | |
| """Parse the Income Statement / Revenues page.""" | |
| data = {} | |
| # Revenue line items | |
| revenue_patterns = [ | |
| (r'P&C Commissions\s+\$?\s*([\d,]+)', 'pc_commissions'), | |
| (r'Life & Health Commissions\s+(\d[\d,]*)', 'lh_commissions'), | |
| (r'Fee Income\s+(\d[\d,]*)', 'fee_income'), | |
| (r'Total Commissions And Fees\s+(\d[\d,]*)', 'total_commissions_fees'), | |
| (r'Contingents\s+(\d[\d,]*)', 'contingents'), | |
| (r'Net Revenues\s+\$?\s*([\d,]+)', 'net_revenues'), | |
| (r'Total Compensation\s+\$?\s*([\d,]+)', 'total_compensation'), | |
| (r'Pre-Tax Profit\s+\$?\s*([\d,]+)', 'pretax_profit'), | |
| (r'EBITDA\s+\$?\s*([\d,]+)', 'ebitda'), | |
| (r'Operating EBITDA\s+\$?\s*([\d,]+)', 'operating_ebitda'), | |
| (r'Total Expenses\s+\$?\s*([\d,]+)', 'total_expenses'), | |
| (r'Gross Revenues\s+(\d[\d,]*)', 'gross_revenues'), | |
| ] | |
| lines = text.strip().split('\n') | |
| for line in lines: | |
| line = line.strip() | |
| for pattern, key in revenue_patterns: | |
| m = re.search(pattern, line) | |
| if m: | |
| # The line has multiple values: last_year pct this_year pct ... | |
| # Extract all dollar amounts from the line | |
| amounts = re.findall(r'\$?\s*([\d,]+(?:\.\d+)?)\s+[\d.]+\s*%', line) | |
| if len(amounts) >= 2: | |
| data[key] = { | |
| 'last_year': clean_number(amounts[0]), | |
| 'this_year': clean_number(amounts[1]), | |
| } | |
| elif amounts: | |
| data[key] = {'value': clean_number(amounts[0])} | |
| # More robust parsing: grab all dollar values on lines with known labels | |
| for line in lines: | |
| line = line.strip() | |
| # Match: Label $amount pct% $amount pct% | |
| m = re.match(r'([\w&\s\(\)]+?)\s+\$?\s*([\d,]+)\s+([\d.]+)\s*%?\s+\$?\s*([\d,]+)\s+([\d.]+)\s*%', line) | |
| if m: | |
| label = m.group(1).strip().lower().replace(' ', '_').replace('&', 'and') | |
| label = re.sub(r'[^a-z0-9_]', '', label) | |
| if label and label not in data: | |
| data[label] = { | |
| 'last_year': clean_number(m.group(2)), | |
| 'last_year_pct': float(m.group(3)), | |
| 'this_year': clean_number(m.group(4)), | |
| 'this_year_pct': float(m.group(5)), | |
| } | |
| return data | |
| def clean_number(s): | |
| """Clean a number string, removing commas and dollar signs.""" | |
| if isinstance(s, (int, float)): | |
| return float(s) | |
| s = str(s).replace(',', '').replace('$', '').strip() | |
| try: | |
| return float(s) | |
| except: | |
| return 0.0 | |
| def extract_all_data(pdf_dir): | |
| """Extract data from all VI Portal PDFs in a directory.""" | |
| all_data = [] | |
| pdf_files = sorted(Path(pdf_dir).glob("VI_Portal*.pdf")) | |
| print(f"Found {len(pdf_files)} PDF files") | |
| for pdf_path in pdf_files: | |
| print(f"\nProcessing: {pdf_path.name}") | |
| try: | |
| pages = extract_text_pages(str(pdf_path)) | |
| except Exception as e: | |
| print(f" ERROR reading {pdf_path.name}: {e}") | |
| continue | |
| effective_date = extract_effective_date(pages) | |
| if effective_date: | |
| date_str = effective_date.strftime("%m/%d/%Y") | |
| quarter_label = effective_date.strftime("%b %Y") | |
| print(f" Effective Date: {date_str}") | |
| else: | |
| print(f" WARNING: Could not extract effective date") | |
| date_str = pdf_path.stem | |
| quarter_label = pdf_path.stem | |
| report = { | |
| 'file': pdf_path.name, | |
| 'effective_date': date_str, | |
| 'quarter_label': quarter_label, | |
| 'perspectives': {}, | |
| 'key_return_metrics': {}, | |
| 'growth_metrics': {}, | |
| 'income_statement': {}, | |
| } | |
| # Parse Perspectives (page 3 in all PDFs) | |
| _, persp_text = find_page_by_header(pages, "PROFIT PERSPECTIVES") | |
| if persp_text: | |
| report['perspectives'] = parse_perspectives(persp_text) | |
| print(f" Perspectives: {len(report['perspectives'])} metrics") | |
| # Parse Key Return Metrics | |
| _, krm_text = find_page_by_header(pages, "KEY RETURN METRICS") | |
| if krm_text: | |
| report['key_return_metrics'] = parse_key_return_metrics(krm_text) | |
| print(f" Key Return Metrics: {len(report['key_return_metrics'])} metrics") | |
| # Parse Growth of Insurance Income | |
| _, growth_text = find_page_by_header(pages, "GROWTH OF INSURANCE INCOME") | |
| if growth_text: | |
| report['growth_metrics'] = parse_growth_metrics(growth_text) | |
| print(f" Growth Metrics: {len(report['growth_metrics'])} metrics") | |
| # Parse Income Statement | |
| for header in ["Revenues", "Revenue"]: | |
| _, inc_text = find_page_by_header(pages, header) | |
| if inc_text and len(inc_text) > 500: | |
| report['income_statement'] = parse_income_statement(inc_text) | |
| print(f" Income Statement: {len(report['income_statement'])} items") | |
| break | |
| all_data.append(report) | |
| return all_data | |
| def build_time_series(all_data): | |
| """Build flat time-series data for dashboard consumption.""" | |
| # KPI Time Series | |
| kpi_rows = [] | |
| for report in all_data: | |
| date = report['effective_date'] | |
| label = report['quarter_label'] | |
| # Key Return Metrics | |
| krm = report.get('key_return_metrics', {}) | |
| perspectives = report.get('perspectives', {}) | |
| growth = report.get('growth_metrics', {}) | |
| income = report.get('income_statement', {}) | |
| row = {'date': date, 'quarter': label} | |
| # Key Return Metrics | |
| for metric in ['reward_ratio', 'rule_of_40', 'owner_return_rate', 'net_revenue_growth_rate']: | |
| d = krm.get(metric, perspectives.get(metric, {})) | |
| if d: | |
| row[f'{metric}_agency'] = d.get('this_year', d.get('value', '')) | |
| row[f'{metric}_last_year'] = d.get('last_year', '') | |
| row[f'{metric}_average'] = d.get('average', '') | |
| row[f'{metric}_best25'] = d.get('best_25', '') | |
| row[f'{metric}_percentile'] = d.get('percentile', '') | |
| # Additional perspectives metrics | |
| for metric in ['servicing_cost_per_comm', 'employee_marginal_profitability', | |
| 'sales_velocity', 'total_cf_growth_rate', 'revenue_per_employee', | |
| 'cf_per_prod_person', 'cf_per_service_person', 'support_staff_pct', | |
| 'new_biz_per_prod_person', 'nupp', 'debt_service_coverage', | |
| 'trust_ratio', 'defensive_interval', 'weighted_avg_shareholder_age', | |
| 'contingent_consistency_ratio']: | |
| d = perspectives.get(metric, {}) | |
| if d: | |
| row[f'{metric}_agency'] = d.get('this_year', '') | |
| row[f'{metric}_last_year'] = d.get('last_year', '') | |
| row[f'{metric}_average'] = d.get('average', '') | |
| row[f'{metric}_best25'] = d.get('best_25', '') | |
| row[f'{metric}_percentile'] = d.get('percentile', '') | |
| # EBITDA / Compensation from Key Return Metrics charts | |
| for metric in ['operational_ebitda_margin', 'compensation_pct_net_rev']: | |
| d = krm.get(metric, {}) | |
| if d: | |
| row[f'{metric}_agency'] = d.get('this_year', '') | |
| row[f'{metric}_last_year'] = d.get('last_year', '') | |
| row[f'{metric}_average'] = d.get('average', '') | |
| row[f'{metric}_best25'] = d.get('best_25', '') | |
| # Growth metrics | |
| for section in ['total_growth', 'organic_growth', 'sales_velocity']: | |
| for lob in ['tcf', 'total', 'pc', 'cl', 'pl', 'lh', 'grp']: | |
| key = f"{section}_{lob}" | |
| d = growth.get(key, {}) | |
| if d: | |
| row[f'{key}_agency'] = d.get('this_year', '') | |
| row[f'{key}_last_year'] = d.get('last_year', '') | |
| row[f'{key}_average'] = d.get('average', '') | |
| row[f'{key}_best25'] = d.get('best_25', '') | |
| if 'percentile' in d: | |
| row[f'{key}_percentile'] = d['percentile'] | |
| # Leakage/Lift | |
| for lob in ['total', 'pc', 'cl', 'pl', 'lh', 'grp']: | |
| key = f"leakage_lift_{lob}" | |
| d = growth.get(key, {}) | |
| if d: | |
| row[f'{key}_agency'] = d.get('this_year', '') | |
| row[f'{key}_last_year'] = d.get('last_year', '') | |
| row[f'{key}_average'] = d.get('average', '') | |
| # Income statement highlights | |
| for key in ['net_revenues', 'total_commissions_fees', 'pc_commissions', | |
| 'lh_commissions', 'contingents', 'total_compensation', | |
| 'pretax_profit', 'ebitda', 'operating_ebitda', 'total_expenses', | |
| 'gross_revenues', 'fee_income']: | |
| d = income.get(key, {}) | |
| if d: | |
| row[f'income_{key}_this_year'] = d.get('this_year', d.get('value', '')) | |
| row[f'income_{key}_last_year'] = d.get('last_year', '') | |
| kpi_rows.append(row) | |
| return kpi_rows | |
| def save_data(kpi_rows, output_dir): | |
| """Save extracted data to JSON and CSV files.""" | |
| os.makedirs(output_dir, exist_ok=True) | |
| # Save JSON | |
| json_path = os.path.join(output_dir, 'vi_portal_data.json') | |
| with open(json_path, 'w') as f: | |
| json.dump(kpi_rows, f, indent=2, default=str) | |
| print(f"\nSaved JSON: {json_path}") | |
| # Save CSV | |
| if kpi_rows: | |
| csv_path = os.path.join(output_dir, 'vi_portal_kpis.csv') | |
| all_keys = set() | |
| for row in kpi_rows: | |
| all_keys.update(row.keys()) | |
| all_keys = sorted(all_keys) | |
| with open(csv_path, 'w', newline='') as f: | |
| writer = csv.DictWriter(f, fieldnames=all_keys) | |
| writer.writeheader() | |
| writer.writerows(kpi_rows) | |
| print(f"Saved CSV: {csv_path}") | |
| return json_path | |
| if __name__ == '__main__': | |
| import sys | |
| pdf_dir = sys.argv[1] if len(sys.argv) > 1 else '/home/claude' | |
| output_dir = sys.argv[2] if len(sys.argv) > 2 else '/home/claude/vi_dashboard/data' | |
| print("=" * 60) | |
| print("VI Portal Data Extraction") | |
| print("=" * 60) | |
| all_data = extract_all_data(pdf_dir) | |
| # Save raw extracted data | |
| raw_path = os.path.join(output_dir, 'raw_extracted.json') | |
| os.makedirs(output_dir, exist_ok=True) | |
| with open(raw_path, 'w') as f: | |
| json.dump(all_data, f, indent=2, default=str) | |
| # Build time series | |
| kpi_rows = build_time_series(all_data) | |
| save_data(kpi_rows, output_dir) | |
| print(f"\n{'=' * 60}") | |
| print(f"Extracted {len(all_data)} reports, {len(kpi_rows)} quarterly data points") | |
| print(f"{'=' * 60}") | |