import gc import html import json import re from urllib.parse import unquote, urlparse import gradio as gr import numpy as np import plotly.graph_objects as go import requests import spaces import torch from sentence_transformers import SentenceTransformer from transformers import ( AutoModelForCausalLM, AutoModelForTokenClassification, AutoTokenizer, pipeline, ) from combo import COMBO # ============================================================ # MODELE # ============================================================ STELLA_MODEL = "sdadas/stella-pl-retrieval-8k" NER_MODEL = "pczarnik/herbert-base-ner" FASTPDN_MODEL = "clarin-pl/FastPDN" COMBO_MODEL = ( "clarin-pl/" "combo-nlp-xlm-roberta-base-polish-pdb-ud2.17" ) SENTIMENT_MODEL = ( "nie3e/sentiment-polish-gpt2-small" ) LLM_MODEL = "Qwen/Qwen2.5-1.5B-Instruct" WIKIPEDIA_API = ( "https://pl.wikipedia.org/w/api.php" ) MAX_ARTICLES = 20 # ============================================================ # DOMYŚLNE URL # ============================================================ DEFAULT_URLS = """https://pl.wikipedia.org/wiki/Barczewo_(gmina) https://pl.wikipedia.org/wiki/Olsztyn https://pl.wikipedia.org/wiki/Jaros%C5%82aw_Iwaszkiewicz https://pl.wikipedia.org/wiki/Loudun https://pl.wikipedia.org/wiki/Cystersi https://pl.wikipedia.org/wiki/Ministerstwo_Bezpiecze%C5%84stwa_Publicznego https://pl.wikipedia.org/wiki/Anatolij_Kaszpirowski https://pl.wikipedia.org/wiki/Turbina_gazowa https://pl.wikipedia.org/wiki/Fruczak_go%C5%82%C4%85bek https://pl.wikipedia.org/wiki/Kwas_3-hydroksymas%C5%82owy""" # ============================================================ # INFORMACJE O MODELACH # ============================================================ MODEL_INFO = { "Podobieństwo semantyczne": ( "**Model:** `sdadas/stella-pl-retrieval-8k` \n" "**Zadanie:** embeddingi i podobieństwo semantyczne \n" "**Metoda:** embedding → cosine similarity" ), "Mapa semantyczna 3D": ( "**Model:** `sdadas/stella-pl-retrieval-8k` \n" "**Zadanie:** reprezentacja semantyczna tekstu \n" "**Metoda:** embedding → PCA → 3D" ), "NER": ( "**Model:** `pczarnik/herbert-base-ner` \n" "**Zadanie:** rozpoznawanie nazwanych encji (NER) \n" "**Wynik:** typ encji, confidence, artykuł, " "zdanie i kontekst" ), "NER — CLARIN-PL FastPDN": ( "**Model:** `clarin-pl/FastPDN` \n" "**Zadanie:** rozpoznawanie nazwanych encji (NER) \n" "**Wynik:** szczegółowy typ encji, confidence, " "artykuł, zdanie i kontekst" ), "COMBO — morfoskładnia i składnia": ( "**Model:** " "`clarin-pl/" "combo-nlp-xlm-roberta-base-polish-pdb-ud2.17` \n" "**Zadania:** POS, morfologia, lematyzacja " "i dependency parsing" ), "Sentiment — PolEmo2": ( "**Model:** `nie3e/sentiment-polish-gpt2-small` \n" "**Zadanie:** klasyfikacja sentymentu zdań \n" "**Korpus:** `clarin-pl/polemo2-official` \n" "**Klasy:** NEUTRAL, NEGATIVE, POSITIVE, AMBIGUOUS" ), "Podsumowanie": ( "**Model:** `Qwen/Qwen2.5-1.5B-Instruct` \n" "**Zadanie:** generowanie podsumowania" ), "JSON": ( "**Model:** `Qwen/Qwen2.5-1.5B-Instruct` \n" "**Zadanie:** ekstrakcja informacji do JSON" ), } # ============================================================ # PROMPTY # ============================================================ PROMPTS = { "Podobieństwo semantyczne": ( "Porównaj artykuły pod względem podobieństwa " "semantycznego na podstawie ich reprezentacji " "embeddingowej. Wynik przedstaw w uporządkowanej " "tabeli oraz wskaż najbardziej podobne pary." ), "Mapa semantyczna 3D": ( "Przedstaw artykuły na trójwymiarowej mapie " "semantycznej. Położenie punktów powinno wynikać " "z reprezentacji embeddingowej artykułów." ), "NER": ( "Przeprowadź uporządkowaną analizę nazwanych encji. " "Dla każdej rozpoznanej encji podaj: dokładny tekst " "encji, typ encji, confidence modelu NER, tytuł " "artykułu, URL źródłowy, pełne zdanie zawierające " "encję oraz krótki kontekst obejmujący trzy słowa " "przed encją i trzy słowa po niej. Nie łącz confidence " "NER z żadną inną miarą prawdopodobieństwa." ), "NER — CLARIN-PL FastPDN": ( "Przeprowadź uporządkowaną analizę nazwanych encji " "z zachowaniem szczegółowych typów encji zwracanych " "przez model FastPDN. Dla każdej encji podaj: dokładny " "tekst encji, szczegółowy typ encji, confidence modelu " "NER, tytuł artykułu, URL źródłowy, pełne zdanie " "zawierające encję oraz krótki kontekst obejmujący " "trzy słowa przed encją i trzy słowa po niej." ), "COMBO — morfoskładnia i składnia": ( "Przeprowadź uporządkowaną analizę morfosyntaktyczną " "i składniową. Dla każdego tokenu podaj formę, lemat, " "UPOS, XPOS, cechy morfologiczne, numer głowy " "składniowej oraz relację dependency. Podsumuj " "także rozkład części mowy i relacji składniowych." ), "Sentiment — PolEmo2": ( "Przeanalizuj sentyment kolejnych zdań artykułów. " "Dla każdego zdania podaj: tekst zdania, klasę " "sentymentu, prawdopodobieństwo rozpoznanej klasy " "oraz wartość sentymentu w zakresie od -1 do +1. " "Użyj klas NEUTRAL, NEGATIVE, POSITIVE i AMBIGUOUS. " "POSITIVE ma wartość dodatnią, NEGATIVE ujemną, " "natomiast NEUTRAL i AMBIGUOUS mają wartość 0. " "Nie interpretuj wartości jako dodatkowego " "prawdopodobieństwa modelu." ), "Podsumowanie": ( "Przygotuj neutralne, rzeczowe podsumowanie " "poniższego artykułu z polskiej Wikipedii. " "Nie dodawaj informacji, których nie ma w tekście. " "Podsumowanie powinno mieć 5–8 zdań." ), "JSON": ( "Przeanalizuj poniższy artykuł z polskiej Wikipedii " "i zwróć wyłącznie poprawny obiekt JSON. " "Nie dodawaj żadnego tekstu przed JSON-em ani po nim. " "Nie wymyślaj informacji." ), } # ============================================================ # KOLORY SENTYMENTU # ============================================================ SENTIMENT_STYLES = { "POSITIVE": { "background": "#eaf7ea", "border": "#4caf50", "label": "pozytywny", }, "NEGATIVE": { "background": "#fdeaea", "border": "#d9534f", "label": "negatywny", }, "NEUTRAL": { "background": "#fff8dc", "border": "#d6b656", "label": "neutralny", }, "AMBIGUOUS": { "background": "#f1eafa", "border": "#9068b5", "label": "niejednoznaczny", }, } SENTIMENT_SIGNS = { "POSITIVE": 1, "NEGATIVE": -1, "NEUTRAL": 0, "AMBIGUOUS": 0, } def calculate_sentiment_value( label, probability, ): sign = SENTIMENT_SIGNS.get( label, 0, ) return sign * probability # ============================================================ # POMOCNICZE # ============================================================ def cleanup_memory(): gc.collect() if torch.cuda.is_available(): torch.cuda.empty_cache() def parse_urls(text): urls = [] for line in text.splitlines(): line = line.strip() if line: urls.append(line) return urls[:MAX_ARTICLES] # ============================================================ # WIKIPEDIA # ============================================================ def extract_wikipedia_title(url): parsed = urlparse(url.strip()) path = unquote( parsed.path ) match = re.search( r"/wiki/(.+)$", path, ) if not match: return None title = ( match.group(1) .replace("_", " ") .strip() ) return title or None def fetch_article(url): title = extract_wikipedia_title( url ) if not title: raise ValueError( f"Nieprawidłowy adres Wikipedii: {url}" ) params = { "action": "query", "format": "json", "prop": "extracts|info", "explaintext": 1, "inprop": "url", "titles": title, } response = requests.get( WIKIPEDIA_API, params=params, timeout=30, headers={ "User-Agent": ( "Wikipedia-NLP-Experiment/1.0" ) }, ) response.raise_for_status() data = response.json() pages = ( data .get("query", {}) .get("pages", {}) ) if not pages: raise ValueError( f"Nie znaleziono artykułu: {title}" ) page = next( iter(pages.values()) ) if "missing" in page: raise ValueError( f"Nie znaleziono artykułu: {title}" ) return { "title": page.get( "title", title, ), "url": page.get( "fullurl", url, ), "text": page.get( "extract", "", ), } def get_stats(article): text = article["text"] paragraphs = [ p.strip() for p in re.split( r"\n\s*\n", text, ) if p.strip() ] sentences = [ s.strip() for s in re.split( r"(?<=[.!?])\s+", text, ) if s.strip() ] words = re.findall( r"\b[\wÀ-ž'-]+\b", text, flags=re.UNICODE, ) return { "znaki": len(text), "slowa": len(words), "akapity": len(paragraphs), "zdania": len(sentences), } def load_articles(url_text): urls = parse_urls(url_text) if not urls: return ( [], "Nie podano adresów URL.", ) articles = [] errors = [] for url in urls: try: article = fetch_article( url ) article["stats"] = get_stats( article ) articles.append(article) except Exception as error: errors.append( f"{url}: {error}" ) if not articles: return ( [], "Nie udało się pobrać żadnego " "artykułu.\n\n" + "\n".join(errors), ) message = ( f"Pobrano {len(articles)} artykułów." ) if errors: message += ( "\n\nNie udało się pobrać:\n" + "\n".join(errors) ) return articles, message def articles_table(articles): rows = [] for article in articles: stats = article["stats"] rows.append( [ article["title"], stats["slowa"], stats["znaki"], stats["akapity"], stats["zdania"], article["url"], ] ) return rows # ============================================================ # STELLA # ============================================================ @spaces.GPU(duration=180) def run_stella(articles): if len(articles) < 2: return ( "Do analizy podobieństwa potrzebne " "są co najmniej 2 artykuły.", None, ) device = ( "cuda" if torch.cuda.is_available() else "cpu" ) model = SentenceTransformer( STELLA_MODEL, trust_remote_code=True, device=device, ) texts = [] for article in articles: text = article["text"] if len(text) > 30000: text = text[:30000] texts.append(text) embeddings = model.encode( texts, normalize_embeddings=True, convert_to_numpy=True, show_progress_bar=False, ) similarity = np.matmul( embeddings, embeddings.T, ) lines = [ "## Podobieństwo semantyczne", "", "Wartości oznaczają podobieństwo " "cosinusowe embeddingów.", "", "| Artykuł | " + " | ".join( article["title"] for article in articles ) + " |", "|---|" + "|".join( ["---"] * len(articles) ) + "|", ] for i, article in enumerate( articles ): values = [ f"{similarity[i, j]:.3f}" for j in range( len(articles) ) ] lines.append( "| " + article["title"] + " | " + " | ".join(values) + " |" ) pairs = [] for i in range( len(articles) ): for j in range( i + 1, len(articles), ): pairs.append( ( similarity[i, j], articles[i]["title"], articles[j]["title"], ) ) pairs.sort( reverse=True ) lines.extend( [ "", "### Najbardziej podobne pary", "", ] ) for score, first, second in pairs[:10]: lines.append( f"- **{first}** ↔ " f"**{second}**: {score:.3f}" ) del model del embeddings cleanup_memory() return ( "\n".join(lines), None, ) # ============================================================ # MAPA 3D # ============================================================ def create_3d_coordinates( embeddings ): data = np.asarray( embeddings, dtype=np.float64, ) centered = ( data - data.mean(axis=0) ) if len(data) < 2: return np.zeros( (len(data), 3) ) covariance = np.cov( centered, rowvar=False, ) eigenvalues, eigenvectors = np.linalg.eigh( covariance ) order = np.argsort( eigenvalues )[::-1] eigenvectors = eigenvectors[ :, order, ] dimensions = min( 3, eigenvectors.shape[1], ) coordinates = ( centered @ eigenvectors[:, :dimensions] ) if dimensions < 3: coordinates = np.pad( coordinates, ( (0, 0), (0, 3 - dimensions), ), ) return coordinates @spaces.GPU(duration=180) def run_semantic_map(articles): if len(articles) < 2: return ( "Do stworzenia mapy potrzebne " "są co najmniej 2 artykuły.", None, ) device = ( "cuda" if torch.cuda.is_available() else "cpu" ) model = SentenceTransformer( STELLA_MODEL, trust_remote_code=True, device=device, ) texts = [] for article in articles: text = article["text"] if len(text) > 30000: text = text[:30000] texts.append(text) embeddings = model.encode( texts, normalize_embeddings=True, convert_to_numpy=True, show_progress_bar=False, ) coordinates = create_3d_coordinates( embeddings ) figure = go.Figure() figure.add_trace( go.Scatter3d( x=coordinates[:, 0], y=coordinates[:, 1], z=coordinates[:, 2], mode="markers+text", text=[ article["title"] for article in articles ], textposition="top center", hovertext=[ article["url"] for article in articles ], hoverinfo="text", marker={ "size": 8, "opacity": 0.85, }, ) ) figure.update_layout( title="Mapa semantyczna 3D", height=700, scene={ "xaxis_title": "PCA 1", "yaxis_title": "PCA 2", "zaxis_title": "PCA 3", }, margin={ "l": 0, "r": 0, "t": 50, "b": 0, }, ) del model del embeddings cleanup_memory() return ( "## Mapa semantyczna 3D", figure, ) # ============================================================ # NER — POMOCNICZE # ============================================================ def split_for_ner( text, max_chars=1800, ): if len(text) <= max_chars: return [text] sentences = re.split( r"(?<=[.!?])\s+", text, ) chunks = [] current = "" for sentence in sentences: sentence = sentence.strip() if not sentence: continue candidate = ( sentence if not current else current + " " + sentence ) if len(candidate) > max_chars: if current: chunks.append( current ) current = sentence else: current = candidate if current: chunks.append( current ) return chunks def get_sentence_spans(text): spans = [] pattern = re.compile( r".+?(?:[.!?](?=\s|$)|$)", flags=re.DOTALL, ) for match in pattern.finditer(text): start = match.start() end = match.end() while ( start < end and text[start].isspace() ): start += 1 while ( end > start and text[end - 1].isspace() ): end -= 1 if end > start: spans.append( { "start": start, "end": end, "text": text[start:end], } ) return spans def find_sentence_for_entity( sentence_spans, start, end, ): if start is None or end is None: return None for sentence in sentence_spans: if ( sentence["start"] <= start and sentence["end"] >= end ): return sentence return None def get_word_context( text, start, end, before=3, after=3, ): if start is None or end is None: return text word_spans = list( re.finditer( r"\b[\wÀ-ž'-]+\b", text, flags=re.UNICODE, ) ) entity_index = None for index, match in enumerate( word_spans ): if ( match.start() <= start and match.end() >= end ): entity_index = index break if entity_index is None: return text first = max( 0, entity_index - before, ) last = min( len(word_spans), entity_index + after + 1, ) return " ".join( match.group(0) for match in word_spans[ first:last ] ) # ============================================================ # NER # ============================================================ @spaces.GPU(duration=300) def run_ner_model( articles, model_name, ): tokenizer = AutoTokenizer.from_pretrained( model_name ) model = AutoModelForTokenClassification.from_pretrained( model_name ) device = ( 0 if torch.cuda.is_available() else -1 ) ner = pipeline( "token-classification", model=model, tokenizer=tokenizer, aggregation_strategy="simple", device=device, ) rows = [] for article in articles: chunks = split_for_ner( article["text"] ) for chunk in chunks: sentence_spans = ( get_sentence_spans( chunk ) ) results = ner(chunk) for result in results: entity_text = ( result .get("word", "") .strip() ) if not entity_text: continue start = result.get( "start" ) end = result.get( "end" ) sentence = ( find_sentence_for_entity( sentence_spans, start, end, ) ) entity_type = result.get( "entity_group", result.get( "entity", "UNKNOWN", ), ) confidence = float( result.get( "score", 0, ) ) # Tylko krótki kontekst: # 3 słowa przed + encja + 3 słowa po. context = get_word_context( chunk, start, end, before=3, after=3, ) rows.append( [ entity_type, entity_text, article["title"], article["url"], ( sentence["text"] if sentence else "" ), context, confidence, ] ) del ner del model del tokenizer cleanup_memory() # NIE TWORZYMY tutaj tabeli Markdown. # Wyniki są wyświetlane wyłącznie przez # osobną tabelę gr.Dataframe. return ( f"## Rozpoznane encje\n\n" f"**Model:** `{model_name}`\n\n" f"Znaleziono **{len(rows)}** " f"wystąpień encji.", rows, ) # ============================================================ # SENTIMENT # ============================================================ def split_into_sentences(text): sentences = re.split( r"(?<=[.!?])\s+", text, ) return [ sentence.strip() for sentence in sentences if sentence.strip() ] def sentiment_label( label ): return label.upper().strip() def build_sentiment_html( article_results ): parts = [ """