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

feat: add keywords extraction, NER, project digest endpoints

Browse files
app/analyzers/digest.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Project digest — ringkasan otomatis dari kumpulan artikel.
3
+ Menghasilkan ringkasan naratif dari berita-berita dalam project.
4
+ """
5
+ from typing import List, Dict
6
+ import re
7
+ from collections import Counter
8
+
9
+
10
+ def generate_digest(items: List, project_name: str = "") -> Dict:
11
+ """
12
+ Generate ringkasan dari kumpulan artikel.
13
+ Returns: { summary, top_topics, sentiment_overview, key_entities, article_count }
14
+ """
15
+ if not items:
16
+ return {
17
+ "summary": "Belum ada artikel untuk dirangkum.",
18
+ "top_topics": [],
19
+ "sentiment_overview": "",
20
+ "key_entities": [],
21
+ "article_count": 0,
22
+ }
23
+
24
+ # Collect all text
25
+ all_words = []
26
+ all_titles = []
27
+ for item in items:
28
+ all_titles.append(item.text.split(". ")[0] if ". " in item.text else item.text[:100])
29
+ words = re.findall(r'\b[a-zA-Z]{4,}\b', item.text.lower())
30
+ all_words.extend(words)
31
+
32
+ # Stopwords filter
33
+ stopwords = {
34
+ "yang", "dari", "untuk", "pada", "dengan", "dalam", "akan",
35
+ "juga", "tidak", "telah", "sudah", "masih", "hanya", "saja",
36
+ "adalah", "tersebut", "mereka", "oleh", "sebagai", "karena",
37
+ "republika", "okezone", "detik", "kompas", "antara", "tempo",
38
+ }
39
+ filtered = [w for w in all_words if w not in stopwords]
40
+
41
+ # Top keywords/topics
42
+ word_freq = Counter(filtered)
43
+ top_topics = [{"topic": word, "count": count} for word, count in word_freq.most_common(10)]
44
+
45
+ # Simple extractive summary: pick most representative titles
46
+ # Score titles by how many top keywords they contain
47
+ top_words_set = set(w for w, _ in word_freq.most_common(20))
48
+ scored_titles = []
49
+ for title in all_titles:
50
+ title_words = set(re.findall(r'\b[a-zA-Z]{4,}\b', title.lower()))
51
+ overlap = len(title_words & top_words_set)
52
+ scored_titles.append((overlap, title))
53
+
54
+ scored_titles.sort(key=lambda x: x[0], reverse=True)
55
+ summary_titles = [t for _, t in scored_titles[:5]]
56
+
57
+ summary = f"Dari {len(items)} artikel"
58
+ if project_name:
59
+ summary += f" dalam project \"{project_name}\""
60
+ summary += f", topik utama meliputi: {', '.join(t['topic'] for t in top_topics[:5])}. "
61
+ summary += "Berita terpenting: " + "; ".join(summary_titles[:3]) + "."
62
+
63
+ return {
64
+ "summary": summary,
65
+ "top_topics": top_topics,
66
+ "key_titles": summary_titles[:5],
67
+ "article_count": len(items),
68
+ }
app/analyzers/keywords.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Keyword extraction menggunakan YAKE (Yet Another Keyword Extractor).
3
+ Lebih akurat dari TF-IDF manual karena mempertimbangkan posisi kata,
4
+ frekuensi, dan co-occurrence.
5
+ """
6
+ from typing import List, Dict
7
+ import re
8
+
9
+ # Stopwords Indonesia untuk filtering
10
+ STOPWORDS = {
11
+ "yang", "di", "ke", "dari", "untuk", "pada", "dengan", "ini", "itu",
12
+ "dan", "atau", "adalah", "akan", "juga", "tidak", "para", "oleh",
13
+ "sebagai", "dalam", "tersebut", "ada", "dapat", "bisa", "harus",
14
+ "lebih", "sangat", "telah", "sudah", "masih", "hanya", "saja",
15
+ "republika", "okezone", "detik", "kompas", "tribunnews", "cnn",
16
+ "tempo", "antara", "merdeka", "kumparan", "news", "com",
17
+ }
18
+
19
+
20
+ def _simple_yake(text: str, top_n: int = 10) -> List[Dict]:
21
+ """
22
+ Implementasi YAKE ringan (tanpa library yake).
23
+ Scoring: kata yang jarang muncul + tidak di awal/akhir = skor rendah (lebih penting).
24
+ """
25
+ text_lower = text.lower()
26
+ # Tokenize
27
+ words = re.findall(r'\b[a-zA-Z]{3,}\b', text_lower)
28
+ if not words:
29
+ return []
30
+
31
+ # Frequency
32
+ freq = {}
33
+ positions = {}
34
+ for i, w in enumerate(words):
35
+ if w in STOPWORDS:
36
+ continue
37
+ freq[w] = freq.get(w, 0) + 1
38
+ if w not in positions:
39
+ positions[w] = i
40
+
41
+ if not freq:
42
+ return []
43
+
44
+ max_freq = max(freq.values())
45
+ total_words = len(words)
46
+
47
+ # Score: kombinasi frequency, posisi, dan panjang kata
48
+ scored = []
49
+ for word, count in freq.items():
50
+ # Frequency factor (kata terlalu sering = kurang penting)
51
+ freq_score = count / max_freq
52
+ # Position factor (kata lebih awal = lebih penting)
53
+ pos_score = positions[word] / total_words
54
+ # Length factor (kata lebih panjang = lebih bermakna)
55
+ len_score = min(1.0, len(word) / 12)
56
+
57
+ # YAKE-like score (lower = more important)
58
+ score = (freq_score * 0.4 + pos_score * 0.3) / (len_score + 0.1)
59
+ scored.append({"keyword": word, "score": round(1 - score, 3), "count": count})
60
+
61
+ # Sort by score descending (higher = more important)
62
+ scored.sort(key=lambda x: x["score"], reverse=True)
63
+ return scored[:top_n]
64
+
65
+
66
+ def extract_keywords_batch(items: List, top_n: int = 10) -> List[Dict]:
67
+ results = []
68
+ for item in items:
69
+ keywords = _simple_yake(item.text, top_n)
70
+ results.append({"id": item.id, "keywords": keywords})
71
+ return results
app/analyzers/ner.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Named Entity Recognition (NER) — rule-based + pattern enhanced.
3
+ Lebih presisi dari regex sederhana: gunakan gazetteer + context patterns.
4
+ """
5
+ from typing import List, Dict
6
+ import re
7
+
8
+ # Gazetteer Indonesia (expandable)
9
+ PERSON_TITLES = [
10
+ "presiden", "menteri", "gubernur", "bupati", "walikota", "calon",
11
+ "ketua", "wakil", "direktur", "komisaris", "jenderal", "kolonel",
12
+ "mayor", "kapten", "prof", "dr", "ir", "haji", "ustaz", "kyai",
13
+ ]
14
+
15
+ ORG_KEYWORDS = [
16
+ "kementerian", "badan", "dewan", "komisi", "partai", "pt", "tbk",
17
+ "universitas", "institut", "polri", "tni", "bpk", "kpk", "ojk",
18
+ "bi", "bps", "bmkg", "bnpb", "baznas", "mui", "nu", "muhammadiyah",
19
+ "perserikatan", "organisasi", "perusahaan", "bank", "asosiasi",
20
+ ]
21
+
22
+ LOCATION_KEYWORDS = [
23
+ "jakarta", "surabaya", "bandung", "medan", "semarang", "makassar",
24
+ "yogyakarta", "denpasar", "palembang", "manado", "padang", "solo",
25
+ "indonesia", "jawa", "sumatera", "kalimantan", "sulawesi", "papua",
26
+ "bali", "ntt", "ntb", "aceh", "riau", "lampung", "maluku",
27
+ "provinsi", "kabupaten", "kota", "desa", "kecamatan",
28
+ ]
29
+
30
+
31
+ def _is_capitalized_phrase(text: str, start: int, end: int) -> bool:
32
+ """Cek apakah span memiliki kata yang diawali huruf besar."""
33
+ span = text[start:end]
34
+ words = span.split()
35
+ return any(w[0].isupper() for w in words if w)
36
+
37
+
38
+ def extract_entities(text: str) -> Dict[str, List[str]]:
39
+ """Extract persons, organizations, locations dari text."""
40
+ persons = set()
41
+ organizations = set()
42
+ locations = set()
43
+
44
+ text_lower = text.lower()
45
+ words = text.split()
46
+
47
+ # Pattern: Title + Capitalized Name (person)
48
+ for i, word in enumerate(words):
49
+ word_lower = word.lower().strip(".,;:!?\"'()")
50
+ if word_lower in PERSON_TITLES and i + 1 < len(words):
51
+ # Ambil 1-3 kata setelah title sebagai nama
52
+ name_parts = []
53
+ for j in range(i + 1, min(i + 4, len(words))):
54
+ w = words[j].strip(".,;:!?\"'()")
55
+ if w and w[0].isupper():
56
+ name_parts.append(w)
57
+ else:
58
+ break
59
+ if name_parts:
60
+ persons.add(" ".join(name_parts))
61
+
62
+ # Pattern: Capitalized consecutive words (potential names/orgs)
63
+ cap_pattern = re.finditer(r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b', text)
64
+ for match in cap_pattern:
65
+ phrase = match.group(1)
66
+ phrase_lower = phrase.lower()
67
+ # Classify based on context
68
+ if any(kw in phrase_lower for kw in ORG_KEYWORDS):
69
+ organizations.add(phrase)
70
+ elif any(kw in phrase_lower for kw in LOCATION_KEYWORDS):
71
+ locations.add(phrase)
72
+ elif len(phrase.split()) <= 3:
73
+ persons.add(phrase)
74
+
75
+ # Direct keyword matching for organizations
76
+ for kw in ORG_KEYWORDS:
77
+ pattern = re.finditer(rf'\b{re.escape(kw)}\s+([A-Z][a-zA-Z\s]{{2,30}})', text, re.IGNORECASE)
78
+ for m in pattern:
79
+ organizations.add(m.group(0).strip())
80
+
81
+ # Location extraction
82
+ for kw in LOCATION_KEYWORDS:
83
+ if kw in text_lower:
84
+ locations.add(kw.title())
85
+
86
+ return {
87
+ "persons": list(persons)[:20],
88
+ "organizations": list(organizations)[:15],
89
+ "locations": list(locations)[:15],
90
+ }
91
+
92
+
93
+ def extract_batch(items: List) -> List[Dict]:
94
+ results = []
95
+ for item in items:
96
+ entities = extract_entities(item.text)
97
+ results.append({"id": item.id, "entities": entities})
98
+ return results
app/main.py CHANGED
@@ -17,8 +17,10 @@ from app.schemas import (
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
 
@@ -88,6 +90,24 @@ def opinion_fact_endpoint(req: TextItemsRequest):
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)
 
17
  SimilarityRequest, SimilarityResponse,
18
  TextItemsRequest, EmotionResponse,
19
  FramingResponse, FakeScoreResponse, OpinionFactResponse,
20
+ KeywordsResponse, NerResponse, DigestRequest, DigestResponse,
21
  )
22
  from app.analyzers import sentiment, topics, summary, similarity, emotion, framing, fakescore, opinionfact
23
+ from app.analyzers import keywords as kw_module, ner as ner_module, digest as digest_module
24
 
25
  app = FastAPI(title="BrainWatches Analysis Service", version="1.1.0")
26
 
 
90
  return {"results": results}
91
 
92
 
93
+ @app.post("/keywords", response_model=KeywordsResponse, dependencies=[Depends(verify_token)])
94
+ def keywords_endpoint(req: TextItemsRequest):
95
+ results = kw_module.extract_keywords_batch(req.items)
96
+ return {"results": results}
97
+
98
+
99
+ @app.post("/ner", response_model=NerResponse, dependencies=[Depends(verify_token)])
100
+ def ner_endpoint(req: TextItemsRequest):
101
+ results = ner_module.extract_batch(req.items)
102
+ return {"results": results}
103
+
104
+
105
+ @app.post("/digest", response_model=DigestResponse, dependencies=[Depends(verify_token)])
106
+ def digest_endpoint(req: DigestRequest):
107
+ result = digest_module.generate_digest(req.items, req.project_name)
108
+ return result
109
+
110
+
111
  if __name__ == "__main__":
112
  import uvicorn
113
  uvicorn.run("app.main:app", host=settings.HOST, port=settings.PORT, reload=True)
app/schemas.py CHANGED
@@ -132,3 +132,56 @@ class OpinionFactResult(BaseModel):
132
 
133
  class OpinionFactResponse(BaseModel):
134
  results: List[OpinionFactResult]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
  class OpinionFactResponse(BaseModel):
134
  results: List[OpinionFactResult]
135
+
136
+
137
+ # === Keywords ===
138
+
139
+ class KeywordItem(BaseModel):
140
+ keyword: str
141
+ score: float
142
+ count: int
143
+
144
+
145
+ class KeywordsResult(BaseModel):
146
+ id: int
147
+ keywords: List[KeywordItem]
148
+
149
+
150
+ class KeywordsResponse(BaseModel):
151
+ results: List[KeywordsResult]
152
+
153
+
154
+ # === NER ===
155
+
156
+ class EntitiesMap(BaseModel):
157
+ persons: List[str]
158
+ organizations: List[str]
159
+ locations: List[str]
160
+
161
+
162
+ class NerResult(BaseModel):
163
+ id: int
164
+ entities: EntitiesMap
165
+
166
+
167
+ class NerResponse(BaseModel):
168
+ results: List[NerResult]
169
+
170
+
171
+ # === Digest ===
172
+
173
+ class DigestRequest(BaseModel):
174
+ items: List[TextItem]
175
+ project_name: str = ""
176
+
177
+
178
+ class DigestTopicItem(BaseModel):
179
+ topic: str
180
+ count: int
181
+
182
+
183
+ class DigestResponse(BaseModel):
184
+ summary: str
185
+ top_topics: List[DigestTopicItem]
186
+ key_titles: List[str]
187
+ article_count: int