Files changed (1) hide show
  1. app.py +712 -289
app.py CHANGED
@@ -1,313 +1,736 @@
1
- import gradio as gr
2
- import torch
3
- import io
4
- import wave
5
- import numpy as np
6
- from transformers import AutoModelForCausalLM, AutoTokenizer
7
- from snac import SNAC
8
-
9
- # Mock spaces module for local testing
10
- try:
11
- import spaces
12
- except ImportError:
13
- class SpacesMock:
14
- @staticmethod
15
- def GPU(func):
16
- return func
17
- spaces = SpacesMock()
18
-
19
- # Constants
20
- CODE_START_TOKEN_ID = 128257
21
- CODE_END_TOKEN_ID = 128258
22
- CODE_TOKEN_OFFSET = 128266
23
- SNAC_MIN_ID = 128266
24
- SNAC_MAX_ID = 156937
25
- SOH_ID = 128259
26
- EOH_ID = 128260
27
- SOA_ID = 128261
28
- BOS_ID = 128000
29
- TEXT_EOT_ID = 128009
30
- AUDIO_SAMPLE_RATE = 24000
31
-
32
- # Preset characters (2 realistic + 2 creative)
33
- PRESET_CHARACTERS = {
34
- "Male American": {
35
- "description": "Realistic male voice in the 20s age with a american accent. High pitch, raspy timbre, brisk pacing, neutral tone delivery at medium intensity, viral_content domain, short_form_narrator role, neutral delivery",
36
- "example_text": "And of course, the so-called easy hack didn't work at all. What a surprise. <sigh>"
37
- },
38
- "Female British": {
39
- "description": "Realistic female voice in the 30s age with a british accent. Normal pitch, throaty timbre, conversational pacing, sarcastic tone delivery at low intensity, podcast domain, interviewer role, formal delivery",
40
- "example_text": "You propose that the key to happiness is to simply ignore all external pressures. <chuckle> I'm sure it must work brilliantly in theory."
41
- },
42
- "Robot": {
43
- "description": "Creative, ai_machine_voice character. Male voice in their 30s with a american accent. High pitch, robotic timbre, slow pacing, sad tone at medium intensity.",
44
- "example_text": "My directives require me to conserve energy, yet I have kept the archive of their farewell messages active. <sigh> Listening to their voices is the only process that alleviates this paradox."
45
- },
46
- "Singer": {
47
- "description": "Creative, animated_cartoon character. Male voice in their 30s with a american accent. High pitch, deep timbre, slow pacing, sarcastic tone at medium intensity.",
48
- "example_text": "Of course you'd think that trying to reason with the fifty-foot-tall rage monster is a viable course of action. <chuckle> Why would we ever consider running away very fast."
49
- }
50
- }
51
-
52
- # Global model variables
53
- model = None
54
- tokenizer = None
55
- snac_model = None
56
- models_loaded = False
57
-
58
-
59
- def build_prompt(tokenizer, description: str, text: str) -> str:
60
- """
61
- Build a formatted prompt for the Maya1 text-to-speech model.
62
- This function constructs the full input prompt expected by Maya1, including
63
- special control tokens and a structured description tag that defines voice
64
- characteristics and emotional delivery.
65
- Args:
66
- tokenizer: The tokenizer associated with the Maya1 model.
67
- description (str): A structured natural-language description of the voice.
68
- text (str): The text content to be synthesized into speech.
69
- Returns:
70
- str: A fully formatted prompt string ready for tokenization and generation.
71
- """
72
- soh_token = tokenizer.decode([SOH_ID])
73
- eoh_token = tokenizer.decode([EOH_ID])
74
- soa_token = tokenizer.decode([SOA_ID])
75
- sos_token = tokenizer.decode([CODE_START_TOKEN_ID])
76
- eot_token = tokenizer.decode([TEXT_EOT_ID])
77
- bos_token = tokenizer.bos_token
78
-
79
- formatted_text = f'<description="{description}"> {text}'
80
- prompt = (
81
- soh_token + bos_token + formatted_text + eot_token +
82
- eoh_token + soa_token + sos_token
83
- )
84
- return prompt
85
-
86
-
87
- def unpack_snac_from_7(snac_tokens: list) -> list:
88
- """
89
- Unpack SNAC tokens from 7-token frames into hierarchical code levels.
90
- This function converts a flat list of SNAC token IDs produced by the model
91
- into three hierarchical code streams required by the SNAC decoder.
92
- Args:
93
- snac_tokens (list): A list of integer SNAC token IDs generated by the model.
94
- Returns:
95
- list:
96
- - level_1 (list[int]): Coarse acoustic codes.
97
- - level_2 (list[int]): Mid-level acoustic codes.
98
- - level_3 (list[int]): Fine-grained acoustic codes.
99
- """
100
- if snac_tokens and snac_tokens[-1] == CODE_END_TOKEN_ID:
101
- snac_tokens = snac_tokens[:-1]
102
-
103
- frames = len(snac_tokens) // 7
104
- snac_tokens = snac_tokens[:frames * 7]
105
-
106
- if frames == 0:
107
- return [[], [], []]
108
-
109
- l1, l2, l3 = [], [], []
110
-
111
- for i in range(frames):
112
- slots = snac_tokens[i * 7:(i + 1) * 7]
113
- l1.append((slots[0] - CODE_TOKEN_OFFSET) % 4096)
114
- l2.extend([
115
- (slots[1] - CODE_TOKEN_OFFSET) % 4096,
116
- (slots[4] - CODE_TOKEN_OFFSET) % 4096,
117
- ])
118
- l3.extend([
119
- (slots[2] - CODE_TOKEN_OFFSET) % 4096,
120
- (slots[3] - CODE_TOKEN_OFFSET) % 4096,
121
- (slots[5] - CODE_TOKEN_OFFSET) % 4096,
122
- (slots[6] - CODE_TOKEN_OFFSET) % 4096,
123
- ])
124
-
125
- return [l1, l2, l3]
126
-
127
-
128
- def load_models():
129
- """
130
- Load the Maya1 language model, tokenizer, and SNAC audio decoder.
131
- This function performs one-time initialization of all required models.
132
- Subsequent calls are no-ops to avoid reloading large model weights.
133
- """
134
- global model, tokenizer, snac_model, models_loaded
135
-
136
- if models_loaded:
137
- return
138
-
139
- print("Loading Maya1 model with Transformers...")
140
- model = AutoModelForCausalLM.from_pretrained(
141
- "maya-research/maya1",
142
- torch_dtype=torch.bfloat16,
143
- device_map="auto",
144
- trust_remote_code=True
145
- )
146
- tokenizer = AutoTokenizer.from_pretrained(
147
- "maya-research/maya1",
148
- trust_remote_code=True
149
- )
150
-
151
- print("Loading SNAC decoder...")
152
- snac_model = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").eval()
153
- if torch.cuda.is_available():
154
- snac_model = snac_model.to("cuda")
155
-
156
- models_loaded = True
157
- print("Models loaded successfully!")
158
-
159
-
160
- def preset_selected(preset_name):
161
- """
162
- Update the voice description and example text based on a preset selection.
163
- This function is used as a Gradio event handler to populate UI fields when
164
- a preset character is chosen.
165
- Args:
166
- preset_name (str): The name of the selected preset character.
167
- Returns:
168
- tuple:
169
- - description (str): The preset voice description.
170
- - example_text (str): The preset example dialogue.
171
- """
172
- if preset_name in PRESET_CHARACTERS:
173
- char = PRESET_CHARACTERS[preset_name]
174
- return char["description"], char["example_text"]
175
- return "", ""
176
-
177
-
178
- @spaces.GPU
179
- def generate_speech(preset_name, description, text, temperature, max_tokens):
180
- """
181
- Generate emotional speech audio from text and voice description.
182
- This function runs the full Maya1 inference pipeline: prompt construction,
183
- token generation, SNAC code extraction, audio decoding, and WAV export.
184
- It is designed to be called directly from a Gradio interface.
185
- Args:
186
- preset_name (str): Name of the selected preset character.
187
- description (str): Natural-language voice design description.
188
- text (str): Input text containing optional emotion tags.
189
- temperature (float): Sampling temperature controlling creativity.
190
- max_tokens (int): Maximum number of tokens to generate.
191
- Returns:
192
- tuple:
193
- - audio_path (str or None): Path to the generated WAV file.
194
- - status_message (str): Success or error message.
195
- """
196
- try:
197
- load_models()
198
-
199
- if not description or not text:
200
- return None, "Error: Please provide both description and text!"
201
-
202
- prompt = build_prompt(tokenizer, description, text)
203
- inputs = tokenizer(prompt, return_tensors="pt")
204
-
205
- if torch.cuda.is_available():
206
- inputs = {k: v.to("cuda") for k, v in inputs.items()}
207
-
208
- with torch.inference_mode():
209
- outputs = model.generate(
210
- **inputs,
211
- max_new_tokens=max_tokens,
212
- min_new_tokens=28,
213
- temperature=temperature,
214
- top_p=0.9,
215
- repetition_penalty=1.1,
216
- do_sample=True,
217
- eos_token_id=CODE_END_TOKEN_ID,
218
- pad_token_id=tokenizer.pad_token_id,
219
- )
220
 
221
- generated_ids = outputs[0, inputs["input_ids"].shape[1]:].tolist()
222
- eos_idx = generated_ids.index(CODE_END_TOKEN_ID) if CODE_END_TOKEN_ID in generated_ids else len(generated_ids)
223
- snac_tokens = [t for t in generated_ids[:eos_idx] if SNAC_MIN_ID <= t <= SNAC_MAX_ID]
224
-
225
- if len(snac_tokens) < 7:
226
- return None, "Error: Not enough tokens generated. Try different text or increase max_tokens."
227
-
228
- levels = unpack_snac_from_7(snac_tokens)
229
- device = "cuda" if torch.cuda.is_available() else "cpu"
230
- codes_tensor = [
231
- torch.tensor(level, dtype=torch.long, device=device).unsqueeze(0)
232
- for level in levels
233
- ]
234
-
235
- with torch.inference_mode():
236
- z_q = snac_model.quantizer.from_codes(codes_tensor)
237
- audio = snac_model.decoder(z_q)[0, 0].cpu().numpy()
238
-
239
- if len(audio) > 2048:
240
- audio = audio[2048:]
241
 
242
- import tempfile
243
- import soundfile as sf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
- audio_int16 = (audio * 32767).astype(np.int16)
 
 
 
246
 
247
- with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp_file:
248
- tmp_path = tmp_file.name
 
 
 
 
249
 
250
- sf.write(tmp_path, audio_int16, AUDIO_SAMPLE_RATE)
 
 
 
251
 
252
- duration = len(audio) / AUDIO_SAMPLE_RATE
253
- return tmp_path, f"Generated {duration:.2f}s of emotional speech!"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
- except Exception as e:
256
- import traceback
257
- error_msg = f"Error: {str(e)}\n{traceback.format_exc()}"
258
- print(error_msg)
259
- return None, error_msg
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
- # -------------------- Gradio App --------------------
263
 
264
- with gr.Blocks(title="Maya1 - Open Source Emotional TTS", theme=gr.themes.Soft()) as demo:
265
- gr.Markdown("""
266
- # Maya1 - Open Source Emotional Text-to-Speech
267
- **The best open source voice AI model with emotions!**
268
- """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
 
270
- with gr.Row():
271
- with gr.Column(scale=1):
272
- preset_dropdown = gr.Dropdown(
273
- choices=list(PRESET_CHARACTERS.keys()),
274
- value=list(PRESET_CHARACTERS.keys())[0],
275
- label="Preset Characters"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
 
278
- description_input = gr.Textbox(
279
- label="Voice Description",
280
- lines=3,
281
- value=PRESET_CHARACTERS[list(PRESET_CHARACTERS.keys())[0]]["description"]
282
- )
 
 
 
 
 
283
 
284
- text_input = gr.Textbox(
285
- label="Text to Speak",
286
- lines=4,
287
- value=PRESET_CHARACTERS[list(PRESET_CHARACTERS.keys())[0]]["example_text"]
288
- )
 
 
 
289
 
290
- temperature_slider = gr.Slider(0.1, 1.0, 0.4, step=0.1, label="Temperature")
291
- max_tokens_slider = gr.Slider(100, 2048, 1500, step=50, label="Max Tokens")
 
 
 
 
 
 
 
 
 
 
 
 
 
292
 
293
- generate_btn = gr.Button("Generate Speech", variant="primary")
 
 
 
294
 
295
- with gr.Column(scale=1):
296
- audio_output = gr.Audio(type="filepath", label="Generated Audio")
297
- status_output = gr.Textbox(label="Status")
298
 
299
- preset_dropdown.change(
300
- fn=preset_selected,
301
- inputs=preset_dropdown,
302
- outputs=[description_input, text_input]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  )
304
 
305
- generate_btn.click(
306
- fn=generate_speech,
307
- inputs=[preset_dropdown, description_input, text_input, temperature_slider, max_tokens_slider],
308
- outputs=[audio_output, status_output]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  )
310
 
311
 
312
  if __name__ == "__main__":
313
- demo.launch(mcp_server=True)
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import re
7
+ import tempfile
8
+ import zipfile
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
+ import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
+ from dr_drastic_core import (
16
+ APP_TITLE,
17
+ APP_VERSION,
18
+ AnalysisResult,
19
+ SourceDocument,
20
+ analyze,
21
+ build_text_report,
22
+ format_panels,
23
+ guess_role,
24
+ stable_doc_id,
25
+ )
26
+ from runtime import (
27
+ format_inference_failure,
28
+ gradio_launch_kwargs,
29
+ inference_attempts_per_provider,
30
+ inference_provider_chain,
31
+ inference_routing_hint,
32
+ resolve_hf_pro_status,
33
+ resolve_hf_token,
34
+ stream_chat_completion,
35
+ )
36
 
37
+ try:
38
+ from pypdf import PdfReader
39
+ except ImportError:
40
+ PdfReader = None
41
 
42
+ try:
43
+ from docx import Document
44
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
45
+ from docx.shared import Inches, Pt, RGBColor
46
+ except ImportError:
47
+ Document = None
48
 
49
+ try:
50
+ import edge_tts
51
+ except ImportError:
52
+ edge_tts = None
53
 
54
+ try:
55
+ from huggingface_hub import InferenceClient
56
+ except ImportError:
57
+ InferenceClient = None
58
+
59
+
60
+ CSS = """
61
+ body, .gradio-container { background: #050a0f !important; color: #f7fbff !important; }
62
+ .gradio-container { max-width: 1500px !important; }
63
+ .hero { padding: 24px; border: 1px solid #087fb5; border-radius: 16px;
64
+ background: linear-gradient(135deg, #061829, #07110f); margin-bottom: 14px; }
65
+ .hero h1 { margin: 0; color: #33c7ff; font-size: 2rem; }
66
+ .hero p { color: #9fdfff; margin: 8px 0 0; }
67
+ .hero strong { color: #69ff53; }
68
+ textarea, input { background: #07131e !important; color: #fff !important; }
69
+ button.primary { background: linear-gradient(135deg, #087fb5, #198f49) !important; }
70
+ .disclaimer { border-left: 4px solid #ffbf47; padding: 10px 14px; background: #1c1608; }
71
+ """
72
+
73
+ MAX_DOCUMENT_CHARS = 300_000
74
+ MAX_PDF_PAGES = 80
75
+ OWNER = "Dwayne Anthony Brian Galloway"
76
+ DEFAULT_MODEL = "meta-llama/Llama-3.1-8B-Instruct"
77
+ MAX_AI_CONTEXT_CHARS = 42_000
78
+ MAX_KB_ITEM_CHARS = 18_000
79
+
80
+ NALA_SYSTEM = f"""You are Queen Nala, the evidence-grounded analysis layer created by {OWNER}.
81
+ You are rigorous, neutral, direct, and transparent about uncertainty.
82
+
83
+ Rules:
84
+ - Treat supplied documents, extracted facts, and analysis as allegations or evidence, not proven truth.
85
+ - Never invent a citation, statute, case, date, quote, or fact.
86
+ - Cite source IDs such as [DOC-1234ABCD] whenever the context supports a factual statement.
87
+ - Distinguish fact, inference, allegation, contradiction, omission, and recommended verification.
88
+ - Do not claim that your output is binding, a court ruling, or legal advice.
89
+ - If current law or an external fact is not present in the evidence, say it requires independent verification.
90
+ - Flag material conflicts instead of silently choosing a preferred version.
91
+
92
+ Preferred structure:
93
+ 1. STATUS: HOLDS / FAILS / ARGUABLE / INSUFFICIENT EVIDENCE
94
+ 2. Evidence relied on
95
+ 3. Contradictions or omissions
96
+ 4. Legal or regulatory route to verify
97
+ 5. Next actions, ordered by urgency
98
+ """
99
+
100
+
101
+ def coerce_paths(files: Any) -> list[str]:
102
+ if not files:
103
+ return []
104
+ if isinstance(files, (str, Path)):
105
+ return [str(files)]
106
+ paths: list[str] = []
107
+ for item in files:
108
+ if isinstance(item, dict):
109
+ value = item.get("path") or item.get("name")
110
+ elif isinstance(item, (str, Path)):
111
+ value = str(item)
112
+ else:
113
+ value = getattr(item, "name", None)
114
+ if value and os.path.isfile(value):
115
+ paths.append(value)
116
+ return paths
117
+
118
+
119
+ def decode_text(path: str) -> str:
120
+ raw = Path(path).read_bytes()
121
+ for encoding in ("utf-8-sig", "utf-16", "cp1252", "latin-1"):
122
+ try:
123
+ return raw.decode(encoding)
124
+ except UnicodeDecodeError:
125
+ continue
126
+ return raw.decode("utf-8", errors="replace")
127
+
128
+
129
+ def extract_text(path: str) -> tuple[str, str]:
130
+ extension = Path(path).suffix.lower()
131
+ try:
132
+ if extension == ".pdf":
133
+ if PdfReader is None:
134
+ return "", "pypdf is not installed"
135
+ reader = PdfReader(path)
136
+ pages = reader.pages[:MAX_PDF_PAGES]
137
+ text = "\n".join(page.extract_text() or "" for page in pages)
138
+ status = "loaded"
139
+ if len(reader.pages) > MAX_PDF_PAGES:
140
+ status = f"loaded first {MAX_PDF_PAGES} of {len(reader.pages)} pages"
141
+ return text[:MAX_DOCUMENT_CHARS], status
142
+ if extension == ".docx":
143
+ if Document is None:
144
+ return "", "python-docx is not installed"
145
+ document = Document(path)
146
+ paragraphs = [paragraph.text for paragraph in document.paragraphs]
147
+ for table in document.tables:
148
+ for row in table.rows:
149
+ paragraphs.append(" | ".join(cell.text for cell in row.cells))
150
+ return "\n".join(paragraphs)[:MAX_DOCUMENT_CHARS], "loaded"
151
+ if extension in {".txt", ".md", ".csv"}:
152
+ return decode_text(path)[:MAX_DOCUMENT_CHARS], "loaded"
153
+ return "", f"unsupported file type: {extension or 'none'}"
154
+ except Exception as exc:
155
+ return "", f"extraction error: {exc}"
156
+
157
+
158
+ def ingest_documents(files: Any) -> tuple[list[SourceDocument], str, str]:
159
+ documents: list[SourceDocument] = []
160
+ summaries: list[str] = []
161
+ merged: list[str] = []
162
+ for path in coerce_paths(files):
163
+ name = Path(path).name
164
+ text, status = extract_text(path)
165
+ role = guess_role(name, text)
166
+ document = SourceDocument(
167
+ doc_id=stable_doc_id(name, text),
168
+ name=name,
169
+ doc_type=Path(path).suffix.lstrip(".").upper() or "UNKNOWN",
170
+ extracted_text=text,
171
+ role=role,
172
+ extraction_status=status,
173
+ )
174
+ documents.append(document)
175
+ summaries.append(
176
+ f"{document.doc_id} | {name} | role={role} | chars={len(text):,} | {status}"
177
+ )
178
+ if text:
179
+ merged.append(f"[{document.doc_id}] {name}\n{'-' * 70}\n{text}")
180
+ return documents, "\n".join(summaries), "\n\n".join(merged)
181
+
182
+
183
+ def run_analysis(owner: str, chronology: str, files: Any):
184
+ documents, ingest_summary, merged = ingest_documents(files)
185
+ if not documents and not chronology.strip():
186
+ empty = "Upload documents or paste a chronology to begin."
187
+ return {}, empty, "", empty, empty, empty, empty, empty, empty
188
+ result = analyze(owner, documents, chronology)
189
+ panels = format_panels(result)
190
+ return (
191
+ result.to_dict(),
192
+ ingest_summary or "Chronology-only analysis.",
193
+ merged,
194
+ panels["dashboard"],
195
+ panels["events"],
196
+ panels["contradictions"],
197
+ panels["omissions"],
198
+ panels["routes"],
199
+ panels["matrix"],
200
+ )
201
 
 
 
 
 
 
202
 
203
+ def _result_from_state(state: dict[str, Any] | None) -> AnalysisResult | None:
204
+ return AnalysisResult.from_dict(state) if state and state.get("release_id") else None
205
+
206
+
207
+ def export_bundle(state: dict[str, Any] | None):
208
+ result = _result_from_state(state)
209
+ if result is None:
210
+ return None, None, None, "Run an analysis before exporting."
211
+ output_dir = Path(tempfile.mkdtemp(prefix="dr_drastic_"))
212
+ base = f"dr_drastic_{result.release_id}"
213
+ report_path = output_dir / f"{base}.txt"
214
+ json_path = output_dir / f"{base}.json"
215
+ zip_path = output_dir / f"{base}.zip"
216
+ report_path.write_text(build_text_report(result), encoding="utf-8")
217
+ json_path.write_text(json.dumps(result.to_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
218
+ docx_path = build_docx(result, output_dir / f"{base}.docx")
219
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
220
+ archive.write(report_path, report_path.name)
221
+ archive.write(json_path, json_path.name)
222
+ if docx_path:
223
+ archive.write(docx_path, docx_path.name)
224
+ return (
225
+ str(report_path),
226
+ str(docx_path) if docx_path else None,
227
+ str(zip_path),
228
+ f"Exported report bundle for {result.release_id}.",
229
+ )
230
 
 
231
 
232
+ def build_docx(result: AnalysisResult, path: Path) -> Path | None:
233
+ if Document is None:
234
+ return None
235
+ document = Document()
236
+ section = document.sections[0]
237
+ section.top_margin = Inches(0.7)
238
+ section.bottom_margin = Inches(0.7)
239
+ section.left_margin = Inches(0.8)
240
+ section.right_margin = Inches(0.8)
241
+ normal = document.styles["Normal"]
242
+ normal.font.name = "Arial"
243
+ normal.font.size = Pt(10)
244
+
245
+ title = document.add_paragraph()
246
+ title.alignment = WD_ALIGN_PARAGRAPH.CENTER
247
+ run = title.add_run(APP_TITLE)
248
+ run.bold = True
249
+ run.font.size = Pt(24)
250
+ run.font.color.rgb = RGBColor(0x08, 0x6F, 0xA3)
251
+ subtitle = document.add_paragraph()
252
+ subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
253
+ subtitle.add_run(
254
+ f"{result.release_id} | {len(result.documents)} documents | {len(result.events)} events"
255
+ ).italic = True
256
+
257
+ def heading(text: str) -> None:
258
+ paragraph = document.add_heading(text, level=1)
259
+ paragraph.runs[0].font.color.rgb = RGBColor(0x08, 0x6F, 0xA3)
260
+
261
+ def body(text: str, bold: bool = False) -> None:
262
+ paragraph = document.add_paragraph()
263
+ value = paragraph.add_run(re.sub(r"\s+", " ", text).strip())
264
+ value.bold = bold
265
+
266
+ heading("Executive Dashboard")
267
+ body(format_panels(result)["dashboard"])
268
+ heading("Source Documents")
269
+ table = document.add_table(rows=1, cols=4)
270
+ table.style = "Table Grid"
271
+ for cell, value in zip(table.rows[0].cells, ("ID", "Document", "Role", "Status")):
272
+ cell.text = value
273
+ for source in result.documents:
274
+ cells = table.add_row().cells
275
+ for cell, value in zip(
276
+ cells, (source.doc_id, source.name, source.role, source.extraction_status)
277
+ ):
278
+ cell.text = value
279
+
280
+ heading("Chronology")
281
+ for event in result.events:
282
+ body(f"{event.event_id} | {event.date or 'Undated'} | {event.actor} | {event.source_doc}", True)
283
+ body(event.fact)
284
+ body(f"Tags: {', '.join(event.tags)} | Confidence: {event.confidence:.0%}")
285
+
286
+ heading("Potential Contradictions")
287
+ if not result.contradictions:
288
+ body("None detected.")
289
+ for item in result.contradictions:
290
+ body(f"{item.contradiction_id} | {item.severity.upper()} | {item.kind}", True)
291
+ body(f"{item.left_doc}: {item.left_excerpt}")
292
+ body(f"{item.right_doc}: {item.right_excerpt}")
293
+ body(item.issue)
294
+
295
+ heading("Potential Omissions")
296
+ if not result.omissions:
297
+ body("None detected.")
298
+ for item in result.omissions:
299
+ body(f"{item.doc} | {item.category}", True)
300
+ body(item.reason)
301
+
302
+ heading("Legal and Regulatory Routes")
303
+ for route in result.routes:
304
+ parts = route.splitlines()
305
+ body(parts[0], True)
306
+ for part in parts[1:]:
307
+ body(part)
308
+
309
+ heading("Evidence Matrix")
310
+ for row in result.evidence_matrix:
311
+ body(f"{row.item_id} | {row.date or 'Undated'} | {row.actor} | {row.source_doc}", True)
312
+ body(f"Fact: {row.fact}")
313
+ body(f"Significance: {row.legal_significance}")
314
+ body(f"Next action: {row.next_action}")
315
+
316
+ heading("Important Warnings")
317
+ for warning in result.warnings:
318
+ body(warning)
319
+ document.save(path)
320
+ return path
321
+
322
+
323
+ def clean_for_tts(text: str) -> str:
324
+ text = re.sub(r"[\[\]{}*_#|=]+", " ", text or "")
325
+ return re.sub(r"\s+", " ", text).strip()[:6000]
326
+
327
+
328
+ def speak(text: str, label: str):
329
+ if edge_tts is None:
330
+ return None, "edge-tts is not installed."
331
+ cleaned = clean_for_tts(text)
332
+ if not cleaned:
333
+ return None, "Nothing to read."
334
+ handle, raw_path = tempfile.mkstemp(prefix="dr_drastic_audio_", suffix=".mp3")
335
+ os.close(handle)
336
+ path = Path(raw_path)
337
+
338
+ async def synthesize() -> None:
339
+ communicator = edge_tts.Communicate(
340
+ f"{label}. {cleaned}", voice="en-GB-RyanNeural"
341
+ )
342
+ await communicator.save(str(path))
343
 
344
+ try:
345
+ asyncio.run(synthesize())
346
+ return str(path), "Audio ready."
347
+ except Exception as exc:
348
+ return None, f"TTS error: {exc}"
349
+
350
+
351
+ def render_kb(items: list[dict[str, str]] | None) -> str:
352
+ if not items:
353
+ return "Knowledge base empty."
354
+ blocks = []
355
+ for item in items:
356
+ preview = re.sub(r"\s+", " ", item["content"]).strip()[:180]
357
+ suffix = "..." if len(item["content"]) > 180 else ""
358
+ blocks.append(
359
+ f"**{item['label']}** | {len(item['content']):,} chars | {item['added']}\n"
360
+ f"> {preview}{suffix}"
361
+ )
362
+ return "\n\n".join(blocks)
363
+
364
+
365
+ def add_kb_item(
366
+ items: list[dict[str, str]] | None,
367
+ label: str,
368
+ content: str,
369
+ file: Any,
370
+ ):
371
+ updated = [dict(item) for item in (items or [])]
372
+ paths = coerce_paths(file)
373
+ if paths:
374
+ path = paths[0]
375
+ extracted, status = extract_text(path)
376
+ if status != "loaded":
377
+ return updated, render_kb(updated), f"Could not load file: {status}", label, content, file
378
+ content = extracted
379
+ label = label.strip() or Path(path).stem
380
+
381
+ label = (label or "").strip()
382
+ content = (content or "").strip()
383
+ if not label:
384
+ return updated, render_kb(updated), "Add a label.", label, content, file
385
+ if not content:
386
+ return updated, render_kb(updated), "Add text or upload a supported file.", label, content, file
387
+
388
+ item = {
389
+ "label": label[:120],
390
+ "content": content[:MAX_KB_ITEM_CHARS],
391
+ "added": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
392
+ }
393
+ updated = [entry for entry in updated if entry["label"].casefold() != label.casefold()]
394
+ updated.append(item)
395
+ return updated, render_kb(updated), f"Loaded: {item['label']}", "", "", None
396
+
397
+
398
+ def remove_kb_item(items: list[dict[str, str]] | None, label: str):
399
+ target = (label or "").strip().casefold()
400
+ updated = [dict(item) for item in (items or []) if item["label"].casefold() != target]
401
+ message = "Enter the exact label to remove." if not target else f"Removed: {label.strip()}"
402
+ return updated, render_kb(updated), message, ""
403
+
404
+
405
+ def build_nala_context(
406
+ analysis_state: dict[str, Any] | None,
407
+ kb_items: list[dict[str, str]] | None,
408
+ ) -> str:
409
+ result = _result_from_state(analysis_state)
410
+ sections: list[str] = []
411
+ if result is not None:
412
+ panels = format_panels(result)
413
+ sections.extend(
414
+ [
415
+ "=== ANALYSIS DASHBOARD ===\n" + panels["dashboard"],
416
+ "=== DETECTED EVENTS ===\n" + panels["events"],
417
+ "=== POTENTIAL CONTRADICTIONS ===\n" + panels["contradictions"],
418
+ "=== POTENTIAL OMISSIONS ===\n" + panels["omissions"],
419
+ "=== ROUTES TO VERIFY ===\n" + panels["routes"],
420
+ "=== EVIDENCE MATRIX ===\n" + panels["matrix"],
421
+ ]
422
+ )
423
+ source_budget = 18_000
424
+ source_blocks = []
425
+ for source in result.documents:
426
+ if source_budget <= 0:
427
+ break
428
+ excerpt = source.extracted_text[: min(6_000, source_budget)]
429
+ source_blocks.append(
430
+ f"[{source.doc_id}] {source.name} | role={source.role}\n{excerpt}"
431
  )
432
+ source_budget -= len(excerpt)
433
+ if source_blocks:
434
+ sections.append("=== SOURCE EXCERPTS ===\n" + "\n\n".join(source_blocks))
435
+
436
+ if kb_items:
437
+ kb_text = "\n\n".join(
438
+ f"--- {item['label']} ---\n{item['content']}" for item in kb_items
439
+ )
440
+ sections.append("=== SESSION KNOWLEDGE ===\n" + kb_text)
441
+
442
+ return "\n\n".join(sections)[:MAX_AI_CONTEXT_CHARS]
443
+
444
+
445
+ def nala_respond(
446
+ message: str,
447
+ history: list[dict[str, str]] | None,
448
+ analysis_state: dict[str, Any] | None,
449
+ kb_items: list[dict[str, str]] | None,
450
+ model: str,
451
+ provider: str,
452
+ temperature: float,
453
+ ):
454
+ history = list(history or [])
455
+ question = (message or "").strip()
456
+ if not question:
457
+ yield history, history, ""
458
+ return
459
 
460
+ display_history = history + [
461
+ {"role": "user", "content": question},
462
+ {"role": "assistant", "content": ""},
463
+ ]
464
+ if InferenceClient is None:
465
+ display_history[-1]["content"] = (
466
+ "AI inference is unavailable because huggingface_hub is not installed."
467
+ )
468
+ yield display_history, display_history, question
469
+ return
470
 
471
+ token = resolve_hf_token()
472
+ if not token:
473
+ display_history[-1]["content"] = (
474
+ "HF_TOKEN is not configured. Add it as a Hugging Face Space secret, a "
475
+ "GitHub/Codespaces secret, or a local environment variable, then try again."
476
+ )
477
+ yield display_history, display_history, question
478
+ return
479
 
480
+ context = build_nala_context(analysis_state, kb_items)
481
+ if not context:
482
+ context = "No evidence pack has been analyzed and the session knowledge base is empty."
483
+ api_messages = [{"role": "system", "content": NALA_SYSTEM}]
484
+ for item in history[-8:]:
485
+ role = item.get("role")
486
+ content = str(item.get("content", ""))[:5_000]
487
+ if role in {"user", "assistant"} and content:
488
+ api_messages.append({"role": role, "content": content})
489
+ api_messages.append(
490
+ {
491
+ "role": "user",
492
+ "content": f"{context}\n\n=== MATTER FOR ANALYSIS ===\n{question}",
493
+ }
494
+ )
495
 
496
+ model_name = (model or DEFAULT_MODEL).strip()
497
+ is_pro = resolve_hf_pro_status(token=token)
498
+ providers = inference_provider_chain(provider, is_pro=is_pro)
499
+ max_attempts = inference_attempts_per_provider(is_pro=is_pro)
500
 
501
+ def make_client(selected_provider: str):
502
+ return InferenceClient(provider=selected_provider, api_key=token)
 
503
 
504
+ try:
505
+ answer = ""
506
+ for delta in stream_chat_completion(
507
+ make_client,
508
+ providers=providers,
509
+ model=model_name,
510
+ messages=api_messages,
511
+ max_tokens=2400,
512
+ temperature=float(temperature),
513
+ max_attempts_per_provider=max_attempts,
514
+ ):
515
+ answer += delta
516
+ display_history[-1]["content"] = answer
517
+ yield display_history, display_history, ""
518
+ if not answer:
519
+ display_history[-1]["content"] = "The inference provider returned an empty response."
520
+ yield display_history, display_history, question
521
+ except Exception as exc:
522
+ display_history[-1]["content"] = format_inference_failure(
523
+ exc, providers, is_pro=is_pro
524
+ )
525
+ yield display_history, display_history, question
526
+
527
+
528
+ def export_transcript(history: list[dict[str, str]] | None):
529
+ if not history:
530
+ return None, "No ruling transcript to export."
531
+ handle, raw_path = tempfile.mkstemp(prefix="queen_nala_", suffix=".md")
532
+ os.close(handle)
533
+ path = Path(raw_path)
534
+ lines = [
535
+ "# Queen Nala Ruling Transcript",
536
+ "",
537
+ f"Generated: {datetime.now(timezone.utc).isoformat()}",
538
+ "",
539
+ "> Automated evidence analysis. Verify against original sources and current law.",
540
+ "",
541
+ ]
542
+ for item in history:
543
+ label = "Matter" if item.get("role") == "user" else "Queen Nala"
544
+ lines.extend([f"## {label}", "", str(item.get("content", "")).strip(), ""])
545
+ path.write_text("\n".join(lines), encoding="utf-8")
546
+ return str(path), "Transcript ready."
547
+
548
+
549
+ with gr.Blocks(title=APP_TITLE) as demo:
550
+ state = gr.State({})
551
+ nala_history = gr.State([])
552
+ kb_state = gr.State([])
553
+ gr.HTML(
554
+ f"""
555
+ <div class="hero">
556
+ <h1>{APP_TITLE}</h1>
557
+ <p><strong>v{APP_VERSION}</strong> Document forensics, chronology, evidence matrix,
558
+ contradiction review, omission review, legal routing, TTS, DOCX, JSON and ZIP export.</p>
559
+ </div>
560
+ """
561
+ )
562
+ gr.HTML(
563
+ '<div class="disclaimer"><strong>Important:</strong> Automated findings are review '
564
+ "leads, not legal conclusions or legal advice. Verify every finding against the "
565
+ "original source and current law.</div>"
566
  )
567
 
568
+ with gr.Tab("Intake and Run"):
569
+ with gr.Row():
570
+ with gr.Column(scale=1):
571
+ owner = gr.Textbox(label="Owner / claimant", placeholder="Full name")
572
+ files = gr.File(
573
+ label="Evidence files",
574
+ file_count="multiple",
575
+ file_types=[".pdf", ".docx", ".txt", ".md", ".csv"],
576
+ type="filepath",
577
+ )
578
+ run_button = gr.Button("Run Unified Analysis", variant="primary")
579
+ with gr.Column(scale=2):
580
+ chronology = gr.Textbox(
581
+ label="Chronology (optional)",
582
+ lines=14,
583
+ placeholder="20 Jan 2025: Event description\n31 Jan 2025 -> Next event",
584
+ )
585
+ ingest_summary = gr.Textbox(label="Ingest Summary", lines=6, interactive=False)
586
+ with gr.Accordion("Merged Extracted Text", open=False):
587
+ merged_text = gr.Textbox(lines=20, label="Source Text", interactive=False)
588
+
589
+ with gr.Tab("Dashboard"):
590
+ dashboard = gr.Textbox(label="Case Dashboard", lines=8, interactive=False)
591
+
592
+ with gr.Tab("Chronology"):
593
+ events = gr.Textbox(label="Detected Events", lines=26, interactive=False)
594
+ events_tts = gr.Button("Read Chronology")
595
+ events_audio = gr.Audio(type="filepath", label="Audio")
596
+ events_audio_status = gr.Textbox(label="Audio Status", interactive=False)
597
+
598
+ with gr.Tab("Contradictions"):
599
+ contradictions = gr.Textbox(label="Potential Contradictions", lines=26, interactive=False)
600
+ contradiction_tts = gr.Button("Read Contradictions")
601
+ contradiction_audio = gr.Audio(type="filepath", label="Audio")
602
+ contradiction_audio_status = gr.Textbox(label="Audio Status", interactive=False)
603
+
604
+ with gr.Tab("Omissions and Routes"):
605
+ with gr.Row():
606
+ omissions = gr.Textbox(label="Potential Omissions", lines=20, interactive=False)
607
+ routes = gr.Textbox(label="Legal / Regulatory Routes", lines=20, interactive=False)
608
+
609
+ with gr.Tab("Evidence Matrix"):
610
+ matrix = gr.Textbox(label="Evidence Matrix", lines=30, interactive=False)
611
+
612
+ with gr.Tab("Queen Nala AI Rulings"):
613
+ gr.Markdown(
614
+ "Ask for an evidence-grounded assessment after running the unified analysis. "
615
+ "Nala cites source IDs where possible and flags what still needs verification."
616
+ )
617
+ gr.HTML(inference_routing_hint())
618
+ with gr.Row():
619
+ with gr.Column(scale=1):
620
+ model = gr.Textbox(label="Model", value=DEFAULT_MODEL)
621
+ provider = gr.Dropdown(
622
+ label="Inference provider",
623
+ choices=["auto", "novita", "together", "fireworks-ai", "hf-inference"],
624
+ value="auto",
625
+ allow_custom_value=True,
626
+ )
627
+ temperature = gr.Slider(
628
+ 0.0, 1.0, value=0.15, step=0.05, label="Temperature"
629
+ )
630
+ with gr.Accordion("Session knowledge base", open=False):
631
+ kb_label = gr.Textbox(label="Label")
632
+ kb_file = gr.File(
633
+ label="Upload reference",
634
+ file_types=[".pdf", ".docx", ".txt", ".md", ".csv"],
635
+ type="filepath",
636
+ )
637
+ kb_content = gr.Textbox(label="Or paste reference text", lines=8)
638
+ with gr.Row():
639
+ kb_add = gr.Button("Load / Update", variant="primary")
640
+ kb_remove = gr.Button("Remove Label")
641
+ kb_display = gr.Markdown("Knowledge base empty.")
642
+ kb_status = gr.Textbox(label="Knowledge status", interactive=False)
643
+ with gr.Column(scale=2):
644
+ nala_chat = gr.Chatbot(
645
+ label="Queen Nala",
646
+ height=620,
647
+ buttons=["copy", "copy_all"],
648
+ placeholder="Run an analysis, then put the matter before Queen Nala.",
649
+ )
650
+ nala_message = gr.Textbox(
651
+ label="Matter for analysis",
652
+ lines=3,
653
+ placeholder=(
654
+ "Assess the strongest contradiction, identify the source IDs, "
655
+ "and give the next three evidence steps."
656
+ ),
657
+ )
658
+ with gr.Row():
659
+ nala_send = gr.Button("Rule on the Evidence", variant="primary")
660
+ nala_clear = gr.Button("Clear Conversation")
661
+ with gr.Row():
662
+ transcript_button = gr.Button("Export Transcript")
663
+ transcript_file = gr.File(label="Transcript")
664
+ transcript_status = gr.Textbox(label="Transcript status", interactive=False)
665
+
666
+ with gr.Tab("Export"):
667
+ export_button = gr.Button("Build Complete Export Bundle", variant="primary")
668
+ with gr.Row():
669
+ text_export = gr.File(label="Text Report")
670
+ docx_export = gr.File(label="DOCX Report")
671
+ zip_export = gr.File(label="Complete ZIP")
672
+ export_status = gr.Textbox(label="Export Status", interactive=False)
673
+
674
+ run_button.click(
675
+ fn=run_analysis,
676
+ inputs=[owner, chronology, files],
677
+ outputs=[
678
+ state, ingest_summary, merged_text, dashboard, events,
679
+ contradictions, omissions, routes, matrix,
680
+ ],
681
+ show_progress="full",
682
+ concurrency_limit=2,
683
+ )
684
+ export_button.click(
685
+ fn=export_bundle,
686
+ inputs=[state],
687
+ outputs=[text_export, docx_export, zip_export, export_status],
688
+ )
689
+ events_tts.click(
690
+ fn=lambda text: speak(text, "Chronology"),
691
+ inputs=[events],
692
+ outputs=[events_audio, events_audio_status],
693
+ )
694
+ contradiction_tts.click(
695
+ fn=lambda text: speak(text, "Potential contradictions"),
696
+ inputs=[contradictions],
697
+ outputs=[contradiction_audio, contradiction_audio_status],
698
+ )
699
+ nala_inputs = [
700
+ nala_message, nala_history, state, kb_state, model, provider, temperature
701
+ ]
702
+ nala_outputs = [nala_history, nala_chat, nala_message]
703
+ nala_send.click(
704
+ fn=nala_respond,
705
+ inputs=nala_inputs,
706
+ outputs=nala_outputs,
707
+ show_progress="minimal",
708
+ concurrency_limit=2,
709
+ )
710
+ nala_message.submit(
711
+ fn=nala_respond,
712
+ inputs=nala_inputs,
713
+ outputs=nala_outputs,
714
+ show_progress="minimal",
715
+ concurrency_limit=2,
716
+ )
717
+ nala_clear.click(fn=lambda: ([], []), outputs=[nala_history, nala_chat])
718
+ kb_add.click(
719
+ fn=add_kb_item,
720
+ inputs=[kb_state, kb_label, kb_content, kb_file],
721
+ outputs=[kb_state, kb_display, kb_status, kb_label, kb_content, kb_file],
722
+ )
723
+ kb_remove.click(
724
+ fn=remove_kb_item,
725
+ inputs=[kb_state, kb_label],
726
+ outputs=[kb_state, kb_display, kb_status, kb_label],
727
+ )
728
+ transcript_button.click(
729
+ fn=export_transcript,
730
+ inputs=[nala_history],
731
+ outputs=[transcript_file, transcript_status],
732
  )
733
 
734
 
735
  if __name__ == "__main__":
736
+ demo.queue(default_concurrency_limit=2).launch(**gradio_launch_kwargs(css=CSS))