""" views/ai_summary_view.py — AI Document Summary panel. Allows any logged-in user to upload one or more document files (PDF, DOCX, TXT, CSV, XLSX, MD) and receive an AI-generated summary powered by the Groq API. Behavior by role: Admin : sees API key config + model selector (can save settings) User : settings are hidden; uses the stored API key transparently The AI prompt is focused on extracting procurement/solicitation fields: Solicitation Number, Type, Set-Aside, Description, Work Site, Pre-Proposal Conference, POC, Square Footage, Due Date, etc. Layout ------ Top : page header + (admin only) API-key / model config bar Middle: two-pane — left = file list / controls, right = summary output Bottom: status bar """ import os import logging import threading import tkinter as tk from tkinter import ttk, filedialog from utils.ui_helpers import ( COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, show_error, show_info, ) logger = logging.getLogger("ai_summary_view") # -- Supported file extensions ------------------------------------------------- SUPPORTED_EXT = { ".txt", ".md", ".csv", ".pdf", ".doc", ".docx", ".xlsx", ".xls", } FILE_DIALOG_TYPES = [ ("Supported documents", "*.txt *.md *.csv *.pdf *.docx *.doc *.xlsx *.xls"), ("Text files", "*.txt *.md *.csv"), ("PDF files", "*.pdf"), ("Word documents", "*.docx *.doc"), ("Excel files", "*.xlsx *.xls"), ("All files", "*.*"), ] # -- Groq model options -------------------------------------------------------- GROQ_MODELS = [ "llama-3.3-70b-versatile", "llama-3.1-8b-instant", "gemma2-9b-it", "mixtral-8x7b-32768", ] # -- Config keys stored in config.ini ------------------------------------------ _CFG_SECTION = "groq" _CFG_KEY_KEY = "api_key" _CFG_KEY_MODEL = "model" # -- Focused extraction prompt (procurement / solicitation) -------------------- _OFFICE_ADDRESS = "2815 Hartland Road, Falls Church, VA 22043, USA" _EXTRACTION_PROMPT = """\ You are an expert government procurement analyst. The user has provided {n} document(s). Your job is to extract specific \ information from each document and present it in a clean, structured format. IMPORTANT — Our office is located at: {office} Use this as the ORIGIN address for all driving distance and travel time \ calculations in field #9 below. For EACH document, extract and clearly label the following fields \ (write "N/A" if a field is not found): 1. Solicitation Number 2. Solicitation Type (e.g. RFP, RFQ, IFB, etc.) 3. Set-Aside (e.g. Small Business, 8(a), N/A) 4. Description / Scope of Work 5. Work Site / Location(s) 6. Pre-Proposal Conference / Site-Visit (date, time, full address — mandatory or optional) 7. Point of Contact (POC) (name, phone, email) 8. Total Square Footage (if applicable) 9. Driving Distance & Travel Time - Origin: {office} - Destination: the Pre-Proposal Conference or Site-Visit address from field #6 \ (if no conference, use the primary Work Site address from field #5) - Provide your best estimate of driving distance (miles) and typical \ driving time using major highways - Note that these are AI estimates; actual times may vary with traffic 10. Last Day to Submit Questions 11. Due Date & Time 12. Any other notable requirements or deadlines After the per-document breakdown, provide a detailed OVERALL SUMMARY covering: A. Scope of Work - What services or work are being requested? - What are the key performance requirements, quality standards, or \ special conditions? - Are there any specific technical, staffing, or equipment requirements? B. Contract Period - What is the anticipated base period of performance? - Are there any option years or renewal provisions mentioned? - What is the expected contract start date (if stated)? C. Proposal Submission Requirements - What documents, forms, or sections must be included in the proposal? - Are there page limits, formatting requirements, or specific \ submission instructions? - What evaluation criteria or factors will be used to award the contract? - Are there any bonding, insurance, licensing, or certification requirements? D. Key Deadlines & Action Items - List all critical dates in chronological order. - Flag any mandatory attendance requirements (e.g. site visits). Be precise, detailed, and use bullet points throughout. \ If information is not explicitly stated in the documents, note it as \ "Not specified in the document." DOCUMENTS: {documents} """ # ------------------------------------------------------------------------------ class AiSummaryView(ttk.Frame): def __init__(self, parent, current_user: dict): super().__init__(parent) self.current_user = current_user self._is_admin = (current_user.get("role") == "admin") self._files: list[str] = [] # list of absolute file paths self._running = False # True while AI call is in-flight self._api_key_var = tk.StringVar() self._model_var = tk.StringVar(value=GROQ_MODELS[0]) self._status_var = tk.StringVar(value="Ready.") self._load_config() self._build_ui() # -- Config persistence ---------------------------------------------------- def _load_config(self): """Read stored API key / model from config.ini [groq] section.""" try: import configparser from config import CONFIG_FILE cfg = configparser.ConfigParser() cfg.read(CONFIG_FILE, encoding="utf-8") if cfg.has_section(_CFG_SECTION): self._api_key_var.set( cfg.get(_CFG_SECTION, _CFG_KEY_KEY, fallback="")) model = cfg.get(_CFG_SECTION, _CFG_KEY_MODEL, fallback=GROQ_MODELS[0]) if model in GROQ_MODELS: self._model_var.set(model) except Exception as e: logger.warning(f"Could not load Groq config: {e}") def _save_config(self): """Persist API key / model to config.ini [groq] section.""" try: import configparser from config import CONFIG_FILE cfg = configparser.ConfigParser() cfg.read(CONFIG_FILE, encoding="utf-8") if not cfg.has_section(_CFG_SECTION): cfg.add_section(_CFG_SECTION) cfg.set(_CFG_SECTION, _CFG_KEY_KEY, self._api_key_var.get().strip()) cfg.set(_CFG_SECTION, _CFG_KEY_MODEL, self._model_var.get()) with open(CONFIG_FILE, "w", encoding="utf-8") as fh: cfg.write(fh) except Exception as e: logger.warning(f"Could not save Groq config: {e}") # -- UI construction ------------------------------------------------------- def _build_ui(self): C = COLOURS # -- Page header ------------------------------------------------------- hdr = ttk.Frame(self) hdr.pack(fill="x", pady=(0, 10)) ttk.Label(hdr, text="🤖 AI Document Summary", style="Heading.TLabel").pack(side="left") ttk.Label(hdr, text="Upload documents and let AI extract key information.", style="Dim.TLabel").pack(side="left", padx=(12, 0)) # -- Admin-only: API key / model config bar ---------------------------- if self._is_admin: self._build_admin_config_bar() # -- Main two-pane area ------------------------------------------------ pane = tk.PanedWindow(self, orient="horizontal", bg=C["border"], sashwidth=4, sashrelief="flat") pane.pack(fill="both", expand=True, pady=(0, 6)) # Left pane -- file list left = tk.Frame(pane, bg=C["bg"]) pane.add(left, minsize=260, width=300) self._build_file_panel(left) # Right pane -- summary output right = tk.Frame(pane, bg=C["bg"]) pane.add(right, minsize=350) self._build_output_panel(right) # -- Status bar -------------------------------------------------------- status_bar = tk.Frame(self, bg=C["surface2"], pady=4) status_bar.pack(fill="x", side="bottom") tk.Label(status_bar, textvariable=self._status_var, bg=C["surface2"], fg=C["text_dim"], font=FONT_SMALL, anchor="w").pack(side="left", padx=10) def _build_admin_config_bar(self): """Render the API key + model selector card (admin only).""" C = COLOURS cfg_card = tk.Frame(self, bg=C["surface"], pady=10, padx=14) cfg_card.pack(fill="x", pady=(0, 10)) # Row 0 -- API key tk.Label(cfg_card, text="Groq API Key:", bg=C["surface"], fg=C["text_dim"], font=FONT_SMALL).grid(row=0, column=0, sticky="w", padx=(0, 8), pady=4) self._key_entry = tk.Entry( cfg_card, textvariable=self._api_key_var, show="•", width=48, bg=C["surface2"], fg=C["text"], insertbackground=C["text"], relief="flat", font=FONT, ) self._key_entry.grid(row=0, column=1, sticky="ew", padx=(0, 8), pady=4) # Show / hide toggle self._eye_btn = tk.Button( cfg_card, text="👁", command=self._toggle_key_visibility, bg=C["surface2"], fg=C["text_dim"], activebackground=C["accent"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=6, ) self._eye_btn.grid(row=0, column=2, padx=(0, 12), pady=4) # Row 1 -- model selector + save tk.Label(cfg_card, text="Model:", bg=C["surface"], fg=C["text_dim"], font=FONT_SMALL).grid(row=1, column=0, sticky="w", padx=(0, 8), pady=4) ttk.Combobox( cfg_card, textvariable=self._model_var, values=GROQ_MODELS, state="readonly", width=30, ).grid(row=1, column=1, sticky="w", padx=(0, 8), pady=4) tk.Button( cfg_card, text="💾 Save Settings", command=self._on_save_settings, bg=C["surface2"], fg=C["text"], activebackground=C["accent"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=4, ).grid(row=1, column=2, padx=(0, 12), pady=4) cfg_card.columnconfigure(1, weight=1) # -- File panel ------------------------------------------------------------ def _build_file_panel(self, parent): C = COLOURS # Toolbar toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10) toolbar.pack(fill="x") tk.Label(toolbar, text="📂 Files", bg=C["surface"], fg=C["text"], font=FONT_BOLD).pack(side="left") tk.Button( toolbar, text="✕ Clear All", command=self._clear_files, bg=C["surface"], fg=C["danger"], activebackground=C["danger"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", ).pack(side="right", padx=(4, 0)) tk.Button( toolbar, text="➕ Add Files", command=self._add_files, bg=C["accent"], fg=C["white"], activebackground=C["accent_hover"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=4, ).pack(side="right", padx=(4, 0)) # File listbox with scrollbar list_frame = tk.Frame(parent, bg=C["bg"]) list_frame.pack(fill="both", expand=True, padx=6, pady=(4, 6)) vsb = ttk.Scrollbar(list_frame, orient="vertical") vsb.pack(side="right", fill="y") self._file_lb = tk.Listbox( list_frame, bg=C["surface"], fg=C["text"], selectbackground=C["accent"], selectforeground=C["white"], activestyle="none", relief="flat", font=FONT_SMALL, yscrollcommand=vsb.set, selectmode="extended", ) self._file_lb.pack(side="left", fill="both", expand=True) vsb.config(command=self._file_lb.yview) # Right-click context menu self._ctx_menu = tk.Menu(self._file_lb, tearoff=0) self._ctx_menu.add_command(label="Remove selected", command=self._remove_selected) self._file_lb.bind("", self._show_ctx_menu) # Run button (bottom of left pane) btn_frame = tk.Frame(parent, bg=C["bg"], pady=6) btn_frame.pack(fill="x", padx=6) self._run_btn = tk.Button( btn_frame, text="✨ Analyze with AI", command=self._on_summarize, bg=C["accent"], fg=C["white"], activebackground=C["accent_hover"], activeforeground=C["white"], relief="flat", font=FONT_BOLD, cursor="hand2", pady=10, ) self._run_btn.pack(fill="x") # Progress bar (hidden until running) self._progress = ttk.Progressbar(btn_frame, mode="indeterminate") self._refresh_file_list() # -- Output panel ---------------------------------------------------------- def _build_output_panel(self, parent): C = COLOURS # Toolbar toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10) toolbar.pack(fill="x") tk.Label(toolbar, text="📝 Extracted Information", bg=C["surface"], fg=C["text"], font=FONT_BOLD).pack(side="left") tk.Button( toolbar, text="📋 Copy", command=self._copy_summary, bg=C["surface2"], fg=C["text"], activebackground=C["accent"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=8, pady=4, ).pack(side="right", padx=(4, 0)) tk.Button( toolbar, text="💾 Save as TXT", command=self._save_summary, bg=C["surface2"], fg=C["text"], activebackground=C["success"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=8, pady=4, ).pack(side="right", padx=(4, 0)) tk.Button( toolbar, text="🗑 Clear", command=self._clear_output, bg=C["surface"], fg=C["danger"], activebackground=C["danger"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", ).pack(side="right", padx=(4, 0)) # Text widget txt_frame = tk.Frame(parent, bg=C["bg"]) txt_frame.pack(fill="both", expand=True, padx=6, pady=(4, 6)) vsb = ttk.Scrollbar(txt_frame, orient="vertical") vsb.pack(side="right", fill="y") hsb = ttk.Scrollbar(txt_frame, orient="horizontal") hsb.pack(side="bottom", fill="x") self._output_txt = tk.Text( txt_frame, wrap="word", bg=C["surface"], fg=C["text"], insertbackground=C["text"], relief="flat", font=FONT, padx=12, pady=10, yscrollcommand=vsb.set, xscrollcommand=hsb.set, state="disabled", ) self._output_txt.pack(side="left", fill="both", expand=True) vsb.config(command=self._output_txt.yview) hsb.config(command=self._output_txt.xview) self._output_txt.tag_configure( "placeholder", foreground=COLOURS["text_dim"], font=FONT_SMALL, ) self._set_output_placeholder() # -- File operations ------------------------------------------------------- def _add_files(self): paths = filedialog.askopenfilenames( title="Select documents to analyze", filetypes=FILE_DIALOG_TYPES, ) added = 0 for p in paths: ext = os.path.splitext(p)[1].lower() if ext not in SUPPORTED_EXT: show_error( f"Unsupported file type: {ext}\n\n" f"Supported: {', '.join(sorted(SUPPORTED_EXT))}", title="Unsupported File", ) continue if p not in self._files: self._files.append(p) added += 1 if added: self._refresh_file_list() self._set_status(f"{added} file(s) added. {len(self._files)} total.") def _remove_selected(self): selected = list(self._file_lb.curselection()) for i in reversed(selected): del self._files[i] self._refresh_file_list() self._set_status(f"{len(self._files)} file(s) remaining.") def _clear_files(self): self._files.clear() self._refresh_file_list() self._set_status("File list cleared.") def _show_ctx_menu(self, event): try: self._file_lb.selection_set(self._file_lb.nearest(event.y)) self._ctx_menu.tk_popup(event.x_root, event.y_root) finally: self._ctx_menu.grab_release() def _refresh_file_list(self): self._file_lb.delete(0, "end") for path in self._files: icon = _file_icon(path) size = _human_size(os.path.getsize(path)) self._file_lb.insert( "end", f" {icon} {os.path.basename(path)} ({size})") # -- Output helpers -------------------------------------------------------- def _set_output_text(self, text: str): self._output_txt.config(state="normal") self._output_txt.delete("1.0", "end") self._output_txt.insert("end", text) self._output_txt.config(state="disabled") def _set_output_placeholder(self): self._output_txt.config(state="normal") self._output_txt.delete("1.0", "end") placeholder = ( "Extracted information will appear here.\n\n" "1. Add one or more documents using ➕ Add Files.\n" "2. Click ✨ Analyze with AI.\n\n" "The AI will extract:\n" " • Solicitation Number & Type\n" " • Set-Aside\n" " • Description / Scope of Work\n" " • Work Site / Location(s)\n" " • Pre-Proposal Conference details\n" " • Point of Contact (POC)\n" " • Square Footage\n" " • Due Date & Time\n" " • Key deadlines & action items" ) if not self._is_admin: placeholder = placeholder.replace( "1. Add one or more documents using ➕ Add Files.\n" "2. Click ✨ Analyze with AI.", "1. Add one or more documents using ➕ Add Files.\n" "2. Click ✨ Analyze with AI.\n\n" "(Contact your administrator to configure the API key.)" ) self._output_txt.insert("end", placeholder, "placeholder") self._output_txt.config(state="disabled") def _clear_output(self): self._set_output_placeholder() self._set_status("Output cleared.") def _copy_summary(self): text = self._output_txt.get("1.0", "end").strip() if not text or text.startswith("Extracted information"): show_error("Nothing to copy yet.") return self.clipboard_clear() self.clipboard_append(text) self._set_status("Output copied to clipboard.") def _save_summary(self): text = self._output_txt.get("1.0", "end").strip() if not text or text.startswith("Extracted information"): show_error("Nothing to save yet.") return path = filedialog.asksaveasfilename( title="Save extracted information as", defaultextension=".txt", filetypes=[("Text file", "*.txt"), ("All files", "*.*")], initialfile="ai_extraction.txt", ) if not path: return try: with open(path, "w", encoding="utf-8") as f: f.write(text) self._set_status(f"Saved to: {path}") show_info(f"File saved to:\n{path}") except Exception as e: show_error(f"Could not save file:\n{e}") # -- Settings (admin only) ------------------------------------------------- def _toggle_key_visibility(self): current = self._key_entry.cget("show") self._key_entry.config(show="" if current == "•" else "•") def _on_save_settings(self): self._save_config() self._set_status("Groq settings saved.") show_info("Settings saved successfully.") # -- Analyze --------------------------------------------------------------- def _on_summarize(self): if self._running: return api_key = self._api_key_var.get().strip() if not api_key: msg = ( "No Groq API key is configured.\n\n" "Please ask your administrator to set one up." if not self._is_admin else "Please enter your Groq API key above.\n\n" "You can get one for free at https://console.groq.com" ) show_error(msg, title="API Key Required") return if not self._files: show_error("Please add at least one document file.", title="No Files") return self._start_spinner() threading.Thread( target=self._run_analyze, args=(api_key, self._model_var.get(), list(self._files)), daemon=True, ).start() def _run_analyze(self, api_key: str, model: str, file_paths: list[str]): """Background thread: extract text -> call Groq -> post result.""" try: # 1. Extract text from each file combined_text = "" for fp in file_paths: self.after(0, lambda f=fp: self._set_status( f"Reading: {os.path.basename(f)} ...")) try: text = _extract_text(fp) except Exception as ex: combined_text += ( f"\n\n{'─' * 40}\n" f"FILE: {os.path.basename(fp)}\n" f"{'─' * 40}\n" f"[ERROR: Could not read this file — {ex}]\n" ) continue if not text.strip(): combined_text += ( f"\n\n{'─' * 40}\n" f"FILE: {os.path.basename(fp)}\n" f"{'─' * 40}\n" f"[No readable text found in this file.]\n" ) continue # Truncate very large files to stay within token limits MAX_CHARS_PER_FILE = 14_000 if len(text) > MAX_CHARS_PER_FILE: text = (text[:MAX_CHARS_PER_FILE] + "\n\n[... content truncated to fit token limit ...]") combined_text += ( f"\n\n{'─' * 40}\n" f"FILE: {os.path.basename(fp)}\n" f"{'─' * 40}\n" f"{text}" ) if not combined_text.strip(): self.after(0, lambda: ( self._stop_spinner(), show_error("No readable text found in the selected files."), )) return # 2. Build prompt n = len(file_paths) prompt = _EXTRACTION_PROMPT.format( n=n, office=_OFFICE_ADDRESS, documents=combined_text) # 3. Call Groq API self.after(0, lambda: self._set_status( "Sending to Groq AI ... please wait.")) from groq import Groq client = Groq(api_key=api_key) response = client.chat.completions.create( model=model, messages=[{"role": "user", "content": prompt}], temperature=0.2, max_tokens=4096, ) result = response.choices[0].message.content.strip() # 4. Build output header file_list_str = "\n".join( f" - {os.path.basename(fp)}" for fp in file_paths ) header = ( f"AI Extraction | Model: {model}\n" f"Files analyzed ({n}):\n{file_list_str}\n" f"{'=' * 60}\n\n" ) full_output = header + result self.after(0, lambda t=full_output: self._on_success(t)) except Exception as exc: self.after(0, lambda e=exc: self._on_error(e)) def _on_success(self, text: str): self._stop_spinner() self._set_output_text(text) self._set_status("Analysis complete.") logger.info( f"[AI SUMMARY] Completed for user " f"'{self.current_user.get('username')}' " f"({len(self._files)} file(s))." ) def _on_error(self, exc: Exception): self._stop_spinner() err_msg = str(exc) if "401" in err_msg or "invalid_api_key" in err_msg.lower(): err_msg = "Invalid API key. Please check your Groq API key and try again." elif "429" in err_msg or "rate_limit" in err_msg.lower(): err_msg = "Rate limit reached. Please wait a moment and try again." elif "connection" in err_msg.lower(): err_msg = "Could not connect to Groq. Please check your internet connection." show_error(f"AI analysis failed:\n\n{err_msg}", title="Groq API Error") self._set_status("Error - see popup for details.") logger.error(f"[AI SUMMARY] Error: {exc}") # -- Spinner helpers ------------------------------------------------------- def _start_spinner(self): self._running = True self._run_btn.config(state="disabled", text="⏳ Processing ...") self._progress.pack(fill="x", pady=(6, 0)) self._progress.start(12) def _stop_spinner(self): self._running = False self._progress.stop() self._progress.pack_forget() self._run_btn.config(state="normal", text="✨ Analyze with AI") # -- Status bar ------------------------------------------------------------ def _set_status(self, msg: str): self._status_var.set(msg) # ------------------------------------------------------------------------------ # Text extraction helpers # ------------------------------------------------------------------------------ def _extract_text(file_path: str) -> str: """Return the plain-text content of a supported file.""" ext = os.path.splitext(file_path)[1].lower() if ext in (".txt", ".md", ".csv"): return _read_text_file(file_path) if ext == ".pdf": return _read_pdf(file_path) if ext == ".docx": return _read_docx(file_path) if ext == ".doc": return _read_doc(file_path) if ext in (".xlsx", ".xls"): return _read_excel(file_path) raise ValueError(f"Unsupported file type: {ext}") def _read_text_file(path: str) -> str: for enc in ("utf-8", "utf-8-sig", "cp1252", "latin-1"): try: with open(path, "r", encoding=enc) as f: return f.read() except UnicodeDecodeError: continue raise ValueError("Cannot decode text file with common encodings.") def _read_pdf(path: str) -> str: try: import pypdf reader = pypdf.PdfReader(path) return "\n".join(page.extract_text() or "" for page in reader.pages) except ImportError: pass try: import PyPDF2 with open(path, "rb") as f: reader = PyPDF2.PdfReader(f) return "\n".join(page.extract_text() or "" for page in reader.pages) except ImportError: raise ImportError( "PDF reading requires 'pypdf'.\n" "Install it with: pip install pypdf" ) def _read_docx(path: str) -> str: """Read modern .docx (Office Open XML) via python-docx.""" try: import docx doc = docx.Document(path) return "\n".join(p.text for p in doc.paragraphs) except ImportError: raise ImportError( "DOCX reading requires 'python-docx'.\n" "Install it with: pip install python-docx" ) def _read_doc(path: str) -> str: """Read legacy .doc (binary OLE) files. Strategy (Windows): 1. win32com — automates Word to save as temp .docx, then reads it. Requires Microsoft Word to be installed; most reliable. 2. docx2txt — pure-Python fallback that can sometimes handle simple .doc files by treating them as ZIP-like structures (works for some .doc). 3. Raw text scrape — last resort ASCII extraction from the binary. """ # --- Strategy 1: Word COM automation (Windows + Word installed) ----------- try: import win32com.client import tempfile import pythoncom pythoncom.CoInitialize() word = win32com.client.Dispatch("Word.Application") word.Visible = False try: abs_path = os.path.abspath(path) doc = word.Documents.Open(abs_path) # Save as a temp .docx so python-docx can read it cleanly with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tf: tmp_path = tf.name doc.SaveAs2(tmp_path, FileFormat=16) # 16 = wdFormatXMLDocument doc.Close(False) text = _read_docx(tmp_path) return text finally: try: word.Quit() except Exception: pass try: os.unlink(tmp_path) except Exception: pass pythoncom.CoUninitialize() except Exception: pass # Word not installed or COM error — try next strategy # --- Strategy 2: docx2txt (pure Python, works on some .doc files) -------- try: import docx2txt text = docx2txt.process(path) if text and text.strip(): return text except Exception: pass # --- Strategy 3: raw ASCII scrape from binary ---------------------------- try: with open(path, "rb") as f: raw = f.read() # Extract printable ASCII runs of 4+ chars import re chunks = re.findall(rb"[ -~]{4,}", raw) text = "\n".join(c.decode("ascii", errors="ignore") for c in chunks) if text.strip(): return text except Exception: pass raise ValueError( f"Could not extract text from '{os.path.basename(path)}'.\n" "For best results with .doc files, install Microsoft Word " "or convert the file to .docx before uploading." ) def _read_excel(path: str) -> str: try: import openpyxl wb = openpyxl.load_workbook(path, read_only=True, data_only=True) lines = [] for sheet in wb.worksheets: lines.append(f"[Sheet: {sheet.title}]") for row in sheet.iter_rows(values_only=True): row_str = "\t".join( str(v) if v is not None else "" for v in row) if row_str.strip(): lines.append(row_str) return "\n".join(lines) except ImportError: raise ImportError( "Excel reading requires 'openpyxl'.\n" "Install it with: pip install openpyxl" ) # -- Utility helpers ----------------------------------------------------------- def _file_icon(path: str) -> str: ext = os.path.splitext(path)[1].lower() return { ".pdf": "📄", ".doc": "📝", ".docx": "📝", ".xlsx": "📊", ".xls": "📊", ".csv": "📋", ".txt": "🗒", ".md": "📓", }.get(ext, "📁") def _human_size(n: int) -> str: for unit in ("B", "KB", "MB", "GB"): if n < 1024: return f"{n:.0f} {unit}" n /= 1024 return f"{n:.1f} GB"