| import streamlit as st |
| import torch |
| from transformers import AutoTokenizer, AutoModelForNextSentencePrediction |
| import nltk |
| from typing import List, Tuple |
| import re |
| import json |
| import os |
|
|
| |
| try: |
| nltk.data.find('tokenizers/punkt') |
| except LookupError: |
| nltk.download('punkt') |
|
|
| |
| st.set_page_config( |
| page_title="NSP-CitiLink Demo", |
| page_icon="π", |
| layout="wide", |
| initial_sidebar_state="expanded" |
| ) |
|
|
| |
| st.markdown(""" |
| <style> |
| .main-header { |
| font-size: 2.5rem; |
| font-weight: bold; |
| color: #1f77b4; |
| text-align: center; |
| margin-bottom: 1rem; |
| } |
| .segment-box { |
| padding: 1rem; |
| margin: 0.5rem 0; |
| border-radius: 0.5rem; |
| border-left: 4px solid #1f77b4; |
| background-color: #f0f2f6; |
| color: #1e1e1e; |
| } |
| /* Dark mode support for segment box */ |
| @media (prefers-color-scheme: dark) { |
| .segment-box { |
| background-color: rgb(38, 39, 48); |
| color: #e0e0e0; |
| } |
| } |
| .boundary-marker { |
| color: #ff4b4b; |
| font-weight: bold; |
| font-size: 1.2rem; |
| text-align: center; |
| margin: 1rem 0; |
| } |
| .metric-box { |
| background-color: #e8f4f8; |
| padding: 1rem; |
| border-radius: 0.5rem; |
| text-align: center; |
| } |
| </style> |
| """, unsafe_allow_html=True) |
|
|
| |
| @st.cache_resource |
| def load_model(model_name): |
| """Load the NSP model and tokenizer with fallback.""" |
| try: |
| tokenizer = AutoTokenizer.from_pretrained(model_name) |
| model = AutoModelForNextSentencePrediction.from_pretrained(model_name) |
| model.eval() |
| return tokenizer, model, None |
| except Exception as e: |
| error_msg = str(e) |
| |
| if "401" in error_msg or "Unauthorized" in error_msg: |
| return None, None, f"β οΈ Model '{model_name}' is still being processed by Hugging Face. Please wait a few minutes and refresh the page." |
| else: |
| return None, None, f"β Error loading model: {error_msg}" |
|
|
| |
| @st.cache_data |
| def load_example_texts(): |
| """Load example texts from JSON file.""" |
| json_path = os.path.join(os.path.dirname(__file__), 'example_texts.json') |
| try: |
| with open(json_path, 'r', encoding='utf-8') as f: |
| data = json.load(f) |
| |
| return { |
| "Texto Personalizado": "", |
| "Atas de ReuniΓ£o (PortuguΓͺs)": data.get("Portuguese Meeting Minutes", "") |
| } |
| except FileNotFoundError: |
| return { |
| "Texto Personalizado": "", |
| "Atas de ReuniΓ£o (PortuguΓͺs)": "" |
| } |
|
|
| def split_into_sentences(text: str) -> List[str]: |
| """ |
| Split text into sentences using NLTK and merge numbered items. |
| Uses the same logic as the NSP model for consistency. |
| """ |
| |
| sentences = nltk.sent_tokenize(text) |
| |
| sentences = [s.strip() for s in sentences if s.strip() and _is_valid_sentence(s.strip())] |
| |
| sentences = _merge_numbered_sentences(sentences) |
| return sentences |
|
|
| def _is_valid_sentence(sentence: str) -> bool: |
| """ |
| Check if a sentence is valid and should be kept. |
| Same validation logic as NSP model. |
| """ |
| if not sentence: |
| return False |
| |
| |
| numbered_pattern = r''' |
| ^\s* # Optional whitespace at start |
| (?: # Non-capturing group for prefixes |
| [-ββ]+\s* # Dashes (regular, en-dash, em-dash) |
| |[β’Β·*+]\s* # Bullet points |
| |Β§\s* # Section symbol |
| )? # Prefix is optional |
| (?: # Main numbering patterns |
| \d+(?:\.\d+)*[\.\)]\s* # Numbers with optional decimal parts: "1.", "2.1.", "12.2." |
| |[a-z]\)\s* # Letters with parenthesis: "a)", "b)" |
| |[ivxlcdm]+[\.\)]\s* # Roman numerals: "i.", "ii)", "iv." |
| |[-ββ]+\s* # Just dashes: "--", "---" |
| ) |
| $ # End of string |
| ''' |
| if re.match(numbered_pattern, sentence, re.IGNORECASE | re.VERBOSE): |
| return True |
| |
| |
| return len(sentence) > 3 |
|
|
| def _merge_numbered_sentences(sentences: List[str]) -> List[str]: |
| """ |
| Merge sentences that are just numbers/bullets (e.g., "2.", "3.") |
| with the following sentence. Same logic as NSP model. |
| """ |
| if not sentences: |
| return sentences |
| |
| merged_sentences = [] |
| i = 0 |
| |
| while i < len(sentences): |
| current_sentence = sentences[i].strip() |
| |
| |
| numbered_pattern = r''' |
| ^\s* # Optional whitespace at start |
| (?: # Non-capturing group for prefixes |
| [-ββ]+\s* # Dashes (regular, en-dash, em-dash) |
| |[β’Β·*+]\s* # Bullet points |
| |Β§\s* # Section symbol |
| )? # Prefix is optional |
| (?: # Main numbering patterns |
| \d+(?:\.\d+)*[\.\)]\s* # Numbers with optional decimal parts: "1.", "2.1.", "12.2." |
| |[a-z]\)\s* # Letters with parenthesis: "a)", "b)" |
| |[ivxlcdm]+[\.\)]\s* # Roman numerals: "i.", "ii)", "iv." |
| |[-ββ]+\s* # Just dashes: "--", "---" |
| ) |
| $ # End of string |
| ''' |
| |
| if re.match(numbered_pattern, current_sentence, re.IGNORECASE | re.VERBOSE): |
| |
| merge_target_idx = i + 1 |
| |
| |
| while (merge_target_idx < len(sentences) and |
| re.match(numbered_pattern, sentences[merge_target_idx].strip(), re.IGNORECASE | re.VERBOSE)): |
| merge_target_idx += 1 |
| |
| if merge_target_idx < len(sentences): |
| |
| target_sentence = sentences[merge_target_idx].strip() |
| merged_sentence = f"{current_sentence} {target_sentence}" |
| merged_sentences.append(merged_sentence) |
| i = merge_target_idx + 1 |
| else: |
| |
| merged_sentences.append(current_sentence) |
| i += 1 |
| else: |
| |
| merged_sentences.append(current_sentence) |
| i += 1 |
| |
| return merged_sentences |
|
|
| def predict_boundary(sentence_a: str, sentence_b: str, tokenizer, model) -> Tuple[float, float]: |
| """ |
| Predict if there's a topic boundary between two sentences. |
| |
| Returns: |
| Tuple of (is_next_prob, not_next_prob) |
| """ |
| inputs = tokenizer( |
| sentence_a, |
| sentence_b, |
| return_tensors="pt", |
| truncation=True, |
| max_length=512 |
| ) |
| |
| with torch.no_grad(): |
| outputs = model(**inputs) |
| logits = outputs.logits |
| probs = torch.softmax(logits, dim=1) |
| |
| is_next_prob = probs[0][0].item() |
| not_next_prob = probs[0][1].item() |
| |
| return is_next_prob, not_next_prob |
|
|
| def segment_document(text: str, tokenizer, model, threshold: float = 0.5) -> Tuple[List[Tuple[List[str], float]], List[dict]]: |
| """ |
| Segment a document into topics. |
| |
| Returns: |
| Tuple of: |
| - List of (segment_sentences, boundary_confidence) tuples |
| - List of segment dictionaries with text and offsets |
| """ |
| sentences = split_into_sentences(text) |
| |
| if len(sentences) == 0: |
| return [], [] |
| |
| segments = [] |
| current_segment = [sentences[0]] |
| |
| for i in range(1, len(sentences)): |
| is_next_prob, not_next_prob = predict_boundary( |
| sentences[i-1], |
| sentences[i], |
| tokenizer, |
| model |
| ) |
| |
| if not_next_prob > threshold: |
| |
| segments.append((current_segment, not_next_prob)) |
| current_segment = [sentences[i]] |
| else: |
| |
| current_segment.append(sentences[i]) |
| |
| |
| if current_segment: |
| segments.append((current_segment, 0.0)) |
| |
| |
| segments_json = [] |
| current_offset = 0 |
| |
| for segment_sentences, _ in segments: |
| segment_text = ' '.join(segment_sentences) |
| |
| |
| start_pos = text.find(segment_sentences[0], current_offset) |
| if start_pos == -1: |
| |
| start_pos = current_offset |
| |
| |
| last_sentence = segment_sentences[-1] |
| end_pos = text.find(last_sentence, start_pos) |
| if end_pos != -1: |
| end_pos += len(last_sentence) |
| else: |
| |
| end_pos = start_pos + len(segment_text) |
| |
| segments_json.append({ |
| "text": segment_text, |
| "start": start_pos, |
| "end": end_pos |
| }) |
| |
| current_offset = end_pos |
| |
| return segments, segments_json |
|
|
| |
| def main(): |
| |
| st.markdown('<p class="main-header">π NSP-CitiLink: Text Segmentation Demo</p>', unsafe_allow_html=True) |
| st.markdown(""" |
| <p style="text-align: center; color: #666;"> |
| Automatic text segmentation for city council minutes and administrative documents |
| </p> |
| """, unsafe_allow_html=True) |
| |
| |
| st.sidebar.header("βοΈ ConfiguraΓ§Γ£o") |
|
|
| |
| example = st.sidebar.selectbox( |
| "Escolha um exemplo ou insira o seu prΓ³prio texto:", |
| [ |
| "Texto Personalizado", |
| "Atas de ReuniΓ£o (PortuguΓͺs)" |
| ] |
| ) |
|
|
| |
| model_configs = { |
| "Texto Personalizado": { |
| "model": "inesctec/Citilink-NSP-Segmentation-pt", |
| "default_threshold": 0.5 |
| }, |
| "Atas de ReuniΓ£o (PortuguΓͺs)": { |
| "model": "inesctec/Citilink-NSP-Segmentation-pt", |
| "default_threshold": 0.5 |
| } |
| } |
| |
| current_config = model_configs[example] |
| |
| |
| with st.spinner(f"π Loading model: {current_config['model']}..."): |
| tokenizer, model, error = load_model(current_config['model']) |
| |
| |
| if error: |
| st.sidebar.error(error) |
| if "401" in error or "being processed" in error: |
| st.sidebar.info(""" |
| **π‘ Tip:** Newly uploaded models can take 5-15 minutes to be fully processed by Hugging Face. |
| |
| **Workaround:** You can use the Portuguese model (`nsp-citilink`) for English text - it works reasonably well for both languages. |
| """) |
| |
| st.sidebar.warning("π Attempting to use fallback model: `nsp-citilink`") |
| tokenizer, model, fallback_error = load_model("inesctec/CitiLink-NSP-Segmentation-pt") |
| if fallback_error: |
| st.error("β Fallback model also failed to load. Please try again later.") |
| st.stop() |
| |
| threshold = st.sidebar.slider( |
| "Boundary Detection Threshold", |
| min_value=0.0, |
| max_value=1.0, |
| value=current_config['default_threshold'], |
| step=0.05, |
| help="Higher threshold = fewer boundaries (more conservative)" |
| ) |
| |
| st.sidebar.markdown("---") |
| st.sidebar.markdown("### π About") |
|
|
| |
| current_model_display = "Citilink_NSP_Segmentation" |
|
|
| st.sidebar.info(f""" |
| **Citilink NSP** uses Next Sentence Prediction to identify topic boundaries in administrative documents. |
| |
| - **Current Model**: {current_model_display} |
| - **Language**: Portuguese |
| """) |
|
|
| st.sidebar.markdown("---") |
| st.sidebar.markdown("### π Resources") |
| st.sidebar.markdown(""" |
| - [π Citilink Model Card](https://huggingface.co/inesctec/CitiLink-NSP-Segmentation-pt) |
| - [π» GitHub Repository](https://github.com/jmisidro/segnsp) |
| - [π CitiLink Dataset](https://github.com/INESCTEC/citilink-dataset) |
| """) |
| |
| |
| col1, col2 = st.columns([1, 1]) |
| |
| with col1: |
| st.subheader("π Input Document") |
| |
| |
| example_texts = load_example_texts() |
| |
| if example == "Custom Text": |
| input_text = st.text_area( |
| "Enter your text here:", |
| height=400, |
| placeholder="Paste your document text here..." |
| ) |
| else: |
| input_text = st.text_area( |
| f"Example: {example}", |
| value=example_texts[example], |
| height=400 |
| ) |
| |
| segment_button = st.button("π Segment Document", type="primary", use_container_width=True) |
| |
| with col2: |
| st.subheader("π Segmentation Results") |
| |
| if segment_button and input_text: |
| with st.spinner("π Analyzing document..."): |
| |
| segments, segments_json = segment_document(input_text, tokenizer, model, threshold) |
| |
| if not segments: |
| st.warning("β οΈ No text to segment. Please enter some text.") |
| else: |
| |
| metric_col1, metric_col2, metric_col3 = st.columns(3) |
| |
| with metric_col1: |
| st.markdown('<div class="metric-box">', unsafe_allow_html=True) |
| st.metric("Total Segments", len(segments)) |
| st.markdown('</div>', unsafe_allow_html=True) |
| |
| with metric_col2: |
| total_sentences = sum(len(seg[0]) for seg in segments) |
| st.markdown('<div class="metric-box">', unsafe_allow_html=True) |
| st.metric("Total Sentences", total_sentences) |
| st.markdown('</div>', unsafe_allow_html=True) |
| |
| with metric_col3: |
| avg_segment_size = total_sentences / len(segments) if segments else 0 |
| st.markdown('<div class="metric-box">', unsafe_allow_html=True) |
| st.metric("Avg. Sentences/Segment", f"{avg_segment_size:.1f}") |
| st.markdown('</div>', unsafe_allow_html=True) |
| |
| st.markdown("---") |
| |
| |
| st.markdown("### π Segmented Document") |
| |
| for idx, (segment_sentences, boundary_conf) in enumerate(segments, 1): |
| |
| st.markdown(f""" |
| <div class="segment-box"> |
| <strong>π Segment {idx}</strong> ({len(segment_sentences)} sentences) |
| <br><br> |
| {' '.join(segment_sentences)} |
| </div> |
| """, unsafe_allow_html=True) |
| |
| |
| if idx < len(segments): |
| confidence_pct = boundary_conf * 100 |
| st.markdown(f""" |
| <div class="boundary-marker"> |
| π΄ Topic Boundary Detected (confidence: {confidence_pct:.1f}%) |
| </div> |
| """, unsafe_allow_html=True) |
| |
| |
| st.markdown("---") |
| |
| |
| result_text = "" |
| for idx, (segment_sentences, _) in enumerate(segments, 1): |
| result_text += f"=== SEGMENT {idx} ===\n" |
| result_text += ' '.join(segment_sentences) |
| result_text += "\n\n" |
| |
| |
| json_output = { |
| "segments": segments_json |
| } |
| json_str = json.dumps(json_output, indent=2, ensure_ascii=False) |
| |
| |
| col_download1, col_download2 = st.columns(2) |
| |
| with col_download1: |
| st.download_button( |
| label="β¬οΈ Download Segmented Text", |
| data=result_text, |
| file_name="segmented_document.txt", |
| mime="text/plain", |
| use_container_width=True |
| ) |
| |
| with col_download2: |
| st.download_button( |
| label="β¬οΈ Download JSON", |
| data=json_str, |
| file_name="segments.json", |
| mime="application/json", |
| use_container_width=True |
| ) |
| |
| |
| with st.expander("π View Raw JSON (with offsets)"): |
| st.json(json_output) |
| else: |
| st.info("π Enter text in the input box and click 'Segment Document' to begin.") |
| |
| |
| st.markdown("### π― How It Works") |
| st.markdown(""" |
| The model analyzes pairs of consecutive sentences and predicts: |
| - **Same Topic** (label 0): Sentences discuss the same subject |
| - **Topic Boundary** (label 1): New topic begins at second sentence |
| |
| **Example:** |
| """) |
| |
| st.code(""" |
| Sentence A: "By the President, minutes no. 28 of 20.12.2023 were present at the meeting." |
| Sentence B: "After considering and analyzing the matter, the Municipal Executive unanimously decided to approve minute no. 28 of 12.20.2023." |
| β Prediction: Same Topic (confidence: 76%) |
| Sentence A: "After considering and analyzing the matter, the Municipal Executive unanimously decided to approve minute no. 28 of 12.20.2023." |
| Sentence B: "There were no various processes and requests to submit." |
| β Prediction: Topic Boundary (confidence: 82%) |
| """) |
|
|
| if __name__ == "__main__": |
| main() |