Upload 6 files
Browse files- Flowme.md +5 -0
- README.md +14 -0
- WRITERS_DESK_PROMPTS.xlsx +0 -0
- app.py +997 -0
- requirements.txt +12 -0
- writer_prompts.py +59 -0
Flowme.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
To Run this Application we need the Below Files,
|
| 2 |
+
- app.py
|
| 3 |
+
- requirements.txt
|
| 4 |
+
- writer_prompts.py
|
| 5 |
+
- WRITERS_DESK_PROMPTS.xlsx
|
README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Al Recruiter Desk V2
|
| 3 |
+
emoji: ⚡
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.14.0
|
| 8 |
+
python_version: '3.13'
|
| 9 |
+
app_file: app.py
|
| 10 |
+
pinned: false
|
| 11 |
+
short_description: AI powered Recruiter and Writer Desk
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
WRITERS_DESK_PROMPTS.xlsx
ADDED
|
Binary file (45.3 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,997 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
import os
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
from typing import Optional, List, Tuple, Dict
|
| 7 |
+
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
import gradio as gr
|
| 10 |
+
import google.generativeai as genai
|
| 11 |
+
from openai import AsyncOpenAI
|
| 12 |
+
import fitz # PyMuPDF
|
| 13 |
+
from docx import Document
|
| 14 |
+
import pandas as pd
|
| 15 |
+
|
| 16 |
+
from writer_prompts import WRITER_PRESET_LABELS, WRITER_PRESET_MAP
|
| 17 |
+
|
| 18 |
+
load_dotenv()
|
| 19 |
+
|
| 20 |
+
logging.basicConfig(
|
| 21 |
+
level=logging.INFO,
|
| 22 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 23 |
+
handlers=[logging.StreamHandler()],
|
| 24 |
+
)
|
| 25 |
+
logger = logging.getLogger("ATSApp")
|
| 26 |
+
|
| 27 |
+
logger.info("===== Application Startup =====")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
RECRUITER_USERNAME = os.getenv("RECRUITER_USERNAME") or os.getenv("USERNAME_DualMind")
|
| 31 |
+
RECRUITER_PASSWORD = os.getenv("RECRUITER_PASSWORD") or os.getenv("PASSWORD")
|
| 32 |
+
WRITER_USERNAME = os.getenv("WRITER_USERNAME") or ""
|
| 33 |
+
WRITER_PASSWORD = os.getenv("WRITER_PASSWORD") or ""
|
| 34 |
+
|
| 35 |
+
WRITER_COMBINED_MAX_CHARS = 300_000
|
| 36 |
+
|
| 37 |
+
current_task_cancelled = False
|
| 38 |
+
current_task: Optional[asyncio.Task] = None
|
| 39 |
+
|
| 40 |
+
# -------------------------
|
| 41 |
+
# Helper: Clean & truncate
|
| 42 |
+
# -------------------------
|
| 43 |
+
def clean_text(s: str) -> str:
|
| 44 |
+
logger.debug("Cleaning text input...")
|
| 45 |
+
original_len = len(s)
|
| 46 |
+
s = s.replace("\x00", " ")
|
| 47 |
+
s = re.sub(r"[ \t]+", " ", s)
|
| 48 |
+
s = re.sub(r"\n\s*\n\s*\n+", "\n\n", s)
|
| 49 |
+
cleaned = s.strip()
|
| 50 |
+
logger.info(f"Text cleaned. Original length={original_len}, Cleaned length={len(cleaned)}")
|
| 51 |
+
return cleaned
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def truncate_tokens(plain: str, max_chars: int = WRITER_COMBINED_MAX_CHARS) -> str:
|
| 55 |
+
logger.debug(f"Checking if truncation required. Length={len(plain)}, Limit={max_chars}")
|
| 56 |
+
if plain and len(plain) > max_chars:
|
| 57 |
+
logger.warning(f"Truncating text. Original length={len(plain)} exceeds {max_chars}")
|
| 58 |
+
return plain[:max_chars] + "\n\n[TRUNCATED]"
|
| 59 |
+
return plain or ""
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _extract_docx_paragraphs_and_tables(path: str) -> str:
|
| 63 |
+
doc = Document(path)
|
| 64 |
+
parts: List[str] = []
|
| 65 |
+
for p in doc.paragraphs:
|
| 66 |
+
if p.text.strip():
|
| 67 |
+
parts.append(p.text.strip())
|
| 68 |
+
for tbl in doc.tables:
|
| 69 |
+
for row in tbl.rows:
|
| 70 |
+
cells = [c.text.strip() for c in row.cells]
|
| 71 |
+
if any(cells):
|
| 72 |
+
parts.append(" | ".join(cells))
|
| 73 |
+
return "\n".join(parts)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def extract_legacy_doc(path: str) -> Tuple[str, str]:
|
| 77 |
+
if os.name != "nt":
|
| 78 |
+
return "error", "Legacy .doc is only supported on Windows with Word and pywin32 installed."
|
| 79 |
+
try:
|
| 80 |
+
import win32com.client # type: ignore
|
| 81 |
+
except ImportError:
|
| 82 |
+
return "error", "Legacy .doc requires pywin32 and Microsoft Word. Convert to DOCX instead."
|
| 83 |
+
path = os.path.abspath(path)
|
| 84 |
+
word = win32com.client.DispatchEx("Word.Application")
|
| 85 |
+
word.Visible = False
|
| 86 |
+
opened = None
|
| 87 |
+
try:
|
| 88 |
+
opened = word.Documents.Open(path, ReadOnly=True)
|
| 89 |
+
text = opened.Content.Text or ""
|
| 90 |
+
return "doc", text
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.error(f"Word .doc extraction failed: {e}", exc_info=True)
|
| 93 |
+
return "error", f".doc error: {e}"
|
| 94 |
+
finally:
|
| 95 |
+
try:
|
| 96 |
+
if opened:
|
| 97 |
+
opened.Close(False)
|
| 98 |
+
except Exception:
|
| 99 |
+
pass
|
| 100 |
+
try:
|
| 101 |
+
word.Quit()
|
| 102 |
+
except Exception:
|
| 103 |
+
pass
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def extract_excel(path: str) -> Tuple[str, str]:
|
| 107 |
+
low = path.lower()
|
| 108 |
+
kwargs: Dict = {"sheet_name": None, "dtype": str}
|
| 109 |
+
try:
|
| 110 |
+
if low.endswith((".xlsx", ".xlsm")):
|
| 111 |
+
dfs = pd.read_excel(path, engine="openpyxl", **kwargs)
|
| 112 |
+
elif low.endswith(".xls"):
|
| 113 |
+
dfs = pd.read_excel(path, engine="xlrd", **kwargs)
|
| 114 |
+
else:
|
| 115 |
+
return "error", "Unsupported spreadsheet format."
|
| 116 |
+
except Exception as e:
|
| 117 |
+
logger.error(f"Excel read failed: {e}", exc_info=True)
|
| 118 |
+
return "error", f"Excel error: {e}"
|
| 119 |
+
|
| 120 |
+
if not isinstance(dfs, dict):
|
| 121 |
+
return "error", "Unexpected Excel parse shape."
|
| 122 |
+
|
| 123 |
+
chunks: List[str] = []
|
| 124 |
+
for sheet_name, frame in dfs.items():
|
| 125 |
+
chunks.append(f"=== Sheet: {sheet_name} ===")
|
| 126 |
+
chunks.append(frame.fillna("").astype(str).to_csv(index=False))
|
| 127 |
+
|
| 128 |
+
text = "\n".join(chunks)
|
| 129 |
+
logger.info(f"Excel extracted chars={len(text)}")
|
| 130 |
+
return "excel", text
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def extract_writer_document(file_path: str) -> Tuple[str, str]:
|
| 134 |
+
ext = os.path.splitext(file_path)[1].lower()
|
| 135 |
+
logger.info(f"Extracting Writer desk file: {file_path}")
|
| 136 |
+
|
| 137 |
+
if ext == ".pdf":
|
| 138 |
+
try:
|
| 139 |
+
doc = fitz.open(file_path)
|
| 140 |
+
text = "\n".join(page.get_text() for page in doc)
|
| 141 |
+
return "pdf", text
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.error(f"PDF error: {e}", exc_info=True)
|
| 144 |
+
return "error", f"PDF error: {e}"
|
| 145 |
+
|
| 146 |
+
if ext == ".docx":
|
| 147 |
+
try:
|
| 148 |
+
return "docx", _extract_docx_paragraphs_and_tables(file_path)
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logger.error(f"DOCX error: {e}", exc_info=True)
|
| 151 |
+
return "error", f"DOCX error: {e}"
|
| 152 |
+
|
| 153 |
+
if ext == ".doc":
|
| 154 |
+
return extract_legacy_doc(file_path)
|
| 155 |
+
|
| 156 |
+
if ext in (".xlsx", ".xlsm", ".xls"):
|
| 157 |
+
return extract_excel(file_path)
|
| 158 |
+
|
| 159 |
+
logger.warning(f"Unsupported Writer desk format: {ext}")
|
| 160 |
+
return "error", f"Unsupported extension {ext}: use PDF, DOC, DOCX, XLS, or XLSX."
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def extract_resume(file_path: str) -> Tuple[str, str]:
|
| 164 |
+
logger.info(f"Extracting content from file: {file_path}")
|
| 165 |
+
ext = os.path.splitext(file_path)[1].lower()
|
| 166 |
+
|
| 167 |
+
if ext == ".pdf":
|
| 168 |
+
try:
|
| 169 |
+
doc = fitz.open(file_path)
|
| 170 |
+
text = "\n".join(page.get_text() for page in doc)
|
| 171 |
+
return "pdf", text
|
| 172 |
+
except Exception as e:
|
| 173 |
+
logger.error(f"Error extracting PDF: {e}", exc_info=True)
|
| 174 |
+
return "error", f"PDF error: {e}"
|
| 175 |
+
|
| 176 |
+
elif ext == ".docx":
|
| 177 |
+
try:
|
| 178 |
+
doc = Document(file_path)
|
| 179 |
+
text = "\n".join(p.text for p in doc.paragraphs)
|
| 180 |
+
return "docx", text
|
| 181 |
+
except Exception as e:
|
| 182 |
+
logger.error(f"Error extracting DOCX: {e}", exc_info=True)
|
| 183 |
+
return "error", f"DOCX error: {e}"
|
| 184 |
+
|
| 185 |
+
logger.warning(f"Unsupported file format: {ext}")
|
| 186 |
+
return "error", "Only PDF and DOCX files are supported for this upload."
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# -------------------------
|
| 190 |
+
# Build Matcher Prompt
|
| 191 |
+
# -------------------------
|
| 192 |
+
def build_matcher_prompt(resume_text: str, jd_text: str) -> str:
|
| 193 |
+
logger.info("Building ATS matcher prompt")
|
| 194 |
+
return f"""
|
| 195 |
+
Resume vs Job Description Matcher – "Check Resume with JD" GPT
|
| 196 |
+
Goal:
|
| 197 |
+
To analyze a candidate's resume against a specific job description and deliver a structured, comprehensive comparison.
|
| 198 |
+
Highlight relevance, alignment, missing elements, and strengths. Provide detailed tables and feedback for improvements.
|
| 199 |
+
Prompt Instructions:
|
| 200 |
+
1. Extract Key Criteria from JD:
|
| 201 |
+
- Identify required and desired skills, qualifications, domain experience, scope of the project, tools, and years of experience.
|
| 202 |
+
- Note special industry (e.g., healthcare, US government), technical domains (e.g., cloud, data), and methodologies (e.g., Agile, Waterfall).
|
| 203 |
+
2. Extract Key Experience from Resume:
|
| 204 |
+
- List certifications, domain expertise, scope matching, years of experience per skill, tools used, and roles.
|
| 205 |
+
- Highlight any experience directly related to government or healthcare projects.
|
| 206 |
+
3. Match Each JD Requirement to Resume Evidence:
|
| 207 |
+
- Create a detailed table showing:
|
| 208 |
+
• JD Requirement
|
| 209 |
+
• Resume Evidence
|
| 210 |
+
• Estimated Experience
|
| 211 |
+
• Match Level (Strong, Partial, Missing)
|
| 212 |
+
• Notes
|
| 213 |
+
4. Summarize Gaps & Suggestions:
|
| 214 |
+
- Highlight unmatched or weak areas with actionable suggestions.
|
| 215 |
+
- Suggest language or formatting improvements to better match keywords.
|
| 216 |
+
5. Calculate Matching Score:
|
| 217 |
+
- Provide a percentage estimate based on number and weight of matched criteria.
|
| 218 |
+
6. Professional Summary:
|
| 219 |
+
- Offer a polished paragraph summarizing compatibility and recommendation for applying or tailoring further.
|
| 220 |
+
7. Tone:
|
| 221 |
+
- Use a professional, constructive, and supportive tone focused on improvement and clarity.
|
| 222 |
+
8. You need to show matching score and why this candidate or candidates are suitable for the positions
|
| 223 |
+
and if not then why they are not suitable for the position.
|
| 224 |
+
### JOB DESCRIPTION
|
| 225 |
+
{jd_text}
|
| 226 |
+
### CANDIDATE RESUMES
|
| 227 |
+
{resume_text}
|
| 228 |
+
"""
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def build_writer_user_prompt(preset_choice: str, user_custom_prompt: str) -> Tuple[bool, str]:
|
| 232 |
+
preset_choice = preset_choice or WRITER_PRESET_LABELS[0]
|
| 233 |
+
preset_body = (WRITER_PRESET_MAP.get(preset_choice) or "").strip()
|
| 234 |
+
custom = (user_custom_prompt or "").strip()
|
| 235 |
+
|
| 236 |
+
if not preset_body and not custom:
|
| 237 |
+
return False, ""
|
| 238 |
+
|
| 239 |
+
chunks: List[str] = []
|
| 240 |
+
if preset_body:
|
| 241 |
+
chunks.append(preset_body)
|
| 242 |
+
if custom:
|
| 243 |
+
chunks.append("### USER PROMPT\n" + custom)
|
| 244 |
+
|
| 245 |
+
return True, "\n\n".join(chunks)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
class ResumeATSChat:
|
| 249 |
+
def __init__(self):
|
| 250 |
+
logger.info("Initializing ResumeATSChat instance")
|
| 251 |
+
self.setup_apis()
|
| 252 |
+
|
| 253 |
+
def setup_apis(self):
|
| 254 |
+
logger.info("Setting up AI API connections...")
|
| 255 |
+
self.gemini_available = False
|
| 256 |
+
self.openai_available = False
|
| 257 |
+
try:
|
| 258 |
+
genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
|
| 259 |
+
self.gemini_model = genai.GenerativeModel("gemini-2.5-flash")
|
| 260 |
+
self.gemini_available = True
|
| 261 |
+
logger.info("Gemini API configured successfully")
|
| 262 |
+
except Exception as e:
|
| 263 |
+
logger.error(f"Gemini setup failed: {e}")
|
| 264 |
+
|
| 265 |
+
try:
|
| 266 |
+
self.openai_client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
| 267 |
+
self.openai_available = True
|
| 268 |
+
logger.info("OpenAI API configured successfully")
|
| 269 |
+
except Exception as e:
|
| 270 |
+
logger.error(f"OpenAI setup failed: {e}")
|
| 271 |
+
|
| 272 |
+
async def _analyze_with_gemini(self, prompt: str) -> Dict[str, Optional[str]]:
|
| 273 |
+
global current_task_cancelled
|
| 274 |
+
if current_task_cancelled:
|
| 275 |
+
logger.info("Gemini analysis aborted before sending")
|
| 276 |
+
return {"data": "⚠️ Cancelled"}
|
| 277 |
+
|
| 278 |
+
logger.info("Sending prompt to Gemini API")
|
| 279 |
+
try:
|
| 280 |
+
gem_raw = await self.gemini_model.generate_content_async(prompt)
|
| 281 |
+
text = getattr(gem_raw, "text", None) or ""
|
| 282 |
+
if not text.strip():
|
| 283 |
+
text = "(Gemini returned an empty reply.)"
|
| 284 |
+
|
| 285 |
+
if current_task_cancelled:
|
| 286 |
+
logger.info("Gemini analysis aborted after response")
|
| 287 |
+
return {"data": "⚠️ Cancelled"}
|
| 288 |
+
|
| 289 |
+
return {"data": text}
|
| 290 |
+
except Exception as e:
|
| 291 |
+
logger.error(f"Gemini analysis failed: {e}", exc_info=True)
|
| 292 |
+
return {"data": f"Gemini error: {e}"}
|
| 293 |
+
|
| 294 |
+
async def _analyze_with_openai(self, prompt: str) -> Dict[str, Optional[str]]:
|
| 295 |
+
global current_task_cancelled
|
| 296 |
+
if current_task_cancelled:
|
| 297 |
+
logger.info("OpenAI analysis aborted before sending")
|
| 298 |
+
return {"data": "⚠️ Cancelled"}
|
| 299 |
+
|
| 300 |
+
logger.info("Sending prompt to OpenAI GPT API")
|
| 301 |
+
try:
|
| 302 |
+
gpt_raw = await self.openai_client.chat.completions.create(
|
| 303 |
+
model="gpt-4o-mini",
|
| 304 |
+
messages=[{"role": "user", "content": prompt}],
|
| 305 |
+
temperature=0.2,
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
if current_task_cancelled:
|
| 309 |
+
logger.info("OpenAI analysis aborted after response")
|
| 310 |
+
return {"data": "⚠️ Cancelled"}
|
| 311 |
+
|
| 312 |
+
content = (
|
| 313 |
+
gpt_raw.choices[0].message.content if gpt_raw and gpt_raw.choices else ""
|
| 314 |
+
) or ""
|
| 315 |
+
return {"data": content}
|
| 316 |
+
except Exception as e:
|
| 317 |
+
logger.error(f"ChatGPT analysis failed: {e}", exc_info=True)
|
| 318 |
+
return {"data": f"ChatGPT error: {e}"}
|
| 319 |
+
|
| 320 |
+
async def _run_dual_llm(self, full_prompt: str) -> Tuple[str, str]:
|
| 321 |
+
global current_task_cancelled
|
| 322 |
+
tasks: List = []
|
| 323 |
+
names: List[str] = []
|
| 324 |
+
if self.gemini_available:
|
| 325 |
+
tasks.append(self._analyze_with_gemini(full_prompt))
|
| 326 |
+
names.append("Gemini")
|
| 327 |
+
if self.openai_available:
|
| 328 |
+
tasks.append(self._analyze_with_openai(full_prompt))
|
| 329 |
+
names.append("ChatGPT")
|
| 330 |
+
|
| 331 |
+
if not tasks:
|
| 332 |
+
logger.error("No AI providers available")
|
| 333 |
+
return "", "No AI providers are configured. Please set API keys."
|
| 334 |
+
|
| 335 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 336 |
+
|
| 337 |
+
gemini_resp, chatgpt_resp = "", ""
|
| 338 |
+
for name, res in zip(names, results):
|
| 339 |
+
if current_task_cancelled:
|
| 340 |
+
output_text = "⚠️ Cancelled"
|
| 341 |
+
elif isinstance(res, Exception):
|
| 342 |
+
logger.error(f"{name} raised: {res}", exc_info=True)
|
| 343 |
+
output_text = f"⚠️ {res}"
|
| 344 |
+
else:
|
| 345 |
+
output_text = (res.get("data") if isinstance(res, dict) else "") or ""
|
| 346 |
+
logger.info(f"{name} done. chars={len(output_text)}")
|
| 347 |
+
|
| 348 |
+
if name == "Gemini":
|
| 349 |
+
gemini_resp = output_text or ""
|
| 350 |
+
elif name == "ChatGPT":
|
| 351 |
+
chatgpt_resp = output_text or ""
|
| 352 |
+
|
| 353 |
+
return chatgpt_resp, gemini_resp
|
| 354 |
+
|
| 355 |
+
async def process_multiple(
|
| 356 |
+
self,
|
| 357 |
+
jd_message: str,
|
| 358 |
+
files: Optional[List],
|
| 359 |
+
prompt_text: Optional[str] = None,
|
| 360 |
+
) -> Tuple[str, str]:
|
| 361 |
+
logger.info("Processing multiple resumes against JD")
|
| 362 |
+
if not files:
|
| 363 |
+
return "", "Please upload one or more resumes (PDF/DOCX)."
|
| 364 |
+
|
| 365 |
+
combined_resumes: List[str] = []
|
| 366 |
+
for f in files:
|
| 367 |
+
global current_task_cancelled
|
| 368 |
+
if current_task_cancelled:
|
| 369 |
+
return "⚠️ Cancelled", "⚠️ Cancelled"
|
| 370 |
+
file_path = f.name if hasattr(f, "name") else str(f)
|
| 371 |
+
file_type, resume_content = extract_resume(file_path)
|
| 372 |
+
if file_type == "error":
|
| 373 |
+
combined_resumes.append(
|
| 374 |
+
f"⚠️ Could not extract {os.path.basename(file_path)}: {resume_content}"
|
| 375 |
+
)
|
| 376 |
+
else:
|
| 377 |
+
combined_resumes.append(
|
| 378 |
+
f"=== Resume: {os.path.basename(file_path)} ===\n{clean_text(resume_content)}"
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
resume_text = "\n\n".join(combined_resumes)
|
| 382 |
+
jd_text = clean_text(jd_message)
|
| 383 |
+
|
| 384 |
+
if prompt_text and prompt_text.strip():
|
| 385 |
+
ats_prompt = (
|
| 386 |
+
f"{prompt_text.strip()}\n\n### JOB DESCRIPTION\n{jd_text}\n\n"
|
| 387 |
+
f"### CANDIDATE RESUMES\n{resume_text}"
|
| 388 |
+
)
|
| 389 |
+
else:
|
| 390 |
+
ats_prompt = build_matcher_prompt(resume_text, jd_text)
|
| 391 |
+
|
| 392 |
+
return await self._run_dual_llm(ats_prompt)
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# -------------------------
|
| 396 |
+
# JD Extract
|
| 397 |
+
# -------------------------
|
| 398 |
+
def extract_jd(file_path: str) -> str:
|
| 399 |
+
file_type, text = extract_resume(file_path)
|
| 400 |
+
if file_type == "error":
|
| 401 |
+
logger.warning("JD extraction failed")
|
| 402 |
+
return ""
|
| 403 |
+
return clean_text(text)
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
# -------------------------
|
| 407 |
+
# Recruiter desk UI (must be built inside root gr.Blocks — no nested Blocks)
|
| 408 |
+
# -------------------------
|
| 409 |
+
def build_recruiter_desk(chat: ResumeATSChat) -> None:
|
| 410 |
+
logger.info("Creating Recruiter desk UI")
|
| 411 |
+
|
| 412 |
+
async def analyze(jd_text, resume_files, jd_file, prompt_text):
|
| 413 |
+
logger.info("Recruiter: analyze triggered")
|
| 414 |
+
|
| 415 |
+
jd_content = ""
|
| 416 |
+
if jd_file:
|
| 417 |
+
jd_content = extract_jd(
|
| 418 |
+
jd_file.name if hasattr(jd_file, "name") else str(jd_file)
|
| 419 |
+
)
|
| 420 |
+
elif jd_text:
|
| 421 |
+
jd_content = clean_text(jd_text)
|
| 422 |
+
|
| 423 |
+
if not jd_content:
|
| 424 |
+
yield (
|
| 425 |
+
"Please upload or paste a Job Description.",
|
| 426 |
+
"Please upload or paste a Job Description.",
|
| 427 |
+
)
|
| 428 |
+
return
|
| 429 |
+
|
| 430 |
+
if not resume_files:
|
| 431 |
+
yield (
|
| 432 |
+
"Please upload one or more resumes (PDF/DOCX).",
|
| 433 |
+
"Please upload one or more resumes (PDF/DOCX).",
|
| 434 |
+
)
|
| 435 |
+
return
|
| 436 |
+
|
| 437 |
+
yield "Analyzing resumes, please wait...", "Analyzing resumes, please wait..."
|
| 438 |
+
|
| 439 |
+
chatgpt_resp, gemini_resp = await chat.process_multiple(
|
| 440 |
+
jd_content, resume_files, prompt_text
|
| 441 |
+
)
|
| 442 |
+
yield chatgpt_resp, gemini_resp
|
| 443 |
+
|
| 444 |
+
async def analyze_wrapper(jd_text, resume_files, jd_file, prompt_text):
|
| 445 |
+
global current_task, current_task_cancelled
|
| 446 |
+
current_task_cancelled = False
|
| 447 |
+
current_task = analyze(jd_text, resume_files, jd_file, prompt_text)
|
| 448 |
+
try:
|
| 449 |
+
async for result in current_task:
|
| 450 |
+
if current_task_cancelled:
|
| 451 |
+
yield "⚠️ Cancelled", "⚠️ Cancelled"
|
| 452 |
+
break
|
| 453 |
+
yield result
|
| 454 |
+
except asyncio.CancelledError:
|
| 455 |
+
yield "⚠️ Cancelled", "⚠️ Cancelled"
|
| 456 |
+
finally:
|
| 457 |
+
current_task = None
|
| 458 |
+
|
| 459 |
+
async def cancel_analysis():
|
| 460 |
+
global current_task_cancelled
|
| 461 |
+
current_task_cancelled = True
|
| 462 |
+
logger.info("Recruiter: cancel requested")
|
| 463 |
+
return "⚠️ Cancelled", "⚠️ Cancelled"
|
| 464 |
+
|
| 465 |
+
gr.Markdown("## AI Recruiter Desk — JD ↔ Resume Evaluator")
|
| 466 |
+
|
| 467 |
+
with gr.Row():
|
| 468 |
+
with gr.Column(scale=2):
|
| 469 |
+
jd_text = gr.Textbox(
|
| 470 |
+
label="Paste Job Description (text)",
|
| 471 |
+
placeholder="Paste JD here...",
|
| 472 |
+
lines=10,
|
| 473 |
+
)
|
| 474 |
+
prompt_text = gr.Textbox(
|
| 475 |
+
label="Prompt template override (optional)",
|
| 476 |
+
placeholder="Optional custom recruiter prompt...",
|
| 477 |
+
lines=8,
|
| 478 |
+
)
|
| 479 |
+
with gr.Column(scale=2):
|
| 480 |
+
jd_file = gr.File(
|
| 481 |
+
label="Job Description file (PDF/DOCX)",
|
| 482 |
+
file_types=[".pdf", ".docx"],
|
| 483 |
+
)
|
| 484 |
+
resume_files = gr.File(
|
| 485 |
+
label="Candidate resumes (PDF/DOCX, multiple)",
|
| 486 |
+
file_types=[".pdf", ".docx"],
|
| 487 |
+
file_count="multiple",
|
| 488 |
+
)
|
| 489 |
+
with gr.Row():
|
| 490 |
+
analyze_btn = gr.Button("Analyze", variant="primary")
|
| 491 |
+
cancel_btn = gr.Button("Cancel", variant="stop")
|
| 492 |
+
|
| 493 |
+
with gr.Row():
|
| 494 |
+
with gr.Column():
|
| 495 |
+
gr.Markdown("### ChatGPT")
|
| 496 |
+
recruiter_chatgpt = gr.Markdown(buttons=["copy"])
|
| 497 |
+
with gr.Column():
|
| 498 |
+
gr.Markdown("### Gemini")
|
| 499 |
+
recruiter_gemini = gr.Markdown(buttons=["copy"])
|
| 500 |
+
|
| 501 |
+
analyze_btn.click(
|
| 502 |
+
analyze_wrapper,
|
| 503 |
+
inputs=[jd_text, resume_files, jd_file, prompt_text],
|
| 504 |
+
outputs=[recruiter_chatgpt, recruiter_gemini],
|
| 505 |
+
show_progress="full",
|
| 506 |
+
)
|
| 507 |
+
cancel_btn.click(
|
| 508 |
+
cancel_analysis,
|
| 509 |
+
inputs=[],
|
| 510 |
+
outputs=[recruiter_chatgpt, recruiter_gemini],
|
| 511 |
+
show_progress="full",
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
# -------------------------
|
| 516 |
+
# Writer desk UI (must be built inside root gr.Blocks — no nested Blocks)
|
| 517 |
+
# -------------------------
|
| 518 |
+
def build_writer_desk(chat: ResumeATSChat) -> None:
|
| 519 |
+
logger.info("Creating Writer desk UI")
|
| 520 |
+
|
| 521 |
+
async def analyze_writer(preset_choice, user_custom_prompt, uploaded_files):
|
| 522 |
+
global current_task_cancelled
|
| 523 |
+
logger.info("Writer: analyze triggered")
|
| 524 |
+
|
| 525 |
+
ok_combo, preset_and_custom = build_writer_user_prompt(
|
| 526 |
+
preset_choice, user_custom_prompt
|
| 527 |
+
)
|
| 528 |
+
if not ok_combo:
|
| 529 |
+
yield (
|
| 530 |
+
"Select a preset and/or fill the user prompt.",
|
| 531 |
+
"Select a preset and/or fill the user prompt.",
|
| 532 |
+
"",
|
| 533 |
+
"",
|
| 534 |
+
"",
|
| 535 |
+
"",
|
| 536 |
+
)
|
| 537 |
+
return
|
| 538 |
+
|
| 539 |
+
if not uploaded_files:
|
| 540 |
+
yield (
|
| 541 |
+
"Upload one or more source files (DOC, DOCX, PDF, XLS, XLSX).",
|
| 542 |
+
"Upload one or more source files (DOC, DOCX, PDF, XLS, XLSX).",
|
| 543 |
+
"",
|
| 544 |
+
"",
|
| 545 |
+
"",
|
| 546 |
+
"",
|
| 547 |
+
)
|
| 548 |
+
return
|
| 549 |
+
|
| 550 |
+
yield "Running Writer desk analysis...", "Running Writer desk analysis...", "", "", "", ""
|
| 551 |
+
|
| 552 |
+
doc_chunks: List[str] = []
|
| 553 |
+
for f in uploaded_files:
|
| 554 |
+
if current_task_cancelled:
|
| 555 |
+
yield "⚠️ Cancelled", "⚠️ Cancelled"
|
| 556 |
+
return
|
| 557 |
+
file_path = f.name if hasattr(f, "name") else str(f)
|
| 558 |
+
kind, content = extract_writer_document(file_path)
|
| 559 |
+
base = os.path.basename(file_path)
|
| 560 |
+
if kind == "error":
|
| 561 |
+
doc_chunks.append(f"=== File: {base} ===\n{content}")
|
| 562 |
+
else:
|
| 563 |
+
doc_chunks.append(f"=== File: {base} ({kind}) ===\n{clean_text(content)}")
|
| 564 |
+
|
| 565 |
+
documents_blob = "\n\n".join(doc_chunks)
|
| 566 |
+
final_prompt = truncate_tokens(
|
| 567 |
+
f"{preset_and_custom}\n\n### UPLOADED SOURCE DOCUMENTS\n{documents_blob}",
|
| 568 |
+
max_chars=WRITER_COMBINED_MAX_CHARS,
|
| 569 |
+
)
|
| 570 |
+
|
| 571 |
+
chatgpt_resp, gemini_resp = await chat._run_dual_llm(final_prompt)
|
| 572 |
+
yield (
|
| 573 |
+
chatgpt_resp,
|
| 574 |
+
gemini_resp,
|
| 575 |
+
preset_and_custom,
|
| 576 |
+
documents_blob,
|
| 577 |
+
chatgpt_resp,
|
| 578 |
+
gemini_resp,
|
| 579 |
+
)
|
| 580 |
+
|
| 581 |
+
async def analyze_writer_wrapper(preset_choice, user_custom_prompt, uploaded_files):
|
| 582 |
+
global current_task, current_task_cancelled
|
| 583 |
+
current_task_cancelled = False
|
| 584 |
+
current_task = analyze_writer(preset_choice, user_custom_prompt, uploaded_files)
|
| 585 |
+
try:
|
| 586 |
+
async for chunk in current_task:
|
| 587 |
+
if current_task_cancelled:
|
| 588 |
+
yield "⚠️ Cancelled", "⚠️ Cancelled", "", "", "", ""
|
| 589 |
+
break
|
| 590 |
+
yield chunk
|
| 591 |
+
except asyncio.CancelledError:
|
| 592 |
+
yield "⚠️ Cancelled", "⚠️ Cancelled", "", "", "", ""
|
| 593 |
+
finally:
|
| 594 |
+
current_task = None
|
| 595 |
+
|
| 596 |
+
async def cancel_writer(
|
| 597 |
+
base_prompt_memory, documents_memory, chatgpt_memory, gemini_memory
|
| 598 |
+
):
|
| 599 |
+
global current_task_cancelled
|
| 600 |
+
current_task_cancelled = True
|
| 601 |
+
logger.info("Writer: cancel requested")
|
| 602 |
+
return (
|
| 603 |
+
"⚠️ Cancelled",
|
| 604 |
+
"⚠️ Cancelled",
|
| 605 |
+
base_prompt_memory,
|
| 606 |
+
documents_memory,
|
| 607 |
+
chatgpt_memory,
|
| 608 |
+
gemini_memory,
|
| 609 |
+
)
|
| 610 |
+
|
| 611 |
+
def _followup_action_instruction(action: str) -> str:
|
| 612 |
+
action = (action or "").strip()
|
| 613 |
+
if action == "Continue":
|
| 614 |
+
return (
|
| 615 |
+
"Continue from where the prior answer stopped. Do not restart from the beginning. "
|
| 616 |
+
"Preserve section numbering and structure, and complete all missing remaining sections."
|
| 617 |
+
)
|
| 618 |
+
if action == "Reformat":
|
| 619 |
+
return (
|
| 620 |
+
"Reformat the answer to be rigid and consistent. Use clear headings, stable section order, "
|
| 621 |
+
"and markdown tables where applicable. Keep all factual content grounded in source docs."
|
| 622 |
+
)
|
| 623 |
+
if action == "Fill Missing Fields":
|
| 624 |
+
return (
|
| 625 |
+
"Fill only missing, incomplete, or placeholder fields. Keep completed fields unchanged. "
|
| 626 |
+
"For unknown items, explicitly write 'Not specified in documents' and cite source location if available."
|
| 627 |
+
)
|
| 628 |
+
if action == "Cite Sources":
|
| 629 |
+
return (
|
| 630 |
+
"Provide explicit source citations for each claim or table row using file name and page/section/sheet "
|
| 631 |
+
"where possible. If unavailable, mark citation as 'Source not identifiable from extracted text'."
|
| 632 |
+
)
|
| 633 |
+
return "Improve and complete the response based on the follow-up request."
|
| 634 |
+
|
| 635 |
+
async def run_writer_followup(
|
| 636 |
+
target_model,
|
| 637 |
+
action_choice,
|
| 638 |
+
followup_instruction,
|
| 639 |
+
base_prompt_memory,
|
| 640 |
+
documents_memory,
|
| 641 |
+
chatgpt_memory,
|
| 642 |
+
gemini_memory,
|
| 643 |
+
):
|
| 644 |
+
global current_task_cancelled
|
| 645 |
+
current_task_cancelled = False
|
| 646 |
+
|
| 647 |
+
if not (base_prompt_memory and documents_memory):
|
| 648 |
+
return (
|
| 649 |
+
"Run Analyze first so follow-up has context memory.",
|
| 650 |
+
"Run Analyze first so follow-up has context memory.",
|
| 651 |
+
base_prompt_memory,
|
| 652 |
+
documents_memory,
|
| 653 |
+
chatgpt_memory,
|
| 654 |
+
gemini_memory,
|
| 655 |
+
)
|
| 656 |
+
|
| 657 |
+
if not ((followup_instruction or "").strip() or (action_choice or "").strip()):
|
| 658 |
+
return (
|
| 659 |
+
"Choose a follow-up action and/or add instructions.",
|
| 660 |
+
"Choose a follow-up action and/or add instructions.",
|
| 661 |
+
base_prompt_memory,
|
| 662 |
+
documents_memory,
|
| 663 |
+
chatgpt_memory,
|
| 664 |
+
gemini_memory,
|
| 665 |
+
)
|
| 666 |
+
|
| 667 |
+
action_directive = _followup_action_instruction(action_choice)
|
| 668 |
+
custom_followup = (followup_instruction or "").strip()
|
| 669 |
+
|
| 670 |
+
def build_model_followup_prompt(model_name: str, model_previous_output: str) -> str:
|
| 671 |
+
return truncate_tokens(
|
| 672 |
+
(
|
| 673 |
+
"You are refining your own prior Writer Desk output.\n\n"
|
| 674 |
+
f"### MODEL\n{model_name}\n\n"
|
| 675 |
+
"### ORIGINAL INSTRUCTIONS\n"
|
| 676 |
+
f"{base_prompt_memory}\n\n"
|
| 677 |
+
"### ORIGINAL SOURCE DOCUMENTS\n"
|
| 678 |
+
f"{documents_memory}\n\n"
|
| 679 |
+
"### YOUR PREVIOUS OUTPUT\n"
|
| 680 |
+
f"{model_previous_output or '(none)'}\n\n"
|
| 681 |
+
"### TARGET FOLLOW-UP ACTION\n"
|
| 682 |
+
f"{action_choice or 'General follow-up'}\n\n"
|
| 683 |
+
"### ACTION DIRECTIVE\n"
|
| 684 |
+
f"{action_directive}\n\n"
|
| 685 |
+
"### USER FOLLOW-UP INSTRUCTION\n"
|
| 686 |
+
f"{custom_followup or '(none)'}\n\n"
|
| 687 |
+
"### RESPONSE RULES\n"
|
| 688 |
+
"- Return a complete, standalone response (not partial notes).\n"
|
| 689 |
+
"- Do not summarize unless explicitly requested.\n"
|
| 690 |
+
"- Preserve factual grounding to uploaded documents only.\n"
|
| 691 |
+
"- If information is missing, state 'Not specified in documents'.\n"
|
| 692 |
+
),
|
| 693 |
+
max_chars=WRITER_COMBINED_MAX_CHARS,
|
| 694 |
+
)
|
| 695 |
+
|
| 696 |
+
chatgpt_resp = chatgpt_memory or ""
|
| 697 |
+
gemini_resp = gemini_memory or ""
|
| 698 |
+
target = (target_model or "").strip()
|
| 699 |
+
|
| 700 |
+
if target == "ChatGPT":
|
| 701 |
+
if not chat.openai_available:
|
| 702 |
+
chatgpt_resp = "ChatGPT provider unavailable."
|
| 703 |
+
else:
|
| 704 |
+
chatgpt_prompt = build_model_followup_prompt("ChatGPT", chatgpt_memory)
|
| 705 |
+
res = await chat._analyze_with_openai(chatgpt_prompt)
|
| 706 |
+
chatgpt_resp = (res.get("data") if isinstance(res, dict) else "") or ""
|
| 707 |
+
elif target == "Gemini":
|
| 708 |
+
if not chat.gemini_available:
|
| 709 |
+
gemini_resp = "Gemini provider unavailable."
|
| 710 |
+
else:
|
| 711 |
+
gemini_prompt = build_model_followup_prompt("Gemini", gemini_memory)
|
| 712 |
+
res = await chat._analyze_with_gemini(gemini_prompt)
|
| 713 |
+
gemini_resp = (res.get("data") if isinstance(res, dict) else "") or ""
|
| 714 |
+
else:
|
| 715 |
+
return (
|
| 716 |
+
"Invalid follow-up target model.",
|
| 717 |
+
"Invalid follow-up target model.",
|
| 718 |
+
base_prompt_memory,
|
| 719 |
+
documents_memory,
|
| 720 |
+
chatgpt_memory,
|
| 721 |
+
gemini_memory,
|
| 722 |
+
)
|
| 723 |
+
|
| 724 |
+
return (
|
| 725 |
+
chatgpt_resp,
|
| 726 |
+
gemini_resp,
|
| 727 |
+
base_prompt_memory,
|
| 728 |
+
documents_memory,
|
| 729 |
+
chatgpt_resp,
|
| 730 |
+
gemini_resp,
|
| 731 |
+
)
|
| 732 |
+
|
| 733 |
+
preset_default = (
|
| 734 |
+
WRITER_PRESET_LABELS[1]
|
| 735 |
+
if len(WRITER_PRESET_LABELS) > 1
|
| 736 |
+
else WRITER_PRESET_LABELS[0]
|
| 737 |
+
)
|
| 738 |
+
|
| 739 |
+
gr.Markdown("## AI Writer Desk — presets + uploads")
|
| 740 |
+
|
| 741 |
+
preset_choice = gr.Dropdown(
|
| 742 |
+
choices=list(WRITER_PRESET_LABELS),
|
| 743 |
+
value=preset_default,
|
| 744 |
+
label="Prompt preset",
|
| 745 |
+
)
|
| 746 |
+
user_custom_prompt = gr.Textbox(
|
| 747 |
+
label="Additional / full custom prompt",
|
| 748 |
+
placeholder="Add instructions here, or use “(none — use user prompt below only)” and paste a full prompt.",
|
| 749 |
+
lines=12,
|
| 750 |
+
)
|
| 751 |
+
uploaded_files = gr.File(
|
| 752 |
+
label="Source documents (DOC, DOCX, PDF, XLS, XLSX — multiple allowed)",
|
| 753 |
+
file_types=[".doc", ".docx", ".pdf", ".xls", ".xlsx", ".xlsm"],
|
| 754 |
+
file_count="multiple",
|
| 755 |
+
)
|
| 756 |
+
with gr.Row():
|
| 757 |
+
go_btn = gr.Button("Analyze", variant="primary")
|
| 758 |
+
stop_btn = gr.Button("Cancel", variant="stop")
|
| 759 |
+
|
| 760 |
+
with gr.Row():
|
| 761 |
+
with gr.Column():
|
| 762 |
+
gr.Markdown("### ChatGPT")
|
| 763 |
+
writer_chatgpt = gr.Markdown(buttons=["copy"])
|
| 764 |
+
with gr.Column():
|
| 765 |
+
gr.Markdown("### Gemini")
|
| 766 |
+
writer_gemini = gr.Markdown(buttons=["copy"])
|
| 767 |
+
|
| 768 |
+
gr.Markdown("### Follow-up refinement (stateful, per model)")
|
| 769 |
+
with gr.Row():
|
| 770 |
+
with gr.Column():
|
| 771 |
+
gr.Markdown("#### ChatGPT follow-up")
|
| 772 |
+
chatgpt_followup_action = gr.Dropdown(
|
| 773 |
+
choices=["Continue", "Reformat", "Fill Missing Fields", "Cite Sources"],
|
| 774 |
+
value="Continue",
|
| 775 |
+
label="ChatGPT follow-up action",
|
| 776 |
+
)
|
| 777 |
+
chatgpt_followup_instruction = gr.Textbox(
|
| 778 |
+
label="ChatGPT follow-up instruction",
|
| 779 |
+
placeholder="Example: Continue from row 57 onward and keep strict markdown table format.",
|
| 780 |
+
lines=4,
|
| 781 |
+
)
|
| 782 |
+
chatgpt_followup_btn = gr.Button("Run ChatGPT Follow-up", variant="secondary")
|
| 783 |
+
with gr.Column():
|
| 784 |
+
gr.Markdown("#### Gemini follow-up")
|
| 785 |
+
gemini_followup_action = gr.Dropdown(
|
| 786 |
+
choices=["Continue", "Reformat", "Fill Missing Fields", "Cite Sources"],
|
| 787 |
+
value="Continue",
|
| 788 |
+
label="Gemini follow-up action",
|
| 789 |
+
)
|
| 790 |
+
gemini_followup_instruction = gr.Textbox(
|
| 791 |
+
label="Gemini follow-up instruction",
|
| 792 |
+
placeholder="Example: Reformat output into strict markdown tables with citations.",
|
| 793 |
+
lines=4,
|
| 794 |
+
)
|
| 795 |
+
gemini_followup_btn = gr.Button("Run Gemini Follow-up", variant="secondary")
|
| 796 |
+
|
| 797 |
+
writer_base_prompt_memory = gr.State("")
|
| 798 |
+
writer_documents_memory = gr.State("")
|
| 799 |
+
writer_chatgpt_memory = gr.State("")
|
| 800 |
+
writer_gemini_memory = gr.State("")
|
| 801 |
+
followup_target_chatgpt = gr.State("ChatGPT")
|
| 802 |
+
followup_target_gemini = gr.State("Gemini")
|
| 803 |
+
|
| 804 |
+
go_btn.click(
|
| 805 |
+
analyze_writer_wrapper,
|
| 806 |
+
inputs=[preset_choice, user_custom_prompt, uploaded_files],
|
| 807 |
+
outputs=[
|
| 808 |
+
writer_chatgpt,
|
| 809 |
+
writer_gemini,
|
| 810 |
+
writer_base_prompt_memory,
|
| 811 |
+
writer_documents_memory,
|
| 812 |
+
writer_chatgpt_memory,
|
| 813 |
+
writer_gemini_memory,
|
| 814 |
+
],
|
| 815 |
+
show_progress="full",
|
| 816 |
+
)
|
| 817 |
+
stop_btn.click(
|
| 818 |
+
cancel_writer,
|
| 819 |
+
inputs=[
|
| 820 |
+
writer_base_prompt_memory,
|
| 821 |
+
writer_documents_memory,
|
| 822 |
+
writer_chatgpt_memory,
|
| 823 |
+
writer_gemini_memory,
|
| 824 |
+
],
|
| 825 |
+
outputs=[
|
| 826 |
+
writer_chatgpt,
|
| 827 |
+
writer_gemini,
|
| 828 |
+
writer_base_prompt_memory,
|
| 829 |
+
writer_documents_memory,
|
| 830 |
+
writer_chatgpt_memory,
|
| 831 |
+
writer_gemini_memory,
|
| 832 |
+
],
|
| 833 |
+
show_progress="full",
|
| 834 |
+
)
|
| 835 |
+
chatgpt_followup_btn.click(
|
| 836 |
+
run_writer_followup,
|
| 837 |
+
inputs=[
|
| 838 |
+
followup_target_chatgpt,
|
| 839 |
+
chatgpt_followup_action,
|
| 840 |
+
chatgpt_followup_instruction,
|
| 841 |
+
writer_base_prompt_memory,
|
| 842 |
+
writer_documents_memory,
|
| 843 |
+
writer_chatgpt_memory,
|
| 844 |
+
writer_gemini_memory,
|
| 845 |
+
],
|
| 846 |
+
outputs=[
|
| 847 |
+
writer_chatgpt,
|
| 848 |
+
writer_gemini,
|
| 849 |
+
writer_base_prompt_memory,
|
| 850 |
+
writer_documents_memory,
|
| 851 |
+
writer_chatgpt_memory,
|
| 852 |
+
writer_gemini_memory,
|
| 853 |
+
],
|
| 854 |
+
show_progress="full",
|
| 855 |
+
)
|
| 856 |
+
gemini_followup_btn.click(
|
| 857 |
+
run_writer_followup,
|
| 858 |
+
inputs=[
|
| 859 |
+
followup_target_gemini,
|
| 860 |
+
gemini_followup_action,
|
| 861 |
+
gemini_followup_instruction,
|
| 862 |
+
writer_base_prompt_memory,
|
| 863 |
+
writer_documents_memory,
|
| 864 |
+
writer_chatgpt_memory,
|
| 865 |
+
writer_gemini_memory,
|
| 866 |
+
],
|
| 867 |
+
outputs=[
|
| 868 |
+
writer_chatgpt,
|
| 869 |
+
writer_gemini,
|
| 870 |
+
writer_base_prompt_memory,
|
| 871 |
+
writer_documents_memory,
|
| 872 |
+
writer_chatgpt_memory,
|
| 873 |
+
writer_gemini_memory,
|
| 874 |
+
],
|
| 875 |
+
show_progress="full",
|
| 876 |
+
)
|
| 877 |
+
|
| 878 |
+
|
| 879 |
+
# -------------------------
|
| 880 |
+
# Login shell
|
| 881 |
+
# -------------------------
|
| 882 |
+
def create_app_with_login():
|
| 883 |
+
logger.info("Creating landing + dual-login")
|
| 884 |
+
llm_bridge = ResumeATSChat()
|
| 885 |
+
|
| 886 |
+
demo = gr.Blocks(title="DualMind AI Desks")
|
| 887 |
+
|
| 888 |
+
with demo:
|
| 889 |
+
gr.Markdown("# DualMind AI Desks")
|
| 890 |
+
# One login column + one workspace column (avoid toggling multiple top-level
|
| 891 |
+
# Columns; Gradio's client can leave a blank main area after desk switches.)
|
| 892 |
+
login_ui = gr.Column(visible=True)
|
| 893 |
+
post_login = gr.Column(visible=False)
|
| 894 |
+
|
| 895 |
+
with login_ui:
|
| 896 |
+
gr.Markdown("## Desk login")
|
| 897 |
+
desk_choice = gr.Radio(
|
| 898 |
+
choices=["AI Recruiter Desk", "AI Writer Desk"],
|
| 899 |
+
label="Choose desk",
|
| 900 |
+
value="AI Recruiter Desk",
|
| 901 |
+
)
|
| 902 |
+
username = gr.Textbox(label="Username")
|
| 903 |
+
password = gr.Textbox(label="Password", type="password")
|
| 904 |
+
login_button = gr.Button("Login", variant="primary")
|
| 905 |
+
login_status = gr.Markdown()
|
| 906 |
+
|
| 907 |
+
with post_login:
|
| 908 |
+
back_btn = gr.Button("← Back to login", variant="secondary")
|
| 909 |
+
recruiter_only = gr.Column(visible=False)
|
| 910 |
+
writer_only = gr.Column(visible=False)
|
| 911 |
+
with recruiter_only:
|
| 912 |
+
build_recruiter_desk(llm_bridge)
|
| 913 |
+
with writer_only:
|
| 914 |
+
build_writer_desk(llm_bridge)
|
| 915 |
+
|
| 916 |
+
shell_outputs = [
|
| 917 |
+
login_ui,
|
| 918 |
+
post_login,
|
| 919 |
+
recruiter_only,
|
| 920 |
+
writer_only,
|
| 921 |
+
login_status,
|
| 922 |
+
]
|
| 923 |
+
|
| 924 |
+
def go_to_login():
|
| 925 |
+
global current_task_cancelled
|
| 926 |
+
current_task_cancelled = True
|
| 927 |
+
t = current_task
|
| 928 |
+
if t is not None and isinstance(t, asyncio.Task) and not t.done():
|
| 929 |
+
t.cancel()
|
| 930 |
+
logger.info("User returned to login screen")
|
| 931 |
+
return (
|
| 932 |
+
gr.update(visible=True),
|
| 933 |
+
gr.update(visible=False),
|
| 934 |
+
gr.update(visible=False),
|
| 935 |
+
gr.update(visible=False),
|
| 936 |
+
"Signed out. Choose a desk and sign in again.",
|
| 937 |
+
)
|
| 938 |
+
|
| 939 |
+
def handle_login(desk_name: str, user: str, pw: str):
|
| 940 |
+
logger.info(f"Login attempt for desk={desk_name} user={user!r}")
|
| 941 |
+
recruiter_ok = user == RECRUITER_USERNAME and pw == RECRUITER_PASSWORD
|
| 942 |
+
writer_ok = user == WRITER_USERNAME and pw == WRITER_PASSWORD
|
| 943 |
+
|
| 944 |
+
hidden_inner = (gr.update(visible=False), gr.update(visible=False))
|
| 945 |
+
|
| 946 |
+
if desk_name == "AI Recruiter Desk" and recruiter_ok:
|
| 947 |
+
logger.info("Recruiter login OK")
|
| 948 |
+
return (
|
| 949 |
+
gr.update(visible=False),
|
| 950 |
+
gr.update(visible=True),
|
| 951 |
+
gr.update(visible=True),
|
| 952 |
+
gr.update(visible=False),
|
| 953 |
+
"Login successful.",
|
| 954 |
+
)
|
| 955 |
+
if desk_name == "AI Writer Desk" and writer_ok:
|
| 956 |
+
logger.info("Writer login OK")
|
| 957 |
+
return (
|
| 958 |
+
gr.update(visible=False),
|
| 959 |
+
gr.update(visible=True),
|
| 960 |
+
gr.update(visible=False),
|
| 961 |
+
gr.update(visible=True),
|
| 962 |
+
"Login successful.",
|
| 963 |
+
)
|
| 964 |
+
|
| 965 |
+
logger.warning("Login failed")
|
| 966 |
+
return (
|
| 967 |
+
gr.update(visible=True),
|
| 968 |
+
gr.update(visible=False),
|
| 969 |
+
hidden_inner[0],
|
| 970 |
+
hidden_inner[1],
|
| 971 |
+
"Invalid credentials or wrong desk.",
|
| 972 |
+
)
|
| 973 |
+
|
| 974 |
+
login_button.click(
|
| 975 |
+
handle_login,
|
| 976 |
+
inputs=[desk_choice, username, password],
|
| 977 |
+
outputs=shell_outputs,
|
| 978 |
+
)
|
| 979 |
+
|
| 980 |
+
# Full reload clears a Gradio client bug where the main area stays blank
|
| 981 |
+
# after logging out of one desk and into another.
|
| 982 |
+
back_btn.click(
|
| 983 |
+
go_to_login,
|
| 984 |
+
inputs=[],
|
| 985 |
+
outputs=shell_outputs,
|
| 986 |
+
js="() => setTimeout(() => window.location.reload(), 400)",
|
| 987 |
+
)
|
| 988 |
+
|
| 989 |
+
demo.queue()
|
| 990 |
+
return demo
|
| 991 |
+
|
| 992 |
+
|
| 993 |
+
if __name__ == "__main__":
|
| 994 |
+
logger.info("Launching Gradio server with login...")
|
| 995 |
+
demo = create_app_with_login()
|
| 996 |
+
demo.launch(server_name="0.0.0.0", server_port=7860, share=True, debug=True)
|
| 997 |
+
logger.info("===== Application Shutdown =====")
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.29.0
|
| 2 |
+
pillow>=10.0.0
|
| 3 |
+
pymupdf>=1.23.5
|
| 4 |
+
python-docx>=1.0.1
|
| 5 |
+
pandas>=2.2.2
|
| 6 |
+
openpyxl>=3.1.2
|
| 7 |
+
xlrd>=2.0.1
|
| 8 |
+
google-generativeai>=0.5.4
|
| 9 |
+
openai>=1.30.1
|
| 10 |
+
python-dotenv>=1.0.1
|
| 11 |
+
markdown==3.10.1
|
| 12 |
+
pywin32>=306; sys_platform == "win32"
|
writer_prompts.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Writer Desk presets loaded from WRITERS_DESK_PROMPTS.xlsx (edit Excel to add/remove prompts)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Dict, Tuple
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger("ATSApp")
|
| 12 |
+
|
| 13 |
+
PROMPTS_XLSX = Path(__file__).resolve().parent / "WRITERS_DESK_PROMPTS.xlsx"
|
| 14 |
+
NONE_PRESET_LABEL = "(none — use user prompt below only)"
|
| 15 |
+
|
| 16 |
+
_TITLE_COL = "Prompt_Title"
|
| 17 |
+
_CONTENT_COL = "Prompt_Content"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def load_writer_presets_from_excel(
|
| 21 |
+
xlsx_path: Path | None = None,
|
| 22 |
+
) -> Tuple[Tuple[str, ...], Dict[str, str]]:
|
| 23 |
+
path = xlsx_path or PROMPTS_XLSX
|
| 24 |
+
preset_map: Dict[str, str] = {NONE_PRESET_LABEL: ""}
|
| 25 |
+
|
| 26 |
+
if not path.is_file():
|
| 27 |
+
logger.error("Writer presets Excel not found: %s", path)
|
| 28 |
+
return (NONE_PRESET_LABEL,), preset_map
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
df = pd.read_excel(path, engine="openpyxl")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.error("Failed to read Writer presets Excel %s: %s", path, e, exc_info=True)
|
| 34 |
+
return (NONE_PRESET_LABEL,), preset_map
|
| 35 |
+
|
| 36 |
+
if _TITLE_COL not in df.columns or _CONTENT_COL not in df.columns:
|
| 37 |
+
logger.error(
|
| 38 |
+
"Writer presets Excel must have columns %r and %r; got %s",
|
| 39 |
+
_TITLE_COL,
|
| 40 |
+
_CONTENT_COL,
|
| 41 |
+
list(df.columns),
|
| 42 |
+
)
|
| 43 |
+
return (NONE_PRESET_LABEL,), preset_map
|
| 44 |
+
|
| 45 |
+
labels: list[str] = [NONE_PRESET_LABEL]
|
| 46 |
+
for _, row in df.iterrows():
|
| 47 |
+
title = str(row[_TITLE_COL]).strip()
|
| 48 |
+
if not title or title in preset_map:
|
| 49 |
+
continue
|
| 50 |
+
content = row[_CONTENT_COL]
|
| 51 |
+
body = "" if pd.isna(content) else str(content).strip()
|
| 52 |
+
preset_map[title] = body
|
| 53 |
+
labels.append(title)
|
| 54 |
+
|
| 55 |
+
logger.info("Loaded %d Writer desk preset(s) from %s", len(labels) - 1, path.name)
|
| 56 |
+
return tuple(labels), preset_map
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
WRITER_PRESET_LABELS, WRITER_PRESET_MAP = load_writer_presets_from_excel()
|