Shiv1143 commited on
Commit
456ef51
Β·
verified Β·
1 Parent(s): f325bfb

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +533 -0
app.py ADDED
@@ -0,0 +1,533 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()