Spaces:
Running
Running
| """ | |
| main.py — Iris AI Service (v1.1 - April 2026) | |
| AI layer for the Iris Support Portal (IrisPlus / Unified Spark Desk). | |
| Deployed as a HuggingFace Space monofile (Flask + Gemini + AssemblyAI + Firebase). | |
| CHANGELOG v1.1: | |
| - Model: gemini-3.1-flash-lite-preview (multimodal reasoning) | |
| - /api/kb/whatsapp-import: now accepts multipart ZIP upload | |
| * Extracts _chat.txt + maps image files to <Media omitted> pointers | |
| * Sliding-window chunking (~10k tokens / ~40k chars with overlap) | |
| * Multimodal: sends images inline with their surrounding text chunk | |
| * Strict JSON enforcement + pre-save validation | |
| * JSON parse error recovery (regex extraction fallback) | |
| - All other endpoints unchanged from v1.0 | |
| FEATURES: | |
| 1. WhatsApp Export → Knowledge Base (ZIP multimodal, chunked, additive) | |
| 2. Bulk KB Upload (CSV / Excel / PDF) | |
| 3. Natural Language + Voice Ticket Submission | |
| 4. System Tutorial Ingestion (timestamped transcripts) | |
| 5. Agent NL/Voice Solution Writing | |
| 6. Iris Chatbot (KB RAG) | |
| ENV VARS: | |
| GOOGLE_API_KEY — Gemini API key | |
| ASSEMBLYAI_API_KEY — AssemblyAI API key | |
| FIREBASE — JSON string of Firebase service account | |
| GEMINI_MODEL — Override model (default: gemini-3.1-flash-lite-preview) | |
| PORT — Server port (default 7860) | |
| """ | |
| import os | |
| import io | |
| import re | |
| import json | |
| import time | |
| import uuid | |
| import logging | |
| import base64 | |
| import hashlib | |
| import zipfile | |
| import tempfile | |
| import subprocess | |
| import threading | |
| from functools import wraps | |
| from collections import OrderedDict | |
| from datetime import datetime, timezone | |
| from typing import Any, Dict, List, Optional, Tuple | |
| import requests | |
| from flask import Flask, request, jsonify | |
| from flask_cors import CORS | |
| # ─── Logging ────────────────────────────────────────────────────────────────── | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s | %(levelname)s | %(message)s" | |
| ) | |
| logger = logging.getLogger("iris-ai-service") | |
| # ─── Gemini SDK ─────────────────────────────────────────────────────────────── | |
| try: | |
| from google import genai | |
| from google.genai import types as genai_types | |
| except Exception as e: | |
| genai = None | |
| logger.error("google-genai not installed: %s", e) | |
| GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "") | |
| # v1.1: upgraded to gemini-3.1-flash-lite-preview for multimodal reasoning | |
| GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite-preview") | |
| # Hard timeout (ms) for the long video-extraction call so it can't hang forever. | |
| GEMINI_VIDEO_TIMEOUT_MS = int(os.environ.get("GEMINI_VIDEO_TIMEOUT_MS", "300000")) | |
| _gemini_client = None | |
| if genai and GOOGLE_API_KEY: | |
| try: | |
| _gemini_client = genai.Client(api_key=GOOGLE_API_KEY) | |
| logger.info("Gemini client ready (model=%s).", GEMINI_MODEL) | |
| except Exception as e: | |
| logger.error("Failed to init Gemini client: %s", e) | |
| # ─── AssemblyAI ─────────────────────────────────────────────────────────────── | |
| ASSEMBLYAI_API_KEY = os.environ.get("ASSEMBLYAI_API_KEY", "") | |
| ASSEMBLYAI_BASE = "https://api.assemblyai.com/v2" | |
| # ─── Firebase ───────────────────────────────────────────────────────────────── | |
| try: | |
| import firebase_admin | |
| from firebase_admin import credentials, firestore, auth as fb_auth, storage as fb_storage | |
| FIREBASE_AVAILABLE = True | |
| except ImportError: | |
| FIREBASE_AVAILABLE = False | |
| logger.warning("firebase-admin not installed. Persistence disabled.") | |
| FIREBASE_ENV = os.environ.get("FIREBASE", "") | |
| FIREBASE_STORAGE_BUCKET = os.environ.get("FIREBASE_STORAGE_BUCKET", "") | |
| def init_firestore() -> Optional[Any]: | |
| if not FIREBASE_AVAILABLE: | |
| return None | |
| if not firebase_admin._apps: | |
| if not FIREBASE_ENV: | |
| logger.warning("FIREBASE env var missing. Persistence disabled.") | |
| return None | |
| try: | |
| sa_info = json.loads(FIREBASE_ENV) | |
| cred = credentials.Certificate(sa_info) | |
| opts = {"storageBucket": FIREBASE_STORAGE_BUCKET} if FIREBASE_STORAGE_BUCKET else None | |
| firebase_admin.initialize_app(cred, opts) | |
| logger.info("Firebase initialized (storage bucket=%s).", FIREBASE_STORAGE_BUCKET or "none") | |
| except Exception as e: | |
| logger.critical("Firebase init failed: %s", e) | |
| return None | |
| return firestore.client() | |
| db = init_firestore() | |
| def get_bucket() -> Optional[Any]: | |
| """Return the Firebase Storage bucket, or None if not configured.""" | |
| if not (FIREBASE_AVAILABLE and firebase_admin._apps and FIREBASE_STORAGE_BUCKET): | |
| return None | |
| try: | |
| return fb_storage.bucket() | |
| except Exception as e: | |
| logger.error("Storage bucket unavailable: %s", e) | |
| return None | |
| def _upload_to_storage(data: bytes, dest_path: str, content_type: str) -> str: | |
| """ | |
| Upload bytes to Firebase Storage and return a publicly fetchable URL. | |
| Twilio fetches media_url over the public internet, so the URL must be reachable | |
| without auth. We try make_public() (works on buckets with fine-grained ACLs); if | |
| the bucket uses uniform bucket-level access (the modern default — object ACLs are | |
| disabled and make_public raises), we fall back to a long-lived v4 signed URL. | |
| Returns "" on failure. | |
| """ | |
| bucket = get_bucket() | |
| if not bucket: | |
| logger.warning("Storage upload skipped — bucket not configured (dest=%s)", dest_path) | |
| return "" | |
| try: | |
| t0 = time.time() | |
| blob = bucket.blob(dest_path) | |
| blob.upload_from_string(data, content_type=content_type) | |
| logger.info("[storage] uploaded %s (%d bytes) in %.1fs", dest_path, len(data), time.time() - t0) | |
| try: | |
| blob.make_public() | |
| logger.info("[storage] %s public URL ready", dest_path) | |
| return blob.public_url | |
| except Exception as e: | |
| logger.info("[storage] make_public unavailable (uniform access?): %s — signing URL", e) | |
| signed = _storage_signed_url(dest_path, minutes=60 * 24 * 7) | |
| return signed or "" | |
| except Exception as e: | |
| logger.error("[storage] upload failed for %s: %s", dest_path, e) | |
| return "" | |
| def _storage_signed_url(path: str, minutes: int = 120) -> str: | |
| """ | |
| Generate a v4 signed URL for a stored object. Works regardless of uniform | |
| bucket-level access. Used at send-time for tutorial clips so the URL Twilio | |
| fetches is always valid even if the object is not publicly readable. | |
| """ | |
| bucket = get_bucket() | |
| if not (bucket and path): | |
| return "" | |
| try: | |
| from datetime import timedelta | |
| blob = bucket.blob(path) | |
| return blob.generate_signed_url(expiration=timedelta(minutes=minutes), version="v4") | |
| except Exception as e: | |
| logger.error("Signed URL generation failed for %s: %s", path, e) | |
| return "" | |
| # Maximum length (seconds) of a tutorial clip we will cut. Generous so full steps | |
| # aren't truncated; stream-copied clips stay well under Twilio's ~16MB media limit. | |
| MAX_CLIP_SECONDS = 300 | |
| # Padding added to the end of a clip so the demonstrated step is not cut off abruptly. | |
| CLIP_TAIL_PAD = 2 | |
| def _video_duration(path: str) -> float: | |
| """Return the video's duration in seconds via ffprobe, or 0.0 if unknown.""" | |
| try: | |
| out = subprocess.run( | |
| ["ffprobe", "-v", "error", "-show_entries", "format=duration", | |
| "-of", "default=noprint_wrappers=1:nokey=1", path], | |
| capture_output=True, timeout=60, | |
| ) | |
| return float(out.stdout.decode().strip()) | |
| except Exception as e: | |
| logger.warning("[ffprobe] duration probe failed: %s", e) | |
| return 0.0 | |
| def _video_codec(path: str) -> str: | |
| """Return the video stream's codec name (e.g. h264, hevc) for diagnostics.""" | |
| try: | |
| out = subprocess.run( | |
| ["ffprobe", "-v", "error", "-select_streams", "v:0", | |
| "-show_entries", "stream=codec_name", | |
| "-of", "default=noprint_wrappers=1:nokey=1", path], | |
| capture_output=True, timeout=60, | |
| ) | |
| return out.stdout.decode().strip() or "unknown" | |
| except Exception as e: | |
| logger.warning("[ffprobe] codec probe failed: %s", e) | |
| return "unknown" | |
| def _crop_video_segment(src_path: str, start: int, end: int, out_path: str, | |
| video_duration: float = 0.0) -> bool: | |
| """ | |
| Cut [start, end] seconds out of src_path into out_path using ffmpeg. | |
| We re-encode (not -c copy) so the cut is frame-accurate and the resulting clip | |
| is a self-contained, WhatsApp-playable MP4 (H.264/AAC + faststart). When the | |
| real duration is known, start/end are clamped to it so Gemini timestamps that | |
| overshoot the video don't make ffmpeg seek past EOF (which yields no frames). | |
| Returns True on success. | |
| """ | |
| try: | |
| start = max(0, int(start)) | |
| end = int(end) if end and int(end) > start else start + 30 | |
| end += CLIP_TAIL_PAD | |
| if end - start > MAX_CLIP_SECONDS: | |
| end = start + MAX_CLIP_SECONDS | |
| if video_duration and video_duration > 0: | |
| if start >= video_duration: | |
| logger.warning("[ffmpeg] start %ds is beyond video duration %.1fs — skipping", | |
| start, video_duration) | |
| return False | |
| end = min(end, int(video_duration)) | |
| duration = end - start | |
| if duration <= 0: | |
| logger.warning("[ffmpeg] non-positive clip duration after clamping — skipping") | |
| return False | |
| logger.info("[ffmpeg] cropping %ds segment (%ds–%ds)...", duration, start, end) | |
| def _run(cmd: list, label: str) -> bool: | |
| t0 = time.time() | |
| proc = subprocess.run(cmd, capture_output=True, timeout=300) | |
| ok = proc.returncode == 0 and os.path.exists(out_path) and os.path.getsize(out_path) > 0 | |
| if ok: | |
| logger.info("[ffmpeg] %s done in %.1fs → ok (%d bytes)", | |
| label, time.time() - t0, os.path.getsize(out_path)) | |
| else: | |
| logger.warning("[ffmpeg] %s failed (%d): %s", label, proc.returncode, | |
| proc.stderr.decode("utf-8", "replace")[-300:]) | |
| return ok | |
| # Primary: stream copy — no decode, no libx264. Fast and avoids encoder/codec | |
| # dependency failures ("Could not open encoder before EOF"). Fast input seek | |
| # lands on the nearest keyframe, which is fine for tutorial clips. | |
| copy_cmd = [ | |
| "ffmpeg", "-y", "-ss", str(start), "-i", src_path, "-t", str(duration), | |
| "-c", "copy", "-avoid_negative_ts", "make_zero", | |
| "-movflags", "+faststart", out_path, | |
| ] | |
| if _run(copy_cmd, "copy"): | |
| return True | |
| # Fallback: re-encode (covers cases where copy can't produce a clean MP4). | |
| reencode_cmd = [ | |
| "ffmpeg", "-y", "-ss", str(start), "-i", src_path, "-t", str(duration), | |
| "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", | |
| "-c:a", "aac", "-movflags", "+faststart", out_path, | |
| ] | |
| return _run(reencode_cmd, "re-encode") | |
| except subprocess.TimeoutExpired: | |
| logger.error("[ffmpeg] crop timed out after 300s for segment %ds–%ds", start, end) | |
| return False | |
| except FileNotFoundError: | |
| logger.error("[ffmpeg] not installed — cannot crop video segments.") | |
| return False | |
| except Exception as e: | |
| logger.error("[ffmpeg] crop error: %s", e) | |
| return False | |
| # ─── Twilio / WhatsApp client (optional — only when env configured) ───────────── | |
| try: | |
| import whatsapp_client as wa | |
| WHATSAPP_AVAILABLE = bool(os.environ.get("TWILIO_ACCOUNT_SID")) | |
| if WHATSAPP_AVAILABLE: | |
| logger.info("WhatsApp (Twilio) client loaded.") | |
| except Exception as e: | |
| wa = None | |
| WHATSAPP_AVAILABLE = False | |
| logger.warning("WhatsApp client unavailable: %s", e) | |
| # ─── Optional libs ──────────────────────────────────────────────────────────── | |
| try: | |
| import pandas as pd | |
| PANDAS_AVAILABLE = True | |
| except ImportError: | |
| PANDAS_AVAILABLE = False | |
| try: | |
| import pypdf | |
| PYPDF_AVAILABLE = True | |
| except ImportError: | |
| PYPDF_AVAILABLE = False | |
| # ─── Flask App ──────────────────────────────────────────────────────────────── | |
| app = Flask(__name__) | |
| CORS(app) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # AUTH — Firebase ID token verification + role enforcement | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # | |
| # The Lovable dashboard uses Firebase Auth ONLY to sign in and obtain an ID token. | |
| # Every API call carries `Authorization: Bearer <idToken>`. Flask verifies it with | |
| # firebase_admin and resolves the caller's role from custom claims (fallback: | |
| # the iris_staff/{uid} profile doc). All data work happens server-side. | |
| def _resolve_role(decoded: Dict, uid: str) -> str: | |
| """Resolve a user's role from token claims, falling back to the staff profile.""" | |
| role = decoded.get("role") | |
| if role: | |
| return role | |
| if db: | |
| try: | |
| snap = db.collection("iris_staff").document(uid).get() | |
| if snap.exists: | |
| return snap.to_dict().get("role", "") | |
| except Exception as e: | |
| logger.error("Role lookup error: %s", e) | |
| return "" | |
| def require_auth(roles: Optional[List[str]] = None): | |
| """ | |
| Decorator: require a valid Firebase ID token. If `roles` is given, the caller's | |
| role must be one of them. On success, attaches `request.user` = {uid, email, role}. | |
| """ | |
| def decorator(fn): | |
| def wrapper(*args, **kwargs): | |
| if not (FIREBASE_AVAILABLE and firebase_admin._apps): | |
| return jsonify({"ok": False, "error": "Auth backend unavailable"}), 503 | |
| header = request.headers.get("Authorization", "") | |
| if not header.startswith("Bearer "): | |
| return jsonify({"ok": False, "error": "Missing bearer token"}), 401 | |
| token = header.split(" ", 1)[1].strip() | |
| try: | |
| decoded = fb_auth.verify_id_token(token) | |
| except Exception as e: | |
| logger.warning("Token verification failed: %s", e) | |
| return jsonify({"ok": False, "error": "Invalid or expired token"}), 401 | |
| uid = decoded.get("uid") | |
| role = _resolve_role(decoded, uid) | |
| if roles and role not in roles: | |
| return jsonify({"ok": False, "error": "Insufficient permissions"}), 403 | |
| request.user = {"uid": uid, "email": decoded.get("email", ""), "role": role} | |
| return fn(*args, **kwargs) | |
| return wrapper | |
| return decorator | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # SHARED HELPERS | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # Supported image extensions for multimodal WhatsApp ingestion | |
| SUPPORTED_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} | |
| # Approx chars per token (conservative for mixed Shona/English/emoji content) | |
| CHARS_PER_TOKEN = 4 | |
| # Target ~10k tokens per chunk with ~1k token overlap | |
| CHUNK_CHARS = 40_000 | |
| OVERLAP_CHARS = 4_000 | |
| def _safe_json(text: str, fallback: Any) -> Any: | |
| """ | |
| Multi-strategy JSON parser. | |
| 1. Direct parse after stripping markdown fences. | |
| 2. Regex extraction of first [...] or {...} block. | |
| 3. Return fallback. | |
| """ | |
| if not text: | |
| return fallback | |
| # Strategy 1: strip fences | |
| clean = text.strip() | |
| for fence in ("```json", "```JSON", "```"): | |
| if fence in clean: | |
| parts = clean.split(fence) | |
| # take the content between the first pair of fences | |
| if len(parts) >= 3: | |
| clean = parts[1].strip() | |
| elif len(parts) == 2: | |
| clean = parts[1].split("```")[0].strip() | |
| break | |
| try: | |
| return json.loads(clean) | |
| except json.JSONDecodeError: | |
| pass | |
| # Strategy 2: regex — find outermost [...] array | |
| arr_match = re.search(r'\[[\s\S]*\]', clean) | |
| if arr_match: | |
| try: | |
| return json.loads(arr_match.group()) | |
| except json.JSONDecodeError: | |
| pass | |
| # Strategy 3: regex — find outermost {...} object | |
| obj_match = re.search(r'\{[\s\S]*\}', clean) | |
| if obj_match: | |
| try: | |
| return json.loads(obj_match.group()) | |
| except json.JSONDecodeError: | |
| pass | |
| logger.error("JSON parse exhausted all strategies. First 300 chars: %s", text[:300]) | |
| return fallback | |
| def _validate_articles(data: Any) -> List[Dict]: | |
| """ | |
| Validate that extracted articles are a list of dicts with required fields. | |
| Filters out malformed items rather than failing the whole batch. | |
| """ | |
| if not isinstance(data, list): | |
| logger.warning("Expected list from Gemini, got %s", type(data)) | |
| return [] | |
| valid = [] | |
| for item in data: | |
| if not isinstance(item, dict): | |
| continue | |
| title = str(item.get("title", "")).strip() | |
| content = str(item.get("content", "")).strip() | |
| if len(title) < 3 or len(content) < 10: | |
| continue | |
| entry = { | |
| "title": title, | |
| "content": content, | |
| "category": str(item.get("category", "General")).strip() or "General", | |
| "tags": item.get("tags", []) if isinstance(item.get("tags"), list) else [], | |
| } | |
| # Preserve tutorial timestamps when present so clip cropping can use them. | |
| # (WhatsApp/PDF sources simply don't set these.) | |
| for ts_key in ("timestamp_start", "timestamp_end"): | |
| if item.get(ts_key) is not None: | |
| entry[ts_key] = item[ts_key] | |
| valid.append(entry) | |
| return valid | |
| def _gemini_text(prompt: str, json_mode: bool = False) -> str: | |
| """Call Gemini with text-only content.""" | |
| if not _gemini_client: | |
| return "" | |
| cfg = genai_types.GenerateContentConfig( | |
| response_mime_type="application/json" | |
| ) if json_mode else None | |
| try: | |
| resp = _gemini_client.models.generate_content( | |
| model=GEMINI_MODEL, | |
| contents=prompt, | |
| config=cfg | |
| ) | |
| return resp.text or "" | |
| except Exception as e: | |
| logger.error("Gemini text call error: %s", e) | |
| return "" | |
| def _gemini_multimodal(parts: list, json_mode: bool = False) -> str: | |
| """Call Gemini with a mixed list of text strings and image Parts.""" | |
| if not _gemini_client: | |
| return "" | |
| cfg = genai_types.GenerateContentConfig( | |
| response_mime_type="application/json" | |
| ) if json_mode else None | |
| try: | |
| resp = _gemini_client.models.generate_content( | |
| model=GEMINI_MODEL, | |
| contents=parts, | |
| config=cfg | |
| ) | |
| return resp.text or "" | |
| except Exception as e: | |
| logger.error("Gemini multimodal call error: %s", e) | |
| return "" | |
| def _article_fingerprint(title: str, content: str) -> str: | |
| raw = f"{title.strip().lower()}::{content.strip().lower()[:300]}" | |
| return hashlib.sha256(raw.encode()).hexdigest()[:16] | |
| def _get_existing_fingerprints() -> set: | |
| if not db: | |
| return set() | |
| try: | |
| docs = db.collection("iris_kb_articles").select(["fingerprint"]).stream() | |
| return {d.to_dict().get("fingerprint") for d in docs if d.to_dict().get("fingerprint")} | |
| except Exception as e: | |
| logger.error("Fingerprint fetch error: %s", e) | |
| return set() | |
| def _save_kb_articles(articles: List[Dict], source_label: str) -> Dict: | |
| if not db: | |
| return {"saved": 0, "skipped": 0, "error": "Firebase unavailable"} | |
| existing = _get_existing_fingerprints() | |
| saved, skipped = 0, 0 | |
| for article in articles: | |
| title = article.get("title", "Untitled") | |
| content = article.get("content", "") | |
| fp = _article_fingerprint(title, content) | |
| if fp in existing: | |
| skipped += 1 | |
| continue | |
| doc = { | |
| "title": title, | |
| "content": content, | |
| "category": article.get("category", "General"), | |
| "tags": article.get("tags", []), | |
| "source": source_label, | |
| "fingerprint": fp, | |
| "created_at": datetime.now(timezone.utc).isoformat(), | |
| } | |
| if article.get("timestamp_start") is not None: | |
| doc["timestamp_start"] = article["timestamp_start"] | |
| doc["timestamp_end"] = article.get("timestamp_end") | |
| doc["video_url"] = article.get("video_url", "") | |
| # Multimodal pass-through: cropped tutorial clip + source image | |
| for extra_key in ("clip_url", "clip_path", "clip_start", "clip_end", "video_title", "image_url"): | |
| if article.get(extra_key) not in (None, ""): | |
| doc[extra_key] = article[extra_key] | |
| db.collection("iris_kb_articles").add(doc) | |
| existing.add(fp) | |
| saved += 1 | |
| return {"saved": saved, "skipped": skipped} | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # WHATSAPP ZIP PROCESSOR | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # Regex to match WhatsApp timestamp lines | |
| # Handles both: DD/MM/YYYY, HH:MM - Sender: message | |
| # and: DD/MM/YYYY, HH:MM am/pm - Sender: message | |
| WA_LINE_RE = re.compile( | |
| r'^\d{1,2}/\d{1,2}/\d{4},\s+\d{1,2}:\d{2}(?:\s*[ap]m)?\s+-\s+', | |
| re.IGNORECASE | |
| ) | |
| # Matches <Media omitted> or [filename.jpg] style media pointers | |
| MEDIA_POINTER_RE = re.compile( | |
| r'<Media omitted>|\[?([^\]]+\.(?:jpg|jpeg|png|webp|gif|mp4|opus|aac|m4a))\]?', | |
| re.IGNORECASE | |
| ) | |
| class WhatsAppZipProcessor: | |
| """ | |
| Handles extraction and multimodal chunking of a WhatsApp .zip export. | |
| A WhatsApp export zip typically contains: | |
| _chat.txt — the full conversation | |
| IMG-YYYYMMDD-*.jpg — attached images | |
| VID-*.mp4 — videos (we skip these, too large) | |
| PTT-*.opus — voice notes (skipped) | |
| """ | |
| def __init__(self, zip_bytes: bytes): | |
| self.zip_bytes = zip_bytes | |
| self.chat_text = "" | |
| self.media_map: Dict[str, bytes] = {} # filename -> raw bytes | |
| def extract(self) -> bool: | |
| """Extract chat text and image files from ZIP. Returns True on success.""" | |
| try: | |
| with zipfile.ZipFile(io.BytesIO(self.zip_bytes)) as zf: | |
| names = zf.namelist() | |
| logger.info("ZIP contains %d files: %s", len(names), names[:20]) | |
| # Find chat file — WhatsApp names it _chat.txt or WhatsApp Chat with *.txt | |
| chat_file = None | |
| for name in names: | |
| base = os.path.basename(name).lower() | |
| if base == "_chat.txt" or (base.endswith(".txt") and "chat" in base): | |
| chat_file = name | |
| break | |
| if not chat_file: | |
| # Fallback: any .txt file | |
| txts = [n for n in names if n.lower().endswith(".txt")] | |
| if txts: | |
| chat_file = txts[0] | |
| if not chat_file: | |
| logger.error("No chat .txt found in ZIP") | |
| return False | |
| raw = zf.read(chat_file) | |
| self.chat_text = raw.decode("utf-8", errors="replace") | |
| logger.info("Chat text extracted: %d chars from %s", len(self.chat_text), chat_file) | |
| # Extract images (skip videos and audio — too large / not useful for KB) | |
| for name in names: | |
| ext = os.path.splitext(name.lower())[1] | |
| if ext in SUPPORTED_IMAGE_EXTS: | |
| try: | |
| self.media_map[os.path.basename(name)] = zf.read(name) | |
| except Exception as e: | |
| logger.warning("Could not read media file %s: %s", name, e) | |
| logger.info("Media files extracted: %d images", len(self.media_map)) | |
| return True | |
| except zipfile.BadZipFile as e: | |
| logger.error("Bad ZIP file: %s", e) | |
| return False | |
| except Exception as e: | |
| logger.error("ZIP extraction error: %s", e) | |
| return False | |
| def _resolve_media_in_line(self, line: str) -> Optional[bytes]: | |
| """ | |
| Given a chat line, check if it references a media file we have. | |
| Returns the image bytes if found, else None. | |
| """ | |
| match = MEDIA_POINTER_RE.search(line) | |
| if not match: | |
| return None | |
| filename = match.group(1) # group 1 = explicit filename, None for <Media omitted> | |
| if filename: | |
| fname = os.path.basename(filename) | |
| if fname in self.media_map: | |
| return self.media_map[fname] | |
| # <Media omitted> — we can't recover the file since it wasn't exported | |
| return None | |
| def build_chunks(self) -> List[Dict]: | |
| """ | |
| Split chat text into overlapping chunks, each annotated with | |
| the image bytes found within that chunk. | |
| Returns list of: | |
| { "text": str, "images": [bytes, ...], "line_range": (start, end) } | |
| """ | |
| lines = self.chat_text.splitlines() | |
| chunks = [] | |
| i = 0 | |
| total = len(lines) | |
| char_count = 0 | |
| chunk_lines: List[str] = [] | |
| chunk_images: List[bytes] = [] | |
| while i < total: | |
| line = lines[i] | |
| chunk_lines.append(line) | |
| char_count += len(line) + 1 # +1 for newline | |
| # Check if this line has an image we can include | |
| img_bytes = self._resolve_media_in_line(line) | |
| if img_bytes and len(chunk_images) < 5: # cap images per chunk | |
| chunk_images.append(img_bytes) | |
| if char_count >= CHUNK_CHARS or i == total - 1: | |
| chunks.append({ | |
| "text": "\n".join(chunk_lines), | |
| "images": chunk_images[:], | |
| "line_range": (i - len(chunk_lines) + 1, i) | |
| }) | |
| logger.info( | |
| "Chunk %d: %d lines, %d chars, %d images", | |
| len(chunks), len(chunk_lines), char_count, len(chunk_images) | |
| ) | |
| # Overlap: keep last OVERLAP_CHARS worth of lines for next chunk | |
| overlap_text = 0 | |
| overlap_start = len(chunk_lines) - 1 | |
| while overlap_start > 0 and overlap_text < OVERLAP_CHARS: | |
| overlap_text += len(chunk_lines[overlap_start]) + 1 | |
| overlap_start -= 1 | |
| chunk_lines = chunk_lines[overlap_start:] | |
| chunk_images = [] | |
| char_count = sum(len(l) + 1 for l in chunk_lines) | |
| i += 1 | |
| logger.info("Total chunks: %d", len(chunks)) | |
| return chunks | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # WHATSAPP EXTRACTION PROMPT | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| WHATSAPP_EXTRACTION_PROMPT = """You are a support knowledge base curator for the Iris platform, deployed across Zimbabwe. | |
| Your task: analyse this WhatsApp support group chat segment and extract ONLY clear problem→solution pairs. | |
| CONTEXT ABOUT THIS PLATFORM: | |
| - "Iris" is an integrated POS (Point of Sale) and fiscalisation system with a mobile attendance and | |
| location-tracking module used by field sales reps and in-store tellers at retail stores. | |
| - The POS and fiscalisation layer handles sales transactions, receipt generation, and ZIMRA fiscal | |
| compliance. The mobile module handles teller clock-in/out, GPS location verification, and hours tracking. | |
| - Common POS/fiscal issues: fiscalisation failures, receipt errors, device not syncing to ZIMRA servers, | |
| Elixir (fiscal device software) login/password problems. | |
| - Common mobile attendance issues: GPS location not detected, clock-in failures, app killed by Android | |
| battery optimiser, teller passkey problems, hours recording incorrectly, store radius too small, | |
| wrong teller name shown after login, app not running in the background. | |
| - Messages mix English, Shona, and Ndebele. Understand regional vernacular (e.g. "irikudzima" = switching | |
| off, "ndakashanda" = I worked, "short yemahours" = hours shortage, "gadzirisayi" = fix it, "hupfu" = flour, | |
| "yakuda kulogwa patsva" = needs to be logged in fresh). | |
| - If screenshots show Android error dialogs (e.g. "Service killed by system", "App stopped", "Abrupt stop"), | |
| reason through what that means for Android background restriction and background service killing, and include | |
| that diagnosis and fix in the solution content. | |
| - If screenshots show fiscal device or POS screens, extract the error code or state shown and reason through | |
| the likely cause from the Elixir/ZIMRA integration context. | |
| STRICT RULES: | |
| 1. Extract ONLY exchanges where a user described a problem AND a named support person (Tendayi, Tony, Violet, | |
| Rufaro, Albrighton, Ishmael, or any named responder) provided a working solution or clear instruction. | |
| 2. Ignore: greetings, media-only messages, deleted messages, clock-in screenshots with no text context, | |
| messages from unknown numbers with no solution attached. | |
| 3. Each article must be self-contained and usable by a support agent in future. | |
| 4. Translate all Shona/Ndebele problem descriptions to English in the article content. | |
| 5. If a screenshot appears to show an Android error or GPS issue, reason through the likely cause and | |
| include that reasoning in the solution content. | |
| OUTPUT FORMAT: Return ONLY a valid JSON array. No preamble, no explanation, no markdown fences. | |
| Every string value MUST be properly JSON-escaped. Do not use unescaped newlines, tabs, or quotes inside strings. | |
| Use \\n for line breaks within content strings. | |
| Schema per item: | |
| {"title": "string (max 80 chars)", "content": "string (escaped, solution steps)", "category": "one of: Account|Technical|Location|Attendance|Device|Other", "tags": ["array", "of", "strings"]} | |
| If no valid problem→solution pairs exist in this segment, return an empty array: [] | |
| Chat segment: | |
| """ | |
| def _process_chunk_with_gemini(chunk: Dict) -> List[Dict]: | |
| """ | |
| Send a single chunk (text + optional images) to Gemini. | |
| Returns validated list of article dicts. | |
| """ | |
| text_part = WHATSAPP_EXTRACTION_PROMPT + chunk["text"] | |
| images = chunk.get("images", []) | |
| if images and _gemini_client: | |
| # Build multimodal content list | |
| parts = [text_part] | |
| for img_bytes in images: | |
| # Detect mime type from magic bytes | |
| mime = "image/jpeg" | |
| if img_bytes[:4] == b'\x89PNG': | |
| mime = "image/png" | |
| elif img_bytes[:4] == b'RIFF': | |
| mime = "image/webp" | |
| parts.append( | |
| genai_types.Part.from_bytes(data=img_bytes, mime_type=mime) | |
| ) | |
| raw = _gemini_multimodal(parts, json_mode=True) | |
| else: | |
| raw = _gemini_text(text_part, json_mode=True) | |
| if not raw: | |
| logger.warning("Empty Gemini response for chunk") | |
| return [] | |
| parsed = _safe_json(raw, []) | |
| return _validate_articles(parsed) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # FEATURE 1 — WhatsApp Export → Knowledge Base (v1.1: ZIP multimodal + chunked) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def whatsapp_import(): | |
| """ | |
| Accepts EITHER: | |
| (a) multipart file upload with field "file" containing a .zip WhatsApp export, OR | |
| (b) JSON body { "chat_text": "..." } for plain text (legacy support) | |
| Processes in sliding-window chunks, sends images to Gemini multimodally. | |
| Saves new articles only (additive, dedup by fingerprint). | |
| """ | |
| all_articles: List[Dict] = [] | |
| source_label = "whatsapp_export" | |
| # ── Branch A: ZIP upload ────────────────────────────────────────────────── | |
| if "file" in request.files: | |
| f = request.files["file"] | |
| filename = f.filename or "" | |
| if not filename.lower().endswith(".zip"): | |
| return jsonify({"ok": False, "error": "Expected a .zip WhatsApp export file"}), 400 | |
| zip_bytes = f.read() | |
| logger.info("WhatsApp ZIP upload: %d bytes, filename=%s", len(zip_bytes), filename) | |
| processor = WhatsAppZipProcessor(zip_bytes) | |
| if not processor.extract(): | |
| return jsonify({"ok": False, "error": "Could not extract chat from ZIP. Ensure it is a valid WhatsApp export."}), 400 | |
| if len(processor.chat_text) < 100: | |
| return jsonify({"ok": False, "error": "Extracted chat text too short to process"}), 400 | |
| chunks = processor.build_chunks() | |
| source_label = f"whatsapp_zip:{filename}" | |
| for idx, chunk in enumerate(chunks): | |
| logger.info("Processing chunk %d/%d", idx + 1, len(chunks)) | |
| articles = _process_chunk_with_gemini(chunk) | |
| all_articles.extend(articles) | |
| logger.info("Chunk %d yielded %d articles (running total: %d)", idx + 1, len(articles), len(all_articles)) | |
| # ── Branch B: Legacy plain text JSON body ───────────────────────────────── | |
| else: | |
| body = request.get_json(silent=True) or {} | |
| raw_chat = body.get("chat_text", "").strip() | |
| if not raw_chat: | |
| return jsonify({"ok": False, "error": "Provide a .zip file upload or chat_text in JSON body"}), 400 | |
| if len(raw_chat) < 100: | |
| return jsonify({"ok": False, "error": "Chat text too short to process"}), 400 | |
| logger.info("WhatsApp plain text import: %d chars", len(raw_chat)) | |
| # Chunk the plain text too (handles large exports) | |
| lines = raw_chat.splitlines() | |
| pseudo_zip = type("PseudoZip", (), { | |
| "chat_text": raw_chat, | |
| "media_map": {} | |
| })() | |
| processor = WhatsAppZipProcessor(b"") | |
| processor.chat_text = raw_chat | |
| processor.media_map = {} | |
| chunks = processor.build_chunks() | |
| for idx, chunk in enumerate(chunks): | |
| logger.info("Processing text chunk %d/%d", idx + 1, len(chunks)) | |
| articles = _process_chunk_with_gemini(chunk) | |
| all_articles.extend(articles) | |
| if not all_articles: | |
| logger.info("No articles extracted from this export") | |
| return jsonify({ | |
| "ok": True, | |
| "articles_found": 0, | |
| "saved": 0, | |
| "skipped_dupes": 0, | |
| "note": "No clear problem→solution pairs found in this chat segment" | |
| }) | |
| stats = _save_kb_articles(all_articles, source_label=source_label) | |
| logger.info("WhatsApp import complete: found=%d, %s", len(all_articles), stats) | |
| return jsonify({ | |
| "ok": True, | |
| "articles_found": len(all_articles), | |
| "articles": all_articles, # full list — frontend INSERTs to Supabase kb_articles | |
| "saved": stats["saved"], | |
| "skipped_dupes": stats["skipped"], | |
| }) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # FEATURE 2 — Bulk KB Upload (CSV / Excel / PDF) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def _extract_text_from_pdf_bytes(pdf_bytes: bytes) -> str: | |
| if PYPDF_AVAILABLE: | |
| try: | |
| reader = pypdf.PdfReader(io.BytesIO(pdf_bytes)) | |
| pages = [p.extract_text() or "" for p in reader.pages] | |
| text = "\n\n".join(pages).strip() | |
| if text: | |
| return text | |
| except Exception as e: | |
| logger.warning("pypdf extraction failed: %s", e) | |
| if _gemini_client: | |
| try: | |
| resp = _gemini_client.models.generate_content( | |
| model=GEMINI_MODEL, | |
| contents=[ | |
| "Extract all text from this PDF document. Return plain text only.", | |
| genai_types.Part.from_bytes(data=pdf_bytes, mime_type="application/pdf") | |
| ] | |
| ) | |
| return resp.text or "" | |
| except Exception as e: | |
| logger.error("Gemini PDF extraction failed: %s", e) | |
| return "" | |
| PDF_KB_PROMPT = """You are a support knowledge base curator. | |
| Convert the following document content into structured KB articles. | |
| Each article covers one distinct topic, issue, or procedure. | |
| Return ONLY a valid JSON array — no preamble, no markdown fences. | |
| All string values must be properly JSON-escaped (no raw newlines inside strings, use \\n). | |
| Schema per item: | |
| {"title": "string", "content": "string", "category": "one of: Account|Billing|Technical|Feature|Other", "tags": ["string"]} | |
| Document content: | |
| """ | |
| def bulk_upload(): | |
| if "file" not in request.files: | |
| return jsonify({"ok": False, "error": "No file uploaded"}), 400 | |
| f = request.files["file"] | |
| filename = f.filename or "" | |
| ext = filename.rsplit(".", 1)[-1].lower() | |
| file_data = f.read() | |
| articles = [] | |
| if ext in ("csv", "xlsx", "xls"): | |
| if not PANDAS_AVAILABLE: | |
| return jsonify({"ok": False, "error": "pandas not installed on server"}), 500 | |
| try: | |
| df = pd.read_csv(io.BytesIO(file_data)) if ext == "csv" else pd.read_excel(io.BytesIO(file_data)) | |
| df.columns = [c.strip().lower() for c in df.columns] | |
| if "title" not in df.columns or "content" not in df.columns: | |
| return jsonify({"ok": False, "error": "CSV/Excel must have 'title' and 'content' columns"}), 400 | |
| for _, row in df.iterrows(): | |
| tags = [] | |
| if "tags" in df.columns and pd.notna(row.get("tags")): | |
| tags = [t.strip() for t in re.split(r"[,;|]", str(row["tags"])) if t.strip()] | |
| articles.append({ | |
| "title": str(row["title"]).strip(), | |
| "content": str(row["content"]).strip(), | |
| "category": str(row.get("category", "General")).strip() if pd.notna(row.get("category")) else "General", | |
| "tags": tags, | |
| }) | |
| except Exception as e: | |
| return jsonify({"ok": False, "error": f"Could not parse file: {e}"}), 400 | |
| elif ext == "pdf": | |
| text = _extract_text_from_pdf_bytes(file_data) | |
| if not text: | |
| return jsonify({"ok": False, "error": "Could not extract text from PDF"}), 400 | |
| raw = _gemini_text(PDF_KB_PROMPT + text[:50000], json_mode=True) | |
| parsed = _safe_json(raw, []) | |
| articles = _validate_articles(parsed) | |
| if not articles: | |
| return jsonify({"ok": False, "error": "Gemini PDF structuring returned no valid articles"}), 500 | |
| else: | |
| return jsonify({"ok": False, "error": f"Unsupported file type .{ext}. Use csv, xlsx, or pdf"}), 400 | |
| if not articles: | |
| return jsonify({"ok": False, "error": "No articles extracted from file"}), 400 | |
| stats = _save_kb_articles(articles, source_label=f"bulk_upload:{filename}") | |
| return jsonify({"ok": True, "articles_found": len(articles), "articles": articles, # full list — frontend INSERTs to Supabase kb_articles | |
| "saved": stats["saved"], "skipped_dupes": stats["skipped"]}) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # FEATURE 3 — Ticket Submission via NL Text or Voice | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| TICKET_EXTRACTION_PROMPT = """You are a support ticket intake system for a software support portal. | |
| A user has described their issue in natural language. Extract structured ticket fields. | |
| Return ONLY a valid JSON object — no preamble, no markdown fences. | |
| All string values must be properly JSON-escaped. | |
| Schema: | |
| {"title": "string (max 80 chars)", "description": "string (full clear description)", "category_hint": "one of: Account|Billing|Technical|Feature|Other", "priority_hint": "one of: low|medium|high|critical", "keywords": ["string"]} | |
| User message: | |
| """ | |
| def _transcribe_audio_assemblyai(audio_b64: str, audio_format: str = "wav") -> str: | |
| if not ASSEMBLYAI_API_KEY: | |
| return "" | |
| audio_bytes = base64.b64decode(audio_b64) | |
| headers = {"authorization": ASSEMBLYAI_API_KEY} | |
| try: | |
| upload_resp = requests.post( | |
| f"{ASSEMBLYAI_BASE}/upload", | |
| headers={**headers, "Content-Type": "application/octet-stream"}, | |
| data=audio_bytes, timeout=30 | |
| ) | |
| upload_resp.raise_for_status() | |
| upload_url = upload_resp.json().get("upload_url") | |
| except Exception as e: | |
| logger.error("AssemblyAI upload error: %s", e) | |
| return "" | |
| try: | |
| tx_resp = requests.post( | |
| f"{ASSEMBLYAI_BASE}/transcript", | |
| headers={**headers, "Content-Type": "application/json"}, | |
| json={"audio_url": upload_url, "language_detection": True}, timeout=15 | |
| ) | |
| tx_resp.raise_for_status() | |
| tx_id = tx_resp.json().get("id") | |
| except Exception as e: | |
| logger.error("AssemblyAI transcript request error: %s", e) | |
| return "" | |
| for _ in range(30): | |
| time.sleep(3) | |
| try: | |
| poll = requests.get(f"{ASSEMBLYAI_BASE}/transcript/{tx_id}", headers=headers, timeout=15) | |
| poll.raise_for_status() | |
| result = poll.json() | |
| status = result.get("status") | |
| if status == "completed": | |
| return result.get("text", "") | |
| elif status == "error": | |
| logger.error("AssemblyAI error: %s", result.get("error")) | |
| return "" | |
| except Exception as e: | |
| logger.error("AssemblyAI poll error: %s", e) | |
| return "" | |
| def _transcribe_audio_bytes(audio_bytes: bytes) -> str: | |
| """Transcribe raw audio bytes (e.g. a downloaded WhatsApp voice note).""" | |
| if not audio_bytes: | |
| return "" | |
| return _transcribe_audio_assemblyai(base64.b64encode(audio_bytes).decode(), "ogg") | |
| def submit_ticket_nl(): | |
| body = request.get_json(silent=True) or {} | |
| message = body.get("message", "").strip() | |
| user_id = body.get("user_id", "anonymous") | |
| if not message: | |
| return jsonify({"ok": False, "error": "message is required"}), 400 | |
| raw = _gemini_text(TICKET_EXTRACTION_PROMPT + message, json_mode=True) | |
| ticket = _safe_json(raw, {}) | |
| if not isinstance(ticket, dict) or not ticket.get("title"): | |
| return jsonify({"ok": False, "error": "Could not extract ticket info from message"}), 500 | |
| if db: | |
| db.collection("iris_ai_ticket_drafts").add({ | |
| "user_id": user_id, "raw_input": message, | |
| "extracted": ticket, "channel": "nl_text", | |
| "created_at": datetime.now(timezone.utc).isoformat(), | |
| }) | |
| return jsonify({"ok": True, "ticket": ticket}) | |
| def submit_ticket_voice(): | |
| body = request.get_json(silent=True) or {} | |
| audio_b64 = body.get("audio_b64", "") | |
| audio_format = body.get("audio_format", "wav") | |
| user_id = body.get("user_id", "anonymous") | |
| if not audio_b64: | |
| return jsonify({"ok": False, "error": "audio_b64 is required"}), 400 | |
| if not ASSEMBLYAI_API_KEY: | |
| return jsonify({"ok": False, "error": "AssemblyAI not configured on server"}), 500 | |
| transcript = _transcribe_audio_assemblyai(audio_b64, audio_format) | |
| if not transcript: | |
| return jsonify({"ok": False, "error": "Transcription failed or returned empty result"}), 500 | |
| raw = _gemini_text(TICKET_EXTRACTION_PROMPT + transcript, json_mode=True) | |
| ticket = _safe_json(raw, {}) | |
| if not isinstance(ticket, dict) or not ticket.get("title"): | |
| return jsonify({"ok": False, "error": "Could not extract ticket info from transcript"}), 500 | |
| if db: | |
| db.collection("iris_ai_ticket_drafts").add({ | |
| "user_id": user_id, "raw_input": transcript, | |
| "extracted": ticket, "channel": "voice", | |
| "created_at": datetime.now(timezone.utc).isoformat(), | |
| }) | |
| return jsonify({"ok": True, "transcript": transcript, "ticket": ticket}) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # FEATURE 4 — System Tutorial Ingestion | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| TUTORIAL_VIDEO_PROMPT = """You are a knowledge base curator watching a tutorial video about the Iris platform. | |
| CONTEXT ABOUT IRIS: | |
| - Iris is an integrated POS (Point of Sale) and fiscalisation system with a mobile attendance and | |
| location-tracking module used by tellers and field reps at retail stores in Zimbabwe. | |
| - The POS/fiscal layer handles sales, receipts, and ZIMRA fiscal compliance (Elixir device). | |
| - The mobile module handles teller clock-in/out, GPS location, store radius, and hours tracking. | |
| - The Iris Support Portal is a customer support desk used by admin staff, agents, and support tiers | |
| to manage tickets, agents, customers, and the knowledge base. | |
| YOUR TASK: | |
| Watch this tutorial video in full. For every distinct feature, workflow, or task you observe being | |
| demonstrated, extract one self-contained KB article. Identify the exact timestamp range in the video | |
| where each demonstration occurs so users can jump directly to the relevant moment. | |
| Be precise about timestamps — state the second at which the demonstration starts and ends. | |
| Write step-by-step instructions based on what you see happening on screen, not generic descriptions. | |
| If the presenter speaks, incorporate their narration into the steps. | |
| Return ONLY a valid JSON array. No preamble, no markdown fences. All strings properly JSON-escaped. | |
| Use \n for line breaks within content strings. | |
| Schema per item: | |
| { | |
| "title": "string — concise how-to title, max 80 chars", | |
| "content": "string — numbered step-by-step instructions based on what is shown", | |
| "category": "one of: Account|Tickets|Agents|Reports|Admin|POS|Attendance|Other", | |
| "tags": ["string"], | |
| "timestamp_start": <integer — seconds from video start where this demo begins>, | |
| "timestamp_end": <integer — seconds from video start where this demo ends> | |
| } | |
| If the video contains no discernible how-to demonstrations, return an empty array: [] | |
| """ | |
| def _upload_video_to_gemini(video_bytes: bytes, mime_type: str, display_name: str) -> Optional[Any]: | |
| """ | |
| Upload a video to the Gemini Files API and poll until processing is ACTIVE. | |
| Returns the uploaded file object (with .uri and .name) or None on failure. | |
| Gemini Files API processes video at 1 FPS, adding timestamps every second. | |
| Files are retained for 48 hours. We delete after use to be tidy. | |
| """ | |
| if not _gemini_client: | |
| return None | |
| try: | |
| # Write bytes to a named temp file — Files API needs a file path or IO object | |
| with tempfile.NamedTemporaryFile(suffix=f".{mime_type.split('/')[-1]}", delete=False) as tmp: | |
| tmp.write(video_bytes) | |
| tmp_path = tmp.name | |
| logger.info("[gemini-files] uploading %s (%.2f MB)...", display_name, len(video_bytes) / 1024 / 1024) | |
| t_up = time.time() | |
| uploaded = _gemini_client.files.upload( | |
| file=tmp_path, | |
| config={"mime_type": mime_type, "display_name": display_name} | |
| ) | |
| os.unlink(tmp_path) | |
| logger.info("[gemini-files] upload complete in %.1fs (%s) — polling for ACTIVE...", | |
| time.time() - t_up, uploaded.name) | |
| except Exception as e: | |
| logger.error("[gemini-files] upload error: %s", e) | |
| return None | |
| # Poll until state is ACTIVE (video processing complete) — max ~3 minutes | |
| for attempt in range(36): | |
| time.sleep(5) | |
| try: | |
| file_info = _gemini_client.files.get(name=uploaded.name) | |
| state = getattr(file_info, "state", None) | |
| state_str = str(state).upper() if state else "" | |
| logger.info("Poll %d: file state = %s", attempt + 1, state_str) | |
| if "ACTIVE" in state_str: | |
| logger.info("Video ACTIVE after %d polls (~%ds)", attempt + 1, (attempt + 1) * 5) | |
| return file_info | |
| elif "FAILED" in state_str: | |
| logger.error("Gemini Files API processing failed for %s", uploaded.name) | |
| return None | |
| except Exception as e: | |
| logger.warning("Poll error: %s", e) | |
| logger.error("Video did not reach ACTIVE state within timeout") | |
| return None | |
| def _delete_gemini_file(file_obj: Any) -> None: | |
| """Best-effort cleanup of a file from the Gemini Files API.""" | |
| try: | |
| _gemini_client.files.delete(name=file_obj.name) | |
| logger.info("Deleted Gemini file: %s", file_obj.name) | |
| except Exception as e: | |
| logger.warning("Could not delete Gemini file %s: %s", file_obj.name, e) | |
| # Supported video MIME types for tutorial upload | |
| SUPPORTED_VIDEO_MIMES = { | |
| ".mp4": "video/mp4", | |
| ".mov": "video/quicktime", | |
| ".avi": "video/x-msvideo", | |
| ".webm": "video/webm", | |
| ".mkv": "video/x-matroska", | |
| ".3gp": "video/3gpp", | |
| ".flv": "video/x-flv", | |
| } | |
| def tutorial_ingest(): | |
| """ | |
| Accepts a tutorial video file upload (multipart, field name "file"). | |
| Gemini watches the full video, self-generates timestamps, and extracts | |
| one KB article per distinct feature or task demonstrated. | |
| No transcript required — Gemini reasons directly from video + audio. | |
| Supported: mp4, mov, avi, webm, mkv, 3gp, flv | |
| Max practical size: ~500MB (Files API limit is 2GB, but HF Space upload limit applies) | |
| Returns articles with timestamp_start/end in seconds so the frontend | |
| can generate deep-links into the video. | |
| """ | |
| if "file" not in request.files: | |
| return jsonify({"ok": False, "error": "No file uploaded. Use multipart field name 'file'."}), 400 | |
| f = request.files["file"] | |
| filename = f.filename or "tutorial" | |
| ext = os.path.splitext(filename.lower())[1] | |
| video_title = request.form.get("video_title", filename) | |
| video_url = request.form.get("video_url", "") | |
| mime_type = SUPPORTED_VIDEO_MIMES.get(ext) | |
| if not mime_type: | |
| return jsonify({ | |
| "ok": False, | |
| "error": f"Unsupported video format '{ext}'. Supported: {', '.join(SUPPORTED_VIDEO_MIMES)}" | |
| }), 400 | |
| if not _gemini_client: | |
| return jsonify({"ok": False, "error": "Gemini client not initialised — check GOOGLE_API_KEY"}), 500 | |
| t_ingest = time.time() | |
| video_bytes = f.read() | |
| logger.info("[tutorial] STEP 1/5: received '%s' (%.2f MB, mime=%s)", | |
| video_title, len(video_bytes) / 1024 / 1024, mime_type) | |
| # Upload to Gemini Files API and wait for processing | |
| logger.info("[tutorial] STEP 2/5: uploading to Gemini Files API + waiting for ACTIVE...") | |
| gemini_file = _upload_video_to_gemini(video_bytes, mime_type, display_name=video_title) | |
| if not gemini_file: | |
| logger.error("[tutorial] STEP 2/5 FAILED: upload/processing did not reach ACTIVE") | |
| return jsonify({"ok": False, "error": "Video upload or processing by Gemini failed. Try a smaller file or check the format."}), 500 | |
| # Ask Gemini to watch and extract articles with self-generated timestamps. | |
| # A hard timeout prevents the request from hanging the whole ingest indefinitely. | |
| try: | |
| logger.info("[tutorial] STEP 3/5: sending video to Gemini for extraction " | |
| "(model=%s, timeout=%ds)...", GEMINI_MODEL, GEMINI_VIDEO_TIMEOUT_MS // 1000) | |
| t_extract = time.time() | |
| resp = _gemini_client.models.generate_content( | |
| model=GEMINI_MODEL, | |
| contents=[gemini_file, TUTORIAL_VIDEO_PROMPT], | |
| config=genai_types.GenerateContentConfig( | |
| response_mime_type="application/json", | |
| http_options=genai_types.HttpOptions(timeout=GEMINI_VIDEO_TIMEOUT_MS), | |
| ) | |
| ) | |
| raw = resp.text or "" | |
| logger.info("[tutorial] STEP 3/5 done: Gemini responded in %.1fs (%d chars)", | |
| time.time() - t_extract, len(raw)) | |
| except Exception as e: | |
| logger.error("[tutorial] Gemini video analysis FAILED after %.1fs: %s", | |
| time.time() - t_extract, e) | |
| _delete_gemini_file(gemini_file) | |
| return jsonify({"ok": False, "error": f"Gemini analysis failed: {e}"}), 500 | |
| finally: | |
| # Always attempt cleanup — files expire in 48h anyway but clean up early | |
| _delete_gemini_file(gemini_file) | |
| parsed = _safe_json(raw, []) | |
| articles = _validate_articles(parsed) if isinstance(parsed, list) else [] | |
| logger.info("[tutorial] STEP 4/5: parsed %d valid article(s) from Gemini response", len(articles)) | |
| if not articles: | |
| logger.warning("[tutorial] No articles extracted — aborting.") | |
| return jsonify({ | |
| "ok": False, | |
| "error": "Gemini could not extract any how-to articles from this video. " | |
| "Ensure the video contains on-screen demonstrations of Iris features." | |
| }), 500 | |
| # Attach video metadata and normalise timestamp types | |
| for a in articles: | |
| a["video_url"] = video_url | |
| a["video_title"] = video_title | |
| for ts_key in ("timestamp_start", "timestamp_end"): | |
| val = a.get(ts_key) | |
| if not isinstance(val, int): | |
| try: | |
| a[ts_key] = int(val) if val is not None else 0 | |
| except (TypeError, ValueError): | |
| a[ts_key] = 0 | |
| logger.info("[tutorial] article timestamps (start–end s): %s", | |
| [(a.get("timestamp_start"), a.get("timestamp_end")) for a in articles]) | |
| # ── THE INNOVATION: pre-crop each demonstrated segment into a standalone clip ── | |
| # We write the original once to a temp file, then ffmpeg-cut each article's | |
| # [timestamp_start, timestamp_end] window, upload the clip to Firebase Storage, | |
| # and stamp clip_url on the article. The WhatsApp bot later sends this exact clip. | |
| bucket_ready = bool(get_bucket()) | |
| clips_made = 0 | |
| if bucket_ready: | |
| logger.info("[tutorial] STEP 5/5: generating clips for %d article(s)...", len(articles)) | |
| t_clips = time.time() | |
| src_path = None | |
| try: | |
| with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as src_tmp: | |
| src_tmp.write(video_bytes) | |
| src_path = src_tmp.name | |
| # Probe is logged for diagnostics only — container metadata is sometimes | |
| # wrong (esp. WhatsApp/edited MP4s), so we do NOT use it to skip segments. | |
| # We trust Gemini's timestamps (it watched the real file) and let ffmpeg | |
| # copy fail gracefully if a segment is genuinely past EOF. | |
| logger.info("[tutorial] wrote source video to temp: %s (reported duration %.1fs, codec %s)", | |
| src_path, _video_duration(src_path), _video_codec(src_path)) | |
| for idx, a in enumerate(articles, 1): | |
| start = a.get("timestamp_start", 0) | |
| end = a.get("timestamp_end", 0) | |
| if not end or end <= start: | |
| logger.info("[tutorial] clip %d/%d skipped — no valid timestamps (%s-%s)", | |
| idx, len(articles), start, end) | |
| continue | |
| out_path = os.path.join(tempfile.gettempdir(), f"clip_{uuid.uuid4().hex}.mp4") | |
| clip_path = f"kb_clips/{uuid.uuid4().hex}.mp4" | |
| logger.info("[tutorial] clip %d/%d: cropping %ss–%ss...", idx, len(articles), start, end) | |
| if _crop_video_segment(src_path, start, end, out_path): | |
| try: | |
| clip_bytes = os.path.getsize(out_path) | |
| logger.info("[tutorial] clip %d/%d: uploading %d bytes to %s...", | |
| idx, len(articles), clip_bytes, clip_path) | |
| t_up = time.time() | |
| with open(out_path, "rb") as cf: | |
| data = cf.read() | |
| clip_url = _upload_to_storage(data, clip_path, "video/mp4") | |
| if clip_url: | |
| # Store both the best-effort URL and the object path. The path | |
| # lets us mint a fresh signed URL at send-time (uniform-access safe). | |
| a["clip_url"] = clip_url | |
| a["clip_path"] = clip_path | |
| a["clip_start"] = start | |
| a["clip_end"] = end | |
| clips_made += 1 | |
| logger.info("[tutorial] clip %d/%d: uploaded in %.1fs", | |
| idx, len(articles), time.time() - t_up) | |
| else: | |
| logger.warning("[tutorial] clip %d/%d: upload returned no URL", idx, len(articles)) | |
| finally: | |
| try: | |
| os.remove(out_path) | |
| except Exception: | |
| pass | |
| else: | |
| logger.warning("[tutorial] clip %d/%d: ffmpeg crop failed", idx, len(articles)) | |
| logger.info("[tutorial] STEP 5/5 done: %d/%d clips made in %.1fs", | |
| clips_made, len(articles), time.time() - t_clips) | |
| except Exception as e: | |
| logger.error("[tutorial] Clip generation error: %s", e) | |
| finally: | |
| if src_path: | |
| try: | |
| os.remove(src_path) | |
| except Exception: | |
| pass | |
| else: | |
| logger.warning("[tutorial] Storage bucket not configured — skipping clip generation.") | |
| logger.info("[tutorial] saving %d article(s) to Firestore...", len(articles)) | |
| stats = _save_kb_articles(articles, source_label=f"tutorial:{video_title}") | |
| logger.info("[tutorial] COMPLETE: %d articles, %d clips, saved=%d, skipped=%d (total %.1fs)", | |
| len(articles), clips_made, stats["saved"], stats["skipped"], time.time() - t_ingest) | |
| return jsonify({ | |
| "ok": True, | |
| "video_title": video_title, | |
| "articles_found": len(articles), | |
| "articles": articles, # full list — frontend INSERTs to Supabase kb_articles | |
| "saved": stats["saved"], | |
| "skipped_dupes": stats["skipped"], | |
| }) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # FEATURE 5 — Agent Solution Writing (NL Text + Voice) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| SOLUTION_EXTRACTION_PROMPT = """You are a support knowledge base curator. | |
| An agent has described a solution they used to resolve a ticket. | |
| Structure this into a reusable KB article. | |
| Return ONLY a valid JSON object — no preamble, no markdown fences. | |
| All strings must be properly JSON-escaped. | |
| Schema: | |
| {"title": "string", "content": "string (clear step-by-step solution)", "category": "one of: Account|Billing|Technical|Feature|Other", "tags": ["string"]} | |
| Agent description: | |
| """ | |
| def agent_solution_nl(): | |
| body = request.get_json(silent=True) or {} | |
| message = body.get("message", "").strip() | |
| agent_id = body.get("agent_id", "unknown") | |
| ticket_id = body.get("ticket_id", "") | |
| if not message: | |
| return jsonify({"ok": False, "error": "message is required"}), 400 | |
| raw = _gemini_text(SOLUTION_EXTRACTION_PROMPT + message, json_mode=True) | |
| article = _safe_json(raw, {}) | |
| if not isinstance(article, dict) or not article.get("title"): | |
| return jsonify({"ok": False, "error": "Could not structure solution"}), 500 | |
| if ticket_id: | |
| article.setdefault("tags", []).append(f"ticket:{ticket_id}") | |
| stats = _save_kb_articles([article], source_label=f"agent:{agent_id}") | |
| return jsonify({"ok": True, "saved": stats["saved"], | |
| "article": article, # single article — frontend INSERTs to Supabase kb_articles | |
| "articles": [article]}) | |
| def agent_solution_voice(): | |
| body = request.get_json(silent=True) or {} | |
| audio_b64 = body.get("audio_b64", "") | |
| audio_format = body.get("audio_format", "wav") | |
| agent_id = body.get("agent_id", "unknown") | |
| ticket_id = body.get("ticket_id", "") | |
| if not audio_b64: | |
| return jsonify({"ok": False, "error": "audio_b64 is required"}), 400 | |
| transcript = _transcribe_audio_assemblyai(audio_b64, audio_format) | |
| if not transcript: | |
| return jsonify({"ok": False, "error": "Transcription failed"}), 500 | |
| raw = _gemini_text(SOLUTION_EXTRACTION_PROMPT + transcript, json_mode=True) | |
| article = _safe_json(raw, {}) | |
| if not isinstance(article, dict) or not article.get("title"): | |
| return jsonify({"ok": False, "error": "Could not structure solution from transcript"}), 500 | |
| if ticket_id: | |
| article.setdefault("tags", []).append(f"ticket:{ticket_id}") | |
| stats = _save_kb_articles([article], source_label=f"agent:{agent_id}") | |
| return jsonify({"ok": True, "transcript": transcript, "saved": stats["saved"], | |
| "article": article, # single article — frontend INSERTs to Supabase kb_articles | |
| "articles": [article]}) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # FEATURE 6 — Iris Chatbot (RAG over KB + Tutorials) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def _search_kb(query: str, limit: int = 5) -> List[Dict]: | |
| if not db: | |
| return [] | |
| query_terms = [t.lower() for t in query.split() if len(t) > 2] | |
| try: | |
| docs = db.collection("iris_kb_articles").order_by( | |
| "created_at", direction=firestore.Query.DESCENDING | |
| ).limit(200).stream() | |
| results = [] | |
| for doc in docs: | |
| d = doc.to_dict() | |
| text = f"{d.get('title','')} {d.get('content','')} {' '.join(d.get('tags',[]))}".lower() | |
| score = sum(1 for term in query_terms if term in text) | |
| if score > 0: | |
| results.append({"score": score, **d}) | |
| results.sort(key=lambda x: x["score"], reverse=True) | |
| return results[:limit] | |
| except Exception as e: | |
| logger.error("KB search error: %s", e) | |
| return [] | |
| CHATBOT_SYSTEM_PROMPT = """You are Iris, an intelligent support assistant for the Iris Support Portal. | |
| Answer ONLY from the provided knowledge base context. | |
| If the answer is in a tutorial with a timestamp, mention the video and timestamp. | |
| Be concise, clear, and friendly. Format step-by-step answers as numbered lists. | |
| If you cannot find the answer, say so honestly and suggest submitting a ticket. | |
| """ | |
| def chatbot_query(): | |
| body = request.get_json(silent=True) or {} | |
| message = body.get("message", "").strip() | |
| session_id = body.get("session_id", "default") | |
| user_id = body.get("user_id", "anonymous") | |
| if not message: | |
| return jsonify({"ok": False, "error": "message is required"}), 400 | |
| kb_results = _search_kb(message, limit=5) | |
| context_blocks = [] | |
| sources = [] | |
| for r in kb_results: | |
| block = f"[Article: {r.get('title')}]\n{r.get('content', '')}" | |
| if r.get("timestamp_start") is not None: | |
| ts = r["timestamp_start"] | |
| block += f"\n(Tutorial: {r.get('video_title','Video')} at {ts//60:02d}:{ts%60:02d}" | |
| if r.get("video_url"): | |
| block += f" — {r['video_url']}" | |
| block += ")" | |
| context_blocks.append(block) | |
| sources.append({ | |
| "title": r.get("title"), | |
| "category": r.get("category"), | |
| "source": r.get("source"), | |
| "ts_start": r.get("timestamp_start"), | |
| "video_url": r.get("video_url"), | |
| }) | |
| context_str = "\n\n---\n\n".join(context_blocks) if context_blocks else "No relevant articles found." | |
| full_prompt = f"{CHATBOT_SYSTEM_PROMPT}\n\nKNOWLEDGE BASE CONTEXT:\n{context_str}\n\nUSER QUESTION: {message}\n\nAnswer:" | |
| answer = _gemini_text(full_prompt) | |
| if not answer: | |
| answer = "Sorry, I could not process your question right now. Please try again or submit a support ticket." | |
| if db: | |
| db.collection("iris_chatbot_logs").add({ | |
| "user_id": user_id, "session_id": session_id, | |
| "message": message, "answer": answer, "sources": sources, | |
| "created_at": datetime.now(timezone.utc).isoformat(), | |
| }) | |
| return jsonify({"ok": True, "answer": answer, "sources": sources}) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # KB CRUD ENDPOINTS — list / get / create / update / delete | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def create_kb_article(): | |
| """Manually create a KB article (no AI) — full-control authoring from the dashboard.""" | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| body = request.get_json(silent=True) or {} | |
| title = (body.get("title") or "").strip() | |
| content = (body.get("content") or "").strip() | |
| if len(title) < 3 or len(content) < 3: | |
| return jsonify({"ok": False, "error": "title and content are required"}), 400 | |
| tags = body.get("tags", []) | |
| if isinstance(tags, str): | |
| tags = [t.strip() for t in re.split(r"[,;|]", tags) if t.strip()] | |
| doc = { | |
| "title": title, | |
| "content": content, | |
| "category": (body.get("category") or "General").strip(), | |
| "tags": tags if isinstance(tags, list) else [], | |
| "source": f"manual:{request.user.get('email','')}", | |
| "fingerprint": _article_fingerprint(title, content), | |
| "created_at": _now_iso(), | |
| } | |
| ref = db.collection("iris_kb_articles").add(doc) | |
| return jsonify({"ok": True, "id": ref[1].id, "article": {"id": ref[1].id, **doc}}) | |
| def get_kb_article(article_id: str): | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| snap = db.collection("iris_kb_articles").document(article_id).get() | |
| if not snap.exists: | |
| return jsonify({"ok": False, "error": "Article not found"}), 404 | |
| return jsonify({"ok": True, "article": {"id": snap.id, **snap.to_dict()}}) | |
| def update_kb_article(article_id: str): | |
| """Edit an existing article — title, content, category, tags (for revisions/updates).""" | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| body = request.get_json(silent=True) or {} | |
| updates = {} | |
| for field in ("title", "content", "category"): | |
| if field in body and isinstance(body[field], str) and body[field].strip(): | |
| updates[field] = body[field].strip() | |
| if "tags" in body: | |
| tags = body["tags"] | |
| if isinstance(tags, str): | |
| tags = [t.strip() for t in re.split(r"[,;|]", tags) if t.strip()] | |
| updates["tags"] = tags if isinstance(tags, list) else [] | |
| if not updates: | |
| return jsonify({"ok": False, "error": "No valid fields to update"}), 400 | |
| # Keep the dedup fingerprint consistent when title/content change. | |
| if "title" in updates or "content" in updates: | |
| snap = db.collection("iris_kb_articles").document(article_id).get() | |
| cur = snap.to_dict() if snap.exists else {} | |
| updates["fingerprint"] = _article_fingerprint( | |
| updates.get("title", cur.get("title", "")), | |
| updates.get("content", cur.get("content", "")), | |
| ) | |
| updates["updated_at"] = _now_iso() | |
| db.collection("iris_kb_articles").document(article_id).update(updates) | |
| return jsonify({"ok": True, "updated": list(updates.keys())}) | |
| def list_kb_articles(): | |
| category = request.args.get("category", "") | |
| limit = int(request.args.get("limit", 50)) | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| try: | |
| query = db.collection("iris_kb_articles").order_by("created_at", direction=firestore.Query.DESCENDING) | |
| if category: | |
| query = query.where("category", "==", category) | |
| docs = query.limit(limit).stream() | |
| articles = [{"id": d.id, **d.to_dict()} for d in docs] | |
| return jsonify({"ok": True, "articles": articles, "count": len(articles)}) | |
| except Exception as e: | |
| return jsonify({"ok": False, "error": str(e)}), 500 | |
| def delete_kb_article(article_id: str): | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| try: | |
| db.collection("iris_kb_articles").document(article_id).delete() | |
| return jsonify({"ok": True}) | |
| except Exception as e: | |
| return jsonify({"ok": False, "error": str(e)}), 500 | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # ADMIN — Staff account management (admin provisions accounts, no public signup) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def create_staff(): | |
| body = request.get_json(silent=True) or {} | |
| email = body.get("email", "").strip().lower() | |
| password = body.get("password", "") | |
| name = body.get("name", "").strip() | |
| role = body.get("role", "support").strip() | |
| if not email or not password: | |
| return jsonify({"ok": False, "error": "email and password are required"}), 400 | |
| if role not in ("admin", "support"): | |
| return jsonify({"ok": False, "error": "role must be 'admin' or 'support'"}), 400 | |
| try: | |
| user = fb_auth.create_user(email=email, password=password, display_name=name or None) | |
| fb_auth.set_custom_user_claims(user.uid, {"role": role}) | |
| if db: | |
| db.collection("iris_staff").document(user.uid).set({ | |
| "uid": user.uid, "email": email, "name": name, "role": role, | |
| "disabled": False, "created_at": datetime.now(timezone.utc).isoformat(), | |
| "created_by": request.user.get("email", ""), | |
| }) | |
| return jsonify({"ok": True, "uid": user.uid, "email": email, "role": role}) | |
| except Exception as e: | |
| logger.error("create_staff error: %s", e) | |
| return jsonify({"ok": False, "error": str(e)}), 400 | |
| def list_staff(): | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| try: | |
| docs = db.collection("iris_staff").stream() | |
| staff = [{"id": d.id, **d.to_dict()} for d in docs] | |
| return jsonify({"ok": True, "staff": staff, "count": len(staff)}) | |
| except Exception as e: | |
| return jsonify({"ok": False, "error": str(e)}), 500 | |
| def disable_staff(uid: str): | |
| try: | |
| fb_auth.update_user(uid, disabled=True) | |
| if db: | |
| db.collection("iris_staff").document(uid).set({"disabled": True}, merge=True) | |
| return jsonify({"ok": True}) | |
| except Exception as e: | |
| logger.error("disable_staff error: %s", e) | |
| return jsonify({"ok": False, "error": str(e)}), 400 | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # MULTIMODAL KB INGEST — Image + Audio (dashboard, auth-protected) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| IMAGE_KB_PROMPT = """You are a knowledge base curator for the Iris support platform. | |
| You are given a screenshot or photo (often an error screen, POS display, or app state) plus | |
| an optional caption. Extract ONE self-contained how-to / troubleshooting KB article that explains | |
| the situation shown and how a user resolves it. | |
| Return ONLY a valid JSON object — no preamble, no markdown fences. All strings JSON-escaped. | |
| Schema: | |
| {"title": "string (max 80 chars)", "content": "string (clear step-by-step explanation/solution)", "category": "one of: Account|Technical|Location|Attendance|Device|POS|Other", "tags": ["string"]} | |
| Caption: """ | |
| def image_ingest(): | |
| if "file" not in request.files: | |
| return jsonify({"ok": False, "error": "No file uploaded. Use multipart field 'file'."}), 400 | |
| if not _gemini_client: | |
| return jsonify({"ok": False, "error": "Gemini client not initialised"}), 500 | |
| f = request.files["file"] | |
| caption = request.form.get("caption", "") | |
| img_bytes = f.read() | |
| mime = f.mimetype or "image/jpeg" | |
| try: | |
| part = genai_types.Part.from_bytes(data=img_bytes, mime_type=mime) | |
| raw = _gemini_multimodal([IMAGE_KB_PROMPT + caption, part], json_mode=True) | |
| except Exception as e: | |
| logger.error("image_ingest vision error: %s", e) | |
| return jsonify({"ok": False, "error": f"Vision analysis failed: {e}"}), 500 | |
| article = _safe_json(raw, {}) | |
| if not isinstance(article, dict) or not article.get("title"): | |
| return jsonify({"ok": False, "error": "Could not extract an article from this image"}), 500 | |
| image_url = _upload_to_storage(img_bytes, f"kb_images/{uuid.uuid4().hex}", mime) | |
| if image_url: | |
| article["image_url"] = image_url | |
| stats = _save_kb_articles([article], source_label="image_upload") | |
| return jsonify({"ok": True, "saved": stats["saved"], "article": article, "articles": [article]}) | |
| def audio_ingest(): | |
| if "file" not in request.files: | |
| return jsonify({"ok": False, "error": "No file uploaded. Use multipart field 'file'."}), 400 | |
| if not ASSEMBLYAI_API_KEY: | |
| return jsonify({"ok": False, "error": "AssemblyAI not configured on server"}), 500 | |
| f = request.files["file"] | |
| audio_bytes = f.read() | |
| transcript = _transcribe_audio_bytes(audio_bytes) | |
| if not transcript: | |
| return jsonify({"ok": False, "error": "Transcription failed or empty"}), 500 | |
| raw = _gemini_text(SOLUTION_EXTRACTION_PROMPT + transcript, json_mode=True) | |
| article = _safe_json(raw, {}) | |
| if not isinstance(article, dict) or not article.get("title"): | |
| return jsonify({"ok": False, "error": "Could not structure an article from the audio"}), 500 | |
| stats = _save_kb_articles([article], source_label="audio_upload") | |
| return jsonify({"ok": True, "transcript": transcript, "saved": stats["saved"], | |
| "article": article, "articles": [article]}) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # TICKETS — customer issues raised on WhatsApp, resolved by staff (two-way) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def _now_iso() -> str: | |
| return datetime.now(timezone.utc).isoformat() | |
| def _get_open_ticket_for_phone(phone: str) -> Optional[Tuple[str, Dict]]: | |
| """Return (doc_id, ticket) for the most recent non-resolved ticket for a phone.""" | |
| if not db: | |
| return None | |
| try: | |
| # Single-field filter + small fetch, status filtered in Python — avoids | |
| # needing a Firestore composite index on (customer_phone, status, created_at). | |
| docs = (db.collection("iris_tickets") | |
| .where("customer_phone", "==", phone) | |
| .limit(10).stream()) | |
| candidates = [(d.id, d.to_dict()) for d in docs] | |
| candidates = [c for c in candidates if c[1].get("status") in ("open", "with_agent")] | |
| if candidates: | |
| candidates.sort(key=lambda c: c[1].get("created_at", ""), reverse=True) | |
| return candidates[0] | |
| except Exception as e: | |
| logger.error("open-ticket lookup error: %s", e) | |
| return None | |
| def _create_ticket(phone: str, first_message: str) -> Optional[str]: | |
| """Create a ticket from a customer's WhatsApp message, AI-pre-filling metadata.""" | |
| if not db: | |
| return None | |
| meta = {} | |
| try: | |
| raw = _gemini_text(TICKET_EXTRACTION_PROMPT + first_message, json_mode=True) | |
| meta = _safe_json(raw, {}) or {} | |
| except Exception as e: | |
| logger.error("ticket metadata extraction error: %s", e) | |
| doc = { | |
| "customer_phone": phone, | |
| "title": meta.get("title") or first_message[:80], | |
| "description": meta.get("description", first_message), | |
| "category": meta.get("category_hint", "Other"), | |
| "priority": meta.get("priority_hint", "medium"), | |
| "status": "with_agent", | |
| "assigned_to": "", | |
| "thread": [{"sender": "customer", "text": first_message, "ts": _now_iso()}], | |
| "created_at": _now_iso(), | |
| "updated_at": _now_iso(), | |
| } | |
| ref = db.collection("iris_tickets").add(doc) | |
| ticket_id = ref[1].id | |
| logger.info("Created ticket %s for %s", ticket_id, phone) | |
| return ticket_id | |
| def _append_ticket_message(ticket_id: str, sender: str, text: str, extra: Optional[Dict] = None) -> None: | |
| if not db: | |
| return | |
| entry = {"sender": sender, "text": text, "ts": _now_iso()} | |
| if extra: | |
| entry.update(extra) | |
| db.collection("iris_tickets").document(ticket_id).update({ | |
| "thread": firestore.ArrayUnion([entry]), | |
| "updated_at": _now_iso(), | |
| }) | |
| def list_tickets(): | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| status = request.args.get("status", "") | |
| assignee = request.args.get("assignee", "") | |
| limit = int(request.args.get("limit", 100)) | |
| try: | |
| q = db.collection("iris_tickets").order_by("updated_at", direction=firestore.Query.DESCENDING) | |
| if status: | |
| q = q.where("status", "==", status) | |
| if assignee: | |
| q = q.where("assigned_to", "==", assignee) | |
| docs = q.limit(limit).stream() | |
| tickets = [{"id": d.id, **d.to_dict()} for d in docs] | |
| return jsonify({"ok": True, "tickets": tickets, "count": len(tickets)}) | |
| except Exception as e: | |
| return jsonify({"ok": False, "error": str(e)}), 500 | |
| def get_ticket(ticket_id: str): | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| snap = db.collection("iris_tickets").document(ticket_id).get() | |
| if not snap.exists: | |
| return jsonify({"ok": False, "error": "Ticket not found"}), 404 | |
| return jsonify({"ok": True, "ticket": {"id": snap.id, **snap.to_dict()}}) | |
| def reply_ticket(ticket_id: str): | |
| """Staff reply → appended to thread AND delivered to the customer on WhatsApp.""" | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| body = request.get_json(silent=True) or {} | |
| text = body.get("text", "").strip() | |
| if not text: | |
| return jsonify({"ok": False, "error": "text is required"}), 400 | |
| snap = db.collection("iris_tickets").document(ticket_id).get() | |
| if not snap.exists: | |
| return jsonify({"ok": False, "error": "Ticket not found"}), 404 | |
| ticket = snap.to_dict() | |
| agent = request.user.get("email", "") | |
| _append_ticket_message(ticket_id, "agent", text, {"agent": agent}) | |
| updates = {"status": "with_agent", "updated_at": _now_iso()} | |
| if not ticket.get("assigned_to"): | |
| updates["assigned_to"] = agent | |
| db.collection("iris_tickets").document(ticket_id).update(updates) | |
| # Deliver to the customer over WhatsApp (must be within the 24h service window) | |
| sent = False | |
| if WHATSAPP_AVAILABLE and wa: | |
| sent = wa.send_text_message(ticket.get("customer_phone", ""), text) | |
| return jsonify({"ok": True, "delivered": sent}) | |
| def update_ticket(ticket_id: str): | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| body = request.get_json(silent=True) or {} | |
| updates = {} | |
| if "status" in body and body["status"] in ("open", "with_agent", "resolved"): | |
| updates["status"] = body["status"] | |
| if "assigned_to" in body: | |
| updates["assigned_to"] = body["assigned_to"] | |
| if "priority" in body: | |
| updates["priority"] = body["priority"] | |
| if not updates: | |
| return jsonify({"ok": False, "error": "No valid fields to update"}), 400 | |
| updates["updated_at"] = _now_iso() | |
| db.collection("iris_tickets").document(ticket_id).update(updates) | |
| return jsonify({"ok": True, "updated": list(updates.keys())}) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # CUSTOMERS — who is interacting, by phone number (aggregated for the dashboard) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def _aggregate_customers() -> List[Dict]: | |
| """ | |
| Aggregate every customer the bot has interacted with, keyed by phone number: | |
| ticket counts by status, total interactions, and last-seen time. Shared by the | |
| Customers list and the analytics endpoints. | |
| """ | |
| customers: Dict[str, Dict] = {} | |
| def _row(phone: str) -> Dict: | |
| return customers.setdefault(phone, { | |
| "phone": phone, "open_tickets": 0, "resolved_tickets": 0, | |
| "total_tickets": 0, "interactions": 0, "last_interaction": "", | |
| }) | |
| # Tickets → status breakdown + last activity. | |
| for d in db.collection("iris_tickets").limit(2000).stream(): | |
| t = d.to_dict() | |
| phone = t.get("customer_phone") | |
| if not phone: | |
| continue | |
| row = _row(phone) | |
| row["total_tickets"] += 1 | |
| if t.get("status") == "resolved": | |
| row["resolved_tickets"] += 1 | |
| else: | |
| row["open_tickets"] += 1 | |
| upd = t.get("updated_at", "") or t.get("created_at", "") | |
| if upd > row["last_interaction"]: | |
| row["last_interaction"] = upd | |
| # Chatbot logs → interaction counts + last-seen. | |
| for d in db.collection("iris_chatbot_logs").limit(5000).stream(): | |
| l = d.to_dict() | |
| phone = l.get("user_id") | |
| if not phone: | |
| continue | |
| row = _row(phone) | |
| row["interactions"] += 1 | |
| ts = l.get("created_at", "") | |
| if ts > row["last_interaction"]: | |
| row["last_interaction"] = ts | |
| return list(customers.values()) | |
| def list_customers(): | |
| """Who's active, who has open tickets, who's resolved — all by phone number.""" | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| try: | |
| result = sorted(_aggregate_customers(), | |
| key=lambda c: c["last_interaction"], reverse=True) | |
| return jsonify({"ok": True, "customers": result, "count": len(result)}) | |
| except Exception as e: | |
| logger.error("list_customers error: %s", e) | |
| return jsonify({"ok": False, "error": str(e)}), 500 | |
| def get_customer(phone: str): | |
| """One customer's full history: their tickets, recent interactions, and feedback.""" | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| try: | |
| tickets = [{"id": d.id, **d.to_dict()} for d in | |
| db.collection("iris_tickets").where("customer_phone", "==", phone).limit(200).stream()] | |
| tickets.sort(key=lambda t: t.get("updated_at", ""), reverse=True) | |
| logs = [d.to_dict() for d in | |
| db.collection("iris_chatbot_logs").where("user_id", "==", phone).limit(200).stream()] | |
| logs.sort(key=lambda l: l.get("created_at", ""), reverse=True) | |
| feedback = [{"id": d.id, **d.to_dict()} for d in | |
| db.collection("iris_feedback").where("customer_phone", "==", phone).limit(100).stream()] | |
| return jsonify({"ok": True, "phone": phone, "tickets": tickets, | |
| "interactions": logs[:50], "feedback": feedback}) | |
| except Exception as e: | |
| logger.error("get_customer error: %s", e) | |
| return jsonify({"ok": False, "error": str(e)}), 500 | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # FEEDBACK — 👎 signals from customers, flagged for the dashboard | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def list_feedback(): | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| status = request.args.get("status", "open") # default: show what needs attention | |
| limit = int(request.args.get("limit", 100)) | |
| try: | |
| q = db.collection("iris_feedback") | |
| if status: | |
| q = q.where("status", "==", status) | |
| items = [{"id": d.id, **d.to_dict()} for d in q.limit(limit).stream()] | |
| items.sort(key=lambda f: f.get("created_at", ""), reverse=True) | |
| return jsonify({"ok": True, "feedback": items, "count": len(items)}) | |
| except Exception as e: | |
| return jsonify({"ok": False, "error": str(e)}), 500 | |
| def update_feedback(feedback_id: str): | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| body = request.get_json(silent=True) or {} | |
| status = body.get("status") | |
| if status not in ("open", "closed"): | |
| return jsonify({"ok": False, "error": "status must be 'open' or 'closed'"}), 400 | |
| db.collection("iris_feedback").document(feedback_id).update({ | |
| "status": status, "updated_at": _now_iso(), | |
| "handled_by": request.user.get("email", ""), | |
| }) | |
| return jsonify({"ok": True}) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # ANALYTICS — top users + most recurring issues (dashboard insights) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # Common words to ignore when surfacing recurring-issue keywords. | |
| _STOPWORDS = { | |
| "the", "a", "an", "and", "or", "but", "is", "are", "was", "were", "be", "been", | |
| "to", "of", "in", "on", "for", "with", "my", "me", "i", "you", "it", "this", | |
| "that", "how", "do", "i'm", "im", "can", "cant", "can't", "not", "no", "yes", | |
| "please", "help", "issue", "problem", "iris", "have", "has", "get", "got", | |
| "when", "what", "why", "where", "there", "your", "they", "from", "at", "as", | |
| } | |
| def top_users(): | |
| """Customers ranked by how much they interact — busiest phone numbers first.""" | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| limit = int(request.args.get("limit", 10)) | |
| try: | |
| ranked = sorted( | |
| _aggregate_customers(), | |
| key=lambda c: (c["interactions"] + c["total_tickets"], c["last_interaction"]), | |
| reverse=True, | |
| ) | |
| return jsonify({"ok": True, "users": ranked[:limit]}) | |
| except Exception as e: | |
| logger.error("top_users error: %s", e) | |
| return jsonify({"ok": False, "error": str(e)}), 500 | |
| def recurring_issues(): | |
| """ | |
| What customers keep asking about: ticket counts by category, plus the most | |
| frequent keywords across ticket titles and bot questions. | |
| """ | |
| if not db: | |
| return jsonify({"ok": False, "error": "Firebase unavailable"}), 500 | |
| limit = int(request.args.get("limit", 15)) | |
| try: | |
| from collections import Counter | |
| by_category: "Counter[str]" = Counter() | |
| keywords: "Counter[str]" = Counter() | |
| def _harvest(text: str) -> None: | |
| for term in re.findall(r"[a-zA-Z']{3,}", (text or "").lower()): | |
| if term not in _STOPWORDS: | |
| keywords[term] += 1 | |
| for d in db.collection("iris_tickets").limit(2000).stream(): | |
| t = d.to_dict() | |
| by_category[t.get("category", "Other") or "Other"] += 1 | |
| _harvest(t.get("title", "")) | |
| for d in db.collection("iris_chatbot_logs").limit(5000).stream(): | |
| _harvest(d.to_dict().get("message", "")) | |
| return jsonify({ | |
| "ok": True, | |
| "by_category": [{"category": c, "count": n} for c, n in by_category.most_common()], | |
| "top_keywords": [{"term": t, "count": n} for t, n in keywords.most_common(limit)], | |
| }) | |
| except Exception as e: | |
| logger.error("recurring_issues error: %s", e) | |
| return jsonify({"ok": False, "error": str(e)}), 500 | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # WHATSAPP BOT — customer-facing channel (Twilio webhook) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # Phrases that signal the customer wants a human agent rather than the bot. | |
| _HUMAN_REQUEST_RE = re.compile( | |
| r"\b(human|agent|person|someone|representative|support team|talk to|speak to|call me|operator)\b", | |
| re.IGNORECASE, | |
| ) | |
| # In-memory dedup of Twilio MessageSid (Twilio retries webhooks). Backed by Firestore. | |
| _processed_msgs = OrderedDict() | |
| _dedup_lock = threading.Lock() | |
| def _is_duplicate(msg_id: str) -> bool: | |
| if not msg_id: | |
| return False | |
| with _dedup_lock: | |
| if msg_id in _processed_msgs: | |
| return True | |
| _processed_msgs[msg_id] = time.time() | |
| # Trim memory cache | |
| while len(_processed_msgs) > 5000: | |
| _processed_msgs.popitem(last=False) | |
| if db: | |
| try: | |
| ref = db.collection("processed_messages").document(msg_id) | |
| if ref.get().exists: | |
| return True | |
| ref.set({"processed_at": _now_iso()}) | |
| except Exception as e: | |
| logger.error("Dedup persistence error: %s", e) | |
| return False | |
| def _build_kb_answer(query: str) -> Tuple[str, Optional[str]]: | |
| """ | |
| RAG over the KB. Returns (answer_text, clip_url_or_None) where clip_url is a | |
| freshly-signed, publicly-fetchable URL for the best matching tutorial clip. | |
| answer_text is "" when nothing relevant was found (caller escalates). | |
| """ | |
| kb_results = _search_kb(query, limit=5) | |
| if not kb_results: | |
| return "", None | |
| context_blocks = [] | |
| clip_send_url = None | |
| for r in kb_results: | |
| block = f"[Article: {r.get('title')}]\n{r.get('content', '')}" | |
| if r.get("timestamp_start") is not None: | |
| ts = r["timestamp_start"] | |
| block += f"\n(Tutorial: {r.get('video_title','Video')} at {ts//60:02d}:{ts%60:02d})" | |
| context_blocks.append(block) | |
| if clip_send_url is None: | |
| # Prefer a fresh signed URL from the stored path (uniform-access safe); | |
| # fall back to a stored public URL if that's all we have. | |
| if r.get("clip_path"): | |
| clip_send_url = _storage_signed_url(r["clip_path"], minutes=120) or r.get("clip_url") | |
| elif r.get("clip_url"): | |
| clip_send_url = r["clip_url"] | |
| context_str = "\n\n---\n\n".join(context_blocks) | |
| full_prompt = (f"{CHATBOT_SYSTEM_PROMPT}\n\nKNOWLEDGE BASE CONTEXT:\n{context_str}\n\n" | |
| f"USER QUESTION: {query}\n\nAnswer:") | |
| answer = _gemini_text(full_prompt) or "" | |
| return answer, clip_send_url | |
| def _log_whatsapp_qa(phone: str, message: str, answer: str) -> None: | |
| if not db: | |
| return | |
| try: | |
| db.collection("iris_chatbot_logs").add({ | |
| "user_id": phone, "session_id": "whatsapp", "channel": "whatsapp", | |
| "message": message, "answer": answer, "created_at": _now_iso(), | |
| }) | |
| except Exception as e: | |
| logger.error("WhatsApp QA log error: %s", e) | |
| # ── Feedback (👍 / 👎) on bot answers ────────────────────────────────────────── | |
| FEEDBACK_BTN_UP = "👍 Helpful" | |
| FEEDBACK_BTN_DOWN = "👎 Not helpful" | |
| _POSITIVE_FB_RE = re.compile(r"(👍|\bhelpful\b|\bthis helped\b|\bthat helped\b|\bit helped\b)", re.IGNORECASE) | |
| _NEGATIVE_FB_RE = re.compile(r"(👎|\bnot helpful\b|\bdidn'?t help\b|\bdid not help\b|\bunhelpful\b|\bno help\b)", re.IGNORECASE) | |
| # Lightweight per-phone conversation state (last Q&A + whether we're awaiting a | |
| # "what was missing" explanation after a 👎). In-memory: resets on restart, which | |
| # is fine for an ephemeral feedback follow-up. | |
| _wa_state: Dict[str, Dict] = {} | |
| _wa_state_lock = threading.Lock() | |
| def _set_wa_state(phone: str, **kwargs) -> None: | |
| with _wa_state_lock: | |
| _wa_state.setdefault(phone, {}).update(kwargs) | |
| def _get_wa_state(phone: str) -> Dict: | |
| with _wa_state_lock: | |
| return dict(_wa_state.get(phone, {})) | |
| def _clear_wa_pending(phone: str) -> None: | |
| with _wa_state_lock: | |
| if phone in _wa_state: | |
| _wa_state[phone].pop("pending", None) | |
| def _store_feedback(phone: str, rating: str, question: str, answer: str, explanation: str = "") -> None: | |
| """Persist a feedback signal. Negative feedback is flagged open for the dashboard.""" | |
| if not db: | |
| return | |
| try: | |
| db.collection("iris_feedback").add({ | |
| "customer_phone": phone, | |
| "rating": rating, # "up" | "down" | |
| "question": question, | |
| "answer": answer, | |
| "explanation": explanation, | |
| "status": "open" if rating == "down" else "closed", | |
| "created_at": _now_iso(), | |
| }) | |
| except Exception as e: | |
| logger.error("Feedback store error: %s", e) | |
| def _send_answer_with_feedback(phone: str, question: str, answer: str, clip_url: Optional[str]) -> None: | |
| """Send the answer text, then the clip as its OWN message, then a feedback prompt.""" | |
| wa.send_text_message(phone, answer) | |
| if clip_url: | |
| # Send media as a separate message from any text/caption — WhatsApp delivers | |
| # the video on its own and the caption text does not get swallowed. | |
| wa.send_text_message(phone, "🎥 Here's a short clip showing the exact steps:") | |
| wa.send_video_message(phone, clip_url) | |
| _log_whatsapp_qa(phone, question, answer) | |
| _set_wa_state(phone, last_q=question, last_a=answer) | |
| wa.send_buttons_as_text(phone, "Did this answer your question?", | |
| [FEEDBACK_BTN_UP, FEEDBACK_BTN_DOWN]) | |
| def process_text_message(text: str, phone: str) -> None: | |
| """Core customer conversation logic for an inbound WhatsApp text.""" | |
| if not (WHATSAPP_AVAILABLE and wa): | |
| return | |
| try: | |
| text = (text or "").strip() | |
| if not text: | |
| return | |
| state = _get_wa_state(phone) | |
| # 0a. We asked "what was missing?" after a 👎 — capture this as the explanation. | |
| if state.get("pending") == "feedback_explanation": | |
| _clear_wa_pending(phone) | |
| _store_feedback(phone, "down", state.get("last_q", ""), state.get("last_a", ""), text) | |
| wa.send_text_message(phone, "🙏 Thank you — I've flagged this for our support team to review " | |
| "and improve. If you'd like a person to follow up, reply *agent*.") | |
| return | |
| # 0b. Feedback reactions on the previous answer. | |
| if _NEGATIVE_FB_RE.search(text): | |
| _set_wa_state(phone, pending="feedback_explanation") | |
| wa.send_text_message(phone, "Sorry that didn't help. 🙇 What were you trying to do, or what's " | |
| "missing from the answer? Tell me and I'll pass it to our team.") | |
| return | |
| if _POSITIVE_FB_RE.search(text): | |
| _store_feedback(phone, "up", state.get("last_q", ""), state.get("last_a", "")) | |
| wa.send_text_message(phone, "Great — glad that helped! 😊 Send another question any time.") | |
| return | |
| # 1. If a live ticket is already with an agent, route the message into the thread. | |
| open_ticket = _get_open_ticket_for_phone(phone) | |
| if open_ticket and open_ticket[1].get("status") == "with_agent": | |
| _append_ticket_message(open_ticket[0], "customer", text) | |
| wa.send_text_message(phone, "✅ Got it — your message was added to your support ticket. " | |
| "An agent will reply here shortly.") | |
| return | |
| # 2. Explicit request for a human → escalate. | |
| if _HUMAN_REQUEST_RE.search(text): | |
| tid = _create_ticket(phone, text) | |
| ref = (tid or "")[-6:].upper() | |
| wa.send_text_message(phone, f"🙋 I've created support ticket *#{ref}* and our team will " | |
| f"reply to you right here on WhatsApp. You can keep typing to add details.") | |
| return | |
| # 3. Try to auto-resolve from the knowledge base (answer + clip + feedback buttons). | |
| answer, clip_url = _build_kb_answer(text) | |
| if answer: | |
| _send_answer_with_feedback(phone, text, answer, clip_url) | |
| return | |
| # 4. Nothing relevant found → escalate to a human ticket. | |
| tid = _create_ticket(phone, text) | |
| ref = (tid or "")[-6:].upper() | |
| wa.send_text_message(phone, f"I couldn't find an answer for that in our help library, so I've " | |
| f"raised support ticket *#{ref}*. Our team will reply to you here shortly. 🙏") | |
| _log_whatsapp_qa(phone, text, "[escalated to ticket]") | |
| except Exception as e: | |
| logger.error("process_text_message error: %s", e) | |
| try: | |
| wa.send_text_message(phone, "Sorry, something went wrong on our side. Please try again shortly.") | |
| except Exception: | |
| pass | |
| def process_audio_message(audio_url: str, phone: str) -> None: | |
| """Download a WhatsApp voice note, transcribe it, then treat it as a text query.""" | |
| if not (WHATSAPP_AVAILABLE and wa): | |
| return | |
| if not ASSEMBLYAI_API_KEY: | |
| wa.send_text_message(phone, "Voice messages aren't supported yet — please type your question. 🙏") | |
| return | |
| tmp_path = os.path.join(tempfile.gettempdir(), f"wa_{uuid.uuid4().hex}.ogg") | |
| try: | |
| dl = wa.download_media(audio_url, tmp_path) | |
| if not dl: | |
| wa.send_text_message(phone, "Sorry, I couldn't download your voice message.") | |
| return | |
| with open(dl, "rb") as fp: | |
| transcript = _transcribe_audio_bytes(fp.read()) | |
| if not transcript: | |
| wa.send_text_message(phone, "Sorry, I couldn't understand that voice message. Please try typing it.") | |
| return | |
| process_text_message(transcript, phone) | |
| except Exception as e: | |
| logger.error("process_audio_message error: %s", e) | |
| finally: | |
| try: | |
| os.remove(tmp_path) | |
| except Exception: | |
| pass | |
| IMAGE_ISSUE_PROMPT = """A customer sent this image to support, often a screenshot of an error or app state. | |
| Caption: "{caption}" | |
| Briefly describe, in one or two sentences, what problem the image shows so we can search our help library. | |
| Reply with the description only — no preamble.""" | |
| def process_image_message(image_url: str, caption: str, phone: str) -> None: | |
| """Understand a customer's screenshot via Gemini vision, then answer from the KB.""" | |
| if not (WHATSAPP_AVAILABLE and wa): | |
| return | |
| tmp_path = os.path.join(tempfile.gettempdir(), f"wa_{uuid.uuid4().hex}.jpg") | |
| try: | |
| dl = wa.download_media(image_url, tmp_path) | |
| if not dl or not _gemini_client: | |
| process_text_message(caption or "I sent an image", phone) | |
| return | |
| with open(dl, "rb") as fp: | |
| img_bytes = fp.read() | |
| try: | |
| part = genai_types.Part.from_bytes(data=img_bytes, mime_type="image/jpeg") | |
| desc = _gemini_multimodal([IMAGE_ISSUE_PROMPT.format(caption=caption or ""), part]) or "" | |
| except Exception as e: | |
| logger.error("image issue vision error: %s", e) | |
| desc = "" | |
| query = (caption + " " + desc).strip() or desc or caption or "image issue" | |
| process_text_message(query, phone) | |
| except Exception as e: | |
| logger.error("process_image_message error: %s", e) | |
| finally: | |
| try: | |
| os.remove(tmp_path) | |
| except Exception: | |
| pass | |
| def whatsapp_webhook(): | |
| """ | |
| Twilio inbound WhatsApp webhook. Returns empty TwiML immediately and does all | |
| work in a background thread (Twilio expects a response within ~15s). | |
| """ | |
| if not (WHATSAPP_AVAILABLE and wa): | |
| return ("WhatsApp not configured", 503) | |
| # Optional signature validation (enable in production with TWILIO_VALIDATE_SIGNATURE=true) | |
| if os.environ.get("TWILIO_VALIDATE_SIGNATURE", "false").lower() == "true": | |
| sig = request.headers.get("X-Twilio-Signature", "") | |
| if not wa.validate_signature(request.url, request.form.to_dict(), sig): | |
| logger.warning("Rejected webhook with invalid Twilio signature") | |
| return ("Invalid signature", 403) | |
| details = wa.get_message_details(request.form) | |
| xml = wa.twiml_empty() | |
| if not details: | |
| return app.response_class(xml, mimetype="text/xml") | |
| if _is_duplicate(details.get("id")): | |
| logger.info("Duplicate webhook ignored: %s", details.get("id")) | |
| return app.response_class(xml, mimetype="text/xml") | |
| phone = details.get("from") | |
| mtype = details.get("type") | |
| if mtype == "text": | |
| threading.Thread(target=process_text_message, | |
| args=(details.get("text", ""), phone), daemon=True).start() | |
| elif mtype == "audio": | |
| threading.Thread(target=process_audio_message, | |
| args=(details.get("audio_url"), phone), daemon=True).start() | |
| elif mtype in ("image", "document"): | |
| url = details.get("image_url") or details.get("document_url") | |
| threading.Thread(target=process_image_message, | |
| args=(url, details.get("caption", ""), phone), daemon=True).start() | |
| return app.response_class(xml, mimetype="text/xml") | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # HEALTH | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def health(): | |
| article_count = 0 | |
| if db: | |
| try: | |
| docs = db.collection("iris_kb_articles").count().get() | |
| article_count = docs[0][0].value | |
| except Exception: | |
| pass | |
| return jsonify({ | |
| "ok": True, | |
| "service": "Iris AI Service v2.0 (WhatsApp + Helpdesk)", | |
| "model": GEMINI_MODEL, | |
| "gemini": bool(_gemini_client), | |
| "assemblyai": bool(ASSEMBLYAI_API_KEY), | |
| "firebase": bool(db), | |
| "storage": bool(get_bucket()), | |
| "whatsapp": bool(WHATSAPP_AVAILABLE and wa), | |
| "kb_articles": article_count, | |
| }) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # ENTRYPOINT | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 7860)) | |
| logger.info("Iris AI Service v1.1 starting on port %d (model=%s)", port, GEMINI_MODEL) | |
| app.run(host="0.0.0.0", port=port) |