Spaces:
Running
Running
Commit ·
60e8c0a
1
Parent(s): f76b926
feat: BrainWatches Python Analysis Service - sentiment, topics, summarize, similarity
Browse files- Dockerfile +13 -0
- README.md +21 -9
- app/__init__.py +0 -0
- app/analyzers/__init__.py +0 -0
- app/analyzers/sentiment.py +91 -0
- app/analyzers/similarity.py +45 -0
- app/analyzers/summary.py +32 -0
- app/analyzers/topics.py +71 -0
- app/config.py +21 -0
- app/main.py +70 -0
- app/schemas.py +67 -0
- requirements.txt +18 -0
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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: indigo
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
python_version: '3.13'
|
| 9 |
-
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
-
short_description: analisisNews
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|