ahmadsayadi commited on
Commit
60e8c0a
·
1 Parent(s): f76b926

feat: BrainWatches Python Analysis Service - sentiment, topics, summarize, similarity

Browse files
Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ # HuggingFace Spaces exposes port 7860
11
+ EXPOSE 7860
12
+
13
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,14 +1,26 @@
1
  ---
2
- title: AnalisisNews
3
- emoji: 🐢
4
- colorFrom: yellow
5
  colorTo: indigo
6
- sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
- app_file: app.py
10
  pinned: false
11
- short_description: analisisNews
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: BrainWatches Analysis Service
3
+ emoji: 📊
4
+ colorFrom: green
5
  colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
 
 
8
  pinned: false
 
9
  ---
10
 
11
+ # BrainWatches Python Analysis Service
12
+
13
+ FastAPI microservice untuk analisis NLP berita Indonesia.
14
+
15
+ ## Endpoints
16
+
17
+ - `GET /health` — Health check
18
+ - `POST /sentiment` — Analisis sentimen (lexicon / IndoBERT)
19
+ - `POST /topics` — Topic modeling otomatis (TF-IDF + KMeans)
20
+ - `POST /summarize` — Ringkasan extractive (TextRank)
21
+ - `POST /similarity` — Kemiripan semantik (cosine similarity)
22
+
23
+ ## Auth
24
+
25
+ Semua endpoint (kecuali `/health`) butuh header `X-Service-Token`.
26
+ Set via environment variable `PY_SERVICE_TOKEN` di Settings HuggingFace Space.
app/__init__.py ADDED
File without changes
app/analyzers/__init__.py ADDED
File without changes
app/analyzers/sentiment.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sentiment analyzer.
3
+ - mode "light": lexicon-based sederhana (cepat, tanpa dependency berat)
4
+ - mode "transformer": IndoBERT/RoBERTa (akurat, butuh torch + transformers)
5
+ """
6
+ from typing import List, Dict
7
+ from app.config import settings
8
+
9
+ # Lexicon ringan Bahasa Indonesia (subset). Bisa diperluas.
10
+ POSITIVE_WORDS = {
11
+ "baik", "bagus", "hebat", "sukses", "untung", "naik", "tumbuh", "positif",
12
+ "menang", "juara", "prestasi", "maju", "berhasil", "setuju", "dukung",
13
+ "apresiasi", "optimis", "damai", "sehat", "aman", "puas", "senang",
14
+ "bangga", "harapan", "solusi", "peluang", "inovasi", "manfaat", "efektif",
15
+ }
16
+ NEGATIVE_WORDS = {
17
+ "buruk", "jelek", "gagal", "rugi", "turun", "anjlok", "negatif", "kalah",
18
+ "krisis", "masalah", "korupsi", "tewas", "meninggal", "bencana", "konflik",
19
+ "protes", "demo", "tolak", "kecam", "marah", "takut", "khawatir", "sakit",
20
+ "bahaya", "ancaman", "kritik", "lemah", "lambat", "mahal", "sulit", "rusak",
21
+ "kecewa", "tertekan", "korban", "darurat",
22
+ }
23
+
24
+ _pipeline = None
25
+
26
+
27
+ def _load_transformer():
28
+ global _pipeline
29
+ if _pipeline is None:
30
+ from transformers import pipeline
31
+ _pipeline = pipeline(
32
+ "text-classification",
33
+ model=settings.SENTIMENT_MODEL,
34
+ truncation=True,
35
+ max_length=512,
36
+ )
37
+ return _pipeline
38
+
39
+
40
+ def _normalize_label(label: str) -> str:
41
+ low = label.lower()
42
+ if "pos" in low:
43
+ return "positive"
44
+ if "neg" in low:
45
+ return "negative"
46
+ return "neutral"
47
+
48
+
49
+ def analyze_light(text: str) -> Dict:
50
+ tokens = [t.strip(".,!?;:\"'()[]").lower() for t in text.split()]
51
+ pos = sum(1 for t in tokens if t in POSITIVE_WORDS)
52
+ neg = sum(1 for t in tokens if t in NEGATIVE_WORDS)
53
+ total = pos + neg
54
+
55
+ if total == 0:
56
+ return {"sentiment": "neutral", "score": 0.0, "confidence": 0.5}
57
+
58
+ score = round((pos - neg) / total, 3)
59
+ if score > 0.15:
60
+ sentiment = "positive"
61
+ elif score < -0.15:
62
+ sentiment = "negative"
63
+ else:
64
+ sentiment = "neutral"
65
+
66
+ confidence = round(min(1.0, total / 10), 3)
67
+ return {"sentiment": sentiment, "score": score, "confidence": confidence}
68
+
69
+
70
+ def analyze_transformer(texts: List[str]) -> List[Dict]:
71
+ pipe = _load_transformer()
72
+ outputs = pipe(texts)
73
+ results = []
74
+ for out in outputs:
75
+ label = _normalize_label(out["label"])
76
+ conf = round(float(out["score"]), 3)
77
+ score = conf if label == "positive" else (-conf if label == "negative" else 0.0)
78
+ results.append({"sentiment": label, "score": round(score, 3), "confidence": conf})
79
+ return results
80
+
81
+
82
+ def analyze_batch(items: List) -> List[Dict]:
83
+ if settings.MODEL_MODE == "transformer":
84
+ try:
85
+ texts = [it.text[:2000] for it in items]
86
+ tf = analyze_transformer(texts)
87
+ return [{"id": it.id, **r} for it, r in zip(items, tf)]
88
+ except Exception:
89
+ # Fallback ke light bila transformer gagal load
90
+ pass
91
+ return [{"id": it.id, **analyze_light(it.text)} for it in items]
app/analyzers/similarity.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Semantic similarity via TF-IDF + cosine (scikit-learn).
3
+ Cari pasangan artikel mirip secara makna.
4
+ """
5
+ from typing import List, Dict
6
+ import re
7
+
8
+ INDO_STOPWORDS = {
9
+ "yang", "di", "ke", "dari", "untuk", "pada", "dengan", "ini", "itu", "dan",
10
+ "atau", "adalah", "akan", "juga", "tidak", "para", "oleh", "sebagai",
11
+ }
12
+
13
+
14
+ def _clean(text: str) -> str:
15
+ text = re.sub(r"[^a-zA-Z\s]", " ", text.lower())
16
+ return re.sub(r"\s+", " ", text).strip()
17
+
18
+
19
+ def find_similar_pairs(items: List, threshold: float = 0.3) -> List[Dict]:
20
+ if len(items) < 2:
21
+ return []
22
+
23
+ from sklearn.feature_extraction.text import TfidfVectorizer
24
+ from sklearn.metrics.pairwise import cosine_similarity
25
+
26
+ docs = [_clean(it.text) for it in items]
27
+ ids = [it.id for it in items]
28
+
29
+ vectorizer = TfidfVectorizer(max_features=3000, stop_words=list(INDO_STOPWORDS))
30
+ try:
31
+ X = vectorizer.fit_transform(docs)
32
+ except ValueError:
33
+ return []
34
+
35
+ sim = cosine_similarity(X)
36
+ pairs = []
37
+ n = len(ids)
38
+ for i in range(n):
39
+ for j in range(i + 1, n):
40
+ score = float(sim[i, j])
41
+ if score >= threshold:
42
+ pairs.append({"id_a": ids[i], "id_b": ids[j], "score": round(score, 3)})
43
+
44
+ pairs.sort(key=lambda p: p["score"], reverse=True)
45
+ return pairs[:500]
app/analyzers/summary.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Extractive summarization via TextRank (sumy).
3
+ Ringan, tanpa model berat.
4
+ """
5
+ from typing import List, Dict
6
+ import re
7
+
8
+
9
+ def summarize(text: str, num_sentences: int = 3) -> Dict:
10
+ text = (text or "").strip()
11
+ if not text:
12
+ return {"summary": "", "sentences": []}
13
+
14
+ try:
15
+ from sumy.parsers.plaintext import PlaintextParser
16
+ from sumy.nlp.tokenizers import Tokenizer
17
+ from sumy.summarizers.text_rank import TextRankSummarizer
18
+
19
+ parser = PlaintextParser.from_string(text, Tokenizer("english"))
20
+ summarizer = TextRankSummarizer()
21
+ sentences = [str(s) for s in summarizer(parser.document, num_sentences)]
22
+ if sentences:
23
+ return {"summary": " ".join(sentences), "sentences": sentences}
24
+ except Exception:
25
+ pass
26
+
27
+ # Fallback: ambil N kalimat terpanjang (proxy informativeness)
28
+ raw = re.split(r"(?<=[.!?])\s+", text)
29
+ raw = [s.strip() for s in raw if len(s.strip()) > 40]
30
+ raw.sort(key=len, reverse=True)
31
+ picked = raw[:num_sentences]
32
+ return {"summary": " ".join(picked), "sentences": picked}
app/analyzers/topics.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Topic modeling via TF-IDF + KMeans (scikit-learn).
3
+ Auto-discover topik tanpa kategori predefined.
4
+ """
5
+ from typing import List, Dict
6
+ import re
7
+
8
+ INDO_STOPWORDS = {
9
+ "yang", "di", "ke", "dari", "untuk", "pada", "dengan", "ini", "itu", "dan",
10
+ "atau", "adalah", "akan", "juga", "tidak", "para", "oleh", "sebagai",
11
+ "dalam", "tersebut", "ada", "dapat", "bisa", "harus", "lebih", "sangat",
12
+ "telah", "sudah", "masih", "hanya", "saja", "karena", "namun", "tetapi",
13
+ "tapi", "saat", "ketika", "setelah", "sebelum", "antara", "hingga",
14
+ "republika", "okezone", "detik", "kompas", "tribunnews", "cnn", "tempo",
15
+ "antaranews", "antara", "merdeka", "kumparan", "news", "com",
16
+ }
17
+
18
+
19
+ def _clean(text: str) -> str:
20
+ text = re.sub(r"[^a-zA-Z\s]", " ", text.lower())
21
+ return re.sub(r"\s+", " ", text).strip()
22
+
23
+
24
+ def discover_topics(items: List, num_topics: int = 8) -> List[Dict]:
25
+ if len(items) < num_topics:
26
+ num_topics = max(2, len(items) // 2)
27
+ if len(items) < 4:
28
+ return []
29
+
30
+ from sklearn.feature_extraction.text import TfidfVectorizer
31
+ from sklearn.cluster import KMeans
32
+ import numpy as np
33
+
34
+ docs = [_clean(it.text) for it in items]
35
+ ids = [it.id for it in items]
36
+
37
+ vectorizer = TfidfVectorizer(
38
+ max_features=2000,
39
+ stop_words=list(INDO_STOPWORDS),
40
+ min_df=2,
41
+ ngram_range=(1, 2),
42
+ )
43
+ try:
44
+ X = vectorizer.fit_transform(docs)
45
+ except ValueError:
46
+ return []
47
+
48
+ if X.shape[1] == 0:
49
+ return []
50
+
51
+ k = min(num_topics, X.shape[0])
52
+ km = KMeans(n_clusters=k, random_state=42, n_init=10)
53
+ labels = km.fit_predict(X)
54
+
55
+ terms = vectorizer.get_feature_names_out()
56
+ order_centroids = km.cluster_centers_.argsort()[:, ::-1]
57
+
58
+ topics = []
59
+ for topic_id in range(k):
60
+ keywords = [terms[ind] for ind in order_centroids[topic_id, :8]]
61
+ member_ids = [ids[i] for i in range(len(ids)) if labels[i] == topic_id]
62
+ topics.append({
63
+ "topic_id": topic_id,
64
+ "label": ", ".join(keywords[:3]),
65
+ "keywords": keywords,
66
+ "article_ids": member_ids,
67
+ "size": len(member_ids),
68
+ })
69
+
70
+ topics.sort(key=lambda t: t["size"], reverse=True)
71
+ return topics
app/config.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Konfigurasi service via environment variables."""
2
+ import os
3
+
4
+
5
+ class Settings:
6
+ # Token sederhana untuk otentikasi antara Laravel <-> Python service
7
+ API_TOKEN: str = os.getenv("PY_SERVICE_TOKEN", "change-this-token")
8
+
9
+ # Mode model: "light" (sklearn/lexicon, cepat) atau "transformer" (IndoBERT, akurat)
10
+ MODEL_MODE: str = os.getenv("PY_MODEL_MODE", "light")
11
+
12
+ # Nama model transformer (dipakai bila MODEL_MODE=transformer)
13
+ SENTIMENT_MODEL: str = os.getenv(
14
+ "PY_SENTIMENT_MODEL", "w11wo/indonesian-roberta-base-sentiment-classifier"
15
+ )
16
+
17
+ HOST: str = os.getenv("PY_HOST", "0.0.0.0")
18
+ PORT: int = int(os.getenv("PY_PORT", "7860"))
19
+
20
+
21
+ settings = Settings()
app/main.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BrainWatches Python Analysis Service
3
+ ====================================
4
+ FastAPI microservice untuk analisis NLP lanjutan (sentiment transformer,
5
+ topic modeling, summarization, semantic similarity).
6
+
7
+ Dipanggil oleh Laravel via HTTP. Otentikasi via header X-Service-Token.
8
+
9
+ Jalankan:
10
+ uvicorn app.main:app --host 0.0.0.0 --port 8001
11
+ """
12
+ from fastapi import FastAPI, Header, HTTPException, Depends
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+
15
+ from app.config import settings
16
+ from app.schemas import (
17
+ SentimentRequest, SentimentResponse,
18
+ SummarizeRequest, SummarizeResponse,
19
+ TopicRequest, TopicResponse,
20
+ SimilarityRequest, SimilarityResponse,
21
+ )
22
+ from app.analyzers import sentiment, topics, summary, similarity
23
+
24
+ app = FastAPI(title="BrainWatches Analysis Service", version="1.0.0")
25
+
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=["*"],
29
+ allow_methods=["*"],
30
+ allow_headers=["*"],
31
+ )
32
+
33
+
34
+ def verify_token(x_service_token: str = Header(default="")):
35
+ if x_service_token != settings.API_TOKEN:
36
+ raise HTTPException(status_code=401, detail="Invalid service token")
37
+ return True
38
+
39
+
40
+ @app.get("/health")
41
+ def health():
42
+ return {"status": "ok", "model_mode": settings.MODEL_MODE}
43
+
44
+
45
+ @app.post("/sentiment", response_model=SentimentResponse, dependencies=[Depends(verify_token)])
46
+ def sentiment_endpoint(req: SentimentRequest):
47
+ results = sentiment.analyze_batch(req.items)
48
+ return {"results": results, "model_mode": settings.MODEL_MODE}
49
+
50
+
51
+ @app.post("/summarize", response_model=SummarizeResponse, dependencies=[Depends(verify_token)])
52
+ def summarize_endpoint(req: SummarizeRequest):
53
+ return summary.summarize(req.text, req.sentences)
54
+
55
+
56
+ @app.post("/topics", response_model=TopicResponse, dependencies=[Depends(verify_token)])
57
+ def topics_endpoint(req: TopicRequest):
58
+ result = topics.discover_topics(req.items, req.num_topics)
59
+ return {"topics": result, "model_mode": settings.MODEL_MODE}
60
+
61
+
62
+ @app.post("/similarity", response_model=SimilarityResponse, dependencies=[Depends(verify_token)])
63
+ def similarity_endpoint(req: SimilarityRequest):
64
+ pairs = similarity.find_similar_pairs(req.items, req.threshold)
65
+ return {"pairs": pairs}
66
+
67
+
68
+ if __name__ == "__main__":
69
+ import uvicorn
70
+ uvicorn.run("app.main:app", host=settings.HOST, port=settings.PORT, reload=True)
app/schemas.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic schemas untuk request/response."""
2
+ from typing import List, Optional
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class TextItem(BaseModel):
7
+ id: int
8
+ text: str
9
+
10
+
11
+ class SentimentRequest(BaseModel):
12
+ items: List[TextItem]
13
+
14
+
15
+ class SentimentResult(BaseModel):
16
+ id: int
17
+ sentiment: str # positive | negative | neutral
18
+ score: float # -1.0 .. 1.0
19
+ confidence: float
20
+
21
+
22
+ class SentimentResponse(BaseModel):
23
+ results: List[SentimentResult]
24
+ model_mode: str
25
+
26
+
27
+ class SummarizeRequest(BaseModel):
28
+ text: str
29
+ sentences: int = 3
30
+
31
+
32
+ class SummarizeResponse(BaseModel):
33
+ summary: str
34
+ sentences: List[str]
35
+
36
+
37
+ class TopicRequest(BaseModel):
38
+ items: List[TextItem]
39
+ num_topics: int = 8
40
+
41
+
42
+ class TopicCluster(BaseModel):
43
+ topic_id: int
44
+ label: str
45
+ keywords: List[str]
46
+ article_ids: List[int]
47
+ size: int
48
+
49
+
50
+ class TopicResponse(BaseModel):
51
+ topics: List[TopicCluster]
52
+ model_mode: str
53
+
54
+
55
+ class SimilarityRequest(BaseModel):
56
+ items: List[TextItem]
57
+ threshold: float = 0.3
58
+
59
+
60
+ class SimilarityPair(BaseModel):
61
+ id_a: int
62
+ id_b: int
63
+ score: float
64
+
65
+
66
+ class SimilarityResponse(BaseModel):
67
+ pairs: List[SimilarityPair]
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BrainWatches Python Analysis Service
2
+ fastapi==0.115.6
3
+ uvicorn[standard]==0.34.0
4
+ pydantic==2.10.4
5
+
6
+ # NLP ringan (default, cepat, tanpa GPU)
7
+ scikit-learn==1.6.1
8
+ numpy==2.2.1
9
+
10
+ # Opsional — model transformer Indonesia (IndoBERT). Uncomment bila ingin akurasi tinggi.
11
+ # Membutuhkan ~2GB disk + RAM lebih besar.
12
+ # torch==2.5.1
13
+ # transformers==4.48.0
14
+ # sentencepiece==0.2.0
15
+
16
+ # Summarization & similarity ringan
17
+ sumy==0.11.0
18
+ nltk==3.9.1