Files
WebChecker/views/ai_summary_view.py
T

892 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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("<Button-3>", 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"