Spaces:
Build error
Build error
Create utils.py
Browse files
utils.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reusable helper utilities for the University Admissions RAG Chatbot.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import time
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from logging_config import get_logger
|
| 13 |
+
|
| 14 |
+
logger = get_logger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ββ File helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
|
| 19 |
+
def validate_file(
|
| 20 |
+
filepath: str,
|
| 21 |
+
allowed_extensions: tuple[str, ...] = (".pdf", ".docx", ".txt"),
|
| 22 |
+
max_size_mb: int = 20,
|
| 23 |
+
) -> tuple[bool, str]:
|
| 24 |
+
"""Return (is_valid, reason)."""
|
| 25 |
+
path = Path(filepath)
|
| 26 |
+
|
| 27 |
+
if not path.exists():
|
| 28 |
+
return False, f"File not found: {filepath}"
|
| 29 |
+
|
| 30 |
+
suffix = path.suffix.lower()
|
| 31 |
+
if suffix not in allowed_extensions:
|
| 32 |
+
return False, (
|
| 33 |
+
f"Extension '{suffix}' not allowed. "
|
| 34 |
+
f"Supported: {', '.join(allowed_extensions)}"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
size_mb = path.stat().st_size / (1024 * 1024)
|
| 38 |
+
if size_mb > max_size_mb:
|
| 39 |
+
return False, f"File exceeds {max_size_mb} MB limit ({size_mb:.1f} MB)."
|
| 40 |
+
|
| 41 |
+
return True, "OK"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def file_checksum(filepath: str) -> str:
|
| 45 |
+
"""MD5 checksum of a file (for deduplication)."""
|
| 46 |
+
h = hashlib.md5()
|
| 47 |
+
with open(filepath, "rb") as f:
|
| 48 |
+
for chunk in iter(lambda: f.read(8192), b""):
|
| 49 |
+
h.update(chunk)
|
| 50 |
+
return h.hexdigest()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def ensure_dir(path: str) -> str:
|
| 54 |
+
Path(path).mkdir(parents=True, exist_ok=True)
|
| 55 |
+
return path
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ββ Text helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
|
| 60 |
+
def sanitize_input(text: str, max_length: int = 1000) -> str:
|
| 61 |
+
"""Strip control characters and enforce length cap."""
|
| 62 |
+
text = re.sub(r"[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]", "", text)
|
| 63 |
+
return text.strip()[:max_length]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def truncate_text(text: str, max_chars: int = 300) -> str:
|
| 67 |
+
if len(text) <= max_chars:
|
| 68 |
+
return text
|
| 69 |
+
return text[:max_chars].rstrip() + "β¦"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ββ Timing helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
|
| 74 |
+
class Timer:
|
| 75 |
+
"""Context-manager timer."""
|
| 76 |
+
|
| 77 |
+
def __enter__(self) -> "Timer":
|
| 78 |
+
self.start = time.perf_counter()
|
| 79 |
+
return self
|
| 80 |
+
|
| 81 |
+
def __exit__(self, *_: Any) -> None:
|
| 82 |
+
self.elapsed = time.perf_counter() - self.start
|
| 83 |
+
|
| 84 |
+
def __str__(self) -> str:
|
| 85 |
+
return f"{self.elapsed:.3f}s"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ββ Chat history helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 89 |
+
|
| 90 |
+
def format_history_for_context(
|
| 91 |
+
history: list[tuple[str, str]],
|
| 92 |
+
max_turns: int = 6,
|
| 93 |
+
) -> str:
|
| 94 |
+
"""Convert Gradio-style [(user, bot), ...] history to a plain string."""
|
| 95 |
+
recent = history[-max_turns:]
|
| 96 |
+
lines: list[str] = []
|
| 97 |
+
for user_msg, bot_msg in recent:
|
| 98 |
+
if user_msg:
|
| 99 |
+
lines.append(f"User: {user_msg}")
|
| 100 |
+
if bot_msg:
|
| 101 |
+
lines.append(f"Assistant: {bot_msg}")
|
| 102 |
+
return "\n".join(lines)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def export_chat_history(history: list[tuple[str, str]]) -> str:
|
| 106 |
+
"""Return a plain-text transcript."""
|
| 107 |
+
if not history:
|
| 108 |
+
return "No conversation history to export."
|
| 109 |
+
lines = ["University Admissions Assistant β Chat Transcript", "=" * 52, ""]
|
| 110 |
+
for i, (user_msg, bot_msg) in enumerate(history, 1):
|
| 111 |
+
lines.append(f"[Turn {i}]")
|
| 112 |
+
lines.append(f"You: {user_msg}")
|
| 113 |
+
lines.append(f"Assistant: {bot_msg}")
|
| 114 |
+
lines.append("")
|
| 115 |
+
return "\n".join(lines)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ββ Source formatting βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 119 |
+
|
| 120 |
+
def format_sources(docs: list[Any]) -> str:
|
| 121 |
+
"""Format LangChain Document objects into a readable source list."""
|
| 122 |
+
if not docs:
|
| 123 |
+
return ""
|
| 124 |
+
seen: set[str] = set()
|
| 125 |
+
parts: list[str] = []
|
| 126 |
+
for doc in docs:
|
| 127 |
+
src = doc.metadata.get("source", "Unknown source")
|
| 128 |
+
page = doc.metadata.get("page")
|
| 129 |
+
label = f"π {os.path.basename(src)}"
|
| 130 |
+
if page is not None:
|
| 131 |
+
label += f" (p. {page + 1})"
|
| 132 |
+
if label not in seen:
|
| 133 |
+
seen.add(label)
|
| 134 |
+
parts.append(label)
|
| 135 |
+
return "\n".join(parts)
|