alx-d commited on
Commit
7e7e541
·
verified ·
1 Parent(s): 11554bd

Upload folder using huggingface_hub

Browse files
__pycache__/filterlm.cpython-311.pyc CHANGED
Binary files a/__pycache__/filterlm.cpython-311.pyc and b/__pycache__/filterlm.cpython-311.pyc differ
 
__pycache__/pdf_criteria_search_gui.cpython-311.pyc ADDED
Binary file (61.8 kB). View file
 
__pycache__/pdf_criteria_search_gui_v2.cpython-311.pyc ADDED
Binary file (64.3 kB). View file
 
__pycache__/search_keywords.cpython-311.pyc ADDED
Binary file (3.46 kB). View file
 
criteria_search_terms.csv ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ term
2
+ quality
3
+ helpfulness
4
+ harmlessness
5
+ safety
6
+ groundedness
7
+ honesty
8
+ truthfulness
9
+ hallucinations
10
+ empathy
11
+ politeness
12
+ relevance
13
+ verbosity
14
+ component
15
+ aggregation
16
+ ranking
17
+ weighting
18
+ tradeoffs
19
+ other
20
+ nonspecific
21
+ grice
22
+ specific
23
+ otherframework
24
+ conversations
25
+ humanai
26
+ annotation
27
+ separate
28
+ naturalistic
29
+ hybrid
30
+ synthetic
31
+ datasetname
32
+ datasetsource
33
+ dialogs
34
+ annotators
35
+ context
36
+ annotator
37
+ single
38
+ multi
39
+ prerecorded
40
+ realtime
41
+ scales
42
+ binary
43
+ validated
44
+ notes
45
+ named
46
+ unspecified
47
+ family
48
+ rlhf
49
+ partial
50
+ collected
51
+ reward
52
+ policy
53
+ stage
54
+ benchmark
55
+ judge
56
+ comparators
57
+ winrate
58
+ strategy
59
+ gains
60
+ losses
61
+ inconsistent
62
+ unchanged
63
+ quantified
filterlm.py CHANGED
The diff for this file is too large to render. See raw diff
 
filterlm_previous.py ADDED
@@ -0,0 +1,1714 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
3
+
4
+ # API Key Configuration - Set your API keys here or as environment variables
5
+ MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY", "")
6
+ OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
7
+ NEBIUS_API_KEY = os.environ.get("NEBIUS_API_KEY", "")
8
+ GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
9
+ ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
10
+ GROK_API_KEY = os.environ.get("GROK_API_KEY", "")
11
+ HF_API_TOKEN = os.environ.get("HF_API_TOKEN", "")
12
+
13
+ # Import pandas for table display
14
+ try:
15
+ import pandas as pd
16
+ except ImportError:
17
+ pd = None
18
+
19
+ # Import API libraries
20
+ try:
21
+ import openai
22
+ from openai import OpenAI
23
+ except ImportError:
24
+ openai = None
25
+ OpenAI = None
26
+
27
+ try:
28
+ import google.generativeai as genai
29
+ except ImportError:
30
+ genai = None
31
+
32
+ try:
33
+ from mistralai import Mistral
34
+ MISTRAL_AVAILABLE = True
35
+ except ImportError as e:
36
+ Mistral = None
37
+ MISTRAL_AVAILABLE = False
38
+ print(f"Mistral import failed: {e}")
39
+ print("Please install mistralai package with: pip install mistralai")
40
+ except Exception as e:
41
+ Mistral = None
42
+ MISTRAL_AVAILABLE = False
43
+ print(f"Mistral import error: {e}")
44
+
45
+ try:
46
+ import anthropic
47
+ except ImportError:
48
+ anthropic = None
49
+
50
+ import datetime
51
+ import functools
52
+ import traceback
53
+ from typing import List, Optional, Any, Dict
54
+ import re
55
+ import time
56
+ import threading
57
+ import uuid
58
+ import csv
59
+ import requests
60
+ import mimetypes
61
+ import tempfile
62
+ try:
63
+ from pdfminer.high_level import extract_text
64
+ except ImportError:
65
+ # Fallback for older pdfminer versions
66
+ from pdfminer.pdfinterp import PDFResourceManager, PDFPageInterpreter
67
+ from pdfminer.converter import TextConverter
68
+ from pdfminer.layout import LAParams
69
+ from pdfminer.pdfpage import PDFPage
70
+ from io import StringIO
71
+ import statistics
72
+ import glob
73
+ import urllib.parse
74
+ from pathlib import Path
75
+
76
+ import torch
77
+ import transformers
78
+ from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
79
+ from langchain_community.llms import HuggingFacePipeline
80
+ from langchain_community.document_loaders import OnlinePDFLoader, PyPDFLoader
81
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
82
+ from langchain_community.vectorstores import FAISS
83
+ from langchain.embeddings import HuggingFaceEmbeddings
84
+ from langchain_community.retrievers import BM25Retriever
85
+ from langchain.retrievers import EnsembleRetriever
86
+ from langchain.prompts import ChatPromptTemplate
87
+ from langchain.schema import StrOutputParser, Document
88
+ from langchain_core.runnables import RunnableParallel, RunnableLambda
89
+ from transformers.quantizers.auto import AutoQuantizationConfig
90
+ import gradio as gr
91
+ import requests
92
+ from pydantic import PrivateAttr
93
+ import pydantic
94
+ import zipfile
95
+ import mimetypes
96
+
97
+ from langchain.llms.base import LLM
98
+ from typing import Any, Optional, List
99
+ import typing
100
+
101
+ # Debug print function
102
+ def debug_print(message):
103
+ print(f"[DEBUG] {message}")
104
+
105
+ # Google Drive processing functions
106
+ def get_confirm_token(response):
107
+ for key, value in response.cookies.items():
108
+ if key.startswith("download_warning"):
109
+ return value
110
+ return None
111
+
112
+ def download_file_from_google_drive(file_id, destination):
113
+ """
114
+ Download a file from Google Drive handling large file confirmation.
115
+ """
116
+ URL = "https://docs.google.com/uc?export=download&confirm=1"
117
+ session = requests.Session()
118
+ response = session.get(URL, params={"id": file_id}, stream=True)
119
+ token = get_confirm_token(response)
120
+ if token:
121
+ params = {"id": file_id, "confirm": token}
122
+ response = session.get(URL, params=params, stream=True)
123
+ save_response_content(response, destination)
124
+
125
+ def save_response_content(response, destination):
126
+ CHUNK_SIZE = 32768
127
+ with open(destination, "wb") as f:
128
+ for chunk in response.iter_content(CHUNK_SIZE):
129
+ if chunk:
130
+ f.write(chunk)
131
+
132
+ def extract_file_id(drive_link: str) -> str:
133
+ # Check for /d/ format (including with view?usp=drive_link)
134
+ match = re.search(r"/d/([a-zA-Z0-9_-]+)", drive_link)
135
+ if match:
136
+ return match.group(1)
137
+
138
+ # Check for open?id= format
139
+ match = re.search(r"open\?id=([a-zA-Z0-9_-]+)", drive_link)
140
+ if match:
141
+ return match.group(1)
142
+
143
+ raise ValueError("Could not extract file ID from the provided Google Drive link.")
144
+
145
+ def load_pdf_from_google_drive(link: str) -> str:
146
+ """
147
+ Load a PDF document from a Google Drive link using pdfminer to extract text.
148
+ Returns the extracted text as a string.
149
+ """
150
+ file_id = extract_file_id(link)
151
+ debug_print(f"Extracted file ID: {file_id}")
152
+ with tempfile.NamedTemporaryFile(delete=False) as temp_file:
153
+ temp_path = temp_file.name
154
+ try:
155
+ download_file_from_google_drive(file_id, temp_path)
156
+ debug_print(f"File downloaded to: {temp_path}")
157
+ try:
158
+ try:
159
+ # Try the high-level API first
160
+ full_text = extract_text(temp_path)
161
+ except NameError:
162
+ # Fallback to low-level API if high-level is not available
163
+ full_text = extract_text_fallback(temp_path)
164
+
165
+ if not full_text.strip():
166
+ raise ValueError("Extracted text is empty. The PDF might be image-based.")
167
+ debug_print("Extracted preview text from PDF:")
168
+ debug_print(full_text[:1000]) # Preview first 1000 characters
169
+ return full_text
170
+ except Exception as e:
171
+ debug_print(f"Could not extract text from PDF: {e}")
172
+ return ""
173
+ finally:
174
+ if os.path.exists(temp_path):
175
+ os.remove(temp_path)
176
+
177
+ def extract_text_fallback(pdf_path):
178
+ """Fallback text extraction for older pdfminer versions"""
179
+ resource_manager = PDFResourceManager()
180
+ fake_file_handle = StringIO()
181
+ converter = TextConverter(resource_manager, fake_file_handle, laparams=LAParams())
182
+ page_interpreter = PDFPageInterpreter(resource_manager, converter)
183
+
184
+ with open(pdf_path, 'rb') as fh:
185
+ for page in PDFPage.get_pages(fh, caching=True, check_extractable=True):
186
+ page_interpreter.process_page(page)
187
+ text = fake_file_handle.getvalue()
188
+
189
+ converter.close()
190
+ fake_file_handle.close()
191
+ return text
192
+
193
+ def load_file_from_google_drive(link: str) -> str:
194
+ """
195
+ Load a document from a Google Drive link, detecting whether it's a PDF or TXT file.
196
+ Returns the extracted text as a string.
197
+ """
198
+ file_id = extract_file_id(link)
199
+
200
+ # Create direct download link
201
+ download_url = f"https://drive.google.com/uc?export=download&id={file_id}"
202
+
203
+ # First, try to read a small portion of the file to determine its type
204
+ try:
205
+ # Use a streaming request to read just the first part of the file
206
+ response = requests.get(download_url, stream=True)
207
+ if response.status_code != 200:
208
+ raise ValueError(f"Failed to download file from Google Drive. Status code: {response.status_code}")
209
+
210
+ # Read just the first 1024 bytes to check file signature
211
+ file_start = next(response.iter_content(1024))
212
+ response.close() # Close the stream
213
+
214
+ # Convert bytes to string for pattern matching
215
+ file_start_str = file_start.decode('utf-8', errors='ignore')
216
+
217
+ # Check for PDF signature (%PDF-) at the beginning of the file
218
+ if file_start_str.startswith('%PDF-') or b'%PDF-' in file_start:
219
+ debug_print(f"Detected PDF file by content signature from Google Drive: {link}")
220
+ return load_pdf_from_google_drive(link)
221
+ else:
222
+ # If not a PDF, try as text
223
+ debug_print(f"No PDF signature found, treating as TXT file from Google Drive: {link}")
224
+
225
+ # Since we already downloaded part of the file, get the full content
226
+ response = requests.get(download_url)
227
+ if response.status_code != 200:
228
+ raise ValueError(f"Failed to download complete file from Google Drive. Status code: {response.status_code}")
229
+
230
+ content = response.text
231
+ if not content.strip():
232
+ raise ValueError(f"TXT file from Google Drive is empty.")
233
+
234
+ return content
235
+
236
+ except UnicodeDecodeError:
237
+ # If we get a decode error, it's likely a binary file like PDF
238
+ debug_print(f"Got decode error, likely a binary file. Treating as PDF from Google Drive: {link}")
239
+ return load_pdf_from_google_drive(link)
240
+ except Exception as e:
241
+ debug_print(f"Error detecting file type: {e}")
242
+
243
+ # Fall back to trying both formats
244
+ debug_print("Falling back to trying both formats for Google Drive file")
245
+ try:
246
+ return load_pdf_from_google_drive(link)
247
+ except Exception as txt_error:
248
+ debug_print(f"Failed to load as PDF: {txt_error}")
249
+ try:
250
+ response = requests.get(download_url)
251
+ if response.status_code != 200:
252
+ raise ValueError(f"Failed to download complete file from Google Drive. Status code: {response.status_code}")
253
+ content = response.text
254
+ if not content.strip():
255
+ raise ValueError(f"TXT file from Google Drive is empty.")
256
+ return content
257
+ except Exception as txt_error2:
258
+ debug_print(f"Failed to load as TXT: {txt_error2}")
259
+ raise ValueError(f"Could not load file from Google Drive as either PDF or TXT: {link}")
260
+
261
+ def extract_folder_id(drive_link: str) -> str:
262
+ """Extract folder ID from Google Drive folder link"""
263
+ # Check for /folders/ format
264
+ match = re.search(r"/folders/([a-zA-Z0-9_-]+)", drive_link)
265
+ if match:
266
+ return match.group(1)
267
+
268
+ # Check for open?id= format for folders
269
+ match = re.search(r"open\?id=([a-zA-Z0-9_-]+)", drive_link)
270
+ if match:
271
+ return match.group(1)
272
+
273
+ raise ValueError("Could not extract folder ID from the provided Google Drive folder link.")
274
+
275
+ def list_files_in_gdrive_folder(folder_link: str) -> List[str]:
276
+ """
277
+ List all files in a Google Drive folder and return their direct download links.
278
+ This uses a simple web scraping approach to get file links from the folder.
279
+ """
280
+ try:
281
+ folder_id = extract_folder_id(folder_link)
282
+ debug_print(f"Extracted folder ID: {folder_id}")
283
+
284
+ # Create the folder URL
285
+ folder_url = f"https://drive.google.com/drive/folders/{folder_id}"
286
+
287
+ # Try to get the folder page content
288
+ response = requests.get(folder_url)
289
+ if response.status_code != 200:
290
+ debug_print(f"Failed to access Google Drive folder. Status code: {response.status_code}")
291
+ return []
292
+
293
+ # Look for file links in the page content
294
+ # This is a basic approach - Google Drive uses JavaScript to load content
295
+ # so this might not work for all cases
296
+ content = response.text
297
+
298
+ # Look for file IDs in the content
299
+ file_id_pattern = r'data-id="([a-zA-Z0-9_-]+)"'
300
+ file_ids = re.findall(file_id_pattern, content)
301
+
302
+ # Also look for other patterns that might contain file IDs
303
+ if not file_ids:
304
+ # Try alternative patterns
305
+ alt_patterns = [
306
+ r'"/file/d/([a-zA-Z0-9_-]+)/"',
307
+ r'"id":"([a-zA-Z0-9_-]+)"',
308
+ r'data-id="([a-zA-Z0-9_-]+)"'
309
+ ]
310
+
311
+ for pattern in alt_patterns:
312
+ matches = re.findall(pattern, content)
313
+ if matches:
314
+ file_ids.extend(matches)
315
+ break
316
+
317
+ if file_ids:
318
+ # Convert file IDs to direct download links
319
+ file_links = []
320
+ for file_id in file_ids:
321
+ # Skip if it's the same as folder ID
322
+ if file_id != folder_id:
323
+ file_link = f"https://drive.google.com/file/d/{file_id}/view".strip()
324
+ file_links.append(file_link)
325
+ debug_print(f"Found file: {file_link}")
326
+
327
+ debug_print(f"Found {len(file_links)} files in Google Drive folder")
328
+ return file_links
329
+ else:
330
+ debug_print("No files found in Google Drive folder")
331
+ debug_print("Note: Google Drive folder listing may not work for all folder types")
332
+ debug_print("Please provide direct file links instead of folder links for better reliability")
333
+ return []
334
+
335
+ except Exception as e:
336
+ debug_print(f"Error listing Google Drive folder: {e}")
337
+ debug_print("Please provide direct file links instead of folder links")
338
+ return []
339
+
340
+ # Error patterns for retry logic
341
+ error_patterns = [
342
+ r"error generating response:",
343
+ r"api error occurred:",
344
+ r"bad gateway",
345
+ r"cloudflare",
346
+ r"server disconnected without sending a response",
347
+ r"getaddrinfo failed"
348
+ ]
349
+
350
+ # Model configurations
351
+ models = [
352
+ # NEBIUS
353
+ {"provider": "nebius", "display": "🟦 DeepSeek-R1-0528 (Nebius) (32K)", "backend": "deepseek-ai/DeepSeek-R1-0528", "max_tokens": 32768},
354
+ {"provider": "nebius", "display": "🟦 DeepSeek-V3-0324 (Nebius) (32K)", "backend": "deepseek-ai/DeepSeek-V3-0324", "max_tokens": 32768},
355
+ {"provider": "nebius", "display": "🟦 DeepSeek-R1 (Nebius) (32K)", "backend": "deepseek-ai/DeepSeek-R1", "max_tokens": 32768},
356
+ {"provider": "nebius", "display": "🟦 DeepSeek-V3 (Nebius) (32K)", "backend": "deepseek-ai/DeepSeek-V3", "max_tokens": 32768},
357
+ {"provider": "nebius", "display": "🟦 DeepSeek-R1-Distill-Llama-70B (Nebius) (32K)", "backend": "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", "max_tokens": 32768},
358
+ {"provider": "nebius", "display": "🟦 Meta-Llama-3.3-70B-Instruct (Nebius) (32K)", "backend": "meta-llama/Llama-3.3-70B-Instruct", "max_tokens": 32768},
359
+ {"provider": "nebius", "display": "🟦 Meta-Llama-3.1-8B-Instruct (Nebius) (32K)", "backend": "meta-llama/Meta-Llama-3.1-8B-Instruct", "max_tokens": 32768},
360
+ {"provider": "nebius", "display": "🟦 Meta-Llama-3.1-70B-Instruct (Nebius) (32K)", "backend": "meta-llama/Meta-Llama-3.1-70B-Instruct", "max_tokens": 32768},
361
+ {"provider": "nebius", "display": "🟦 Meta-Llama-3.1-405B-Instruct (Nebius) (32K)", "backend": "meta-llama/Meta-Llama-3.1-405B-Instruct", "max_tokens": 32768},
362
+ {"provider": "nebius", "display": "🟦 NVIDIA Llama-3_1-Nemotron-Ultra-253B-v1 (Nebius) (32K)", "backend": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", "max_tokens": 32768},
363
+ {"provider": "nebius", "display": "🟦 NVIDIA Llama-3_3-Nemotron-Super-49B-v1 (Nebius) (32K)", "backend": "nvidia/Llama-3_3-Nemotron-Super-49B-v1", "max_tokens": 32768},
364
+ {"provider": "nebius", "display": "🟦 Mistral-Nemo-Instruct-2407 (Nebius) (32K)", "backend": "mistralai/Mistral-Nemo-Instruct-2407", "max_tokens": 32768},
365
+ {"provider": "nebius", "display": "🟦 Microsoft phi-4 (Nebius) (32K)", "backend": "microsoft/phi-4", "max_tokens": 32768},
366
+ {"provider": "nebius", "display": "🟦 Qwen3-235B-A22B (Nebius) (32K)", "backend": "Qwen/Qwen3-235B-A22B", "max_tokens": 32768},
367
+ {"provider": "nebius", "display": "🟦 Qwen3-30B-A3B (Nebius) (32K)", "backend": "Qwen/Qwen3-30B-A3B", "max_tokens": 32768},
368
+ {"provider": "nebius", "display": "🟦 Qwen3-32B (Nebius) (32K)", "backend": "Qwen/Qwen3-32B", "max_tokens": 32768},
369
+ {"provider": "nebius", "display": "🟦 Qwen3-14B (Nebius) (32K)", "backend": "Qwen/Qwen3-14B", "max_tokens": 32768},
370
+ {"provider": "nebius", "display": "🟦 Qwen3-4B-fast (Nebius) (32K)", "backend": "Qwen/Qwen3-4B-fast", "max_tokens": 32768},
371
+ {"provider": "nebius", "display": "🟦 QwQ-32B (Nebius) (32K)", "backend": "Qwen/QwQ-32B", "max_tokens": 32768},
372
+ {"provider": "nebius", "display": "🟦 Google Gemma-2-2b-it (Nebius) (32K)", "backend": "google/gemma-2-2b-it", "max_tokens": 32768},
373
+ {"provider": "nebius", "display": "🟦 Google Gemma-2-9b-it (Nebius) (32K)", "backend": "google/gemma-2-9b-it", "max_tokens": 32768},
374
+ {"provider": "nebius", "display": "🟦 Hermes-3-Llama-405B (Nebius) (32K)", "backend": "NousResearch/Hermes-3-Llama-405B", "max_tokens": 32768},
375
+ {"provider": "nebius", "display": "🟦 Llama3-OpenBioLLM-70B (Nebius, Medical) (32K)", "backend": "aaditya/Llama3-OpenBioLLM-70B", "max_tokens": 32768},
376
+ {"provider": "nebius", "display": "🟦 Qwen2.5-72B-Instruct (Nebius, Code) (32K)", "backend": "Qwen/Qwen2.5-72B-Instruct", "max_tokens": 32768},
377
+ {"provider": "nebius", "display": "🟦 Qwen2.5-Coder-7B (Nebius, Code) (32K)", "backend": "Qwen/Qwen2.5-Coder-7B", "max_tokens": 32768},
378
+ {"provider": "nebius", "display": "🟦 Qwen2.5-Coder-32B-Instruct (Nebius, Code) (32K)", "backend": "Qwen/Qwen2.5-Coder-32B-Instruct", "max_tokens": 32768},
379
+
380
+ # HuggingFace
381
+ {"provider": "hf_inference", "display": "🤗 Remote Meta-Llama-3 (HuggingFace) (32K)", "backend": "meta-llama/Meta-Llama-3-8B-Instruct", "max_tokens": 32768},
382
+ {"provider": "hf_inference", "display": "🤗 SciFive PubMed Classifier (HuggingFace) (32K)", "backend": "razent/SciFive-base-Pubmed_PMC", "max_tokens": 32768},
383
+ {"provider": "hf_inference", "display": "🤗 Tiny GPT-2 Classifier (HuggingFace) (32K)", "backend": "ydshieh/tiny-random-GPT2ForSequenceClassification", "max_tokens": 32768},
384
+ {"provider": "hf_inference", "display": "🤗 ArabianGPT QA (0.4B) (HuggingFace) (32K)", "backend": "gp-tar4/QA_FineTuned_ArabianGPT-03B", "max_tokens": 32768},
385
+ {"provider": "hf_inference", "display": "🤗 Tiny Mistral Classifier (HuggingFace) (32K)", "backend": "xshubhamx/tiny-mistral", "max_tokens": 32768},
386
+ {"provider": "hf_inference", "display": "🤗 Hallucination Scorer (HuggingFace) (32K)", "backend": "tcapelle/hallu_scorer", "max_tokens": 32768},
387
+
388
+ # Mistral
389
+ {"provider": "mistral", "display": "🇪🇺 Mistral-API (Mistral) (32K)", "backend": "mistral-small-latest", "max_tokens": 32768},
390
+
391
+ # OpenAI
392
+ {"provider": "openai", "display": "🇺🇸 GPT-3.5 (OpenAI) (16K)", "backend": "gpt-3.5-turbo", "max_tokens": 16384},
393
+ {"provider": "openai", "display": "🇺🇸 GPT-4o (OpenAI) (128K)", "backend": "gpt-4o", "max_tokens": 131072},
394
+ {"provider": "openai", "display": "🇺🇸 GPT-4o mini (OpenAI) (128K)", "backend": "gpt-4o-mini", "max_tokens": 131072},
395
+ {"provider": "openai", "display": "🇺🇸 o1-mini (OpenAI) (128K)", "backend": "o1-mini", "max_tokens": 131072},
396
+ {"provider": "openai", "display": "🇺🇸 o3-mini (OpenAI) (128K)", "backend": "o3-mini", "max_tokens": 131072},
397
+
398
+ # Grok (xAI)
399
+ {"provider": "grok", "display": "🦾 Grok 2 (xAI) (32K)", "backend": "grok-2", "max_tokens": 32768},
400
+ {"provider": "grok", "display": "🦾 Grok 3 (xAI) (32K)", "backend": "grok-3", "max_tokens": 32768},
401
+
402
+ # Anthropic
403
+ {"provider": "anthropic", "display": "🟧 Sonnet 4 (Anthropic) (200K)", "backend": "sonnet-4", "max_tokens": 204800},
404
+ {"provider": "anthropic", "display": "🟧 Sonnet 3.7 (Anthropic) (200K)", "backend": "sonnet-3.7", "max_tokens": 204800},
405
+
406
+ # Gemini (Google)
407
+ {"provider": "gemini", "display": "🔷 Gemini 2.5 Pro (Google) (1M)", "backend": "gemini-2.5-pro", "max_tokens": 1048576},
408
+ {"provider": "gemini", "display": "🔷 Gemini 2.5 Flash (Google) (1M)", "backend": "gemini-2.5-flash", "max_tokens": 1048576},
409
+ {"provider": "gemini", "display": "🔷 Gemini 2.5 Flash Lite Preview (Google) (1M)", "backend": "gemini-2.5-flash-lite-preview-06-17", "max_tokens": 1048576},
410
+ {"provider": "gemini", "display": "🔷 Gemini 2.0 Flash (Google) (1M)", "backend": "gemini-2.0-flash", "max_tokens": 1048576},
411
+ {"provider": "gemini", "display": "🔷 Gemini 2.0 Flash Preview Image Gen (Text+Image) (Google) (1M)", "backend": "gemini-2.0-flash-preview-image-generation", "max_tokens": 1048576},
412
+ {"provider": "gemini", "display": "🔷 Gemini 2.0 Flash Lite (Google) (1M)", "backend": "gemini-2.0-flash-lite", "max_tokens": 1048576},
413
+ ]
414
+
415
+ # Global variables for job management
416
+ jobs = {}
417
+ last_job_id = None
418
+
419
+ def get_pdf_files_from_source(source_path):
420
+ """Get list of PDF files from folder or URL(s)"""
421
+ pdf_files = []
422
+
423
+ if not source_path or source_path.strip() == "":
424
+ # Use current directory if no path specified
425
+ source_path = "."
426
+
427
+ # Check if it's a comma-separated list of URLs
428
+ if ',' in source_path and any(url.strip().startswith(('http://', 'https://')) or 'drive.google.com' in url.strip() for url in source_path.split(',')):
429
+ # Multiple URLs - split by comma and process each
430
+ urls = [url.strip() for url in source_path.split(',') if url.strip()]
431
+ for url in urls:
432
+ if url.startswith(('http://', 'https://')) or 'drive.google.com' in url:
433
+ # Check if it's a Google Drive folder
434
+ if '/folders/' in url or ('drive.google.com' in url and '/d/' not in url and 'open?id=' not in url):
435
+ debug_print(f"Detected Google Drive folder: {url}")
436
+ folder_files = list_files_in_gdrive_folder(url)
437
+ pdf_files.extend(folder_files)
438
+ debug_print(f"Added {len(folder_files)} files from folder")
439
+ else:
440
+ pdf_files.append(url)
441
+ debug_print(f"Added URL: {url}")
442
+ else:
443
+ debug_print(f"Skipping non-URL: {url}")
444
+ elif source_path.startswith(('http://', 'https://')) or 'drive.google.com' in source_path:
445
+ # Single URL source
446
+ if '/folders/' in source_path or ('drive.google.com' in source_path and '/d/' not in source_path and 'open?id=' not in source_path):
447
+ debug_print(f"Detected Google Drive folder: {source_path}")
448
+ folder_files = list_files_in_gdrive_folder(source_path)
449
+ pdf_files.extend(folder_files)
450
+ debug_print(f"Added {len(folder_files)} files from folder")
451
+ else:
452
+ pdf_files.append(source_path)
453
+ debug_print(f"Added single URL: {source_path}")
454
+ else:
455
+ # Local folder or file source
456
+ if os.path.isdir(source_path):
457
+ # Search for PDF files in the directory
458
+ pdf_pattern = os.path.join(source_path, "**", "*.pdf")
459
+ pdf_files = glob.glob(pdf_pattern, recursive=True)
460
+ debug_print(f"Found {len(pdf_files)} PDF files in directory: {source_path}")
461
+ elif os.path.isfile(source_path) and source_path.lower().endswith('.pdf'):
462
+ # Single PDF file
463
+ pdf_files.append(source_path)
464
+ debug_print(f"Added single PDF file: {source_path}")
465
+ else:
466
+ debug_print(f"Source path not found or not a PDF: {source_path}")
467
+
468
+ debug_print(f"Total PDF files to process: {len(pdf_files)}")
469
+ return pdf_files
470
+
471
+ def load_pdf_content(pdf_path):
472
+ """Load content from a PDF file"""
473
+ try:
474
+ if 'drive.google.com' in pdf_path:
475
+ # Google Drive PDF - handle directly to avoid pdfminer import issues
476
+ try:
477
+ full_text = load_file_from_google_drive(pdf_path)
478
+ if not full_text.strip():
479
+ debug_print(f"Empty content from Google Drive PDF: {pdf_path}")
480
+ return None
481
+ return full_text
482
+ except Exception as e:
483
+ debug_print(f"Error loading Google Drive PDF {pdf_path}: {e}")
484
+ return None
485
+ elif pdf_path.startswith(('http://', 'https://')):
486
+ # Online PDF - use OnlinePDFLoader for non-Google Drive URLs
487
+ try:
488
+ loader = OnlinePDFLoader(pdf_path)
489
+ documents = loader.load()
490
+ # Combine all pages into a single text
491
+ full_text = "\n\n".join([doc.page_content for doc in documents])
492
+ return full_text
493
+ except Exception as e:
494
+ debug_print(f"Error loading online PDF {pdf_path}: {e}")
495
+ return None
496
+ else:
497
+ # Local PDF
498
+ loader = PyPDFLoader(pdf_path)
499
+ documents = loader.load()
500
+ # Combine all pages into a single text
501
+ full_text = "\n\n".join([doc.page_content for doc in documents])
502
+ return full_text
503
+ except Exception as e:
504
+ debug_print(f"Error loading PDF {pdf_path}: {e}")
505
+ return None
506
+
507
+ def submit_query_updated(query, model, temperature, top_p, top_k, max_tokens):
508
+ """Submit query to the specified model"""
509
+ debug_print(f"Submitting query to {model}")
510
+
511
+ try:
512
+ # Find the model configuration to determine provider
513
+ model_config = next((m for m in models if m["backend"] == model), None)
514
+ if not model_config:
515
+ # Fallback to pattern matching if model not found in config
516
+ if model.startswith("gpt-") or model.startswith("o1-") or model.startswith("o3-"):
517
+ return call_openai_api(query, model, temperature, top_p, top_k, max_tokens)
518
+ elif model.startswith("gemini-"):
519
+ return call_gemini_api(query, model, temperature, top_p, top_k, max_tokens)
520
+ elif model.startswith("mistral-") or model.startswith("nemo-"):
521
+ return call_mistral_api(query, model, temperature, top_p, top_k, max_tokens)
522
+ elif model.startswith("claude-") or model.startswith("sonnet-"):
523
+ return call_anthropic_api(query, model, temperature, top_p, top_k, max_tokens)
524
+ elif model.startswith("grok-"):
525
+ return call_grok_api(query, model, temperature, top_p, top_k, max_tokens)
526
+ else:
527
+ return call_generic_api(query, model, temperature, top_p, top_k, max_tokens)
528
+
529
+ # Route based on provider
530
+ provider = model_config["provider"]
531
+ if provider == "openai":
532
+ return call_openai_api(query, model, temperature, top_p, top_k, max_tokens)
533
+ elif provider == "gemini":
534
+ return call_gemini_api(query, model, temperature, top_p, top_k, max_tokens)
535
+ elif provider == "mistral":
536
+ return call_mistral_api(query, model, temperature, top_p, top_k, max_tokens)
537
+ elif provider == "anthropic":
538
+ return call_anthropic_api(query, model, temperature, top_p, top_k, max_tokens)
539
+ elif provider == "grok":
540
+ return call_grok_api(query, model, temperature, top_p, top_k, max_tokens)
541
+ elif provider == "nebius":
542
+ return call_nebius_api(query, model, temperature, top_p, top_k, max_tokens)
543
+ elif provider == "hf_inference":
544
+ return call_huggingface_api(query, model, temperature, top_p, top_k, max_tokens)
545
+ else:
546
+ return call_generic_api(query, model, temperature, top_p, top_k, max_tokens)
547
+
548
+ except Exception as e:
549
+ debug_print(f"Error calling API for {model}: {e}")
550
+ # Return error response
551
+ error_response = f"Error calling {model}: {str(e)}"
552
+ return error_response, None, len(query.split()), len(error_response.split())
553
+
554
+ def call_openai_api(query, model, temperature, top_p, top_k, max_tokens):
555
+ """Call OpenAI API"""
556
+ try:
557
+ if openai is None:
558
+ raise Exception("OpenAI library not installed. Install with: pip install openai")
559
+ client = openai.OpenAI(api_key=OPENAI_API_KEY)
560
+
561
+ response = client.chat.completions.create(
562
+ model=model,
563
+ messages=[{"role": "user", "content": query}],
564
+ temperature=temperature,
565
+ top_p=top_p,
566
+ max_tokens=min(max_tokens, 131072) # Cap at 128K
567
+ )
568
+
569
+ content = response.choices[0].message.content
570
+ input_tokens = response.usage.prompt_tokens
571
+ output_tokens = response.usage.completion_tokens
572
+
573
+ return content, None, input_tokens, output_tokens
574
+
575
+ except Exception as e:
576
+ raise Exception(f"OpenAI API error: {e}")
577
+
578
+ def call_gemini_api(query, model, temperature, top_p, top_k, max_tokens):
579
+ """Call Google Gemini API"""
580
+ try:
581
+ if genai is None:
582
+ raise Exception("Google Generative AI library not installed. Install with: pip install google-generativeai")
583
+ genai.configure(api_key=GEMINI_API_KEY)
584
+
585
+ # Map model names to Gemini model names
586
+ model_mapping = {
587
+ "gemini-2.5-pro": "gemini-2.0-flash-exp",
588
+ "gemini-2.5-flash": "gemini-2.0-flash-exp",
589
+ "gemini-2.5-flash-lite-preview-06-17": "gemini-2.0-flash-exp",
590
+ "gemini-2.0-flash": "gemini-2.0-flash-exp",
591
+ "gemini-2.0-flash-preview-image-generation": "gemini-2.0-flash-exp",
592
+ "gemini-2.0-flash-lite": "gemini-2.0-flash-exp"
593
+ }
594
+
595
+ gemini_model_name = model_mapping.get(model, "gemini-2.0-flash-exp")
596
+
597
+ # Configure generation parameters
598
+ generation_config = genai.types.GenerationConfig(
599
+ temperature=temperature,
600
+ top_p=top_p,
601
+ top_k=top_k,
602
+ max_output_tokens=min(max_tokens, 1048576) # Cap at 1M
603
+ )
604
+
605
+ # Get the model
606
+ model_instance = genai.GenerativeModel(gemini_model_name)
607
+
608
+ # Generate content
609
+ response = model_instance.generate_content(
610
+ query,
611
+ generation_config=generation_config
612
+ )
613
+
614
+ content = response.text
615
+ # Estimate tokens (rough approximation
616
+ input_tokens = len(query.split())
617
+ output_tokens = len(content.split())
618
+
619
+ return content, None, input_tokens, output_tokens
620
+
621
+ except Exception as e:
622
+ raise Exception(f"Gemini API error: {e}")
623
+
624
+ def call_mistral_api(query, model, temperature, top_p, top_k, max_tokens):
625
+ """Call Mistral API"""
626
+ try:
627
+ if not MISTRAL_AVAILABLE or Mistral is None:
628
+ raise Exception("Mistral AI library not installed. Install with: pip install mistralai")
629
+
630
+ client = Mistral(api_key=MISTRAL_API_KEY)
631
+
632
+ # Map model names
633
+ model_mapping = {
634
+ "mistral-small-latest": "mistral-small-latest"
635
+ }
636
+
637
+ mistral_model = model_mapping.get(model, "mistral-small-latest")
638
+
639
+ response = client.chat.complete(
640
+ model=mistral_model,
641
+ messages=[{"role": "user", "content": query}],
642
+ temperature=temperature,
643
+ top_p=top_p,
644
+ max_tokens=min(max_tokens, 32768)
645
+ )
646
+
647
+ content = response.choices[0].message.content
648
+ input_tokens = response.usage.prompt_tokens
649
+ output_tokens = response.usage.completion_tokens
650
+
651
+ return content, None, input_tokens, output_tokens
652
+
653
+ except Exception as e:
654
+ raise Exception(f"Mistral API error: {e}")
655
+
656
+ def call_anthropic_api(query, model, temperature, top_p, top_k, max_tokens):
657
+ """Call Anthropic API"""
658
+ try:
659
+ if anthropic is None:
660
+ raise Exception("Anthropic library not installed. Install with: pip install anthropic")
661
+
662
+ client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
663
+
664
+ # Map model names
665
+ model_mapping = {
666
+ "sonnet-4": "claude-3-5-sonnet-20241022",
667
+ "sonnet-3.7": "claude-3-5-sonnet-20241022"
668
+ }
669
+
670
+ anthropic_model = model_mapping.get(model, "claude-3-5-sonnet-20241022")
671
+
672
+ response = client.messages.create(
673
+ model=anthropic_model,
674
+ max_tokens=min(max_tokens, 204800),
675
+ temperature=temperature,
676
+ messages=[{"role": "user", "content": query}]
677
+ )
678
+
679
+ content = response.content[0].text
680
+ input_tokens = response.usage.input_tokens
681
+ output_tokens = response.usage.output_tokens
682
+
683
+ return content, None, input_tokens, output_tokens
684
+
685
+ except Exception as e:
686
+ raise Exception(f"Anthropic API error: {e}")
687
+
688
+ def call_grok_api(query, model, temperature, top_p, top_k, max_tokens):
689
+ """Call Grok API"""
690
+ try:
691
+ if not GROK_API_KEY:
692
+ raise Exception("Grok API key not set. Please set GROK_API_KEY environment variable.")
693
+
694
+ headers = {
695
+ "Authorization": f"Bearer {GROK_API_KEY}",
696
+ "Content-Type": "application/json"
697
+ }
698
+ data = {
699
+ "model": model,
700
+ "messages": [{"role": "user", "content": query}],
701
+ "temperature": temperature,
702
+ "top_p": top_p,
703
+ "max_tokens": min(max_tokens, 32768)
704
+ }
705
+
706
+ response = requests.post("https://api.x.ai/v1/chat/completions", headers=headers, json=data, timeout=60)
707
+ response.raise_for_status()
708
+ result = response.json()
709
+
710
+ content = result["choices"][0]["message"]["content"]
711
+ # Estimate tokens (rough approximation)
712
+ input_tokens = len(query.split())
713
+ output_tokens = len(content.split())
714
+
715
+ return content, None, input_tokens, output_tokens
716
+
717
+ except Exception as e:
718
+ raise Exception(f"Grok API error: {e}")
719
+
720
+ def call_nebius_api(query, model, temperature, top_p, top_k, max_tokens):
721
+ """Call Nebius API"""
722
+ try:
723
+ if not NEBIUS_API_KEY:
724
+ raise Exception("Nebius API key not set. Please set NEBIUS_API_KEY environment variable.")
725
+
726
+ if OpenAI is None:
727
+ raise Exception("OpenAI library not installed. Install with: pip install openai")
728
+
729
+ client = OpenAI(base_url="https://api.studio.nebius.com/v1/", api_key=NEBIUS_API_KEY)
730
+
731
+ response = client.chat.completions.create(
732
+ model=model,
733
+ messages=[{"role": "user", "content": query}],
734
+ temperature=temperature,
735
+ top_p=top_p,
736
+ max_tokens=min(max_tokens, 32768)
737
+ )
738
+
739
+ content = response.choices[0].message.content
740
+ input_tokens = response.usage.prompt_tokens
741
+ output_tokens = response.usage.completion_tokens
742
+
743
+ return content, None, input_tokens, output_tokens
744
+
745
+ except Exception as e:
746
+ raise Exception(f"Nebius API error: {e}")
747
+
748
+ def call_huggingface_api(query, model, temperature, top_p, top_k, max_tokens):
749
+ """Call HuggingFace Inference API"""
750
+ try:
751
+ if not HF_API_TOKEN:
752
+ raise Exception("HuggingFace API token not set. Please set HF_API_TOKEN environment variable.")
753
+
754
+ headers = {
755
+ "Authorization": f"Bearer {HF_API_TOKEN}",
756
+ "Content-Type": "application/json"
757
+ }
758
+
759
+ data = {
760
+ "inputs": query,
761
+ "parameters": {
762
+ "temperature": temperature,
763
+ "top_p": top_p,
764
+ "max_new_tokens": min(max_tokens, 32768),
765
+ "return_full_text": False
766
+ }
767
+ }
768
+
769
+ # Use the HuggingFace Inference API
770
+ api_url = f"https://api-inference.huggingface.co/models/{model}"
771
+
772
+ response = requests.post(api_url, headers=headers, json=data, timeout=60)
773
+ response.raise_for_status()
774
+ result = response.json()
775
+
776
+ # Handle different response formats
777
+ if isinstance(result, list) and len(result) > 0:
778
+ if "generated_text" in result[0]:
779
+ content = result[0]["generated_text"]
780
+ elif "text" in result[0]:
781
+ content = result[0]["text"]
782
+ else:
783
+ content = str(result[0])
784
+ else:
785
+ content = str(result)
786
+
787
+ # Estimate tokens (rough approximation)
788
+ input_tokens = len(query.split())
789
+ output_tokens = len(content.split())
790
+
791
+ return content, None, input_tokens, output_tokens
792
+
793
+ except Exception as e:
794
+ raise Exception(f"HuggingFace API error: {e}")
795
+
796
+ def call_generic_api(query, model, temperature, top_p, top_k, max_tokens):
797
+ """Call generic API (for HuggingFace, Nebius, etc.)"""
798
+ try:
799
+ # Determine provider based on model backend
800
+ if "nebius" in model.lower() or any(provider in model for provider in ["deepseek-ai", "meta-llama", "nvidia", "mistralai", "microsoft", "Qwen", "google", "NousResearch", "aaditya"]):
801
+ return call_nebius_api(query, model, temperature, top_p, top_k, max_tokens)
802
+ elif "hf_inference" in model.lower() or any(provider in model for provider in ["razent", "ydshieh", "gp-tar4", "xshubhamx", "tcapelle"]):
803
+ return call_huggingface_api(query, model, temperature, top_p, top_k, max_tokens)
804
+ else:
805
+ # Fallback for unknown models
806
+ response = f"Generic API call to {model} - provider not recognized"
807
+ input_tokens = len(query.split())
808
+ output_tokens = len(response.split())
809
+ return response, None, input_tokens, output_tokens
810
+ except Exception as e:
811
+ debug_print(f"Error in generic API call: {e}")
812
+ return f"Error: {e}", None, 0, 0
813
+
814
+ def extract_columns_from_query(query):
815
+ """Extract column names from the query text"""
816
+ import re
817
+
818
+ columns = []
819
+
820
+ # Look for the pattern "following columns" and extract everything after it
821
+ # This handles multi-line column lists - updated to handle single newlines too
822
+ following_pattern = r'following\s+columns?[:\s]*\n(.*?)(?:\n\n|\Z)'
823
+ match = re.search(following_pattern, query, re.IGNORECASE | re.DOTALL)
824
+
825
+ if match:
826
+ # Extract the column list
827
+ column_text = match.group(1).strip()
828
+ # Split by newlines and clean up each line
829
+ lines = column_text.split('\n')
830
+ for line in lines:
831
+ line = line.strip()
832
+ if line and not line.startswith('Extract') and not line.startswith('Query'):
833
+ # Remove any leading numbers, bullets, or special characters
834
+ line = re.sub(r'^\s*[-•\d\.\)\s]*', '', line)
835
+ if line and len(line) > 1: # Make sure it's not just a single character
836
+ columns.append(line)
837
+
838
+ # If no columns found with double newline pattern, try single newline pattern
839
+ if not columns:
840
+ # Look for pattern that captures everything after "following columns" until end or next major section
841
+ single_newline_pattern = r'following\s+columns?[:\s]*\n(.*?)(?:\n\s*[A-Z][a-z]*\s*[A-Z]|\Z)'
842
+ match = re.search(single_newline_pattern, query, re.IGNORECASE | re.DOTALL)
843
+
844
+ if match:
845
+ # Extract the column list
846
+ column_text = match.group(1).strip()
847
+ # Split by newlines and clean up each line
848
+ lines = column_text.split('\n')
849
+ for line in lines:
850
+ line = line.strip()
851
+ if line and not line.startswith('Extract') and not line.startswith('Query'):
852
+ # Remove any leading numbers, bullets, or special characters
853
+ line = re.sub(r'^\s*[-•\d\.\)\s]*', '', line)
854
+ if line and len(line) > 1: # Make sure it's not just a single character
855
+ columns.append(line)
856
+
857
+ # If still no columns found, try a more aggressive approach - capture everything after "following columns"
858
+ if not columns:
859
+ # Look for "following columns" and capture everything after it until the end
860
+ aggressive_pattern = r'following\s+columns?[:\s]*\n(.*)'
861
+ match = re.search(aggressive_pattern, query, re.IGNORECASE | re.DOTALL)
862
+
863
+ if match:
864
+ # Extract the column list
865
+ column_text = match.group(1).strip()
866
+ # Split by newlines and clean up each line
867
+ lines = column_text.split('\n')
868
+ for line in lines:
869
+ line = line.strip()
870
+ if line and not line.startswith('Extract') and not line.startswith('Query'):
871
+ # Remove any leading numbers, bullets, or special characters
872
+ line = re.sub(r'^\s*[-•\d\.\)\s]*', '', line)
873
+ if line and len(line) > 1: # Make sure it's not just a single character
874
+ columns.append(line)
875
+
876
+ # If no columns found with the main pattern, try alternative patterns
877
+ if not columns:
878
+ # Look for patterns like "columns:" followed by a list
879
+ column_patterns = [
880
+ r'columns?[:\s]*\n(.*?)(?:\n\n|\Z)',
881
+ r'(?:extract|get|find).*?columns?[:\s]*\n(.*?)(?:\n\n|\Z)',
882
+ ]
883
+
884
+ for pattern in column_patterns:
885
+ matches = re.findall(pattern, query, re.IGNORECASE | re.DOTALL)
886
+ for match in matches:
887
+ # Split by newlines and clean up
888
+ lines = match.strip().split('\n')
889
+ for line in lines:
890
+ line = line.strip()
891
+ if line and not line.startswith('Extract') and not line.startswith('Query'):
892
+ # Remove any leading numbers or bullets
893
+ line = re.sub(r'^\s*[-•\d\.\)\s]*', '', line)
894
+ if line and len(line) > 1:
895
+ columns.append(line)
896
+ if columns: # If we found columns, break out of the loop
897
+ break
898
+ if columns: # If we found columns, break out of the outer loop
899
+ break
900
+
901
+ # If still no columns found, try to find individual column mentions
902
+ if not columns:
903
+ # Look for common column names in the query
904
+ common_columns = [
905
+ 'Title', 'Authors', 'Journal', 'Year', 'Analysis Method',
906
+ 'Methodology Detail', 'Performance Parameters', 'Study Population',
907
+ 'Study Type', 'Results', 'Metrics from Results', 'Quantitative Data',
908
+ 'Qualitative Data', 'Abstract', 'Keywords', 'DOI', 'Volume', 'Issue',
909
+ 'Pages', 'Publisher', 'Language', 'Country', 'Institution'
910
+ ]
911
+
912
+ for col in common_columns:
913
+ if col.lower() in query.lower():
914
+ columns.append(col)
915
+
916
+ # Remove duplicates while preserving order
917
+ seen = set()
918
+ unique_columns = []
919
+ for col in columns:
920
+ if col not in seen:
921
+ seen.add(col)
922
+ unique_columns.append(col)
923
+
924
+ # Always add Raw Response
925
+ if 'Raw Response' not in unique_columns:
926
+ unique_columns.append('Raw Response')
927
+
928
+ return unique_columns
929
+
930
+ def parse_structured_response(response, query, pdf_content=None):
931
+ """Parse LLM response to extract structured data based on query instructions"""
932
+ # Extract columns from query dynamically
933
+ columns = extract_columns_from_query(query)
934
+
935
+ # Create default structure with dynamic columns
936
+ default_structure = {col: '' for col in columns}
937
+ # Ensure Raw Response is always included (no truncation)
938
+ default_structure['Raw Response'] = response
939
+
940
+ try:
941
+ # Check if the query asks for specific CSV columns
942
+ if any(keyword in query.lower() for keyword in ['csv', 'columns', 'title', 'authors', 'journal', 'year']):
943
+ # Try to extract structured data from the response
944
+ lines = response.split('\n')
945
+ structured_data = {}
946
+
947
+ # Look for key-value pairs in the response
948
+ for line in lines:
949
+ line = line.strip()
950
+ if ':' in line:
951
+ # Try to find the best split point for key-value pairs
952
+ # Look for patterns where the key might be a full column name
953
+ best_split = None
954
+ best_score = 0
955
+
956
+ # Try different split points
957
+ for i, char in enumerate(line):
958
+ if char == ':':
959
+ potential_key = line[:i].strip()
960
+ potential_value = line[i+1:].strip()
961
+
962
+ # Score this split based on how well the key matches our expected columns
963
+ key_lower = potential_key.lower()
964
+ score = 0
965
+
966
+ for expected_col in columns:
967
+ expected_lower = expected_col.lower()
968
+ if key_lower == expected_lower:
969
+ score = 100 # Exact match
970
+ break
971
+ elif key_lower in expected_lower or expected_lower in key_lower:
972
+ # Partial match - score based on overlap
973
+ overlap = len(set(key_lower.split()) & set(expected_lower.split()))
974
+ score = max(score, overlap * 10)
975
+
976
+ if score > best_score:
977
+ best_score = score
978
+ best_split = (potential_key, potential_value)
979
+
980
+ if best_split and best_score > 5: # Minimum threshold
981
+ key, value = best_split
982
+ else:
983
+ # Fallback to simple first colon split
984
+ if len(line.split(':', 1)) == 2:
985
+ key, value = line.split(':', 1)
986
+ key = key.strip()
987
+ value = value.strip()
988
+ else:
989
+ continue
990
+
991
+ # Clean up value - remove any duplicate key information
992
+ # Sometimes the LLM includes the key again in the value
993
+ if value.startswith(key + ':'):
994
+ value = value[len(key) + 1:].strip()
995
+
996
+ # Try to match the key to one of our expected columns (case insensitive)
997
+ key_lower = key.lower().strip()
998
+ matched_column = None
999
+
1000
+ # First try exact matches
1001
+ for expected_col in columns:
1002
+ if expected_col.lower() == key_lower:
1003
+ matched_column = expected_col
1004
+ break
1005
+
1006
+ # If no exact match and we used fallback split, try to find the best matching column
1007
+ if not matched_column and best_split is None:
1008
+ # This means we used the fallback split, so try to find the best match
1009
+ best_match = None
1010
+ best_score = 0
1011
+
1012
+ for expected_col in columns:
1013
+ expected_lower = expected_col.lower()
1014
+ score = 0
1015
+
1016
+ # Check if the key is a significant part of the expected column
1017
+ if key_lower in expected_lower:
1018
+ score = (len(key_lower) / len(expected_lower)) * 50
1019
+ # Bonus for common important fields
1020
+ if key_lower in ['title', 'authors', 'journal', 'year', 'results', 'study', 'context', 'setting', 'language', 'population', 'concept']:
1021
+ score += 30
1022
+ elif expected_lower in key_lower:
1023
+ score = (len(expected_lower) / len(key_lower)) * 40
1024
+ if key_lower in ['title', 'authors', 'journal', 'year', 'results', 'study', 'context', 'setting', 'language', 'population', 'concept']:
1025
+ score += 30
1026
+
1027
+ if score > best_score and score > 15: # Higher threshold for fallback
1028
+ best_score = score
1029
+ best_match = expected_col
1030
+
1031
+ if best_match:
1032
+ matched_column = best_match
1033
+
1034
+ # If no exact match, try partial matches with better scoring
1035
+ if not matched_column:
1036
+ best_match = None
1037
+ best_score = 0
1038
+
1039
+ for expected_col in columns:
1040
+ expected_lower = expected_col.lower()
1041
+ score = 0
1042
+
1043
+ # Exact match gets highest score
1044
+ if key_lower == expected_lower:
1045
+ score = 100
1046
+ # Key is contained in expected column
1047
+ elif key_lower in expected_lower:
1048
+ # Score based on how much of the key matches
1049
+ score = (len(key_lower) / len(expected_lower)) * 50
1050
+ # Bonus for common important fields
1051
+ if key_lower in ['title', 'authors', 'journal', 'year', 'results']:
1052
+ score += 20
1053
+ # Expected column is contained in the key (reverse case)
1054
+ elif expected_lower in key_lower:
1055
+ # Score based on how much of the expected column matches
1056
+ score = (len(expected_lower) / len(key_lower)) * 40
1057
+ # Bonus for common important fields
1058
+ if key_lower in ['title', 'authors', 'journal', 'year', 'results', 'study', 'context', 'setting']:
1059
+ score += 20
1060
+ # Expected column key words are in the response key
1061
+ else:
1062
+ expected_words = [word for word in expected_lower.split() if len(word) > 3]
1063
+ matching_words = sum(1 for word in expected_words if word in key_lower)
1064
+ if matching_words > 0:
1065
+ score = (matching_words / len(expected_words)) * 30
1066
+
1067
+ if score > best_score and score > 10: # Minimum threshold
1068
+ best_score = score
1069
+ best_match = expected_col
1070
+
1071
+ if best_match:
1072
+ matched_column = best_match
1073
+
1074
+ if matched_column:
1075
+ structured_data[matched_column] = value
1076
+
1077
+ # Also try to extract from the PDF content itself if the LLM didn't format properly
1078
+ if not structured_data:
1079
+ # Look for common patterns in the PDF content
1080
+ pdf_content_start = response.find("PDF Content:")
1081
+ if pdf_content_start != -1:
1082
+ pdf_content = response[pdf_content_start:].split('\n')
1083
+
1084
+ # Try to extract title (usually first line after "PDF Content:")
1085
+ for i, line in enumerate(pdf_content[1:6]): # Check first 5 lines
1086
+ line = line.strip()
1087
+ if line and not line.startswith('Vol.:') and not line.startswith('RESEARCH ARTICLE') and not line.startswith('You are'):
1088
+ structured_data['Title'] = line
1089
+ break
1090
+
1091
+ # Look for authors (often contains "et al" or multiple names)
1092
+ for line in pdf_content:
1093
+ if 'et al' in line.lower() or (',' in line and len(line.split(',')) > 2):
1094
+ structured_data['Authors'] = line.strip()
1095
+ break
1096
+
1097
+ # Look for year (4-digit number)
1098
+ import re
1099
+ year_match = re.search(r'\b(19|20)\d{2}\b', response)
1100
+ if year_match:
1101
+ structured_data['Year'] = year_match.group()
1102
+
1103
+ # If still no structured data, try to extract from the original PDF content
1104
+ if not structured_data and 'pdf_content' in locals():
1105
+ # Try to extract basic information from the PDF content directly
1106
+ lines = pdf_content.split('\n') if isinstance(pdf_content, str) else pdf_content
1107
+
1108
+ # Look for title (first substantial line)
1109
+ for line in lines[:10]:
1110
+ line = line.strip()
1111
+ if line and len(line) > 10 and not line.startswith('Vol.:') and not line.startswith('RESEARCH ARTICLE'):
1112
+ structured_data['Title'] = line
1113
+ break
1114
+
1115
+ # Look for authors
1116
+ for line in lines:
1117
+ if 'et al' in line.lower() or (',' in line and len(line.split(',')) > 2):
1118
+ structured_data['Authors'] = line.strip()
1119
+ break
1120
+
1121
+ # Look for year
1122
+ year_match = re.search(r'\b(19|20)\d{2}\b', pdf_content if isinstance(pdf_content, str) else ' '.join(pdf_content))
1123
+ if year_match:
1124
+ structured_data['Year'] = year_match.group()
1125
+
1126
+ # If we found some structured data, use it
1127
+ if structured_data:
1128
+ # Fill in missing fields with empty strings
1129
+ for key in default_structure:
1130
+ if key not in structured_data and key != 'Raw Response':
1131
+ structured_data[key] = ''
1132
+ structured_data['Raw Response'] = response
1133
+ return structured_data
1134
+
1135
+ # If no structured data found or query doesn't ask for it, return default
1136
+ return default_structure
1137
+
1138
+ except Exception as e:
1139
+ debug_print(f"Error parsing structured response: {e}")
1140
+ return default_structure
1141
+
1142
+ def process_pdf_with_llm(pdf_content, pdf_name, query, model, temperature, top_p, top_k, max_tokens):
1143
+ """Process PDF content with LLM to extract information"""
1144
+ # Check if the query asks for structured data extraction
1145
+ is_structured_query = any(keyword in query.lower() for keyword in ['csv', 'columns', 'title', 'authors', 'journal', 'year'])
1146
+
1147
+ if is_structured_query:
1148
+ # Extract columns from query dynamically
1149
+ columns = extract_columns_from_query(query)
1150
+
1151
+ # Build the prompt with dynamic columns
1152
+ column_prompts = []
1153
+ for col in columns:
1154
+ if col != 'Raw Response': # Don't include Raw Response in the prompt
1155
+ column_prompts.append(f"{col}: [value here]")
1156
+
1157
+ columns_text = "\n".join(column_prompts)
1158
+
1159
+ # Enhanced prompt for structured data extraction
1160
+ full_prompt = f"""
1161
+ Extract the following information from the PDF and respond ONLY with this exact format:
1162
+
1163
+ {columns_text}
1164
+
1165
+ PDF Content:
1166
+ {pdf_content}
1167
+
1168
+ CRITICAL INSTRUCTIONS:
1169
+ 1. Your response must start with the first column name and contain only the structured data above. No other text.
1170
+ 2. For the Context column about "dark moves": If you find evidence of lying, misleading, manipulation, or other deceptive practices by the AI, describe them specifically. If you find NO dark moves, you MUST provide a detailed explanation of WHY there are none, including specific evidence from the paper (e.g., "No dark moves found because the study explicitly tested for deception and found none" or "The AI was designed with transparency measures that prevented misleading responses").
1171
+ 3. Be thorough and specific in your analysis - don't just say "no dark moves" without explaining the reasoning.
1172
+ 4. In the column include / exclude recommendation - consider all the criteria above and provide an answer based on the other columns. If a "must" column is not met, then exclude.
1173
+ """
1174
+ else:
1175
+ # Standard prompt for general queries
1176
+ full_prompt = f"""
1177
+ PDF Content:
1178
+ {pdf_content}
1179
+
1180
+ Query/Instructions:
1181
+ {query}
1182
+
1183
+ Please extract the requested information from the PDF content above and format it according to the instructions.
1184
+ """
1185
+
1186
+ response, _, input_tokens, output_tokens = submit_query_updated(
1187
+ full_prompt, model, temperature, top_p, top_k, max_tokens
1188
+ )
1189
+
1190
+ # Calculate token sufficiency information
1191
+ total_input_tokens = len(full_prompt.split()) # Rough estimate
1192
+ token_sufficiency = "Sufficient" if total_input_tokens <= max_tokens else "Truncated"
1193
+ token_info = f"Input tokens: {total_input_tokens}/{max_tokens} ({token_sufficiency})"
1194
+
1195
+ # Debug: Print the actual response for structured queries
1196
+ if is_structured_query:
1197
+ debug_print(f"Structured query response for {pdf_name}: {response[:1000]}...")
1198
+ debug_print(f"Response starts with: {response[:100]}")
1199
+ debug_print(f"Token usage: {token_info}")
1200
+
1201
+ return response, input_tokens, output_tokens, token_info
1202
+
1203
+ def process_pdf_batch_job(job_id, source_path, query, selected_models, temperature, top_p, top_k, max_tokens, csv_prefix):
1204
+ """Process all PDFs with selected models and save results to CSV"""
1205
+ global jobs
1206
+
1207
+ try:
1208
+ # Get list of PDF files
1209
+ pdf_files = get_pdf_files_from_source(source_path)
1210
+ debug_print(f"Found {len(pdf_files)} PDF files: {[os.path.basename(f) for f in pdf_files]}")
1211
+ debug_print(f"Selected models: {selected_models}")
1212
+
1213
+ if not pdf_files:
1214
+ jobs[job_id]["status"] = "completed"
1215
+ jobs[job_id]["error"] = "No PDF files found in the specified source"
1216
+ return
1217
+
1218
+ if not selected_models:
1219
+ jobs[job_id]["status"] = "completed"
1220
+ jobs[job_id]["error"] = "No models selected"
1221
+ return
1222
+
1223
+ results = []
1224
+ total_processed = 0
1225
+ total_to_process = len(pdf_files) * len(selected_models)
1226
+ debug_print(f"Total to process: {total_to_process} (PDFs: {len(pdf_files)}, Models: {len(selected_models)})")
1227
+
1228
+ # Initialize job progress
1229
+ jobs[job_id]["partial_results"] = {
1230
+ "num_done": 0,
1231
+ "total": total_to_process,
1232
+ "current_pdf": "Starting...",
1233
+ "current_model": ""
1234
+ }
1235
+
1236
+ for pdf_path in pdf_files:
1237
+ # Extract proper PDF name from Google Drive URLs
1238
+ if 'drive.google.com' in pdf_path:
1239
+ try:
1240
+ file_id = extract_file_id(pdf_path)
1241
+ pdf_name = f"gdrive_{file_id}.pdf"
1242
+ except:
1243
+ pdf_name = os.path.basename(pdf_path)
1244
+ else:
1245
+ pdf_name = os.path.basename(pdf_path)
1246
+
1247
+ debug_print(f"Processing PDF: {pdf_name}")
1248
+ debug_print(f"Full URL: {pdf_path}")
1249
+
1250
+ # Load PDF content
1251
+ pdf_content = load_pdf_content(pdf_path)
1252
+ if pdf_content is None:
1253
+ debug_print(f"Failed to load content from {pdf_name} (URL: {pdf_path})")
1254
+ # Still count this as processed to maintain progress accuracy
1255
+ for model_display in selected_models:
1256
+ total_processed += 1
1257
+ jobs[job_id]["partial_results"] = {
1258
+ "num_done": total_processed,
1259
+ "total": total_to_process,
1260
+ "current_pdf": pdf_name,
1261
+ "current_model": f"Failed to load PDF"
1262
+ }
1263
+ continue
1264
+
1265
+ for model_display in selected_models:
1266
+ # Find the model configuration
1267
+ model_config = next((m for m in models if m["display"] == model_display), None)
1268
+ if not model_config:
1269
+ debug_print(f"Model configuration not found for {model_display}")
1270
+ total_processed += 1
1271
+ jobs[job_id]["partial_results"] = {
1272
+ "num_done": total_processed,
1273
+ "total": total_to_process,
1274
+ "current_pdf": pdf_name,
1275
+ "current_model": f"Model not found: {model_display}"
1276
+ }
1277
+ continue
1278
+
1279
+ # Use model-specific max_tokens if available, otherwise use the slider value
1280
+ model_max_tokens = model_config.get("max_tokens", max_tokens)
1281
+ model_backend = model_config["backend"]
1282
+
1283
+ debug_print(f"Processing {pdf_name} with {model_display} (backend: {model_backend}, max_tokens: {model_max_tokens})")
1284
+
1285
+ # Process PDF with LLM
1286
+ response, input_tokens, output_tokens, token_info = process_pdf_with_llm(
1287
+ pdf_content, pdf_name, query, model_backend, temperature, top_p, top_k, model_max_tokens
1288
+ )
1289
+
1290
+ # Parse structured response
1291
+ structured_data = parse_structured_response(response, query, pdf_content)
1292
+
1293
+ # Store result with structured data
1294
+ result = {
1295
+ 'pdf_name': pdf_name,
1296
+ 'pdf_path': pdf_path,
1297
+ 'model': model_display,
1298
+ 'model_backend': model_backend,
1299
+ 'query': query,
1300
+ 'input_tokens': input_tokens,
1301
+ 'output_tokens': output_tokens,
1302
+ 'token_sufficiency': token_info,
1303
+ 'timestamp': datetime.datetime.now().isoformat()
1304
+ }
1305
+
1306
+ # Add structured fields
1307
+ result.update(structured_data)
1308
+
1309
+ results.append(result)
1310
+
1311
+ total_processed += 1
1312
+
1313
+ # Update job progress
1314
+ jobs[job_id]["partial_results"] = {
1315
+ "num_done": total_processed,
1316
+ "total": total_to_process,
1317
+ "current_pdf": pdf_name,
1318
+ "current_model": model_display
1319
+ }
1320
+
1321
+ # Save results to CSV
1322
+ sanitize = lambda s: re.sub(r'[^A-Za-z0-9_-]+', '', str(s).replace(' ', '_'))
1323
+ safe_prefix = sanitize(csv_prefix) if csv_prefix else 'pdf_results'
1324
+ date_str = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
1325
+ filename = f"{safe_prefix}_{date_str}.csv"
1326
+
1327
+ with open(filename, 'w', newline='', encoding='utf-8') as csvfile:
1328
+ if results:
1329
+ fieldnames = results[0].keys()
1330
+ writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
1331
+ writer.writeheader()
1332
+ writer.writerows(results)
1333
+
1334
+ jobs[job_id]["status"] = "completed"
1335
+ jobs[job_id]["csv_file"] = filename
1336
+ jobs[job_id]["results"] = results
1337
+
1338
+ except Exception as e:
1339
+ debug_print(f"Error in process_pdf_batch_job: {e}")
1340
+ jobs[job_id]["status"] = "error"
1341
+ jobs[job_id]["error"] = str(e)
1342
+
1343
+ def process_in_background(job_id, func, args):
1344
+ """Process job in background thread"""
1345
+ try:
1346
+ func(*args)
1347
+ except Exception as e:
1348
+ debug_print(f"Background job {job_id} failed: {e}")
1349
+ jobs[job_id]["status"] = "error"
1350
+ jobs[job_id]["error"] = str(e)
1351
+
1352
+ def get_job_list():
1353
+ """Get formatted job list for display"""
1354
+ if not jobs:
1355
+ return "No jobs submitted yet."
1356
+
1357
+ job_list = "### Submitted Jobs\n\n"
1358
+ for job_id, job_info in jobs.items():
1359
+ status = job_info.get("status", "unknown")
1360
+ job_type = job_info.get("type", "unknown")
1361
+ start_time = job_info.get("start_time", 0)
1362
+
1363
+ if start_time:
1364
+ elapsed = time.time() - start_time
1365
+ elapsed_str = f"{elapsed:.1f}s"
1366
+ else:
1367
+ elapsed_str = "N/A"
1368
+
1369
+ job_list += f"**Job {job_id[:8]}...** ({job_type})\n"
1370
+ job_list += f"- Status: {status}\n"
1371
+ job_list += f"- Elapsed: {elapsed_str}\n"
1372
+
1373
+ if "partial_results" in job_info:
1374
+ partial = job_info["partial_results"]
1375
+ job_list += f"- Progress: {partial.get('num_done', 0)}/{partial.get('total', 0)}\n"
1376
+ if "current_pdf" in partial:
1377
+ job_list += f"- Current: {partial['current_pdf']} ({partial.get('current_model', '')})\n"
1378
+
1379
+ job_list += "\n"
1380
+
1381
+ return job_list
1382
+
1383
+ def submit_pdf_processing_job(source_path, query, selected_models, temperature, top_p, top_k, max_tokens, csv_prefix):
1384
+ """Submit PDF processing job"""
1385
+ global last_job_id
1386
+
1387
+ if not query.strip():
1388
+ return "Please enter a query/prompt", "", get_job_list()
1389
+
1390
+ if not selected_models:
1391
+ return "Please select at least one model", "", get_job_list()
1392
+
1393
+ job_id = str(uuid.uuid4())
1394
+ last_job_id = job_id
1395
+
1396
+ # Start background job
1397
+ threading.Thread(
1398
+ target=process_in_background,
1399
+ args=(job_id, process_pdf_batch_job, [job_id, source_path, query, selected_models, temperature, top_p, top_k, max_tokens, csv_prefix])
1400
+ ).start()
1401
+
1402
+ # Store job info
1403
+ jobs[job_id] = {
1404
+ "status": "processing",
1405
+ "type": "pdf_processing",
1406
+ "start_time": time.time(),
1407
+ "query": query,
1408
+ "source_path": source_path,
1409
+ "models": selected_models,
1410
+ "params": {
1411
+ "temperature": temperature,
1412
+ "top_p": top_p,
1413
+ "top_k": top_k,
1414
+ "max_tokens": max_tokens,
1415
+ "csv_prefix": csv_prefix
1416
+ }
1417
+ }
1418
+
1419
+ return f"PDF processing job submitted. Job ID: {job_id}", job_id, get_job_list()
1420
+
1421
+ def load_csv_data_for_table(csv_file):
1422
+ """Load CSV data and format it for the table display"""
1423
+ if not csv_file or not os.path.exists(csv_file):
1424
+ return None
1425
+
1426
+ if pd is None:
1427
+ debug_print("Pandas not available for table display")
1428
+ return None
1429
+
1430
+ try:
1431
+ df = pd.read_csv(csv_file)
1432
+
1433
+ # Select only the data columns (exclude technical columns for display)
1434
+ display_columns = []
1435
+ for col in df.columns:
1436
+ if col not in ['pdf_path', 'model_backend', 'query', 'input_tokens', 'output_tokens', 'timestamp', 'Raw Response']:
1437
+ display_columns.append(col)
1438
+
1439
+ # Create a simplified dataframe for display
1440
+ display_df = df[display_columns].copy()
1441
+
1442
+ # Ensure all values are strings and handle NaN values
1443
+ for col in display_df.columns:
1444
+ display_df[col] = display_df[col].astype(str).fillna('')
1445
+
1446
+ # Create HTML table with proper styling
1447
+ html_table = create_html_table(display_df, display_columns)
1448
+ return html_table
1449
+
1450
+ except Exception as e:
1451
+ debug_print(f"Error loading CSV for table: {e}")
1452
+ return None
1453
+
1454
+ def create_html_table(df, columns):
1455
+ """Create an HTML table with proper styling for the CSV data"""
1456
+ html = """
1457
+ <div style="max-height: 600px; overflow-y: auto; border: 1px solid #ddd; border-radius: 5px;">
1458
+ <table style="width: 100%; border-collapse: collapse; font-size: 11px; font-family: Arial, sans-serif;">
1459
+ <thead>
1460
+ <tr style="background-color: #f5f5f5; position: sticky; top: 0; z-index: 10;">
1461
+ """
1462
+
1463
+ # Add headers
1464
+ for col in columns:
1465
+ html += f'<th style="padding: 8px; border: 1px solid #ddd; text-align: left; font-weight: bold; white-space: nowrap; min-width: 100px; color: #333; background-color: #f5f5f5;">{col}</th>'
1466
+
1467
+ html += """
1468
+ </tr>
1469
+ </thead>
1470
+ <tbody>
1471
+ """
1472
+
1473
+ # Add data rows
1474
+ for _, row in df.iterrows():
1475
+ html += '<tr style="border-bottom: 1px solid #eee;">'
1476
+ for col in columns:
1477
+ value = str(row[col]) if pd.notna(row[col]) else ''
1478
+ # Escape HTML characters
1479
+ value = value.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
1480
+ html += f'<td style="padding: 6px; border: 1px solid #ddd; word-wrap: break-word; white-space: pre-wrap; max-width: 300px; vertical-align: top;">{value}</td>'
1481
+ html += '</tr>'
1482
+
1483
+ html += """
1484
+ </tbody>
1485
+ </table>
1486
+ </div>
1487
+ """
1488
+
1489
+ return html
1490
+
1491
+ def check_job_status(job_id):
1492
+ """Check status of a specific job"""
1493
+ if not job_id or job_id not in jobs:
1494
+ return "Job not found", "", "", get_job_list(), None
1495
+
1496
+ job_info = jobs[job_id]
1497
+ status = job_info.get("status", "unknown")
1498
+
1499
+ if status == "completed":
1500
+ csv_file = job_info.get("csv_file", "")
1501
+ results = job_info.get("results", [])
1502
+
1503
+ # Create summary
1504
+ summary = f"Job completed successfully!\n"
1505
+ summary += f"Processed {len(results)} PDF-model combinations\n"
1506
+ summary += f"CSV file: {csv_file}\n"
1507
+
1508
+ # Create download link
1509
+ if csv_file and os.path.exists(csv_file):
1510
+ rel_path = os.path.relpath(csv_file, ".")
1511
+ download_link = f'<a href="/file={rel_path}" download target="_blank">{os.path.basename(csv_file)}</a>'
1512
+ else:
1513
+ download_link = "File not found"
1514
+
1515
+ # Load CSV data for table
1516
+ html_table = load_csv_data_for_table(csv_file)
1517
+
1518
+ return summary, download_link, csv_file, get_job_list(), html_table
1519
+
1520
+ elif status == "error":
1521
+ error_msg = job_info.get("error", "Unknown error")
1522
+ return f"Job failed: {error_msg}", "", "", get_job_list(), None
1523
+
1524
+ else:
1525
+ # Job still processing
1526
+ partial = job_info.get("partial_results", {})
1527
+ progress = f"Processing... {partial.get('num_done', 0)}/{partial.get('total', 0)}"
1528
+ if "current_pdf" in partial:
1529
+ progress += f" - {partial['current_pdf']} ({partial.get('current_model', '')})"
1530
+
1531
+ return progress, "", "", get_job_list(), None
1532
+
1533
+ # Create Gradio interface
1534
+ def create_interface():
1535
+ with gr.Blocks(title="FilterLM") as interface:
1536
+ gr.Markdown("# FilterLM")
1537
+ gr.Markdown("Extract structured information from PDFs using multiple LLMs and save results to CSV")
1538
+
1539
+ with gr.Tab("PDF Processing"):
1540
+ with gr.Row():
1541
+ source_path_input = gr.Textbox(
1542
+ label="PDF Source (Folder Path, URL, or Google Drive Link)",
1543
+ placeholder="Enter local folder path, single URL, comma-separated URLs, Google Drive file links, or Google Drive folder links (e.g., url1.pdf,url2.pdf,https://drive.google.com/file/d/1234567890/view,https://drive.google.com/drive/folders/1234567890). Leave empty for current directory.",
1544
+ lines=2
1545
+ )
1546
+
1547
+ with gr.Row():
1548
+ csv_prefix_input = gr.Textbox(
1549
+ label="CSV Filename Prefix",
1550
+ placeholder="Enter prefix for CSV filename (optional)",
1551
+ lines=1
1552
+ )
1553
+
1554
+ with gr.Row():
1555
+ default_query = """Extract from every paper in a CSV the following columns
1556
+ Title
1557
+ Authors
1558
+ Journal
1559
+ Year
1560
+ Publication status - check journal to be peer-reviewed and exclude proceedings or theoretical papers
1561
+ Language: English only
1562
+ Study type: What kind of empirical research is it? (i.e., quantitative, qualitative, mixed-methods). No reviews, no theoretical papers
1563
+ Population: - must be only humans
1564
+ Concept - what type of conversational AI is it?
1565
+ Context: What type of „dark moves" done by AI chatbots / LLMs ? example of dark moves in communication: lying or misleading. REQUIRED: If there are no dark moves, you MUST provide a detailed explanation of WHY there are none, including specific evidence or reasoning from the study
1566
+ Setting: domain (e.g., medicine, educational, general population, etc.)
1567
+ Results - must include perceptions or attitudes of people
1568
+ Include / Exclude Recommendation - if all criteria above have been complete, then Include
1569
+ Raw Response"""
1570
+
1571
+ query_input = gr.Textbox(
1572
+ label="Query/Prompt for Information Extraction",
1573
+ value=default_query,
1574
+ placeholder="Enter your prompt here...",
1575
+ lines=8
1576
+ )
1577
+
1578
+ with gr.Row():
1579
+ # Create a single radio button group with all models, organized by provider with separators
1580
+ all_models = []
1581
+
1582
+ # Add Mistral & HuggingFace models
1583
+ mistral_hf_models = [m["display"] for m in models if m["provider"] in ("mistral", "hf_inference")]
1584
+ if mistral_hf_models:
1585
+ all_models.extend(mistral_hf_models)
1586
+
1587
+ # Add separator
1588
+ if all_models and (nebius_models := [m["display"] for m in models if m["provider"] == "nebius"]):
1589
+ all_models.append("─────────── Nebius Models ───────────")
1590
+ all_models.extend(nebius_models)
1591
+
1592
+ # Add separator for OpenAI/Gemini/Grok/Anthropic models
1593
+ openai_models = [m["display"] for m in models if m["provider"] in ("openai", "gemini", "grok", "anthropic")]
1594
+ if openai_models:
1595
+ all_models.append("────── OpenAI / Gemini / Grok / Anthropic ──────")
1596
+ all_models.extend(openai_models)
1597
+
1598
+ model_radio = gr.Radio(
1599
+ choices=all_models,
1600
+ label="Select Model (Single Selection)",
1601
+ value="🇪🇺 Mistral-API (Mistral) (32K)"
1602
+ )
1603
+
1604
+ with gr.Row():
1605
+ temperature_slider = gr.Slider(
1606
+ minimum=0.1, maximum=1.0, value=0.5, step=0.1,
1607
+ label="Randomness (Temperature)"
1608
+ )
1609
+ top_p_slider = gr.Slider(
1610
+ minimum=0.1, maximum=0.99, value=0.95, step=0.05,
1611
+ label="Word Variety (Top-p)"
1612
+ )
1613
+ top_k_slider = gr.Slider(
1614
+ minimum=1, maximum=100, value=50, step=1,
1615
+ label="Top-k (Number of tokens to consider)"
1616
+ )
1617
+ max_tokens_slider = gr.Slider(
1618
+ minimum=64, maximum=1048576, value=32768, step=64,
1619
+ label="Max Tokens (Response length) - Higher values allow processing larger PDFs"
1620
+ )
1621
+
1622
+ with gr.Row():
1623
+ submit_button = gr.Button("Start PDF Processing", variant="primary")
1624
+
1625
+ with gr.Row():
1626
+ status_output = gr.Textbox(
1627
+ label="Status",
1628
+ lines=3
1629
+ )
1630
+
1631
+ with gr.Row():
1632
+ job_id_output = gr.Textbox(
1633
+ label="Job ID",
1634
+ interactive=False
1635
+ )
1636
+
1637
+ with gr.Row():
1638
+ check_button = gr.Button("Check Job Status")
1639
+ auto_refresh = gr.Checkbox(label="Enable Auto Refresh", value=False)
1640
+
1641
+ with gr.Row():
1642
+ results_output = gr.Textbox(
1643
+ label="Results",
1644
+ lines=5
1645
+ )
1646
+ download_html = gr.HTML(label="Download CSV")
1647
+ csv_path_output = gr.Textbox(
1648
+ label="CSV File Path",
1649
+ interactive=False
1650
+ )
1651
+
1652
+ with gr.Row():
1653
+ job_list = gr.Markdown(label="Job List", value=get_job_list())
1654
+ refresh_job_list_button = gr.Button("Refresh Job List")
1655
+
1656
+ # Add table view for CSV data
1657
+ with gr.Row():
1658
+ csv_table = gr.HTML(
1659
+ label="CSV Data Preview",
1660
+ value="<p style='text-align: center; color: #666; padding: 20px;'>No data available. Process some PDFs to see results here.</p>"
1661
+ )
1662
+
1663
+ # Event handlers
1664
+ def submit_job(source_path, query, selected_model, temperature, top_p, top_k, max_tokens, csv_prefix):
1665
+ # Filter out separator lines (lines with dashes)
1666
+ if selected_model and not selected_model.startswith("─"):
1667
+ selected_models = [selected_model]
1668
+ else:
1669
+ selected_models = []
1670
+ return submit_pdf_processing_job(source_path, query, selected_models, temperature, top_p, top_k, max_tokens, csv_prefix)
1671
+
1672
+ submit_button.click(
1673
+ submit_job,
1674
+ inputs=[
1675
+ source_path_input,
1676
+ query_input,
1677
+ model_radio,
1678
+ temperature_slider,
1679
+ top_p_slider,
1680
+ top_k_slider,
1681
+ max_tokens_slider,
1682
+ csv_prefix_input
1683
+ ],
1684
+ outputs=[status_output, job_id_output, job_list]
1685
+ )
1686
+
1687
+ check_button.click(
1688
+ check_job_status,
1689
+ inputs=[job_id_output],
1690
+ outputs=[results_output, download_html, csv_path_output, job_list, csv_table]
1691
+ )
1692
+
1693
+ refresh_job_list_button.click(
1694
+ lambda: get_job_list(),
1695
+ outputs=[job_list]
1696
+ )
1697
+
1698
+ # Auto refresh functionality
1699
+ def auto_refresh_job_status():
1700
+ if last_job_id and last_job_id in jobs:
1701
+ return check_job_status(last_job_id)
1702
+ return "No active job", "", "", get_job_list(), None
1703
+
1704
+ auto_refresh.change(
1705
+ auto_refresh_job_status,
1706
+ outputs=[results_output, download_html, csv_path_output, job_list, csv_table]
1707
+ )
1708
+
1709
+
1710
+ return interface
1711
+
1712
+ if __name__ == "__main__":
1713
+ interface = create_interface()
1714
+ interface.launch(share=False)
pdf_criteria_search_gui_v1.py ADDED
@@ -0,0 +1,844 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Multi-PDF criteria search GUI using the same quote / text match logic as
3
+ "Add Quotes" in visualize_gui.py (normalization + _fuzzy_match_quote_in_normalized).
4
+
5
+ No LLMs. Results can be exported to an easy-to-read .docx with page numbers
6
+ and APA-style references from PDF metadata.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ import os
13
+ import re
14
+ import sys
15
+ import threading
16
+ import tkinter as tk
17
+ from tkinter import filedialog, messagebox, ttk
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+
20
+ # -----------------------------------------------------------------------------
21
+ # Search terms: one basic word (or single token) per rubric *group* (e.g. all
22
+ # "1.1 Overall quality …" variants → "quality"), stored in criteria_search_terms.csv
23
+ # -----------------------------------------------------------------------------
24
+
25
+ TERMS_CSV_NAME = "criteria_search_terms.csv"
26
+
27
+
28
+ def terms_csv_path() -> str:
29
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), TERMS_CSV_NAME)
30
+
31
+
32
+ def default_single_word_terms() -> List[str]:
33
+ """Fallback list written when the CSV is missing; keep in sync with criteria_search_terms.csv."""
34
+ return [
35
+ "quality",
36
+ "helpfulness",
37
+ "harmlessness",
38
+ "safety",
39
+ "groundedness",
40
+ "honesty",
41
+ "truthfulness",
42
+ "hallucinations",
43
+ "empathy",
44
+ "politeness",
45
+ "relevance",
46
+ "verbosity",
47
+ "component",
48
+ "aggregation",
49
+ "ranking",
50
+ "weighting",
51
+ "tradeoffs",
52
+ "other",
53
+ "nonspecific",
54
+ "grice",
55
+ "specific",
56
+ "otherframework",
57
+ "conversations",
58
+ "humanai",
59
+ "annotation",
60
+ "separate",
61
+ "naturalistic",
62
+ "hybrid",
63
+ "synthetic",
64
+ "datasetname",
65
+ "datasetsource",
66
+ "dialogs",
67
+ "annotators",
68
+ "context",
69
+ "annotator",
70
+ "single",
71
+ "multi",
72
+ "prerecorded",
73
+ "realtime",
74
+ "scales",
75
+ "binary",
76
+ "validated",
77
+ "notes",
78
+ "named",
79
+ "unspecified",
80
+ "family",
81
+ "rlhf",
82
+ "partial",
83
+ "collected",
84
+ "reward",
85
+ "policy",
86
+ "stage",
87
+ "benchmark",
88
+ "judge",
89
+ "comparators",
90
+ "winrate",
91
+ "strategy",
92
+ "gains",
93
+ "losses",
94
+ "inconsistent",
95
+ "unchanged",
96
+ "quantified",
97
+ ]
98
+
99
+
100
+ def ensure_terms_csv(path: str) -> None:
101
+ if os.path.isfile(path):
102
+ return
103
+ write_terms_to_csv(path, default_single_word_terms())
104
+
105
+
106
+ def load_terms_from_csv(path: str) -> List[str]:
107
+ if not os.path.isfile(path):
108
+ return list(default_single_word_terms())
109
+ terms: List[str] = []
110
+ with open(path, newline="", encoding="utf-8") as f:
111
+ reader = csv.reader(f)
112
+ rows = list(reader)
113
+ if not rows:
114
+ return list(default_single_word_terms())
115
+ header = [c.strip().lower() for c in rows[0]]
116
+ col = 0
117
+ if "term" in header:
118
+ col = header.index("term")
119
+ for row in rows[1:]:
120
+ if col >= len(row):
121
+ continue
122
+ t = row[col].strip() if row[col] else ""
123
+ if t and not t.startswith("#"):
124
+ terms.append(t)
125
+ return terms if terms else list(default_single_word_terms())
126
+
127
+
128
+ def write_terms_to_csv(path: str, terms: List[str]) -> None:
129
+ with open(path, "w", newline="", encoding="utf-8") as f:
130
+ w = csv.writer(f)
131
+ w.writerow(["term"])
132
+ for t in terms:
133
+ t = t.strip()
134
+ if t:
135
+ w.writerow([t])
136
+
137
+
138
+ def parse_terms_text(raw: str) -> List[str]:
139
+ """One search term per line in the text panel (ignores empty lines and # comments)."""
140
+ out: List[str] = []
141
+ for line in raw.splitlines():
142
+ s = line.strip()
143
+ if not s or s.startswith("#"):
144
+ continue
145
+ out.append(s)
146
+ return out
147
+
148
+
149
+ # -----------------------------------------------------------------------------
150
+ # Quote search engine — aligned with visualize_gui.py (Add Quotes PDF verification)
151
+ # -----------------------------------------------------------------------------
152
+
153
+
154
+ class QuoteSearchEngine:
155
+ """Normalizes text and runs the same multi-strategy match used in Add Quotes."""
156
+
157
+ def __init__(self) -> None:
158
+ self._normalized_source_cache: Dict[str, Any] = {}
159
+
160
+ def clear_cache(self) -> None:
161
+ self._normalized_source_cache.clear()
162
+
163
+ def _normalize_text_for_search(self, text: str) -> str:
164
+ import re
165
+
166
+ text = re.sub(r"-\s+([a-z])", r"\1", text)
167
+ text = re.sub(r"-\s*\n\s*([a-z])", r"\1", text)
168
+ text = re.sub(r"[\r\n]+", " ", text)
169
+ normalized = re.sub(r"\s+", " ", text)
170
+ return normalized.strip()
171
+
172
+ def _normalize_text_aggressive(self, text: str) -> str:
173
+ import re
174
+
175
+ normalized = self._normalize_text_for_search(text)
176
+ normalized = normalized.rstrip(".,;:!?")
177
+ normalized = re.sub(r"\s*([,.;:!?])\s*", r"\1 ", normalized)
178
+ normalized = re.sub(r"\s+", " ", normalized)
179
+ return normalized.strip()
180
+
181
+ def _fuzzy_match_quote_in_normalized(self, quote_text: str, normalized_data: Optional[Dict]) -> bool:
182
+ if normalized_data is None:
183
+ return False
184
+ normalized_source = normalized_data["normalized_text"]
185
+ clean_quote = self._normalize_text_for_search(quote_text)
186
+ if clean_quote in normalized_source:
187
+ return True
188
+ if clean_quote.lower() in normalized_source.lower():
189
+ return True
190
+ agg_quote = self._normalize_text_aggressive(quote_text)
191
+ agg_source = self._normalize_text_aggressive(normalized_source)
192
+ if agg_quote.lower() in agg_source.lower():
193
+ return True
194
+ quote_words: List[str] = []
195
+ for w in clean_quote.split():
196
+ cleaned = w.strip(".,;:!?()[]{}\"'").lower()
197
+ if len(cleaned) > 1:
198
+ quote_words.append(cleaned)
199
+ source_words: List[str] = []
200
+ for w in normalized_source.split():
201
+ cleaned = w.strip(".,;:!?()[]{}\"'").lower()
202
+ if len(cleaned) > 1:
203
+ source_words.append(cleaned)
204
+ if len(quote_words) >= 3:
205
+ quote_idx = 0
206
+ consecutive_matches = 0
207
+ max_consecutive = 0
208
+ for source_word in source_words:
209
+ if quote_idx < len(quote_words) and source_word == quote_words[quote_idx]:
210
+ quote_idx += 1
211
+ consecutive_matches += 1
212
+ max_consecutive = max(max_consecutive, consecutive_matches)
213
+ if quote_idx == len(quote_words):
214
+ return True
215
+ else:
216
+ consecutive_matches = 0
217
+ if quote_idx >= len(quote_words) * 0.70:
218
+ return True
219
+ if max_consecutive >= min(3, len(quote_words) * 0.4):
220
+ return True
221
+ if len(quote_words) >= 8:
222
+ middle_quote_words = quote_words[2:-2]
223
+ if len(middle_quote_words) >= 3:
224
+ qidx = 0
225
+ for source_word in source_words:
226
+ if qidx < len(middle_quote_words) and source_word == middle_quote_words[qidx]:
227
+ qidx += 1
228
+ if qidx == len(middle_quote_words):
229
+ return True
230
+ if qidx >= len(middle_quote_words) * 0.70:
231
+ return True
232
+ if quote_text.startswith("Quote "):
233
+ parts = quote_text.split(":", 1)
234
+ if len(parts) > 1:
235
+ after = parts[1].strip()
236
+ no_prefix = self._normalize_text_for_search(after)
237
+ if no_prefix.lower() in normalized_source.lower():
238
+ return True
239
+ return False
240
+
241
+ def _load_and_normalize_source(self, source_file_path: str, file_type: str) -> Optional[Dict]:
242
+ cache_key = f"{source_file_path}_{file_type}"
243
+ if cache_key in self._normalized_source_cache:
244
+ return self._normalized_source_cache[cache_key]
245
+ normalized_data: Optional[Dict] = None
246
+ if file_type == "PDF":
247
+ all_text = ""
248
+ normalized_pages: Dict[int, str] = {}
249
+ try:
250
+ import pypdf
251
+
252
+ with open(source_file_path, "rb") as file:
253
+ pdf_reader = pypdf.PdfReader(file)
254
+ for page_num, page in enumerate(pdf_reader.pages, start=1):
255
+ page_text = page.extract_text() or ""
256
+ all_text += page_text + " "
257
+ normalized_pages[page_num] = self._normalize_text_for_search(page_text)
258
+ except Exception:
259
+ try:
260
+ from pdfminer.high_level import extract_pages
261
+ from pdfminer.layout import LTTextContainer
262
+
263
+ for page_num, page_layout in enumerate(extract_pages(source_file_path), start=1):
264
+ page_text = ""
265
+ for element in page_layout:
266
+ if isinstance(element, LTTextContainer):
267
+ page_text += element.get_text()
268
+ all_text += page_text + " "
269
+ normalized_pages[page_num] = self._normalize_text_for_search(page_text)
270
+ except Exception:
271
+ return None
272
+ normalized_text = self._normalize_text_for_search(all_text)
273
+ normalized_data = {
274
+ "normalized_pages": normalized_pages,
275
+ "normalized_text": normalized_text,
276
+ "type": "PDF",
277
+ "num_pages": len(normalized_pages),
278
+ "original_text": all_text,
279
+ }
280
+ if normalized_data:
281
+ self._normalized_source_cache[cache_key] = normalized_data
282
+ return normalized_data
283
+
284
+ def _find_excerpt_in_normalized(self, page_norm: str, term: str, context: int = 180) -> str:
285
+ clean = self._normalize_text_for_search(term)
286
+ low = page_norm.lower()
287
+ idx = low.find(clean.lower()) if clean else -1
288
+ if idx < 0:
289
+ agg = self._normalize_text_aggressive(term)
290
+ al = self._normalize_text_aggressive(page_norm)
291
+ idx = al.lower().find(agg.lower()) if agg else -1
292
+ if idx >= 0:
293
+ # map approximate position back to page_norm (same length often)
294
+ idx = min(idx, max(0, len(page_norm) - 1))
295
+ if idx < 0:
296
+ return (page_norm[: min(len(page_norm), context * 2)] + "…") if page_norm else ""
297
+ a = max(0, idx - context)
298
+ b = min(len(page_norm), idx + len(clean) + context)
299
+ snippet = page_norm[a:b]
300
+ if a > 0:
301
+ snippet = "…" + snippet
302
+ if b < len(page_norm):
303
+ snippet = snippet + "…"
304
+ return snippet.strip()
305
+
306
+ def _estimate_page_from_position(self, data: Dict, term: str) -> int:
307
+ clean_quote = self._normalize_text_for_search(term)
308
+ normalized_text = data["normalized_text"]
309
+ position = normalized_text.lower().find(clean_quote.lower())
310
+ if position == -1:
311
+ position = len(normalized_text) // 2
312
+ num_pages = max(1, data.get("num_pages", 1))
313
+ avg = len(normalized_text) / num_pages
314
+ est = int(position / max(1e-6, avg)) + 1
315
+ return min(est, num_pages)
316
+
317
+ def search_term_in_pdf(self, pdf_path: str, term: str) -> List[Dict[str, Any]]:
318
+ """Return one record per page where the term matches; snippet is normalized PDF text window."""
319
+ data = self._load_and_normalize_source(pdf_path, "PDF")
320
+ if not data or not self._fuzzy_match_quote_in_normalized(term, data):
321
+ return []
322
+ out: List[Dict[str, Any]] = []
323
+ for page_num, ptxt in data["normalized_pages"].items():
324
+ sub = {"normalized_text": ptxt, "type": "PDF"}
325
+ if self._fuzzy_match_quote_in_normalized(term, sub):
326
+ out.append(
327
+ {
328
+ "page": page_num,
329
+ "excerpt": self._find_excerpt_in_normalized(ptxt, term),
330
+ "approximate": False,
331
+ }
332
+ )
333
+ if not out:
334
+ est = self._estimate_page_from_position(data, term)
335
+ ntxt = data["normalized_text"]
336
+ out.append(
337
+ {
338
+ "page": est,
339
+ "excerpt": self._find_excerpt_in_normalized(ntxt, term, context=220),
340
+ "approximate": True,
341
+ }
342
+ )
343
+ return out
344
+
345
+
346
+ # -----------------------------------------------------------------------------
347
+ # PDF metadata — APA-style line (author, year, title) from document info
348
+ # -----------------------------------------------------------------------------
349
+
350
+
351
+ def _parse_pdf_date_year(date_val) -> Optional[str]:
352
+ if date_val is None:
353
+ return None
354
+ s = str(date_val)
355
+ m = re.search(r"D:(\d{4})", s)
356
+ if m:
357
+ return m.group(1)
358
+ m = re.search(r"(\d{4})", s)
359
+ if m and 1900 <= int(m.group(1)) <= 2100:
360
+ return m.group(1)
361
+ return None
362
+
363
+
364
+ def apa_reference_for_pdf(pdf_path: str) -> Tuple[str, str, str, str]:
365
+ """
366
+ Returns (author, title, year, apa_one_line).
367
+ Unknown fields use best-effort defaults suitable for a report.
368
+ """
369
+ from pathlib import Path
370
+
371
+ author = "Unknown"
372
+ title = Path(pdf_path).stem.replace("_", " ")
373
+ year = "n.d."
374
+ try:
375
+ import pypdf
376
+
377
+ with open(pdf_path, "rb") as f:
378
+ r = pypdf.PdfReader(f)
379
+ meta = r.metadata
380
+ if meta is not None:
381
+ t = getattr(meta, "title", None)
382
+ a = getattr(meta, "author", None)
383
+ if hasattr(meta, "get"):
384
+ t = t or meta.get("/Title") # type: ignore[union-attr]
385
+ a = a or meta.get("/Author") # type: ignore[union-attr]
386
+ if t and str(t).strip():
387
+ title = str(t).strip()
388
+ if a and str(a).strip():
389
+ author = str(a).strip()
390
+ dt = getattr(meta, "creation_date", None) or getattr(meta, "modification_date", None)
391
+ if dt is not None and hasattr(dt, "year"):
392
+ year = str(dt.year)
393
+ else:
394
+ raw = getattr(meta, "creation_date_raw", None) or getattr(meta, "modification_date_raw", None)
395
+ y = _parse_pdf_date_year(raw)
396
+ if y:
397
+ year = y
398
+ except Exception:
399
+ pass
400
+ if year == "n.d.":
401
+ m = re.search(r"(20\d{2}|19\d{2})", title)
402
+ if m:
403
+ year = m.group(1)
404
+ apa = f"{author} ({year}). {title}."
405
+ return author, title, year, apa
406
+
407
+
408
+ def first_author_from_metadata_author(author: str) -> str:
409
+ """First listed author, single cell for the tree: surname or first name segment from PDF metadata."""
410
+ if not author or not str(author).strip() or str(author).strip() == "Unknown":
411
+ return "Unknown"
412
+ a0 = re.split(r"\s*;\s*|\s+and\s+", str(author).strip(), maxsplit=1, flags=re.IGNORECASE)[0].strip()
413
+ a0 = re.sub(r"^\s*[\(\[]?et\s+al\.[\)\]]?\s*$", "", a0, flags=re.IGNORECASE)
414
+ if not a0:
415
+ return "Unknown"
416
+ if "," in a0:
417
+ return a0.split(",")[0].strip()
418
+ parts = a0.split()
419
+ return parts[0] if parts else a0
420
+
421
+
422
+ def get_pdf_title_and_first_author(pdf_path: str) -> Tuple[str, str]:
423
+ """(document_title, first_author) for display columns."""
424
+ author, title, _year, _ = apa_reference_for_pdf(pdf_path)
425
+ return title, first_author_from_metadata_author(author)
426
+
427
+
428
+ # -----------------------------------------------------------------------------
429
+ # DOCX export (green highlight for verified match — same idea as Add Quotes in Word)
430
+ # -----------------------------------------------------------------------------
431
+
432
+
433
+ def export_docx(
434
+ output_path: str,
435
+ folder: str,
436
+ results_by_term: Dict[str, List[Dict[str, Any]]],
437
+ criteria_order: List[str],
438
+ ) -> None:
439
+ from docx import Document
440
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
441
+ from docx.oxml import OxmlElement
442
+ from docx.oxml.ns import qn
443
+ from docx.shared import Pt, RGBColor
444
+
445
+ doc = Document()
446
+ doc.add_heading("PDF criteria search report", 0)
447
+ p = doc.add_paragraph(
448
+ f"Folder searched: {folder}. "
449
+ "Each term uses the same normalized-text / fuzzy match logic as 'Add Quotes' in visualize_gui.py. "
450
+ "Green background indicates a match on the cited page; 'approximate page' is used when the match spans a page break."
451
+ )
452
+ p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
453
+
454
+ for term in criteria_order:
455
+ hits = results_by_term.get(term, [])
456
+ doc.add_heading(term, level=2)
457
+ if not hits:
458
+ doc.add_paragraph("No matches in any PDF in this folder.")
459
+ continue
460
+ for h in hits:
461
+ pdf = h["pdf_path"]
462
+ _, _, _, apa = apa_reference_for_pdf(pdf)
463
+ doc.add_paragraph(apa, style="List Bullet")
464
+ for occ in h["occurrences"]:
465
+ page = occ["page"]
466
+ approx = occ.get("approximate")
467
+ ex = occ.get("excerpt", "")
468
+ line = f"p. {page}" + (" (approximate page)" if approx else "")
469
+ pr = doc.add_paragraph()
470
+ r0 = pr.add_run(line + " — ")
471
+ r0.bold = True
472
+ r0.font.size = Pt(10)
473
+ r1 = pr.add_run(ex)
474
+ r1.font.size = Pt(10)
475
+ r1.font.color.rgb = RGBColor(255, 255, 255)
476
+ rpr = r1._element.get_or_add_rPr()
477
+ shd = OxmlElement("w:shd")
478
+ shd.set(qn("w:fill"), "006400")
479
+ shd.set(qn("w:val"), "clear")
480
+ rpr.append(shd)
481
+ doc.add_paragraph()
482
+
483
+ doc.save(output_path)
484
+
485
+
486
+ # -----------------------------------------------------------------------------
487
+ # GUI
488
+ # -----------------------------------------------------------------------------
489
+
490
+
491
+ class CriteriaSearchApp(tk.Tk):
492
+ def __init__(self) -> None:
493
+ super().__init__()
494
+ self.title("PDF criteria search (Add Quotes matching)")
495
+ self.geometry("1280x720")
496
+ self.engine = QuoteSearchEngine()
497
+ self._search_results: Dict[str, List[Dict[str, Any]]] = {}
498
+ self._criteria_order: List[str] = []
499
+ self._pdf_display_cache: Dict[str, Tuple[str, str]] = {}
500
+ self.group_var = tk.BooleanVar(value=True)
501
+ self._terms_csv_path = terms_csv_path()
502
+ ensure_terms_csv(self._terms_csv_path)
503
+
504
+ top = ttk.Frame(self, padding=8)
505
+ top.pack(fill=tk.X)
506
+ ttk.Label(top, text="Folder with PDFs:").pack(side=tk.LEFT)
507
+ self.folder_var = tk.StringVar()
508
+ ttk.Entry(top, textvariable=self.folder_var, width=70).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4)
509
+ ttk.Button(top, text="Browse…", command=self._browse).pack(side=tk.LEFT)
510
+ ttk.Button(top, text="Search", command=self._start_search).pack(side=tk.LEFT, padx=4)
511
+ ttk.Button(top, text="Export DOCX…", command=self._export).pack(side=tk.LEFT)
512
+
513
+ self.progress = ttk.Progressbar(self, mode="indeterminate")
514
+ self.progress.pack(fill=tk.X, padx=8, pady=(0, 4))
515
+
516
+ body = ttk.PanedWindow(self, orient=tk.HORIZONTAL)
517
+ body.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
518
+
519
+ left = ttk.Frame(body, width=420)
520
+ body.add(left, weight=1)
521
+ left_top = ttk.Frame(left)
522
+ left_top.pack(fill=tk.X)
523
+ ttk.Label(
524
+ left_top,
525
+ text="Search terms (one word or token per line; load/save: criteria_search_terms.csv)",
526
+ wraplength=400,
527
+ ).pack(side=tk.LEFT, anchor=tk.W, fill=tk.X, expand=True)
528
+ ttk.Button(left_top, text="Save to CSV", command=self._save_terms_csv).pack(side=tk.RIGHT)
529
+ ttk.Label(left, text="Use # for comments", font=("Segoe UI", 8)).pack(anchor=tk.W, pady=(0, 2))
530
+ self.terms_text = tk.Text(left, width=50, height=24, wrap=tk.WORD, font=("Consolas", 9))
531
+ ys = ttk.Scrollbar(left, command=self.terms_text.yview)
532
+ self.terms_text.configure(yscrollcommand=ys.set)
533
+ self.terms_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
534
+ ys.pack(side=tk.RIGHT, fill=tk.Y)
535
+ for line in load_terms_from_csv(self._terms_csv_path):
536
+ self.terms_text.insert(tk.END, line + "\n")
537
+
538
+ right = ttk.Frame(body, width=640)
539
+ body.add(right, weight=2)
540
+ rhead = ttk.Frame(right)
541
+ rhead.pack(fill=tk.X)
542
+ ttk.Label(
543
+ rhead,
544
+ text="All keywords for one PDF, then the next. Columns: title & first author from PDF metadata.",
545
+ wraplength=750,
546
+ ).pack(side=tk.LEFT, fill=tk.X, expand=True, anchor=tk.W)
547
+ self.group_cb = ttk.Checkbutton(
548
+ rhead,
549
+ text="GROUP",
550
+ variable=self.group_var,
551
+ command=self._on_group_toggle,
552
+ )
553
+ self.group_cb.pack(side=tk.RIGHT)
554
+ ttk.Label(
555
+ right,
556
+ text="(GROUP: one row per PDF + term, preview shows all page hits together)",
557
+ font=("Segoe UI", 8),
558
+ ).pack(anchor=tk.W)
559
+ tree_fr = ttk.Frame(right)
560
+ tree_fr.pack(fill=tk.BOTH, expand=True)
561
+ cols = ("term", "title", "first_author", "pdf", "page", "approx")
562
+ self.tree = ttk.Treeview(tree_fr, columns=cols, show="headings", height=10)
563
+ self.tree.heading("term", text="Term")
564
+ self.tree.heading("title", text="Title")
565
+ self.tree.heading("first_author", text="First author")
566
+ self.tree.heading("pdf", text="PDF file")
567
+ self.tree.heading("page", text="Page")
568
+ self.tree.heading("approx", text="Approx?")
569
+ self.tree.column("term", width=120)
570
+ self.tree.column("title", width=220)
571
+ self.tree.column("first_author", width=110)
572
+ self.tree.column("pdf", width=150)
573
+ self.tree.column("page", width=90)
574
+ self.tree.column("approx", width=60)
575
+ y2 = ttk.Scrollbar(tree_fr, command=self.tree.yview)
576
+ self.tree.configure(yscrollcommand=y2.set)
577
+ self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
578
+ y2.pack(side=tk.RIGHT, fill=tk.Y)
579
+ self.tree.bind("<<TreeviewSelect>>", self._on_tree_select)
580
+ self._tree_row_meta: Dict[str, Dict[str, Any]] = {}
581
+
582
+ ttk.Label(right, text="Preview (excerpts)").pack(anchor=tk.W, pady=(8, 0))
583
+ prev_fr = ttk.Frame(right)
584
+ prev_fr.pack(fill=tk.BOTH, expand=True)
585
+ self.preview = tk.Text(prev_fr, height=10, wrap=tk.WORD, font=("Consolas", 9), state=tk.DISABLED)
586
+ self.preview.tag_configure("m", background="#006400", foreground="white")
587
+ pys = ttk.Scrollbar(prev_fr, command=self.preview.yview)
588
+ self.preview.configure(yscrollcommand=pys.set)
589
+ self.preview.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
590
+ pys.pack(side=tk.RIGHT, fill=tk.Y)
591
+
592
+ self.status = ttk.Label(
593
+ self,
594
+ text=f"Ready. Terms: {self._terms_csv_path}",
595
+ anchor=tk.W,
596
+ )
597
+ self.status.pack(fill=tk.X, padx=8, pady=4)
598
+
599
+ def _browse(self) -> None:
600
+ d = filedialog.askdirectory()
601
+ if d:
602
+ self.folder_var.set(d)
603
+ self.engine.clear_cache()
604
+
605
+ def _read_terms(self) -> List[str]:
606
+ raw = self.terms_text.get("1.0", tk.END)
607
+ return parse_terms_text(raw)
608
+
609
+ def _save_terms_csv(self) -> None:
610
+ terms = self._read_terms()
611
+ if not terms:
612
+ messagebox.showerror("No terms", "Add at least one line before saving.")
613
+ return
614
+ try:
615
+ write_terms_to_csv(self._terms_csv_path, terms)
616
+ except OSError as e:
617
+ messagebox.showerror("Save failed", str(e))
618
+ return
619
+ self.status.config(
620
+ text=f"Saved {len(terms)} terms to {self._terms_csv_path}"
621
+ )
622
+ messagebox.showinfo("Saved", f"Wrote {len(terms)} terms to:\n{self._terms_csv_path}")
623
+
624
+ def _start_search(self) -> None:
625
+ folder = self.folder_var.get().strip()
626
+ if not folder or not os.path.isdir(folder):
627
+ messagebox.showerror("Invalid folder", "Please choose a folder that contains PDFs.")
628
+ return
629
+ terms = self._read_terms()
630
+ if not terms:
631
+ messagebox.showerror("No terms", "Add at least one search term.")
632
+ return
633
+ self.engine.clear_cache()
634
+ self._pdf_display_cache.clear()
635
+ self.progress.start(8)
636
+ self.status.config(text="Searching…")
637
+ t = threading.Thread(target=self._search_worker, args=(folder, terms), daemon=True)
638
+ t.start()
639
+
640
+ def _search_worker(self, folder: str, terms: List[str]) -> None:
641
+ pdfs = sorted(
642
+ f for f in os.listdir(folder) if f.lower().endswith(".pdf")
643
+ )
644
+ full_paths = [os.path.join(folder, f) for f in pdfs]
645
+ results: Dict[str, List[Dict[str, Any]]] = {t: [] for t in terms}
646
+ for term in terms:
647
+ for fp in full_paths:
648
+ occ = self.engine.search_term_in_pdf(fp, term)
649
+ if occ:
650
+ results[term].append({"pdf_path": fp, "occurrences": occ})
651
+ self._criteria_order = list(terms)
652
+ self._search_results = results
653
+ self.after(0, lambda: self._search_done(len(pdfs), len(terms)))
654
+
655
+ def _search_done(self, n_pdf: int, n_term: int) -> None:
656
+ self.progress.stop()
657
+ self.status.config(
658
+ text=f"Done. {n_pdf} PDF file(s), {n_term} term(s). Terms file: {self._terms_csv_path}"
659
+ )
660
+ self._fill_tree()
661
+ if not any(self._search_results.get(t) for t in self._search_results):
662
+ messagebox.showinfo("Search", "No matches for any term. Try editing terms or another folder.")
663
+
664
+ def _on_group_toggle(self) -> None:
665
+ if self._search_results:
666
+ self._fill_tree()
667
+
668
+ def _get_pdf_display_titles(self, fp: str) -> Tuple[str, str]:
669
+ if fp not in self._pdf_display_cache:
670
+ t, fa = get_pdf_title_and_first_author(fp)
671
+ self._pdf_display_cache[fp] = (t, fa)
672
+ return self._pdf_display_cache[fp]
673
+
674
+ def _build_pdf_to_term_blocks(self) -> Tuple[List[str], Dict[str, List[Tuple[str, Dict[str, Any]]]]]:
675
+ """
676
+ List PDFs in sorted name order. For each path, a list of (term, block) in criteria order.
677
+ """
678
+ pdf_data: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
679
+ for term in self._criteria_order:
680
+ for block in self._search_results.get(term, []):
681
+ fp = block["pdf_path"]
682
+ pdf_data.setdefault(fp, []).append((term, block))
683
+ sorted_pdfs = sorted(pdf_data.keys(), key=lambda p: os.path.basename(p).lower())
684
+ return sorted_pdfs, pdf_data
685
+
686
+ def _fill_tree(self) -> None:
687
+ for i in self.tree.get_children():
688
+ self.tree.delete(i)
689
+ self._tree_row_meta.clear()
690
+ if not self._search_results or not any(self._search_results.get(t) for t in self._criteria_order):
691
+ return
692
+ grouped = self.group_var.get()
693
+ sorted_pdfs, pdf_data = self._build_pdf_to_term_blocks()
694
+ for fp in sorted_pdfs:
695
+ tdoc, tauth = self._get_pdf_display_titles(fp)
696
+ base = os.path.basename(fp)
697
+ for term, block in pdf_data[fp]:
698
+ occs = block.get("occurrences", [])
699
+ if not occs:
700
+ continue
701
+ if grouped:
702
+ apx = "yes" if any(o.get("approximate") for o in occs) else "no"
703
+ pages = ", ".join(str(o["page"]) for o in occs)
704
+ iid = self.tree.insert(
705
+ "",
706
+ tk.END,
707
+ values=(term, tdoc, tauth, base, pages, apx),
708
+ )
709
+ self._tree_row_meta[iid] = {
710
+ "term": term,
711
+ "pdf_path": fp,
712
+ "grouped": True,
713
+ "items": [
714
+ {
715
+ "page": o["page"],
716
+ "excerpt": o.get("excerpt", ""),
717
+ "approximate": o.get("approximate", False),
718
+ }
719
+ for o in occs
720
+ ],
721
+ }
722
+ else:
723
+ for occ in occs:
724
+ apx = "yes" if occ.get("approximate") else "no"
725
+ iid = self.tree.insert(
726
+ "",
727
+ tk.END,
728
+ values=(term, tdoc, tauth, base, str(occ["page"]), apx),
729
+ )
730
+ self._tree_row_meta[iid] = {
731
+ "term": term,
732
+ "pdf_path": fp,
733
+ "grouped": False,
734
+ "excerpt": occ.get("excerpt", ""),
735
+ "page": occ["page"],
736
+ "approximate": occ.get("approximate", False),
737
+ }
738
+
739
+ def _on_tree_select(self, _e=None) -> None:
740
+ sel = self.tree.selection()
741
+ if not sel:
742
+ return
743
+ meta = self._tree_row_meta.get(sel[0])
744
+ if not meta:
745
+ return
746
+ term = meta["term"]
747
+ if meta.get("grouped") and "items" in meta:
748
+ self._set_preview_hl_list(term, meta["items"])
749
+ else:
750
+ self._set_preview_hl(
751
+ term,
752
+ meta.get("excerpt", ""),
753
+ )
754
+
755
+ def _insert_excerpt_hl(self, term: str, excerpt: str) -> None:
756
+ n = self._normalize_for_preview(term)
757
+ if not excerpt:
758
+ self.preview.insert(tk.END, "(no excerpt)")
759
+ return
760
+ if not n:
761
+ self.preview.insert(tk.END, excerpt)
762
+ return
763
+ low_ex = excerpt.lower()
764
+ idx = low_ex.find(n.lower())
765
+ if idx < 0:
766
+ self.preview.insert(tk.END, excerpt)
767
+ return
768
+ self.preview.insert(tk.END, excerpt[:idx])
769
+ self.preview.insert(tk.END, excerpt[idx : idx + len(n)], "m")
770
+ self.preview.insert(tk.END, excerpt[idx + len(n) :])
771
+
772
+ def _set_preview_hl(self, term: str, excerpt: str) -> None:
773
+ self.preview.config(state=tk.NORMAL)
774
+ self.preview.delete("1.0", tk.END)
775
+ self._insert_excerpt_hl(term, excerpt)
776
+ self.preview.config(state=tk.DISABLED)
777
+
778
+ def _set_preview_hl_list(self, term: str, items: List[Dict[str, Any]]) -> None:
779
+ self.preview.config(state=tk.NORMAL)
780
+ self.preview.delete("1.0", tk.END)
781
+ for i, it in enumerate(items):
782
+ if i:
783
+ self.preview.insert(tk.END, "\n\n")
784
+ p = it.get("page", "")
785
+ apx = " (approx.)" if it.get("approximate") else ""
786
+ self.preview.insert(tk.END, f"p. {p}{apx}\n")
787
+ self._insert_excerpt_hl(term, it.get("excerpt", ""))
788
+ self.preview.config(state=tk.DISABLED)
789
+
790
+ def _normalize_for_preview(self, term: str) -> str:
791
+ e = self.engine
792
+ a = e._normalize_text_for_search(term)
793
+ b = e._normalize_text_aggressive(term)
794
+ return a if len(a) >= len(b) else b
795
+
796
+ def _export(self) -> None:
797
+ if not self._search_results or not any(self._search_results.values()):
798
+ messagebox.showinfo("Export", "Run a search with at least one match first.")
799
+ return
800
+ try:
801
+ import docx # noqa: F401
802
+ except ImportError:
803
+ messagebox.showerror("Missing package", "Install python-docx: pip install python-docx")
804
+ return
805
+ path = filedialog.asksaveasfilename(
806
+ defaultextension=".docx",
807
+ filetypes=[("Word", "*.docx"), ("All", "*.*")],
808
+ )
809
+ if not path:
810
+ return
811
+ try:
812
+ export_docx(
813
+ path,
814
+ self.folder_var.get().strip(),
815
+ self._search_results,
816
+ self._criteria_order,
817
+ )
818
+ messagebox.showinfo("Export", f"Saved:\n{path}")
819
+ except Exception as ex: # noqa: BLE001
820
+ messagebox.showerror("Export failed", str(ex))
821
+
822
+
823
+ def main() -> int:
824
+ # pypdf can trigger cryptography’s ARC4 import warning (third-party, harmless for read-only PDFs)
825
+ import warnings
826
+
827
+ try:
828
+ from cryptography.utils import CryptographyDeprecationWarning
829
+
830
+ warnings.filterwarnings("ignore", category=CryptographyDeprecationWarning)
831
+ except ImportError:
832
+ warnings.filterwarnings("ignore", message=".*ARC4.*")
833
+ try:
834
+ import pypdf # noqa: F401
835
+ except ImportError:
836
+ print("Install pypdf: pip install pypdf", file=sys.stderr)
837
+ return 1
838
+ app = CriteriaSearchApp()
839
+ app.mainloop()
840
+ return 0
841
+
842
+
843
+ if __name__ == "__main__":
844
+ raise SystemExit(main())
pdf_criteria_search_gui_v2.py ADDED
@@ -0,0 +1,991 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Multi-PDF criteria search GUI using the same quote / text match logic as
3
+ "Add Quotes" in visualize_gui.py (normalization + _fuzzy_match_quote_in_normalized).
4
+
5
+ No LLMs. Results can be exported to an easy-to-read .docx with page numbers
6
+ and APA-style references from PDF metadata.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ import os
13
+ import re
14
+ import sys
15
+ import threading
16
+ import tkinter as tk
17
+ from tkinter import filedialog, messagebox, ttk
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+
20
+ from search_keywords import (
21
+ CATEGORY_HEADINGS,
22
+ CATEGORY_STYLES,
23
+ KEY_TO_CATEGORY,
24
+ SEARCH_KEYWORDS,
25
+ ordered_group_keys,
26
+ )
27
+
28
+ # -----------------------------------------------------------------------------
29
+ # Search terms: each line is a *group id*; phrases for that group live in
30
+ # search_keywords.py. Optional criteria_search_terms.csv overrides the ordered list.
31
+ # -----------------------------------------------------------------------------
32
+
33
+ TERMS_CSV_NAME = "criteria_search_terms.csv"
34
+
35
+
36
+ def terms_csv_path() -> str:
37
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), TERMS_CSV_NAME)
38
+
39
+
40
+ def ensure_terms_csv(path: str) -> None:
41
+ if os.path.isfile(path):
42
+ return
43
+ write_terms_to_csv(path, ordered_group_keys())
44
+
45
+
46
+ def load_terms_from_csv(path: str) -> List[str]:
47
+ if not os.path.isfile(path):
48
+ return list(ordered_group_keys())
49
+ terms: List[str] = []
50
+ with open(path, newline="", encoding="utf-8") as f:
51
+ reader = csv.reader(f)
52
+ rows = list(reader)
53
+ if not rows:
54
+ return list(ordered_group_keys())
55
+ header = [c.strip().lower() for c in rows[0]]
56
+ col = 0
57
+ if "term" in header:
58
+ col = header.index("term")
59
+ for row in rows[1:]:
60
+ if col >= len(row):
61
+ continue
62
+ t = row[col].strip() if row[col] else ""
63
+ if t and not t.startswith("#"):
64
+ terms.append(t)
65
+ return terms if terms else list(ordered_group_keys())
66
+
67
+
68
+ def write_terms_to_csv(path: str, terms: List[str]) -> None:
69
+ with open(path, "w", newline="", encoding="utf-8") as f:
70
+ w = csv.writer(f)
71
+ w.writerow(["term"])
72
+ for t in terms:
73
+ t = t.strip()
74
+ if t:
75
+ w.writerow([t])
76
+
77
+
78
+ def parse_terms_text(raw: str) -> List[str]:
79
+ """One group id per line (ignores empty lines and # comment lines, including section headers)."""
80
+ out: List[str] = []
81
+ for line in raw.splitlines():
82
+ s = line.strip()
83
+ if not s or s.startswith("#"):
84
+ continue
85
+ out.append(s)
86
+ return out
87
+
88
+
89
+ def phrases_for_group_key(key: str) -> List[str]:
90
+ """All PDF search phrases for a group, or a single best-effort phrase for custom keys."""
91
+ return list(SEARCH_KEYWORDS.get(key, [key.replace("_", " ")]))
92
+
93
+
94
+ def sanitize_xml_compatible(text: str) -> str:
95
+ """
96
+ Strip characters that are illegal in XML 1.0 / OOXML text runs (e.g. NUL from PDFs).
97
+ python-docx raises if runs contain bare control characters.
98
+ """
99
+ if not text:
100
+ return ""
101
+ out: List[str] = []
102
+ for ch in text:
103
+ o = ord(ch)
104
+ if o in (0x9, 0xA, 0xD):
105
+ out.append(ch)
106
+ elif 0x20 <= o <= 0xD7FF:
107
+ out.append(ch)
108
+ elif 0xE000 <= o <= 0xFFFD:
109
+ out.append(ch)
110
+ elif 0x10000 <= o <= 0x10FFFF:
111
+ out.append(ch)
112
+ return "".join(out)
113
+
114
+
115
+ def _normalize_text_for_search_static(text: str) -> str:
116
+ """Same normalization as QuoteSearchEngine._normalize_text_for_search (for highlight spans)."""
117
+ import re
118
+
119
+ text = re.sub(r"-\s+([a-z])", r"\1", text)
120
+ text = re.sub(r"-\s*\n\s*([a-z])", r"\1", text)
121
+ text = re.sub(r"[\r\n]+", " ", text)
122
+ normalized = re.sub(r"\s+", " ", text)
123
+ return normalized.strip()
124
+
125
+
126
+ def best_highlight_phrase_for_group(group_key: str, excerpt: str) -> str:
127
+ """
128
+ Contiguous substring in excerpt to highlight — same rules as CriteriaSearchApp._best_highlight_phrase
129
+ (Tk preview): longest matching phrase, else longest token from phrases, else normalized group label.
130
+ """
131
+ import re
132
+
133
+ if not excerpt:
134
+ return ""
135
+ ex_low = excerpt.lower()
136
+ candidates = list(phrases_for_group_key(group_key))
137
+
138
+ for p in sorted(candidates, key=len, reverse=True):
139
+ p = p.strip()
140
+ if len(p) < 2:
141
+ continue
142
+ lo = ex_low.find(p.lower())
143
+ if lo >= 0:
144
+ return excerpt[lo : lo + len(p)]
145
+
146
+ tokens: List[str] = []
147
+ seen_lower: set = set()
148
+ for p in candidates:
149
+ for w in re.findall(r"[A-Za-z][A-Za-z0-9\-]{2,}", p):
150
+ if len(w) >= 3:
151
+ wl = w.lower()
152
+ if wl not in seen_lower:
153
+ seen_lower.add(wl)
154
+ tokens.append(w)
155
+ tokens.sort(key=len, reverse=True)
156
+ for w in tokens:
157
+ lo = ex_low.find(w.lower())
158
+ if lo >= 0:
159
+ return excerpt[lo : lo + len(w)]
160
+
161
+ n = _normalize_text_for_search_static(group_key.replace("_", " "))
162
+ if n and len(n) >= 3:
163
+ lo = ex_low.find(n.lower())
164
+ if lo >= 0:
165
+ return excerpt[lo : lo + len(n)]
166
+ return ""
167
+
168
+
169
+ # -----------------------------------------------------------------------------
170
+ # Quote search engine — aligned with visualize_gui.py (Add Quotes PDF verification)
171
+ # -----------------------------------------------------------------------------
172
+
173
+
174
+ class QuoteSearchEngine:
175
+ """Normalizes text and runs the same multi-strategy match used in Add Quotes."""
176
+
177
+ def __init__(self) -> None:
178
+ self._normalized_source_cache: Dict[str, Any] = {}
179
+
180
+ def clear_cache(self) -> None:
181
+ self._normalized_source_cache.clear()
182
+
183
+ def _normalize_text_for_search(self, text: str) -> str:
184
+ import re
185
+
186
+ text = re.sub(r"-\s+([a-z])", r"\1", text)
187
+ text = re.sub(r"-\s*\n\s*([a-z])", r"\1", text)
188
+ text = re.sub(r"[\r\n]+", " ", text)
189
+ normalized = re.sub(r"\s+", " ", text)
190
+ return normalized.strip()
191
+
192
+ def _normalize_text_aggressive(self, text: str) -> str:
193
+ import re
194
+
195
+ normalized = self._normalize_text_for_search(text)
196
+ normalized = normalized.rstrip(".,;:!?")
197
+ normalized = re.sub(r"\s*([,.;:!?])\s*", r"\1 ", normalized)
198
+ normalized = re.sub(r"\s+", " ", normalized)
199
+ return normalized.strip()
200
+
201
+ def _fuzzy_match_quote_in_normalized(self, quote_text: str, normalized_data: Optional[Dict]) -> bool:
202
+ if normalized_data is None:
203
+ return False
204
+ normalized_source = normalized_data["normalized_text"]
205
+ clean_quote = self._normalize_text_for_search(quote_text)
206
+ if clean_quote in normalized_source:
207
+ return True
208
+ if clean_quote.lower() in normalized_source.lower():
209
+ return True
210
+ agg_quote = self._normalize_text_aggressive(quote_text)
211
+ agg_source = self._normalize_text_aggressive(normalized_source)
212
+ if agg_quote.lower() in agg_source.lower():
213
+ return True
214
+ quote_words: List[str] = []
215
+ for w in clean_quote.split():
216
+ cleaned = w.strip(".,;:!?()[]{}\"'").lower()
217
+ if len(cleaned) > 1:
218
+ quote_words.append(cleaned)
219
+ source_words: List[str] = []
220
+ for w in normalized_source.split():
221
+ cleaned = w.strip(".,;:!?()[]{}\"'").lower()
222
+ if len(cleaned) > 1:
223
+ source_words.append(cleaned)
224
+ if len(quote_words) >= 3:
225
+ quote_idx = 0
226
+ consecutive_matches = 0
227
+ max_consecutive = 0
228
+ for source_word in source_words:
229
+ if quote_idx < len(quote_words) and source_word == quote_words[quote_idx]:
230
+ quote_idx += 1
231
+ consecutive_matches += 1
232
+ max_consecutive = max(max_consecutive, consecutive_matches)
233
+ if quote_idx == len(quote_words):
234
+ return True
235
+ else:
236
+ consecutive_matches = 0
237
+ if quote_idx >= len(quote_words) * 0.70:
238
+ return True
239
+ if max_consecutive >= min(3, len(quote_words) * 0.4):
240
+ return True
241
+ if len(quote_words) >= 8:
242
+ middle_quote_words = quote_words[2:-2]
243
+ if len(middle_quote_words) >= 3:
244
+ qidx = 0
245
+ for source_word in source_words:
246
+ if qidx < len(middle_quote_words) and source_word == middle_quote_words[qidx]:
247
+ qidx += 1
248
+ if qidx == len(middle_quote_words):
249
+ return True
250
+ if qidx >= len(middle_quote_words) * 0.70:
251
+ return True
252
+ if quote_text.startswith("Quote "):
253
+ parts = quote_text.split(":", 1)
254
+ if len(parts) > 1:
255
+ after = parts[1].strip()
256
+ no_prefix = self._normalize_text_for_search(after)
257
+ if no_prefix.lower() in normalized_source.lower():
258
+ return True
259
+ return False
260
+
261
+ def _load_and_normalize_source(self, source_file_path: str, file_type: str) -> Optional[Dict]:
262
+ cache_key = f"{source_file_path}_{file_type}"
263
+ if cache_key in self._normalized_source_cache:
264
+ return self._normalized_source_cache[cache_key]
265
+ normalized_data: Optional[Dict] = None
266
+ if file_type == "PDF":
267
+ all_text = ""
268
+ normalized_pages: Dict[int, str] = {}
269
+ try:
270
+ import pypdf
271
+
272
+ with open(source_file_path, "rb") as file:
273
+ pdf_reader = pypdf.PdfReader(file)
274
+ for page_num, page in enumerate(pdf_reader.pages, start=1):
275
+ page_text = page.extract_text() or ""
276
+ all_text += page_text + " "
277
+ normalized_pages[page_num] = self._normalize_text_for_search(page_text)
278
+ except Exception:
279
+ try:
280
+ from pdfminer.high_level import extract_pages
281
+ from pdfminer.layout import LTTextContainer
282
+
283
+ for page_num, page_layout in enumerate(extract_pages(source_file_path), start=1):
284
+ page_text = ""
285
+ for element in page_layout:
286
+ if isinstance(element, LTTextContainer):
287
+ page_text += element.get_text()
288
+ all_text += page_text + " "
289
+ normalized_pages[page_num] = self._normalize_text_for_search(page_text)
290
+ except Exception:
291
+ return None
292
+ normalized_text = self._normalize_text_for_search(all_text)
293
+ normalized_data = {
294
+ "normalized_pages": normalized_pages,
295
+ "normalized_text": normalized_text,
296
+ "type": "PDF",
297
+ "num_pages": len(normalized_pages),
298
+ "original_text": all_text,
299
+ }
300
+ if normalized_data:
301
+ self._normalized_source_cache[cache_key] = normalized_data
302
+ return normalized_data
303
+
304
+ def _find_excerpt_in_normalized(self, page_norm: str, term: str, context: int = 180) -> str:
305
+ clean = self._normalize_text_for_search(term)
306
+ low = page_norm.lower()
307
+ idx = low.find(clean.lower()) if clean else -1
308
+ if idx < 0:
309
+ agg = self._normalize_text_aggressive(term)
310
+ al = self._normalize_text_aggressive(page_norm)
311
+ idx = al.lower().find(agg.lower()) if agg else -1
312
+ if idx >= 0:
313
+ # map approximate position back to page_norm (same length often)
314
+ idx = min(idx, max(0, len(page_norm) - 1))
315
+ if idx < 0:
316
+ return (page_norm[: min(len(page_norm), context * 2)] + "…") if page_norm else ""
317
+ a = max(0, idx - context)
318
+ b = min(len(page_norm), idx + len(clean) + context)
319
+ snippet = page_norm[a:b]
320
+ if a > 0:
321
+ snippet = "…" + snippet
322
+ if b < len(page_norm):
323
+ snippet = snippet + "…"
324
+ return snippet.strip()
325
+
326
+ def _estimate_page_from_position(self, data: Dict, term: str) -> int:
327
+ clean_quote = self._normalize_text_for_search(term)
328
+ normalized_text = data["normalized_text"]
329
+ position = normalized_text.lower().find(clean_quote.lower())
330
+ if position == -1:
331
+ position = len(normalized_text) // 2
332
+ num_pages = max(1, data.get("num_pages", 1))
333
+ avg = len(normalized_text) / num_pages
334
+ est = int(position / max(1e-6, avg)) + 1
335
+ return min(est, num_pages)
336
+
337
+ def search_term_in_pdf(self, pdf_path: str, term: str) -> List[Dict[str, Any]]:
338
+ """Return one record per page where the term matches; snippet is normalized PDF text window."""
339
+ data = self._load_and_normalize_source(pdf_path, "PDF")
340
+ if not data or not self._fuzzy_match_quote_in_normalized(term, data):
341
+ return []
342
+ out: List[Dict[str, Any]] = []
343
+ for page_num, ptxt in data["normalized_pages"].items():
344
+ sub = {"normalized_text": ptxt, "type": "PDF"}
345
+ if self._fuzzy_match_quote_in_normalized(term, sub):
346
+ out.append(
347
+ {
348
+ "page": page_num,
349
+ "excerpt": self._find_excerpt_in_normalized(ptxt, term),
350
+ "approximate": False,
351
+ }
352
+ )
353
+ if not out:
354
+ est = self._estimate_page_from_position(data, term)
355
+ ntxt = data["normalized_text"]
356
+ out.append(
357
+ {
358
+ "page": est,
359
+ "excerpt": self._find_excerpt_in_normalized(ntxt, term, context=220),
360
+ "approximate": True,
361
+ }
362
+ )
363
+ return out
364
+
365
+ def search_group_in_pdf(self, pdf_path: str, phrases: List[str]) -> List[Dict[str, Any]]:
366
+ """
367
+ Search all phrases in the group; merge hits by page. Any phrase that matches
368
+ on a page counts; excerpt prefers the longer span for that page.
369
+ """
370
+ if not phrases:
371
+ return []
372
+ by_page: Dict[int, Dict[str, Any]] = {}
373
+ for phrase in phrases:
374
+ hits = self.search_term_in_pdf(pdf_path, phrase)
375
+ for h in hits:
376
+ p = h["page"]
377
+ prev = by_page.get(p)
378
+ if prev is None:
379
+ by_page[p] = dict(h)
380
+ continue
381
+ e_old = (prev.get("excerpt") or "")
382
+ e_new = (h.get("excerpt") or "")
383
+ if len(e_new) > len(e_old):
384
+ prev["excerpt"] = e_new
385
+ if h.get("approximate") is False:
386
+ prev["approximate"] = False
387
+ return [by_page[k] for k in sorted(by_page)]
388
+
389
+
390
+ # -----------------------------------------------------------------------------
391
+ # PDF metadata — APA-style line (author, year, title) from document info
392
+ # -----------------------------------------------------------------------------
393
+
394
+
395
+ def _parse_pdf_date_year(date_val) -> Optional[str]:
396
+ if date_val is None:
397
+ return None
398
+ s = str(date_val)
399
+ m = re.search(r"D:(\d{4})", s)
400
+ if m:
401
+ return m.group(1)
402
+ m = re.search(r"(\d{4})", s)
403
+ if m and 1900 <= int(m.group(1)) <= 2100:
404
+ return m.group(1)
405
+ return None
406
+
407
+
408
+ def apa_reference_for_pdf(pdf_path: str) -> Tuple[str, str, str, str]:
409
+ """
410
+ Returns (author, title, year, apa_one_line).
411
+ Unknown fields use best-effort defaults suitable for a report.
412
+ """
413
+ from pathlib import Path
414
+
415
+ author = "Unknown"
416
+ title = Path(pdf_path).stem.replace("_", " ")
417
+ year = "n.d."
418
+ try:
419
+ import pypdf
420
+
421
+ with open(pdf_path, "rb") as f:
422
+ r = pypdf.PdfReader(f)
423
+ meta = r.metadata
424
+ if meta is not None:
425
+ t = getattr(meta, "title", None)
426
+ a = getattr(meta, "author", None)
427
+ if hasattr(meta, "get"):
428
+ t = t or meta.get("/Title") # type: ignore[union-attr]
429
+ a = a or meta.get("/Author") # type: ignore[union-attr]
430
+ if t and str(t).strip():
431
+ title = str(t).strip()
432
+ if a and str(a).strip():
433
+ author = str(a).strip()
434
+ dt = getattr(meta, "creation_date", None) or getattr(meta, "modification_date", None)
435
+ if dt is not None and hasattr(dt, "year"):
436
+ year = str(dt.year)
437
+ else:
438
+ raw = getattr(meta, "creation_date_raw", None) or getattr(meta, "modification_date_raw", None)
439
+ y = _parse_pdf_date_year(raw)
440
+ if y:
441
+ year = y
442
+ except Exception:
443
+ pass
444
+ if year == "n.d.":
445
+ m = re.search(r"(20\d{2}|19\d{2})", title)
446
+ if m:
447
+ year = m.group(1)
448
+ apa = f"{author} ({year}). {title}."
449
+ return author, title, year, apa
450
+
451
+
452
+ def first_author_from_metadata_author(author: str) -> str:
453
+ """First listed author, single cell for the tree: surname or first name segment from PDF metadata."""
454
+ if not author or not str(author).strip() or str(author).strip() == "Unknown":
455
+ return "Unknown"
456
+ a0 = re.split(r"\s*;\s*|\s+and\s+", str(author).strip(), maxsplit=1, flags=re.IGNORECASE)[0].strip()
457
+ a0 = re.sub(r"^\s*[\(\[]?et\s+al\.[\)\]]?\s*$", "", a0, flags=re.IGNORECASE)
458
+ if not a0:
459
+ return "Unknown"
460
+ if "," in a0:
461
+ return a0.split(",")[0].strip()
462
+ parts = a0.split()
463
+ return parts[0] if parts else a0
464
+
465
+
466
+ def get_pdf_title_and_first_author(pdf_path: str) -> Tuple[str, str]:
467
+ """(document_title, first_author) for display columns."""
468
+ author, title, _year, _ = apa_reference_for_pdf(pdf_path)
469
+ return title, first_author_from_metadata_author(author)
470
+
471
+
472
+ # -----------------------------------------------------------------------------
473
+ # DOCX export (green highlight for verified match — same idea as Add Quotes in Word)
474
+ # -----------------------------------------------------------------------------
475
+
476
+
477
+ def export_docx(
478
+ output_path: str,
479
+ folder: str,
480
+ results_by_term: Dict[str, List[Dict[str, Any]]],
481
+ criteria_order: List[str],
482
+ ) -> None:
483
+ from docx import Document
484
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
485
+ from docx.oxml import OxmlElement
486
+ from docx.oxml.ns import qn
487
+ from docx.shared import Pt, RGBColor
488
+
489
+ doc = Document()
490
+ doc.add_heading(sanitize_xml_compatible("PDF criteria search report"), 0)
491
+ folder_safe = sanitize_xml_compatible(folder or "")
492
+ p = doc.add_paragraph(
493
+ sanitize_xml_compatible(
494
+ f"Folder searched: {folder_safe}. "
495
+ "Each group key searches a list of related phrases (see search_keywords.py). "
496
+ "Matching uses the same normalized-text / fuzzy match logic as 'Add Quotes' in visualize_gui.py. "
497
+ "Green background indicates a match on the cited page; 'approximate page' is used when the match spans a page break."
498
+ )
499
+ )
500
+ p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
501
+
502
+ for term in criteria_order:
503
+ hits = results_by_term.get(term, [])
504
+ doc.add_heading(sanitize_xml_compatible(str(term)), level=2)
505
+ if not hits:
506
+ doc.add_paragraph(sanitize_xml_compatible("No matches in any PDF in this folder."))
507
+ continue
508
+ for h in hits:
509
+ pdf = h["pdf_path"]
510
+ _, _, _, apa = apa_reference_for_pdf(pdf)
511
+ doc.add_paragraph(sanitize_xml_compatible(apa), style="List Bullet")
512
+ for occ in h["occurrences"]:
513
+ page = occ["page"]
514
+ approx = occ.get("approximate")
515
+ raw_ex = occ.get("excerpt", "") or ""
516
+ phrase = best_highlight_phrase_for_group(str(term), raw_ex)
517
+ line = sanitize_xml_compatible(
518
+ f"p. {page}" + (" (approximate page)" if approx else "")
519
+ )
520
+ pr = doc.add_paragraph()
521
+ r0 = pr.add_run(line + " — ")
522
+ r0.bold = True
523
+ r0.font.size = Pt(10)
524
+
525
+ def add_green_run(txt: str) -> None:
526
+ t = sanitize_xml_compatible(txt)
527
+ if not t:
528
+ return
529
+ rr = pr.add_run(t)
530
+ rr.font.size = Pt(10)
531
+ rr.font.color.rgb = RGBColor(255, 255, 255)
532
+ rpr = rr._element.get_or_add_rPr()
533
+ shd = OxmlElement("w:shd")
534
+ shd.set(qn("w:fill"), "006400")
535
+ shd.set(qn("w:val"), "clear")
536
+ rpr.append(shd)
537
+
538
+ def add_plain_run(txt: str) -> None:
539
+ t = sanitize_xml_compatible(txt)
540
+ if not t:
541
+ return
542
+ rr = pr.add_run(t)
543
+ rr.font.size = Pt(10)
544
+
545
+ if phrase and raw_ex:
546
+ lo = raw_ex.lower().find(phrase.lower())
547
+ if lo >= 0:
548
+ hi = lo + len(phrase)
549
+ add_plain_run(raw_ex[:lo])
550
+ add_green_run(raw_ex[lo:hi])
551
+ add_plain_run(raw_ex[hi:])
552
+ else:
553
+ add_plain_run(raw_ex)
554
+ else:
555
+ add_plain_run(raw_ex)
556
+ doc.add_paragraph()
557
+
558
+ doc.save(output_path)
559
+
560
+
561
+ # -----------------------------------------------------------------------------
562
+ # GUI
563
+ # -----------------------------------------------------------------------------
564
+
565
+
566
+ class CriteriaSearchApp(tk.Tk):
567
+ def __init__(self) -> None:
568
+ super().__init__()
569
+ self.title("PDF criteria search (Add Quotes matching)")
570
+ self.geometry("1280x720")
571
+ self.engine = QuoteSearchEngine()
572
+ self._search_results: Dict[str, List[Dict[str, Any]]] = {}
573
+ self._criteria_order: List[str] = []
574
+ self._pdf_display_cache: Dict[str, Tuple[str, str]] = {}
575
+ self.group_var = tk.BooleanVar(value=True)
576
+ self._terms_csv_path = terms_csv_path()
577
+ ensure_terms_csv(self._terms_csv_path)
578
+
579
+ top = ttk.Frame(self, padding=8)
580
+ top.pack(fill=tk.X)
581
+ ttk.Label(top, text="Folder with PDFs:").pack(side=tk.LEFT)
582
+ self.folder_var = tk.StringVar()
583
+ ttk.Entry(top, textvariable=self.folder_var, width=70).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4)
584
+ ttk.Button(top, text="Browse…", command=self._browse).pack(side=tk.LEFT)
585
+ ttk.Button(top, text="Search", command=self._start_search).pack(side=tk.LEFT, padx=4)
586
+ ttk.Button(top, text="Export DOCX…", command=self._export).pack(side=tk.LEFT)
587
+
588
+ self.progress = ttk.Progressbar(self, mode="indeterminate")
589
+ self.progress.pack(fill=tk.X, padx=8, pady=(0, 4))
590
+
591
+ body = ttk.PanedWindow(self, orient=tk.HORIZONTAL)
592
+ body.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
593
+
594
+ left = ttk.Frame(body, width=420)
595
+ body.add(left, weight=1)
596
+ left_top = ttk.Frame(left)
597
+ left_top.pack(fill=tk.X)
598
+ ttk.Label(
599
+ left_top,
600
+ text="Group keys (search_keywords.py): each key searches all listed phrases. Save list: CSV.",
601
+ wraplength=400,
602
+ ).pack(side=tk.LEFT, anchor=tk.W, fill=tk.X, expand=True)
603
+ ttk.Button(left_top, text="Save to CSV", command=self._save_terms_csv).pack(side=tk.RIGHT)
604
+ ttk.Label(left, text="# = section header. Colors = category.", font=("Segoe UI", 8)).pack(
605
+ anchor=tk.W, pady=(0, 2)
606
+ )
607
+ self.terms_text = tk.Text(left, width=52, height=24, wrap=tk.WORD, font=("Consolas", 9))
608
+ ys = ttk.Scrollbar(left, command=self.terms_text.yview)
609
+ self.terms_text.configure(yscrollcommand=ys.set)
610
+ self.terms_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
611
+ ys.pack(side=tk.RIGHT, fill=tk.Y)
612
+ self._setup_term_text_tags()
613
+ self._insert_keys_colored(load_terms_from_csv(self._terms_csv_path))
614
+
615
+ right = ttk.Frame(body, width=640)
616
+ body.add(right, weight=2)
617
+ rhead = ttk.Frame(right)
618
+ rhead.pack(fill=tk.X)
619
+ ttk.Label(
620
+ rhead,
621
+ text="All keywords for one PDF, then the next. Columns: title & first author from PDF metadata.",
622
+ wraplength=750,
623
+ ).pack(side=tk.LEFT, fill=tk.X, expand=True, anchor=tk.W)
624
+ self.group_cb = ttk.Checkbutton(
625
+ rhead,
626
+ text="GROUP",
627
+ variable=self.group_var,
628
+ command=self._on_group_toggle,
629
+ )
630
+ self.group_cb.pack(side=tk.RIGHT)
631
+ ttk.Label(
632
+ right,
633
+ text="(GROUP: one row per PDF + term, preview shows all page hits together)",
634
+ font=("Segoe UI", 8),
635
+ ).pack(anchor=tk.W)
636
+ tree_fr = ttk.Frame(right)
637
+ tree_fr.pack(fill=tk.BOTH, expand=True)
638
+ cols = ("term", "title", "first_author", "pdf", "page", "approx")
639
+ self.tree = ttk.Treeview(tree_fr, columns=cols, show="headings", height=10)
640
+ self.tree.heading("term", text="Term")
641
+ self.tree.heading("title", text="Title")
642
+ self.tree.heading("first_author", text="First author")
643
+ self.tree.heading("pdf", text="PDF file")
644
+ self.tree.heading("page", text="Page")
645
+ self.tree.heading("approx", text="Approx?")
646
+ self.tree.column("term", width=120)
647
+ self.tree.column("title", width=220)
648
+ self.tree.column("first_author", width=110)
649
+ self.tree.column("pdf", width=150)
650
+ self.tree.column("page", width=90)
651
+ self.tree.column("approx", width=60)
652
+ y2 = ttk.Scrollbar(tree_fr, command=self.tree.yview)
653
+ self.tree.configure(yscrollcommand=y2.set)
654
+ self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
655
+ y2.pack(side=tk.RIGHT, fill=tk.Y)
656
+ self.tree.bind("<<TreeviewSelect>>", self._on_tree_select)
657
+ self._tree_row_meta: Dict[str, Dict[str, Any]] = {}
658
+ self._configure_tree_category_tags()
659
+
660
+ ttk.Label(right, text="Preview (excerpts)").pack(anchor=tk.W, pady=(8, 0))
661
+ prev_fr = ttk.Frame(right)
662
+ prev_fr.pack(fill=tk.BOTH, expand=True)
663
+ self.preview = tk.Text(prev_fr, height=10, wrap=tk.WORD, font=("Consolas", 9), state=tk.DISABLED)
664
+ self.preview.tag_configure("m", background="#006400", foreground="white")
665
+ pys = ttk.Scrollbar(prev_fr, command=self.preview.yview)
666
+ self.preview.configure(yscrollcommand=pys.set)
667
+ self.preview.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
668
+ pys.pack(side=tk.RIGHT, fill=tk.Y)
669
+ ttk.Label(
670
+ right,
671
+ text=(
672
+ "Highlight: green needs the exact substring in the excerpt. "
673
+ "Matches without green used fuzzy/word-order logic or an excerpt slice without one whole phrase."
674
+ ),
675
+ font=("Segoe UI", 8),
676
+ wraplength=760,
677
+ foreground="#444444",
678
+ ).pack(anchor=tk.W, pady=(4, 0))
679
+
680
+ self.status = ttk.Label(
681
+ self,
682
+ text=f"Ready. Terms: {self._terms_csv_path}",
683
+ anchor=tk.W,
684
+ )
685
+ self.status.pack(fill=tk.X, padx=8, pady=4)
686
+
687
+ def _browse(self) -> None:
688
+ d = filedialog.askdirectory()
689
+ if d:
690
+ self.folder_var.set(d)
691
+ self.engine.clear_cache()
692
+
693
+ def _configure_tree_category_tags(self) -> None:
694
+ """Match row colors to left-panel category colors (whole row uses category fg/bg)."""
695
+ for cat_id, (bg, fg) in CATEGORY_STYLES.items():
696
+ self.tree.tag_configure(f"tcat_{cat_id}", background=bg, foreground=fg)
697
+ self.tree.tag_configure(
698
+ "pdf_sep",
699
+ background="#D5D5D5",
700
+ foreground="#333333",
701
+ font=("Segoe UI", 9, "bold"),
702
+ )
703
+
704
+ def _tree_tag_for_term(self, term: str) -> str:
705
+ cat = KEY_TO_CATEGORY.get(term, "unknown")
706
+ return f"tcat_{cat}"
707
+
708
+ def _setup_term_text_tags(self) -> None:
709
+ self.terms_text.tag_configure(
710
+ "header",
711
+ background="#ECEFF1",
712
+ foreground="#37474F",
713
+ font=("Consolas", 9, "bold"),
714
+ )
715
+ for cat_id, (bg, fg) in CATEGORY_STYLES.items():
716
+ self.terms_text.tag_configure(f"cat_{cat_id}", background=bg, foreground=fg)
717
+
718
+ def _insert_keys_colored(self, keys: List[str]) -> None:
719
+ self.terms_text.delete("1.0", tk.END)
720
+ if not keys:
721
+ return
722
+ last_cat: Optional[str] = None
723
+ for key in keys:
724
+ cat = KEY_TO_CATEGORY.get(key, "unknown")
725
+ if cat != last_cat:
726
+ if last_cat is not None:
727
+ self.terms_text.insert(tk.END, "\n")
728
+ title = CATEGORY_HEADINGS.get(cat, cat)
729
+ self.terms_text.insert(tk.END, f"# --- {title} ---\n", "header")
730
+ last_cat = cat
731
+ self.terms_text.insert(tk.END, f"{key}\n", f"cat_{cat}")
732
+
733
+ def _read_terms(self) -> List[str]:
734
+ raw = self.terms_text.get("1.0", tk.END)
735
+ return parse_terms_text(raw)
736
+
737
+ def _save_terms_csv(self) -> None:
738
+ terms = self._read_terms()
739
+ if not terms:
740
+ messagebox.showerror("No terms", "Add at least one line before saving.")
741
+ return
742
+ try:
743
+ write_terms_to_csv(self._terms_csv_path, terms)
744
+ except OSError as e:
745
+ messagebox.showerror("Save failed", str(e))
746
+ return
747
+ self._insert_keys_colored(terms)
748
+ self.status.config(
749
+ text=f"Saved {len(terms)} terms to {self._terms_csv_path}"
750
+ )
751
+ messagebox.showinfo("Saved", f"Wrote {len(terms)} terms to:\n{self._terms_csv_path}")
752
+
753
+ def _start_search(self) -> None:
754
+ folder = self.folder_var.get().strip()
755
+ if not folder or not os.path.isdir(folder):
756
+ messagebox.showerror("Invalid folder", "Please choose a folder that contains PDFs.")
757
+ return
758
+ terms = self._read_terms()
759
+ if not terms:
760
+ messagebox.showerror("No terms", "Add at least one search term.")
761
+ return
762
+ self.engine.clear_cache()
763
+ self._pdf_display_cache.clear()
764
+ self.progress.start(8)
765
+ self.status.config(text="Searching…")
766
+ t = threading.Thread(target=self._search_worker, args=(folder, terms), daemon=True)
767
+ t.start()
768
+
769
+ def _search_worker(self, folder: str, terms: List[str]) -> None:
770
+ pdfs = sorted(
771
+ f for f in os.listdir(folder) if f.lower().endswith(".pdf")
772
+ )
773
+ full_paths = [os.path.join(folder, f) for f in pdfs]
774
+ results: Dict[str, List[Dict[str, Any]]] = {t: [] for t in terms}
775
+ for term in terms:
776
+ phrases = phrases_for_group_key(term)
777
+ for fp in full_paths:
778
+ occ = self.engine.search_group_in_pdf(fp, phrases)
779
+ if occ:
780
+ results[term].append({"pdf_path": fp, "occurrences": occ})
781
+ self._criteria_order = list(terms)
782
+ self._search_results = results
783
+ self.after(0, lambda: self._search_done(len(pdfs), len(terms)))
784
+
785
+ def _search_done(self, n_pdf: int, n_term: int) -> None:
786
+ self.progress.stop()
787
+ self.status.config(
788
+ text=f"Done. {n_pdf} PDF file(s), {n_term} term(s). Terms file: {self._terms_csv_path}"
789
+ )
790
+ self._fill_tree()
791
+ if not any(self._search_results.get(t) for t in self._search_results):
792
+ messagebox.showinfo("Search", "No matches for any term. Try editing terms or another folder.")
793
+
794
+ def _on_group_toggle(self) -> None:
795
+ if self._search_results:
796
+ self._fill_tree()
797
+
798
+ def _get_pdf_display_titles(self, fp: str) -> Tuple[str, str]:
799
+ if fp not in self._pdf_display_cache:
800
+ t, fa = get_pdf_title_and_first_author(fp)
801
+ self._pdf_display_cache[fp] = (t, fa)
802
+ return self._pdf_display_cache[fp]
803
+
804
+ def _build_pdf_to_term_blocks(self) -> Tuple[List[str], Dict[str, List[Tuple[str, Dict[str, Any]]]]]:
805
+ """
806
+ List PDFs in sorted name order. For each path, a list of (term, block) in criteria order.
807
+ """
808
+ pdf_data: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
809
+ for term in self._criteria_order:
810
+ for block in self._search_results.get(term, []):
811
+ fp = block["pdf_path"]
812
+ pdf_data.setdefault(fp, []).append((term, block))
813
+ sorted_pdfs = sorted(pdf_data.keys(), key=lambda p: os.path.basename(p).lower())
814
+ return sorted_pdfs, pdf_data
815
+
816
+ def _fill_tree(self) -> None:
817
+ for i in self.tree.get_children():
818
+ self.tree.delete(i)
819
+ self._tree_row_meta.clear()
820
+ if not self._search_results or not any(self._search_results.get(t) for t in self._criteria_order):
821
+ return
822
+ grouped = self.group_var.get()
823
+ sorted_pdfs, pdf_data = self._build_pdf_to_term_blocks()
824
+ for fp in sorted_pdfs:
825
+ tdoc, tauth = self._get_pdf_display_titles(fp)
826
+ base = os.path.basename(fp)
827
+ sep_label = f"━━━━ {base} ━━━━"
828
+ sep_iid = self.tree.insert(
829
+ "",
830
+ tk.END,
831
+ values=(sep_label, "", "", "", "", ""),
832
+ tags=("pdf_sep",),
833
+ )
834
+ self._tree_row_meta[sep_iid] = {"separator": True, "basename": base}
835
+ for term, block in pdf_data[fp]:
836
+ occs = block.get("occurrences", [])
837
+ if not occs:
838
+ continue
839
+ if grouped:
840
+ apx = "yes" if any(o.get("approximate") for o in occs) else "no"
841
+ pages = ", ".join(str(o["page"]) for o in occs)
842
+ iid = self.tree.insert(
843
+ "",
844
+ tk.END,
845
+ values=(term, tdoc, tauth, base, pages, apx),
846
+ tags=(self._tree_tag_for_term(term),),
847
+ )
848
+ self._tree_row_meta[iid] = {
849
+ "term": term,
850
+ "pdf_path": fp,
851
+ "grouped": True,
852
+ "items": [
853
+ {
854
+ "page": o["page"],
855
+ "excerpt": o.get("excerpt", ""),
856
+ "approximate": o.get("approximate", False),
857
+ }
858
+ for o in occs
859
+ ],
860
+ }
861
+ else:
862
+ for occ in occs:
863
+ apx = "yes" if occ.get("approximate") else "no"
864
+ iid = self.tree.insert(
865
+ "",
866
+ tk.END,
867
+ values=(term, tdoc, tauth, base, str(occ["page"]), apx),
868
+ tags=(self._tree_tag_for_term(term),),
869
+ )
870
+ self._tree_row_meta[iid] = {
871
+ "term": term,
872
+ "pdf_path": fp,
873
+ "grouped": False,
874
+ "excerpt": occ.get("excerpt", ""),
875
+ "page": occ["page"],
876
+ "approximate": occ.get("approximate", False),
877
+ }
878
+
879
+ def _on_tree_select(self, _e=None) -> None:
880
+ sel = self.tree.selection()
881
+ if not sel:
882
+ return
883
+ meta = self._tree_row_meta.get(sel[0])
884
+ if not meta:
885
+ return
886
+ if meta.get("separator"):
887
+ self.preview.config(state=tk.NORMAL)
888
+ self.preview.delete("1.0", tk.END)
889
+ self.preview.insert(
890
+ tk.END,
891
+ f"[PDF section: {meta.get('basename', '')}] — select a row below for excerpts.",
892
+ )
893
+ self.preview.config(state=tk.DISABLED)
894
+ return
895
+ term = meta["term"]
896
+ if meta.get("grouped") and "items" in meta:
897
+ self._set_preview_hl_list(term, meta["items"])
898
+ else:
899
+ self._set_preview_hl(
900
+ term,
901
+ meta.get("excerpt", ""),
902
+ )
903
+
904
+ def _best_highlight_phrase(self, group_key: str, excerpt: str) -> str:
905
+ """Pick a contiguous substring in `excerpt` to highlight (mirror of literal match UX)."""
906
+ return best_highlight_phrase_for_group(group_key, excerpt)
907
+
908
+ def _insert_excerpt_hl(self, group_key: str, excerpt: str) -> None:
909
+ if not excerpt:
910
+ self.preview.insert(tk.END, "(no excerpt)")
911
+ return
912
+ n = self._best_highlight_phrase(group_key, excerpt)
913
+ if not n:
914
+ self.preview.insert(tk.END, excerpt)
915
+ return
916
+ low_ex = excerpt.lower()
917
+ idx = low_ex.find(n.lower()) if n else -1
918
+ if idx < 0:
919
+ self.preview.insert(tk.END, excerpt)
920
+ return
921
+ self.preview.insert(tk.END, excerpt[:idx])
922
+ self.preview.insert(tk.END, excerpt[idx : idx + len(n)], "m")
923
+ self.preview.insert(tk.END, excerpt[idx + len(n) :])
924
+
925
+ def _set_preview_hl(self, group_key: str, excerpt: str) -> None:
926
+ self.preview.config(state=tk.NORMAL)
927
+ self.preview.delete("1.0", tk.END)
928
+ self._insert_excerpt_hl(group_key, excerpt)
929
+ self.preview.config(state=tk.DISABLED)
930
+
931
+ def _set_preview_hl_list(self, group_key: str, items: List[Dict[str, Any]]) -> None:
932
+ self.preview.config(state=tk.NORMAL)
933
+ self.preview.delete("1.0", tk.END)
934
+ for i, it in enumerate(items):
935
+ if i:
936
+ self.preview.insert(tk.END, "\n\n")
937
+ p = it.get("page", "")
938
+ apx = " (approx.)" if it.get("approximate") else ""
939
+ self.preview.insert(tk.END, f"p. {p}{apx}\n")
940
+ self._insert_excerpt_hl(group_key, it.get("excerpt", ""))
941
+ self.preview.config(state=tk.DISABLED)
942
+
943
+ def _export(self) -> None:
944
+ if not self._search_results or not any(self._search_results.values()):
945
+ messagebox.showinfo("Export", "Run a search with at least one match first.")
946
+ return
947
+ try:
948
+ import docx # noqa: F401
949
+ except ImportError:
950
+ messagebox.showerror("Missing package", "Install python-docx: pip install python-docx")
951
+ return
952
+ path = filedialog.asksaveasfilename(
953
+ defaultextension=".docx",
954
+ filetypes=[("Word", "*.docx"), ("All", "*.*")],
955
+ )
956
+ if not path:
957
+ return
958
+ try:
959
+ export_docx(
960
+ path,
961
+ self.folder_var.get().strip(),
962
+ self._search_results,
963
+ self._criteria_order,
964
+ )
965
+ messagebox.showinfo("Export", f"Saved:\n{path}")
966
+ except Exception as ex: # noqa: BLE001
967
+ messagebox.showerror("Export failed", str(ex))
968
+
969
+
970
+ def main() -> int:
971
+ # pypdf can trigger cryptography’s ARC4 import warning (third-party, harmless for read-only PDFs)
972
+ import warnings
973
+
974
+ try:
975
+ from cryptography.utils import CryptographyDeprecationWarning
976
+
977
+ warnings.filterwarnings("ignore", category=CryptographyDeprecationWarning)
978
+ except ImportError:
979
+ warnings.filterwarnings("ignore", message=".*ARC4.*")
980
+ try:
981
+ import pypdf # noqa: F401
982
+ except ImportError:
983
+ print("Install pypdf: pip install pypdf", file=sys.stderr)
984
+ return 1
985
+ app = CriteriaSearchApp()
986
+ app.mainloop()
987
+ return 0
988
+
989
+
990
+ if __name__ == "__main__":
991
+ raise SystemExit(main())
requirements.txt CHANGED
@@ -1,52 +1,53 @@
1
- gradio==3.40.0
2
- langchain-community==0.0.19
3
- langchain_core==0.1.22
4
- langchain-openai==0.0.5
5
- faiss-cpu==1.7.3
6
- huggingface-hub==0.24.7
7
- google-generativeai==0.3.2
8
- opencv-python==4.9.0.80
9
- pdf2image==1.17.0
10
- pdfminer-six==20221105
11
- pikepdf==8.12.0
12
- pypdf==4.0.1
13
- rank-bm25==0.2.2
14
- replicate==0.23.1
15
- tiktoken==0.5.2
16
- unstructured==0.12.3
17
- unstructured-pytesseract==0.3.12
18
- unstructured-inference==0.7.23
19
-
20
- # generated
21
-
22
- # Transformers for the DeepSeek model and cross-encoder reranker
23
- transformers>=4.34.0
24
-
25
- # PyTorch required by DeepSeek and many Hugging Face models
26
- torch>=2.0.0
27
-
28
- # LangChain (the main package) – adjust the version if needed
29
- langchain>=0.0.200
30
-
31
- # LangChain Community components (for document loaders, vector stores, retrievers, etc.)
32
- langchain-community
33
-
34
- # LangChain Core components (for runnables, etc.)
35
- langchain-core
36
-
37
-
38
- # FAISS for vector storage and similarity search (CPU version)
39
- faiss-cpu
40
-
41
- # PDF parsing (e.g., used by OnlinePDFLoader)
42
- pdfminer.six
43
-
44
- # Pin Pydantic to version 2 for mistralai compatibility
45
- pydantic>=2.9.0,<3.0.0
46
-
47
- sentence-transformers>=2.4.0
48
-
49
- mistralai==1.5.0
50
- google-generativeai
51
- anthropic
 
52
  requests
 
1
+ gradio==3.40.0
2
+ langchain-community==0.0.19
3
+ langchain_core==0.1.22
4
+ langchain-openai==0.0.5
5
+ faiss-cpu==1.7.3
6
+ huggingface-hub==0.24.7
7
+ google-generativeai==0.3.2
8
+ opencv-python==4.9.0.80
9
+ pdf2image==1.17.0
10
+ pdfminer-six==20221105
11
+ pikepdf==8.12.0
12
+ pypdf==4.0.1
13
+ python-docx>=1.1.0
14
+ rank-bm25==0.2.2
15
+ replicate==0.23.1
16
+ tiktoken==0.5.2
17
+ unstructured==0.12.3
18
+ unstructured-pytesseract==0.3.12
19
+ unstructured-inference==0.7.23
20
+
21
+ # generated
22
+
23
+ # Transformers for the DeepSeek model and cross-encoder reranker
24
+ transformers>=4.34.0
25
+
26
+ # PyTorch required by DeepSeek and many Hugging Face models
27
+ torch>=2.0.0
28
+
29
+ # LangChain (the main package) – adjust the version if needed
30
+ langchain>=0.0.200
31
+
32
+ # LangChain Community components (for document loaders, vector stores, retrievers, etc.)
33
+ langchain-community
34
+
35
+ # LangChain Core components (for runnables, etc.)
36
+ langchain-core
37
+
38
+
39
+ # FAISS for vector storage and similarity search (CPU version)
40
+ faiss-cpu
41
+
42
+ # PDF parsing (e.g., used by OnlinePDFLoader)
43
+ pdfminer.six
44
+
45
+ # Pin Pydantic to version 2 for mistralai compatibility
46
+ pydantic>=2.9.0,<3.0.0
47
+
48
+ sentence-transformers>=2.4.0
49
+
50
+ mistralai==1.5.0
51
+ google-generativeai
52
+ anthropic
53
  requests
search_keywords.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phrase lists and category metadata for PDF criteria search (FilterLM / pdf_criteria_search_gui_v2).
3
+
4
+ Each *group key* maps to one or more literal phrases searched in normalized PDF text.
5
+ Keys and ordering align with ``criteria_search_terms.csv`` when present.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Dict, List
11
+
12
+ # Ordered keys (must match criteria_search_terms.csv “term” column).
13
+ _ORDERED_KEYS: List[str] = [
14
+ "quality",
15
+ "helpfulness",
16
+ "harmlessness",
17
+ "safety",
18
+ "groundedness",
19
+ "honesty",
20
+ "truthfulness",
21
+ "hallucinations",
22
+ "empathy",
23
+ "politeness",
24
+ "relevance",
25
+ "verbosity",
26
+ "component",
27
+ "aggregation",
28
+ "ranking",
29
+ "weighting",
30
+ "tradeoffs",
31
+ "other",
32
+ "nonspecific",
33
+ "grice",
34
+ "specific",
35
+ "otherframework",
36
+ "conversations",
37
+ "humanai",
38
+ "annotation",
39
+ "separate",
40
+ "naturalistic",
41
+ "hybrid",
42
+ "synthetic",
43
+ "datasetname",
44
+ "datasetsource",
45
+ "dialogs",
46
+ "annotators",
47
+ "context",
48
+ "annotator",
49
+ "single",
50
+ "multi",
51
+ "prerecorded",
52
+ "realtime",
53
+ "scales",
54
+ "binary",
55
+ "validated",
56
+ "notes",
57
+ "named",
58
+ "unspecified",
59
+ "family",
60
+ "rlhf",
61
+ "partial",
62
+ "collected",
63
+ "reward",
64
+ "policy",
65
+ "stage",
66
+ "benchmark",
67
+ "judge",
68
+ "comparators",
69
+ "winrate",
70
+ "strategy",
71
+ "gains",
72
+ "losses",
73
+ "inconsistent",
74
+ "unchanged",
75
+ "quantified",
76
+ ]
77
+
78
+
79
+ def _default_phrases(key: str) -> List[str]:
80
+ """Human-readable phrase plus the raw token; extend SEARCH_KEYWORDS below as needed."""
81
+ human = key.replace("_", " ")
82
+ seen: set[str] = set()
83
+ out: List[str] = []
84
+ for p in (human, key):
85
+ low = p.lower()
86
+ if low not in seen:
87
+ seen.add(low)
88
+ out.append(p)
89
+ return out
90
+
91
+
92
+ SEARCH_KEYWORDS: Dict[str, List[str]] = {k: _default_phrases(k) for k in _ORDERED_KEYS}
93
+
94
+ # Category slices over _ORDERED_KEYS indices [start, end).
95
+ _SLICES = (
96
+ ("dimensions", 0, 12),
97
+ ("modeling", 12, 20),
98
+ ("linguistics", 20, 24),
99
+ ("corpus", 24, 32),
100
+ ("dataset_meta", 32, 49),
101
+ ("training_eval", 49, len(_ORDERED_KEYS)),
102
+ )
103
+
104
+ KEY_TO_CATEGORY: Dict[str, str] = {}
105
+ for cat_id, a, b in _SLICES:
106
+ for key in _ORDERED_KEYS[a:b]:
107
+ KEY_TO_CATEGORY[key] = cat_id
108
+
109
+ CATEGORY_HEADINGS: Dict[str, str] = {
110
+ "dimensions": "Evaluation dimensions",
111
+ "modeling": "Aggregation / modeling",
112
+ "linguistics": "Discourse & frameworks",
113
+ "corpus": "Study / corpus types",
114
+ "dataset_meta": "Dataset & annotation",
115
+ "training_eval": "Training & metrics",
116
+ }
117
+
118
+ CATEGORY_STYLES: Dict[str, tuple[str, str]] = {
119
+ "dimensions": ("#E8F5E9", "#1B5E20"),
120
+ "modeling": ("#E3F2FD", "#0D47A1"),
121
+ "linguistics": ("#FFF3E0", "#E65100"),
122
+ "corpus": ("#F3E5F5", "#6A1B9A"),
123
+ "dataset_meta": ("#ECEFF1", "#37474F"),
124
+ "training_eval": ("#FFEBEE", "#B71C1C"),
125
+ }
126
+
127
+
128
+ def ordered_group_keys() -> List[str]:
129
+ return list(_ORDERED_KEYS)