Marthee commited on
Commit
8c4ca9e
·
verified ·
1 Parent(s): 56a515e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1294 -95
app.py CHANGED
@@ -37,6 +37,379 @@ logging.basicConfig(
37
 
38
  logger = logging.getLogger(__name__)
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  def get_toc_page_numbers(doc, max_pages_to_check=15):
41
  toc_pages = []
42
 
@@ -109,7 +482,326 @@ def openPDF(pdf_path):
109
  logger.info(f"PDF opened successfully, {len(doc)} pages")
110
  return doc
111
 
112
- def identify_headers_with_openrouter(pdf_path, model, LLM_prompt, pages_to_check=None, top_margin=0, bottom_margin=0):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  """Ask an LLM (OpenRouter) to identify headers in the document.
114
  Returns a list of dicts: {text, page, suggested_level, confidence}.
115
  The function sends plain page-line strings to the LLM (including page numbers)
@@ -117,20 +809,21 @@ def identify_headers_with_openrouter(pdf_path, model, LLM_prompt, pages_to_check
117
  """
118
  logger.info("=" * 80)
119
  logger.info("STARTING IDENTIFY_HEADERS_WITH_OPENROUTER")
120
- logger.info(f"PDF Path: {pdf_path}")
121
  logger.info(f"Model: {model}")
122
- logger.info(f"LLM Prompt: {LLM_prompt[:200]}..." if len(LLM_prompt) > 200 else f"LLM Prompt: {LLM_prompt}")
123
 
124
- doc = openPDF(pdf_path)
125
  api_key = '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'
126
  if api_key is None:
127
  api_key = os.getenv("OPENROUTER_API_KEY") or None
 
128
  model = str(model)
129
  # toc_pages = get_toc_page_numbers(doc)
130
  lines_for_prompt = []
131
- pgestoRun=20
132
  # logger.info(f"TOC pages to skip: {toc_pages}")
133
- logger.info(f"Total pages in document: {pgestoRun}")
134
 
135
  # Collect text lines from pages (skip TOC pages)
136
  total_lines = 0
@@ -140,71 +833,26 @@ def identify_headers_with_openrouter(pdf_path, model, LLM_prompt, pages_to_check
140
  # if pno in toc_pages:
141
  # logger.debug(f"Skipping TOC page {pno}")
142
  # continue
 
143
  page = doc.load_page(pno)
144
  page_height = page.rect.height
145
-
146
- text_dict = page.get_text("dict")
147
- lines_for_prompt = []
148
  lines_on_page = 0
149
-
150
- for block in text_dict.get("blocks", []):
151
- if block.get("type") != 0: # text blocks only
152
- continue
153
-
154
- for line in block.get("lines", []):
155
- spans = line.get("spans", [])
156
- if not spans:
157
- continue
158
-
159
- # Use first span to check vertical position
160
- y0 = spans[0]["bbox"][1]
161
- y1 = spans[0]['bbox'][3]
162
- # if y0 < top_margin or y1 > (page_height - bottom_margin):
163
- # continue
164
- text = " ".join(s.get('text','') for s in spans).strip()
165
- if text:
166
-
167
-
168
- # prefix with page for easier mapping back
169
- lines_for_prompt.append(f"PAGE {pno+1}: {text}")
170
- lines_on_page += 1
171
-
172
- # if lines_on_page > 0:
173
-
174
- # page = doc.load_page(pno)
175
- # page_height = page.rect.height
176
- # lines_on_page = 0
177
- # text_dict = page.get_text("dict")
178
- # lines = []
179
  # y_tolerance = 0.2 # tweak if needed (1–3 usually works)
180
- # for block in page.get_text("dict").get('blocks', []):
181
- # if block.get('type') != 0:
182
- # continue
183
- # for line in block.get('lines', []):
184
- # spans = line.get('spans', [])
185
- # if not spans:
186
- # continue
187
- # y0 = spans[0]['bbox'][1]
188
- # y1 = spans[0]['bbox'][3]
189
- # if y0 < top_margin or y1 > (page_height - bottom_margin):
190
- # continue
191
- # for s in spans:
192
- # # text,font,size,flags,color
193
- # # ArrayofTextWithFormat={'Font':s.get('font')},{'Size':s.get('size')},{'Flags':s.get('flags')},{'Color':s.get('color')},{'Text':s.get('text')}
194
-
195
- # # prefix with page for easier mapping back
196
- # text = s["text"].strip()
197
- # lines_for_prompt.append(f"PAGE {pno+1}: {text}")
198
-
199
- # # if not lines_for_prompt:
200
- # # return []
201
-
202
- # if text:
203
- # # prefix with page for easier mapping back
204
- # # lines_for_prompt.append(f"PAGE {pno+1}: {line}")
205
- # lines_on_page += 1
206
 
207
-
208
  if lines_on_page > 0:
209
  logger.debug(f"Page {pno}: collected {lines_on_page} lines")
210
  total_lines += lines_on_page
@@ -220,9 +868,8 @@ def identify_headers_with_openrouter(pdf_path, model, LLM_prompt, pages_to_check
220
  for i, line in enumerate(lines_for_prompt[:10]):
221
  logger.info(f" {i}: {line}")
222
 
223
- prompt = LLM_prompt+"\n\nLines:\n" + "\n".join(lines_for_prompt)
224
-
225
-
226
  logger.debug(f"Full prompt length: {len(prompt)} characters")
227
  # Changed: Print entire prompt, not truncated
228
  print("=" * 80)
@@ -231,12 +878,12 @@ def identify_headers_with_openrouter(pdf_path, model, LLM_prompt, pages_to_check
231
  print("=" * 80)
232
 
233
  # Also log to file
234
- # try:
235
- # with open("full_prompt.txt", "w", encoding="utf-8") as f:
236
- # f.write(prompt)
237
- # logger.info("Full prompt saved to full_prompt.txt")
238
- # except Exception as e:
239
- # logger.error(f"Could not save prompt to file: {e}")
240
 
241
  if not api_key:
242
  # No API key: return empty so caller can fallback to heuristics
@@ -244,14 +891,16 @@ def identify_headers_with_openrouter(pdf_path, model, LLM_prompt, pages_to_check
244
  return []
245
 
246
  url = "https://openrouter.ai/api/v1/chat/completions"
247
-
248
  # Build headers following the OpenRouter example
249
  headers = {
250
  "Authorization": f"Bearer {api_key}",
251
  "Content-Type": "application/json",
252
  "HTTP-Referer": os.getenv("OPENROUTER_REFERER", ""),
253
- "X-Title": os.getenv("OPENROUTER_X_TITLE", "")
 
 
254
  }
 
255
 
256
  # Log request details (without exposing full API key)
257
  logger.info(f"Making request to OpenRouter with model: {model}")
@@ -269,7 +918,9 @@ def identify_headers_with_openrouter(pdf_path, model, LLM_prompt, pages_to_check
269
  }
270
  ]
271
  }
272
-
 
 
273
  # Debug: log request body (truncated) and write raw response for inspection
274
  try:
275
  # Changed: Log full body (excluding prompt text which is already logged)
@@ -426,35 +1077,583 @@ def identify_headers_with_openrouter(pdf_path, model, LLM_prompt, pages_to_check
426
 
427
  logger.info(f"Returning {len(out)} valid header entries")
428
  return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
 
 
 
 
 
430
 
431
- def identify_headers_and_save_excel(pdf_path, model, llm_prompt):
432
- try:
433
- # 1. Get the result from your LLM function
434
- result = identify_headers_with_openrouter(pdf_path, model, llm_prompt)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
 
436
- # 2. Safety Check: If LLM failed or returned nothing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  if not result:
438
- logger.warning("No headers found or LLM failed. Creating an empty report.")
439
- df = pd.DataFrame([{"System Message": "No headers were identified by the LLM."}])
 
 
 
 
 
 
440
  else:
 
441
  df = pd.DataFrame(result)
442
-
443
- # 3. Use an Absolute Path for the output
444
- # This ensures Gradio knows exactly where the file is
 
 
 
 
 
 
 
 
 
 
445
  output_path = os.path.abspath("header_analysis_output.xlsx")
446
-
447
- # 4. Save using the engine explicitly
448
- df.to_excel(output_path, index=False, engine='openpyxl')
449
-
450
- logger.info(f"File successfully saved to {output_path}")
451
- return output_path
452
 
453
  except Exception as e:
454
  logger.error(f"Critical error in processing: {str(e)}")
455
- # Return None or a custom error message to Gradio
456
  return None
457
 
 
 
458
  # Improved launch with debug mode enabled
459
  iface = gr.Interface(
460
  fn=identify_headers_and_save_excel,
 
37
 
38
  logger = logging.getLogger(__name__)
39
 
40
+
41
+ top_margin = 70
42
+ bottom_margin = 85
43
+
44
+ def getLocation_of_header(doc, headerText, expected_page=None):
45
+ locations = []
46
+
47
+ # pages = (
48
+ # [(expected_page, doc.load_page(expected_page))]
49
+ # if expected_page is not None
50
+ # else enumerate(doc)
51
+ # )
52
+ expectedpageNorm=expected_page
53
+
54
+ page=doc[expectedpageNorm]
55
+ # for page_number, page in pages:
56
+ page_height = page.rect.height
57
+ rects = page.search_for(headerText)
58
+
59
+ for r in rects:
60
+ y = r.y0
61
+
62
+ # Skip headers in top or bottom margin
63
+ if y <= top_margin:
64
+ continue
65
+ if y >= page_height - bottom_margin:
66
+ continue
67
+
68
+ locations.append({
69
+ "headerText":headerText,
70
+ "page": expectedpageNorm,
71
+ "x": r.x0,
72
+ "y": y
73
+ })
74
+ return locations
75
+
76
+ def filter_headers_outside_toc(headers, toc_pages):
77
+ toc_pages_set = set(toc_pages)
78
+
79
+ filtered = []
80
+ for h in headers:
81
+ page = h[2]
82
+ y = h[3]
83
+
84
+ # Skip invalid / fallback headers
85
+ if page is None or y is None:
86
+ continue
87
+
88
+ # Skip headers inside TOC pages
89
+ if page in toc_pages_set:
90
+ continue
91
+
92
+ filtered.append(h)
93
+
94
+ return filtered
95
+
96
+
97
+ def headers_with_location(doc, llm_headers):
98
+ """
99
+ Converts LLM headers into:
100
+ [text, font_size, page, y, suggested_level, confidence]
101
+ Always include all headers, even if location not found.
102
+ """
103
+ headersJson = []
104
+
105
+ for h in llm_headers:
106
+ text = h["text"]
107
+ llm_page = h["page"]
108
+
109
+ # Attempt to locate the header on the page
110
+ locations = getLocation_of_header(doc, text,llm_page)
111
+
112
+ if locations:
113
+ for loc in locations:
114
+ page = doc.load_page(loc["page"])
115
+ fontsize = None
116
+
117
+ for block in page.get_text("dict")["blocks"]:
118
+ if block.get("type") != 0:
119
+ continue
120
+ for line in block.get("lines", []):
121
+ line_text = "".join(span["text"] for span in line["spans"]).strip()
122
+ if normalize(line_text) == normalize(text):
123
+ fontsize = line["spans"][0]["size"]
124
+ break
125
+ if fontsize:
126
+ break
127
+ entry = [
128
+ text,
129
+ fontsize,
130
+ loc["page"],
131
+ loc["y"],
132
+ h["suggested_level"],
133
+
134
+ ]
135
+ if entry not in headersJson:
136
+ headersJson.append(entry)
137
+ return headersJson
138
+
139
+
140
+
141
+ def build_hierarchy_from_llm(headers):
142
+ nodes = []
143
+
144
+ # -------------------------
145
+ # 1. Build nodes safely
146
+ # -------------------------
147
+ for h in headers:
148
+ # print("headerrrrrrrrrrrrrrr", h)
149
+
150
+ if len(h) < 5:
151
+ continue
152
+
153
+ text, size, page, y, level = h
154
+
155
+ if level is None:
156
+ continue
157
+
158
+ try:
159
+ level = int(level)
160
+ except Exception:
161
+ continue
162
+
163
+ node = {
164
+ "text": text,
165
+ "page": page if page is not None else -1,
166
+ "y": y if y is not None else -1,
167
+ "size": size,
168
+ "bold": False,
169
+ "color": None,
170
+ "font": None,
171
+ "children": [],
172
+ "is_numbered": is_numbered(text),
173
+ "original_size": size,
174
+ "norm_text": normalize(text),
175
+ "level": level,
176
+ }
177
+
178
+ nodes.append(node)
179
+
180
+ if not nodes:
181
+ return []
182
+
183
+ # -------------------------
184
+ # 2. Sort top-to-bottom
185
+ # -------------------------
186
+ nodes.sort(key=lambda x: (x["page"], x["y"]))
187
+
188
+ # -------------------------
189
+ # 3. NORMALIZE LEVELS
190
+ # (smallest level → 0)
191
+ # -------------------------
192
+ min_level = min(n["level"] for n in nodes)
193
+
194
+ for n in nodes:
195
+ n["level"] -= min_level
196
+
197
+ # -------------------------
198
+ # 4. Build hierarchy
199
+ # -------------------------
200
+ root = []
201
+ stack = []
202
+ added_level0 = set()
203
+
204
+ for header in nodes:
205
+ lvl = header["level"]
206
+
207
+ if lvl < 0:
208
+ continue
209
+
210
+ # De-duplicate true top-level headers
211
+ if lvl == 0:
212
+ key = (header["norm_text"], header["page"])
213
+ if key in added_level0:
214
+ continue
215
+ added_level0.add(key)
216
+
217
+ while stack and stack[-1]["level"] >= lvl:
218
+ stack.pop()
219
+
220
+ parent = stack[-1] if stack else None
221
+
222
+ if parent:
223
+ header["path"] = parent["path"] + [header["norm_text"]]
224
+ parent["children"].append(header)
225
+ else:
226
+ header["path"] = [header["norm_text"]]
227
+ root.append(header)
228
+
229
+ stack.append(header)
230
+
231
+ # -------------------------
232
+ # 5. Enforce nesting sanity
233
+ # -------------------------
234
+ def enforce_nesting(node_list, parent_level=-1):
235
+ for node in node_list:
236
+ if node["level"] <= parent_level:
237
+ node["level"] = parent_level + 1
238
+ enforce_nesting(node["children"], node["level"])
239
+
240
+ enforce_nesting(root)
241
+
242
+ # -------------------------
243
+ # 6. OPTIONAL cleanup
244
+ # (only if real level-0s exist)
245
+ # -------------------------
246
+ if any(h["level"] == 0 for h in root):
247
+ root = [
248
+ h for h in root
249
+ if not (h["level"] == 0 and not h["children"])
250
+ ]
251
+
252
+ # -------------------------
253
+ # 7. Final pass
254
+ # -------------------------
255
+ header_tree = enforce_level_hierarchy(root)
256
+
257
+ return header_tree
258
+
259
+
260
+
261
+ def get_regular_font_size_and_color(doc):
262
+ font_sizes = []
263
+ colors = []
264
+ fonts = []
265
+
266
+ # Loop through all pages
267
+ for page_num in range(len(doc)):
268
+ page = doc.load_page(page_num)
269
+ for span in page.get_text("dict")["blocks"]:
270
+ if "lines" in span:
271
+ for line in span["lines"]:
272
+ for span in line["spans"]:
273
+ font_sizes.append(span['size'])
274
+ colors.append(span['color'])
275
+ fonts.append(span['font'])
276
+
277
+ # Get the most common font size, color, and font
278
+ most_common_font_size = Counter(font_sizes).most_common(1)[0][0] if font_sizes else None
279
+ most_common_color = Counter(colors).most_common(1)[0][0] if colors else None
280
+ most_common_font = Counter(fonts).most_common(1)[0][0] if fonts else None
281
+
282
+ return most_common_font_size, most_common_color, most_common_font
283
+
284
+ def normalize_text(text):
285
+ if text is None:
286
+ return ""
287
+ return re.sub(r'\s+', ' ', text.strip().lower())
288
+
289
+ def get_spaced_text_from_spans(spans):
290
+ return normalize_text(" ".join(span["text"].strip() for span in spans))
291
+
292
+
293
+
294
+
295
+ def is_numbered(text):
296
+ return bool(re.match(r'^\d', text.strip()))
297
+
298
+ def is_similar(a, b, threshold=0.85):
299
+ return difflib.SequenceMatcher(None, a, b).ratio() > threshold
300
+
301
+ def normalize(text):
302
+ text = text.lower()
303
+ text = re.sub(r'\.{2,}', '', text) # remove long dots
304
+ text = re.sub(r'\s+', ' ', text) # replace multiple spaces with one
305
+ return text.strip()
306
+
307
+ def clean_toc_entry(toc_text):
308
+ """Remove page numbers and formatting from TOC entries"""
309
+ # Remove everything after last sequence of dots/whitespace followed by digits
310
+ return re.sub(r'[\.\s]+\d+.*$', '', toc_text).strip('. ')
311
+
312
+
313
+
314
+
315
+
316
+ def enforce_level_hierarchy(headers):
317
+ """
318
+ Ensure level 2 headers only exist under level 1 headers
319
+ and clean up any orphaned headers
320
+ """
321
+ def process_node_list(node_list, parent_level=-1):
322
+ i = 0
323
+ while i < len(node_list):
324
+ node = node_list[i]
325
+
326
+ # Remove level 2 headers that don't have a level 1 parent
327
+ if node['level'] == 2 and parent_level != 1:
328
+ node_list.pop(i)
329
+ continue
330
+
331
+ # Recursively process children
332
+ process_node_list(node['children'], node['level'])
333
+ i += 1
334
+
335
+ process_node_list(headers)
336
+ return headers
337
+
338
+
339
+
340
+
341
+ def highlight_boxes(doc, highlights, stringtowrite, fixed_width=500): # Set your desired width here
342
+ for page_num, bbox in highlights.items():
343
+ page = doc.load_page(page_num)
344
+ page_width = page.rect.width
345
+
346
+ # Get original rect for vertical coordinates
347
+ orig_rect = fitz.Rect(bbox)
348
+ rect_height = orig_rect.height
349
+ if rect_height > 30:
350
+ if orig_rect.width > 10:
351
+ # Center horizontally using fixed width
352
+ center_x = page_width / 2
353
+ new_x0 = center_x - fixed_width / 2
354
+ new_x1 = center_x + fixed_width / 2
355
+ new_rect = fitz.Rect(new_x0, orig_rect.y0, new_x1, orig_rect.y1)
356
+
357
+ # Add highlight rectangle
358
+ annot = page.add_rect_annot(new_rect)
359
+ if stringtowrite.startswith('Not'):
360
+ annot.set_colors(stroke=(0.5, 0.5, 0.5), fill=(0.5, 0.5, 0.5))
361
+ else:
362
+ annot.set_colors(stroke=(1, 1, 0), fill=(1, 1, 0))
363
+
364
+ annot.set_opacity(0.3)
365
+ annot.update()
366
+
367
+ # Add right-aligned freetext annotation inside the fixed-width box
368
+ text = '['+stringtowrite +']'
369
+ annot1 = page.add_freetext_annot(
370
+ new_rect,
371
+ text,
372
+ fontsize=15,
373
+ fontname='helv',
374
+ text_color=(1, 0, 0),
375
+ rotate=page.rotation,
376
+ align=2 # right alignment
377
+ )
378
+ annot1.update()
379
+
380
+ def get_leaf_headers_with_paths(listtoloop, path=None, output=None):
381
+ if path is None:
382
+ path = []
383
+ if output is None:
384
+ output = []
385
+ for header in listtoloop:
386
+ current_path = path + [header['text']]
387
+ if not header['children']:
388
+ if header['level'] != 0 and header['level'] != 1:
389
+ output.append((header, current_path))
390
+ else:
391
+ get_leaf_headers_with_paths(header['children'], current_path, output)
392
+ return output
393
+ # Add this helper function at the top of your code
394
+ def words_match_ratio(text1, text2):
395
+ words1 = set(text1.split())
396
+ words2 = set(text2.split())
397
+ if not words1 or not words2:
398
+ return 0.0
399
+ common_words = words1 & words2
400
+ return len(common_words) / len(words1)
401
+
402
+ def same_start_word(s1, s2):
403
+ # Split both strings into words
404
+ words1 = s1.strip().split()
405
+ words2 = s2.strip().split()
406
+
407
+ # Check if both have at least one word and compare the first ones
408
+ if words1 and words2:
409
+ return words1[0].lower() == words2[0].lower()
410
+ return False
411
+
412
+
413
  def get_toc_page_numbers(doc, max_pages_to_check=15):
414
  toc_pages = []
415
 
 
482
  logger.info(f"PDF opened successfully, {len(doc)} pages")
483
  return doc
484
 
485
+ # def identify_headers_with_openrouter(pdf_path, model, LLM_prompt, pages_to_check=None, top_margin=0, bottom_margin=0):
486
+ # """Ask an LLM (OpenRouter) to identify headers in the document.
487
+ # Returns a list of dicts: {text, page, suggested_level, confidence}.
488
+ # The function sends plain page-line strings to the LLM (including page numbers)
489
+ # and asks for a JSON array containing only header lines with suggested levels.
490
+ # """
491
+ # logger.info("=" * 80)
492
+ # logger.info("STARTING IDENTIFY_HEADERS_WITH_OPENROUTER")
493
+ # logger.info(f"PDF Path: {pdf_path}")
494
+ # logger.info(f"Model: {model}")
495
+ # logger.info(f"LLM Prompt: {LLM_prompt[:200]}..." if len(LLM_prompt) > 200 else f"LLM Prompt: {LLM_prompt}")
496
+
497
+ # doc = openPDF(pdf_path)
498
+ # api_key = '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'
499
+ # if api_key is None:
500
+ # api_key = os.getenv("OPENROUTER_API_KEY") or None
501
+ # model = str(model)
502
+ # # toc_pages = get_toc_page_numbers(doc)
503
+ # lines_for_prompt = []
504
+ # pgestoRun=20
505
+ # # logger.info(f"TOC pages to skip: {toc_pages}")
506
+ # logger.info(f"Total pages in document: {pgestoRun}")
507
+
508
+ # # Collect text lines from pages (skip TOC pages)
509
+ # total_lines = 0
510
+ # for pno in range(len(doc)):
511
+ # # if pages_to_check and pno not in pages_to_check:
512
+ # # continue
513
+ # # if pno in toc_pages:
514
+ # # logger.debug(f"Skipping TOC page {pno}")
515
+ # # continue
516
+ # page = doc.load_page(pno)
517
+ # page_height = page.rect.height
518
+
519
+ # text_dict = page.get_text("dict")
520
+ # lines_for_prompt = []
521
+ # lines_on_page = 0
522
+
523
+ # for block in text_dict.get("blocks", []):
524
+ # if block.get("type") != 0: # text blocks only
525
+ # continue
526
+
527
+ # for line in block.get("lines", []):
528
+ # spans = line.get("spans", [])
529
+ # if not spans:
530
+ # continue
531
+
532
+ # # Use first span to check vertical position
533
+ # y0 = spans[0]["bbox"][1]
534
+ # y1 = spans[0]['bbox'][3]
535
+ # # if y0 < top_margin or y1 > (page_height - bottom_margin):
536
+ # # continue
537
+ # text = " ".join(s.get('text','') for s in spans).strip()
538
+ # if text:
539
+
540
+
541
+ # # prefix with page for easier mapping back
542
+ # lines_for_prompt.append(f"PAGE {pno+1}: {text}")
543
+ # lines_on_page += 1
544
+
545
+ # # if lines_on_page > 0:
546
+
547
+ # # page = doc.load_page(pno)
548
+ # # page_height = page.rect.height
549
+ # # lines_on_page = 0
550
+ # # text_dict = page.get_text("dict")
551
+ # # lines = []
552
+ # # y_tolerance = 0.2 # tweak if needed (1–3 usually works)
553
+ # # for block in page.get_text("dict").get('blocks', []):
554
+ # # if block.get('type') != 0:
555
+ # # continue
556
+ # # for line in block.get('lines', []):
557
+ # # spans = line.get('spans', [])
558
+ # # if not spans:
559
+ # # continue
560
+ # # y0 = spans[0]['bbox'][1]
561
+ # # y1 = spans[0]['bbox'][3]
562
+ # # if y0 < top_margin or y1 > (page_height - bottom_margin):
563
+ # # continue
564
+ # # for s in spans:
565
+ # # # text,font,size,flags,color
566
+ # # # ArrayofTextWithFormat={'Font':s.get('font')},{'Size':s.get('size')},{'Flags':s.get('flags')},{'Color':s.get('color')},{'Text':s.get('text')}
567
+
568
+ # # # prefix with page for easier mapping back
569
+ # # text = s["text"].strip()
570
+ # # lines_for_prompt.append(f"PAGE {pno+1}: {text}")
571
+
572
+ # # # if not lines_for_prompt:
573
+ # # # return []
574
+
575
+ # # if text:
576
+ # # # prefix with page for easier mapping back
577
+ # # # lines_for_prompt.append(f"PAGE {pno+1}: {line}")
578
+ # # lines_on_page += 1
579
+
580
+
581
+ # if lines_on_page > 0:
582
+ # logger.debug(f"Page {pno}: collected {lines_on_page} lines")
583
+ # total_lines += lines_on_page
584
+
585
+ # logger.info(f"Total lines collected for LLM: {total_lines}")
586
+
587
+ # if not lines_for_prompt:
588
+ # logger.warning("No lines collected for prompt")
589
+ # return []
590
+
591
+ # # Log sample of lines
592
+ # logger.info("Sample lines (first 10):")
593
+ # for i, line in enumerate(lines_for_prompt[:10]):
594
+ # logger.info(f" {i}: {line}")
595
+
596
+ # prompt = LLM_prompt+"\n\nLines:\n" + "\n".join(lines_for_prompt)
597
+
598
+
599
+ # logger.debug(f"Full prompt length: {len(prompt)} characters")
600
+ # # Changed: Print entire prompt, not truncated
601
+ # print("=" * 80)
602
+ # print("FULL LLM PROMPT:")
603
+ # print(prompt)
604
+ # print("=" * 80)
605
+
606
+ # # Also log to file
607
+ # # try:
608
+ # # with open("full_prompt.txt", "w", encoding="utf-8") as f:
609
+ # # f.write(prompt)
610
+ # # logger.info("Full prompt saved to full_prompt.txt")
611
+ # # except Exception as e:
612
+ # # logger.error(f"Could not save prompt to file: {e}")
613
+
614
+ # if not api_key:
615
+ # # No API key: return empty so caller can fallback to heuristics
616
+ # logger.error("No API key provided")
617
+ # return []
618
+
619
+ # url = "https://openrouter.ai/api/v1/chat/completions"
620
+
621
+ # # Build headers following the OpenRouter example
622
+ # headers = {
623
+ # "Authorization": f"Bearer {api_key}",
624
+ # "Content-Type": "application/json",
625
+ # "HTTP-Referer": os.getenv("OPENROUTER_REFERER", ""),
626
+ # "X-Title": os.getenv("OPENROUTER_X_TITLE", "")
627
+ # }
628
+
629
+ # # Log request details (without exposing full API key)
630
+ # logger.info(f"Making request to OpenRouter with model: {model}")
631
+ # logger.debug(f"Headers (API key masked): { {k: '***' if k == 'Authorization' else v for k, v in headers.items()} }")
632
+
633
+ # # Wrap the prompt as the example 'content' array expected by OpenRouter
634
+ # body = {
635
+ # "model": model,
636
+ # "messages": [
637
+ # {
638
+ # "role": "user",
639
+ # "content": [
640
+ # {"type": "text", "text": prompt}
641
+ # ]
642
+ # }
643
+ # ]
644
+ # }
645
+
646
+ # # Debug: log request body (truncated) and write raw response for inspection
647
+ # try:
648
+ # # Changed: Log full body (excluding prompt text which is already logged)
649
+ # logger.debug(f"Request body (without prompt text): { {k: v if k != 'messages' else '[...prompt...]' for k, v in body.items()} }")
650
+
651
+ # # Removed timeout parameter
652
+ # resp = requests.post(
653
+ # url=url,
654
+ # headers=headers,
655
+ # data=json.dumps(body)
656
+ # )
657
+
658
+ # logger.info(f"HTTP Response Status: {resp.status_code}")
659
+ # resp.raise_for_status()
660
+
661
+ # resp_text = resp.text
662
+ # # Changed: Print entire response
663
+ # print("=" * 80)
664
+ # print("FULL LLM RESPONSE:")
665
+ # print(resp_text)
666
+ # print("=" * 80)
667
+
668
+ # logger.info(f"LLM raw response length: {len(resp_text)}")
669
+
670
+ # # Save raw response for offline inspection
671
+ # try:
672
+ # with open("llm_debug.json", "w", encoding="utf-8") as fh:
673
+ # fh.write(resp_text)
674
+ # logger.info("Raw response saved to llm_debug.json")
675
+ # except Exception as e:
676
+ # logger.error(f"Warning: could not write llm_debug.json: {e}")
677
+
678
+ # rj = resp.json()
679
+ # logger.info(f"LLM parsed response type: {type(rj)}")
680
+ # if isinstance(rj, dict):
681
+ # logger.debug(f"Response keys: {list(rj.keys())}")
682
+
683
+ # except requests.exceptions.RequestException as e:
684
+ # logger.error(f"HTTP request failed: {repr(e)}")
685
+ # return []
686
+ # except Exception as e:
687
+ # logger.error(f"LLM call failed: {repr(e)}")
688
+ # return []
689
+
690
+ # # Extract textual reply robustly
691
+ # text_reply = None
692
+ # if isinstance(rj, dict):
693
+ # choices = rj.get('choices') or []
694
+ # logger.debug(f"Number of choices in response: {len(choices)}")
695
+
696
+ # if choices:
697
+ # for i, c in enumerate(choices):
698
+ # logger.debug(f"Choice {i}: {c}")
699
+
700
+ # c0 = choices[0]
701
+ # msg = c0.get('message') or c0.get('delta') or {}
702
+ # content = msg.get('content')
703
+
704
+ # if isinstance(content, list):
705
+ # logger.debug(f"Content is a list with {len(content)} items")
706
+ # for idx, c in enumerate(content):
707
+ # if c.get('type') == 'text' and c.get('text'):
708
+ # text_reply = c.get('text')
709
+ # logger.debug(f"Found text reply in content[{idx}], length: {len(text_reply)}")
710
+ # break
711
+ # elif isinstance(content, str):
712
+ # text_reply = content
713
+ # logger.debug(f"Content is string, length: {len(text_reply)}")
714
+ # elif isinstance(msg, dict) and msg.get('content') and isinstance(msg.get('content'), dict):
715
+ # text_reply = msg.get('content').get('text')
716
+ # logger.debug(f"Found text in nested content dict")
717
+
718
+ # # Fallback extraction
719
+ # if not text_reply:
720
+ # logger.debug("Trying fallback extraction from choices")
721
+ # for c in rj.get('choices', []):
722
+ # if isinstance(c.get('text'), str):
723
+ # text_reply = c.get('text')
724
+ # logger.debug(f"Found text reply in choice.text, length: {len(text_reply)}")
725
+ # break
726
+
727
+ # if not text_reply:
728
+ # logger.error("Could not extract text reply from response")
729
+ # # Changed: Print the entire response structure for debugging
730
+ # print("=" * 80)
731
+ # print("FAILED TO EXTRACT TEXT REPLY. FULL RESPONSE STRUCTURE:")
732
+ # print(json.dumps(rj, indent=2))
733
+ # print("=" * 80)
734
+ # return []
735
+
736
+ # # Changed: Print the extracted text reply
737
+ # print("=" * 80)
738
+ # print("EXTRACTED TEXT REPLY:")
739
+ # print(text_reply)
740
+ # print("=" * 80)
741
+
742
+ # logger.info(f"Extracted text reply length: {len(text_reply)}")
743
+ # logger.debug(f"First 500 chars of reply: {text_reply[:500]}...")
744
+
745
+ # s = text_reply.strip()
746
+ # start = s.find('[')
747
+ # end = s.rfind(']')
748
+ # js = s[start:end+1] if start != -1 and end != -1 else s
749
+
750
+ # logger.debug(f"Looking for JSON array: start={start}, end={end}")
751
+ # logger.debug(f"Extracted JSON string (first 500 chars): {js[:500]}...")
752
+
753
+ # try:
754
+ # parsed = json.loads(js)
755
+ # logger.info(f"Successfully parsed JSON, got {len(parsed)} items")
756
+ # except json.JSONDecodeError as e:
757
+ # logger.error(f"Failed to parse JSON: {e}")
758
+ # logger.error(f"JSON string that failed to parse: {js[:1000]}")
759
+ # # Try to find any JSON-like structure
760
+ # try:
761
+ # # Try to extract any JSON array
762
+ # import re
763
+ # json_pattern = r'\[\s*\{.*?\}\s*\]'
764
+ # matches = re.findall(json_pattern, text_reply, re.DOTALL)
765
+ # if matches:
766
+ # logger.info(f"Found {len(matches)} potential JSON arrays via regex")
767
+ # for i, match in enumerate(matches):
768
+ # try:
769
+ # parsed = json.loads(match)
770
+ # logger.info(f"Successfully parsed regex match {i} with {len(parsed)} items")
771
+ # break
772
+ # except json.JSONDecodeError as e2:
773
+ # logger.debug(f"Regex match {i} also failed: {e2}")
774
+ # continue
775
+ # else:
776
+ # logger.error("All regex matches failed to parse")
777
+ # return []
778
+ # else:
779
+ # logger.error("No JSON-like pattern found via regex")
780
+ # return []
781
+ # except Exception as e2:
782
+ # logger.error(f"Regex extraction also failed: {e2}")
783
+ # return []
784
+
785
+ # # Log parsed results
786
+ # logger.info(f"Parsed {len(parsed)} header items:")
787
+ # for i, obj in enumerate(parsed[:10]): # Log first 10 items
788
+ # logger.info(f" Item {i}: {obj}")
789
+
790
+ # # Normalize parsed entries and return
791
+ # out = []
792
+ # for obj in parsed:
793
+ # t = obj.get('text')
794
+ # page = int(obj.get('page')) if obj.get('page') else None
795
+ # level = obj.get('suggested_level')
796
+ # conf = float(obj.get('confidence') or 0)
797
+ # if t and page is not None:
798
+ # out.append({'text': t, 'page': page-1, 'suggested_level': level, 'confidence': conf})
799
+
800
+ # logger.info(f"Returning {len(out)} valid header entries")
801
+ # return out
802
+
803
+
804
+ def identify_headers_with_openrouterNEWW(doc, model,LLM_prompt, pages_to_check=None, top_margin=0, bottom_margin=0):
805
  """Ask an LLM (OpenRouter) to identify headers in the document.
806
  Returns a list of dicts: {text, page, suggested_level, confidence}.
807
  The function sends plain page-line strings to the LLM (including page numbers)
 
809
  """
810
  logger.info("=" * 80)
811
  logger.info("STARTING IDENTIFY_HEADERS_WITH_OPENROUTER")
812
+ # logger.info(f"PDF Path: {pdf_path}")
813
  logger.info(f"Model: {model}")
814
+ # logger.info(f"LLM Prompt: {LLM_prompt[:200]}..." if len(LLM_prompt) > 200 else f"LLM Prompt: {LLM_prompt}")
815
 
816
+ # doc = openPDF(pdf_path)
817
  api_key = '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'
818
  if api_key is None:
819
  api_key = os.getenv("OPENROUTER_API_KEY") or None
820
+
821
  model = str(model)
822
  # toc_pages = get_toc_page_numbers(doc)
823
  lines_for_prompt = []
824
+ # pgestoRun=20
825
  # logger.info(f"TOC pages to skip: {toc_pages}")
826
+ logger.info(f"Total pages in document: {len(doc)}")
827
 
828
  # Collect text lines from pages (skip TOC pages)
829
  total_lines = 0
 
833
  # if pno in toc_pages:
834
  # logger.debug(f"Skipping TOC page {pno}")
835
  # continue
836
+
837
  page = doc.load_page(pno)
838
  page_height = page.rect.height
 
 
 
839
  lines_on_page = 0
840
+ text_dict = page.get_text("dict")
841
+ lines = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
842
  # y_tolerance = 0.2 # tweak if needed (1–3 usually works)
843
+ for block in text_dict["blocks"]:
844
+ if block["type"] != 0:
845
+ continue
846
+ for line in block["lines"]:
847
+ for span in line["spans"]:
848
+ text = span["text"].strip()
849
+ if not text:
850
+ continue
851
+ if text:
852
+ # prefix with page for easier mapping back
853
+ lines_for_prompt.append(f"PAGE {pno+1}: {text}")
854
+ lines_on_page += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
855
 
 
856
  if lines_on_page > 0:
857
  logger.debug(f"Page {pno}: collected {lines_on_page} lines")
858
  total_lines += lines_on_page
 
868
  for i, line in enumerate(lines_for_prompt[:10]):
869
  logger.info(f" {i}: {line}")
870
 
871
+ prompt =LLM_Prompt + "\n\nLines:\n" + "\n".join(lines_for_prompt)
872
+
 
873
  logger.debug(f"Full prompt length: {len(prompt)} characters")
874
  # Changed: Print entire prompt, not truncated
875
  print("=" * 80)
 
878
  print("=" * 80)
879
 
880
  # Also log to file
881
+ try:
882
+ with open("full_prompt.txt", "w", encoding="utf-8") as f:
883
+ f.write(prompt)
884
+ logger.info("Full prompt saved to full_prompt.txt")
885
+ except Exception as e:
886
+ logger.error(f"Could not save prompt to file: {e}")
887
 
888
  if not api_key:
889
  # No API key: return empty so caller can fallback to heuristics
 
891
  return []
892
 
893
  url = "https://openrouter.ai/api/v1/chat/completions"
 
894
  # Build headers following the OpenRouter example
895
  headers = {
896
  "Authorization": f"Bearer {api_key}",
897
  "Content-Type": "application/json",
898
  "HTTP-Referer": os.getenv("OPENROUTER_REFERER", ""),
899
+ "X-Title": os.getenv("OPENROUTER_X_TITLE", ""),
900
+ # "X-Request-Timestamp": str(unix_timestamp),
901
+ # "X-Request-Datetime": current_time,
902
  }
903
+
904
 
905
  # Log request details (without exposing full API key)
906
  logger.info(f"Making request to OpenRouter with model: {model}")
 
918
  }
919
  ]
920
  }
921
+ # print(f"Request sent at: {current_time}")
922
+
923
+ # print(f"Unix timestamp: {unix_timestamp}")
924
  # Debug: log request body (truncated) and write raw response for inspection
925
  try:
926
  # Changed: Log full body (excluding prompt text which is already logged)
 
1077
 
1078
  logger.info(f"Returning {len(out)} valid header entries")
1079
  return out
1080
+
1081
+ # def identify_headers_and_save_excel(pdf_path, model, llm_prompt):
1082
+ # try:
1083
+ # # 1. Get the result from your LLM function
1084
+ # result = identify_headers_with_openrouter(pdf_path, model, llm_prompt)
1085
+
1086
+ # # 2. Safety Check: If LLM failed or returned nothing
1087
+ # if not result:
1088
+ # logger.warning("No headers found or LLM failed. Creating an empty report.")
1089
+ # df = pd.DataFrame([{"System Message": "No headers were identified by the LLM."}])
1090
+ # else:
1091
+ # df = pd.DataFrame(result)
1092
+
1093
+ # # 3. Use an Absolute Path for the output
1094
+ # # This ensures Gradio knows exactly where the file is
1095
+ # output_path = os.path.abspath("header_analysis_output.xlsx")
1096
+
1097
+ # # 4. Save using the engine explicitly
1098
+ # df.to_excel(output_path, index=False, engine='openpyxl')
1099
+
1100
+ # logger.info(f"File successfully saved to {output_path}")
1101
+ # return output_path
1102
 
1103
+ # except Exception as e:
1104
+ # logger.error(f"Critical error in processing: {str(e)}")
1105
+ # # Return None or a custom error message to Gradio
1106
+ # return None
1107
 
1108
+ def extract_section_under_header_tobebilledMultiplePDFS(multiplePDF_Paths,model):
1109
+ logger.debug(f"Starting function")
1110
+ # keywordstoSkip=["installation", "execution", "miscellaneous items", "workmanship", "testing", "labeling"]
1111
+ filenames=[]
1112
+ keywords = {'installation', 'execution', 'miscellaneous items', 'workmanship', 'testing', 'labeling'}
1113
+
1114
+ arrayofPDFS=multiplePDF_Paths.split(',')
1115
+ print(multiplePDF_Paths)
1116
+ print(arrayofPDFS)
1117
+ docarray=[]
1118
+ jsons=[]
1119
+ df = pd.DataFrame(columns=["PDF Name","NBSLink","Subject","Page","Author","Creation Date","Layer",'Code', 'head above 1', "head above 2","BodyText"])
1120
+ for pdf_path in arrayofPDFS:
1121
+ headertoContinue1 = False
1122
+ headertoContinue2=False
1123
+ Alltexttobebilled=''
1124
+ parsed_url = urlparse(pdf_path)
1125
+ filename = os.path.basename(parsed_url.path)
1126
+ filename = unquote(filename) # decode URL-encoded characters
1127
+ filenames.append(filename)
1128
+ logger.debug(f"Starting with pdf: {filename}")
1129
+ # Optimized URL handling
1130
+ if pdf_path and ('http' in pdf_path or 'dropbox' in pdf_path):
1131
+ pdf_path = pdf_path.replace('dl=0', 'dl=1')
1132
+
1133
+ # Cache frequently used values
1134
+ response = requests.get(pdf_path)
1135
+ pdf_content = BytesIO(response.content)
1136
+ if not pdf_content:
1137
+ raise ValueError("No valid PDF content found.")
1138
+
1139
+ doc = fitz.open(stream=pdf_content, filetype="pdf")
1140
+ logger.info(f"Total pages in document: {len(doc)}")
1141
+ docHighlights = fitz.open(stream=pdf_content, filetype="pdf")
1142
+ most_common_font_size, most_common_color, most_common_font = get_regular_font_size_and_color(doc)
1143
+
1144
+ # Precompute regex patterns
1145
+ dot_pattern = re.compile(r'\.{3,}')
1146
+ url_pattern = re.compile(r'https?://\S+|www\.\S+')
1147
+
1148
+
1149
+ toc_pages = get_toc_page_numbers(doc)
1150
+ logger.info(f"Skipping TOC pages: Range {toc_pages}")
1151
+ # headers, top_3_font_sizes, smallest_font_size, headersSpans = extract_headers(
1152
+ # doc, toc_pages, most_common_font_size, most_common_color, most_common_font, top_margin, bottom_margin
1153
+ # )
1154
+ logger.info(f"Starting model run.")
1155
+ identified_headers = identify_headers_with_openrouterNEWW(doc, model)
1156
+ allheaders_LLM=[]
1157
+ for h in identified_headers:
1158
+ if int(h["page"]) in toc_pages:
1159
+ continue
1160
+ if h['text']:
1161
+ allheaders_LLM.append(h['text'])
1162
+
1163
+ logger.info(f"Done with model.")
1164
+ print('identified_headers',identified_headers)
1165
+ headers_json=headers_with_location(doc,identified_headers)
1166
+ headers=filter_headers_outside_toc(headers_json,toc_pages)
1167
+
1168
+ hierarchy=build_hierarchy_from_llm(headers)
1169
+ listofHeaderstoMarkup = get_leaf_headers_with_paths(hierarchy)
1170
+ logger.info(f"Hierarchy built as {hierarchy}")
1171
+
1172
+ # Precompute all children headers once
1173
+ allchildrenheaders = [normalize_text(item['text']) for item, p in listofHeaderstoMarkup]
1174
+ allchildrenheaders_set = set(allchildrenheaders) # For faster lookups
1175
+
1176
+ # df = pd.DataFrame(columns=["NBSLink","Subject","Page","Author","Creation Date","Layer",'Code', 'head above 1', "head above 2","BodyText"])
1177
+ dictionaryNBS={}
1178
+ data_list_JSON = []
1179
+ json_output=[]
1180
+ currentgroupname=''
1181
+ # if len(top_3_font_sizes)==3:
1182
+ # mainHeaderFontSize, subHeaderFontSize, subsubheaderFontSize = top_3_font_sizes
1183
+ # elif len(top_3_font_sizes)==2:
1184
+ # mainHeaderFontSize= top_3_font_sizes[0]
1185
+ # subHeaderFontSize= top_3_font_sizes[1]
1186
+ # subsubheaderFontSize= top_3_font_sizes[1]
1187
+
1188
+
1189
+
1190
+ # Preload all pages to avoid repeated loading
1191
+ # pages = [doc.load_page(page_num) for page_num in range(len(doc)) if page_num not in toc_pages]
1192
+
1193
+ for heading_to_searchDict,pathss in listofHeaderstoMarkup:
1194
+
1195
+ heading_to_search = heading_to_searchDict['text']
1196
+ heading_to_searchPageNum = heading_to_searchDict['page']
1197
+ paths=heading_to_searchDict['path']
1198
 
1199
+ # Initialize variables
1200
+ headertoContinue1 = False
1201
+ headertoContinue2 = False
1202
+ matched_header_line = None
1203
+ done = False
1204
+ collecting = False
1205
+ collected_lines = []
1206
+ page_highlights = {}
1207
+ current_bbox = {}
1208
+ last_y1s = {}
1209
+ mainHeader = ''
1210
+ subHeader = ''
1211
+ matched_header_line_norm = heading_to_search
1212
+ break_collecting = False
1213
+ heading_norm = normalize_text(heading_to_search)
1214
+ paths_norm = [normalize_text(p) for p in paths[0]] if paths and paths[0] else []
1215
+ for page_num in range(heading_to_searchPageNum,len(doc)):
1216
+ # print(heading_to_search)
1217
+ if paths[0].strip().lower() != currentgroupname.strip().lower():
1218
+ Alltexttobebilled+= paths[0] +'\n'
1219
+ currentgroupname=paths[0]
1220
+ # print(paths[0])
1221
+
1222
+
1223
+ if page_num in toc_pages:
1224
+ continue
1225
+ if break_collecting:
1226
+ break
1227
+ page=doc[page_num]
1228
+ page_height = page.rect.height
1229
+ blocks = page.get_text("dict")["blocks"]
1230
+
1231
+ for block in blocks:
1232
+ if break_collecting:
1233
+ break
1234
+
1235
+ lines = block.get("lines", [])
1236
+ i = 0
1237
+ while i < len(lines):
1238
+ if break_collecting:
1239
+ break
1240
+
1241
+ spans = lines[i].get("spans", [])
1242
+ if not spans:
1243
+ i += 1
1244
+ continue
1245
+
1246
+ y0 = spans[0]["bbox"][1]
1247
+ y1 = spans[0]["bbox"][3]
1248
+ if y0 < top_margin or y1 > (page_height - bottom_margin):
1249
+ i += 1
1250
+ continue
1251
+
1252
+ line_text = get_spaced_text_from_spans(spans).lower()
1253
+ line_text_norm = normalize_text(line_text)
1254
+
1255
+ # Combine with next line if available
1256
+ if i + 1 < len(lines):
1257
+ next_spans = lines[i + 1].get("spans", [])
1258
+ next_line_text = get_spaced_text_from_spans(next_spans).lower()
1259
+ combined_line_norm = normalize_text(line_text + " " + next_line_text)
1260
+ else:
1261
+ combined_line_norm = line_text_norm
1262
+
1263
+ # Check if we should continue processing
1264
+ if combined_line_norm and combined_line_norm in paths[0]:
1265
+
1266
+ headertoContinue1 = combined_line_norm
1267
+ if combined_line_norm and combined_line_norm in paths[-2]:
1268
+
1269
+ headertoContinue2 = combined_line_norm
1270
+ # if 'installation' in paths[-2].lower() or 'execution' in paths[-2].lower() or 'miscellaneous items' in paths[-2].lower() :
1271
+ last_path = paths[-2].lower()
1272
+ # if any(word in paths[-2].lower() for word in keywordstoSkip):
1273
+ # if 'installation' in paths[-2].lower() or 'execution' in paths[-2].lower() or 'miscellaneous items' in paths[-2].lower() or 'workmanship' in paths[-2].lower() or 'testing' in paths[-2].lower() or 'labeling' in paths[-2].lower():
1274
+ if any(keyword in last_path for keyword in keywords):
1275
+ stringtowrite='Not to be billed'
1276
+ logger.info(f"Keyword found. Not to be billed activated. keywords: {keywords}")
1277
+ else:
1278
+ stringtowrite='To be billed'
1279
+ if stringtowrite=='To be billed':
1280
+ # Alltexttobebilled+= combined_line_norm #################################################
1281
+ if matched_header_line_norm in combined_line_norm:
1282
+ Alltexttobebilled+='\n'
1283
+ Alltexttobebilled+= ' '+combined_line_norm
1284
+ # Optimized header matching
1285
+ existsfull = (
1286
+ ( combined_line_norm in allchildrenheaders_set or
1287
+ combined_line_norm in allchildrenheaders ) and heading_to_search in combined_line_norm
1288
+ )
1289
+
1290
+ # New word-based matching
1291
+ current_line_words = set(combined_line_norm.split())
1292
+ heading_words = set(heading_norm.split())
1293
+ all_words_match = current_line_words.issubset(heading_words) and len(current_line_words) > 0
1294
+
1295
+ substring_match = (
1296
+ heading_norm in combined_line_norm or
1297
+ combined_line_norm in heading_norm or
1298
+ all_words_match # Include the new word-based matching
1299
+ )
1300
+ # substring_match = (
1301
+ # heading_norm in combined_line_norm or
1302
+ # combined_line_norm in heading_norm
1303
+ # )
1304
+
1305
+ if (substring_match and existsfull and not collecting and
1306
+ len(combined_line_norm) > 0 ):#and (headertoContinue1 or headertoContinue2) ):
1307
+
1308
+ # Check header conditions more efficiently
1309
+ # header_spans = [
1310
+ # span for span in spans
1311
+ # if (is_header(span, most_common_font_size, most_common_color, most_common_font)
1312
+ # # and span['size'] >= subsubheaderFontSize
1313
+ # and span['size'] < mainHeaderFontSize)
1314
+ # ]
1315
+ if stringtowrite.startswith('To') :
1316
+ collecting = True
1317
+ # if stringtowrite=='To be billed':
1318
+ # Alltexttobebilled+='\n'
1319
+ # matched_header_font_size = max(span["size"] for span in header_spans)
1320
+
1321
+ # collected_lines.append(line_text)
1322
+ valid_spans = [span for span in spans if span.get("bbox")]
1323
+
1324
+ if valid_spans:
1325
+ x0s = [span["bbox"][0] for span in valid_spans]
1326
+ x1s = [span["bbox"][2] for span in valid_spans]
1327
+ y0s = [span["bbox"][1] for span in valid_spans]
1328
+ y1s = [span["bbox"][3] for span in valid_spans]
1329
+
1330
+ header_bbox = [min(x0s), min(y0s), max(x1s), max(y1s)]
1331
+
1332
+ if page_num in current_bbox:
1333
+ cb = current_bbox[page_num]
1334
+ current_bbox[page_num] = [
1335
+ min(cb[0], header_bbox[0]),
1336
+ min(cb[1], header_bbox[1]),
1337
+ max(cb[2], header_bbox[2]),
1338
+ max(cb[3], header_bbox[3])
1339
+ ]
1340
+ else:
1341
+ current_bbox[page_num] = header_bbox
1342
+ last_y1s[page_num] = header_bbox[3]
1343
+ x0, y0, x1, y1 = header_bbox
1344
+
1345
+ zoom = 200
1346
+ left = int(x0)
1347
+ top = int(y0)
1348
+ zoom_str = f"{zoom},{left},{top}"
1349
+ pageNumberFound = page_num + 1
1350
+
1351
+ # Build the query parameters
1352
+ params = {
1353
+ 'pdfLink': pdf_path, # Your PDF link
1354
+ 'keyword': heading_to_search, # Your keyword (could be a string or list)
1355
+ }
1356
+
1357
+ # URL encode each parameter
1358
+ encoded_params = {key: urllib.parse.quote(value, safe='') for key, value in params.items()}
1359
+
1360
+ # Construct the final encoded link
1361
+ encoded_link = '&'.join([f"{key}={value}" for key, value in encoded_params.items()])
1362
+
1363
+ # Correctly construct the final URL with page and zoom
1364
+ # final_url = f"{baselink}{encoded_link}#page={str(pageNumberFound)}&zoom={zoom_str}"
1365
+
1366
+ # Get current date and time
1367
+ now = datetime.now()
1368
+
1369
+ # Format the output
1370
+ formatted_time = now.strftime("%d/%m/%Y %I:%M:%S %p")
1371
+ # Optionally, add the URL to a DataFrame
1372
+
1373
+
1374
+ data_entry = {
1375
+ "PDF Name":filename,
1376
+ "NBSLink": zoom_str,
1377
+ "Subject": heading_to_search,
1378
+ "Page": str(pageNumberFound),
1379
+ "Author": "ADR",
1380
+ "Creation Date": formatted_time,
1381
+ "Layer": "Initial",
1382
+ "Code": stringtowrite,
1383
+ # "head above 1": paths[-2],
1384
+ # "head above 2": paths[0],
1385
+ "BodyText":collected_lines,
1386
+ "MC Connnection": 'Go to ' + paths[0].strip().split()[0] +'/'+ heading_to_search.strip().split()[0] + ' in '+ filename
1387
+ }
1388
+ # Dynamically add "head above 1", "head above 2", ... depending on the number of levels
1389
+ for i, path_text in enumerate(paths[:-1]): # skip the last one because that's the current heading
1390
+ data_entry[f"head above {i+1}"] = path_text
1391
+ data_list_JSON.append(data_entry)
1392
+
1393
+ # Convert list to JSON
1394
+ # json_output = [data_list_JSON]
1395
+ # json_output = json.dumps(data_list_JSON, indent=4)
1396
+
1397
+ i += 2
1398
+ continue
1399
+ else:
1400
+ if (substring_match and not collecting and
1401
+ len(combined_line_norm) > 0): # and (headertoContinue1 or headertoContinue2) ):
1402
+
1403
+ # Calculate word match percentage
1404
+ word_match_percent = words_match_ratio(heading_norm, combined_line_norm) * 100
1405
+
1406
+ # Check if at least 70% of header words exist in this line
1407
+ meets_word_threshold = word_match_percent >= 100
1408
+
1409
+ # Check header conditions (including word threshold)
1410
+ # header_spans = [
1411
+ # span for span in spans
1412
+ # if (is_header(span, most_common_font_size, most_common_color, most_common_font)
1413
+ # # and span['size'] >= subsubheaderFontSize
1414
+ # and span['size'] < mainHeaderFontSize)
1415
+ # ]
1416
+
1417
+ if (meets_word_threshold or same_start_word(heading_to_search, combined_line_norm) ) and stringtowrite.startswith('To'):
1418
+ collecting = True
1419
+ if stringtowrite=='To be billed':
1420
+ Alltexttobebilled+='\n'
1421
+ # if stringtowrite=='To be billed':
1422
+ # Alltexttobebilled+= ' '+ combined_line_norm
1423
+ # matched_header_font_size = max(span["size"] for span in header_spans)
1424
+
1425
+ collected_lines.append(line_text)
1426
+ valid_spans = [span for span in spans if span.get("bbox")]
1427
+
1428
+ if valid_spans:
1429
+ x0s = [span["bbox"][0] for span in valid_spans]
1430
+ x1s = [span["bbox"][2] for span in valid_spans]
1431
+ y0s = [span["bbox"][1] for span in valid_spans]
1432
+ y1s = [span["bbox"][3] for span in valid_spans]
1433
+
1434
+ header_bbox = [min(x0s), min(y0s), max(x1s), max(y1s)]
1435
+
1436
+ if page_num in current_bbox:
1437
+ cb = current_bbox[page_num]
1438
+ current_bbox[page_num] = [
1439
+ min(cb[0], header_bbox[0]),
1440
+ min(cb[1], header_bbox[1]),
1441
+ max(cb[2], header_bbox[2]),
1442
+ max(cb[3], header_bbox[3])
1443
+ ]
1444
+ else:
1445
+ current_bbox[page_num] = header_bbox
1446
+
1447
+ last_y1s[page_num] = header_bbox[3]
1448
+ x0, y0, x1, y1 = header_bbox
1449
+ zoom = 200
1450
+ left = int(x0)
1451
+ top = int(y0)
1452
+ zoom_str = f"{zoom},{left},{top}"
1453
+ pageNumberFound = page_num + 1
1454
+
1455
+ # Build the query parameters
1456
+ params = {
1457
+ 'pdfLink': pdf_path, # Your PDF link
1458
+ 'keyword': heading_to_search, # Your keyword (could be a string or list)
1459
+ }
1460
+
1461
+ # URL encode each parameter
1462
+ encoded_params = {key: urllib.parse.quote(value, safe='') for key, value in params.items()}
1463
+
1464
+ # Construct the final encoded link
1465
+ encoded_link = '&'.join([f"{key}={value}" for key, value in encoded_params.items()])
1466
+
1467
+ # Correctly construct the final URL with page and zoom
1468
+ # final_url = f"{baselink}{encoded_link}#page={str(pageNumberFound)}&zoom={zoom_str}"
1469
+
1470
+ # Get current date and time
1471
+ now = datetime.now()
1472
+
1473
+ # Format the output
1474
+ formatted_time = now.strftime("%d/%m/%Y %I:%M:%S %p")
1475
+ # Optionally, add the URL to a DataFrame
1476
+
1477
+ logger.info(f"Logging into table")
1478
+ data_entry = {
1479
+ "PDF Name":filename,
1480
+ "NBSLink": zoom_str,
1481
+ "Subject": heading_to_search,
1482
+ "Page": str(pageNumberFound),
1483
+ "Author": "ADR",
1484
+ "Creation Date": formatted_time,
1485
+ "Layer": "Initial",
1486
+ "Code": stringtowrite,
1487
+ # "head above 1": paths[-2],
1488
+ # "head above 2": paths[0],
1489
+ "BodyText":collected_lines,
1490
+ "MC Connnection": 'Go to ' + paths[0].strip().split()[0] +'/'+ heading_to_search.strip().split()[0] + ' in '+ filename
1491
+ }
1492
+ # Dynamically add "head above 1", "head above 2", ... depending on the number of levels
1493
+ for i, path_text in enumerate(paths[:-1]): # skip the last one because that's the current heading
1494
+ data_entry[f"head above {i+1}"] = path_text
1495
+ data_list_JSON.append(data_entry)
1496
+
1497
+ # Convert list to JSON
1498
+ # json_output = [data_list_JSON]
1499
+ # json_output = json.dumps(data_list_JSON, indent=4)
1500
+
1501
+
1502
+ i += 2
1503
+ continue
1504
+ if collecting:
1505
+ norm_line = normalize_text(line_text)
1506
+
1507
+ # Optimized URL check
1508
+ if url_pattern.match(norm_line):
1509
+ line_is_header = False
1510
+ else:
1511
+ # line_is_header = any(is_header(span, most_common_font_size, most_common_color, most_common_font) for span in spans)
1512
+ def normalize(text):
1513
+ return " ".join(text.lower().split())
1514
+
1515
+ line_text = " ".join(span["text"] for span in spans).strip()
1516
+
1517
+ line_is_header = any(
1518
+ normalize(line_text) == normalize(header)
1519
+ for header in allheaders_LLM
1520
+ )
1521
+ if line_is_header:
1522
+ header_font_size = max(span["size"] for span in spans)
1523
+ is_probably_real_header = (
1524
+ # header_font_size >= matched_header_font_size and
1525
+ # is_header(spans[0], most_common_font_size, most_common_color, most_common_font) and
1526
+ len(line_text.strip()) > 2
1527
+ )
1528
+
1529
+ if (norm_line != matched_header_line_norm and
1530
+ norm_line != heading_norm and
1531
+ is_probably_real_header):
1532
+ if line_text not in heading_norm:
1533
+ collecting = False
1534
+ done = True
1535
+ headertoContinue1 = False
1536
+ headertoContinue2=False
1537
+ for page_num, bbox in current_bbox.items():
1538
+ bbox[3] = last_y1s.get(page_num, bbox[3])
1539
+ page_highlights[page_num] = bbox
1540
+ highlight_boxes(docHighlights, page_highlights,stringtowrite)
1541
+
1542
+ break_collecting = True
1543
+ break
1544
+
1545
+ if break_collecting:
1546
+ break
1547
+
1548
+ collected_lines.append(line_text)
1549
+ valid_spans = [span for span in spans if span.get("bbox")]
1550
+ if valid_spans:
1551
+ x0s = [span["bbox"][0] for span in valid_spans]
1552
+ x1s = [span["bbox"][2] for span in valid_spans]
1553
+ y0s = [span["bbox"][1] for span in valid_spans]
1554
+ y1s = [span["bbox"][3] for span in valid_spans]
1555
+
1556
+ line_bbox = [min(x0s), min(y0s), max(x1s), max(y1s)]
1557
+
1558
+ if page_num in current_bbox:
1559
+ cb = current_bbox[page_num]
1560
+ current_bbox[page_num] = [
1561
+ min(cb[0], line_bbox[0]),
1562
+ min(cb[1], line_bbox[1]),
1563
+ max(cb[2], line_bbox[2]),
1564
+ max(cb[3], line_bbox[3])
1565
+ ]
1566
+ else:
1567
+ current_bbox[page_num] = line_bbox
1568
+
1569
+ last_y1s[page_num] = line_bbox[3]
1570
+ i += 1
1571
+
1572
+ if not done:
1573
+ for page_num, bbox in current_bbox.items():
1574
+ bbox[3] = last_y1s.get(page_num, bbox[3])
1575
+ page_highlights[page_num] = bbox
1576
+ if 'installation' in paths[-2].lower() or 'execution' in paths[-2].lower() or 'miscellaneous items' in paths[-2].lower() :
1577
+ stringtowrite='Not to be billed'
1578
+ else:
1579
+ stringtowrite='To be billed'
1580
+ highlight_boxes(docHighlights, page_highlights,stringtowrite)
1581
+ docarray.append(docHighlights)
1582
+ if data_list_JSON and not data_list_JSON[-1]["BodyText"] and collected_lines:
1583
+ data_list_JSON[-1]["BodyText"] = collected_lines[1:] if len(collected_lines) > 0 else []
1584
+ # Final cleanup of the JSON data before returning
1585
+ for entry in data_list_JSON:
1586
+ # Check if BodyText exists and has content
1587
+ if isinstance(entry.get("BodyText"), list) and len(entry["BodyText"]) > 0:
1588
+ # Check if the first line of the body is essentially the same as the Subject
1589
+ first_line = normalize_text(entry["BodyText"][0])
1590
+ subject = normalize_text(entry["Subject"])
1591
+
1592
+ # If they match or the subject is inside the first line, remove it
1593
+ if subject in first_line or first_line in subject:
1594
+ entry["BodyText"] = entry["BodyText"][1:]
1595
+ jsons.append(data_list_JSON)
1596
+ logger.info(f"Markups done! Uploading to dropbox")
1597
+ logger.info(f"Uploaded and Readyy!")
1598
+
1599
+
1600
+ return jsons,identified_headers
1601
+
1602
+ def build_subject_body_map(jsons):
1603
+ subject_body = {}
1604
+
1605
+ for obj in jsons:
1606
+ subject = obj.get("Subject")
1607
+ body = obj.get("BodyText", [])
1608
+
1609
+ if subject:
1610
+ # join body text into a readable paragraph
1611
+ subject_body[subject.strip()] = " ".join(body)
1612
+
1613
+ return subject_body
1614
+
1615
+ def identify_headers_and_save_excel(pdf_path, model):
1616
+ try:
1617
+ # result = identify_headers_with_openrouterNEWW(pdf_path, model)
1618
+ jsons,result = extract_section_under_header_tobebilledMultiplePDFS(pdf_path, model)
1619
+ print(jsons)
1620
  if not result:
1621
+ df = pd.DataFrame([{
1622
+ "text": None,
1623
+ "page": None,
1624
+ "suggested_level": None,
1625
+ "confidence": None,
1626
+ "body": None,
1627
+ "System Message": "No headers were identified by the LLM."
1628
+ }])
1629
  else:
1630
+ print('here')
1631
  df = pd.DataFrame(result)
1632
+
1633
+ subject_body_map = {}
1634
+
1635
+ for pdf_sections in jsons:
1636
+ for obj in pdf_sections:
1637
+ subject = obj.get("Subject")
1638
+ body = obj.get("BodyText", [])
1639
+
1640
+ if subject:
1641
+ subject_body_map[subject.strip()] = " ".join(body)
1642
+
1643
+ df["body"] = df["text"].map(subject_body_map)
1644
+
1645
  output_path = os.path.abspath("header_analysis_output.xlsx")
1646
+ df.to_excel(output_path, index=False, engine="openpyxl")
1647
+ print(df)
1648
+
1649
+ return output_path
 
 
1650
 
1651
  except Exception as e:
1652
  logger.error(f"Critical error in processing: {str(e)}")
 
1653
  return None
1654
 
1655
+
1656
+
1657
  # Improved launch with debug mode enabled
1658
  iface = gr.Interface(
1659
  fn=identify_headers_and_save_excel,