sunodoc / app.py
ashish-doing's picture
feat: add packages.txt + new OCR stack
edb1034
Raw
History Blame Contribute Delete
14.9 kB
# -*- coding: utf-8 -*-
"""
SunoDoc HuggingFace Space
pytesseract OCR + Qwen2.5-7B (Hyperbolic) + edge-tts
"""
import gradio as gr
import tempfile
import os
import asyncio
import edge_tts
from PIL import Image
import pytesseract
from huggingface_hub import InferenceClient
HINDI_VOICE = "hi-IN-SwaraNeural"
HF_TOKEN = os.environ.get("sunodoc", None)
VL_MODEL = "Qwen/Qwen2.5-7B-Instruct"
client = InferenceClient(provider="hyperbolic", api_key=HF_TOKEN)
EXPLAIN_PROMPT = """{ocr_text}
Oopar diya gaya document padhkar exactly 3 lines mein simple Hindi mein samjhao:
Line 1: \u092f\u0939 \u0915\u094d\u092f\u093e \u0939\u0948 - (ek vakya mein batao yah document kya hai)
Line 2: \u0906\u092a\u0915\u094b \u0915\u094d\u092f\u093e \u0915\u0930\u0928\u093e \u0939\u0948 - (ek vakya mein action batao)
Line 3: \u0915\u092c \u0924\u0915 \u092f\u093e \u0915\u093f\u0924\u0928\u093e - (deadline ya amount, ya "\u0915\u094b\u0908 \u0938\u092e\u092f \u0938\u0940\u092e\u093e \u0928\u0939\u0940\u0902" agar koi nahi)
Rules: simple Hindi, 20 words se kam per line, repeat mat karo, Line 3 ke baad ruko."""
FOLLOWUP_PROMPT = """Document summary: {context}
Question: {question}
2-3 sentences mein simple Hindi mein jawab do."""
def ocr_image(image):
try:
text = pytesseract.image_to_string(image, lang="hin+eng")
if text.strip():
return text.strip()
except Exception:
pass
try:
return pytesseract.image_to_string(image, lang="eng").strip()
except Exception as e:
return ""
def text_to_speech(text):
out = tempfile.mktemp(suffix=".mp3")
clean = text.replace("*", "").strip()
async def _speak():
await edge_tts.Communicate(clean, HINDI_VOICE).save(out)
asyncio.run(_speak())
return out
def explain_document(image):
if image is None:
return "\u0915\u094b\u0908 \u0926\u0938\u094d\u0924\u093e\u0935\u0947\u091c\u093c \u0928\u0939\u0940\u0902 \u092e\u093f\u0932\u093e\u0964", None
ocr_text = ocr_image(image)
if not ocr_text or len(ocr_text) < 20:
return "\u0926\u0938\u094d\u0924\u093e\u0935\u0947\u091c\u093c \u092a\u0922\u093c \u0928\u0939\u0940\u0902 \u092a\u093e\u090f\u0964 \u0938\u093e\u092b\u093c \u092b\u094b\u091f\u094b \u0932\u0947\u0902\u0964", None
prompt = EXPLAIN_PROMPT.format(ocr_text=ocr_text[:2000])
response = client.chat.completions.create(
model=VL_MODEL,
messages=[{"role": "user", "content": prompt}],
max_tokens=200, temperature=0.1,
)
hindi_text = response.choices[0].message.content.strip()
return hindi_text, text_to_speech(hindi_text)
def answer_followup(question, context):
if not question or not question.strip():
return "\u0915\u0943\u092a\u092f\u093e \u092a\u094d\u0930\u0936\u094d\u0928 \u0932\u093f\u0916\u0947\u0902\u0964", None
if not context:
return "\u092a\u0939\u0932\u0947 \u0926\u0938\u094d\u0924\u093e\u0935\u0947\u091c\u093c \u0938\u092e\u091d\u093e\u090f\u0902\u0964", None
prompt = FOLLOWUP_PROMPT.format(context=context, question=question)
response = client.chat.completions.create(
model=VL_MODEL,
messages=[{"role": "user", "content": prompt}],
max_tokens=150, temperature=0.1,
)
answer = response.choices[0].message.content.strip()
return answer, text_to_speech(answer)
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;500;600;700&family=Sora:wght@300;400;500;600;700;800&display=swap');
*,*::before,*::after{box-sizing:border-box}
body,.gradio-container{background:#080808!important;font-family:'Sora',sans-serif!important;color:#F0EBE3!important;min-height:100vh;overflow-x:hidden}
.hero{position:relative;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80px 24px 60px;text-align:center;overflow:hidden}
.hero-glow{position:absolute;top:-20%;left:50%;transform:translateX(-50%);width:800px;height:600px;background:radial-gradient(ellipse,rgba(255,90,0,0.18) 0%,transparent 65%);pointer-events:none;animation:pulse 4s ease-in-out infinite}
.hero-glow-2{position:absolute;bottom:10%;right:-10%;width:400px;height:400px;background:radial-gradient(ellipse,rgba(255,50,0,0.08) 0%,transparent 65%);pointer-events:none}
@keyframes pulse{0%,100%{opacity:.8;transform:translateX(-50%) scale(1)}50%{opacity:1;transform:translateX(-50%) scale(1.05)}}
.hero-eyebrow{display:inline-flex;align-items:center;gap:8px;border:1px solid rgba(255,107,0,.35);background:rgba(255,107,0,.08);border-radius:100px;padding:6px 18px;font-size:.7rem;letter-spacing:2.5px;text-transform:uppercase;color:#FF7A2B;margin-bottom:36px;position:relative;z-index:1}
.hero-wordmark{font-size:clamp(5rem,14vw,9rem);font-weight:800;line-height:.9;letter-spacing:-5px;margin-bottom:20px;position:relative;z-index:1}
.hero-wordmark .orange{color:#FF5500}.hero-wordmark .white{color:#F0EBE3}
.hero-tagline{font-family:'Noto Sans Devanagari',sans-serif;font-size:clamp(1.1rem,2.5vw,1.6rem);color:rgba(240,235,227,.5);margin-bottom:32px;position:relative;z-index:1}
.hero-story{max-width:480px;font-size:1rem;line-height:1.75;color:rgba(240,235,227,.45);margin-bottom:16px;position:relative;z-index:1}
.hero-story strong{color:#F0EBE3;font-weight:600}
.hero-stats{display:flex;gap:56px;margin:32px 0 48px;position:relative;z-index:1}
.stat{text-align:center}
.stat-val{font-size:2.2rem;font-weight:800;color:#FF5500;line-height:1}
.stat-key{font-size:.68rem;letter-spacing:2px;text-transform:uppercase;color:rgba(240,235,227,.3);margin-top:6px}
.hero-pills{display:flex;gap:10px;flex-wrap:wrap;justify-content:center;position:relative;z-index:1}
.pill{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.1);border-radius:100px;padding:7px 18px;font-size:.76rem;color:rgba(240,235,227,.6)}
.scroll-hint{position:absolute;bottom:32px;left:50%;transform:translateX(-50%);color:rgba(240,235,227,.2);font-size:.7rem;letter-spacing:2px;text-transform:uppercase;animation:bounce 2s ease-in-out infinite;z-index:1}
@keyframes bounce{0%,100%{transform:translateX(-50%) translateY(0)}50%{transform:translateX(-50%) translateY(6px)}}
.line{border:none;border-top:1px solid rgba(255,255,255,.06);margin:0}
.hiw{padding:100px 40px;max-width:960px;margin:0 auto}
.eyebrow{font-size:.68rem;letter-spacing:3px;text-transform:uppercase;color:#FF5500;margin-bottom:14px}
.section-h{font-size:clamp(2rem,4vw,3rem);font-weight:700;letter-spacing:-1.5px;line-height:1.1;margin-bottom:56px}
.section-h .muted{color:rgba(240,235,227,.3);font-weight:300}
.steps{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}
.step{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.09);border-radius:24px;padding:32px 28px;transition:all .3s}
.step:hover{border-color:rgba(255,85,0,.25);transform:translateY(-6px)}
.step-n{font-size:4rem;font-weight:800;color:rgba(255,85,0,.18);line-height:1;margin-bottom:20px}
.step-ico{font-size:2rem;margin-bottom:14px;display:block}
.step-h{font-family:'Noto Sans Devanagari','Sora',sans-serif;font-size:1.1rem;font-weight:600;margin-bottom:10px}
.step-p{font-size:.82rem;color:rgba(240,235,227,.42);line-height:1.65}
.app-zone{padding:80px 40px;max-width:1060px;margin:0 auto}
.card-tag{display:inline-flex;align-items:center;gap:8px;background:rgba(255,85,0,.12);border:1px solid rgba(255,85,0,.22);border-radius:100px;padding:5px 16px;font-size:.68rem;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#FF7A2B;margin-bottom:24px}
textarea,.gr-textbox textarea{background:rgba(255,255,255,.05)!important;border:1px solid rgba(255,255,255,.1)!important;border-radius:16px!important;color:#F0EBE3!important;font-family:'Noto Sans Devanagari','Sora',sans-serif!important;font-size:1.05rem!important;line-height:1.85!important;padding:16px!important}
.privacy{background:rgba(20,80,40,.15);border:1px solid rgba(40,160,80,.18);border-radius:16px;padding:16px 24px;font-size:.78rem;color:rgba(100,210,130,.75);text-align:center;margin-top:32px;line-height:1.6}
.footer{border-top:1px solid rgba(255,255,255,.05);padding:48px 24px;text-align:center;color:rgba(240,235,227,.2);font-size:.75rem;letter-spacing:.5px;line-height:2}
.footer a{color:rgba(255,85,0,.6);text-decoration:none}
@media(max-width:680px){.steps{grid-template-columns:1fr}.hero-stats{gap:28px}.hiw,.app-zone{padding:60px 20px}}
"""
LANDING = (
'<div class="hero">'
'<div class="hero-glow"></div><div class="hero-glow-2"></div>'
'<div class="hero-eyebrow">&#10022; Gradio &times; HuggingFace &middot; Build Small 2026 &#10022;</div>'
'<div class="hero-wordmark"><span class="orange">Suno</span><span class="white">Doc</span></div>'
'<div class="hero-tagline">&#2360;&#2369;&#2344;&#2379;, &#2360;&#2350;&#2334;&#2379; &mdash; &#2310;&#2346;&#2325;&#2366; &#2342;&#2360;&#2381;&#2340;&#2366;&#2357;&#2375;&#2332;&#2364; &#2361;&#2367;&#2306;&#2342;&#2368; &#2350;&#2375;&#2306; &#2348;&#2379;&#2354;&#2375;&#2327;&#2366;</div>'
'<div class="hero-story">Your parent receives a bank letter in English.<br>They can\'t read it. You\'re not there.<br><br>'
'<strong>SunoDoc reads it, explains it in 3 simple Hindi lines,<br>and speaks it aloud.</strong></div>'
'<div class="hero-stats">'
'<div class="stat"><div class="stat-val">7B</div><div class="stat-key">Parameters</div></div>'
'<div class="stat"><div class="stat-val">&#8377;0</div><div class="stat-key">API Cost</div></div>'
'<div class="stat"><div class="stat-val">&#2361;&#2367;&#2306;&#2342;&#2368;</div><div class="stat-key">Language</div></div>'
'<div class="stat"><div class="stat-val">&#128274;</div><div class="stat-key">Private</div></div>'
'</div>'
'<div class="hero-pills">'
'<div class="pill">&#128268; Off the Grid</div>'
'<div class="pill">&#129113; Llama Champion</div>'
'<div class="pill">&#127912; Off-Brand</div>'
'<div class="pill">&#128211; Field Notes</div>'
'</div>'
'<div class="scroll-hint">&darr; scroll</div>'
'</div>'
'<hr class="line">'
'<div class="hiw">'
'<div class="eyebrow">How it works</div>'
'<h2 class="section-h">&#2340;&#2368;&#2344; &#2310;&#2360;&#2366;&#2344; &#2325;&#2342;&#2350;<br><span class="muted">Three simple steps</span></h2>'
'<div class="steps">'
'<div class="step"><div class="step-n">01</div><span class="step-ico">&#128247;</span>'
'<div class="step-h">&#2347;&#2379;&#2335;&#2379; &#2354;&#2375;&#2306;</div>'
'<div class="step-p">Take a photo of any English document &mdash; bank letter, electricity bill, government notice.</div></div>'
'<div class="step"><div class="step-n">02</div><span class="step-ico">&#129504;</span>'
'<div class="step-h">AI &#2360;&#2350;&#2334;&#2375;&#2327;&#2366;</div>'
'<div class="step-p">OCR extracts text, then Qwen2.5-7B explains it in 3 simple Hindi sentences.</div></div>'
'<div class="step"><div class="step-n">03</div><span class="step-ico">&#128266;</span>'
'<div class="step-h">&#2360;&#2369;&#2344;&#2367;&#2319;</div>'
'<div class="step-p">A clear Hindi voice reads the explanation aloud. Ask follow-up questions by typing.</div></div>'
'</div></div>'
'<hr class="line">'
'<div class="app-zone">'
'<div class="eyebrow">Try it now</div>'
'<h2 class="section-h">&#2309;&#2346;&#2344;&#2366; &#2342;&#2360;&#2381;&#2340;&#2366;&#2357;&#2375;&#2332;&#2364; &#2351;&#2361;&#2366;&#2305; &#2354;&#2366;&#2319;&#2306;<br><span class="muted">Drop your document below</span></h2>'
'</div>'
)
with gr.Blocks(css=CSS, title="SunoDoc") as demo:
explanation_state = gr.State("")
gr.HTML(LANDING)
with gr.Group():
gr.HTML('<div class="card-tag">&#128196; Step 1 &mdash; &#2342;&#2360;&#2381;&#2340;&#2366;&#2357;&#2375;&#2332;&#2364; &#2325;&#2368; &#2347;&#2379;&#2335;&#2379; &#2354;&#2375;&#2306;</div>')
with gr.Row(equal_height=True):
with gr.Column(scale=1):
doc_image = gr.Image(label="Upload or capture document photo", type="pil", sources=["upload","webcam"], height=320)
with gr.Column(scale=1):
explain_btn = gr.Button("&#2360;&#2350;&#2334;&#2366;&#2451; -- Explain in Hindi", variant="primary", size="lg")
hindi_text = gr.Textbox(label="&#2361;&#2367;&#2306;&#2342;&#2368; &#2350;&#2375;&#2306; &#2360;&#2350;&#2334;&#2366;&#2351;&#2366; &#2327;&#2351;&#2366; / Explanation", lines=6, interactive=False, placeholder="&#2351;&#2361;&#2366;&#2305; &#2361;&#2367;&#2306;&#2342;&#2368; &#2350;&#2375;&#2306; &#2360;&#2350;&#2334;&#2366;&#2351;&#2366; &#2332;&#2366;&#2319;&#2327;&#2366;...")
audio_output = gr.Audio(label="Listen to explanation", type="filepath", interactive=False, autoplay=True)
with gr.Group():
gr.HTML('<div class="card-tag">&#128172; Step 2 &mdash; &#2346;&#2381;&#2352;&#2358;&#2381;&#2344; &#2346;&#2370;&#2331;&#2375;&#2306; &middot; Optional</div>')
with gr.Row(equal_height=True):
with gr.Column(scale=1):
text_question = gr.Textbox(label="&#2361;&#2367;&#2306;&#2342;&#2368; &#2350;&#2375;&#2306; &#2346;&#2381;&#2352;&#2358;&#2381;&#2344; &#2354;&#2367;&#2326;&#2375;&#2306; / Type question", lines=3, placeholder="&#2332;&#2376;&#2360;&#2375;: &#2350;&#2369;&#2334;&#2375; &#2325;&#2381;&#2351;&#2366; &#2349;&#2352;&#2344;&#2366; &#2361;&#2376;?")
ask_btn = gr.Button("Ask in Hindi", size="sm")
with gr.Column(scale=1):
followup_text = gr.Textbox(label="&#2313;&#2340;&#2381;&#2340;&#2352; / Answer", lines=4, interactive=False)
followup_audio = gr.Audio(label="Hear answer", type="filepath", interactive=False, autoplay=True)
gr.HTML(
'<div style="max-width:1060px;margin:0 auto;padding:0 40px 80px">'
'<div class="privacy">&#128274; No data stored &middot; Qwen2.5-7B &middot; pytesseract &middot; edge-tts &middot; Gradio &middot; &#2325;&#2379;&#2312; &#2337;&#2375;&#2335;&#2366; &#2360;&#2375;&#2357; &#2344;&#2361;&#2368;&#2306; &#2361;&#2379;&#2340;&#2366;</div>'
'<div class="footer">Built with &#9825; for parents in Jhansi, UP &middot; '
'<a href="https://github.com/ashish-doing/sunodoc">GitHub</a> &middot; '
'Gradio &times; HuggingFace Build Small Hackathon 2026 &middot; '
'<span style="color:rgba(255,85,0,0.5)">Track: Backyard AI &#127968;</span></div>'
'</div>'
)
explain_btn.click(fn=explain_document, inputs=[doc_image], outputs=[hindi_text, audio_output]).then(fn=lambda t: t, inputs=[hindi_text], outputs=[explanation_state])
ask_btn.click(fn=answer_followup, inputs=[text_question, explanation_state], outputs=[followup_text, followup_audio])
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860)