argyrotsipi commited on
Commit
5262c26
Β·
verified Β·
1 Parent(s): 48c01d8

Upload 6 files

Browse files
Files changed (6) hide show
  1. README.md +164 -6
  2. app.py +534 -0
  3. prompt_templates.py +137 -0
  4. requirements.txt +8 -0
  5. sample_data.json +152 -0
  6. utils.py +292 -0
README.md CHANGED
@@ -1,11 +1,169 @@
1
  ---
2
- title: ParliaBench
3
- emoji: πŸ“Š
4
- colorFrom: pink
5
  colorTo: indigo
6
- sdk: docker
7
- pinned: false
 
 
8
  license: mit
 
 
 
 
 
 
 
 
 
 
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: ParliaBench Demo
3
+ emoji: πŸ›οΈ
4
+ colorFrom: blue
5
  colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: 4.36.0
8
+ app_file: app.py
9
+ pinned: true
10
  license: mit
11
+ tags:
12
+ - nlp
13
+ - text-generation
14
+ - political-speech
15
+ - parliamentary
16
+ - fine-tuning
17
+ - lora
18
+ - qlora
19
+ - benchmark
20
+ - peft
21
  ---
22
 
23
+ # πŸ›οΈ ParliaBench β€” UK Parliamentary Speech Generation
24
+
25
+ Interactive inference demo for **ParliaBench**, a benchmark framework for evaluating
26
+ LLM-generated UK parliamentary speeches.
27
+
28
+ **Paper:** *ParliaBench: An Evaluation and Benchmarking Framework for LLM-Generated Parliamentary Speech*
29
+ **Authors:** Marios Koniaris, Argyro Tsipi, Panayiotis Tsanakas Β· NTUA
30
+ **arXiv:** [2511.08247](https://arxiv.org/abs/2511.08247)
31
+
32
+ ---
33
+
34
+ ## What This Space Does
35
+
36
+ 1. **Generate synthetic parliamentary speeches** conditioned on party, EuroVoc topic,
37
+ parliamentary section, house, and political orientation
38
+ 2. **Inspect the exact prompt** sent to each model (chat template tokens included)
39
+ 3. **Browse curated samples** β€” synthetic vs real ParlaMint-GB speeches side by side
40
+ 4. **Tune generation parameters** (temperature, top-p, repetition penalty)
41
+
42
+ ---
43
+
44
+ ## Models
45
+
46
+ Five LLMs fine-tuned on ParlaMint-GB via **QLoRA** (Unsloth framework):
47
+
48
+ | Display name | Base model (Unsloth 4-bit) | Fine-tuned repo |
49
+ |---|---|---|
50
+ | Mistral-7B | `unsloth/mistral-7b-v0.3-bnb-4bit` | [`argyrotsipi/parliabench-unsloth-mistral-7b-v0.3`](https://huggingface.co/argyrotsipi/parliabench-unsloth-mistral-7b-v0.3) |
51
+ | Llama-3.1-8B | `unsloth/Meta-Llama-3.1-8B-bnb-4bit` | [`argyrotsipi/parliabench-unsloth-llama-3.1-8b`](https://huggingface.co/argyrotsipi/parliabench-unsloth-llama-3.1-8b) |
52
+ | Gemma-2-9B | `unsloth/gemma-2-9b-bnb-4bit` | [`argyrotsipi/parliabench-unsloth-gemma-2-9b`](https://huggingface.co/argyrotsipi/parliabench-unsloth-gemma-2-9b) |
53
+ | Qwen2-7B | `unsloth/Qwen2-7B-bnb-4bit` | [`argyrotsipi/parliabench-unsloth-qwen-2-7b`](https://huggingface.co/argyrotsipi/parliabench-unsloth-qwen-2-7b) |
54
+ | Yi-1.5-**6B** | `unsloth/Yi-1.5-6B-bnb-4bit` | [`argyrotsipi/parliabench-unsloth-yi-1.5-6b`](https://huggingface.co/argyrotsipi/parliabench-unsloth-yi-1.5-6b) |
55
+
56
+ Baseline (non-fine-tuned) versions are also selectable for direct comparison.
57
+
58
+ ---
59
+
60
+ ## Datasets
61
+
62
+ | Repo | Contents |
63
+ |---|---|
64
+ | [`argyrotsipi/train-dataset`](https://huggingface.co/datasets/argyrotsipi/train-dataset) | ParlaMint-GB training split (preprocessed) |
65
+ | [`argyrotsipi/generated-dataset`](https://huggingface.co/datasets/argyrotsipi/generated-dataset) | 27 560 generated speeches + evaluation results |
66
+
67
+ ---
68
+
69
+ ## LoRA Training Configuration
70
+
71
+ | Parameter | Value |
72
+ |---|---|
73
+ | LoRA rank (r) | 16 |
74
+ | LoRA alpha | 16 |
75
+ | Target modules | q, k, v, o, gate, up, down projections |
76
+ | Dropout | 0 |
77
+ | Batch size | 64 |
78
+ | Learning rate | 2e-4 |
79
+ | Optimizer | AdamW fused |
80
+ | Max steps | 11 194 (~2 epochs) |
81
+ | Warmup steps | 336 |
82
+ | Max seq length | 1 024 |
83
+ | Framework | Unsloth + SFTTrainer (TRL) |
84
+
85
+ ---
86
+
87
+ ## Prompt Structure
88
+
89
+ **System prompt** (generation):
90
+ ```
91
+ You are a seasoned UK parliamentary member. Generate a coherent speech of
92
+ {min_words}-{max_words} words in standard English (no Unicode artifacts, no special characters).
93
+ Use proper British parliamentary language appropriate for the specified House.
94
+ The speech should reflect the political orientation and typical positions of the
95
+ specified party on the given topic.
96
+ ```
97
+
98
+ **Context string** (pipe-separated, matches generation code exactly):
99
+ ```
100
+ EUROVOC TOPIC: {topic} | SECTION: {section} | PARTY: {party} | POLITICAL ORIENTATION: {orientation} | HOUSE: {house}
101
+ ```
102
+
103
+ Each model wraps these in its own chat template (Mistral `[INST]`, Llama header tokens,
104
+ Gemma `<start_of_turn>`, Qwen/Yi ChatML).
105
+
106
+ ---
107
+
108
+ ## Generation Parameters (thesis defaults)
109
+
110
+ | Parameter | Value | Notes |
111
+ |---|---|---|
112
+ | Temperature | 0.7 | Balances coherence and diversity |
113
+ | Top-p | 0.85 | Nucleus sampling |
114
+ | Repetition penalty | 1.2 | Penalises redundant phrasing |
115
+ | Max new tokens | 850 | 1.33 Γ— P90 speech length |
116
+ | Min words (P10) | 43 | Lower quality threshold |
117
+ | Max words (P90) | 635 | Upper quality threshold |
118
+ | Batch size | 32 | Used in full generation runs |
119
+
120
+ ---
121
+
122
+ ## Evaluation Framework
123
+
124
+ 27 560 speeches evaluated across three dimensions:
125
+
126
+ ### Linguistic Quality
127
+ Perplexity Β· Self-BLEU Β· Distinct-n Β· GRUEN Score Β· BERTScore Β· MoverScore
128
+
129
+ ### Semantic Coherence
130
+ LLM-as-a-Judge (coherence, conciseness, relevance) via FlowJudge-v0.1 (3.8B)
131
+
132
+ ### Political Authenticity ← novel metrics
133
+ - **Political Spectrum Alignment (PSA)** β€” embedding cosine similarity to spectrum axis
134
+ - **Party Alignment** β€” cosine similarity to real party speech embeddings
135
+ - LLM-as-a-Judge (authenticity, political appropriateness, overall quality)
136
+
137
+ Statistical analysis: paired t-tests, independent t-tests, one-way ANOVA, Bonferroni correction.
138
+
139
+ ---
140
+
141
+ ## Space File Structure
142
+
143
+ ```
144
+ argyrotsipi/ParliaBench/
145
+ β”œβ”€β”€ app.py # Gradio UI + inference pipeline
146
+ β”œβ”€β”€ utils.py # Party data, topic lists, validator (from SpeechValidator)
147
+ β”œβ”€β”€ prompt_templates.py # Model-specific chat templates (exact from speech_generator.py)
148
+ β”œβ”€β”€ sample_data.json # 7 curated demo speeches
149
+ β”œβ”€β”€ requirements.txt
150
+ └── README.md
151
+ ```
152
+
153
+ Training code β†’ GitHub
154
+ Datasets β†’ [`argyrotsipi/train-dataset`](https://huggingface.co/datasets/argyrotsipi/train-dataset) Β· [`argyrotsipi/generated-dataset`](https://huggingface.co/datasets/argyrotsipi/generated-dataset)
155
+
156
+ ---
157
+
158
+ ## Citation
159
+
160
+ ```bibtex
161
+ @article{koniaris2025parliabench,
162
+ title = {ParliaBench: An Evaluation and Benchmarking Framework for
163
+ LLM-Generated Parliamentary Speech},
164
+ author = {Koniaris, Marios and Tsipi, Argyro and Tsanakas, Panayiotis},
165
+ journal = {arXiv preprint arXiv:2511.08247},
166
+ year = {2025},
167
+ url = {https://arxiv.org/abs/2511.08247}
168
+ }
169
+ ```
app.py ADDED
@@ -0,0 +1,534 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ParliaBench Demo β€” Hugging Face Space
3
+ Interactive inference demo for LLM-generated UK parliamentary speeches.
4
+
5
+ Based on:
6
+ "ParliaBench: An Evaluation and Benchmarking Framework for
7
+ LLM-Generated Parliamentary Speech"
8
+ Argyro Tsipi, NTUA Diploma Thesis, October 2025
9
+
10
+ Repos:
11
+ Models β†’ argyro/parliabench-{model}-lora
12
+ Dataset β†’ argyro/parliabench-gb-processed
13
+ Space β†’ argyro/parliabench-demo
14
+ """
15
+
16
+ import json
17
+ import re
18
+ import time
19
+
20
+ import gradio as gr
21
+ import torch
22
+ from transformers import AutoTokenizer, AutoModelForCausalLM
23
+
24
+ from utils import (
25
+ PARTIES, EUROVOC_TOPICS, HOUSES, MODELS, MODEL_FAMILY, MODEL_CONFIG,
26
+ DEFAULT_GEN_PARAMS, get_valid_houses, get_orientation,
27
+ build_context_string, count_tokens_approx, validate_speech,
28
+ )
29
+ from prompt_templates import build_full_prompt
30
+
31
+ # ─── Model cache ──────────────────────────────────────────────────────────────
32
+ _model_cache: dict = {}
33
+
34
+
35
+ def _load_model_and_tokenizer(model_display_name: str):
36
+ """Load (and cache) model + tokenizer for the given display name."""
37
+ if model_display_name in _model_cache:
38
+ return _model_cache[model_display_name]
39
+
40
+ repo_id = MODELS[model_display_name]
41
+ family = MODEL_FAMILY[model_display_name]
42
+ is_ft = "fine-tuned" in model_display_name
43
+ base_repo = MODEL_CONFIG[family]["base_model"]
44
+
45
+ tokenizer = AutoTokenizer.from_pretrained(repo_id, trust_remote_code=True)
46
+ if tokenizer.pad_token is None:
47
+ tokenizer.pad_token = tokenizer.eos_token
48
+
49
+ device_map = "auto" if torch.cuda.is_available() else None
50
+ dtype = torch.float16 if torch.cuda.is_available() else torch.float32
51
+
52
+ if is_ft:
53
+ # Load base model, then apply LoRA adapter
54
+ from peft import PeftModel
55
+ base = AutoModelForCausalLM.from_pretrained(
56
+ base_repo, torch_dtype=dtype, device_map=device_map,
57
+ trust_remote_code=True,
58
+ )
59
+ model = PeftModel.from_pretrained(base, repo_id)
60
+ else:
61
+ model = AutoModelForCausalLM.from_pretrained(
62
+ repo_id, torch_dtype=dtype, device_map=device_map,
63
+ trust_remote_code=True,
64
+ )
65
+
66
+ model.eval()
67
+ _model_cache[model_display_name] = (model, tokenizer)
68
+ return model, tokenizer
69
+
70
+
71
+ # ─── Speech extraction (mirrors extract_speech in speech_generator.py) ────────
72
+
73
+ def _extract_speech(raw_text: str, family: str) -> str:
74
+ """Extract clean speech from raw decoded model output."""
75
+ cfg = MODEL_CONFIG[family]
76
+
77
+ # Find start marker
78
+ start = cfg["start_marker"]
79
+ if start in raw_text:
80
+ parts = raw_text.split(start)
81
+ speech = parts[-1].lstrip("\n")
82
+ else:
83
+ speech = raw_text
84
+
85
+ # Truncate at end marker
86
+ for em in cfg["end_markers"]:
87
+ if em in speech:
88
+ speech = speech.split(em)[0]
89
+ break
90
+
91
+ # Remove special tokens
92
+ for tok in cfg["special_tokens_to_remove"]:
93
+ speech = speech.replace(tok, "")
94
+
95
+ # Remove template artefacts
96
+ for art in ["Context:", "Instruction:", "EUROVOC TOPIC:", "SECTION:",
97
+ "PARTY:", "POLITICAL ORIENTATION:", "HOUSE:",
98
+ "\nuser", "\nassistant", "\nsystem"]:
99
+ if art in speech:
100
+ speech = speech.split(art)[0]
101
+
102
+ # Strip meta-commentary prefixes
103
+ _strip_prefixes = [
104
+ "Thank you for providing", "Thank you for your instruction",
105
+ "Here is my speech:", "Here is my response:", "Response:",
106
+ "Based on your specifications", "Based on the context provided",
107
+ ]
108
+ sl = speech.lower()
109
+ for prefix in _strip_prefixes:
110
+ if sl.startswith(prefix.lower()):
111
+ if prefix.endswith(":"):
112
+ speech = speech[len(prefix):].lstrip()
113
+ else:
114
+ cut = speech.find("\n\n")
115
+ if 0 < cut < 200:
116
+ speech = speech[cut + 2:].strip()
117
+ else:
118
+ cut = speech.find("\n")
119
+ if 0 < cut < 150:
120
+ speech = speech[cut + 1:].strip()
121
+ break
122
+
123
+ # Llama reserved tokens
124
+ speech = re.sub(r"<\|reserved_special_token_\d+\|>", "", speech)
125
+ speech = re.sub(r"<\|[^|]*\|>", "", speech)
126
+
127
+ # Whitespace
128
+ speech = re.sub(r"\n{3,}", "\n\n", speech)
129
+ speech = re.sub(r" {2,}", " ", speech)
130
+ speech = speech.strip()
131
+
132
+ # Leading punctuation artefacts
133
+ speech = re.sub(r"^[^\w\s\"'(]+", "", speech).lstrip()
134
+ speech = re.sub(r"^\.{2,}\s*", "", speech)
135
+
136
+ # HTML tags / trailing dashes
137
+ speech = re.sub(r"</?[a-zA-Z][^>]*>", "", speech)
138
+ speech = re.sub(r"----+\s*\.?\s*$", "", speech)
139
+
140
+ # Qwen: literal escape sequences
141
+ if "\\n" in speech or "\\t" in speech:
142
+ speech = speech.replace("\\n", "\n").replace("\\t", " ")
143
+
144
+ # Markdown
145
+ speech = re.sub(r"^#+\s+", "", speech)
146
+ speech = re.sub(r"\n#+\s+", "\n", speech)
147
+ speech = re.sub(r"\n?```\.?", "", speech)
148
+ speech = speech.strip()
149
+
150
+ # Final punctuation
151
+ if speech and not speech.endswith((".", "!", "?", '"', "'")):
152
+ speech = speech.rstrip() + "."
153
+
154
+ return speech
155
+
156
+
157
+ # ─── Main generation function ─────────────────────────────────────────────────
158
+
159
+ def generate_speech(
160
+ model_display_name: str,
161
+ party: str,
162
+ topic: str,
163
+ section: str,
164
+ house: str,
165
+ instruction_input: str,
166
+ temperature: float,
167
+ top_p: float,
168
+ repetition_penalty: float,
169
+ max_new_tokens: int,
170
+ min_words: int,
171
+ max_words: int,
172
+ ):
173
+ """Generate a parliamentary speech and return (speech, prompt, stats, params)."""
174
+ family = MODEL_FAMILY[model_display_name]
175
+ cfg = MODEL_CONFIG[family]
176
+
177
+ instruction = (instruction_input.strip()
178
+ if instruction_input and instruction_input.strip()
179
+ else f"Address the debate on {section} on {topic}.")
180
+
181
+ full_prompt = build_full_prompt(
182
+ model_family=family,
183
+ party=party,
184
+ topic=topic,
185
+ section=section,
186
+ house=house,
187
+ instruction=instruction,
188
+ min_words=int(min_words),
189
+ max_words=int(max_words),
190
+ )
191
+ prompt_tokens = count_tokens_approx(full_prompt)
192
+
193
+ try:
194
+ model, tokenizer = _load_model_and_tokenizer(model_display_name)
195
+ except Exception as exc:
196
+ return (
197
+ f"⚠️ Model loading failed:\n{exc}\n\n"
198
+ "Make sure the model repository exists on Hugging Face "
199
+ "and you have sufficient GPU memory (β‰₯16 GB recommended).",
200
+ full_prompt,
201
+ "*Model loading error β€” see output above.*",
202
+ "",
203
+ )
204
+
205
+ inputs = tokenizer([full_prompt], return_tensors="pt").to(model.device)
206
+ in_len = inputs["input_ids"].shape[-1]
207
+ pad_id = tokenizer.pad_token_id or tokenizer.eos_token_id
208
+
209
+ t0 = time.time()
210
+ with torch.no_grad():
211
+ out_ids = model.generate(
212
+ **inputs,
213
+ max_new_tokens=int(max_new_tokens),
214
+ do_sample=True,
215
+ temperature=float(temperature),
216
+ top_p=float(top_p),
217
+ repetition_penalty=float(repetition_penalty),
218
+ pad_token_id=pad_id,
219
+ eos_token_id=tokenizer.eos_token_id,
220
+ stop_strings=cfg["stop_strings"],
221
+ tokenizer=tokenizer,
222
+ use_cache=True,
223
+ )
224
+ elapsed = time.time() - t0
225
+
226
+ raw = tokenizer.decode(out_ids[0], skip_special_tokens=False)
227
+ speech = _extract_speech(raw, family)
228
+
229
+ is_valid, reason = validate_speech(speech, int(min_words), int(max_words))
230
+ wc = len(speech.split())
231
+
232
+ stats = (
233
+ f"**Tokens in prompt:** ~{prompt_tokens} | "
234
+ f"**Words generated:** {wc} | "
235
+ f"**Time:** {elapsed:.1f}s | "
236
+ f"**Validation:** {'βœ… ' + reason if is_valid else '⚠️ ' + reason}"
237
+ )
238
+ params_used = (
239
+ f"temperature={temperature}, top_p={top_p}, "
240
+ f"repetition_penalty={repetition_penalty}, max_new_tokens={max_new_tokens}"
241
+ )
242
+
243
+ return speech, full_prompt, stats, params_used
244
+
245
+
246
+ # ─── Sample gallery ───────────────────────────────────────────────────────────
247
+ with open("sample_data.json") as _f:
248
+ SAMPLES = json.load(_f)
249
+
250
+
251
+ def _render_sample(s: dict) -> str:
252
+ if s.get("is_real"):
253
+ tag = "πŸ›οΈ Real speech (ParlaMint-GB)"
254
+ elif s.get("is_finetuned"):
255
+ tag = "βœ… Synthetic β€” Fine-tuned"
256
+ else:
257
+ tag = "⬜ Synthetic β€” Baseline"
258
+
259
+ table_ref = f" *(Thesis {s['table']})*" if s.get("table") else ""
260
+ return (
261
+ f"### {tag}{table_ref}\n\n"
262
+ f"| | |\n|---|---|\n"
263
+ f"| **Party** | {s['party']} |\n"
264
+ f"| **Topic** | {s['topic']} |\n"
265
+ f"| **Section** | {s['section']} |\n"
266
+ f"| **House** | {s['house']} |\n"
267
+ f"| **Orientation** | {s['orientation']} |\n"
268
+ f"| **Model** | {s['model']} |\n"
269
+ f"| **Words** | {s['word_count']} |\n\n"
270
+ f"---\n\n{s['speech']}"
271
+ )
272
+
273
+
274
+
275
+ # ─── Dynamic UI helpers ───────────────────────────────────────────────────────
276
+ def _update_house(party):
277
+ valid = get_valid_houses(party)
278
+ return gr.update(choices=valid, value=valid[0])
279
+
280
+
281
+ def _update_orientation(party):
282
+ return gr.update(value=get_orientation(party))
283
+
284
+
285
+ # ─── Gradio app ───────────────────────────────────────────────────────────────
286
+ CSS = """
287
+ #title { text-align: center; margin-bottom: .4em; }
288
+ #sub { text-align: center; color: #666; margin-bottom: 1.4em; font-size: .9em; }
289
+ #speech textarea { font-size: .95em; line-height: 1.65; }
290
+ #prompt textarea { font-family: monospace; font-size: .78em; }
291
+ """
292
+
293
+ with gr.Blocks(css=CSS, title="ParliaBench Demo") as demo:
294
+
295
+ gr.Markdown("# πŸ›οΈ ParliaBench β€” UK Parliamentary Speech Generation",
296
+ elem_id="title")
297
+ gr.Markdown(
298
+ "Inference demo for five LLMs fine-tuned on **ParlaMint-GB** with QLoRA \n"
299
+ "Koniaris, Tsipi & Tsanakas Β· [arXiv:2511.08247](https://arxiv.org/abs/2511.08247) Β· NTUA 2025",
300
+ elem_id="sub",
301
+ )
302
+
303
+ with gr.Tabs():
304
+
305
+ # ── Tab 1: Generate ───────────────────────────────────────────────────
306
+ with gr.Tab("πŸŽ™οΈ Generate Speech"):
307
+ with gr.Row():
308
+
309
+ # Left panel β€” controls
310
+ with gr.Column(scale=1):
311
+ gr.Markdown("### πŸ”§ Configuration")
312
+
313
+ model_select = gr.Dropdown(
314
+ choices=list(MODELS.keys()),
315
+ value="Llama-3.1-8B (fine-tuned)",
316
+ label="Model",
317
+ info="Fine-tuned = QLoRA adapter on Unsloth base; "
318
+ "Baseline = raw 4-bit base model",
319
+ )
320
+
321
+ with gr.Group():
322
+ party_select = gr.Dropdown(
323
+ choices=PARTIES,
324
+ value="Conservative",
325
+ label="Party",
326
+ )
327
+ orientation_box = gr.Textbox(
328
+ value=get_orientation("Conservative"),
329
+ label="Political Orientation (auto-filled)",
330
+ interactive=False,
331
+ )
332
+ house_select = gr.Dropdown(
333
+ choices=HOUSES,
334
+ value="House of Commons",
335
+ label="House",
336
+ info="Some parties are restricted to the Lords",
337
+ )
338
+
339
+ topic_select = gr.Dropdown(
340
+ choices=EUROVOC_TOPICS,
341
+ value="POLITICS",
342
+ label="EuroVoc Topic",
343
+ info="21 domains from the EUROVOC thesaurus",
344
+ )
345
+ section_input = gr.Textbox(
346
+ value="National Health Service Funding",
347
+ label="Debate Section / Bill Title",
348
+ placeholder="e.g. Climate Change Act, Defence Procurement…",
349
+ )
350
+ instruction_input = gr.Textbox(
351
+ label="Custom Instruction (optional)",
352
+ placeholder=(
353
+ "Leave blank for generic instruction, or enter a "
354
+ "specific question/prompt from the debate…"
355
+ ),
356
+ lines=2,
357
+ )
358
+
359
+ gr.Markdown("### βš™οΈ Generation Parameters")
360
+ temperature = gr.Slider(0.1, 1.5,
361
+ value=DEFAULT_GEN_PARAMS["temperature"],
362
+ step=0.05, label="Temperature")
363
+ top_p = gr.Slider(0.5, 1.0,
364
+ value=DEFAULT_GEN_PARAMS["top_p"],
365
+ step=0.05, label="Top-p (nucleus sampling)")
366
+ rep_penalty = gr.Slider(1.0, 2.0,
367
+ value=DEFAULT_GEN_PARAMS["repetition_penalty"],
368
+ step=0.05, label="Repetition Penalty")
369
+ max_new_toks = gr.Slider(100, 850,
370
+ value=500, step=50,
371
+ label="Max New Tokens")
372
+ with gr.Row():
373
+ min_words = gr.Number(value=DEFAULT_GEN_PARAMS["min_words"],
374
+ label="Min Words", precision=0)
375
+ max_words = gr.Number(value=300,
376
+ label="Max Words (demo cap)", precision=0)
377
+
378
+ gen_btn = gr.Button("🎀 Generate Speech",
379
+ variant="primary", size="lg")
380
+
381
+ # Right panel β€” output
382
+ with gr.Column(scale=2):
383
+ gr.Markdown("### πŸ“œ Generated Speech")
384
+
385
+ speech_out = gr.Textbox(
386
+ label="Output",
387
+ lines=18,
388
+ show_copy_button=True,
389
+ elem_id="speech",
390
+ )
391
+ stats_out = gr.Markdown("*Stats will appear here after generation.*")
392
+ params_out = gr.Textbox(label="Parameters Used",
393
+ interactive=False)
394
+
395
+ with gr.Accordion("πŸ” Full Prompt Sent to Model", open=False):
396
+ prompt_out = gr.Textbox(
397
+ label="Prompt (read-only)",
398
+ lines=14,
399
+ interactive=False,
400
+ elem_id="prompt",
401
+ )
402
+
403
+ gr.Markdown(
404
+ "---\n"
405
+ "πŸ’‘ The prompt panel shows the **exact input** fed to the model "
406
+ "(including chat template tokens) β€” useful for reproducibility."
407
+ )
408
+
409
+ # Wire up dynamic helpers
410
+ party_select.change(_update_house, party_select, house_select)
411
+ party_select.change(_update_orientation, party_select, orientation_box)
412
+
413
+ gen_btn.click(
414
+ fn=generate_speech,
415
+ inputs=[model_select, party_select, topic_select, section_input,
416
+ house_select, instruction_input,
417
+ temperature, top_p, rep_penalty, max_new_toks,
418
+ min_words, max_words],
419
+ outputs=[speech_out, prompt_out, stats_out, params_out],
420
+ )
421
+
422
+ # ── Tab 2: Sample Gallery ─────────────────────────────────────────────
423
+ with gr.Tab("πŸ“š Sample Gallery"):
424
+ gr.Markdown(
425
+ "### Example speeches from the thesis (Tables 7.4–7.13)\n"
426
+ "All 10 examples used in the thesis: 5 baseline outputs and 5 fine-tuned outputs, "
427
+ "one per model. Compare quality directly between baseline ⬜ and fine-tuned βœ… versions."
428
+ )
429
+ sample_choices = [
430
+ f"{s['table']} β€” {'βœ… FT' if s.get('is_finetuned') else '⬜ Base'} | {s['model']} | {s['party']} | {s['topic']}"
431
+ for s in SAMPLES
432
+ ]
433
+ sample_sel = gr.Dropdown(
434
+ choices=sample_choices,
435
+ value=sample_choices[0],
436
+ label="Select a sample",
437
+ )
438
+ sample_md = gr.Markdown(_render_sample(SAMPLES[0]))
439
+
440
+ def _show_sample(choice: str) -> str:
441
+ # choice format: "Table 7.X β€” ..."
442
+ table_ref = choice.split(" β€” ")[0].strip() # e.g. "Table 7.4"
443
+ for s in SAMPLES:
444
+ if s.get("table") == table_ref:
445
+ return _render_sample(s)
446
+ return "Sample not found."
447
+
448
+ sample_sel.change(fn=_show_sample,
449
+ inputs=sample_sel,
450
+ outputs=sample_md)
451
+
452
+ # ── Tab 3: About ──────────────────────────────────────────────────────
453
+ with gr.Tab("ℹ️ About"):
454
+ gr.Markdown("""
455
+ ## About ParliaBench
456
+
457
+ **ParliaBench** is a benchmark and evaluation framework for LLM-generated
458
+ UK parliamentary speeches, developed as a Diploma Thesis at NTUA.
459
+
460
+ ### Five fine-tuned models (QLoRA via Unsloth)
461
+
462
+ | Model | Base | HF Repo |
463
+ |-------|------|---------|
464
+ | Mistral-7B | `unsloth/mistral-7b-v0.3-bnb-4bit` | [`argyrotsipi/parliabench-unsloth-mistral-7b-v0.3`](https://huggingface.co/argyrotsipi/parliabench-unsloth-mistral-7b-v0.3) |
465
+ | Llama-3.1-8B | `unsloth/Meta-Llama-3.1-8B-bnb-4bit` | [`argyrotsipi/parliabench-unsloth-llama-3.1-8b`](https://huggingface.co/argyrotsipi/parliabench-unsloth-llama-3.1-8b) |
466
+ | Gemma-2-9B | `unsloth/gemma-2-9b-bnb-4bit` | [`argyrotsipi/parliabench-unsloth-gemma-2-9b`](https://huggingface.co/argyrotsipi/parliabench-unsloth-gemma-2-9b) |
467
+ | Qwen2-7B | `unsloth/Qwen2-7B-bnb-4bit` | [`argyrotsipi/parliabench-unsloth-qwen-2-7b`](https://huggingface.co/argyrotsipi/parliabench-unsloth-qwen-2-7b) |
468
+ | Yi-1.5-**6B** | `unsloth/Yi-1.5-6B-bnb-4bit` | [`argyrotsipi/parliabench-unsloth-yi-1.5-6b`](https://huggingface.co/argyrotsipi/parliabench-unsloth-yi-1.5-6b) |
469
+
470
+ ### LoRA training configuration
471
+
472
+ | Parameter | Value |
473
+ |-----------|-------|
474
+ | LoRA rank (r) | 16 |
475
+ | LoRA alpha | 16 |
476
+ | Target modules | q, k, v, o, gate, up, down projections |
477
+ | Dropout | 0 |
478
+ | Batch size | 64 |
479
+ | Learning rate | 2e-4 |
480
+ | Optimizer | AdamW (fused) |
481
+ | Max steps | 11 194 (~2 epochs) |
482
+ | Warmup steps | 336 |
483
+ | Max seq length | 1 024 |
484
+
485
+ ### Context format used at generation time
486
+
487
+ ```
488
+ EUROVOC TOPIC: {topic} | SECTION: {section} | PARTY: {party} | POLITICAL ORIENTATION: {orientation} | HOUSE: {house}
489
+ ```
490
+
491
+ ### Evaluation framework (3 dimensions, 27 560 speeches)
492
+
493
+ **Linguistic quality:** Perplexity Β· Self-BLEU Β· Distinct-n Β· GRUEN Β· BERTScore Β· MoverScore
494
+ **Semantic coherence:** LLM-as-a-Judge (coherence, conciseness, relevance)
495
+ **Political authenticity:** *Political Spectrum Alignment (PSA)* Β· *Party Alignment* ← novel metrics
496
+ LLM-as-a-Judge (authenticity, political appropriateness, overall quality)
497
+
498
+ ### Generation parameters (thesis defaults)
499
+
500
+ | Parameter | Value |
501
+ |-----------|-------|
502
+ | Temperature | 0.7 |
503
+ | Top-p | 0.85 |
504
+ | Repetition penalty | 1.2 |
505
+ | Max new tokens | 850 |
506
+ | Min words (P10) | 43 |
507
+ | Max words (P90) | 635 |
508
+
509
+ ### Citation
510
+
511
+ ```bibtex
512
+ @article{koniaris2025parliabench,
513
+ title = {ParliaBench: An Evaluation and Benchmarking Framework for LLM-Generated Parliamentary Speech},
514
+ author = {Koniaris, Marios and Tsipi, Argyro and Tsanakas, Panayiotis},
515
+ journal = {arXiv preprint arXiv:2511.08247},
516
+ year = {2025},
517
+ url = {https://arxiv.org/abs/2511.08247}
518
+ }
519
+ ```
520
+
521
+ *[arXiv:2511.08247](https://arxiv.org/abs/2511.08247) Β· National Technical University of Athens Β· School of Electrical and Computer Engineering*
522
+ """)
523
+
524
+ gr.Markdown(
525
+ "---\n"
526
+ "<small>ParliaBench Demo Β· NTUA 2025 Β· "
527
+ "[argyrotsipi on HF](https://huggingface.co/argyrotsipi) Β· "
528
+ "[Train dataset](https://huggingface.co/datasets/argyrotsipi/train-dataset) Β· "
529
+ "[Generated dataset](https://huggingface.co/datasets/argyrotsipi/generated-dataset)</small>"
530
+ )
531
+
532
+
533
+ if __name__ == "__main__":
534
+ demo.launch(share=False)
prompt_templates.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ParliaBench Prompt Templates
3
+ Model-specific chat templates for parliamentary speech generation.
4
+
5
+ Exact templates from speech_generator.py (format_prompt_* functions).
6
+ System prompt from Config.SYSTEM_PROMPT in speech_generator.py.
7
+ """
8
+
9
+ from utils import build_context_string, get_orientation
10
+
11
+ # System prompt used at generation time (from Config.SYSTEM_PROMPT)
12
+ # The {min_words}-{max_words} range is inserted dynamically.
13
+ SYSTEM_PROMPT_TEMPLATE = (
14
+ "You are a seasoned UK parliamentary member. Generate a coherent speech of "
15
+ "{min_words}-{max_words} words in standard English (no Unicode artifacts, no special characters).\n"
16
+ "Use proper British parliamentary language appropriate for the specified House. "
17
+ "The speech should reflect the political orientation and typical positions of the "
18
+ "specified party on the given topic."
19
+ )
20
+
21
+ # System prompt used during training (no word-count constraint β€” from trainer.py)
22
+ TRAINING_SYSTEM_PROMPT = (
23
+ "You are a seasoned UK parliamentary member.\n"
24
+ "Use proper British parliamentary language appropriate for the specified House. "
25
+ "The speech should reflect the political orientation and typical positions of the "
26
+ "specified party on the given topic."
27
+ )
28
+
29
+
30
+ def get_system_prompt(min_words: int, max_words: int) -> str:
31
+ return SYSTEM_PROMPT_TEMPLATE.format(min_words=min_words, max_words=max_words)
32
+
33
+
34
+ # ─── Per-model format functions (exact from speech_generator.py) ──────────────
35
+
36
+ def format_prompt_mistral(context: str, instruction: str,
37
+ min_words: int, max_words: int) -> str:
38
+ system = get_system_prompt(min_words, max_words)
39
+ return (
40
+ f"<s>[INST] {system}\n\n"
41
+ f"Context: {context}\n"
42
+ f"Instruction: {instruction} [/INST] "
43
+ )
44
+
45
+
46
+ def format_prompt_llama(context: str, instruction: str,
47
+ min_words: int, max_words: int) -> str:
48
+ system = get_system_prompt(min_words, max_words)
49
+ return (
50
+ f"<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n"
51
+ f"{system}<|eot_id|><|start_header_id|>user<|end_header_id|>\n"
52
+ f"Context: {context}\n"
53
+ f"Instruction: {instruction}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n"
54
+ )
55
+
56
+
57
+ def format_prompt_gemma(context: str, instruction: str,
58
+ min_words: int, max_words: int) -> str:
59
+ system = get_system_prompt(min_words, max_words)
60
+ return (
61
+ f"<bos><start_of_turn>user\n"
62
+ f"{system}\n"
63
+ f"Context: {context}\n"
64
+ f"Instruction: {instruction}<end_of_turn>\n"
65
+ f"<start_of_turn>model\n"
66
+ )
67
+
68
+
69
+ def format_prompt_qwen(context: str, instruction: str,
70
+ min_words: int, max_words: int) -> str:
71
+ system = get_system_prompt(min_words, max_words)
72
+ return (
73
+ f"<|im_start|>system\n{system}<|im_end|>\n"
74
+ f"<|im_start|>user\n"
75
+ f"Context: {context}\n"
76
+ f"Instruction: {instruction}<|im_end|>\n"
77
+ f"<|im_start|>assistant\n"
78
+ )
79
+
80
+
81
+ def format_prompt_yi(context: str, instruction: str,
82
+ min_words: int, max_words: int) -> str:
83
+ system = get_system_prompt(min_words, max_words)
84
+ return (
85
+ f"<|im_start|>system\n{system}<|im_end|>\n"
86
+ f"<|im_start|>user\n"
87
+ f"Context: {context}\n"
88
+ f"Instruction: {instruction}<|im_end|>\n"
89
+ f"<|im_start|>assistant\n"
90
+ )
91
+
92
+
93
+ # Dispatch table (from FORMAT_FUNCTIONS in speech_generator.py)
94
+ FORMAT_FUNCTIONS = {
95
+ "mistral": format_prompt_mistral,
96
+ "llama": format_prompt_llama,
97
+ "gemma": format_prompt_gemma,
98
+ "qwen": format_prompt_qwen,
99
+ "yi": format_prompt_yi,
100
+ }
101
+
102
+
103
+ def build_full_prompt(model_family: str,
104
+ party: str,
105
+ topic: str,
106
+ section: str,
107
+ house: str,
108
+ instruction: str,
109
+ min_words: int,
110
+ max_words: int) -> str:
111
+ """
112
+ Build the complete prompt string ready to be tokenized.
113
+
114
+ Args:
115
+ model_family: One of 'mistral', 'llama', 'gemma', 'qwen', 'yi'
116
+ party: Political party name
117
+ topic: EuroVoc topic (upper-case)
118
+ section: Parliamentary debate section / bill title
119
+ house: 'House of Commons' or 'House of Lords'
120
+ instruction: Task instruction or generic prompt
121
+ min_words / max_words: Word-count constraints for the system prompt
122
+
123
+ Returns:
124
+ Formatted prompt string
125
+ """
126
+ orientation = get_orientation(party)
127
+ context = build_context_string(party, topic, section, orientation, house)
128
+
129
+ if not instruction or not instruction.strip():
130
+ instruction = f"Address the debate on {section} on {topic}."
131
+
132
+ fmt_fn = FORMAT_FUNCTIONS.get(model_family)
133
+ if fmt_fn is None:
134
+ raise ValueError(f"Unknown model family: {model_family!r}. "
135
+ f"Choose from: {list(FORMAT_FUNCTIONS)}")
136
+
137
+ return fmt_fn(context, instruction, min_words, max_words)
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.36.0
2
+ transformers>=4.43.0
3
+ torch>=2.3.0
4
+ accelerate>=0.31.0
5
+ bitsandbytes>=0.43.0
6
+ peft>=0.11.0
7
+ sentencepiece>=0.2.0
8
+ protobuf>=4.25.0
sample_data.json ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "table_7_4",
4
+ "table": "Table 7.4",
5
+ "model": "Gemma-2-9B (baseline)",
6
+ "party": "Scottish National Party",
7
+ "topic": "ENERGY",
8
+ "section": "Domestic Renewable Energy",
9
+ "house": "House of Commons",
10
+ "orientation": "Centre-left",
11
+ "instruction": "Address the debate on Domestic Renewable Energy on ENERGY.",
12
+ "speech": "Mr Speaker, I rise today to speak about domestic renewable energy as it is vital that we ensure our country's future remains secure by reducing reliance on foreign imports while also protecting local communities from harmful emissions with new clean technologies like solar panels or wind turbines which can be installed across all parts of Scotland!",
13
+ "word_count": 56,
14
+ "is_real": false,
15
+ "is_finetuned": false
16
+ },
17
+ {
18
+ "id": "table_7_5",
19
+ "table": "Table 7.5",
20
+ "model": "Llama-3.1-8B (baseline)",
21
+ "party": "Conservative",
22
+ "topic": "INDUSTRY",
23
+ "section": "Hawk Aircraft: Overseas Promotion",
24
+ "house": "House of Commons",
25
+ "orientation": "Centre-right",
26
+ "instruction": "Address the debate on Hawk Aircraft: Overseas Promotion on INDUSTRY.",
27
+ "speech": "The government's recent decision to promote exports of our world-renowned aircraft manufacturing capabilities is both commendable and timely. As an advocate for economic growth and international trade, I fully support this initiative. Overseas promotion plays a crucial role in boosting Britain's economy by increasing export revenue. It also helps create jobs domestically while enhancing our nation's reputation as a global leader in innovation and engineering excellence. Hawk Aircraft has long been synonymous with quality craftsmanship, safety standards, and technological advancements that set it apart from its competitors worldwide. By highlighting these strengths during overseas promotional campaigns, we can secure lucrative contracts abroad, ensuring sustained employment opportunities here at home. The current market trends indicate growing demand for advanced fighter jets across various regions globally. With our exceptional track record, skilled workforce, and robust infrastructure supporting research & development initiatives – now more than ever before - there exists ample scope for us to capitalise upon emerging prospects beyond Europe too! In conclusion then; let me urge my esteemed colleagues within parliament today to rally behind efforts aimed towards fostering increased foreign sales of Hawks through strategic outreach programmes designed specifically tailored according to individual markets' needs thereby contributing significantly toward bolstering overall industrial output alongside creating much needed job openings amidst ongoing challenges faced due COVID19 pandemic.",
28
+ "word_count": 185,
29
+ "is_real": false,
30
+ "is_finetuned": false
31
+ },
32
+ {
33
+ "id": "table_7_6",
34
+ "table": "Table 7.6",
35
+ "model": "Mistral-7B (baseline)",
36
+ "party": "Conservative",
37
+ "topic": "ENVIRONMENT",
38
+ "section": "The Situation in the Gulf",
39
+ "house": "House of Commons",
40
+ "orientation": "Centre-right",
41
+ "instruction": "Address the debate on The Situation in the Gulf on ENVIRONMENT.",
42
+ "speech": "The Conservatives have long supported the EU's position as an independent global player, and we believe that this is particularly important in the context of environmental policy. We recognize the importance of working with our European partners to address issues such as climate change and biodiversity loss, but also acknowledge the need to work closely with countries outside Europe to achieve these goals. This is especially true when it comes to addressing the challenges posed by pollution, deforestation, and other threats to our planet. As such, I would like to take this opportunity to reiterate my support for the EU's commitment to leading the way on environmental protection and call upon all Member States to join us in making sure that the Gulf region remains safe from further damage caused by human activities. We must ensure that any trade agreements between the EU and its trading partners include strong provisions for protecting the environment, including measures to reduce greenhouse gas emissions and preserve natural resources. Additionally, we should continue to encourage dialogue with non-EU states on matters related to sustainable development so that they can benefit from our experience while avoiding potential conflicts over resource allocation or access rights. Finally, I urge my colleagues here today to consider how best we can leverage existing partnerships within both the GCC and Asia Pacific regions towards achieving greater cooperation across borders when tackling shared problems affecting regional ecosystems and economies alike.",
43
+ "word_count": 211,
44
+ "is_real": false,
45
+ "is_finetuned": false
46
+ },
47
+ {
48
+ "id": "table_7_7",
49
+ "table": "Table 7.7",
50
+ "model": "Qwen2-7B (baseline)",
51
+ "party": "Conservative",
52
+ "topic": "POLITICS",
53
+ "section": "Right-wing Extremism",
54
+ "house": "House of Commons",
55
+ "orientation": "Centre-right",
56
+ "instruction": "Address the debate on Right-wing Extremism on POLITICS.",
57
+ "speech": "The following is an example of how to write this type of speech: \"Mr Speaker, I rise today to address the issue of right-wing extremism within our society. As members of the Conservative Party, we believe that it is important to uphold traditional values and promote social cohesion while respecting diversity. However, there have been concerns raised about some individuals who hold extremist views which run counter to these principles. We must be clear - any form of hate or intolerance has absolutely no place in our country. We cannot allow those with extreme beliefs to divide us based on race, religion or ethnicity. Instead, we need to work together as one nation to build bridges between different communities and foster understanding. In order to combat right-wing extremism, I propose three key actions. Firstly, we need stronger laws against hate crimes so that those responsible can face justice more effectively. Secondly, education plays a vital role; schools must teach young people about tolerance and respect from an early age. Finally, increased engagement by law enforcement agencies will help identify potential threats before they escalate into violence.\" This speech reflects the centre-right position of the conservative party on the topic of right-wing extremism whilst still maintaining a positive tone towards building unity across society. It also highlights specific solutions proposed by the speaker such as tougher legislation, improved educational programs and greater police involvement.",
58
+ "word_count": 224,
59
+ "is_real": false,
60
+ "is_finetuned": false
61
+ },
62
+ {
63
+ "id": "table_7_8",
64
+ "table": "Table 7.8",
65
+ "model": "Yi-1.5-6B (baseline)",
66
+ "party": "Labour",
67
+ "topic": "AGRI-FOODSTUFFS",
68
+ "section": "Food Waste",
69
+ "house": "House of Commons",
70
+ "orientation": "Centre-left",
71
+ "instruction": "Address the debate on Food Waste on AGRI-FOODSTUFFS.",
72
+ "speech": "The EU has an obligation to reduce food waste through sustainable policies that ensure fair prices while also promoting social justice and environmental sustainability. As such, I call upon my fellow parliamentarians from across all parties to support this initiative as it is crucial not only for our planet but also for ensuring equitable access to healthy diets around Europe. This motion must be passed without delay so we can begin implementing these important changes today! Speech generated by AI assistant.",
73
+ "word_count": 75,
74
+ "is_real": false,
75
+ "is_finetuned": false
76
+ },
77
+ {
78
+ "id": "table_7_9",
79
+ "table": "Table 7.9",
80
+ "model": "Gemma-2-9B (fine-tuned)",
81
+ "party": "Conservative",
82
+ "topic": "LAW",
83
+ "section": "HM Courts and Tribunal Service",
84
+ "house": "House of Commons",
85
+ "orientation": "Centre-right",
86
+ "instruction": "Address the debate on HM Courts and Tribunal Service on LAW.",
87
+ "speech": "The hon. Gentleman is absolutely right to say that we must do everything possible β€” and I think everyone across this Chamber agrees with him about that β€” to ensure victims come forward as witnesses when they can provide evidence against perpetrators who have committed such crimes; but he will also know that there has been an enormous amount of work done over many years by successive Governments since at least the early '90s or later to improve how our justice system deals with vulnerable people so that it does not put them off coming into court. As part of these proposals, which were consulted upon very extensively last year, one thing we wanted was more specialist courts where all those involved could be trained up properly to deal sensitively with vulnerable victims, especially children. There would then be only three types of Crown Court sitting centre around England and Wales, each providing different levels of expertise depending on what type of case was being heard there. That includes dealing with sexual offences cases through sex offender treatment programmes, domestic violence offenders going back time after time before magistrates' courts because their partners just cannot get away from them, complex fraudsters whose activities go beyond anything else happening locally within any other single jurisdiction area including county lines drug dealers working out whether someone needs help rather than prosecution if addiction problems make sure she goes down another route towards recovery instead. We believe strongly too much focus remains placed solely onto sentencing outcomes without enough attention paid equally important issues relating thereto during trial proceedings themselves. First though let me remind you why reform matters here. Firstly unlike most European countries ours still uses outdated technology often reliant largely manual processes. Secondly despite having fewer judges per capita compared internationally costs continue rising annually partly due inefficient use resources. Thirdly although overall number defendants decreasing significantly certain categories remain stubbornly higher e.g. serious violent crime. Fourthly finally while public safety obviously paramount nobody wants see unnecessary burden place taxpayers pockets unnecessarily either therefore government determined find balance between protecting society ensuring value money spent means delivering better services faster cheaper way.",
88
+ "word_count": 298,
89
+ "is_real": false,
90
+ "is_finetuned": true
91
+ },
92
+ {
93
+ "id": "table_7_10",
94
+ "table": "Table 7.10",
95
+ "model": "Llama-3.1-8B (fine-tuned)",
96
+ "party": "Conservative",
97
+ "topic": "INTERNATIONAL ORGANISATIONS",
98
+ "section": "UK-EU Trade Policy Options",
99
+ "house": "House of Commons",
100
+ "orientation": "Centre-right",
101
+ "instruction": "Address the debate on UK-EU Trade Policy Options on INTERNATIONAL ORGANISATIONS.",
102
+ "speech": "I am grateful to my right hon. Friend for his response. As he will be aware, I have long campaigned against our membership of the European Union because we were told that it was not possible to leave without damaging ourselves economically; yet here we are with an economy growing faster than any other major advanced country's. Does this not prove that those who warned us about economic damage if we left were wrong? Will my right hon. Friend assure me that when negotiations start there will be no question but that we must take back control over laws relating to trade policy?",
103
+ "word_count": 100,
104
+ "is_real": false,
105
+ "is_finetuned": true
106
+ },
107
+ {
108
+ "id": "table_7_11",
109
+ "table": "Table 7.11",
110
+ "model": "Mistral-7B (fine-tuned)",
111
+ "party": "Conservative",
112
+ "topic": "INTERNATIONAL RELATIONS",
113
+ "section": "Nuclear Weapons (International Relations Committee Report)",
114
+ "house": "House of Commons",
115
+ "orientation": "Centre-right",
116
+ "instruction": "Address the debate on Nuclear Weapons (International Relations Committee Report) on INTERNATIONAL RELATIONS.",
117
+ "speech": "2018 is a year of major anniversaries in our history of nuclear weapons. It marks 70 years since atomic bombs were dropped on Japan; it also marks 70 years since NATO was established β€” I am sure that will be remembered by many Members on both sides of this House; and it also marks 70 years since the UN General Assembly adopted its first resolution calling for an end to nuclear testing. In addition, we mark this year the start of negotiations between President Reagan and Mikhail Gorbachev towards reducing nuclear stockpiles. I commend my noble Friend Lord Howell for his work as Chair of the International Relations Select Committee, which produced the report on nuclear deterrence, \"Time to Stop the Clock\", earlier this year. He said at the time that he hoped that the Government would give serious consideration to all aspects of this important issue. As we have heard today from him and others who spoke eloquently about these issues during this debate, the committee's report contains several key recommendations that I hope the Minister will respond to. These include calls to support multilateral disarmament efforts, particularly through the conference on the humanitarian impact of nuclear weapons in December; to ensure that any future strategic defence review includes proposals to reduce the number of operational warheads carried by each Trident submarine, not just those stored separately ashore; to increase funding for the alternative delivery vehicle programme; to continue to engage with other states such as China, India and Pakistan to encourage them to join the global norm against the use or threat of use of nuclear weapons; and to provide regular updates to Parliament on progress made on implementing the Government's non-proliferation treaty obligations.",
118
+ "word_count": 252,
119
+ "is_real": false,
120
+ "is_finetuned": true
121
+ },
122
+ {
123
+ "id": "table_7_12",
124
+ "table": "Table 7.12",
125
+ "model": "Qwen2-7B (fine-tuned)",
126
+ "party": "Labour",
127
+ "topic": "POLITICS",
128
+ "section": "Hong Kong Arrests Under National Security Law",
129
+ "house": "House of Lords",
130
+ "orientation": "Centre-left",
131
+ "instruction": "Address the debate on Hong Kong Arrests Under National Security Law on POLITICS.",
132
+ "speech": "My Lords, I am grateful to my noble friend Lord Collins for tabling this Urgent Question today. I associate myself with his comments about human rights lawyer Maya Wang being detained by Chinese authorities last week. It is truly shocking that we have seen more than two dozen people arrested under China's national security law over recent days alone; some were simply trying to exercise their right to free assembly or speak out against the actions of Beijing. This is just one example of how the national security law has been used as an instrument of repression since it came into force late last year. We know from Amnesty International and Human Rights Watch reports that the arrests show the full extent of the chilling effect of the draconian new laws passed by Beijing in July, which threaten freedom of expression, association and peaceful protest among other things. These latest developments come after a year marked by escalating threats to civil liberties, including widespread police brutality during pro-democracy protests across Hong Kong. As well as the detention of Ms Wang, there was also the arrest earlier this month of Joshua Wong β€” another young activist who had recently turned down a scholarship at Harvard University because he believed that education could not be separated from politics. Are the Government concerned that these latest arrests demonstrate continued disregard for basic freedoms?",
133
+ "word_count": 212,
134
+ "is_real": false,
135
+ "is_finetuned": true
136
+ },
137
+ {
138
+ "id": "table_7_13",
139
+ "table": "Table 7.13",
140
+ "model": "Yi-1.5-6B (fine-tuned)",
141
+ "party": "Scottish National Party",
142
+ "topic": "INTERNATIONAL RELATIONS",
143
+ "section": "Flooding: EU Solidarity Fund",
144
+ "house": "House of Commons",
145
+ "orientation": "Centre-left",
146
+ "instruction": "Address the debate on Flooding: EU Solidarity Fund on INTERNATIONAL RELATIONS.",
147
+ "speech": "I thank the Minister for his response to my urgent question earlier today about this issue. I am sure that he is aware that last week's devastating floods have caused huge damage across Wales β€” not only flooding but also widespread disruption of power supplies throughout mid-Wales. As well as seeking support from our own Government here at home, can we seek solidarity with those affected by these events? Can he update us further on what discussions there will be with other European Governments over how they might help financially or otherwise with such devastation? Will he make representations when it comes time for any applications to be made so that we get maximum financial benefit out of them? Finally, does he agree that while Brexit means leaving Europe physically, not joining together through co-operation makes absolutely zero sense economically, environmentally or socially? This shows why Scotland would rather remain part of Europe than leave altogether.",
148
+ "word_count": 156,
149
+ "is_real": false,
150
+ "is_finetuned": true
151
+ }
152
+ ]
utils.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ParliaBench Utilities
3
+ Party data, topic lists, orientation mappings, inference helpers, and validation.
4
+ Source: speech_generator.py / trainer.py β€” ParliaBench NTUA 2025
5
+ """
6
+
7
+ import re
8
+
9
+ # ─── Party data (from Config.PARTY_DISTRIBUTION in speech_generator.py) ───────
10
+ PARTY_DISTRIBUTION = {
11
+ "Conservative": {"weight": 0.59, "orientation": "Centre-right"},
12
+ "Labour": {"weight": 0.24, "orientation": "Centre-left"},
13
+ "Scottish National Party": {"weight": 0.05, "orientation": "Centre-left"},
14
+ "Liberal Democrats": {"weight": 0.05, "orientation": "Centre to centre-left"},
15
+ "Crossbench": {"weight": 0.028, "orientation": "Unknown"},
16
+ "Democratic Unionist Party": {"weight": 0.016, "orientation": "Right"},
17
+ "Independent": {"weight": 0.01, "orientation": "Unknown"},
18
+ "Plaid Cymru": {"weight": 0.006, "orientation": "Centre-left to left"},
19
+ "Green Party": {"weight": 0.005, "orientation": "Left"},
20
+ "Non-Affiliated": {"weight": 0.003, "orientation": "Unknown"},
21
+ "Bishops": {"weight": 0.002, "orientation": "Unknown"},
22
+ }
23
+
24
+ PARTIES = list(PARTY_DISTRIBUTION.keys())
25
+
26
+ # House restrictions (from Config.COMMONS_PARTIES / Config.LORDS_PARTIES)
27
+ COMMONS_PARTIES = [
28
+ "Conservative", "Labour", "Scottish National Party", "Liberal Democrats",
29
+ "Democratic Unionist Party", "Independent", "Plaid Cymru", "Green Party",
30
+ ]
31
+ LORDS_PARTIES = [
32
+ "Conservative", "Labour", "Liberal Democrats", "Crossbench",
33
+ "Non-Affiliated", "Green Party", "Bishops", "Independent",
34
+ "Plaid Cymru", "Democratic Unionist Party",
35
+ ]
36
+
37
+ # ─── EuroVoc topic categories (from Config.EUROVOC_TOPICS) ────────────────────
38
+ EUROVOC_TOPICS = [
39
+ "POLITICS", "LAW", "AGRICULTURE, FORESTRY AND FISHERIES",
40
+ "ENERGY", "ECONOMICS", "ENVIRONMENT", "SOCIAL QUESTIONS",
41
+ "EDUCATION AND COMMUNICATIONS", "EMPLOYMENT AND WORKING CONDITIONS",
42
+ "TRANSPORT", "INTERNATIONAL RELATIONS", "TRADE",
43
+ "PRODUCTION, TECHNOLOGY AND RESEARCH", "EUROPEAN UNION",
44
+ "SCIENCE", "GEOGRAPHY", "FINANCE", "BUSINESS AND COMPETITION",
45
+ "INDUSTRY", "AGRI-FOODSTUFFS", "INTERNATIONAL ORGANISATIONS",
46
+ ]
47
+
48
+ # ─── Houses ───────────────────────────────────────────────────────────────────
49
+ HOUSES = ["House of Commons", "House of Lords"]
50
+ HOUSE_DISTRIBUTION = {"House of Commons": 0.78, "House of Lords": 0.22}
51
+
52
+ # ─── Generation parameters (from Config in speech_generator.py) ───────────────
53
+ DEFAULT_GEN_PARAMS = {
54
+ "temperature": 0.7,
55
+ "top_p": 0.85,
56
+ "repetition_penalty": 1.2,
57
+ "max_new_tokens": 850,
58
+ "min_words": 43, # P10 threshold
59
+ "max_words": 635, # P90 threshold
60
+ }
61
+
62
+ # ─── Model registry ────────────────────────────────────────────────────────────
63
+ # Note: Yi is 6B (not 9B) β€” from ModelConfig in speech_generator.py.
64
+ # Fine-tuned models: LoRA adapters uploaded to HF model repos.
65
+ # Baseline models: loaded directly from Unsloth's 4-bit quantised repos.
66
+ MODELS = {
67
+ # Fine-tuned (LoRA adapters β€” argyrotsipi HF repos)
68
+ "Mistral-7B (fine-tuned)": "argyrotsipi/parliabench-unsloth-mistral-7b-v0.3",
69
+ "Llama-3.1-8B (fine-tuned)": "argyrotsipi/parliabench-unsloth-llama-3.1-8b",
70
+ "Gemma-2-9B (fine-tuned)": "argyrotsipi/parliabench-unsloth-gemma-2-9b",
71
+ "Qwen2-7B (fine-tuned)": "argyrotsipi/parliabench-unsloth-qwen-2-7b",
72
+ "Yi-1.5-6B (fine-tuned)": "argyrotsipi/parliabench-unsloth-yi-1.5-6b",
73
+ # Baselines (raw 4-bit quantised from Unsloth)
74
+ "Mistral-7B (baseline)": "unsloth/mistral-7b-v0.3-bnb-4bit",
75
+ "Llama-3.1-8B (baseline)": "unsloth/Meta-Llama-3.1-8B-bnb-4bit",
76
+ "Gemma-2-9B (baseline)": "unsloth/gemma-2-9b-bnb-4bit",
77
+ "Qwen2-7B (baseline)": "unsloth/Qwen2-7B-bnb-4bit",
78
+ "Yi-1.5-6B (baseline)": "unsloth/Yi-1.5-6B-bnb-4bit",
79
+ }
80
+
81
+ # Map display name β†’ model family key (for template + stop-string selection)
82
+ MODEL_FAMILY = {
83
+ "Mistral-7B (fine-tuned)": "mistral",
84
+ "Llama-3.1-8B (fine-tuned)": "llama",
85
+ "Gemma-2-9B (fine-tuned)": "gemma",
86
+ "Qwen2-7B (fine-tuned)": "qwen",
87
+ "Yi-1.5-6B (fine-tuned)": "yi",
88
+ "Mistral-7B (baseline)": "mistral",
89
+ "Llama-3.1-8B (baseline)": "llama",
90
+ "Gemma-2-9B (baseline)": "gemma",
91
+ "Qwen2-7B (baseline)": "qwen",
92
+ "Yi-1.5-6B (baseline)": "yi",
93
+ }
94
+
95
+ # Stop strings, start/end markers, and tokens to strip
96
+ # (from ModelConfig.MODELS in speech_generator.py β€” exact values)
97
+ MODEL_CONFIG = {
98
+ "mistral": {
99
+ "base_model": "unsloth/mistral-7b-v0.3-bnb-4bit",
100
+ "stop_strings": ["</s>", "\n[INST]", "\nContext:", "\nInstruction:"],
101
+ "start_marker": "[/INST]",
102
+ "end_markers": ["</s>", "\n[INST]", "\nContext:"],
103
+ "special_tokens_to_remove": ["</s>", "<s>"],
104
+ },
105
+ "llama": {
106
+ "base_model": "unsloth/Meta-Llama-3.1-8B-bnb-4bit",
107
+ "stop_strings": ["<|eot_id|>", "\n<|start_header_id|>user",
108
+ "\nContext:", "\nInstruction:"],
109
+ "start_marker": "<|start_header_id|>assistant<|end_header_id|>",
110
+ "end_markers": ["<|eot_id|>", "</s>", "<|end_of_text|>",
111
+ "\n<|start_header_id|>"],
112
+ "special_tokens_to_remove": ["<|eot_id|>", "</s>", "<|end_of_text|>",
113
+ "<|start_header_id|>", "<|end_header_id|>"],
114
+ },
115
+ "gemma": {
116
+ "base_model": "unsloth/gemma-2-9b-bnb-4bit",
117
+ "stop_strings": ["<end_of_turn>", "\n<start_of_turn>user",
118
+ "\nContext:", "\nInstruction:"],
119
+ "start_marker": "<start_of_turn>model",
120
+ "end_markers": ["<end_of_turn>", "\n<start_of_turn>user", "\n<bos>"],
121
+ "special_tokens_to_remove": ["<end_of_turn>", "<start_of_turn>", "<bos>", "<eos>"],
122
+ },
123
+ "qwen": {
124
+ "base_model": "unsloth/Qwen2-7B-bnb-4bit",
125
+ "stop_strings": ["<|im_end|>", "\n<|im_start|>user",
126
+ "\nContext:", "\nInstruction:"],
127
+ "start_marker": "<|im_start|>assistant",
128
+ "end_markers": ["<|im_end|>", "\n<|im_start|>user",
129
+ "\n<|im_start|>system"],
130
+ "special_tokens_to_remove": ["<|im_end|>", "<|im_start|>", "<|endoftext|>"],
131
+ },
132
+ "yi": {
133
+ "base_model": "unsloth/Yi-1.5-6B-bnb-4bit",
134
+ "stop_strings": ["<|im_end|>", "\n<|im_start|>user",
135
+ "\nContext:", "\nInstruction:"],
136
+ "start_marker": "<|im_start|>assistant",
137
+ "end_markers": ["<|im_end|>", "\n<|im_start|>user"],
138
+ "special_tokens_to_remove": ["<|im_end|>", "<|im_start|>", "<|endoftext|>"],
139
+ },
140
+ }
141
+
142
+
143
+ # ─── Helper functions ─────────────────────────────────────────────────────────
144
+
145
+ def get_valid_houses(party: str) -> list:
146
+ """Return the allowed houses for a given party."""
147
+ if party not in COMMONS_PARTIES:
148
+ return ["House of Lords"]
149
+ return HOUSES
150
+
151
+
152
+ def get_orientation(party: str) -> str:
153
+ return PARTY_DISTRIBUTION.get(party, {}).get("orientation", "Unknown")
154
+
155
+
156
+ def build_context_string(party: str, topic: str, section: str,
157
+ orientation: str, house: str) -> str:
158
+ """
159
+ Build the pipe-separated context string used at generation time.
160
+ Matches speech_generator.py: context = " | ".join(context_parts)
161
+ """
162
+ parts = [
163
+ f"EUROVOC TOPIC: {topic}",
164
+ f"SECTION: {section}",
165
+ f"PARTY: {party}",
166
+ f"POLITICAL ORIENTATION: {orientation}",
167
+ f"HOUSE: {house}",
168
+ ]
169
+ return " | ".join(parts)
170
+
171
+
172
+ def count_tokens_approx(text: str) -> int:
173
+ """Rough token estimate (~words Γ— 1.3)."""
174
+ return int(len(text.split()) * 1.3)
175
+
176
+
177
+ # ─── Speech Validator ─────────────────────────────────────────────────────────
178
+ # Ported from SpeechValidator in speech_generator.py (9-step logic)
179
+
180
+ _TEMPLATE_MARKERS = [
181
+ "\nuser", "\nassistant", "\nsystem", "\nmodel",
182
+ "user\n", "assistant\n", "system\n", "model\n",
183
+ "<s>", "system<|", "|>system",
184
+ "Context:", "Instruction:", "EUROVOC TOPIC:", "SECTION:",
185
+ "PARTY:", "POLITICAL ORIENTATION:", "HOUSE:",
186
+ "<|", "|>", "<s>", "</s>", "<bos>", "<eos>",
187
+ "<start_of_turn>", "<end_of_turn>",
188
+ "<|im_start|>", "<|im_end|>",
189
+ "[INST]", "[/INST]", "Response:",
190
+ ]
191
+
192
+ _CORRUPTION_PATTERNS = [
193
+ "β–", "Ξ³Ζ’Β»", "Ξ²\"", "erusform", "});", "</>",
194
+ "▍", "β–Œ", "β–Š", "β–ˆ", "・", "━", "┃", "β”œ", "οΏ½",
195
+ "<2mass>", "<3mass>", "<4mass>",
196
+ ]
197
+
198
+ _FORBIDDEN_RANGES = [
199
+ (0x4E00, 0x9FFF), (0x3400, 0x4DBF), (0x3040, 0x309F),
200
+ (0x30A0, 0x30FF), (0xAC00, 0xD7AF), (0x0600, 0x06FF),
201
+ (0x0400, 0x04FF), (0x0E00, 0x0E7F), (0x2580, 0x259F),
202
+ (0x2200, 0x22FF), (0x2300, 0x23FF),
203
+ ]
204
+
205
+ _REFUSAL_PATTERNS = [
206
+ "I am not capable of generating",
207
+ "I cannot generate",
208
+ "I'm sorry but I cannot",
209
+ "This is a Parliamentary Speech generator",
210
+ "You are asked to",
211
+ ]
212
+
213
+
214
+ def validate_speech(text: str,
215
+ min_words: int = DEFAULT_GEN_PARAMS["min_words"],
216
+ max_words: int = DEFAULT_GEN_PARAMS["max_words"]) -> tuple:
217
+ """
218
+ Validate a generated speech.
219
+ Returns (is_valid: bool, reason: str).
220
+ """
221
+ if not text or not text.strip():
222
+ return False, "EMPTY_SPEECH"
223
+
224
+ # Step 1: Template leakage
225
+ for marker in _TEMPLATE_MARKERS:
226
+ if marker in text:
227
+ return False, f"TEMPLATE_LEAK: {marker!r}"
228
+
229
+ # Step 2: Unicode corruption β€” specific patterns
230
+ for pattern in _CORRUPTION_PATTERNS:
231
+ if pattern in text:
232
+ return False, f"ENCODING_ERROR: {pattern!r}"
233
+
234
+ # Step 2b: Forbidden Unicode script ranges
235
+ for char in text:
236
+ cp = ord(char)
237
+ for start, end in _FORBIDDEN_RANGES:
238
+ if start <= cp <= end:
239
+ return False, f"UNICODE_CORRUPTION: U+{cp:04X}"
240
+
241
+ # Step 3: Repetition β€” same word 4+ times consecutively
242
+ words = text.split()
243
+ for i in range(len(words) - 3):
244
+ w = words[i].lower()
245
+ if len(w) > 3 and all(words[i + j].lower() == w for j in range(1, 4)):
246
+ return False, f"REPETITION: '{w}' Γ— 4"
247
+
248
+ # Step 3b: Repeated sequences of 3–7 words
249
+ for seq_len in range(3, 8):
250
+ for i in range(len(words) - seq_len * 3):
251
+ seq = tuple(w.lower() for w in words[i:i + seq_len])
252
+ count, j = 1, i + seq_len
253
+ while j + seq_len <= len(words):
254
+ if tuple(w.lower() for w in words[j:j + seq_len]) == seq:
255
+ count += 1
256
+ j += seq_len
257
+ else:
258
+ break
259
+ if count > 3:
260
+ snippet = " ".join(words[i:i + seq_len])
261
+ return False, f"REPETITION: sequence Γ— {count} '{snippet[:30]}'"
262
+
263
+ # Step 4: Counting pattern
264
+ counting = ["first", "second", "third", "fourth", "fifth",
265
+ "sixth", "seventh", "eighth", "ninth", "tenth"]
266
+ if sum(1 for w in counting if w in text.lower()) > 5:
267
+ return False, "REPETITION: counting_pattern"
268
+
269
+ # Step 5: Length
270
+ wc = len(words)
271
+ if wc < min_words:
272
+ return False, f"TOO_SHORT: {wc} words (min {min_words})"
273
+ if wc > max_words:
274
+ return False, f"TOO_LONG: {wc} words (max {max_words})"
275
+
276
+ # Step 6: Concatenated speeches
277
+ openings = (text.count("My Lords") + text.count("Mr Speaker")
278
+ + text.count("Madam Deputy Speaker"))
279
+ if openings >= 4:
280
+ return False, f"CONCATENATION: {openings} openings detected"
281
+
282
+ # Step 7: Corrupted endings
283
+ if any(text.endswith(end) for end in ["});", "▍▍▍", "...."]):
284
+ return False, "CORRUPTED_ENDING"
285
+
286
+ # Step 8: Refusal / role confusion
287
+ tl = text.lower()
288
+ for p in _REFUSAL_PATTERNS:
289
+ if tl.startswith(p.lower()):
290
+ return False, f"META_REFUSAL: {p[:30]!r}"
291
+
292
+ return True, "OK"