Shiv1143 commited on
Commit
3f2f736
·
verified ·
1 Parent(s): 456ef51

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -533
app.py DELETED
@@ -1,533 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Gradio UI for Vehicle Spec RAG — standalone, same pipeline as the notebook.
4
-
5
- Hugging Face Space files (nothing else required):
6
- app.py
7
- requirements.txt
8
- vehicle_spec_extraction.ipynb (optional, for the demo notebook)
9
-
10
- Upload a workshop-manual PDF in the UI, then ask for specs.
11
- Optional Space secrets: OPENAI_API_KEY, MISTRAL_API_KEY, HF_TOKEN
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import hashlib
17
- import json
18
- import os
19
- import re
20
- import warnings
21
- from dataclasses import dataclass
22
- from pathlib import Path
23
- from typing import Any
24
-
25
- warnings.filterwarnings("ignore", category=DeprecationWarning)
26
-
27
- os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
28
- os.environ.setdefault("OMP_NUM_THREADS", "1")
29
- os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
30
-
31
- ROOT = Path(__file__).resolve().parent
32
- try:
33
- from dotenv import load_dotenv
34
- load_dotenv(ROOT / ".env")
35
- except Exception:
36
- pass
37
-
38
- import gradio as gr
39
- import pymupdf
40
- from langchain_community.vectorstores import FAISS
41
- from langchain_core.documents import Document
42
- from langchain_core.output_parsers import StrOutputParser
43
- from langchain_core.prompts import ChatPromptTemplate
44
- from langchain_core.runnables import RunnablePassthrough
45
- from langchain_huggingface import HuggingFaceEmbeddings
46
-
47
- OPENAI_KEY = os.getenv("OPENAI_API_KEY", "")
48
- MISTRAL_KEY = os.getenv("MISTRAL_API_KEY", "")
49
- HF_TOKEN = os.getenv("HF_TOKEN", "")
50
- has_openai = bool(OPENAI_KEY)
51
- has_mistral = bool(MISTRAL_KEY)
52
- has_hf = bool(HF_TOKEN)
53
-
54
- _BATCH = 128
55
- _LOCAL_PDF = next(ROOT.glob("*.pdf"), None)
56
-
57
- # ── PDF parsing (PyMuPDF, same as notebook) ───────────────────────────────────
58
- _NOISE = [
59
- re.compile(r"file:///C:/TSO/[^\n]+", re.I),
60
- re.compile(r"^\s*repair4less\s*$", re.I | re.M),
61
- re.compile(r"^\s*Page\s+\d+\s+sur\s+\d+\s*$", re.I | re.M),
62
- re.compile(r"^\s*\d{4}-\d{2}-\d{2}\s*$", re.M),
63
- re.compile(r"^\s*2014 F-150 Workshop Manual\s*$", re.I | re.M),
64
- ]
65
- _SECTION_RE = re.compile(r"SECTION\s+([\d\-A-Za-z]+):\s*([^\n]+)", re.I)
66
-
67
-
68
- @dataclass
69
- class PageText:
70
- page_number: int
71
- text: str
72
- section_id: str | None = None
73
- section_title: str | None = None
74
- char_count: int = 0
75
-
76
-
77
- def clean_text(text: str) -> str:
78
- cleaned = text.replace("\x00", " ")
79
- for pattern in _NOISE:
80
- cleaned = pattern.sub("", cleaned)
81
- cleaned = re.sub(r"[ \t]+", " ", cleaned)
82
- return re.sub(r"\n{3,}", "\n\n", cleaned).strip()
83
-
84
-
85
- def extract_pages(pdf_path: str | Path) -> list[PageText]:
86
- pages: list[PageText] = []
87
- last_id = last_title = None
88
- with pymupdf.open(str(pdf_path)) as doc:
89
- for i, page in enumerate(doc):
90
- raw = page.get_text("text") or ""
91
- text = clean_text(raw)
92
- match = _SECTION_RE.search(raw)
93
- if match:
94
- last_id, last_title = match.group(1).strip(), match.group(2).strip()
95
- pages.append(
96
- PageText(
97
- page_number=i + 1,
98
- text=text,
99
- section_id=last_id,
100
- section_title=last_title,
101
- char_count=len(text),
102
- )
103
- )
104
- return pages
105
-
106
-
107
- def chunk_pages(pages: list[PageText], max_chars: int = 1200, overlap: int = 150) -> list[dict]:
108
- spec_markers = ("Torque Specifications", "General Specifications", "Fill Capacity", "Fill\nCapacity")
109
- chunks: list[dict] = []
110
-
111
- def split_long(text: str) -> list[str]:
112
- if len(text) <= max_chars:
113
- return [text]
114
- pieces = re.split(r"\n(?=\d+\.\n|\n)", text)
115
- out, buf = [], ""
116
- for piece in pieces:
117
- piece = piece.strip()
118
- if not piece:
119
- continue
120
- if buf and len(buf) + len(piece) + 1 > max_chars:
121
- out.append(buf.strip())
122
- buf = (buf[-overlap:] + "\n" + piece).strip() if overlap else piece
123
- else:
124
- buf = f"{buf}\n{piece}".strip() if buf else piece
125
- if buf:
126
- out.append(buf.strip())
127
- final = []
128
- for c in out:
129
- if len(c) <= max_chars:
130
- final.append(c)
131
- continue
132
- start = 0
133
- while start < len(c):
134
- end = min(start + max_chars, len(c))
135
- final.append(c[start:end].strip())
136
- if end >= len(c):
137
- break
138
- start = max(0, end - overlap)
139
- return [c for c in final if c]
140
-
141
- for page in pages:
142
- text = page.text.strip()
143
- if page.char_count < 40 or not text:
144
- continue
145
- is_spec = any(m in text for m in spec_markers) and len(text) <= max_chars * 2
146
- parts = [text] if is_spec else split_long(text)
147
- for idx, part in enumerate(parts):
148
- header = f"Page {page.page_number}"
149
- if page.section_id:
150
- header = f"SECTION {page.section_id}: {page.section_title or ''} | {header}"
151
- cid = hashlib.md5(f"{page.page_number}|{idx}|{part[:80]}".encode()).hexdigest()[:12]
152
- chunks.append(
153
- {
154
- "chunk_id": cid,
155
- "text": f"{header}\n{part}",
156
- "page": page.page_number,
157
- "section_id": page.section_id or "",
158
- "section_title": page.section_title or "",
159
- "chunk_type": "spec" if is_spec else ("page" if len(parts) == 1 else "section"),
160
- }
161
- )
162
- return chunks
163
-
164
-
165
- # ── Offline spec parser (same idea as notebook fallback) ──────────────────────
166
- _TORQUE_ROW = re.compile(
167
- r"(?P<component>[A-Za-z][A-Za-z0-9\-/()., ]{2,80}?)\s*\n\s*"
168
- r"(?P<nm>\d+(?:\.\d+)?)\s*\n?\s*"
169
- r"(?P<lbft>\d+(?:\.\d+)?|—|-)\s*\n?\s*"
170
- r"(?P<lbin>\d+(?:\.\d+)?|—|-)",
171
- re.M,
172
- )
173
- _INLINE_TORQUE = re.compile(
174
- r"(?:Tighten(?:\s+the\s+(?P<fastener>\w+(?:\s+\w+)?))?\s+to|torque(?:d)?\s+to)\s+"
175
- r"(?P<nm>\d+(?:\.\d+)?)\s*Nm(?:\s*\((?P<lbft>\d+(?:\.\d+)?)\s*lb-ft\))?",
176
- re.I,
177
- )
178
- _CAPACITY = re.compile(
179
- r"(?P<value>[\d,]+(?:\.\d+)?)\s*(?P<unit>ml|L)\b(?:\s*\((?P<alt>[\d.]+)\s*(?P<alt_unit>pt|qt|ml|L)\))?",
180
- re.I,
181
- )
182
- _DIM = re.compile(
183
- r"(?P<item>[A-Za-z][A-Za-z0-9 /\-]{4,70}?)\s+"
184
- r"(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>mm)\s*(?:\((?P<alt>[\d.]+)\s*in\))?",
185
- re.I,
186
- )
187
- _STOP = {"the", "for", "and", "of", "a", "an", "to", "in", "on", "what", "is", "with",
188
- "torque", "value", "spec", "specification", "capacity", "how"}
189
- _DISTINCTIVE = {
190
- "caliper", "shock", "absorber", "hub", "bearing", "stabilizer", "disc", "pad",
191
- "axle", "strut", "knuckle", "driveshaft",
192
- }
193
-
194
-
195
- def _tokens(query: str) -> set[str]:
196
- return {t for t in re.findall(r"[a-z0-9]+", query.lower()) if t not in _STOP and len(t) > 2}
197
-
198
-
199
- def _relevant(name: str, content: set[str]) -> bool:
200
- if not content:
201
- return True
202
- lower = name.lower()
203
- required = content & _DISTINCTIVE
204
- if required and not all(t in lower for t in required):
205
- return False
206
- for q in ("lower", "upper", "front", "rear"):
207
- if q in content and q not in lower:
208
- return False
209
- roots = {t for t in content if len(t) >= 5} or content
210
- return sum(t in lower for t in roots) / len(roots) >= 0.34
211
-
212
-
213
- def extract_with_rules(query: str, docs: list[Document]) -> list[dict[str, Any]]:
214
- content = _tokens(query) - {"bolt", "bolts", "nut", "nuts", "screw"}
215
- wants_torque = bool(re.search(r"torque|tighten|nm|bolt|nut", query, re.I))
216
- wants_fluid = bool(re.search(r"fluid|capacity|fill|ml|liter", query, re.I))
217
- wants_dim = bool(re.search(r"thickness|clearance|dimension|minimum|maximum", query, re.I))
218
- if not any([wants_torque, wants_fluid, wants_dim]):
219
- wants_torque = True
220
-
221
- results, seen = [], set()
222
- for doc in docs:
223
- text, page, section = doc.page_content, doc.metadata.get("page"), doc.metadata.get("section_id")
224
- if wants_torque:
225
- for m in _TORQUE_ROW.finditer(text):
226
- component = re.sub(r"\s+", " ", m.group("component")).strip(" -")
227
- if len(component) < 4 or component.lower() in {"description", "item", "specification"}:
228
- continue
229
- if not _relevant(component, content):
230
- continue
231
- try:
232
- nm = float(m.group("nm"))
233
- except ValueError:
234
- continue
235
- if not 0 < nm <= 800:
236
- continue
237
- key = (component.lower(), m.group("nm"), page)
238
- if key in seen:
239
- continue
240
- seen.add(key)
241
- notes = []
242
- if m.group("lbft") not in {"—", "-", "–"}:
243
- notes.append(f"{m.group('lbft')} lb-ft")
244
- if m.group("lbin") not in {"—", "-", "–"}:
245
- notes.append(f"{m.group('lbin')} lb-in")
246
- results.append({
247
- "component": component, "spec_type": "Torque",
248
- "value": m.group("nm"), "unit": "Nm",
249
- "notes": "; ".join(notes) or None,
250
- "source_page": page, "section": section,
251
- })
252
- for m in _INLINE_TORQUE.finditer(text):
253
- fastener = (m.group("fastener") or "Fastener").strip()
254
- if not _relevant(fastener + " " + text[:200], content) and content:
255
- continue
256
- key = (fastener.lower(), m.group("nm"), page)
257
- if key in seen:
258
- continue
259
- seen.add(key)
260
- results.append({
261
- "component": fastener, "spec_type": "Torque",
262
- "value": m.group("nm"), "unit": "Nm",
263
- "notes": f"{m.group('lbft')} lb-ft" if m.group("lbft") else None,
264
- "source_page": page, "section": section,
265
- })
266
- if wants_fluid:
267
- for m in _CAPACITY.finditer(text):
268
- value = m.group("value").replace(",", "")
269
- try:
270
- if m.group("unit").lower() == "ml" and float(value) < 50:
271
- continue
272
- except ValueError:
273
- continue
274
- key = ("brake fluid", value, page)
275
- if key in seen:
276
- continue
277
- seen.add(key)
278
- alt = f"{m.group('alt')} {m.group('alt_unit')}" if m.group("alt") else None
279
- results.append({
280
- "component": "Brake Fluid", "spec_type": "Fluid Capacity",
281
- "value": value, "unit": m.group("unit"), "notes": alt,
282
- "source_page": page, "section": section,
283
- })
284
- if wants_dim:
285
- for m in _DIM.finditer(text):
286
- item = re.sub(r"\s+", " ", m.group("item")).strip(" -")
287
- if not _relevant(item, content):
288
- continue
289
- key = (item.lower(), m.group("value"), page)
290
- if key in seen:
291
- continue
292
- seen.add(key)
293
- results.append({
294
- "component": item, "spec_type": "Dimension",
295
- "value": m.group("value"), "unit": "mm",
296
- "notes": f"{m.group('alt')} in" if m.group("alt") else None,
297
- "source_page": page, "section": section,
298
- })
299
- return results
300
-
301
-
302
- # ── LangChain RAG (same LCEL pattern as the notebook) ─────────────────────────
303
- SPEC_PROMPT = ChatPromptTemplate.from_messages([
304
- ("system", """You are an expert automotive technician extracting specifications from a service manual.
305
-
306
- Return ONLY a valid JSON array. Each element must have these exact keys:
307
- component, spec_type, value, unit, notes, source_page, section
308
-
309
- Rules:
310
- - spec_type ∈ {Torque, Fluid Capacity, Dimension, Part Number, Clearance}
311
- - Primary unit: Nm (torque), ml (fluid), mm (dimension)
312
- - Put lb-ft / lb-in / pt in the notes field
313
- - Do NOT invent values. Output raw JSON only."""),
314
- ("user", "Manual excerpts:\n{context}\n\nQuery: {question}\n\nJSON array:"),
315
- ])
316
-
317
-
318
- def format_docs(docs: list) -> str:
319
- return "\n\n---\n".join(
320
- f"[Page {d.metadata.get('page', '?')} | {d.metadata.get('section_id', '')}]\n{d.page_content}"
321
- for d in docs
322
- )
323
-
324
-
325
- def parse_spec_json(raw: str) -> list[dict]:
326
- raw = re.sub(r"^```(?:json)?", "", raw.strip(), flags=re.M).strip(" `\n")
327
- try:
328
- data = json.loads(raw)
329
- if isinstance(data, list):
330
- return data
331
- if isinstance(data, dict):
332
- for key in ("results", "specifications", "data", "items"):
333
- if isinstance(data.get(key), list):
334
- return data[key]
335
- except json.JSONDecodeError:
336
- match = re.search(r"\[.*?\]", raw, re.S)
337
- if match:
338
- try:
339
- return json.loads(match.group())
340
- except Exception:
341
- pass
342
- return []
343
-
344
-
345
- def get_best_llm():
346
- if has_openai:
347
- from langchain_openai import ChatOpenAI
348
- for name, label in (("gpt-4", "GPT-4"), ("gpt-3.5-turbo", "GPT-3.5-turbo")):
349
- try:
350
- return ChatOpenAI(model=name, temperature=0, openai_api_key=OPENAI_KEY), label
351
- except Exception:
352
- continue
353
- if has_mistral:
354
- try:
355
- from langchain_mistralai import ChatMistralAI
356
- return ChatMistralAI(model="mistral-small", temperature=0, mistral_api_key=MISTRAL_KEY), "Mistral"
357
- except Exception:
358
- pass
359
- if has_hf:
360
- try:
361
- from langchain_huggingface import HuggingFaceEndpoint
362
- return HuggingFaceEndpoint(
363
- repo_id="mistralai/Mistral-7B-Instruct-v0.3",
364
- huggingfacehub_api_token=HF_TOKEN,
365
- temperature=0.01,
366
- ), "Mistral-7B (HF)"
367
- except Exception:
368
- pass
369
- return None, "offline (rule-based)"
370
-
371
-
372
- def embeddings():
373
- if has_openai:
374
- from langchain_openai import OpenAIEmbeddings
375
- return OpenAIEmbeddings(model="text-embedding-3-small", openai_api_key=OPENAI_KEY)
376
- return HuggingFaceEmbeddings(
377
- model_name="sentence-transformers/all-MiniLM-L6-v2",
378
- model_kwargs={"device": "cpu"},
379
- encode_kwargs={"normalize_embeddings": True, "batch_size": 32},
380
- multi_process=False,
381
- show_progress=False,
382
- )
383
-
384
-
385
- class SpecRAG:
386
- def __init__(self) -> None:
387
- self.store: FAISS | None = None
388
- self.retriever = None
389
- self.llm = None
390
- self.llm_label = "offline (rule-based)"
391
- self.n_pages = 0
392
- self.n_chunks = 0
393
- self.pdf_name = ""
394
-
395
- def ingest(self, pdf_path: str | Path) -> str:
396
- import faiss as _faiss
397
- _faiss.omp_set_num_threads(1)
398
-
399
- pages = [p for p in extract_pages(pdf_path) if p.char_count >= 40]
400
- chunks = chunk_pages(pages)
401
- docs = [
402
- Document(
403
- page_content=c["text"],
404
- metadata={k: c[k] for k in ("page", "section_id", "section_title", "chunk_type", "chunk_id")},
405
- )
406
- for c in chunks
407
- ]
408
- emb = embeddings()
409
- store = FAISS.from_documents(docs[:_BATCH], emb)
410
- for start in range(_BATCH, len(docs), _BATCH):
411
- store.add_documents(docs[start : start + _BATCH])
412
-
413
- self.store = store
414
- self.retriever = store.as_retriever(search_type="mmr", search_kwargs={"k": 6, "fetch_k": 20})
415
- self.n_pages, self.n_chunks = len(pages), len(docs)
416
- self.pdf_name = Path(pdf_path).name
417
- llm, label = get_best_llm()
418
- self.llm, self.llm_label = llm, label
419
- emb_label = "OpenAI" if has_openai else "Sentence-Transformers"
420
- return (
421
- f"Indexed {self.pdf_name}: {self.n_pages} pages → {self.n_chunks} chunks | "
422
- f"embedding={emb_label} | llm={self.llm_label}"
423
- )
424
-
425
- def query(self, question: str, top_k: int = 6) -> dict[str, Any]:
426
- if self.retriever is None:
427
- raise RuntimeError("Index a PDF first.")
428
- retriever = self.store.as_retriever(
429
- search_type="mmr",
430
- search_kwargs={"k": int(top_k), "fetch_k": max(20, int(top_k) * 3)},
431
- )
432
- docs = retriever.invoke(question)
433
- retrieved = [
434
- f"#{i} p.{d.metadata.get('page')} ({d.metadata.get('section_id')}) — "
435
- f"{d.page_content[:140].replace(chr(10), ' ')}…"
436
- for i, d in enumerate(docs, start=1)
437
- ]
438
- results, method = [], "rules"
439
- if self.llm:
440
- try:
441
- chain = (
442
- {"context": retriever | format_docs, "question": RunnablePassthrough()}
443
- | SPEC_PROMPT | self.llm | StrOutputParser()
444
- )
445
- results = parse_spec_json(chain.invoke(question))
446
- if results:
447
- method = self.llm_label
448
- except Exception:
449
- results = []
450
- if not results:
451
- results = extract_with_rules(question, docs)
452
- method = "rules"
453
- return {"method": method, "results": results, "retrieved": retrieved}
454
-
455
-
456
- pipeline = SpecRAG()
457
-
458
-
459
- def _pdf_path(upload) -> Path | None:
460
- if upload is not None:
461
- return Path(upload if isinstance(upload, str) else getattr(upload, "name", upload))
462
- if _LOCAL_PDF and _LOCAL_PDF.exists():
463
- return _LOCAL_PDF
464
- return None
465
-
466
-
467
- def index_pdf(upload) -> str:
468
- path = _pdf_path(upload)
469
- if path is None:
470
- return "Upload a workshop-manual PDF, then click Index."
471
- return pipeline.ingest(path)
472
-
473
-
474
- def ask(query: str, top_k: int, upload) -> tuple[str, str]:
475
- if not query.strip():
476
- return "[]", "Enter a query."
477
- if pipeline.retriever is None:
478
- path = _pdf_path(upload)
479
- if path is None:
480
- return "[]", "Upload and index a PDF first."
481
- index_pdf(upload)
482
- out = pipeline.query(query.strip(), top_k=int(top_k))
483
- return (
484
- json.dumps(out["results"], indent=2, ensure_ascii=False),
485
- f"method={out['method']}\n" + "\n".join(out["retrieved"]),
486
- )
487
-
488
-
489
- def on_load():
490
- if _LOCAL_PDF and _LOCAL_PDF.exists():
491
- try:
492
- return index_pdf(str(_LOCAL_PDF))
493
- except Exception as exc:
494
- return f"Ready. Upload a PDF to index. ({exc})"
495
- return "Ready. Upload a workshop-manual PDF and click Index."
496
-
497
-
498
- with gr.Blocks(title="Vehicle Spec RAG") as demo:
499
- gr.Markdown(
500
- "# Vehicle Specification Extraction\n"
501
- "Same pipeline as `vehicle_spec_extraction.ipynb`: "
502
- "PyMuPDF → chunk → Sentence-Transformers / OpenAI embeddings → FAISS (MMR) → "
503
- "GPT / Mistral or rule-based JSON extraction.\n\n"
504
- "Upload a service-manual PDF, index it, then ask for torque, fluid, or dimension specs."
505
- )
506
- pdf = gr.File(label="Workshop manual PDF", file_types=[".pdf"])
507
- status = gr.Textbox(label="Index status", interactive=False)
508
- index_btn = gr.Button("Index PDF")
509
- with gr.Row():
510
- query = gr.Textbox(label="Query", placeholder="Torque for brake caliper bolts", scale=4)
511
- top_k = gr.Slider(3, 12, value=6, step=1, label="Top-K")
512
- ask_btn = gr.Button("Extract specs", variant="primary")
513
- results = gr.Code(label="Structured JSON", language="json")
514
- debug = gr.Textbox(label="Retrieval debug", lines=8)
515
-
516
- demo.load(on_load, outputs=status)
517
- index_btn.click(index_pdf, inputs=pdf, outputs=status)
518
- ask_btn.click(ask, inputs=[query, top_k, pdf], outputs=[results, debug])
519
- query.submit(ask, inputs=[query, top_k, pdf], outputs=[results, debug])
520
- gr.Examples(
521
- examples=[
522
- ["Torque for brake caliper bolts"],
523
- ["Brake fluid capacity"],
524
- ["Shock absorber lower nuts torque"],
525
- ["Front brake disc minimum thickness"],
526
- ["Wheel bearing and wheel hub bolts torque"],
527
- ],
528
- inputs=query,
529
- )
530
-
531
-
532
- if __name__ == "__main__":
533
- demo.launch()