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

feat: add emotion, framing, fake-score, opinion-fact endpoints

Browse files
app/analyzers/emotion.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Emotion detection granular.
3
+ Deteksi emosi: anger, fear, joy, sadness, surprise, disgust, trust, anticipation.
4
+ """
5
+ from typing import List, Dict
6
+ import re
7
+
8
+ # Lexicon emosi Bahasa Indonesia (extensible)
9
+ EMOTION_LEXICON: Dict[str, List[str]] = {
10
+ "anger": ["marah", "murka", "geram", "berang", "emosi", "kesal", "jengkel", "protes", "demo", "kecam", "tolak", "rusuh", "amuk"],
11
+ "fear": ["takut", "khawatir", "cemas", "panik", "ancaman", "bahaya", "waspada", "darurat", "teror", "ngeri", "was-was", "resah"],
12
+ "joy": ["senang", "gembira", "bahagia", "sukses", "juara", "menang", "prestasi", "bangga", "puas", "optimis", "harapan", "selebrasi"],
13
+ "sadness": ["sedih", "duka", "meninggal", "tewas", "korban", "tragis", "pilu", "menderita", "kehilangan", "bela sungkawa", "nestapa"],
14
+ "surprise": ["kejutan", "mendadak", "tiba-tiba", "mengejutkan", "tak terduga", "heboh", "viral", "gempar", "terungkap", "ternyata"],
15
+ "disgust": ["jijik", "muak", "korupsi", "skandal", "busuk", "curang", "manipulasi", "penipuan", "menjijikkan", "tercela"],
16
+ "trust": ["percaya", "yakin", "aman", "terpercaya", "jaminan", "komitmen", "konsisten", "transparan", "akuntabel", "integritas"],
17
+ "anticipation": ["harap", "rencana", "siap", "target", "proyeksi", "prediksi", "antisipasi", "agenda", "akan", "berencana"],
18
+ }
19
+
20
+
21
+ def detect_emotions(text: str) -> Dict[str, float]:
22
+ text_lower = text.lower()
23
+ tokens = re.findall(r'\b\w+\b', text_lower)
24
+ token_set = set(tokens)
25
+ total_tokens = max(len(tokens), 1)
26
+
27
+ scores = {}
28
+ for emotion, keywords in EMOTION_LEXICON.items():
29
+ hits = sum(1 for kw in keywords if kw in text_lower or kw in token_set)
30
+ scores[emotion] = round(hits / total_tokens * 10, 3) # normalized
31
+
32
+ return scores
33
+
34
+
35
+ def analyze_batch(items: List) -> List[Dict]:
36
+ results = []
37
+ for item in items:
38
+ scores = detect_emotions(item.text)
39
+ dominant = max(scores, key=scores.get) if any(v > 0 for v in scores.values()) else "neutral"
40
+ results.append({
41
+ "id": item.id,
42
+ "emotions": scores,
43
+ "dominant_emotion": dominant,
44
+ "dominant_score": scores.get(dominant, 0),
45
+ })
46
+ return results
app/analyzers/fakescore.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fake news score (heuristik).
3
+ Skor 0-100 indikasi potensi hoax/misinformasi berdasarkan pola teks.
4
+ """
5
+ from typing import List, Dict
6
+ import re
7
+
8
+ # Indikator clickbait/hoax
9
+ HOAX_INDICATORS = [
10
+ "terungkap", "ternyata", "rahasia", "viral", "heboh", "bikin",
11
+ "terbongkar", "fakta mencengangkan", "anda harus tahu",
12
+ "jangan sampai", "ini dia", "wow", "gempar",
13
+ ]
14
+
15
+ CREDIBILITY_MARKERS = [
16
+ "menurut", "berdasarkan", "data", "riset", "penelitian",
17
+ "sumber", "narasumber", "konfirmasi", "verifikasi", "resmi",
18
+ ]
19
+
20
+
21
+ def calculate_fake_score(text: str, title: str = "") -> Dict:
22
+ text_lower = (title + " " + text).lower()
23
+ score = 0
24
+ reasons = []
25
+
26
+ # 1. Kata-kata pemicu hoax di judul
27
+ title_lower = title.lower()
28
+ hoax_hits = sum(1 for w in HOAX_INDICATORS if w in title_lower)
29
+ if hoax_hits > 0:
30
+ score += min(30, hoax_hits * 15)
31
+ reasons.append(f"{hoax_hits} kata pemicu hoax")
32
+
33
+ # 2. Tanda seru/tanya berlebihan
34
+ excl = title.count("!") + title.count("?")
35
+ if excl > 1:
36
+ score += min(15, excl * 7)
37
+ reasons.append(f"{excl} tanda seru/tanya")
38
+
39
+ # 3. ALL CAPS
40
+ caps = len(re.findall(r'\b[A-Z]{3,}\b', title))
41
+ if caps > 1:
42
+ score += min(15, caps * 7)
43
+ reasons.append(f"{caps} kata KAPITAL")
44
+
45
+ # 4. Tidak ada sumber/narasumber
46
+ cred_hits = sum(1 for w in CREDIBILITY_MARKERS if w in text_lower)
47
+ if cred_hits == 0:
48
+ score += 20
49
+ reasons.append("tidak ada sumber terverifikasi")
50
+ elif cred_hits >= 3:
51
+ score -= 10
52
+ reasons.append(f"{cred_hits} marker kredibilitas")
53
+
54
+ # 5. Teks sangat pendek (kurang substansi)
55
+ word_count = len(text.split())
56
+ if word_count < 50:
57
+ score += 15
58
+ reasons.append("konten sangat pendek")
59
+
60
+ # 6. Banyak klaim tanpa bukti (kalimat deklaratif tanpa attributor)
61
+ sentences = re.split(r'[.!?]', text)
62
+ declarative = sum(1 for s in sentences if len(s.strip()) > 20 and not any(m in s.lower() for m in CREDIBILITY_MARKERS))
63
+ ratio = declarative / max(len(sentences), 1)
64
+ if ratio > 0.8:
65
+ score += 10
66
+ reasons.append("mayoritas klaim tanpa atribusi")
67
+
68
+ score = max(0, min(100, score))
69
+ level = "tinggi" if score >= 60 else "sedang" if score >= 30 else "rendah"
70
+
71
+ return {"score": score, "level": level, "reasons": reasons}
72
+
73
+
74
+ def analyze_batch(items: List) -> List[Dict]:
75
+ results = []
76
+ for item in items:
77
+ # Pisah title dari text (asumsi: kalimat pertama = title)
78
+ parts = item.text.split(". ", 1)
79
+ title = parts[0] if len(parts) > 1 else ""
80
+ content = parts[1] if len(parts) > 1 else item.text
81
+ result = calculate_fake_score(content, title)
82
+ results.append({"id": item.id, **result})
83
+ return results
app/analyzers/framing.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Narrative framing detection.
3
+ Identifikasi angle/frame yang digunakan media dalam meliput suatu berita.
4
+ """
5
+ from typing import List, Dict
6
+ import re
7
+
8
+ # Frame categories berdasarkan teori framing media
9
+ FRAME_PATTERNS: Dict[str, List[str]] = {
10
+ "conflict": ["konflik", "versus", "lawan", "sengketa", "pertentangan", "tuduh", "serang", "bantah", "polemik", "debat", "perang"],
11
+ "human_interest": ["korban", "keluarga", "anak", "ibu", "kisah", "cerita", "perjuangan", "nasib", "derita", "harapan hidup"],
12
+ "economic": ["rupiah", "inflasi", "harga", "ekonomi", "bisnis", "investasi", "saham", "anggaran", "pajak", "untung", "rugi", "pasar"],
13
+ "morality": ["etika", "moral", "haram", "halal", "dosa", "adil", "korupsi", "integritas", "tanggung jawab", "amanah"],
14
+ "attribution": ["pemerintah", "presiden", "menteri", "DPR", "partai", "kebijakan", "regulasi", "aturan", "instruksi", "perintah"],
15
+ "solution": ["solusi", "program", "langkah", "upaya", "strategi", "inovasi", "rencana", "pembangunan", "perbaikan", "reformasi"],
16
+ "sensational": ["viral", "heboh", "gempar", "terungkap", "rahasia", "skandal", "mencengangkan", "bikin", "wow", "ternyata"],
17
+ }
18
+
19
+
20
+ def detect_framing(text: str) -> Dict:
21
+ text_lower = text.lower()
22
+ scores = {}
23
+
24
+ for frame, keywords in FRAME_PATTERNS.items():
25
+ hits = sum(1 for kw in keywords if kw in text_lower)
26
+ scores[frame] = hits
27
+
28
+ total = sum(scores.values())
29
+ if total == 0:
30
+ return {"frames": {k: 0.0 for k in FRAME_PATTERNS}, "dominant_frame": "neutral", "score": 0.0}
31
+
32
+ normalized = {k: round(v / total, 3) for k, v in scores.items()}
33
+ dominant = max(scores, key=scores.get)
34
+
35
+ return {
36
+ "frames": normalized,
37
+ "dominant_frame": dominant,
38
+ "score": round(scores[dominant] / total, 3),
39
+ }
40
+
41
+
42
+ def analyze_batch(items: List) -> List[Dict]:
43
+ results = []
44
+ for item in items:
45
+ framing = detect_framing(item.text)
46
+ results.append({"id": item.id, **framing})
47
+ return results
app/analyzers/opinionfact.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Opinion vs Fact classifier.
3
+ Klasifikasi apakah artikel bersifat opini/editorial atau berita faktual.
4
+ """
5
+ from typing import List, Dict
6
+ import re
7
+
8
+ OPINION_MARKERS = [
9
+ "menurut saya", "saya rasa", "seharusnya", "sebaiknya", "idealnya",
10
+ "opini", "editorial", "kolom", "perspektif", "pandangan",
11
+ "hemat saya", "saya pikir", "kita harus", "perlu diakui",
12
+ "jelas bahwa", "tidak bisa dipungkiri", "menariknya",
13
+ ]
14
+
15
+ FACT_MARKERS = [
16
+ "berdasarkan data", "menurut", "kata", "ujar", "ungkap",
17
+ "dilaporkan", "tercatat", "statistik", "survei", "rilis",
18
+ "laporan", "konferensi pers", "siaran pers", "resmi",
19
+ "diumumkan", "ditetapkan", "diresmikan",
20
+ ]
21
+
22
+ SUBJECTIVE_WORDS = [
23
+ "terbaik", "terburuk", "luar biasa", "mengecewakan", "menyedihkan",
24
+ "mengesankan", "fantastis", "mengerikan", "sempurna", "parah",
25
+ "indah", "jelek", "hebat", "bodoh", "cerdas",
26
+ ]
27
+
28
+
29
+ def classify(text: str) -> Dict:
30
+ text_lower = text.lower()
31
+
32
+ opinion_hits = sum(1 for m in OPINION_MARKERS if m in text_lower)
33
+ fact_hits = sum(1 for m in FACT_MARKERS if m in text_lower)
34
+ subjective_hits = sum(1 for w in SUBJECTIVE_WORDS if w in text_lower)
35
+
36
+ opinion_score = opinion_hits * 3 + subjective_hits * 2
37
+ fact_score = fact_hits * 3
38
+
39
+ total = opinion_score + fact_score
40
+ if total == 0:
41
+ return {"classification": "unknown", "opinion_pct": 50, "fact_pct": 50, "confidence": 0}
42
+
43
+ opinion_pct = round((opinion_score / total) * 100)
44
+ fact_pct = 100 - opinion_pct
45
+
46
+ if opinion_pct > 60:
47
+ classification = "opinion"
48
+ elif fact_pct > 60:
49
+ classification = "fact"
50
+ else:
51
+ classification = "mixed"
52
+
53
+ confidence = round(abs(opinion_pct - 50) / 50, 2)
54
+
55
+ return {
56
+ "classification": classification,
57
+ "opinion_pct": opinion_pct,
58
+ "fact_pct": fact_pct,
59
+ "confidence": confidence,
60
+ }
61
+
62
+
63
+ def analyze_batch(items: List) -> List[Dict]:
64
+ results = []
65
+ for item in items:
66
+ result = classify(item.text)
67
+ results.append({"id": item.id, **result})
68
+ return results
app/main.py CHANGED
@@ -1,13 +1,10 @@
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
@@ -18,10 +15,12 @@ from app.schemas import (
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,
@@ -39,7 +38,7 @@ def verify_token(x_service_token: str = Header(default="")):
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)])
@@ -65,6 +64,30 @@ def similarity_endpoint(req: SimilarityRequest):
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)
 
1
  """
2
  BrainWatches Python Analysis Service
3
  ====================================
4
+ FastAPI microservice untuk analisis NLP lanjutan.
 
 
 
5
 
6
  Jalankan:
7
+ uvicorn app.main:app --host 0.0.0.0 --port 7860
8
  """
9
  from fastapi import FastAPI, Header, HTTPException, Depends
10
  from fastapi.middleware.cors import CORSMiddleware
 
15
  SummarizeRequest, SummarizeResponse,
16
  TopicRequest, TopicResponse,
17
  SimilarityRequest, SimilarityResponse,
18
+ TextItemsRequest, EmotionResponse,
19
+ FramingResponse, FakeScoreResponse, OpinionFactResponse,
20
  )
21
+ from app.analyzers import sentiment, topics, summary, similarity, emotion, framing, fakescore, opinionfact
22
 
23
+ app = FastAPI(title="BrainWatches Analysis Service", version="1.1.0")
24
 
25
  app.add_middleware(
26
  CORSMiddleware,
 
38
 
39
  @app.get("/health")
40
  def health():
41
+ return {"status": "ok", "model_mode": settings.MODEL_MODE, "version": "1.1.0"}
42
 
43
 
44
  @app.post("/sentiment", response_model=SentimentResponse, dependencies=[Depends(verify_token)])
 
64
  return {"pairs": pairs}
65
 
66
 
67
+ @app.post("/emotion", response_model=EmotionResponse, dependencies=[Depends(verify_token)])
68
+ def emotion_endpoint(req: TextItemsRequest):
69
+ results = emotion.analyze_batch(req.items)
70
+ return {"results": results}
71
+
72
+
73
+ @app.post("/framing", response_model=FramingResponse, dependencies=[Depends(verify_token)])
74
+ def framing_endpoint(req: TextItemsRequest):
75
+ results = framing.analyze_batch(req.items)
76
+ return {"results": results}
77
+
78
+
79
+ @app.post("/fake-score", response_model=FakeScoreResponse, dependencies=[Depends(verify_token)])
80
+ def fake_score_endpoint(req: TextItemsRequest):
81
+ results = fakescore.analyze_batch(req.items)
82
+ return {"results": results}
83
+
84
+
85
+ @app.post("/opinion-fact", response_model=OpinionFactResponse, dependencies=[Depends(verify_token)])
86
+ def opinion_fact_endpoint(req: TextItemsRequest):
87
+ results = opinionfact.analyze_batch(req.items)
88
+ return {"results": results}
89
+
90
+
91
  if __name__ == "__main__":
92
  import uvicorn
93
  uvicorn.run("app.main:app", host=settings.HOST, port=settings.PORT, reload=True)
app/schemas.py CHANGED
@@ -1,5 +1,5 @@
1
  """Pydantic schemas untuk request/response."""
2
- from typing import List, Optional
3
  from pydantic import BaseModel
4
 
5
 
@@ -8,14 +8,22 @@ class TextItem(BaseModel):
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
 
@@ -24,6 +32,8 @@ class SentimentResponse(BaseModel):
24
  model_mode: str
25
 
26
 
 
 
27
  class SummarizeRequest(BaseModel):
28
  text: str
29
  sentences: int = 3
@@ -34,6 +44,8 @@ class SummarizeResponse(BaseModel):
34
  sentences: List[str]
35
 
36
 
 
 
37
  class TopicRequest(BaseModel):
38
  items: List[TextItem]
39
  num_topics: int = 8
@@ -52,6 +64,8 @@ class TopicResponse(BaseModel):
52
  model_mode: str
53
 
54
 
 
 
55
  class SimilarityRequest(BaseModel):
56
  items: List[TextItem]
57
  threshold: float = 0.3
@@ -65,3 +79,56 @@ class SimilarityPair(BaseModel):
65
 
66
  class SimilarityResponse(BaseModel):
67
  pairs: List[SimilarityPair]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Pydantic schemas untuk request/response."""
2
+ from typing import List, Dict, Optional
3
  from pydantic import BaseModel
4
 
5
 
 
8
  text: str
9
 
10
 
11
+ # === Shared request ===
12
+
13
+ class TextItemsRequest(BaseModel):
14
+ items: List[TextItem]
15
+
16
+
17
+ # === Sentiment ===
18
+
19
  class SentimentRequest(BaseModel):
20
  items: List[TextItem]
21
 
22
 
23
  class SentimentResult(BaseModel):
24
  id: int
25
+ sentiment: str
26
+ score: float
27
  confidence: float
28
 
29
 
 
32
  model_mode: str
33
 
34
 
35
+ # === Summarize ===
36
+
37
  class SummarizeRequest(BaseModel):
38
  text: str
39
  sentences: int = 3
 
44
  sentences: List[str]
45
 
46
 
47
+ # === Topics ===
48
+
49
  class TopicRequest(BaseModel):
50
  items: List[TextItem]
51
  num_topics: int = 8
 
64
  model_mode: str
65
 
66
 
67
+ # === Similarity ===
68
+
69
  class SimilarityRequest(BaseModel):
70
  items: List[TextItem]
71
  threshold: float = 0.3
 
79
 
80
  class SimilarityResponse(BaseModel):
81
  pairs: List[SimilarityPair]
82
+
83
+
84
+ # === Emotion ===
85
+
86
+ class EmotionResult(BaseModel):
87
+ id: int
88
+ emotions: Dict[str, float]
89
+ dominant_emotion: str
90
+ dominant_score: float
91
+
92
+
93
+ class EmotionResponse(BaseModel):
94
+ results: List[EmotionResult]
95
+
96
+
97
+ # === Framing ===
98
+
99
+ class FramingResult(BaseModel):
100
+ id: int
101
+ frames: Dict[str, float]
102
+ dominant_frame: str
103
+ score: float
104
+
105
+
106
+ class FramingResponse(BaseModel):
107
+ results: List[FramingResult]
108
+
109
+
110
+ # === Fake Score ===
111
+
112
+ class FakeScoreResult(BaseModel):
113
+ id: int
114
+ score: int
115
+ level: str
116
+ reasons: List[str]
117
+
118
+
119
+ class FakeScoreResponse(BaseModel):
120
+ results: List[FakeScoreResult]
121
+
122
+
123
+ # === Opinion vs Fact ===
124
+
125
+ class OpinionFactResult(BaseModel):
126
+ id: int
127
+ classification: str
128
+ opinion_pct: int
129
+ fact_pct: int
130
+ confidence: float
131
+
132
+
133
+ class OpinionFactResponse(BaseModel):
134
+ results: List[OpinionFactResult]