ElegantSolutions commited on
Commit
7ac8879
·
verified ·
1 Parent(s): 12bdc75

Upload 6 files

Browse files
Files changed (6) hide show
  1. Flowme.md +5 -0
  2. README.md +14 -0
  3. WRITERS_DESK_PROMPTS.xlsx +0 -0
  4. app.py +997 -0
  5. requirements.txt +12 -0
  6. 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()