| """Bulk note parsing helpers.""" |
|
|
| from __future__ import annotations |
|
|
| from collections.abc import Callable |
| from typing import Any |
|
|
| import pandas as pd |
|
|
| from voiceledger.parser.rules import parse_transaction |
| from voiceledger.parser.schema import Transaction |
|
|
|
|
| REVIEW_COLUMNS = [ |
| "transaction_type", |
| "item", |
| "quantity", |
| "unit_price", |
| "amount", |
| "customer", |
| "payment_status", |
| "notes", |
| "confidence", |
| ] |
|
|
|
|
| def parse_bulk_notes(notes: str, parser: Callable[[str], Transaction] = parse_transaction) -> pd.DataFrame: |
| """Split pasted notes into lines and parse each line for review.""" |
| transactions = [parser(line) for line in _split_note_lines(notes)] |
| return transactions_to_dataframe(transactions) |
|
|
|
|
| def transactions_to_dataframe(transactions: list[Transaction]) -> pd.DataFrame: |
| """Convert transactions into an editable review DataFrame.""" |
| records = [transaction.model_dump() for transaction in transactions] |
| return pd.DataFrame.from_records(records, columns=REVIEW_COLUMNS) |
|
|
|
|
| def review_table_to_transactions(review_table: Any) -> list[Transaction]: |
| """Convert an edited review table back into validated transactions.""" |
| frame = _coerce_review_table(review_table) |
| transactions: list[Transaction] = [] |
|
|
| for record in frame.to_dict(orient="records"): |
| cleaned = _clean_record(record) |
| if _is_empty_record(cleaned): |
| continue |
| transactions.append(Transaction(**cleaned)) |
|
|
| return transactions |
|
|
|
|
| def _split_note_lines(notes: str) -> list[str]: |
| """Return non-empty stripped lines from pasted notes.""" |
| return [line.strip() for line in (notes or "").splitlines() if line.strip()] |
|
|
|
|
| def _coerce_review_table(review_table: Any) -> pd.DataFrame: |
| """Coerce common Gradio Dataframe values into a Pandas DataFrame.""" |
| if review_table is None: |
| return pd.DataFrame(columns=REVIEW_COLUMNS) |
| if isinstance(review_table, pd.DataFrame): |
| return review_table.reindex(columns=REVIEW_COLUMNS) |
| if isinstance(review_table, list): |
| return pd.DataFrame(review_table, columns=REVIEW_COLUMNS) |
| if isinstance(review_table, dict) and "data" in review_table: |
| headers = review_table.get("headers") or REVIEW_COLUMNS |
| return pd.DataFrame(review_table["data"], columns=headers).reindex(columns=REVIEW_COLUMNS) |
| return pd.DataFrame(review_table).reindex(columns=REVIEW_COLUMNS) |
|
|
|
|
| def _clean_record(record: dict[str, Any]) -> dict[str, Any]: |
| """Normalize editable table values before Pydantic validation.""" |
| cleaned = dict(record) |
| for field in ["item", "customer", "notes"]: |
| cleaned[field] = _none_if_blank(cleaned.get(field)) |
|
|
| for field in ["quantity", "unit_price", "amount", "confidence"]: |
| cleaned[field] = _number_or_none(cleaned.get(field)) |
|
|
| cleaned["transaction_type"] = _none_if_blank(cleaned.get("transaction_type")) or "unknown" |
| cleaned["payment_status"] = _none_if_blank(cleaned.get("payment_status")) or "unknown" |
| cleaned["notes"] = cleaned["notes"] or "" |
| cleaned["confidence"] = cleaned["confidence"] if cleaned["confidence"] is not None else 0.0 |
| return cleaned |
|
|
|
|
| def _none_if_blank(value: Any) -> Any: |
| """Return None for blank, null, or NaN values.""" |
| if value is None: |
| return None |
| if pd.isna(value): |
| return None |
| if isinstance(value, str) and not value.strip(): |
| return None |
| if isinstance(value, str): |
| return value.strip() |
| return value |
|
|
|
|
| def _number_or_none(value: Any) -> float | None: |
| """Return a float for numeric values, otherwise None.""" |
| normalized = _none_if_blank(value) |
| if normalized is None: |
| return None |
| return float(normalized) |
|
|
|
|
| def _is_empty_record(record: dict[str, Any]) -> bool: |
| """Return whether a review row has no meaningful transaction data.""" |
| return ( |
| record["transaction_type"] == "unknown" |
| and record["item"] is None |
| and record["amount"] is None |
| and record["customer"] is None |
| and not record["notes"] |
| ) |
|
|