Benedikt Droste commited on
Commit
22ed911
Β·
1 Parent(s): 853a19b

Initial commit

Browse files
Files changed (3) hide show
  1. README.md +31 -7
  2. app.py +797 -4
  3. requirements.txt +8 -0
README.md CHANGED
@@ -1,13 +1,37 @@
1
  ---
2
- title: Sui Demo
3
- emoji: πŸ¦€
4
- colorFrom: red
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.2.0
8
  app_file: app.py
9
  pinned: false
10
- license: apache-2.0
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: sui-1 Summarizer
3
+ emoji: πŸ“„
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 5.34.2
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ short_description: Grounded Summarization with Source Citations
12
  ---
13
 
14
+ # sui-1 Summarizer
15
+
16
+ Generate grounded summaries with verifiable source citations using the [ellamind/sui-1-24b](https://huggingface.co/ellamind/sui-1-24b) model.
17
+
18
+ ## Features
19
+
20
+ - **PDF Upload**: Extract text using Mistral OCR
21
+ - **Text Input**: Paste text directly for summarization
22
+ - **Multi-language**: Support for English, German, Spanish, French, and Italian
23
+ - **Source Citations**: Every claim is backed by numbered citations from the source document
24
+ - **Streaming Output**: See the summary generate in real-time
25
+
26
+ ## Requirements
27
+
28
+ This Space requires:
29
+ - **ZeroGPU** hardware (uses `@spaces.GPU` decorator)
30
+ - **MISTRAL_API_KEY** secret for PDF OCR functionality
31
+
32
+ ## Setup
33
+
34
+ 1. Create a new Space on HuggingFace
35
+ 2. Set the hardware to **ZeroGPU**
36
+ 3. Add `MISTRAL_API_KEY` as a secret in Space settings
37
+ 4. Upload `app.py`, `requirements.txt`, and this `README.md`
app.py CHANGED
@@ -1,7 +1,800 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- def greet(name):
4
- return "Hello " + name + "!!"
 
5
 
6
- demo = gr.Interface(fn=greet, inputs="text", outputs="text")
7
- demo.launch()
 
 
1
+ """
2
+ sui-1 Summarizer - Grounded Summarization with Source Citations
3
+ A Gradio app for HuggingFace ZeroGPU Spaces
4
+ """
5
+
6
+ import base64
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import re
11
+ from threading import Thread
12
+ from typing import Generator
13
+
14
  import gradio as gr
15
+ import spaces
16
+ import spacy
17
+ import spacy.cli
18
+ import torch
19
+ from mistralai import Mistral
20
+ from mistral_common.protocol.instruct.request import ChatCompletionRequest
21
+ from mistral_common.tokens.tokenizers.mistral import MistralTokenizer
22
+ from transformers import AutoTokenizer, Mistral3ForConditionalGeneration, TextIteratorStreamer
23
+
24
+
25
+ # ============================================================================
26
+ # Configuration
27
+ # ============================================================================
28
+
29
+ MODEL_ID = "ellamind/sui-1-24b"
30
+ HF_TOKENIZER_ID = "mistralai/Mistral-Small-3.1-24B-Instruct-2503"
31
+
32
+ LANGUAGES = {
33
+ "English": "en",
34
+ "German": "de",
35
+ "Spanish": "es",
36
+ "French": "fr",
37
+ "Italian": "it",
38
+ }
39
+
40
+ SPACY_MODELS = {
41
+ "en": "en_core_web_sm",
42
+ "de": "de_core_news_sm",
43
+ "es": "es_core_news_sm",
44
+ "fr": "fr_core_news_sm",
45
+ "it": "it_core_news_sm",
46
+ }
47
+
48
+
49
+ # ============================================================================
50
+ # Initialization
51
+ # ============================================================================
52
+
53
+ def download_spacy_models():
54
+ """Download all required spaCy models."""
55
+ for model_name in SPACY_MODELS.values():
56
+ spacy.cli.download(model_name)
57
+
58
+
59
+ def load_model():
60
+ """Load the sui-1-24b model and tokenizers."""
61
+ print(f"Loading model: {MODEL_ID}")
62
+
63
+ tokenizer = MistralTokenizer.from_hf_hub(MODEL_ID)
64
+ model = Mistral3ForConditionalGeneration.from_pretrained(
65
+ MODEL_ID,
66
+ dtype=torch.bfloat16,
67
+ device_map="auto",
68
+ )
69
+ hf_tokenizer = AutoTokenizer.from_pretrained(HF_TOKENIZER_ID)
70
+
71
+ print("Model loaded successfully")
72
+ return model, tokenizer, hf_tokenizer
73
+
74
+
75
+ # Initialize on startup
76
+ download_spacy_models()
77
+ model, tokenizer, hf_tokenizer = load_model()
78
+ nlp_cache: dict[str, spacy.Language] = {}
79
+
80
+
81
+ # ============================================================================
82
+ # NLP Utilities
83
+ # ============================================================================
84
+
85
+ def get_nlp(lang_code: str) -> spacy.Language:
86
+ """Get or load spaCy model for sentence segmentation."""
87
+ if lang_code not in nlp_cache:
88
+ model_name = SPACY_MODELS.get(lang_code, "en_core_web_sm")
89
+ try:
90
+ nlp_cache[lang_code] = spacy.load(model_name)
91
+ except OSError:
92
+ print(f"spaCy model '{model_name}' not found, using English")
93
+ nlp_cache[lang_code] = spacy.load("en_core_web_sm")
94
+ return nlp_cache[lang_code]
95
+
96
+
97
+ def tag_sentences(text: str, lang_code: str) -> tuple[str, dict[str, str], dict[str, int]]:
98
+ """
99
+ Tag each sentence with a unique XML identifier.
100
+
101
+ Returns:
102
+ tagged: Text with XML tags around each sentence
103
+ tag_to_sentence: Mapping of tag to original sentence
104
+ tag_to_pos: Mapping of tag to character position
105
+ """
106
+ nlp = get_nlp(lang_code)
107
+ doc = nlp(text)
108
+
109
+ tagged = ""
110
+ tag_to_sentence = {}
111
+ tag_to_pos = {}
112
+
113
+ for i, sent in enumerate(doc.sents):
114
+ sentence = sent.text.strip()
115
+ if sentence:
116
+ tag = hashlib.md5(f"{i}_{sentence[:50]}".encode()).hexdigest()[:8]
117
+ tag_to_sentence[tag] = sentence
118
+ tag_to_pos[tag] = sent.start_char
119
+ tagged += f"<{tag}>{sentence}</{tag}>"
120
+
121
+ return tagged, tag_to_sentence, tag_to_pos
122
+
123
+
124
+ # ============================================================================
125
+ # PDF Processing
126
+ # ============================================================================
127
+
128
+ def extract_pdf_text(pdf_path: str) -> tuple[str, dict[int, tuple[int, int]]]:
129
+ """
130
+ Extract text from PDF using Mistral OCR.
131
+
132
+ Returns:
133
+ text: Concatenated markdown text from all pages
134
+ page_ranges: Dict mapping page number (1-indexed) to (start_char, end_char)
135
+ """
136
+ api_key = os.environ.get("MISTRAL_API_KEY")
137
+ if not api_key:
138
+ raise ValueError("MISTRAL_API_KEY not set. Please add it to your Space secrets.")
139
+
140
+ with open(pdf_path, "rb") as f:
141
+ base64_pdf = base64.b64encode(f.read()).decode("utf-8")
142
+
143
+ client = Mistral(api_key=api_key)
144
+ response = client.ocr.process(
145
+ model="mistral-ocr-latest",
146
+ document={
147
+ "type": "document_url",
148
+ "document_url": f"data:application/pdf;base64,{base64_pdf}",
149
+ },
150
+ include_image_base64=False,
151
+ )
152
+
153
+ full_text = ""
154
+ page_ranges = {}
155
+
156
+ for page in response.pages:
157
+ page_num = page.index + 1
158
+ start = len(full_text)
159
+ page_text = page.markdown or ""
160
+ full_text += page_text + "\n\n"
161
+ page_ranges[page_num] = (start, len(full_text))
162
+
163
+ return full_text.strip(), page_ranges
164
+
165
+
166
+ def find_page(char_pos: int, page_ranges: dict[int, tuple[int, int]]) -> int:
167
+ """Find which page a character position belongs to."""
168
+ for page_num, (start, end) in page_ranges.items():
169
+ if start <= char_pos < end:
170
+ return page_num
171
+ return 1
172
+
173
+
174
+ # ============================================================================
175
+ # Output Processing
176
+ # ============================================================================
177
+
178
+ def process_output(
179
+ text: str,
180
+ tag_to_sentence: dict[str, str],
181
+ tag_to_pos: dict[str, int],
182
+ page_ranges: dict[int, tuple[int, int]] | None,
183
+ ) -> tuple[str, list[dict]]:
184
+ """
185
+ Process model output to replace XML tags with numbered citations.
186
+
187
+ Returns:
188
+ processed_text: Summary with [1], [2], etc.
189
+ sources: List of dicts with citation info
190
+ """
191
+ counter = [0]
192
+ sources = []
193
+
194
+ def replace(match):
195
+ tag = match.group(1)
196
+ counter[0] += 1
197
+ num = counter[0]
198
+
199
+ source_info = {
200
+ "num": num,
201
+ "tag": tag,
202
+ "sentence": tag_to_sentence.get(tag, ""),
203
+ }
204
+
205
+ if page_ranges and tag in tag_to_pos:
206
+ source_info["page"] = find_page(tag_to_pos[tag], page_ranges)
207
+
208
+ sources.append(source_info)
209
+ return f"[{num}]"
210
+
211
+ processed = re.sub(r'\[<([a-f0-9]{8})>\]', replace, text)
212
+ return processed, sources
213
+
214
+
215
+ def extract_json_field(raw_output: str, field: str) -> str:
216
+ """Extract a field from JSON output (handles partial JSON during streaming)."""
217
+ # Try complete JSON first
218
+ json_match = re.search(r'\{[\s\S]*\}', raw_output)
219
+ if json_match:
220
+ try:
221
+ data = json.loads(json_match.group())
222
+ return data.get(field, "")
223
+ except json.JSONDecodeError:
224
+ pass
225
+
226
+ # Fallback: extract partial field string
227
+ match = re.search(rf'"{field}"\s*:\s*"((?:[^"\\]|\\.)*)', raw_output)
228
+ if match:
229
+ partial = match.group(1)
230
+ return partial.replace('\\"', '"').replace('\\n', '\n')
231
+
232
+ return ""
233
+
234
+
235
+ def extract_summary_and_structure(raw_output: str) -> tuple[str, str]:
236
+ """
237
+ Extract both summary and structure from JSON output.
238
+
239
+ Returns:
240
+ summary: The main summary text
241
+ structure: The reasoning/planning text
242
+ """
243
+ summary = extract_json_field(raw_output, "summary")
244
+ structure = extract_json_field(raw_output, "structure")
245
+ return summary, structure
246
+
247
+
248
+ def format_sources(sources: list[dict], has_pages: bool) -> str:
249
+ """Format sources as markdown."""
250
+ lines = []
251
+ for src in sources:
252
+ sentence = src["sentence"]
253
+ if len(sentence) > 120:
254
+ sentence = sentence[:117] + "..."
255
+
256
+ if has_pages and "page" in src:
257
+ lines.append(f"**[{src['num']}]** (p.{src['page']}) {sentence}")
258
+ else:
259
+ lines.append(f"**[{src['num']}]** {sentence}")
260
+
261
+ return "\n\n".join(lines)
262
+
263
+
264
+ # ============================================================================
265
+ # Prompt Building
266
+ # ============================================================================
267
+
268
+ def build_prompt(
269
+ tagged_text: str,
270
+ words: int,
271
+ language: str,
272
+ custom_instruction: str = "",
273
+ ) -> str:
274
+ """Build the summarization prompt."""
275
+ num_tags = max(3, min(15, words // 40))
276
+
277
+ custom_section = ""
278
+ if custom_instruction.strip():
279
+ custom_section = f"\n# Custom Instruction\n{custom_instruction.strip()}\n"
280
+
281
+ return f"""You are a professional summarizer, following all given instructions with the utmost care.
282
+ <text>
283
+ {tagged_text}
284
+ </text>
285
+ # Output Format
286
+ The output must be in JSON format with the following structure:
287
+ 1. A "structure" string containing your thoughts about the content and structure of the summary
288
+ 2. An "xml_tags" list containing the XML tag identifiers from the tagged text (e.g., "<a1b2c3d4>")
289
+ 3. A "summary" string containing the actual summary with inline XML tag references
290
+ # Instructions
291
+ 1. Start by thinking about and explaining the structure and content of your summary. Select {num_tags} XML tags from the tagged text that capture the most significant data and facts.
292
+ 2. Begin with an executive summary introducing the title, author (if available), and key findings.
293
+ 3. Structure the summary in coherent paragraphs. Every paragraph should contain at least one XML tag reference.
294
+ 4. Reference XML tags inline in square brackets (e.g., [<a1b2c3d4>]) immediately after the statement they support.
295
+ 5. Each XML tag must appear exactly once in the summary.
296
+ 6. Avoid a concluding paragraph that merely restates points.
297
+ 7. Do not use bullet points or headings unless explicitly requested.
298
+ {custom_section}
299
+ Parameters:
300
+ - Word count (excl. XML tags): {words}
301
+ - Number of XML tags: {num_tags}
302
+ - Language: {language}
303
+ """
304
+
305
+
306
+ # ============================================================================
307
+ # Summary Generation
308
+ # ============================================================================
309
+
310
+ @spaces.GPU(duration=100)
311
+ def generate_summary(
312
+ text: str,
313
+ lang_code: str,
314
+ language_name: str,
315
+ words: int,
316
+ custom_instruction: str,
317
+ page_ranges: dict[int, tuple[int, int]] | None,
318
+ ) -> Generator[tuple[str, str, list[dict], int], None, None]:
319
+ """Generate summary with streaming output."""
320
+ tagged_text, tag_to_sentence, tag_to_pos = tag_sentences(text, lang_code)
321
+ num_sentences = len(tag_to_sentence)
322
+
323
+ prompt = build_prompt(tagged_text, words, language_name, custom_instruction)
324
+ messages = [{"role": "user", "content": prompt}]
325
+ tokenized = tokenizer.encode_chat_completion(ChatCompletionRequest(messages=messages))
326
+ inputs = torch.tensor([tokenized.tokens]).to(model.device)
327
+
328
+ streamer = TextIteratorStreamer(hf_tokenizer, skip_prompt=True, skip_special_tokens=True)
329
+
330
+ gen_kwargs = {
331
+ "input_ids": inputs,
332
+ "streamer": streamer,
333
+ "max_new_tokens": 4096,
334
+ "do_sample": False,
335
+ "pad_token_id": hf_tokenizer.eos_token_id,
336
+ }
337
+
338
+ thread = Thread(target=model.generate, kwargs=gen_kwargs)
339
+ thread.start()
340
+
341
+ full_output = ""
342
+ for new_text in streamer:
343
+ full_output += new_text
344
+ summary, structure = extract_summary_and_structure(full_output)
345
+ processed, sources = process_output(summary, tag_to_sentence, tag_to_pos, page_ranges)
346
+ yield processed, structure, sources, num_sentences
347
+
348
+ thread.join()
349
+
350
+ summary, structure = extract_summary_and_structure(full_output)
351
+ processed, sources = process_output(summary, tag_to_sentence, tag_to_pos, page_ranges)
352
+ yield processed, structure, sources, num_sentences
353
+
354
+
355
+ # ============================================================================
356
+ # Main Summarization Function
357
+ # ============================================================================
358
+
359
+ def format_output(summary: str, structure: str) -> str:
360
+ """Format the summary with thinking section if available."""
361
+ if not summary and not structure:
362
+ return "*Your summary will appear here...*"
363
+
364
+ parts = []
365
+
366
+ if structure:
367
+ parts.append(f'<div class="thinking-section">\n\nπŸ’­ **Thinking**\n\n*{structure}*\n\n</div>')
368
+
369
+ if summary:
370
+ if structure:
371
+ parts.append('<div class="summary-divider"></div>')
372
+ parts.append(f'<div class="summary-section">\n\n{summary}\n\n</div>')
373
+
374
+ return "\n".join(parts)
375
+
376
+
377
+ def summarize(
378
+ pdf_file: str | None,
379
+ text_input: str | None,
380
+ language: str,
381
+ words: int,
382
+ custom_instruction: str,
383
+ progress=gr.Progress(),
384
+ ):
385
+ """Main entry point for summarization."""
386
+ page_ranges = None
387
+ lang_code = LANGUAGES.get(language, "en")
388
+
389
+ # Determine input source
390
+ if pdf_file:
391
+ progress(0.1, desc="πŸ“„ Extracting text from PDF...")
392
+ try:
393
+ text, page_ranges = extract_pdf_text(pdf_file)
394
+ except Exception as e:
395
+ yield (
396
+ f"❌ **Error extracting PDF:** {str(e)}",
397
+ "Please check that MISTRAL_API_KEY is set correctly.",
398
+ gr.update(visible=False),
399
+ )
400
+ return
401
+ elif text_input and text_input.strip():
402
+ text = text_input.strip()
403
+ else:
404
+ yield (
405
+ "⚠️ Please provide a PDF file or paste some text to summarize.",
406
+ "",
407
+ gr.update(visible=False),
408
+ )
409
+ return
410
+
411
+ if not text.strip():
412
+ yield (
413
+ "⚠️ No text content found in the document.",
414
+ "",
415
+ gr.update(visible=False),
416
+ )
417
+ return
418
+
419
+ progress(0.2, desc="πŸ€– Generating summary...")
420
+
421
+ for summary, structure, sources, _ in generate_summary(
422
+ text, lang_code, language, words, custom_instruction, page_ranges
423
+ ):
424
+ formatted_output = format_output(summary, structure)
425
+ sources_md = format_sources(sources, page_ranges is not None) if sources else "*Identifying sources...*"
426
+ yield formatted_output, sources_md, gr.update(visible=True)
427
+
428
+ progress(1.0, desc="βœ… Complete!")
429
+
430
+
431
+ # ============================================================================
432
+ # Theme & Styling
433
+ # ============================================================================
434
+
435
+ THEME = gr.themes.Soft(
436
+ primary_hue="indigo",
437
+ secondary_hue="blue",
438
+ neutral_hue="slate",
439
+ font=gr.themes.GoogleFont("Inter"),
440
+ ).set(
441
+ body_background_fill="linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
442
+ body_background_fill_dark="linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)",
443
+ block_background_fill="white",
444
+ block_background_fill_dark="#1e1e2e",
445
+ block_radius="12px",
446
+ block_shadow="0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
447
+ )
448
+
449
+ CSS = """
450
+ /* Full width layout */
451
+ .gradio-container {
452
+ max-width: 100% !important;
453
+ padding: 0 3rem !important;
454
+ }
455
+
456
+ /* Header styling */
457
+ .main-header {
458
+ text-align: center;
459
+ padding: 2.5rem 2rem;
460
+ background: rgba(30, 30, 46, 0.6);
461
+ backdrop-filter: blur(10px);
462
+ border-radius: 16px;
463
+ margin-bottom: 2rem;
464
+ border: 1px solid rgba(255, 255, 255, 0.1);
465
+ }
466
+
467
+ .main-header h1 {
468
+ font-size: 2.75rem;
469
+ font-weight: 700;
470
+ background: linear-gradient(135deg, #a5b4fc 0%, #c4b5fd 100%);
471
+ -webkit-background-clip: text;
472
+ -webkit-text-fill-color: transparent;
473
+ background-clip: text;
474
+ margin-bottom: 0.75rem;
475
+ }
476
+
477
+ .main-header p {
478
+ color: #94a3b8;
479
+ font-size: 1.15rem;
480
+ margin: 0;
481
+ }
482
+
483
+ /* Card styling */
484
+ .input-card, .output-card {
485
+ background: rgba(30, 30, 46, 0.8) !important;
486
+ border: 1px solid rgba(255, 255, 255, 0.08) !important;
487
+ border-radius: 16px !important;
488
+ padding: 1.5rem !important;
489
+ }
490
+
491
+ /* Section headers */
492
+ .section-title {
493
+ color: #e2e8f0 !important;
494
+ font-size: 1.1rem !important;
495
+ font-weight: 600 !important;
496
+ margin-bottom: 1rem !important;
497
+ display: flex;
498
+ align-items: center;
499
+ gap: 0.5rem;
500
+ }
501
+
502
+ /* Summary output */
503
+ .summary-text {
504
+ font-size: 1.05rem !important;
505
+ line-height: 1.85 !important;
506
+ color: #e2e8f0 !important;
507
+ background: transparent !important;
508
+ }
509
+
510
+ /* Thinking section */
511
+ .thinking-section {
512
+ background: rgba(99, 102, 241, 0.08);
513
+ border-left: 3px solid #6366f1;
514
+ border-radius: 0 8px 8px 0;
515
+ padding: 1rem 1.25rem;
516
+ margin-bottom: 0;
517
+ }
518
+
519
+ .thinking-section p {
520
+ color: #a5b4fc !important;
521
+ font-size: 0.95rem !important;
522
+ line-height: 1.7 !important;
523
+ margin: 0 !important;
524
+ }
525
+
526
+ /* Summary divider */
527
+ .summary-divider {
528
+ height: 1px;
529
+ background: linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.3), transparent);
530
+ margin: 1.5rem 0;
531
+ }
532
+
533
+ /* Summary section */
534
+ .summary-section {
535
+ padding-top: 0.5rem;
536
+ }
537
+
538
+ /* Sources panel - fixed styling */
539
+ .sources-panel {
540
+ background: #252536 !important;
541
+ border-radius: 12px !important;
542
+ padding: 1.25rem !important;
543
+ font-size: 0.95rem !important;
544
+ max-height: 400px !important;
545
+ overflow-y: auto !important;
546
+ color: #cbd5e1 !important;
547
+ border: 1px solid rgba(255, 255, 255, 0.06) !important;
548
+ }
549
+
550
+ .sources-panel p {
551
+ color: #cbd5e1 !important;
552
+ margin-bottom: 0.75rem !important;
553
+ padding: 0.5rem 0 !important;
554
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05) !important;
555
+ }
556
+
557
+ .sources-panel p:last-child {
558
+ border-bottom: none !important;
559
+ margin-bottom: 0 !important;
560
+ }
561
+
562
+ .sources-panel strong {
563
+ color: #a5b4fc !important;
564
+ }
565
+
566
+ /* Accordion styling */
567
+ .accordion {
568
+ background: transparent !important;
569
+ border: none !important;
570
+ }
571
+
572
+ .accordion > .label-wrap {
573
+ background: rgba(37, 37, 54, 0.6) !important;
574
+ border-radius: 10px !important;
575
+ padding: 0.75rem 1rem !important;
576
+ border: 1px solid rgba(255, 255, 255, 0.06) !important;
577
+ }
578
+
579
+ /* Generate button */
580
+ .generate-btn {
581
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
582
+ border: none !important;
583
+ font-weight: 600 !important;
584
+ font-size: 1.05rem !important;
585
+ padding: 0.875rem 2rem !important;
586
+ border-radius: 10px !important;
587
+ transition: all 0.2s ease !important;
588
+ box-shadow: 0 4px 14px rgba(99, 102, 241, 0.4) !important;
589
+ }
590
+
591
+ .generate-btn:hover {
592
+ transform: translateY(-2px) !important;
593
+ box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5) !important;
594
+ }
595
+
596
+ /* Input fields */
597
+ .dark input, .dark textarea {
598
+ background: #252536 !important;
599
+ border: 1px solid rgba(255, 255, 255, 0.1) !important;
600
+ color: #e2e8f0 !important;
601
+ border-radius: 8px !important;
602
+ }
603
+
604
+ .dark input:focus, .dark textarea:focus {
605
+ border-color: #6366f1 !important;
606
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2) !important;
607
+ }
608
+
609
+ /* Dropdown */
610
+ .dark select {
611
+ background: #252536 !important;
612
+ border: 1px solid rgba(255, 255, 255, 0.1) !important;
613
+ color: #e2e8f0 !important;
614
+ }
615
+
616
+ /* Slider */
617
+ .dark .slider {
618
+ background: #252536 !important;
619
+ }
620
+
621
+ /* File upload */
622
+ .dark .upload-button {
623
+ background: #252536 !important;
624
+ border: 2px dashed rgba(255, 255, 255, 0.15) !important;
625
+ border-radius: 12px !important;
626
+ }
627
+
628
+ .dark .upload-button:hover {
629
+ border-color: #6366f1 !important;
630
+ background: rgba(99, 102, 241, 0.1) !important;
631
+ }
632
+
633
+ /* Divider */
634
+ .divider {
635
+ color: #64748b !important;
636
+ text-align: center;
637
+ margin: 1rem 0;
638
+ }
639
+
640
+ /* Footer */
641
+ .footer {
642
+ text-align: center;
643
+ padding: 1.5rem;
644
+ margin-top: 2rem;
645
+ color: #94a3b8;
646
+ font-size: 0.9rem;
647
+ }
648
+
649
+ .footer a {
650
+ color: #a5b4fc;
651
+ text-decoration: none;
652
+ transition: color 0.2s;
653
+ }
654
+
655
+ .footer a:hover {
656
+ color: #c4b5fd;
657
+ text-decoration: underline;
658
+ }
659
+
660
+ .footer-divider {
661
+ color: #475569;
662
+ margin: 0 0.5rem;
663
+ }
664
+
665
+ /* Hide default footer */
666
+ footer {
667
+ display: none !important;
668
+ }
669
+
670
+ /* Responsive adjustments */
671
+ @media (max-width: 768px) {
672
+ .gradio-container {
673
+ padding: 0 1rem !important;
674
+ }
675
+
676
+ .main-header h1 {
677
+ font-size: 2rem;
678
+ }
679
+ }
680
+ """
681
+
682
+
683
+ # ============================================================================
684
+ # Gradio Interface
685
+ # ============================================================================
686
+
687
+ FORCE_DARK_MODE_JS = """
688
+ () => {
689
+ document.body.classList.remove('light');
690
+ document.body.classList.add('dark');
691
+ }
692
+ """
693
+
694
+
695
+ def create_app() -> gr.Blocks:
696
+ """Create the Gradio application."""
697
+ with gr.Blocks(title="sui-1 Summarizer", theme=THEME, css=CSS, js=FORCE_DARK_MODE_JS) as app:
698
+
699
+ # Header
700
+ gr.HTML("""
701
+ <div class="main-header">
702
+ <h1>πŸ“„ sui-1 Summarizer</h1>
703
+ <p>Generate grounded summaries with verifiable source citations</p>
704
+ </div>
705
+ """)
706
+
707
+ with gr.Row(equal_height=False):
708
+ # Left column: Inputs
709
+ with gr.Column(scale=1):
710
+ gr.Markdown("### πŸ“₯ Input", elem_classes=["section-title"])
711
+
712
+ pdf_input = gr.File(
713
+ label="Upload PDF",
714
+ file_types=[".pdf"],
715
+ type="filepath",
716
+ )
717
+
718
+ gr.Markdown("<div class='divider'>β€” or β€”</div>")
719
+
720
+ text_input = gr.Textbox(
721
+ label="Paste Text",
722
+ placeholder="Paste your document text here...",
723
+ lines=8,
724
+ max_lines=15,
725
+ )
726
+
727
+ gr.Markdown("### βš™οΈ Settings", elem_classes=["section-title"])
728
+
729
+ language = gr.Dropdown(
730
+ choices=list(LANGUAGES.keys()),
731
+ value="English",
732
+ label="Output Language",
733
+ )
734
+
735
+ words = gr.Slider(
736
+ minimum=100,
737
+ maximum=800,
738
+ value=250,
739
+ step=50,
740
+ label="Target Word Count",
741
+ info="Longer summaries include more citations",
742
+ )
743
+
744
+ custom_instruction = gr.Textbox(
745
+ label="Custom Instructions (Optional)",
746
+ placeholder="e.g., 'Focus on methodology' or 'Use bullet points'",
747
+ lines=2,
748
+ )
749
+
750
+ generate_btn = gr.Button(
751
+ "✨ Generate Summary",
752
+ variant="primary",
753
+ size="lg",
754
+ elem_classes=["generate-btn"],
755
+ )
756
+
757
+ # Right column: Output
758
+ with gr.Column(scale=1):
759
+ gr.Markdown("### πŸ“‹ Summary", elem_classes=["section-title"])
760
+
761
+ summary_output = gr.Markdown(
762
+ value="*Your summary will appear here...*",
763
+ elem_classes=["summary-text"],
764
+ )
765
+
766
+ with gr.Accordion(
767
+ "πŸ“š Source Citations",
768
+ open=False,
769
+ visible=False,
770
+ ) as sources_accordion:
771
+ sources_output = gr.Markdown(elem_classes=["sources-panel"])
772
+
773
+ # Event handlers
774
+ generate_btn.click(
775
+ fn=summarize,
776
+ inputs=[pdf_input, text_input, language, words, custom_instruction],
777
+ outputs=[summary_output, sources_output, sources_accordion],
778
+ )
779
+
780
+ # Footer
781
+ gr.HTML("""
782
+ <div class="footer">
783
+ <strong>Model:</strong> <a href="https://huggingface.co/ellamind/sui-1-24b" target="_blank">ellamind/sui-1-24b</a>
784
+ <span class="footer-divider">β€’</span>
785
+ <strong>PDF OCR:</strong> <a href="https://docs.mistral.ai/capabilities/document_ai" target="_blank">Mistral OCR</a>
786
+ <span class="footer-divider">β€’</span>
787
+ Made with πŸ’œ by <a href="https://huggingface.co/ellamind" target="_blank">ellamind</a>
788
+ </div>
789
+ """)
790
+
791
+ return app
792
+
793
 
794
+ # ============================================================================
795
+ # Entry Point
796
+ # ============================================================================
797
 
798
+ if __name__ == "__main__":
799
+ app = create_app()
800
+ app.launch()
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ gradio>=5.0.0
2
+ spaces
3
+ spacy>=3.7.0
4
+ torch
5
+ transformers>=4.45.0
6
+ mistralai>=1.0.0
7
+ mistral_common>=1.5.0
8
+ accelerate