rotsl commited on
Commit
226772e
·
verified ·
1 Parent(s): 59b6406

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +288 -206
app.py CHANGED
@@ -1,20 +1,16 @@
1
  #!/usr/bin/env python3
2
  """
3
- Indian Legal AI Assistant with integrated India Code web lookup.
4
 
5
  CPU-only Hugging Face Spaces app.
6
 
7
- Model:
8
- invincibleambuj/Ambuj-Tripathi-Indian-Legal-Llama-GGUF
9
- llama-3.2-1b-instruct.Q4_K_M.gguf
10
-
11
- Web step:
12
- - Uses India Code only: https://www.indiacode.nic.in/
13
- - Searches logically across India Code navigation, Central Acts browse pages,
14
- State Acts links, Repealed Acts, Spent Acts, and discovered India Code pages.
15
- - Reads HTML pages.
16
- - Reads extractable text from PDFs using pypdf.
17
- - Injects retrieved India Code context into the model prompt.
18
  """
19
 
20
  import os
@@ -24,11 +20,14 @@ import html
24
  import time
25
  import traceback
26
  from functools import lru_cache
27
- from urllib.parse import urljoin, urlparse, quote_plus, urldefrag
28
 
29
  import requests
30
  from bs4 import BeautifulSoup
 
31
  from pypdf import PdfReader
 
 
32
 
33
  import gradio as gr
34
  from huggingface_hub import hf_hub_download
@@ -42,28 +41,26 @@ from llama_cpp import Llama
42
  MODEL_REPO = "invincibleambuj/Ambuj-Tripathi-Indian-Legal-Llama-GGUF"
43
  MODEL_FILE = "llama-3.2-1b-instruct.Q4_K_M.gguf"
44
 
45
- # CPU-only defaults for Hugging Face CPU Spaces.
46
- N_CTX = int(os.getenv("N_CTX", "1024"))
 
47
  N_THREADS = int(os.getenv("N_THREADS", "2"))
48
  N_THREADS_BATCH = int(os.getenv("N_THREADS_BATCH", "2"))
49
  N_BATCH = int(os.getenv("N_BATCH", "512"))
50
-
51
- # CPU only.
52
  N_GPU_LAYERS = 0
53
 
54
  # Generation settings.
55
- MAX_TOKENS = int(os.getenv("MAX_TOKENS", "768"))
56
  TEMPERATURE = float(os.getenv("TEMPERATURE", "0.35"))
57
  TOP_P = float(os.getenv("TOP_P", "0.9"))
58
 
59
- # Model cache.
60
  MODEL_CACHE_DIR = os.getenv("MODEL_CACHE_DIR", "./models")
61
 
62
  llm = None
63
 
64
 
65
  # -------------------------------------------------
66
- # India Code search configuration
67
  # -------------------------------------------------
68
 
69
  INDIACODE_HOME = "https://www.indiacode.nic.in/"
@@ -74,12 +71,21 @@ ALLOWED_DOMAINS = {
74
 
75
  REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "12"))
76
 
77
- # Keep these conservative for CPU Spaces.
78
  MAX_DISCOVERY_RESULTS = int(os.getenv("MAX_DISCOVERY_RESULTS", "12"))
79
- MAX_CRAWL_PAGES = int(os.getenv("MAX_CRAWL_PAGES", "28"))
80
- MAX_CONTEXT_DOCS = int(os.getenv("MAX_CONTEXT_DOCS", "6"))
81
- MAX_PDF_PAGES = int(os.getenv("MAX_PDF_PAGES", "8"))
82
- MAX_TEXT_PER_DOC = int(os.getenv("MAX_TEXT_PER_DOC", "3500"))
 
 
 
 
 
 
 
 
 
 
83
 
84
  HEADERS = {
85
  "User-Agent": (
@@ -90,41 +96,28 @@ HEADERS = {
90
 
91
 
92
  # -------------------------------------------------
93
- # General helpers
94
  # -------------------------------------------------
95
 
96
  def clean_text(text):
97
- """Normalize whitespace and decode HTML entities."""
98
  if not text:
99
  return ""
100
-
101
  text = html.unescape(text)
102
  text = re.sub(r"\s+", " ", text)
103
  return text.strip()
104
 
105
 
106
  def normalize_url(url, base=INDIACODE_HOME):
107
- """Resolve, defragment, and normalize a URL."""
108
  if not url:
109
  return ""
110
-
111
  url = urljoin(base, url)
112
  url, _fragment = urldefrag(url)
113
  return url.strip()
114
 
115
 
116
- def get_domain(url):
117
- try:
118
- return urlparse(url).netloc.lower().replace("www.", "")
119
- except Exception:
120
- return ""
121
-
122
-
123
  def is_indiacode_url(url):
124
- """Allow only India Code URLs."""
125
  try:
126
- parsed = urlparse(url)
127
- domain = parsed.netloc.lower()
128
  return domain in ALLOWED_DOMAINS
129
  except Exception:
130
  return False
@@ -135,19 +128,17 @@ def looks_like_pdf_url(url):
135
 
136
 
137
  def query_terms(query):
138
- """Extract useful query terms for scoring."""
139
  stopwords = {
140
  "the", "a", "an", "and", "or", "of", "in", "on", "to", "for", "with",
141
- "under", "section", "sections", "act", "law", "laws", "what", "is",
142
- "are", "explain", "about", "current", "latest", "india", "indian",
 
143
  }
144
-
145
  terms = re.findall(r"[a-zA-Z0-9]+", query.lower())
146
  return [t for t in terms if len(t) >= 3 and t not in stopwords]
147
 
148
 
149
  def score_text_against_query(text, query):
150
- """Simple lexical scoring for relevance."""
151
  text_l = (text or "").lower()
152
  terms = query_terms(query)
153
 
@@ -155,13 +146,11 @@ def score_text_against_query(text, query):
155
  return 0
156
 
157
  score = 0
158
-
159
  for term in terms:
160
  count = text_l.count(term)
161
  if count:
162
  score += min(count, 5)
163
 
164
- # Boost exact phrase match.
165
  q = clean_text(query).lower()
166
  if q and q in text_l:
167
  score += 10
@@ -169,14 +158,13 @@ def score_text_against_query(text, query):
169
  return score
170
 
171
 
172
- def make_snippet(text, query, max_chars=900):
173
- """Create a short context snippet near query terms."""
174
  text = clean_text(text)
175
  if not text:
176
  return ""
177
 
178
- terms = query_terms(query)
179
  lower = text.lower()
 
180
 
181
  first_hit = None
182
  for term in terms:
@@ -188,19 +176,14 @@ def make_snippet(text, query, max_chars=900):
188
  if first_hit is None:
189
  return text[:max_chars]
190
 
191
- start = max(first_hit - 250, 0)
192
  end = min(start + max_chars, len(text))
193
  return text[start:end]
194
 
195
 
196
- # -------------------------------------------------
197
- # HTTP helpers
198
- # -------------------------------------------------
199
-
200
- def safe_get(url, timeout=REQUEST_TIMEOUT):
201
- """GET request with basic error handling."""
202
  try:
203
- response = requests.get(url, headers=HEADERS, timeout=timeout)
204
  response.raise_for_status()
205
  return response
206
  except Exception as error:
@@ -208,18 +191,11 @@ def safe_get(url, timeout=REQUEST_TIMEOUT):
208
  return None
209
 
210
 
211
- def content_type(response):
212
- if response is None:
213
- return ""
214
- return response.headers.get("content-type", "").lower()
215
-
216
-
217
  # -------------------------------------------------
218
- # HTML and PDF extraction
219
  # -------------------------------------------------
220
 
221
  def extract_links_from_html(html_text, base_url):
222
- """Extract India Code links from HTML."""
223
  links = []
224
 
225
  try:
@@ -231,12 +207,8 @@ def extract_links_from_html(html_text, base_url):
231
  url = normalize_url(href, base_url)
232
 
233
  if is_indiacode_url(url):
234
- links.append(
235
- {
236
- "url": url,
237
- "anchor": text,
238
- }
239
- )
240
  except Exception as error:
241
  print(f"Link extraction failed for {base_url}: {error}")
242
 
@@ -244,20 +216,18 @@ def extract_links_from_html(html_text, base_url):
244
 
245
 
246
  def extract_text_from_html(html_text):
247
- """Extract readable text from HTML."""
248
  try:
249
  soup = BeautifulSoup(html_text, "html.parser")
250
 
251
  for tag in soup(["script", "style", "nav", "footer", "header", "aside", "form"]):
252
  tag.decompose()
253
 
254
- title = clean_text(soup.title.get_text(" ")) if soup.title else ""
255
-
256
  parts = []
257
- if title:
258
- parts.append(title)
259
 
260
- for tag in soup.find_all(["h1", "h2", "h3", "h4", "p", "li", "td", "th"], limit=300):
 
 
 
261
  text = clean_text(tag.get_text(" "))
262
  if len(text) >= 20:
263
  parts.append(text)
@@ -269,46 +239,118 @@ def extract_text_from_html(html_text):
269
  return ""
270
 
271
 
272
- def extract_pdf_text(pdf_bytes, max_pages=MAX_PDF_PAGES):
273
- """
274
- Extract text from a PDF.
275
 
276
- This reads text-based PDFs. It will not OCR scanned image-only PDFs.
277
- """
278
  try:
279
  reader = PdfReader(io.BytesIO(pdf_bytes))
280
  parts = []
281
 
282
- total_pages = len(reader.pages)
283
- pages_to_read = min(total_pages, max_pages)
284
 
285
  for page_index in range(pages_to_read):
286
  try:
287
  text = reader.pages[page_index].extract_text() or ""
288
  text = clean_text(text)
289
  if text:
290
- parts.append(f"[PDF page {page_index + 1}] {text}")
291
  except Exception as page_error:
292
- print(f"PDF page extraction failed: {page_error}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
 
294
- extracted = clean_text(" ".join(parts))
 
295
 
296
- if not extracted:
297
- return "[PDF detected, but no extractable text was found. The PDF may be scanned/image-based.]"
 
 
 
 
 
 
298
 
299
- return extracted
 
 
300
 
301
  except Exception as error:
302
- print(f"PDF extraction failed: {error}")
303
  return ""
304
 
305
 
306
- @lru_cache(maxsize=256)
307
- def fetch_document_text(url):
308
  """
309
- Fetch and extract text from an India Code HTML or PDF document.
310
- Cached to speed up follow-up questions.
311
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  if not is_indiacode_url(url):
313
  return {
314
  "url": url,
@@ -319,6 +361,7 @@ def fetch_document_text(url):
319
  }
320
 
321
  response = safe_get(url)
 
322
  if response is None:
323
  return {
324
  "url": url,
@@ -328,16 +371,16 @@ def fetch_document_text(url):
328
  "type": "failed",
329
  }
330
 
331
- ctype = content_type(response)
332
 
333
  if "application/pdf" in ctype or looks_like_pdf_url(url):
334
- text = extract_pdf_text(response.content)
335
  return {
336
  "url": url,
337
  "title": url.split("/")[-1] or "India Code PDF",
338
  "text": text,
339
  "links": [],
340
- "type": "pdf",
341
  }
342
 
343
  html_text = response.text
@@ -362,19 +405,10 @@ def fetch_document_text(url):
362
 
363
 
364
  # -------------------------------------------------
365
- # India Code discovery
366
  # -------------------------------------------------
367
 
368
  def india_code_seed_urls():
369
- """
370
- Core India Code entry points.
371
-
372
- These are logical browse points exposed by India Code:
373
- - Home
374
- - Central Acts browse pages
375
- - Repealed Acts
376
- - Spent Acts
377
- """
378
  return [
379
  INDIACODE_HOME,
380
 
@@ -386,19 +420,17 @@ def india_code_seed_urls():
386
  "https://www.indiacode.nic.in/handle/123456789/1362/browse?type=ministry",
387
  "https://www.indiacode.nic.in/handle/123456789/1362/browse?type=department",
388
 
389
- # Repealed and spent Acts.
390
  "https://www.indiacode.nic.in/repealed-act/repealed-act.jsp",
391
  "https://www.indiacode.nic.in/spent-act/spent-act.jsp",
392
  ]
393
 
394
 
395
  def discover_from_indiacode_home():
396
- """
397
- Discover State Act and other India Code navigation links from homepage.
398
- """
399
  discovered = []
400
 
401
  home_doc = fetch_document_text(INDIACODE_HOME)
 
402
  for link in home_doc.get("links", []):
403
  url = link.get("url", "")
404
  if is_indiacode_url(url):
@@ -409,18 +441,16 @@ def discover_from_indiacode_home():
409
 
410
  def duckduckgo_site_discovery(query):
411
  """
412
- URL discovery using a site-restricted query.
413
 
414
- Important:
415
- This is not used as a content source. It only discovers India Code URLs.
416
- The app fetches and reads the resulting India Code pages directly.
417
  """
418
  discovered = []
419
-
420
  search_query = f"site:indiacode.nic.in {query}"
421
  search_url = f"https://duckduckgo.com/html/?q={quote_plus(search_query)}"
422
 
423
  response = safe_get(search_url)
 
424
  if response is None:
425
  return discovered
426
 
@@ -430,10 +460,8 @@ def duckduckgo_site_discovery(query):
430
  for a in soup.select(".result__title a"):
431
  url = a.get("href", "").strip()
432
 
433
- # DuckDuckGo may wrap links. Keep only direct India Code URLs.
434
  if "uddg=" in url:
435
  try:
436
- from urllib.parse import parse_qs
437
  parsed = urlparse(url)
438
  qs = parse_qs(parsed.query)
439
  if "uddg" in qs:
@@ -450,21 +478,12 @@ def duckduckgo_site_discovery(query):
450
  break
451
 
452
  except Exception as error:
453
- print(f"Site discovery parsing failed: {error}")
454
 
455
  return discovered
456
 
457
 
458
  def relevant_link_filter(link, query):
459
- """
460
- Decide whether to crawl a link.
461
-
462
- We keep:
463
- - links with query terms in anchor or URL
464
- - PDF links
465
- - handle/item/bitstream links, because India Code DSpace pages often use them
466
- - browse pages
467
- """
468
  url = link.get("url", "")
469
  anchor = link.get("anchor", "")
470
 
@@ -483,12 +502,15 @@ def relevant_link_filter(link, query):
483
  "/browse",
484
  "repealed-act",
485
  "spent-act",
 
 
486
  ]
487
 
488
  if any(pattern in url_l for pattern in important_patterns):
489
  return True
490
 
491
  terms = query_terms(query)
 
492
  if any(term in url_l or term in anchor_l for term in terms):
493
  return True
494
 
@@ -496,22 +518,11 @@ def relevant_link_filter(link, query):
496
 
497
 
498
  def crawl_indiacode_for_query(query):
499
- """
500
- Crawl India Code pages to find relevant documents.
501
-
502
- Strategy:
503
- 1. Start with logical India Code seed URLs.
504
- 2. Add homepage-discovered India Code links, including State Acts links.
505
- 3. Add site-restricted discovered India Code URLs.
506
- 4. Fetch pages, score text, follow relevant India Code links.
507
- 5. Include readable PDFs.
508
- """
509
  seeds = []
510
  seeds.extend(india_code_seed_urls())
511
  seeds.extend(discover_from_indiacode_home())
512
  seeds.extend(duckduckgo_site_discovery(query))
513
 
514
- # Preserve order while removing duplicates.
515
  queue = []
516
  seen = set()
517
 
@@ -537,10 +548,10 @@ def crawl_indiacode_for_query(query):
537
  title = doc.get("title", "India Code document")
538
  doc_type = doc.get("type", "html")
539
 
540
- combined_for_score = f"{title} {url} {text}"
541
- score = score_text_against_query(combined_for_score, query)
542
 
543
- if score > 0 or doc_type == "pdf":
544
  scored_docs.append(
545
  {
546
  "url": url,
@@ -551,7 +562,6 @@ def crawl_indiacode_for_query(query):
551
  }
552
  )
553
 
554
- # Follow relevant India Code links from HTML pages.
555
  for link in doc.get("links", []):
556
  link_url = normalize_url(link.get("url", ""), url)
557
 
@@ -564,34 +574,36 @@ def crawl_indiacode_for_query(query):
564
 
565
  scored_docs.sort(key=lambda item: item["score"], reverse=True)
566
 
567
- # Keep top docs with real text.
568
  useful_docs = []
 
569
  for doc in scored_docs:
570
  if doc.get("text"):
571
  useful_docs.append(doc)
 
572
  if len(useful_docs) >= MAX_CONTEXT_DOCS:
573
  break
574
 
575
  return useful_docs
576
 
577
 
578
- def build_indiacode_context(query):
579
- """
580
- Build compact India Code context for the model.
581
- """
582
  docs = crawl_indiacode_for_query(query)
 
583
 
584
  if not docs:
585
  return (
586
- "No directly relevant readable content was retrieved from India Code for this query. "
587
- "The answer should clearly state that India Code verification was not available.",
588
  [],
589
  )
590
 
591
- context_blocks = []
592
 
593
  for index, doc in enumerate(docs, start=1):
594
- snippet = make_snippet(doc.get("text", ""), query, max_chars=MAX_TEXT_PER_DOC)
 
 
 
 
595
 
596
  block = (
597
  f"[India Code Source {index}]\n"
@@ -601,13 +613,12 @@ def build_indiacode_context(query):
601
  f"Relevant excerpt:\n{snippet}\n"
602
  )
603
 
604
- context_blocks.append(block)
605
 
606
- return "\n\n".join(context_blocks), docs
607
 
608
 
609
  def format_sources(docs):
610
- """Append India Code source links to the answer."""
611
  if not docs:
612
  return "\n\nIndia Code sources checked: No readable India Code source was retrieved."
613
 
@@ -627,11 +638,6 @@ def format_sources(docs):
627
  # -------------------------------------------------
628
 
629
  def load_model():
630
- """
631
- Download and load the GGUF model once.
632
-
633
- The model is loaded lazily on the first real user message.
634
- """
635
  global llm
636
 
637
  if llm is not None:
@@ -664,17 +670,10 @@ def load_model():
664
 
665
 
666
  # -------------------------------------------------
667
- # Chat history and prompt construction
668
  # -------------------------------------------------
669
 
670
- def extract_recent_history(history, max_turns=4):
671
- """
672
- Keep recent conversation history for follow-up questions.
673
-
674
- Supports:
675
- - list of dicts: {"role": "...", "content": "..."}
676
- - list of tuples/lists: (user, assistant)
677
- """
678
  if not history:
679
  return ""
680
 
@@ -698,14 +697,10 @@ def extract_recent_history(history, max_turns=4):
698
  return conversation
699
 
700
 
701
- def build_prompt(message, history, indiacode_context):
702
- """
703
- Build a detailed prompt with integrated India Code context.
704
- """
705
- conversation = extract_recent_history(history, max_turns=4)
706
  current_date = time.strftime("%Y-%m-%d")
707
 
708
- prompt = f"""You are an Indian legal AI assistant.
709
 
710
  Current date: {current_date}
711
 
@@ -716,13 +711,12 @@ You must answer using:
716
 
717
  Critical rules:
718
  - The India Code lookup has already been performed automatically.
719
- - Do not describe the lookup as a separate action the user must do.
720
  - Use India Code context as the primary legal source.
721
- - If India Code context is missing, weak, or unreadable, clearly say what could not be verified from India Code.
 
722
  - Do not invent legal provisions, case names, dates, citations, or section text.
723
- - If the user asks for the latest/current position, rely only on the India Code context provided.
724
- - If the context contains a PDF extraction warning, mention that the PDF may be scanned or unreadable.
725
- - Provide detailed, practical explanations.
726
  - Support follow-up questions using the recent conversation.
727
  - Do not claim to be a lawyer.
728
  - Do not present the answer as formal legal advice.
@@ -739,7 +733,88 @@ User question:
739
 
740
  Answer:"""
741
 
742
- return prompt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
743
 
744
 
745
  # -------------------------------------------------
@@ -747,33 +822,37 @@ Answer:"""
747
  # -------------------------------------------------
748
 
749
  def chat(message, history):
750
- """
751
- Main Gradio chat function.
752
-
753
- Integrated flow:
754
- user message -> India Code lookup -> PDF/HTML extraction -> prompt -> local LLM answer.
755
- """
756
  if not message or not message.strip():
757
  return "Please enter a question."
758
 
759
  try:
760
  user_query = message.strip()
761
 
762
- # Integrated India Code web step.
763
- indiacode_context, source_docs = build_indiacode_context(user_query)
764
 
765
- # Load local model lazily.
766
  model = load_model()
767
 
768
- prompt = build_prompt(
 
 
769
  message=user_query,
770
  history=history,
771
- indiacode_context=indiacode_context,
 
 
 
 
 
 
 
 
772
  )
773
 
774
  response = model(
775
  prompt,
776
- max_tokens=MAX_TOKENS,
777
  temperature=TEMPERATURE,
778
  top_p=TOP_P,
779
  echo=False,
@@ -785,16 +864,17 @@ def chat(message, history):
785
  if not answer:
786
  answer = "I could not generate a response. Please try rephrasing your question."
787
 
788
- answer += format_sources(source_docs)
789
 
790
  return answer
791
 
792
  except Exception as error:
793
- print("Error during India Code lookup or generation:")
794
  traceback.print_exc()
795
 
796
  return (
797
- "The app encountered an error while searching India Code or running the model.\n\n"
 
798
  f"Error details: {str(error)}"
799
  )
800
 
@@ -806,21 +886,21 @@ def chat(message, history):
806
  description = """
807
  # 🏛️ Indian Legal AI Assistant
808
 
809
- Ask questions about Indian laws, Acts, legal procedures, sections, rules, and follow-up questions.
810
 
811
  This app uses:
812
  - a local GGUF model through `llama-cpp-python`
813
- - an integrated India Code lookup step
814
- - readable HTML extraction
815
- - extractable-text PDF reading through `pypdf`
 
816
  - recent chat history for follow-up questions
817
 
818
- The India Code lookup runs inside each answer.
819
- You do not need to run a separate search.
820
 
821
  ---
822
 
823
- **Primary source used by the web step:** https://www.indiacode.nic.in/
824
 
825
  **Disclaimer:** This assistant provides general legal information only.
826
  It is not a substitute for advice from a qualified legal professional.
@@ -855,11 +935,13 @@ if __name__ == "__main__":
855
  print(f"Using model: {MODEL_REPO}/{MODEL_FILE}")
856
  print("CPU-only mode enabled.")
857
  print("Integrated India Code lookup enabled.")
 
858
  print(
859
  f"N_CTX={N_CTX}, "
860
  f"N_THREADS={N_THREADS}, "
861
  f"N_THREADS_BATCH={N_THREADS_BATCH}, "
862
- f"N_BATCH={N_BATCH}"
 
863
  )
864
 
865
  demo.launch(
 
1
  #!/usr/bin/env python3
2
  """
3
+ Indian Legal AI Assistant with integrated India Code lookup.
4
 
5
  CPU-only Hugging Face Spaces app.
6
 
7
+ Features:
8
+ - Uses only India Code as the legal web source.
9
+ - Reads India Code HTML pages.
10
+ - Reads text-based PDFs using pypdf.
11
+ - Falls back to lightweight OCR for scanned/image PDFs using PyMuPDF + Tesseract.
12
+ - Automatically trims prompt/context to avoid context-window overflow.
13
+ - Supports follow-up questions using recent chat history.
 
 
 
 
14
  """
15
 
16
  import os
 
20
  import time
21
  import traceback
22
  from functools import lru_cache
23
+ from urllib.parse import urljoin, urlparse, quote_plus, urldefrag, parse_qs
24
 
25
  import requests
26
  from bs4 import BeautifulSoup
27
+ from PIL import Image
28
  from pypdf import PdfReader
29
+ import fitz # PyMuPDF
30
+ import pytesseract
31
 
32
  import gradio as gr
33
  from huggingface_hub import hf_hub_download
 
41
  MODEL_REPO = "invincibleambuj/Ambuj-Tripathi-Indian-Legal-Llama-GGUF"
42
  MODEL_FILE = "llama-3.2-1b-instruct.Q4_K_M.gguf"
43
 
44
+ # CPU-only settings.
45
+ # 2048 is safer than 1024 because India Code context + user question can exceed 1024.
46
+ N_CTX = int(os.getenv("N_CTX", "2048"))
47
  N_THREADS = int(os.getenv("N_THREADS", "2"))
48
  N_THREADS_BATCH = int(os.getenv("N_THREADS_BATCH", "2"))
49
  N_BATCH = int(os.getenv("N_BATCH", "512"))
 
 
50
  N_GPU_LAYERS = 0
51
 
52
  # Generation settings.
53
+ MAX_TOKENS = int(os.getenv("MAX_TOKENS", "512"))
54
  TEMPERATURE = float(os.getenv("TEMPERATURE", "0.35"))
55
  TOP_P = float(os.getenv("TOP_P", "0.9"))
56
 
 
57
  MODEL_CACHE_DIR = os.getenv("MODEL_CACHE_DIR", "./models")
58
 
59
  llm = None
60
 
61
 
62
  # -------------------------------------------------
63
+ # India Code / extraction configuration
64
  # -------------------------------------------------
65
 
66
  INDIACODE_HOME = "https://www.indiacode.nic.in/"
 
71
 
72
  REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "12"))
73
 
 
74
  MAX_DISCOVERY_RESULTS = int(os.getenv("MAX_DISCOVERY_RESULTS", "12"))
75
+ MAX_CRAWL_PAGES = int(os.getenv("MAX_CRAWL_PAGES", "24"))
76
+ MAX_CONTEXT_DOCS = int(os.getenv("MAX_CONTEXT_DOCS", "5"))
77
+
78
+ # PDF text extraction.
79
+ MAX_PDF_TEXT_PAGES = int(os.getenv("MAX_PDF_TEXT_PAGES", "8"))
80
+
81
+ # OCR fallback. Keep this small for CPU Spaces.
82
+ MAX_OCR_PAGES = int(os.getenv("MAX_OCR_PAGES", "3"))
83
+ OCR_DPI_SCALE = float(os.getenv("OCR_DPI_SCALE", "1.5"))
84
+ MIN_PDF_TEXT_CHARS_BEFORE_OCR = int(os.getenv("MIN_PDF_TEXT_CHARS_BEFORE_OCR", "250"))
85
+
86
+ # Context trimming.
87
+ MAX_TEXT_PER_DOC = int(os.getenv("MAX_TEXT_PER_DOC", "2400"))
88
+ PROMPT_SAFETY_MARGIN = int(os.getenv("PROMPT_SAFETY_MARGIN", "96"))
89
 
90
  HEADERS = {
91
  "User-Agent": (
 
96
 
97
 
98
  # -------------------------------------------------
99
+ # Basic helpers
100
  # -------------------------------------------------
101
 
102
  def clean_text(text):
 
103
  if not text:
104
  return ""
 
105
  text = html.unescape(text)
106
  text = re.sub(r"\s+", " ", text)
107
  return text.strip()
108
 
109
 
110
  def normalize_url(url, base=INDIACODE_HOME):
 
111
  if not url:
112
  return ""
 
113
  url = urljoin(base, url)
114
  url, _fragment = urldefrag(url)
115
  return url.strip()
116
 
117
 
 
 
 
 
 
 
 
118
  def is_indiacode_url(url):
 
119
  try:
120
+ domain = urlparse(url).netloc.lower()
 
121
  return domain in ALLOWED_DOMAINS
122
  except Exception:
123
  return False
 
128
 
129
 
130
  def query_terms(query):
 
131
  stopwords = {
132
  "the", "a", "an", "and", "or", "of", "in", "on", "to", "for", "with",
133
+ "under", "section", "sections", "act", "acts", "law", "laws", "what",
134
+ "is", "are", "explain", "about", "current", "latest", "india", "indian",
135
+ "tell", "me", "please", "does", "do",
136
  }
 
137
  terms = re.findall(r"[a-zA-Z0-9]+", query.lower())
138
  return [t for t in terms if len(t) >= 3 and t not in stopwords]
139
 
140
 
141
  def score_text_against_query(text, query):
 
142
  text_l = (text or "").lower()
143
  terms = query_terms(query)
144
 
 
146
  return 0
147
 
148
  score = 0
 
149
  for term in terms:
150
  count = text_l.count(term)
151
  if count:
152
  score += min(count, 5)
153
 
 
154
  q = clean_text(query).lower()
155
  if q and q in text_l:
156
  score += 10
 
158
  return score
159
 
160
 
161
+ def make_snippet(text, query, max_chars=MAX_TEXT_PER_DOC):
 
162
  text = clean_text(text)
163
  if not text:
164
  return ""
165
 
 
166
  lower = text.lower()
167
+ terms = query_terms(query)
168
 
169
  first_hit = None
170
  for term in terms:
 
176
  if first_hit is None:
177
  return text[:max_chars]
178
 
179
+ start = max(first_hit - 350, 0)
180
  end = min(start + max_chars, len(text))
181
  return text[start:end]
182
 
183
 
184
+ def safe_get(url):
 
 
 
 
 
185
  try:
186
+ response = requests.get(url, headers=HEADERS, timeout=REQUEST_TIMEOUT)
187
  response.raise_for_status()
188
  return response
189
  except Exception as error:
 
191
  return None
192
 
193
 
 
 
 
 
 
 
194
  # -------------------------------------------------
195
+ # HTML extraction
196
  # -------------------------------------------------
197
 
198
  def extract_links_from_html(html_text, base_url):
 
199
  links = []
200
 
201
  try:
 
207
  url = normalize_url(href, base_url)
208
 
209
  if is_indiacode_url(url):
210
+ links.append({"url": url, "anchor": text})
211
+
 
 
 
 
212
  except Exception as error:
213
  print(f"Link extraction failed for {base_url}: {error}")
214
 
 
216
 
217
 
218
  def extract_text_from_html(html_text):
 
219
  try:
220
  soup = BeautifulSoup(html_text, "html.parser")
221
 
222
  for tag in soup(["script", "style", "nav", "footer", "header", "aside", "form"]):
223
  tag.decompose()
224
 
 
 
225
  parts = []
 
 
226
 
227
+ if soup.title:
228
+ parts.append(clean_text(soup.title.get_text(" ")))
229
+
230
+ for tag in soup.find_all(["h1", "h2", "h3", "h4", "p", "li", "td", "th"], limit=350):
231
  text = clean_text(tag.get_text(" "))
232
  if len(text) >= 20:
233
  parts.append(text)
 
239
  return ""
240
 
241
 
242
+ # -------------------------------------------------
243
+ # PDF extraction with OCR fallback
244
+ # -------------------------------------------------
245
 
246
+ def extract_pdf_text_with_pypdf(pdf_bytes, max_pages=MAX_PDF_TEXT_PAGES):
 
247
  try:
248
  reader = PdfReader(io.BytesIO(pdf_bytes))
249
  parts = []
250
 
251
+ pages_to_read = min(len(reader.pages), max_pages)
 
252
 
253
  for page_index in range(pages_to_read):
254
  try:
255
  text = reader.pages[page_index].extract_text() or ""
256
  text = clean_text(text)
257
  if text:
258
+ parts.append(f"[PDF text page {page_index + 1}] {text}")
259
  except Exception as page_error:
260
+ print(f"pypdf page extraction failed: {page_error}")
261
+
262
+ return clean_text(" ".join(parts))
263
+
264
+ except Exception as error:
265
+ print(f"pypdf extraction failed: {error}")
266
+ return ""
267
+
268
+
269
+ def ocr_pdf_with_tesseract(pdf_bytes, max_pages=MAX_OCR_PAGES):
270
+ """
271
+ Lightweight OCR fallback for scanned PDFs.
272
+
273
+ Uses:
274
+ - PyMuPDF to render PDF pages to images.
275
+ - Tesseract via pytesseract to OCR those images.
276
+
277
+ Kept intentionally small because Hugging Face CPU Spaces have limited CPU.
278
+ """
279
+ try:
280
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
281
+ parts = []
282
+
283
+ pages_to_read = min(len(doc), max_pages)
284
+
285
+ for page_index in range(pages_to_read):
286
+ try:
287
+ page = doc.load_page(page_index)
288
+
289
+ matrix = fitz.Matrix(OCR_DPI_SCALE, OCR_DPI_SCALE)
290
+ pix = page.get_pixmap(matrix=matrix, alpha=False)
291
+
292
+ img = Image.frombytes(
293
+ "RGB",
294
+ [pix.width, pix.height],
295
+ pix.samples,
296
+ )
297
 
298
+ # Convert to grayscale to reduce OCR work.
299
+ img = img.convert("L")
300
 
301
+ text = pytesseract.image_to_string(img, lang="eng")
302
+ text = clean_text(text)
303
+
304
+ if text:
305
+ parts.append(f"[OCR PDF page {page_index + 1}] {text}")
306
+
307
+ except Exception as page_error:
308
+ print(f"OCR failed on page {page_index + 1}: {page_error}")
309
 
310
+ doc.close()
311
+
312
+ return clean_text(" ".join(parts))
313
 
314
  except Exception as error:
315
+ print(f"OCR PDF extraction failed: {error}")
316
  return ""
317
 
318
 
319
+ def extract_pdf_text(pdf_bytes):
 
320
  """
321
+ First try normal PDF text extraction.
322
+ If too little text is found, fall back to OCR.
323
  """
324
+ text = extract_pdf_text_with_pypdf(pdf_bytes)
325
+
326
+ if len(text) >= MIN_PDF_TEXT_CHARS_BEFORE_OCR:
327
+ return text, "pdf-text"
328
+
329
+ print("PDF appears scanned or has too little extractable text. Running OCR fallback...")
330
+
331
+ ocr_text = ocr_pdf_with_tesseract(pdf_bytes)
332
+
333
+ if ocr_text:
334
+ if text:
335
+ return text + "\n\n" + ocr_text, "pdf-text-plus-ocr"
336
+ return ocr_text, "pdf-ocr"
337
+
338
+ if text:
339
+ return text, "pdf-text-low"
340
+
341
+ return (
342
+ "[PDF detected, but no readable text could be extracted. "
343
+ "The PDF may be scanned, low quality, encrypted, or OCR failed.]",
344
+ "pdf-unreadable",
345
+ )
346
+
347
+
348
+ # -------------------------------------------------
349
+ # Document fetching
350
+ # -------------------------------------------------
351
+
352
+ @lru_cache(maxsize=256)
353
+ def fetch_document_text(url):
354
  if not is_indiacode_url(url):
355
  return {
356
  "url": url,
 
361
  }
362
 
363
  response = safe_get(url)
364
+
365
  if response is None:
366
  return {
367
  "url": url,
 
371
  "type": "failed",
372
  }
373
 
374
+ ctype = response.headers.get("content-type", "").lower()
375
 
376
  if "application/pdf" in ctype or looks_like_pdf_url(url):
377
+ text, pdf_type = extract_pdf_text(response.content)
378
  return {
379
  "url": url,
380
  "title": url.split("/")[-1] or "India Code PDF",
381
  "text": text,
382
  "links": [],
383
+ "type": pdf_type,
384
  }
385
 
386
  html_text = response.text
 
405
 
406
 
407
  # -------------------------------------------------
408
+ # India Code discovery / crawling
409
  # -------------------------------------------------
410
 
411
  def india_code_seed_urls():
 
 
 
 
 
 
 
 
 
412
  return [
413
  INDIACODE_HOME,
414
 
 
420
  "https://www.indiacode.nic.in/handle/123456789/1362/browse?type=ministry",
421
  "https://www.indiacode.nic.in/handle/123456789/1362/browse?type=department",
422
 
423
+ # Repealed / spent Acts.
424
  "https://www.indiacode.nic.in/repealed-act/repealed-act.jsp",
425
  "https://www.indiacode.nic.in/spent-act/spent-act.jsp",
426
  ]
427
 
428
 
429
  def discover_from_indiacode_home():
 
 
 
430
  discovered = []
431
 
432
  home_doc = fetch_document_text(INDIACODE_HOME)
433
+
434
  for link in home_doc.get("links", []):
435
  url = link.get("url", "")
436
  if is_indiacode_url(url):
 
441
 
442
  def duckduckgo_site_discovery(query):
443
  """
444
+ Site-restricted discovery only.
445
 
446
+ Content source remains India Code only. This only helps discover India Code URLs.
 
 
447
  """
448
  discovered = []
 
449
  search_query = f"site:indiacode.nic.in {query}"
450
  search_url = f"https://duckduckgo.com/html/?q={quote_plus(search_query)}"
451
 
452
  response = safe_get(search_url)
453
+
454
  if response is None:
455
  return discovered
456
 
 
460
  for a in soup.select(".result__title a"):
461
  url = a.get("href", "").strip()
462
 
 
463
  if "uddg=" in url:
464
  try:
 
465
  parsed = urlparse(url)
466
  qs = parse_qs(parsed.query)
467
  if "uddg" in qs:
 
478
  break
479
 
480
  except Exception as error:
481
+ print(f"Site discovery failed: {error}")
482
 
483
  return discovered
484
 
485
 
486
  def relevant_link_filter(link, query):
 
 
 
 
 
 
 
 
 
487
  url = link.get("url", "")
488
  anchor = link.get("anchor", "")
489
 
 
502
  "/browse",
503
  "repealed-act",
504
  "spent-act",
505
+ "download",
506
+ "pdf",
507
  ]
508
 
509
  if any(pattern in url_l for pattern in important_patterns):
510
  return True
511
 
512
  terms = query_terms(query)
513
+
514
  if any(term in url_l or term in anchor_l for term in terms):
515
  return True
516
 
 
518
 
519
 
520
  def crawl_indiacode_for_query(query):
 
 
 
 
 
 
 
 
 
 
521
  seeds = []
522
  seeds.extend(india_code_seed_urls())
523
  seeds.extend(discover_from_indiacode_home())
524
  seeds.extend(duckduckgo_site_discovery(query))
525
 
 
526
  queue = []
527
  seen = set()
528
 
 
548
  title = doc.get("title", "India Code document")
549
  doc_type = doc.get("type", "html")
550
 
551
+ combined = f"{title} {url} {text}"
552
+ score = score_text_against_query(combined, query)
553
 
554
+ if score > 0 or doc_type.startswith("pdf"):
555
  scored_docs.append(
556
  {
557
  "url": url,
 
562
  }
563
  )
564
 
 
565
  for link in doc.get("links", []):
566
  link_url = normalize_url(link.get("url", ""), url)
567
 
 
574
 
575
  scored_docs.sort(key=lambda item: item["score"], reverse=True)
576
 
 
577
  useful_docs = []
578
+
579
  for doc in scored_docs:
580
  if doc.get("text"):
581
  useful_docs.append(doc)
582
+
583
  if len(useful_docs) >= MAX_CONTEXT_DOCS:
584
  break
585
 
586
  return useful_docs
587
 
588
 
589
+ def build_indiacode_context(query, max_text_per_doc=MAX_TEXT_PER_DOC, max_docs=MAX_CONTEXT_DOCS):
 
 
 
590
  docs = crawl_indiacode_for_query(query)
591
+ docs = docs[:max_docs]
592
 
593
  if not docs:
594
  return (
595
+ "No directly relevant readable content was retrieved from India Code for this query.",
 
596
  [],
597
  )
598
 
599
+ blocks = []
600
 
601
  for index, doc in enumerate(docs, start=1):
602
+ snippet = make_snippet(
603
+ doc.get("text", ""),
604
+ query,
605
+ max_chars=max_text_per_doc,
606
+ )
607
 
608
  block = (
609
  f"[India Code Source {index}]\n"
 
613
  f"Relevant excerpt:\n{snippet}\n"
614
  )
615
 
616
+ blocks.append(block)
617
 
618
+ return "\n\n".join(blocks), docs
619
 
620
 
621
  def format_sources(docs):
 
622
  if not docs:
623
  return "\n\nIndia Code sources checked: No readable India Code source was retrieved."
624
 
 
638
  # -------------------------------------------------
639
 
640
  def load_model():
 
 
 
 
 
641
  global llm
642
 
643
  if llm is not None:
 
670
 
671
 
672
  # -------------------------------------------------
673
+ # Prompt management / context-window safety
674
  # -------------------------------------------------
675
 
676
+ def extract_recent_history(history, max_turns=3):
 
 
 
 
 
 
 
677
  if not history:
678
  return ""
679
 
 
697
  return conversation
698
 
699
 
700
+ def base_prompt_template(message, conversation, indiacode_context):
 
 
 
 
701
  current_date = time.strftime("%Y-%m-%d")
702
 
703
+ return f"""You are an Indian legal AI assistant.
704
 
705
  Current date: {current_date}
706
 
 
711
 
712
  Critical rules:
713
  - The India Code lookup has already been performed automatically.
 
714
  - Use India Code context as the primary legal source.
715
+ - If India Code context is missing, weak, OCR-based, or unreadable, clearly say what could not be verified.
716
+ - If source text came from OCR, mention that OCR can contain recognition errors.
717
  - Do not invent legal provisions, case names, dates, citations, or section text.
718
+ - If the user asks for latest/current law, rely only on the India Code context provided.
719
+ - Provide a detailed, practical explanation.
 
720
  - Support follow-up questions using the recent conversation.
721
  - Do not claim to be a lawyer.
722
  - Do not present the answer as formal legal advice.
 
733
 
734
  Answer:"""
735
 
736
+
737
+ def count_tokens(model, prompt):
738
+ try:
739
+ return len(model.tokenize(prompt.encode("utf-8"), add_bos=True))
740
+ except Exception:
741
+ # Fallback estimate: roughly 4 chars per token.
742
+ return max(1, len(prompt) // 4)
743
+
744
+
745
+ def build_safe_prompt(model, message, history, source_docs):
746
+ """
747
+ Build a prompt that fits within the model context window.
748
+
749
+ This prevents:
750
+ Requested tokens (...) exceed context window (...)
751
+ """
752
+ max_prompt_tokens = max(128, N_CTX - MAX_TOKENS - PROMPT_SAFETY_MARGIN)
753
+
754
+ history_options = [3, 2, 1, 0]
755
+ doc_options = [5, 4, 3, 2, 1]
756
+ chars_options = [2400, 1800, 1200, 800, 500]
757
+
758
+ for history_turns in history_options:
759
+ conversation = extract_recent_history(history, max_turns=history_turns)
760
+
761
+ for doc_count in doc_options:
762
+ docs = source_docs[:doc_count]
763
+
764
+ for chars_per_doc in chars_options:
765
+ blocks = []
766
+
767
+ for index, doc in enumerate(docs, start=1):
768
+ snippet = make_snippet(
769
+ doc.get("text", ""),
770
+ message,
771
+ max_chars=chars_per_doc,
772
+ )
773
+
774
+ blocks.append(
775
+ f"[India Code Source {index}]\n"
776
+ f"Title: {doc.get('title', 'India Code document')}\n"
777
+ f"Type: {doc.get('type', 'html')}\n"
778
+ f"URL: {doc.get('url')}\n"
779
+ f"Relevant excerpt:\n{snippet}\n"
780
+ )
781
+
782
+ context = "\n\n".join(blocks)
783
+
784
+ if not context:
785
+ context = "No directly relevant readable content was retrieved from India Code for this query."
786
+
787
+ prompt = base_prompt_template(
788
+ message=message,
789
+ conversation=conversation,
790
+ indiacode_context=context,
791
+ )
792
+
793
+ prompt_tokens = count_tokens(model, prompt)
794
+
795
+ if prompt_tokens <= max_prompt_tokens:
796
+ return prompt, docs, prompt_tokens
797
+
798
+ # Final emergency fallback.
799
+ prompt = base_prompt_template(
800
+ message=message,
801
+ conversation="",
802
+ indiacode_context=(
803
+ "India Code context was retrieved but had to be heavily reduced "
804
+ "because it exceeded the local model context window."
805
+ ),
806
+ )
807
+
808
+ return prompt, source_docs[:1], count_tokens(model, prompt)
809
+
810
+
811
+ def safe_generation_max_tokens(prompt_tokens):
812
+ available = N_CTX - prompt_tokens - PROMPT_SAFETY_MARGIN
813
+
814
+ if available < 96:
815
+ return 96
816
+
817
+ return min(MAX_TOKENS, available)
818
 
819
 
820
  # -------------------------------------------------
 
822
  # -------------------------------------------------
823
 
824
  def chat(message, history):
 
 
 
 
 
 
825
  if not message or not message.strip():
826
  return "Please enter a question."
827
 
828
  try:
829
  user_query = message.strip()
830
 
831
+ # India Code lookup first.
832
+ _raw_context, source_docs = build_indiacode_context(user_query)
833
 
834
+ # Load model.
835
  model = load_model()
836
 
837
+ # Build prompt safely within context window.
838
+ prompt, used_docs, prompt_tokens = build_safe_prompt(
839
+ model=model,
840
  message=user_query,
841
  history=history,
842
+ source_docs=source_docs,
843
+ )
844
+
845
+ generation_tokens = safe_generation_max_tokens(prompt_tokens)
846
+
847
+ print(
848
+ f"Prompt tokens: {prompt_tokens}, "
849
+ f"generation tokens: {generation_tokens}, "
850
+ f"context window: {N_CTX}"
851
  )
852
 
853
  response = model(
854
  prompt,
855
+ max_tokens=generation_tokens,
856
  temperature=TEMPERATURE,
857
  top_p=TOP_P,
858
  echo=False,
 
864
  if not answer:
865
  answer = "I could not generate a response. Please try rephrasing your question."
866
 
867
+ answer += format_sources(used_docs)
868
 
869
  return answer
870
 
871
  except Exception as error:
872
+ print("Error during India Code lookup, OCR, or generation:")
873
  traceback.print_exc()
874
 
875
  return (
876
+ "The app encountered an error while searching India Code, reading a PDF, "
877
+ "running OCR, or generating the response.\n\n"
878
  f"Error details: {str(error)}"
879
  )
880
 
 
886
  description = """
887
  # 🏛️ Indian Legal AI Assistant
888
 
889
+ Ask questions about Indian laws, Acts, legal sections, rules, and follow-up questions.
890
 
891
  This app uses:
892
  - a local GGUF model through `llama-cpp-python`
893
+ - integrated India Code lookup
894
+ - HTML extraction
895
+ - text-based PDF extraction
896
+ - lightweight OCR fallback for scanned PDFs
897
  - recent chat history for follow-up questions
898
 
899
+ The India Code lookup runs inside each answer.
 
900
 
901
  ---
902
 
903
+ **Primary source:** https://www.indiacode.nic.in/
904
 
905
  **Disclaimer:** This assistant provides general legal information only.
906
  It is not a substitute for advice from a qualified legal professional.
 
935
  print(f"Using model: {MODEL_REPO}/{MODEL_FILE}")
936
  print("CPU-only mode enabled.")
937
  print("Integrated India Code lookup enabled.")
938
+ print("PDF OCR fallback enabled.")
939
  print(
940
  f"N_CTX={N_CTX}, "
941
  f"N_THREADS={N_THREADS}, "
942
  f"N_THREADS_BATCH={N_THREADS_BATCH}, "
943
+ f"N_BATCH={N_BATCH}, "
944
+ f"MAX_TOKENS={MAX_TOKENS}"
945
  )
946
 
947
  demo.launch(