import pandas as pd
import numpy as np
import gradio as gr
# ═══════════════════════════════════════════════════════════
# MONKEY-PATCH — fix gradio_client bug where get_type() crashes
# when schema is a bool (e.g. {"additionalProperties": true}).
# Upstream bug: TypeError: argument of type 'bool' is not iterable
# This must run BEFORE any Gradio route is registered.
# ═══════════════════════════════════════════════════════════
try:
import gradio_client.utils as _gcu
_original_get_type = _gcu.get_type
def _safe_get_type(schema):
if not isinstance(schema, dict):
return "Any"
return _original_get_type(schema)
_gcu.get_type = _safe_get_type
_original_json_schema_to_python_type = _gcu._json_schema_to_python_type
def _safe_json_schema_to_python_type(schema, defs=None):
if not isinstance(schema, dict):
return "Any"
try:
return _original_json_schema_to_python_type(schema, defs)
except (TypeError, KeyError, AttributeError):
return "Any"
_gcu._json_schema_to_python_type = _safe_json_schema_to_python_type
print("[patch] gradio_client schema handler patched successfully")
except Exception as _e:
print(f"[patch] could not patch gradio_client: {_e}")
import plotly.graph_objects as go
import datetime
import os
import json
import random
import urllib.request
import urllib.error
import tempfile
import re
import base64
import mimetypes
# ══════════════════════════════════════════════
# 1. GLOBAL STATE & CHART OF ACCOUNTS
# ══════════════════════════════════════════════
general_ledger_df = pd.DataFrame(columns=['Date','Account','Type','Sub','Debit','Credit','Description'])
journal_entries_df = pd.DataFrame(columns=['JE #','Date','Description','Entries','Status'])
COA_MAP = {
"Cash":{"type":"Asset","sub":"Current Asset","normal":"Debit","code":"1010"},
"Accounts Receivable":{"type":"Asset","sub":"Current Asset","normal":"Debit","code":"1200"},
"Inventory":{"type":"Asset","sub":"Current Asset","normal":"Debit","code":"1300"},
"Prepaid Expenses":{"type":"Asset","sub":"Current Asset","normal":"Debit","code":"1400"},
"Supplies":{"type":"Asset","sub":"Current Asset","normal":"Debit","code":"1500"},
"Equipment":{"type":"Asset","sub":"Fixed Asset","normal":"Debit","code":"1600"},
"Accumulated Depreciation":{"type":"Asset","sub":"Contra Asset","normal":"Credit","code":"1650"},
"Land":{"type":"Asset","sub":"Fixed Asset","normal":"Debit","code":"1700"},
"Buildings":{"type":"Asset","sub":"Fixed Asset","normal":"Debit","code":"1800"},
"Accounts Payable":{"type":"Liability","sub":"Current Liability","normal":"Credit","code":"2010"},
"Salaries Payable":{"type":"Liability","sub":"Current Liability","normal":"Credit","code":"2020"},
"Interest Payable":{"type":"Liability","sub":"Current Liability","normal":"Credit","code":"2030"},
"Unearned Revenue":{"type":"Liability","sub":"Current Liability","normal":"Credit","code":"2040"},
"Notes Payable":{"type":"Liability","sub":"Long-term Liability","normal":"Credit","code":"2500"},
"Bonds Payable":{"type":"Liability","sub":"Long-term Liability","normal":"Credit","code":"2600"},
"Common Stock":{"type":"Equity","sub":"Equity","normal":"Credit","code":"3010"},
"Retained Earnings":{"type":"Equity","sub":"Equity","normal":"Credit","code":"3020"},
"Dividends":{"type":"Equity","sub":"Contra Equity","normal":"Debit","code":"3030"},
"Revenue":{"type":"Revenue","sub":"Operating Revenue","normal":"Credit","code":"4010"},
"Service Revenue":{"type":"Revenue","sub":"Operating Revenue","normal":"Credit","code":"4020"},
"Sales Revenue":{"type":"Revenue","sub":"Operating Revenue","normal":"Credit","code":"4030"},
"Interest Revenue":{"type":"Revenue","sub":"Other Revenue","normal":"Credit","code":"4500"},
"Cost of Goods Sold":{"type":"Expense","sub":"Operating Expense","normal":"Debit","code":"5010"},
"Salaries Expense":{"type":"Expense","sub":"Operating Expense","normal":"Debit","code":"5100"},
"Rent Expense":{"type":"Expense","sub":"Operating Expense","normal":"Debit","code":"5200"},
"Utilities Expense":{"type":"Expense","sub":"Operating Expense","normal":"Debit","code":"5300"},
"Insurance Expense":{"type":"Expense","sub":"Operating Expense","normal":"Debit","code":"5400"},
"Depreciation Expense":{"type":"Expense","sub":"Operating Expense","normal":"Debit","code":"5500"},
"Supplies Expense":{"type":"Expense","sub":"Operating Expense","normal":"Debit","code":"5600"},
"Interest Expense":{"type":"Expense","sub":"Operating Expense","normal":"Debit","code":"5700"},
"Operating Expenses":{"type":"Expense","sub":"Operating Expense","normal":"Debit","code":"5900"},
}
INSTRUCTOR_SETTINGS = {"show_transaction_manager": False}
je_counter = [0]
# ══════════════════════════════════════════════
# 2. HELPERS
# ══════════════════════════════════════════════
def get_coa_display():
rows = []
for acc, info in sorted(COA_MAP.items(), key=lambda x: x[1]['code']):
rows.append({"Code":info["code"],"Account Name":acc,"Type":info["type"],"Sub-Type":info["sub"],"Normal Bal.":info["normal"]})
return pd.DataFrame(rows)
def add_new_account(name, atype, asub, anormal):
global COA_MAP
name = name.strip()
if not name: raise gr.Error("Account name cannot be empty.")
if name in COA_MAP: raise gr.Error(f"'{name}' already exists.")
pfx = {"Asset":"1","Liability":"2","Equity":"3","Revenue":"4","Expense":"5"}.get(atype,"9")
codes = [int(v["code"]) for v in COA_MAP.values() if v["code"].startswith(pfx)]
nc = str(max(codes)+10) if codes else f"{pfx}010"
COA_MAP[name] = {"type":atype,"sub":asub,"normal":anormal,"code":nc}
return get_coa_display(), f"✅ '{name}' added (Code: {nc})"
def search_accounts(query):
if not query or not query.strip(): return get_coa_display()
q = query.strip().lower()
rows = [{"Code":i["code"],"Account Name":a,"Type":i["type"],"Sub-Type":i["sub"],"Normal Bal.":i["normal"]}
for a,i in sorted(COA_MAP.items(), key=lambda x:x[1]['code'])
if q in a.lower() or q in i['type'].lower() or q in i['sub'].lower()]
return pd.DataFrame(rows) if rows else pd.DataFrame({"Result":["No matches found."]})
def search_accounts_short(query):
if not query or not query.strip():
return get_coa_display()[['Account Name','Normal Bal.']]
q = query.strip().lower()
rows = [{"Account Name":a,"Normal Bal.":i["normal"]}
for a,i in sorted(COA_MAP.items(), key=lambda x:x[1]['code'])
if q in a.lower() or q in i['type'].lower() or q in i['sub'].lower()]
return pd.DataFrame(rows) if rows else pd.DataFrame({"Result":["No matches."]})
# ══════════════════════════════════════════════
# 3. AI CHATBOT (ENHANCED WITH FILE SUPPORT)
# ══════════════════════════════════════════════
ACCOUNTING_KB = {
"debit": "📘 **DEBIT** — Left side of a T-account.\n\n**Increases:** Assets, Expenses, Dividends (DEAD: Debits increase Expenses, Assets, Dividends)\n**Decreases:** Liabilities, Equity, Revenue\n\n**Example:** Receive cash → DEBIT Cash (asset increases).",
"credit": "📗 **CREDIT** — Right side of a T-account.\n\n**Increases:** Liabilities, Equity, Revenue\n**Decreases:** Assets, Expenses, Dividends\n\n**Example:** Earn revenue → CREDIT Revenue (increases it).",
"journal entry": "📝 **JOURNAL ENTRY** — Records a transaction.\n\n**Rules:**\n1. Every entry needs DATE + DESCRIPTION\n2. Debits listed first, credits indented\n3. Total Debits MUST = Total Credits\n4. Minimum 2 accounts affected\n\n**Types:** General, Adjusting, Closing, Reversing entries",
"trial balance": "⚖️ **TRIAL BALANCE** — Lists ALL accounts with debit/credit balances.\n\n**Purpose:** Verify debits = credits before financial statements.\n\n⚠️ A balanced TB does NOT guarantee no errors — won't catch omission or principle errors.",
"balance sheet": "📊 **BALANCE SHEET** — Financial position at a specific date.\n\n**Assets = Liabilities + Equity**\n\nCurrent Assets → Fixed Assets\nCurrent Liabilities → Long-term Liabilities\nCommon Stock → Retained Earnings",
"income statement": "📈 **INCOME STATEMENT (P&L)** — Profitability over a period.\n\n**Revenue − Expenses = Net Income**\n\nRevenue → COGS = Gross Profit → Operating Expenses = Operating Income → Net Income",
"accounting equation": "⚖️ **THE ACCOUNTING EQUATION**\n\n**Assets = Liabilities + Stockholders' Equity**\n\nEvery transaction keeps this in balance. Foundation since Luca Pacioli, 1494!",
"depreciation": "📉 **DEPRECIATION** — Allocating asset cost over useful life.\n\n**Methods:**\n• **Straight-Line:** (Cost − Salvage) / Life\n• **Double-Declining:** 2/Life × Book Value\n• **Units of Production:** Based on usage\n\nLand is NEVER depreciated.",
"amortization": "📉 **AMORTIZATION** — For intangible assets (patents, goodwill) or loan repayment.\n\nEach loan payment = Interest + Principal\nEarly payments → more interest\nLater payments → more principal",
"gaap": "📜 **GAAP** — Generally Accepted Accounting Principles.\n\nRevenue Recognition, Matching, Cost, Full Disclosure, Going Concern, Materiality, Conservatism, Consistency.\n\nSet by FASB. Rules-based (vs IFRS principles-based).",
"normal balance": "📘 **NORMAL BALANCE** — Side that increases an account:\n\n• Assets → Debit\n• Expenses → Debit\n• Dividends → Debit\n• Liabilities → Credit\n• Equity → Credit\n• Revenue → Credit\n\nRemember **DEALER**!",
"t-account": "📐 **T-ACCOUNT** — Visual account representation.\n\nLeft = Debits | Right = Credits\n\nIncreases on NORMAL balance side. Decreases on OPPOSITE side.",
"retained earnings": "💰 **RETAINED EARNINGS** — Accumulated undistributed profits.\n\nBeginning RE + Net Income − Dividends = Ending RE\n\nPart of Equity. NOT the same as Cash!",
"accounts payable": "📋 **ACCOUNTS PAYABLE** — Money owed to suppliers.\n\nCurrent Liability, Credit normal.\nPurchase on credit: DR Supplies / CR AP\nPay bill: DR AP / CR Cash",
"accounts receivable": "📋 **ACCOUNTS RECEIVABLE** — Money owed by customers.\n\nCurrent Asset, Debit normal.\nSale on credit: DR AR / CR Revenue\nCollect: DR Cash / CR AR",
"cash flow": "💵 **CASH FLOW STATEMENT**\n\n1. **Operating:** Day-to-day business\n2. **Investing:** Long-term assets\n3. **Financing:** Debt and equity\n\nEnding Cash must match Balance Sheet!",
"double entry": "📖 **DOUBLE-ENTRY BOOKKEEPING** — Every transaction affects 2+ accounts.\n\nTotal Debits = Total Credits (always).\nFormalized by Luca Pacioli in 1494.",
"adjusting entries": "🔧 **ADJUSTING ENTRIES** — Period-end updates.\n\n• Accrued Revenue/Expenses\n• Deferred Revenue (Unearned)\n• Prepaid Expenses\n• Depreciation\n\nAdjusting entries NEVER involve Cash!",
"cogs": "📦 **COST OF GOODS SOLD**\n\nBeginning Inventory + Purchases − Ending Inventory = COGS\n\nMethods: FIFO, LIFO, Weighted Average\nRevenue − COGS = Gross Profit",
"closing entries": "🔒 **CLOSING ENTRIES** — Transfer temporary accounts to RE.\n\n1. Close Revenue → Income Summary\n2. Close Expenses → Income Summary\n3. Close Income Summary → RE\n4. Close Dividends → RE",
"equity": "🏛️ **EQUITY** — Owner's residual interest.\n\nComponents: Common Stock, APIC, Retained Earnings, Treasury Stock\nEquity = Assets − Liabilities",
"asset": "🏢 **ASSETS** — Resources providing future benefit.\n\nCurrent: Cash, AR, Inventory, Prepaid\nFixed: Equipment, Buildings, Land\nIntangible: Patents, Goodwill\n\nDebit normal. Listed by liquidity.",
"liability": "📋 **LIABILITIES** — Obligations owed.\n\nCurrent: AP, Salaries Payable, Unearned Revenue\nLong-term: Notes Payable, Bonds Payable\n\nCredit normal balance.",
"revenue": "💰 **REVENUE** — Income from primary business.\n\nCredit normal. Recognized when EARNED (accrual), not when cash received.",
"expense": "💸 **EXPENSES** — Costs to generate revenue.\n\nDebit normal. Recorded when INCURRED (matching principle). Reduces Net Income.",
"ratio": "📊 **FINANCIAL RATIOS**\n\nLiquidity: Current Ratio, Quick Ratio\nProfitability: Gross Margin, Net Margin, ROE, ROA\nLeverage: Debt-to-Equity\nEfficiency: Inventory Turnover, AR Turnover",
"bond": "🏦 **BONDS** — Long-term debt.\n\nPar: Coupon = Market rate\nPremium: Coupon > Market rate\nDiscount: Coupon < Market rate",
"inventory": "📦 **INVENTORY** — Goods for sale.\n\nTypes: Raw Materials, WIP, Finished Goods\nMethods: FIFO, LIFO, Weighted Average\nLower of Cost or Market (LCM) applies.",
"ifrs": "🌍 **IFRS** — International Financial Reporting Standards.\n\n140+ countries. Principles-based vs GAAP rules-based.\nLIFO not allowed. Asset revaluation allowed.",
"tax": "🏛️ **TAX ACCOUNTING**\n\nTaxable Income ≠ Book Income\nPermanent vs Temporary differences\nUS Corporate rate: 21% (TCJA 2017)",
"audit": "🔍 **AUDITING** — Independent examination.\n\nTypes: External, Internal, Government\nOpinions: Unqualified, Qualified, Adverse, Disclaimer\nSOX 2002 requires public company audits.",
"accrual": "📅 **ACCRUAL vs CASH BASIS**\n\nAccrual: Revenue when earned, expenses when incurred (GAAP)\nCash: Revenue when received, expenses when paid",
"working capital": "💼 **WORKING CAPITAL** = Current Assets − Current Liabilities\n\nPositive = can cover obligations ✅\nCurrent Ratio = CA / CL (healthy: >1.5)",
"ledger": "📓 **GENERAL LEDGER** — Master record of all accounts.\n\nTransaction → Journal → Ledger → Trial Balance → Financial Statements",
"bank reconciliation": "🏦 **BANK RECONCILIATION**\n\nBank Balance + Deposits in Transit − Outstanding Checks = Adjusted Bank\nBook Balance + Interest − Fees − NSF = Adjusted Book\nBoth must equal!",
}
# ──────────────────────────────────────────────
# REAL LLM INTEGRATION (free APIs)
# ──────────────────────────────────────────────
DEFAULT_API_KEY = ""
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", DEFAULT_API_KEY).strip()
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "").strip()
HF_API_KEY = os.environ.get("HF_API_KEY", "").strip()
SYSTEM_PROMPT = (
"You are an expert Accounting AI Assistant built into an accounting intelligence tool "
"for SUNY Polytechnic. You are helpful, accurate, and thorough. "
"You can answer ANY question the user asks — accounting, finance, math, general knowledge, "
"explanations, coding, writing, etc. — just like ChatGPT, Claude, or Gemini. "
"When the question is about accounting, give clear examples, journal entries, and use GAAP. "
"Format answers with markdown (headings, bullets, bold) for readability.\n\n"
"IMPORTANT — FILE GENERATION:\n"
"When a user asks you to create, generate, or produce a file (Excel spreadsheet, Word document, "
"CSV, PDF table, code file, etc.), you MUST include a special code block in your response that "
"contains the file generation code. Use the following format:\n\n"
"```generate_file\n"
"FILENAME: desired_filename.xlsx\n"
"TYPE: excel|csv|word|text|python|html\n"
"---\n"
"...actual data content here, described as structured text...\n"
"```\n\n"
"For Excel/CSV, format data as pipe-separated rows:\n"
"```generate_file\n"
"FILENAME: report.xlsx\n"
"TYPE: excel\n"
"---\n"
"Column1|Column2|Column3\n"
"value1|value2|value3\n"
"```\n\n"
"For Word/text, just write the content as plain text.\n"
"For code files (python, html, etc.), write the actual code.\n\n"
"Always include this code block when the user asks for a downloadable file. "
"Also provide a text explanation alongside it.\n\n"
"When a user attaches a file, they will provide the file content or a summary of it. "
"Analyze and respond to their questions about the attached file content."
)
def _http_post_json(url, headers, payload, timeout=60):
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def call_groq(messages):
if not GROQ_API_KEY: return None
try:
out = _http_post_json(
"https://api.groq.com/openai/v1/chat/completions",
{"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"},
{"model": "llama-3.3-70b-versatile", "messages": messages, "temperature": 0.7, "max_tokens": 2500},
)
return out["choices"][0]["message"]["content"]
except Exception as e:
print(f"[Groq error] {e}")
return None
def call_openrouter(messages):
if not OPENROUTER_API_KEY: return None
free_models = [
"openrouter/free",
"meta-llama/llama-3.3-70b-instruct:free",
"google/gemma-3-27b-it:free",
"nvidia/nemotron-nano-9b-v2:free",
"deepseek/deepseek-r1:free",
"qwen/qwen3-coder:free",
]
for model in free_models:
try:
out = _http_post_json(
"https://openrouter.ai/api/v1/chat/completions",
{
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://huggingface.co",
"X-Title": "Accounting Intelligence",
},
{"model": model, "messages": messages, "max_tokens": 2500},
)
content = out["choices"][0]["message"]["content"]
print(f"[OpenRouter] SUCCESS with {model}")
return content
except urllib.error.HTTPError as e:
body = ""
try: body = e.read().decode("utf-8", errors="ignore")[:200]
except: pass
print(f"[OpenRouter] {model} → HTTP {e.code}: {body}")
continue
except Exception as e:
print(f"[OpenRouter] {model} → {type(e).__name__}: {e}")
continue
print("[OpenRouter] All free models failed.")
return None
def call_hf(messages):
if not HF_API_KEY: return None
try:
prompt = SYSTEM_PROMPT + "\n\n"
for m in messages:
if m["role"] != "system":
prompt += f"{m['role'].upper()}: {m['content']}\n"
prompt += "ASSISTANT:"
out = _http_post_json(
"https://api-inference.huggingface.co/models/meta-llama/Llama-3.2-3B-Instruct",
{"Authorization": f"Bearer {HF_API_KEY}", "Content-Type": "application/json"},
{"inputs": prompt, "parameters": {"max_new_tokens": 1200, "temperature": 0.7, "return_full_text": False}},
)
if isinstance(out, list) and out:
return out[0].get("generated_text", "").strip()
except Exception as e:
print(f"[HF error] {e}")
return None
def call_llm(user_message, history):
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
if history:
for h in history[-10:]:
if isinstance(h, dict) and h.get("role") in ("user", "assistant"):
messages.append({"role": h["role"], "content": str(h.get("content", ""))})
messages.append({"role": "user", "content": user_message})
for fn in (call_groq, call_openrouter, call_hf):
reply = fn(messages)
if reply: return reply
return None
def local_kb_fallback(msg_lower):
best_score, response = 0, None
for kw, ans in ACCOUNTING_KB.items():
score = 0
if kw in msg_lower: score += 5
for w in kw.split():
if w in msg_lower: score += 2
if score > best_score:
best_score, response = score, ans
if response and best_score >= 2:
return response
return (
"⚠️ **No AI API key configured** (or all providers failed).\n\n"
"To unlock full AI answers for **any** question, set one of these free API keys "
"as an environment variable before launching the app:\n\n"
"• `GROQ_API_KEY` — get free at https://console.groq.com/keys *(recommended)*\n"
"• `OPENROUTER_API_KEY` — get free at https://openrouter.ai/keys\n"
"• `HF_API_KEY` — get free at https://huggingface.co/settings/tokens\n\n"
"**Example (Windows):** `set GROQ_API_KEY=gsk_...` then run `python app.py`\n"
"**Example (Mac/Linux):** `export GROQ_API_KEY=gsk_...`\n\n"
"Meanwhile, try asking about: debits, credits, journal entries, trial balance, "
"balance sheet, income statement, depreciation, GAAP, etc."
)
# ──────────────────────────────────────────────
# FILE READING HELPERS
# ──────────────────────────────────────────────
def read_uploaded_file(filepath):
"""Read an uploaded file and return its text content for the LLM."""
if not filepath or not os.path.exists(filepath):
return None, None
fname = os.path.basename(filepath)
ext = os.path.splitext(fname)[1].lower()
try:
if ext == '.csv':
df = pd.read_csv(filepath)
preview = df.head(50).to_string(index=False)
summary = f"📄 **File:** {fname}\n**Rows:** {len(df)} | **Columns:** {list(df.columns)}\n\n```\n{preview}\n```"
return summary, fname
elif ext in ('.xlsx', '.xls'):
df = pd.read_excel(filepath, engine='openpyxl' if ext == '.xlsx' else None)
preview = df.head(50).to_string(index=False)
summary = f"📄 **File:** {fname}\n**Rows:** {len(df)} | **Columns:** {list(df.columns)}\n\n```\n{preview}\n```"
return summary, fname
elif ext == '.json':
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
data = json.load(f)
content = json.dumps(data, indent=2)[:5000]
summary = f"📄 **File:** {fname}\n\n```json\n{content}\n```"
return summary, fname
elif ext in ('.txt', '.md', '.py', '.html', '.css', '.js', '.log', '.cfg', '.ini', '.yml', '.yaml', '.xml', '.sql'):
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()[:8000]
summary = f"📄 **File:** {fname}\n\n```\n{content}\n```"
return summary, fname
elif ext == '.pdf':
try:
import subprocess
result = subprocess.run(
['python3', '-c', f"""
import fitz
doc = fitz.open("{filepath}")
text = ""
for page in doc:
text += page.get_text()
print(text[:8000])
"""],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0 and result.stdout.strip():
summary = f"📄 **File:** {fname}\n\n```\n{result.stdout.strip()}\n```"
return summary, fname
except:
pass
# Fallback: try with pdfplumber
try:
import pdfplumber
text = ""
with pdfplumber.open(filepath) as pdf:
for page in pdf.pages[:20]:
text += (page.extract_text() or "") + "\n"
if text.strip():
summary = f"📄 **File:** {fname}\n\n```\n{text[:8000]}\n```"
return summary, fname
except:
pass
return f"📄 **File:** {fname} (PDF — could not extract text. Install `pymupdf` or `pdfplumber` for PDF support.)", fname
elif ext in ('.docx',):
try:
from docx import Document
doc = Document(filepath)
text = "\n".join([p.text for p in doc.paragraphs])[:8000]
summary = f"📄 **File:** {fname}\n\n```\n{text}\n```"
return summary, fname
except:
return f"📄 **File:** {fname} (Word doc — install `python-docx` for Word support.)", fname
elif ext in ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'):
summary = f"🖼️ **Image:** {fname} (Image uploaded — I can see it's an image file. Ask me anything about what you need!)"
return summary, fname
else:
# Try reading as text
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()[:5000]
summary = f"📄 **File:** {fname}\n\n```\n{content}\n```"
return summary, fname
except:
return f"📄 **File:** {fname} (Could not read this file type.)", fname
except Exception as e:
return f"📄 **File:** {fname}\n⚠️ Error reading: {str(e)}", fname
# ──────────────────────────────────────────────
# FILE GENERATION FROM LLM RESPONSE
# ──────────────────────────────────────────────
def parse_and_generate_files(response_text):
"""
Parse the LLM response for ```generate_file blocks and create actual files.
Returns: list of file paths created.
"""
generated_files = []
pattern = r'```generate_file\s*\n(.*?)```'
matches = re.findall(pattern, response_text, re.DOTALL)
for match in matches:
lines = match.strip().split('\n')
filename = "output.txt"
filetype = "text"
data_lines = []
in_data = False
for line in lines:
if line.startswith("FILENAME:"):
filename = line.split(":", 1)[1].strip()
elif line.startswith("TYPE:"):
filetype = line.split(":", 1)[1].strip().lower()
elif line.strip() == "---":
in_data = True
elif in_data:
data_lines.append(line)
content = "\n".join(data_lines)
if not content.strip():
continue
try:
outdir = tempfile.mkdtemp()
filepath = os.path.join(outdir, filename)
if filetype in ('excel', 'xlsx'):
# Parse pipe-separated data
rows = [r.split('|') for r in content.strip().split('\n') if r.strip()]
if len(rows) >= 2:
headers = [h.strip() for h in rows[0]]
data = [[c.strip() for c in r] for r in rows[1:]]
df = pd.DataFrame(data, columns=headers)
# Try to convert numeric columns
for col in df.columns:
try:
df[col] = pd.to_numeric(df[col])
except:
pass
if not filepath.endswith('.xlsx'):
filepath = filepath.rsplit('.', 1)[0] + '.xlsx'
df.to_excel(filepath, index=False, engine='openpyxl')
generated_files.append(filepath)
else:
# Single row or raw content — write as text
with open(filepath, 'w') as f:
f.write(content)
generated_files.append(filepath)
elif filetype == 'csv':
rows = [r.split('|') for r in content.strip().split('\n') if r.strip()]
if len(rows) >= 2:
headers = [h.strip() for h in rows[0]]
data = [[c.strip() for c in r] for r in rows[1:]]
df = pd.DataFrame(data, columns=headers)
for col in df.columns:
try:
df[col] = pd.to_numeric(df[col])
except:
pass
if not filepath.endswith('.csv'):
filepath = filepath.rsplit('.', 1)[0] + '.csv'
df.to_csv(filepath, index=False)
generated_files.append(filepath)
else:
with open(filepath, 'w') as f:
f.write(content)
generated_files.append(filepath)
elif filetype in ('word', 'docx'):
try:
from docx import Document
doc = Document()
for para in content.split('\n'):
if para.strip():
doc.add_paragraph(para.strip())
if not filepath.endswith('.docx'):
filepath = filepath.rsplit('.', 1)[0] + '.docx'
doc.save(filepath)
generated_files.append(filepath)
except ImportError:
# Fallback to plain text
if not filepath.endswith('.txt'):
filepath = filepath.rsplit('.', 1)[0] + '.txt'
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
generated_files.append(filepath)
else:
# text, python, html, etc.
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
generated_files.append(filepath)
except Exception as e:
print(f"[File generation error] {e}")
continue
return generated_files
def clean_response_for_display(response_text):
"""Remove the generate_file blocks from the displayed response and add download notice."""
cleaned = re.sub(
r'```generate_file\s*\n.*?```',
'',
response_text,
flags=re.DOTALL
)
# Clean up excessive whitespace
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
return cleaned.strip()
# ──────────────────────────────────────────────
# ENHANCED CHATBOT WITH FILE ATTACHMENT & OUTPUT
# ──────────────────────────────────────────────
def accounting_chatbot_with_files(user_message, history, uploaded_files):
"""Enhanced chatbot that handles file uploads and generates downloadable files."""
if (not user_message or not user_message.strip()) and not uploaded_files:
return history, "", None, []
user_message = (user_message or "").strip()
history = history or []
file_context = ""
attached_names = []
# Process uploaded files
if uploaded_files:
for fpath in uploaded_files:
if fpath:
file_content, fname = read_uploaded_file(fpath)
if file_content:
file_context += f"\n\n--- ATTACHED FILE ---\n{file_content}\n--- END FILE ---\n"
attached_names.append(fname)
# Build the user message with file context
full_message = user_message
if file_context:
full_message = f"{user_message}\n\n[The user has attached the following file(s): {', '.join(attached_names)}]\n{file_context}"
if not full_message.strip():
full_message = "I've uploaded a file. Please analyze it and summarize its contents."
# Display message (without the file dump)
display_msg = user_message
if attached_names:
file_badges = " ".join([f"📎 `{n}`" for n in attached_names])
display_msg = f"{file_badges}\n\n{user_message}" if user_message else f"{file_badges}\n\nPlease analyze this file."
# Try real LLM
response = call_llm(full_message, history)
if not response:
response = local_kb_fallback(user_message.lower())
# Parse response for file generation
generated_files = parse_and_generate_files(response)
display_response = clean_response_for_display(response)
# Add download notice if files were generated
if generated_files:
fnames = [os.path.basename(f) for f in generated_files]
display_response += f"\n\n📥 **Files ready for download:** {', '.join(fnames)}\n*(Check the download area below the chat)*"
history.append({"role": "user", "content": display_msg})
history.append({"role": "assistant", "content": display_response})
return history, "", None, generated_files if generated_files else []
def export_chat_history(history):
"""Export the entire chat conversation as a text/markdown file."""
if not history:
raise gr.Error("No chat history to export.")
outdir = tempfile.mkdtemp()
filepath = os.path.join(outdir, f"chat_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.md")
with open(filepath, 'w', encoding='utf-8') as f:
f.write("# Accounting Intelligence — Chat Export\n")
f.write(f"**Exported:** {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"**Source:** SUNY Polytechnic — Accounting Intelligence Tool\n\n")
f.write("---\n\n")
for msg in history:
role = msg.get("role", "unknown")
content = msg.get("content", "")
if role == "user":
f.write(f"## 👤 You\n{content}\n\n")
elif role == "assistant":
f.write(f"## 🤖 AI Assistant\n{content}\n\n")
f.write("---\n\n")
return filepath
# ══════════════════════════════════════════════
# 4. CORE ACCOUNTING LOGIC
# ══════════════════════════════════════════════
def update_equation_status():
if general_ledger_df.empty:
a, l, te, bal_ok = 0.0, 0.0, 0.0, True
else:
df = general_ledger_df.copy()
a = df[df['Type']=='Asset'].pipe(lambda x: x['Debit'].sum()-x['Credit'].sum())
l = df[df['Type']=='Liability'].pipe(lambda x: x['Credit'].sum()-x['Debit'].sum())
eq = df[df['Type']=='Equity'].pipe(lambda x: x['Credit'].sum()-x['Debit'].sum())
r = df[df['Type']=='Revenue']['Credit'].sum()
e = df[df['Type']=='Expense']['Debit'].sum()
te = eq+(r-e)
bal_ok = round(a,2) == round(l+te,2)
bal_cls = "eq-pill bal" if bal_ok else "eq-pill unbal"
bal_txt = "✓ In balance" if bal_ok else "✗ Off balance"
bal_icon = ('') if bal_ok else '✗'
bal_label = 'In balance' if bal_ok else 'Off balance'
return (
f'
'
f'
Assets
${a:,.2f}
'
f'
=
'
f'
Liabilities
${l:,.2f}
'
f'
+
'
f'
Equity
${te:,.2f}
'
f'
{bal_icon} {bal_label}
'
f'
'
)
def generate_financial_reports():
if general_ledger_df.empty:
e = pd.DataFrame({"Status":["No transactions recorded yet."]})
return e,e,e,e,e,go.Figure()
df = general_ledger_df.copy(); df['Net']=df['Debit']-df['Credit']
s = df.groupby(['Account','Type','Sub'])['Net'].sum().reset_index()
rev=s[s['Type']=='Revenue']['Net'].sum()*-1; exp=s[s['Type']=='Expense']['Net'].sum(); ni=rev-exp
is_r=[]
for _,r in s[s['Type']=='Revenue'].iterrows(): is_r.append([f" {r['Account']}",f"${abs(r['Net']):,.2f}"])
is_r+=[["Total Revenue",f"${rev:,.2f}"],["",""]]
for _,r in s[s['Type']=='Expense'].iterrows(): is_r.append([f" {r['Account']}",f"${abs(r['Net']):,.2f}"])
is_r+=[["Total Expenses",f"${exp:,.2f}"],["─"*20,"─"*14],["NET INCOME",f"${ni:,.2f}"]]
is_df=pd.DataFrame(is_r,columns=["Line Item","Amount"])
cs=s[s['Account']=='Common Stock']['Net'].sum()*-1
re=s[s['Account']=='Retained Earnings']['Net'].sum()*-1+ni
dv=s[s['Account']=='Dividends']['Net'].sum() if 'Dividends' in s['Account'].values else 0
se_df=pd.DataFrame([["Common Stock",f"${cs:,.2f}"],["+Net Income",f"${ni:,.2f}"],["-Dividends",f"${dv:,.2f}"],
["Retained Earnings",f"${re:,.2f}"],["─"*20,"─"*14],["TOTAL EQUITY",f"${cs+re:,.2f}"]],columns=["Component","Value"])
at=s[s['Type']=='Asset']['Net'].sum(); lt=s[s['Type']=='Liability']['Net'].sum()*-1
bs_r=[["━ ASSETS ━",""]]
for _,r in s[s['Type']=='Asset'].iterrows(): bs_r.append([f" {r['Account']}",f"${r['Net']:,.2f}"])
bs_r+=[["Total Assets",f"${at:,.2f}"],["",""],["━ LIABILITIES ━",""]]
for _,r in s[s['Type']=='Liability'].iterrows(): bs_r.append([f" {r['Account']}",f"${abs(r['Net']):,.2f}"])
bs_r+=[["Total Liabilities",f"${lt:,.2f}"],["",""],["━ EQUITY ━",""],["Total Equity",f"${cs+re:,.2f}"],["─"*20,"─"*14],["L + E",f"${lt+cs+re:,.2f}"]]
bs_df=pd.DataFrame(bs_r,columns=["Category","Amount"])
cb=s[s['Account']=='Cash']['Net'].sum() if 'Cash' in s['Account'].values else 0
cf_df=pd.DataFrame([["Net Income",f"${ni:,.2f}"],["Cash on Hand",f"${cb:,.2f}"]],columns=["Activity","Amount"])
tb_r,td,tc=[],0,0
for _,r in s.iterrows():
n=r['Net'];dv2=abs(n) if n>=0 else 0;cv=abs(n) if n<0 else 0;td+=dv2;tc+=cv
tb_r.append([r['Account'],f"${dv2:,.2f}" if dv2>0 else "—",f"${cv:,.2f}" if cv>0 else "—"])
tb_r+=[["─"*20,"─"*14,"─"*14],["TOTALS",f"${td:,.2f}",f"${tc:,.2f}"],
[""," ✅" if round(td,2)==round(tc,2) else " ❌",""]]
tb_df=pd.DataFrame(tb_r,columns=["Account","Debit","Credit"])
cm={'Asset':'#1A3C6E','Liability':'#BF0A30','Equity':'#7C3AED','Revenue':'#059669','Expense':'#EA580C'}
fig=go.Figure()
for _,r in s[s['Net'].abs()>0].iterrows():
fig.add_trace(go.Bar(x=[r['Account']],y=[abs(r['Net'])],marker=dict(color=cm.get(r['Type'],'#64748B'),cornerradius=6),
text=f"${abs(r['Net']):,.0f}",textposition='outside',textfont=dict(size=11,family="DM Sans",color="#1e293b"),
hovertemplate=f"{r['Account']} {r['Type']} ${abs(r['Net']):,.2f}"))
fig.update_layout(title=dict(text="Account Balances",font=dict(size=17,family="DM Sans",color="#0F172A"),x=0.02),
template="plotly_white",showlegend=False,font=dict(family="DM Sans",color="#334155"),
plot_bgcolor='rgba(0,0,0,0)',paper_bgcolor='rgba(0,0,0,0)',margin=dict(l=40,r=30,t=55,b=50),
xaxis=dict(showgrid=False,tickangle=-35),yaxis=dict(showgrid=True,gridcolor='rgba(0,0,0,0.06)',tickprefix='$'),bargap=0.3)
return is_df,se_df,bs_df,cf_df,tb_df,fig
def add_journal_entry_from_table(date_str, description, table_data):
global general_ledger_df, journal_entries_df
if not description or not description.strip(): raise gr.Error("⛔ Description is REQUIRED.")
vr=table_data.dropna(subset=['Account']); vr=vr[vr['Account'].astype(str).str.strip()!=""]
if vr.empty: raise gr.Error("⛔ No valid rows.")
unk=list(set([str(r['Account']).strip() for _,r in vr.iterrows() if str(r['Account']).strip() not in COA_MAP and str(r['Account']).strip()!=""]))
if unk: raise gr.Error(f"⚠️ Unknown: {', '.join(unk)}. Add in Chart of Accounts first.")
dr=pd.to_numeric(vr['Debit'],errors='coerce').fillna(0).sum()
cr=pd.to_numeric(vr['Credit'],errors='coerce').fillna(0).sum()
if round(dr,2)!=round(cr,2) or dr==0: raise gr.Error(f"⛔ Unbalanced! DR:${dr:,.2f} CR:${cr:,.2f}")
je_counter[0]+=1;jn=je_counter[0];gl=[]
for _,row in vr.iterrows():
a=str(row['Account']).strip();inf=COA_MAP.get(a,{"type":"Other","sub":"Other","normal":"Debit","code":"9999"})
gl.append({'Date':pd.to_datetime(date_str),'Account':a,'Type':inf['type'],'Sub':inf['sub'],
'Debit':float(row['Debit'] or 0),'Credit':float(row['Credit'] or 0),'Description':description,'JE_Num':jn})
ng=pd.DataFrame(gl)
general_ledger_df=pd.concat([general_ledger_df,ng],ignore_index=True)
nj=pd.DataFrame([{'JE #':f"JE-{jn:04d}",'Date':date_str,'Description':description,
'Entries':f"DR ${dr:,.2f} / CR ${cr:,.2f}",'Status':'✅ Posted'}])
journal_entries_df=pd.concat([journal_entries_df,nj],ignore_index=True)
fig=go.Figure()
for _,e2 in ng.iterrows():
ac=e2['Account'];nm=COA_MAP.get(ac,{}).get('normal','Debit')
if e2['Debit']>0:
c='#1A3C6E' if nm=='Debit' else '#BF0A30'
fig.add_trace(go.Bar(x=[ac],y=[e2['Debit']],marker=dict(color=c,cornerradius=5),text=f"DR ${e2['Debit']:,.0f}",textposition='outside',textfont=dict(size=11,color="#1e293b")))
if e2['Credit']>0:
c='#1A3C6E' if nm=='Credit' else '#BF0A30'
fig.add_trace(go.Bar(x=[ac],y=[-e2['Credit']],marker=dict(color=c,cornerradius=5),text=f"CR ${e2['Credit']:,.0f}",textposition='outside',textfont=dict(size=11,color="#1e293b")))
fig.update_layout(height=290,barmode='relative',title=dict(text=f"JE-{jn:04d} · 🔵 Increase 🔴 Decrease",font=dict(size=13,family="DM Sans",color="#0F172A")),
template="plotly_white",showlegend=False,font=dict(family="DM Sans",color="#334155"),
plot_bgcolor='rgba(0,0,0,0)',paper_bgcolor='rgba(0,0,0,0)',margin=dict(l=20,r=20,t=50,b=20),
xaxis=dict(showgrid=False),yaxis=dict(showgrid=True,gridcolor='rgba(0,0,0,0.05)',zeroline=True,zerolinecolor='#94A3B8'))
return general_ledger_df,journal_entries_df,fig,update_equation_status()
def delete_journal_entry(jstr):
global general_ledger_df,journal_entries_df
if not jstr or not jstr.strip(): raise gr.Error("Enter JE number (e.g. JE-0001)")
js=jstr.strip().upper()
if not js.startswith("JE-"): js=f"JE-{js}"
try: jn=int(js.replace("JE-",""))
except: raise gr.Error(f"Invalid: {jstr}")
if 'JE_Num' not in general_ledger_df.columns or jn not in general_ledger_df['JE_Num'].values: raise gr.Error(f"{js} not found.")
general_ledger_df=general_ledger_df[general_ledger_df['JE_Num']!=jn].reset_index(drop=True)
journal_entries_df.loc[journal_entries_df['JE #']==f"JE-{jn:04d}",'Status']='🗑️ Deleted'
return general_ledger_df,journal_entries_df,update_equation_status(),f"✅ {js} deleted."
def loan_ui(principal,rate,months,start_date):
p,ar,m=float(principal),float(rate),int(months);mr=ar/12
pmt=p*(mr*(1+mr)**m)/((1+mr)**m-1) if mr>0 else p/m
sch,rem=[],p;cd=pd.to_datetime(start_date)
for i in range(1,m+1):
intr=rem*mr;pr=pmt-intr;rem-=pr
sch.append([i,cd.strftime('%Y-%m-%d'),round(pmt,2),round(intr,2),round(pr,2),round(max(0,rem),2)])
cd+=pd.DateOffset(months=1)
df=pd.DataFrame(sch,columns=['Period','Date','Payment','Interest','Principal','Balance'])
total_int=df['Interest'].sum()
# Cumulative paid amounts over time
cum_principal = df['Principal'].cumsum().tolist()
cum_interest = df['Interest'].cumsum().tolist()
periods = df['Period'].tolist()
fig=go.Figure()
# Interest area (red, underneath)
fig.add_trace(go.Scatter(
x=periods, y=cum_interest,
fill='tozeroy', name='Interest',
line=dict(color='#BF0A30', width=3),
fillcolor='rgba(191,10,48,0.22)',
mode='lines',
hovertemplate='Month %{x} Interest: $%{y:,.2f}'
))
# Principal area (navy, larger values on top)
fig.add_trace(go.Scatter(
x=periods, y=cum_principal,
fill='tonexty', name='Principal',
line=dict(color='#1A3C6E', width=3),
fillcolor='rgba(26,60,110,0.3)',
mode='lines',
hovertemplate='Month %{x} Principal: $%{y:,.2f}'
))
header = (
f"MONTHLY PAYMENT"
f"