File size: 8,097 Bytes
135a99c 2935b46 135a99c 2935b46 135a99c 2935b46 135a99c ebc404b 135a99c ebc404b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | """
JanMat β single Gradio Space.
Replaces the old bills/themes/sentiment dashboard with a chat interface that
answers questions using Groq, grounded in the full text of scraped PRS bill
pages + PDFs + articles (stored in Supabase), with optional voice in/out via
Sarvam AI.
Everything lives in this one Space, same as your original setup β no
separate frontend needed.
"""
import logging
import threading
import gradio as gr
import db
import rag
import voice
from config import CRON_SECRET
from ingest_pipeline import run_ingestion
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("janmat.app")
# This Space's hardware tier is set to ZeroGPU (HF's free GPU allowance).
# The actual workload here is 100% CPU (embeddings, HTTP calls to Supabase/
# Groq/Sarvam) β nothing needs a GPU. However, ZeroGPU hardware expects at
# least one @spaces.GPU-decorated function to exist so HF's runtime
# recognizes the Space as compliant with that hardware tier. This mirrors
# what the original app.py did for the same reason (see git history).
try:
import spaces
@spaces.GPU
def _zerogpu_keepalive():
"""No-op β exists only to satisfy the ZeroGPU hardware requirement."""
return "ok"
try:
_zerogpu_keepalive()
except Exception:
logger.info("ZeroGPU keepalive call skipped (no GPU allocated at import time) β harmless.")
except ImportError:
logger.info("`spaces` package not available β assuming non-ZeroGPU hardware, skipping keepalive.")
_ingestion_lock = threading.Lock()
_ingestion_state = {"running": False, "last_summary": None}
def _ensure_session(session_id):
return session_id or db.create_session()
def _clean_source_title(title: str) -> str:
"""
Source titles are often raw PDF filenames like
'Corporate_Laws_(A)_Bill_2026_Text.pdf' β turn those into something
readable before showing them to a citizen.
"""
name = title.strip()
if name.lower().endswith(".pdf"):
name = name[:-4]
name = name.replace("_", " ").replace("%2C", ",")
# Collapse the double spaces that underscore-stripping can leave behind.
return " ".join(name.split())
def _format_answer_with_sources(answer: str, citations: list) -> str:
if not citations:
return answer
lines = "\n".join(
f"{c['n']}. [{_clean_source_title(c['title'])}]({c['url']})" for c in citations
)
return f"{answer}\n\n---\n\n**Sources**\n\n{lines}"
def chat_fn(message, history, session_id):
if not message or not message.strip():
return history, "", session_id
session_id = _ensure_session(session_id)
db.save_message(session_id, "user", message, input_mode="text")
result = rag.answer_question(session_id, message)
db.save_message(session_id, "assistant", result["answer"], citations=result["citations"], input_mode="text")
display_answer = _format_answer_with_sources(result["answer"], result["citations"])
history = history + [(message, display_answer)]
return history, "", session_id
def voice_fn(audio_path, history, session_id):
if audio_path is None:
return history, session_id, None
session_id = _ensure_session(session_id)
try:
with open(audio_path, "rb") as f:
audio_bytes = f.read()
stt = voice.transcribe_audio(audio_bytes, filename="question.wav")
transcript = stt["transcript"]
language = stt.get("language_code") or "unknown"
if not transcript.strip():
history = history + [(None, "I couldn't hear a question in that clip β please try again.")]
return history, session_id, None
db.save_message(session_id, "user", transcript, input_mode="voice", language_code=language)
result = rag.answer_question(session_id, transcript)
db.save_message(session_id, "assistant", result["answer"], citations=result["citations"],
input_mode="voice", language_code=language)
tts_language = language if language != "unknown" else "en-IN"
answer_audio_bytes = voice.synthesize_speech(result["answer"], language_code=tts_language)
out_path = "/tmp/janmat_answer.wav"
with open(out_path, "wb") as f:
f.write(answer_audio_bytes)
display_answer = _format_answer_with_sources(result["answer"], result["citations"])
history = history + [(transcript, display_answer)]
return history, session_id, out_path
except Exception as exc:
logger.exception("Voice turn failed")
history = history + [(None, f"Voice processing failed: {exc}")]
return history, session_id, None
def run_ingestion_fn(secret):
if not CRON_SECRET or secret != CRON_SECRET:
return "β Incorrect secret."
with _ingestion_lock:
if _ingestion_state["running"]:
return "β³ Ingestion is already running β check back shortly."
_ingestion_state["running"] = True
def _background_run():
try:
summary = run_ingestion()
_ingestion_state["last_summary"] = summary
logger.info("Ingestion finished: %s", summary)
except Exception:
logger.exception("Ingestion run crashed")
finally:
_ingestion_state["running"] = False
threading.Thread(target=_background_run, daemon=True).start()
return "β
Ingestion started in the background. This can take a few minutes for a full run."
def ingestion_status_fn(secret):
if not CRON_SECRET or secret != CRON_SECRET:
return "β Incorrect secret."
if _ingestion_state["running"]:
return "β³ Still running..."
summary = _ingestion_state["last_summary"]
if not summary:
return "No ingestion has completed yet in this session."
return (
f"Last run β seen: {summary.get('documents_seen', 0)}, "
f"ingested: {summary.get('ingested', 0)}, "
f"unchanged: {summary.get('unchanged', 0)}, "
f"skipped: {summary.get('skipped', 0)}, "
f"failed: {summary.get('failed', 0)}"
)
with gr.Blocks(title="JanMat β Ask about Indian Bills") as demo:
gr.Markdown(
"# ποΈ JanMat\n"
"Ask questions about Indian legislative bills, their full PDF briefs, "
"related articles, and public opinion β by text or voice. Answers are "
"grounded in what's actually been read into the knowledge base, with sources cited."
)
session_state = gr.State(value=None)
chatbot = gr.Chatbot(height=460, label="Conversation")
with gr.Row():
msg = gr.Textbox(placeholder="e.g. What does the Digital Data Protection Bill say about consent?",
scale=4, show_label=False)
send_btn = gr.Button("Send", scale=1, variant="primary")
with gr.Row():
mic = gr.Audio(sources=["microphone"], type="filepath", label="Or ask by voice")
answer_audio = gr.Audio(label="Spoken answer", autoplay=True)
send_btn.click(chat_fn, [msg, chatbot, session_state], [chatbot, msg, session_state], api_name="chat")
msg.submit(chat_fn, [msg, chatbot, session_state], [chatbot, msg, session_state], api_name=False)
mic.stop_recording(voice_fn, [mic, chatbot, session_state], [chatbot, session_state, answer_audio], api_name="chat_voice")
with gr.Accordion("Admin: refresh the knowledge base", open=False):
gr.Markdown(
"Triggers a fresh scrape + ingest of PRS bill pages and their PDFs. "
"Requires the CRON_SECRET you set as a Space secret."
)
secret_box = gr.Textbox(label="Cron secret", type="password")
with gr.Row():
run_btn = gr.Button("Run ingestion now")
status_btn = gr.Button("Check status")
status_box = gr.Textbox(label="Status", interactive=False)
run_btn.click(run_ingestion_fn, [secret_box], [status_box])
status_btn.click(ingestion_status_fn, [secret_box], [status_box])
if __name__ == "__main__":
demo.queue().launch(server_name="0.0.0.0", server_port=7860) |