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)