rotsl commited on
Commit
a6851bc
·
verified ·
1 Parent(s): 07775dd

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +544 -176
app.py CHANGED
@@ -1,6 +1,6 @@
1
  #!/usr/bin/env python3
2
  """
3
- Indian Legal AI Assistant with integrated web lookup.
4
 
5
  CPU-only Hugging Face Spaces app.
6
 
@@ -9,20 +9,26 @@ invincibleambuj/Ambuj-Tripathi-Indian-Legal-Llama-GGUF
9
  llama-3.2-1b-instruct.Q4_K_M.gguf
10
 
11
  Web step:
12
- The app automatically searches the web for each user query and injects
13
- the retrieved snippets into the model prompt. The user does not need to
14
- run a separate search.
 
 
 
15
  """
16
 
17
  import os
18
  import re
19
- import time
20
  import html
 
21
  import traceback
22
- from urllib.parse import quote_plus, urlparse
 
23
 
24
  import requests
25
  from bs4 import BeautifulSoup
 
26
 
27
  import gradio as gr
28
  from huggingface_hub import hf_hub_download
@@ -42,30 +48,53 @@ N_THREADS = int(os.getenv("N_THREADS", "2"))
42
  N_THREADS_BATCH = int(os.getenv("N_THREADS_BATCH", "2"))
43
  N_BATCH = int(os.getenv("N_BATCH", "512"))
44
 
45
- # CPU only. Keep this at 0.
46
  N_GPU_LAYERS = 0
47
 
48
  # Generation settings.
49
- MAX_TOKENS = int(os.getenv("MAX_TOKENS", "512"))
50
- TEMPERATURE = float(os.getenv("TEMPERATURE", "0.45"))
51
  TOP_P = float(os.getenv("TOP_P", "0.9"))
52
 
53
- # Web settings.
54
- WEB_RESULTS = int(os.getenv("WEB_RESULTS", "5"))
55
- WEB_TIMEOUT = int(os.getenv("WEB_TIMEOUT", "10"))
56
-
57
- # Use persistent storage if attached, otherwise local cache.
58
  MODEL_CACHE_DIR = os.getenv("MODEL_CACHE_DIR", "./models")
59
 
60
  llm = None
61
 
62
 
63
  # -------------------------------------------------
64
- # Utility helpers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  # -------------------------------------------------
66
 
67
  def clean_text(text):
68
- """Normalize whitespace and remove noisy characters."""
69
  if not text:
70
  return ""
71
 
@@ -74,190 +103,521 @@ def clean_text(text):
74
  return text.strip()
75
 
76
 
77
- def domain_from_url(url):
78
- """Return a readable domain name from a URL."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  try:
80
  parsed = urlparse(url)
81
- return parsed.netloc.replace("www.", "")
 
82
  except Exception:
83
- return "source"
 
84
 
 
 
85
 
86
- def is_probably_url(text):
87
- """Basic URL detector."""
88
- return text.startswith("http://") or text.startswith("https://")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
 
91
  # -------------------------------------------------
92
- # Integrated web search
93
  # -------------------------------------------------
94
 
95
- def duckduckgo_search(query, max_results=5):
96
- """
97
- Lightweight web search using DuckDuckGo HTML results.
 
 
 
 
 
 
98
 
99
- This does not require an API key.
100
 
101
- Note:
102
- Search engines can sometimes block automated requests or return no results.
103
- The app handles that gracefully.
104
- """
105
- results = []
106
 
107
- if not query or not query.strip():
108
- return results
109
 
110
- search_url = f"https://duckduckgo.com/html/?q={quote_plus(query)}"
 
 
111
 
112
- headers = {
113
- "User-Agent": (
114
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
115
- "(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
116
- )
117
- }
118
 
119
  try:
120
- response = requests.get(
121
- search_url,
122
- headers=headers,
123
- timeout=WEB_TIMEOUT,
124
- )
125
- response.raise_for_status()
 
 
 
 
 
 
 
 
 
 
126
 
127
- soup = BeautifulSoup(response.text, "html.parser")
128
 
129
- for result in soup.select(".result"):
130
- title_el = result.select_one(".result__title a")
131
- snippet_el = result.select_one(".result__snippet")
132
 
133
- if not title_el:
134
- continue
 
 
135
 
136
- title = clean_text(title_el.get_text(" "))
137
- url = title_el.get("href", "").strip()
138
- snippet = clean_text(snippet_el.get_text(" ")) if snippet_el else ""
139
 
140
- if not title or not url:
141
- continue
142
 
143
- results.append(
144
- {
145
- "title": title,
146
- "url": url,
147
- "domain": domain_from_url(url),
148
- "snippet": snippet,
149
- }
150
- )
151
 
152
- if len(results) >= max_results:
153
- break
 
 
154
 
155
- except Exception as error:
156
- print(f"Web search failed: {error}")
157
 
158
- return results
 
 
159
 
160
 
161
- def fetch_page_text(url):
162
  """
163
- Fetch readable text from a page.
164
 
165
- This is optional enrichment after search results.
166
- It keeps text short to avoid slowing inference.
167
  """
168
- if not url or not is_probably_url(url):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  return ""
170
 
171
- headers = {
172
- "User-Agent": (
173
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
174
- "(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
175
- )
176
- }
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  try:
179
- response = requests.get(url, headers=headers, timeout=WEB_TIMEOUT)
180
- response.raise_for_status()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
- content_type = response.headers.get("content-type", "").lower()
183
- if "text/html" not in content_type:
184
- return ""
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  soup = BeautifulSoup(response.text, "html.parser")
187
 
188
- for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
189
- tag.decompose()
190
 
191
- paragraphs = []
192
- for p in soup.find_all(["p", "li"], limit=20):
193
- text = clean_text(p.get_text(" "))
194
- if len(text) > 40:
195
- paragraphs.append(text)
 
 
 
 
 
196
 
197
- page_text = " ".join(paragraphs)
198
- return page_text[:1800]
 
 
 
 
 
199
 
200
  except Exception as error:
201
- print(f"Page fetch failed for {url}: {error}")
202
- return ""
 
203
 
204
 
205
- def build_web_context(query):
206
  """
207
- Search the web and build compact context for the model.
208
 
209
- The web step is integrated into the prompt, not shown as a separate workflow.
 
 
 
 
210
  """
211
- results = duckduckgo_search(query, max_results=WEB_RESULTS)
 
212
 
213
- if not results:
214
- return "No web results were retrieved for this query.", []
215
 
216
- context_blocks = []
217
- enriched_results = []
218
 
219
- for index, result in enumerate(results, start=1):
220
- page_text = ""
221
 
222
- # Fetch only top 2 pages to keep things fast.
223
- if index <= 2:
224
- page_text = fetch_page_text(result["url"])
 
 
 
 
225
 
226
- enriched = dict(result)
227
- enriched["page_text"] = page_text
228
- enriched_results.append(enriched)
229
 
230
- block = (
231
- f"[Source {index}]\n"
232
- f"Title: {result['title']}\n"
233
- f"Domain: {result['domain']}\n"
234
- f"URL: {result['url']}\n"
235
- f"Search snippet: {result['snippet']}\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  )
237
 
238
- if page_text:
239
- block += f"Page text excerpt: {page_text}\n"
240
 
241
- context_blocks.append(block)
 
242
 
243
- web_context = "\n".join(context_blocks)
244
- return web_context, enriched_results
 
 
 
 
 
245
 
 
246
 
247
- def format_sources(results):
248
- """Format sources for appending to the assistant answer."""
249
- if not results:
250
- return ""
251
 
252
- lines = ["\n\nSources checked:"]
253
 
254
- for index, result in enumerate(results, start=1):
255
- title = result.get("title", "Source")
256
- url = result.get("url", "")
257
- domain = result.get("domain", "source")
258
 
259
- if url:
260
- lines.append(f"{index}. {title} — {domain}\n {url}")
 
 
 
 
 
261
 
262
  return "\n".join(lines)
263
 
@@ -304,14 +664,14 @@ def load_model():
304
 
305
 
306
  # -------------------------------------------------
307
- # Prompt construction
308
  # -------------------------------------------------
309
 
310
  def extract_recent_history(history, max_turns=4):
311
  """
312
- Keep only recent conversation history for speed and follow-up support.
313
 
314
- Supports both:
315
  - list of dicts: {"role": "...", "content": "..."}
316
  - list of tuples/lists: (user, assistant)
317
  """
@@ -338,12 +698,11 @@ def extract_recent_history(history, max_turns=4):
338
  return conversation
339
 
340
 
341
- def build_prompt(message, history, web_context):
342
  """
343
- Build a detailed prompt with integrated web context.
344
  """
345
  conversation = extract_recent_history(history, max_turns=4)
346
-
347
  current_date = time.strftime("%Y-%m-%d")
348
 
349
  prompt = f"""You are an Indian legal AI assistant.
@@ -352,28 +711,28 @@ Current date: {current_date}
352
 
353
  You must answer using:
354
  1. The user's question.
355
- 2. The recent chat history.
356
- 3. The integrated web context below.
357
-
358
- Important behaviour:
359
- - The web context has already been retrieved automatically.
360
- - Do not say "I searched separately" or ask the user to search.
361
- - Give a detailed, practical answer.
362
- - Use web context for current or time-sensitive facts.
363
- - If the web context is weak, say what you could not verify.
364
- - For Indian law questions, explain the legal concept clearly.
 
 
 
365
  - Do not claim to be a lawyer.
366
  - Do not present the answer as formal legal advice.
367
  - For specific legal matters, advise consulting a qualified lawyer.
368
- - Support follow-up questions by preserving continuity from recent chat history.
369
- - When sources conflict, mention the conflict.
370
- - Do not invent case law, sections, dates, or citations.
371
 
372
  Recent conversation:
373
  {conversation}
374
 
375
- Integrated web context:
376
- {web_context}
377
 
378
  User question:
379
  {message}
@@ -384,15 +743,15 @@ Answer:"""
384
 
385
 
386
  # -------------------------------------------------
387
- # Chat function
388
  # -------------------------------------------------
389
 
390
  def chat(message, history):
391
  """
392
  Main Gradio chat function.
393
 
394
- The web step is integrated here:
395
- user message -> web search -> prompt with web context -> local LLM answer.
396
  """
397
  if not message or not message.strip():
398
  return "Please enter a question."
@@ -400,8 +759,8 @@ def chat(message, history):
400
  try:
401
  user_query = message.strip()
402
 
403
- # Integrated web step.
404
- web_context, web_results = build_web_context(user_query)
405
 
406
  # Load local model lazily.
407
  model = load_model()
@@ -409,7 +768,7 @@ def chat(message, history):
409
  prompt = build_prompt(
410
  message=user_query,
411
  history=history,
412
- web_context=web_context,
413
  )
414
 
415
  response = model(
@@ -426,17 +785,16 @@ def chat(message, history):
426
  if not answer:
427
  answer = "I could not generate a response. Please try rephrasing your question."
428
 
429
- # Append sources so the user can verify.
430
- answer += format_sources(web_results)
431
 
432
  return answer
433
 
434
  except Exception as error:
435
- print("Error during generation:")
436
  traceback.print_exc()
437
 
438
  return (
439
- "The app encountered an error while loading, searching, or running the model.\n\n"
440
  f"Error details: {str(error)}"
441
  )
442
 
@@ -448,18 +806,22 @@ def chat(message, history):
448
  description = """
449
  # 🏛️ Indian Legal AI Assistant
450
 
451
- Ask questions about Indian laws, acts, legal procedures, and current legal information.
452
 
453
  This app uses:
454
  - a local GGUF model through `llama-cpp-python`
455
- - an integrated web step for up-to-date information
 
 
456
  - recent chat history for follow-up questions
457
 
458
- The web step runs automatically inside each answer.
459
  You do not need to run a separate search.
460
 
461
  ---
462
 
 
 
463
  **Disclaimer:** This assistant provides general legal information only.
464
  It is not a substitute for advice from a qualified legal professional.
465
  For specific legal matters, please consult a lawyer.
@@ -470,15 +832,15 @@ demo = gr.ChatInterface(
470
  title="Indian Legal AI Assistant",
471
  description=description,
472
  textbox=gr.Textbox(
473
- placeholder="Ask about Indian laws, recent legal updates, procedures, rights, cases, or follow-up questions...",
474
  lines=3,
475
  label="Your Question",
476
  ),
477
  examples=[
478
- "What is the Indian Penal Code?",
479
- "What changed after the new criminal laws came into force in India?",
480
- "Explain Section 377 of IPC and its current legal status.",
481
  "What are the grounds for divorce under the Hindu Marriage Act?",
 
482
  ],
483
  cache_examples=False,
484
  )
@@ -492,7 +854,13 @@ if __name__ == "__main__":
492
  print("Starting Indian Legal AI Assistant...")
493
  print(f"Using model: {MODEL_REPO}/{MODEL_FILE}")
494
  print("CPU-only mode enabled.")
495
- print(f"N_CTX={N_CTX}, N_THREADS={N_THREADS}, N_THREADS_BATCH={N_THREADS_BATCH}, N_BATCH={N_BATCH}")
 
 
 
 
 
 
496
 
497
  demo.launch(
498
  server_name="0.0.0.0",
 
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
 
 
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
21
  import re
22
+ import io
23
  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
 
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/"
70
+ ALLOWED_DOMAINS = {
71
+ "indiacode.nic.in",
72
+ "www.indiacode.nic.in",
73
+ }
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": (
86
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
87
+ "(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
88
+ )
89
+ }
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
 
 
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
131
+
132
 
133
+ def looks_like_pdf_url(url):
134
+ return ".pdf" in url.lower()
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
+
154
+ if not terms:
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
168
+
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:
183
+ idx = lower.find(term)
184
+ if idx != -1:
185
+ first_hit = idx
186
+ break
187
+
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:
207
+ print(f"GET failed: {url} :: {error}")
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:
226
+ soup = BeautifulSoup(html_text, "html.parser")
227
+
228
+ for a in soup.find_all("a", href=True):
229
+ href = a.get("href", "")
230
+ text = clean_text(a.get_text(" "))
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
 
243
+ return links
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)
264
 
265
+ return clean_text(" ".join(parts))
 
266
 
267
+ except Exception as error:
268
+ print(f"HTML extraction failed: {error}")
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,
315
+ "title": "Blocked non-India-Code URL",
316
+ "text": "",
317
+ "links": [],
318
+ "type": "blocked",
319
+ }
320
+
321
+ response = safe_get(url)
322
+ if response is None:
323
+ return {
324
+ "url": url,
325
+ "title": "Fetch failed",
326
+ "text": "",
327
+ "links": [],
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
344
+ links = extract_links_from_html(html_text, url)
345
+ text = extract_text_from_html(html_text)
346
+
347
+ title = "India Code page"
348
  try:
349
+ soup = BeautifulSoup(html_text, "html.parser")
350
+ if soup.title:
351
+ title = clean_text(soup.title.get_text(" "))
352
+ except Exception:
353
+ pass
354
+
355
+ return {
356
+ "url": url,
357
+ "title": title,
358
+ "text": text,
359
+ "links": links,
360
+ "type": "html",
361
+ }
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
+
381
+ # Central Acts browse pages.
382
+ "https://www.indiacode.nic.in/handle/123456789/1362/browse?type=shorttitle",
383
+ "https://www.indiacode.nic.in/handle/123456789/1362/browse?type=actno",
384
+ "https://www.indiacode.nic.in/handle/123456789/1362/browse?type=actyear",
385
+ "https://www.indiacode.nic.in/handle/123456789/1362/browse?type=enactmentdate",
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):
405
+ discovered.append(url)
406
+
407
+ return discovered
408
 
 
 
 
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
+
427
+ try:
428
  soup = BeautifulSoup(response.text, "html.parser")
429
 
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:
440
+ url = qs["uddg"][0]
441
+ except Exception:
442
+ pass
443
 
444
+ url = normalize_url(url)
445
+
446
+ if is_indiacode_url(url):
447
+ discovered.append(url)
448
+
449
+ if len(discovered) >= MAX_DISCOVERY_RESULTS:
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
 
471
+ if not is_indiacode_url(url):
472
+ return False
473
 
474
+ url_l = url.lower()
475
+ anchor_l = anchor.lower()
476
 
477
+ if looks_like_pdf_url(url):
478
+ return True
479
 
480
+ important_patterns = [
481
+ "/handle/",
482
+ "/bitstream/",
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
+
495
+ return False
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
+
518
+ for url in seeds:
519
+ url = normalize_url(url)
520
+ if is_indiacode_url(url) and url not in seen:
521
+ queue.append(url)
522
+ seen.add(url)
523
+
524
+ visited = set()
525
+ scored_docs = []
526
+
527
+ while queue and len(visited) < MAX_CRAWL_PAGES:
528
+ url = queue.pop(0)
529
+
530
+ if url in visited:
531
+ continue
532
+
533
+ visited.add(url)
534
+
535
+ doc = fetch_document_text(url)
536
+ text = doc.get("text", "")
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,
547
+ "title": title,
548
+ "type": doc_type,
549
+ "score": score,
550
+ "text": text,
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
+
558
+ if link_url in seen:
559
+ continue
560
+
561
+ if relevant_link_filter(link, query):
562
+ queue.append(link_url)
563
+ seen.add(link_url)
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"
598
+ f"Title: {doc.get('title', 'India Code document')}\n"
599
+ f"Type: {doc.get('type', 'html')}\n"
600
+ f"URL: {doc.get('url')}\n"
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
 
614
+ lines = ["\n\nIndia Code sources checked:"]
615
+
616
+ for index, doc in enumerate(docs, start=1):
617
+ title = doc.get("title", "India Code document")
618
+ url = doc.get("url", "")
619
+ dtype = doc.get("type", "html")
620
+ lines.append(f"{index}. {title} [{dtype}]\n {url}")
621
 
622
  return "\n".join(lines)
623
 
 
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
  """
 
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.
 
711
 
712
  You must answer using:
713
  1. The user's question.
714
+ 2. The recent conversation.
715
+ 3. The integrated India Code context below.
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.
729
  - For specific legal matters, advise consulting a qualified lawyer.
 
 
 
730
 
731
  Recent conversation:
732
  {conversation}
733
 
734
+ Integrated India Code context:
735
+ {indiacode_context}
736
 
737
  User question:
738
  {message}
 
743
 
744
 
745
  # -------------------------------------------------
746
+ # Main chat function
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."
 
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()
 
768
  prompt = build_prompt(
769
  message=user_query,
770
  history=history,
771
+ indiacode_context=indiacode_context,
772
  )
773
 
774
  response = model(
 
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
  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.
827
  For specific legal matters, please consult a lawyer.
 
832
  title="Indian Legal AI Assistant",
833
  description=description,
834
  textbox=gr.Textbox(
835
+ placeholder="Ask about Indian laws, Acts, sections, rules, or follow-up questions...",
836
  lines=3,
837
  label="Your Question",
838
  ),
839
  examples=[
840
+ "What is the current status of Section 377 under Indian law?",
841
+ "Explain the Bharatiya Nyaya Sanhita in detail.",
 
842
  "What are the grounds for divorce under the Hindu Marriage Act?",
843
+ "Find the latest India Code position on the Right to Information Act.",
844
  ],
845
  cache_examples=False,
846
  )
 
854
  print("Starting Indian Legal AI Assistant...")
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(
866
  server_name="0.0.0.0",