-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdocument_reader.py
More file actions
308 lines (253 loc) · 8.83 KB
/
Copy pathdocument_reader.py
File metadata and controls
308 lines (253 loc) · 8.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
"""Local document readers exposed as provider-neutral ADK tools."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from .config import Settings, get_settings
SUPPORTED_EXTENSIONS = {".docx", ".pdf", ".txt", ".md", ".tex"}
def _is_within(path: Path, root: Path) -> bool:
"""Return True when path is inside root after both are resolved."""
try:
path.resolve().relative_to(root.resolve())
return True
except ValueError:
return False
def _resolve_local_file_path(
file_path: str,
*,
document_dir: Path,
document_label: str,
settings: Settings,
) -> Path:
"""
Resolve a document from its configured folder or the package folder.
Absolute paths are rejected unless ALLOW_ABSOLUTE_DOCUMENT_PATHS=true.
"""
cleaned = file_path.strip().strip('"').strip("'")
if not cleaned:
raise ValueError(f"No {document_label} file path was provided.")
raw_path = Path(cleaned).expanduser()
if raw_path.is_absolute():
if not settings.allow_absolute_document_paths:
raise PermissionError(
"Absolute document paths are disabled. Place the file in the "
f"{document_dir.name} folder, use a relative path, or set "
"ALLOW_ABSOLUTE_DOCUMENT_PATHS=true."
)
candidates = [raw_path]
else:
# Support both:
# base_resume.docx
# resumes/base_resume.docx
candidates = [
document_dir / raw_path,
settings.package_dir / raw_path,
]
searched: list[str] = []
for candidate in candidates:
resolved = candidate.resolve()
searched.append(str(candidate))
if not raw_path.is_absolute():
allowed_roots = (
document_dir,
settings.package_dir,
)
if not any(_is_within(resolved, root) for root in allowed_roots):
continue
if resolved.exists() and resolved.is_file():
return resolved
raise FileNotFoundError(
f"{document_label.capitalize()} not found. Place it in "
f"job_app_workflow/{document_dir.name} and provide either the "
f"filename or a path such as "
f"{document_dir.name}/example{next(iter(SUPPORTED_EXTENSIONS))}. "
f"Checked: {', '.join(searched)}"
)
def _read_docx(path: Path) -> str:
"""Extract paragraphs and table cells from a DOCX file."""
try:
from docx import Document
except ImportError as exc:
raise ImportError(
"python-docx is not installed. Run: pip install python-docx"
) from exc
document = Document(str(path))
chunks: list[str] = []
for paragraph in document.paragraphs:
text = paragraph.text.strip()
if text:
chunks.append(text)
for table in document.tables:
for row in table.rows:
values = [
cell.text.strip()
for cell in row.cells
if cell.text.strip()
]
if values:
chunks.append(" | ".join(values))
return "\n".join(chunks)
def _read_pdf(path: Path) -> str:
"""Extract text from a text-based PDF."""
try:
from pypdf import PdfReader
except ImportError as exc:
raise ImportError(
"pypdf is not installed. Run: pip install pypdf"
) from exc
reader = PdfReader(str(path))
if reader.is_encrypted:
try:
unlocked = reader.decrypt("")
except Exception as exc:
raise ValueError(
"The PDF is encrypted and could not be opened."
) from exc
if not unlocked:
raise ValueError(
"The PDF is password-protected. Save an unprotected copy."
)
chunks: list[str] = []
for page_number, page in enumerate(reader.pages, start=1):
page_text = (page.extract_text() or "").strip()
if page_text:
chunks.append(f"--- Page {page_number} ---\n{page_text}")
return "\n\n".join(chunks)
def _read_text_file(path: Path) -> str:
"""Read UTF-8 text or Markdown, replacing undecodable characters."""
return path.read_text(encoding="utf-8", errors="replace")
def _extract_document_text(path: Path) -> str:
"""Dispatch extraction according to the file extension."""
suffix = path.suffix.lower()
if suffix == ".docx":
return _read_docx(path)
if suffix == ".pdf":
return _read_pdf(path)
if suffix in {".txt", ".md", ".tex"}:
return _read_text_file(path)
raise ValueError(
f"Unsupported file type '{suffix}'. Supported types: "
".docx, .pdf, .txt, .md, and .tex. Save old .doc files as .docx or .pdf."
)
def _read_local_document(
file_path: str,
*,
document_dir: Path,
document_label: str,
output_text_key: str,
max_chars: int,
settings: Settings,
) -> dict[str, Any]:
"""Shared implementation for resume and job-posting tools."""
try:
path = _resolve_local_file_path(
file_path,
document_dir=document_dir,
document_label=document_label,
settings=settings,
)
suffix = path.suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS:
return {
"status": "error",
"document_type": document_label,
"message": (
f"Unsupported file type '{suffix}'. Supported types: "
".docx, .pdf, .txt, .md, and .tex."
),
}
file_size_mb = path.stat().st_size / 1_000_000
if file_size_mb > settings.max_local_doc_mb:
return {
"status": "error",
"document_type": document_label,
"message": (
f"The file is {file_size_mb:.2f} MB, exceeding the "
f"{settings.max_local_doc_mb:.2f} MB limit."
),
}
text = _extract_document_text(path)
if not text.strip():
return {
"status": "error",
"document_type": document_label,
"message": (
"No readable text was extracted. The PDF may be scanned "
"as an image, encrypted, or unusually formatted. Try a "
"DOCX, text-based PDF, or TXT copy."
),
}
was_truncated = len(text) > max_chars
extracted_text = text[:max_chars]
return {
"status": "success",
"document_type": document_label,
"file_name": path.name,
"file_type": suffix,
"file_size_mb": round(file_size_mb, 3),
"characters_returned": len(extracted_text),
"truncated": was_truncated,
output_text_key: extracted_text,
}
except Exception as exc:
return {
"status": "error",
"document_type": document_label,
"message": str(exc),
}
def read_resume_document(
file_path: str,
max_chars: int = 0,
) -> dict[str, Any]:
"""
Read a local resume.
Args:
file_path: Filename or relative path to a DOCX, PDF, TXT, Markdown, or LaTeX
resume. Files normally belong in job_app_workflow/resumes.
max_chars: Optional character limit. Use 0 for MAX_RESUME_CHARS.
Returns:
A dictionary containing metadata and resume_text.
"""
settings = get_settings()
character_limit = (
max_chars if max_chars > 0 else settings.max_resume_chars
)
character_limit = min(character_limit, settings.max_resume_chars)
return _read_local_document(
file_path,
document_dir=settings.resume_dir,
document_label="resume",
output_text_key="resume_text",
max_chars=character_limit,
settings=settings,
)
def read_job_posting_document(
file_path: str,
max_chars: int = 0,
) -> dict[str, Any]:
"""
Read a locally saved job posting.
Args:
file_path: Filename or relative path to a DOCX, PDF, TXT, Markdown, or LaTeX
job posting. Files normally belong in
job_app_workflow/job_postings.
max_chars: Optional character limit. Use 0 for
MAX_JOB_POSTING_CHARS.
Returns:
A dictionary containing metadata and job_posting_text.
"""
settings = get_settings()
character_limit = (
max_chars if max_chars > 0 else settings.max_job_posting_chars
)
character_limit = min(
character_limit,
settings.max_job_posting_chars,
)
return _read_local_document(
file_path,
document_dir=settings.job_posting_dir,
document_label="job posting",
output_text_key="job_posting_text",
max_chars=character_limit,
settings=settings,
)