-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmain.py
More file actions
1263 lines (1033 loc) · 52.6 KB
/
Copy pathmain.py
File metadata and controls
1263 lines (1033 loc) · 52.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Social Listening AI Analysis API — Python backend for LLM-powered insights."""
import json
import logging
import os
import re
import google.generativeai as genai
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import base64
import io
from datetime import datetime, timezone
from PIL import Image
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import seaborn as sns
from wordcloud import WordCloud
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
app = FastAPI(title="Social Listening AI Analysis API", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
class SentimentCount(BaseModel):
positive: int = 0
neutral: int = 0
negative: int = 0
class AccountSnapshot(BaseModel):
followers: int = 0
posts_scraped: int = 0
avg_likes: int = 0
total_engagement: int = 0
sentiment: SentimentCount = SentimentCount()
class AnalysisRequest(BaseModel):
kalventis: AccountSnapshot
gsk: AccountSnapshot
top_topics: list[str] = []
news_count: int = 0
period: str = ""
top_words: list[str] = []
follower_ratio: float = 1.0
post_ratio: float = 1.0
class AnalysisResponse(BaseModel):
executive_summary: str
kalventis_insights: str
gsk_insights: str
recommendations: list[str]
risk_indicators: list[str]
opportunities: list[str]
sentiment_chart_b64: str | None = None
engagement_chart_b64: str | None = None
wordcloud_chart_b64: str | None = None
def _extract_json(raw: str) -> dict:
"""Extract the first balanced JSON object from an LLM response.
Handles markdown fences, surrounding text, trailing commas, and
invisible characters (BOM, zero-width spaces). Uses a character-
counting brace tracker instead of a greedy regex so that the
response can contain multiple ``{…}`` blocks without confusion.
"""
if not raw:
raise json.JSONDecodeError("Empty LLM response", "", 0)
cleaned = raw.strip().lstrip("\ufeff")
# Fast path: already clean JSON.
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
# Strip markdown code fences.
cleaned = re.sub(r"```(?:json)?\s*", "", cleaned)
cleaned = cleaned.replace("```", "")
# Find the first balanced top-level ``{ … }`` block.
start = cleaned.find("{")
if start == -1:
raise json.JSONDecodeError("No opening brace in LLM response", cleaned, 0)
depth = 0
in_string = False
escape = False
for i in range(start, len(cleaned)):
ch = cleaned[i]
if escape:
escape = False
continue
if ch == "\\":
escape = True
continue
if ch == '"':
in_string = not in_string
continue
if in_string:
continue
if ch in "{[":
depth += 1
elif ch in "}]":
depth -= 1
if depth == 0:
candidate = cleaned[start : i + 1]
candidate = re.sub(r",\s*([}\]])", r"\1", candidate)
return json.loads(candidate)
raise json.JSONDecodeError(
f"Unbalanced braces in LLM response (length={len(cleaned)})",
cleaned[:200],
0,
)
sns.set_theme(style="whitegrid", palette="muted")
def _fig_to_b64(fig: plt.Figure) -> str:
"""Encode a matplotlib Figure as a base64 data-URI string."""
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=120, bbox_inches="tight")
buf.seek(0)
return base64.b64encode(buf.read()).decode()
def _fig_to_pil(fig: plt.Figure) -> Image.Image:
"""Encode a matplotlib Figure as a PIL Image for multimodal LLM input."""
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=120, bbox_inches="tight")
buf.seek(0)
return Image.open(buf).copy()
def _render_sentiment_chart(
sentiment_a: "SentimentCount",
sentiment_b: "SentimentCount",
label_a: str,
label_b: str,
) -> str:
"""Grouped bar chart: positive / neutral / negative for two brands."""
categories = ["Positive", "Neutral", "Negative"]
brand_a_vals = [sentiment_a.positive, sentiment_a.neutral, sentiment_a.negative]
brand_b_vals = [sentiment_b.positive, sentiment_b.neutral, sentiment_b.negative]
x = range(len(categories))
width = 0.35
fig, ax = plt.subplots(figsize=(7, 4.5))
bars_a = ax.bar([i - width / 2 for i in x], brand_a_vals, width, label=label_a, color="#3b82f6")
bars_b = ax.bar([i + width / 2 for i in x], brand_b_vals, width, label=label_b, color="#f59e0b")
ax.set_ylabel("Post Count")
ax.set_title("Sentiment Distribution by Brand")
ax.set_xticks(x)
ax.set_xticklabels(categories)
ax.legend()
for bar in bars_a:
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.5,
str(int(bar.get_height())), ha="center", va="bottom", fontsize=8)
for bar in bars_b:
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.5,
str(int(bar.get_height())), ha="center", va="bottom", fontsize=8)
b64 = _fig_to_b64(fig)
plt.close(fig)
return b64
def _render_engagement_chart(
acc_a: "AccountSnapshot",
acc_b: "AccountSnapshot",
label_a: str,
label_b: str,
) -> str:
"""Grouped bar chart comparing engagement metrics for two brands."""
metrics = ["Followers", "Total\nEngagement", "Avg Likes"]
vals_a = [acc_a.followers, acc_a.total_engagement, acc_a.avg_likes]
vals_b = [acc_b.followers, acc_b.total_engagement, acc_b.avg_likes]
x = range(len(metrics))
width = 0.35
fig, ax = plt.subplots(figsize=(7, 4.5))
bars_a = ax.bar([i - width / 2 for i in x], vals_a, width, label=label_a, color="#3b82f6")
bars_b = ax.bar([i + width / 2 for i in x], vals_b, width, label=label_b, color="#f59e0b")
ax.set_ylabel("Count")
ax.set_title("Engagement Metrics Comparison")
ax.set_xticks(x)
ax.set_xticklabels(metrics)
ax.legend()
def _fmt(v: int) -> str:
if v >= 1_000_000:
return f"{v/1_000_000:.1f}M"
if v >= 1_000:
return f"{v/1_000:.1f}K"
return str(v)
for bar in bars_a:
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.5,
_fmt(int(bar.get_height())), ha="center", va="bottom", fontsize=8)
for bar in bars_b:
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.5,
_fmt(int(bar.get_height())), ha="center", va="bottom", fontsize=8)
b64 = _fig_to_b64(fig)
plt.close(fig)
return b64
def _render_wordcloud_chart(words: list[str]) -> str | None:
"""Generate a word cloud PNG from a list of words.
Uses positional weighting: earlier words get higher frequency so they
appear larger. Returns ``None`` when the word list is empty.
"""
if not words:
return None
# Build frequency dict with positional weighting (first = heaviest).
n = len(words)
freqs: dict[str, float] = {}
for i, word in enumerate(words):
w = word.strip().lower()
if not w:
continue
weight = max(1.0, (n - i) * (100.0 / n))
freqs[w] = freqs.get(w, 0) + weight
if not freqs:
return None
wc = WordCloud(
width=800,
height=400,
max_words=60,
background_color="white",
colormap="viridis",
collocations=False,
).generate_from_frequencies(freqs)
fig, ax = plt.subplots(figsize=(8, 4))
ax.imshow(wc, interpolation="bilinear")
ax.axis("off")
b64 = _fig_to_b64(fig)
plt.close(fig)
return b64
def _render_sentiment_chart_for_llm(
sentiment_a: "SentimentCount",
sentiment_b: "SentimentCount",
label_a: str,
label_b: str,
) -> Image.Image:
"""Simple grouped bar chart sent to the LLM for visual analysis."""
categories = ["Positive", "Neutral", "Negative"]
vals_a = [sentiment_a.positive, sentiment_a.neutral, sentiment_a.negative]
vals_b = [sentiment_b.positive, sentiment_b.neutral, sentiment_b.negative]
x = range(len(categories))
width = 0.35
fig, ax = plt.subplots(figsize=(7, 4.5))
bars_a = ax.bar([i - width / 2 for i in x], vals_a, width, label=label_a, color="#3b82f6")
bars_b = ax.bar([i + width / 2 for i in x], vals_b, width, label=label_b, color="#f59e0b")
ax.set_ylabel("Post Count")
ax.set_title("Sentiment Distribution by Brand")
ax.set_xticks(list(x))
ax.set_xticklabels(categories)
ax.legend()
for bar in bars_a:
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.5,
str(int(bar.get_height())), ha="center", va="bottom", fontsize=8)
for bar in bars_b:
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.5,
str(int(bar.get_height())), ha="center", va="bottom", fontsize=8)
img = _fig_to_pil(fig)
plt.close(fig)
return img
def _render_sentiment_chart_modern(
sentiment_a: "SentimentCount",
sentiment_b: "SentimentCount",
label_a: str,
label_b: str,
) -> str:
"""Modern horizontal grouped bar chart shown to the frontend."""
categories = ["Positive", "Neutral", "Negative"]
vals_a = [sentiment_a.positive, sentiment_a.neutral, sentiment_a.negative]
vals_b = [sentiment_b.positive, sentiment_b.neutral, sentiment_b.negative]
total_a = max(1, sum(vals_a))
total_b = max(1, sum(vals_b))
fig, ax = plt.subplots(figsize=(9, 5))
fig.patch.set_facecolor("#ffffff")
ax.set_facecolor("#f8f9fc")
y = list(range(len(categories)))
height = 0.32
bars_a = ax.barh([i + height / 2 for i in y], vals_a, height,
label=label_a, color="#2557d6", alpha=0.90)
bars_b = ax.barh([i - height / 2 for i in y], vals_b, height,
label=label_b, color="#12a594", alpha=0.90)
max_val = max(max(vals_a, default=1), max(vals_b, default=1), 1)
for bar, val, tot in zip(bars_a, vals_a, [total_a] * 3):
ax.text(bar.get_width() + max_val * 0.02,
bar.get_y() + bar.get_height() / 2,
f"{val} ({val / tot * 100:.0f}%)",
va="center", fontsize=9, color="#374151")
for bar, val, tot in zip(bars_b, vals_b, [total_b] * 3):
ax.text(bar.get_width() + max_val * 0.02,
bar.get_y() + bar.get_height() / 2,
f"{val} ({val / tot * 100:.0f}%)",
va="center", fontsize=9, color="#374151")
ax.set_yticks(y)
ax.set_yticklabels(categories, fontsize=11)
ax.set_xlabel("Post Count", fontsize=10, color="#6b7280")
ax.set_title("Sentiment Distribution by Brand", fontsize=14, fontweight="bold",
color="#111827", pad=16)
ax.set_xlim(0, max_val * 1.40)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_visible(False)
ax.tick_params(left=False)
ax.xaxis.grid(True, linestyle="--", alpha=0.35, color="#d1d5db")
ax.set_axisbelow(True)
ax.legend(fontsize=10, loc="lower right", framealpha=0.9)
plt.tight_layout()
b64 = _fig_to_b64(fig)
plt.close(fig)
return b64
def _render_engagement_chart_for_llm(
acc_a: "AccountSnapshot",
acc_b: "AccountSnapshot",
label_a: str,
label_b: str,
) -> Image.Image:
"""Simple grouped bar chart sent to the LLM for visual analysis."""
metrics = ["Followers", "Total\nEngagement", "Avg Likes"]
vals_a = [acc_a.followers, acc_a.total_engagement, acc_a.avg_likes]
vals_b = [acc_b.followers, acc_b.total_engagement, acc_b.avg_likes]
x = range(len(metrics))
width = 0.35
fig, ax = plt.subplots(figsize=(7, 4.5))
bars_a = ax.bar([i - width / 2 for i in x], vals_a, width, label=label_a, color="#3b82f6")
bars_b = ax.bar([i + width / 2 for i in x], vals_b, width, label=label_b, color="#f59e0b")
ax.set_ylabel("Count")
ax.set_title("Engagement Metrics Comparison")
ax.set_xticks(list(x))
ax.set_xticklabels(metrics)
ax.legend()
def _fmt(v: int) -> str:
if v >= 1_000_000: return f"{v / 1_000_000:.1f}M"
if v >= 1_000: return f"{v / 1_000:.1f}K"
return str(v)
for bar in bars_a:
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.5,
_fmt(int(bar.get_height())), ha="center", va="bottom", fontsize=8)
for bar in bars_b:
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.5,
_fmt(int(bar.get_height())), ha="center", va="bottom", fontsize=8)
img = _fig_to_pil(fig)
plt.close(fig)
return img
def _render_engagement_chart_modern(
acc_a: "AccountSnapshot",
acc_b: "AccountSnapshot",
label_a: str,
label_b: str,
) -> str:
"""Modern three-panel engagement comparison shown to the frontend."""
def _fmt(v: int) -> str:
if v >= 1_000_000: return f"{v / 1_000_000:.1f}M"
if v >= 1_000: return f"{v / 1_000:.1f}K"
return str(v)
panels = [
("Followers", acc_a.followers, acc_b.followers),
("Total Engagement", acc_a.total_engagement, acc_b.total_engagement),
("Avg Likes / Post", acc_a.avg_likes, acc_b.avg_likes),
]
fig, axes = plt.subplots(1, 3, figsize=(11, 4.5))
fig.patch.set_facecolor("#ffffff")
for ax, (title, val_a, val_b) in zip(axes, panels):
ax.set_facecolor("#f8f9fc")
total = max(1, val_a + val_b)
bars = ax.bar(
[label_a, label_b], [val_a, val_b],
color=["#2557d6", "#12a594"], alpha=0.88, width=0.45,
)
for bar, val in zip(bars, [val_a, val_b]):
ax.text(
bar.get_x() + bar.get_width() / 2,
bar.get_height() + total * 0.025,
_fmt(val), ha="center", va="bottom",
fontsize=11, fontweight="bold", color="#111827",
)
ax.set_title(title, fontsize=11, fontweight="bold", color="#374151", pad=10)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_visible(False)
ax.tick_params(left=False, labelleft=False)
ax.tick_params(axis="x", labelsize=9)
ax.set_axisbelow(True)
fig.suptitle(
f"Engagement Metrics — {label_a} vs {label_b}",
fontsize=13, fontweight="bold", color="#111827", y=1.02,
)
plt.tight_layout()
b64 = _fig_to_b64(fig)
plt.close(fig)
return b64
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/v1/analysis")
async def generate_analysis(request: AnalysisRequest) -> AnalysisResponse:
if not GEMINI_API_KEY:
return AnalysisResponse(
executive_summary="Gemini API key not configured. Add GEMINI_API_KEY to .env to enable AI analysis.",
kalventis_insights="Configure GEMINI_API_KEY to enable Kalventis insights.",
gsk_insights="Configure GEMINI_API_KEY to enable GSK competitive insights.",
recommendations=["Add GEMINI_API_KEY to D:\\fastapi_all\\python-social-listening\\.env"],
risk_indicators=[],
opportunities=[],
)
try:
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel(GEMINI_MODEL)
kv = request.kalventis
gsk = request.gsk
kv_total = kv.sentiment.positive + kv.sentiment.neutral + kv.sentiment.negative
gsk_total = gsk.sentiment.positive + gsk.sentiment.neutral + gsk.sentiment.negative
kv_pos_rate = f"{(kv.sentiment.positive / max(1, kv_total) * 100):.1f}%"
gsk_pos_rate = f"{(gsk.sentiment.positive / max(1, gsk_total) * 100):.1f}%"
prompt = f"""You are a senior social media analyst for Kalventis, an Indonesian vaccine awareness brand (@kenapaharusvaksin).
Analyze the following social listening data and provide strategic insights. Be specific, data-driven, and actionable.
=== MONITORING DATA ({request.period}) ===
KALVENTIS (@kenapaharusvaksin) — Owned Brand:
- Followers: {kv.followers:,}
- Posts scraped: {kv.posts_scraped}
- Avg likes/post: {kv.avg_likes}
- Total engagement: {kv.total_engagement:,}
- Sentiment: {kv.sentiment.positive} positive / {kv.sentiment.neutral} neutral / {kv.sentiment.negative} negative → {kv_pos_rate} positive rate
GSK (@ayokitavaksin) — Competitor:
- Followers: {gsk.followers:,}
- Posts scraped: {gsk.posts_scraped}
- Avg likes/post: {gsk.avg_likes}
- Total engagement: {gsk.total_engagement:,}
- Sentiment: {gsk.sentiment.positive} positive / {gsk.sentiment.neutral} neutral / {gsk.sentiment.negative} negative → {gsk_pos_rate} positive rate
COMPETITIVE RATIOS:
- Follower ratio: Kalventis is {request.follower_ratio:.1f}x larger than GSK
- Post volume ratio: Kalventis is {request.post_ratio:.1f}x more active than GSK
MARKET CONTEXT:
- Active vaccine topics: {', '.join(request.top_topics) if request.top_topics else 'None tracked'}
- News articles monitored: {request.news_count}
- Most mentioned terms: {', '.join(request.top_words[:12]) if request.top_words else 'Not available'}
=== INSTRUCTIONS ===
Respond ONLY in valid JSON with exactly these keys (no markdown, no code blocks):
{{
"executive_summary": "2-3 sentence overall summary of competitive landscape and Kalventis position",
"kalventis_insights": "2-3 sentences on Kalventis performance, content effectiveness, and audience engagement",
"gsk_insights": "2-3 sentences on GSK competitive posture and what Kalventis team should know",
"recommendations": ["specific action 1", "specific action 2", "specific action 3", "specific action 4"],
"risk_indicators": ["specific risk 1", "specific risk 2"],
"opportunities": ["growth opportunity 1", "growth opportunity 2", "growth opportunity 3"]
}}
Focus on vaccine awareness, public health education in Indonesia, and practical content strategy advice."""
response = model.generate_content(prompt)
raw = response.text.strip()
data = _extract_json(raw)
sentiment_b64 = _render_sentiment_chart(kv.sentiment, gsk.sentiment, "Kalventis", "GSK")
engagement_b64 = _render_engagement_chart(kv, gsk, "Kalventis", "GSK")
wordcloud_b64 = _render_wordcloud_chart(request.top_words)
return AnalysisResponse(
executive_summary=data.get("executive_summary", ""),
kalventis_insights=data.get("kalventis_insights", ""),
gsk_insights=data.get("gsk_insights", ""),
recommendations=data.get("recommendations", []),
risk_indicators=data.get("risk_indicators", []),
opportunities=data.get("opportunities", []),
sentiment_chart_b64=sentiment_b64,
engagement_chart_b64=engagement_b64,
wordcloud_chart_b64=wordcloud_b64,
)
except json.JSONDecodeError as e:
logger.error(f"JSON parse error from Gemini: {e}")
logger.error(f"Raw response (first 500 chars): {raw[:500]}")
return AnalysisResponse(
executive_summary="Analysis generated but could not be parsed. Please retry.",
kalventis_insights="", gsk_insights="",
recommendations=[], risk_indicators=[], opportunities=[],
)
except Exception as e:
logger.error(f"Analysis error: {e}")
return AnalysisResponse(
executive_summary=f"Analysis unavailable: {str(e)}",
kalventis_insights="", gsk_insights="",
recommendations=[], risk_indicators=[], opportunities=[],
)
class DeepAnalysisRequest(BaseModel):
brand_a_name: str
brand_a_username: str
brand_b_name: str
brand_b_username: str
brand_a: AccountSnapshot
brand_b: AccountSnapshot
comparison: dict
top_terms: list[str] = []
top_posts: list[dict] = []
top_comments: list[dict] = []
coverage: dict | None = None
period: str = ""
language: str = "en"
class DeepAnalysisResponse(BaseModel):
executive_summary: str
brand_a_insights: str
brand_b_insights: str
content_strategy: str
competitive_analysis: str
audience_insights: str
sentiment_deep_dive: str
risk_assessment: list[str]
growth_opportunities: list[str]
recommendations: list[str]
sentiment_chart_insight: list[str] = []
engagement_chart_insight: list[str] = []
wordcloud_insight: list[str] = []
sentiment_chart_b64: str | None = None
engagement_chart_b64: str | None = None
wordcloud_chart_b64: str | None = None
@app.post("/api/v1/monitoring/analysis")
async def deep_monitoring_analysis(request: DeepAnalysisRequest) -> DeepAnalysisResponse:
if not GEMINI_API_KEY:
return DeepAnalysisResponse(
executive_summary="Gemini API key not configured.",
brand_a_insights="", brand_b_insights="",
content_strategy="", competitive_analysis="",
audience_insights="", sentiment_deep_dive="",
risk_assessment=[], growth_opportunities=[], recommendations=[],
)
try:
raw = ""
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel(GEMINI_MODEL)
a = request.brand_a
b = request.brand_b
a_total = a.sentiment.positive + a.sentiment.neutral + a.sentiment.negative
b_total = b.sentiment.positive + b.sentiment.neutral + b.sentiment.negative
a_pos_rate = f"{(a.sentiment.positive / max(1, a_total) * 100):.1f}%"
b_pos_rate = f"{(b.sentiment.positive / max(1, b_total) * 100):.1f}%"
a_neg_rate = f"{(a.sentiment.negative / max(1, a_total) * 100):.1f}%"
b_neg_rate = f"{(b.sentiment.negative / max(1, b_total) * 100):.1f}%"
comp = request.comparison
top_posts_text = "\n".join(
f" [{p.get('side','')}] @{p.get('username','')}: \"{p.get('caption','')[:200]}\" — {p.get('engagement',0)} engagement, sentiment: {p.get('sentiment','Neutral')}"
for p in request.top_posts[:10]
) if request.top_posts else "No post samples available"
top_comments_text = "\n".join(
f" [{c.get('side','')}] @{c.get('ownerUsername','')}: \"{c.get('text','')[:200]}\""
for c in request.top_comments[:8]
) if request.top_comments else "No comment samples available"
lang_instr = "PENTING: Anda HARUS merespons dalam bahasa Indonesia saja. Jangan gunakan bahasa Inggris sama sekali." if request.language == "id" else "Respond in English only."
analyst_role = (
"Anda adalah konsultan senior komunikasi kesehatan masyarakat dengan pengalaman 15+ tahun dalam analisis media sosial, strategi konten vaksin, dan competitive intelligence di pasar Indonesia."
if request.language == "id"
else "You are a senior public health communications consultant with 15+ years of experience in social media analytics, vaccine content strategy, and competitive intelligence."
)
prompt = f"""{lang_instr}
{analyst_role}
Analyze the following comprehensive social media monitoring data for two brands. Provide deep, data-driven strategic analysis. Reference specific numbers from the data. Be candid about weaknesses and specific about opportunities.
=== BRAND A: {request.brand_a_name} (@{request.brand_a_username}) ===
- Followers: {a.followers:,}
- Posts scraped: {a.posts_scraped}
- Avg likes/post: {a.avg_likes:,}
- Avg comments/post: N/A (see engagement total)
- Total engagement: {a.total_engagement:,}
- Sentiment: {a.sentiment.positive} positive / {a.sentiment.neutral} neutral / {a.sentiment.negative} negative → {a_pos_rate} positive rate, {a_neg_rate} negative rate
=== BRAND B: {request.brand_b_name} (@{request.brand_b_username}) ===
- Followers: {b.followers:,}
- Posts scraped: {b.posts_scraped}
- Avg likes/post: {b.avg_likes:,}
- Total engagement: {b.total_engagement:,}
- Sentiment: {b.sentiment.positive} positive / {b.sentiment.neutral} neutral / {b.sentiment.negative} negative → {b_pos_rate} positive rate, {b_neg_rate} negative rate
=== COMPETITIVE COMPARISON ===
- Total engagement across both brands: {comp.get('engagementTotal', 0):,}
- {request.brand_a_name} engagement share: {comp.get('brandAEngagementShare', 0)}%
- {request.brand_b_name} engagement share: {comp.get('brandBEngagementShare', 0)}%
- {request.brand_a_name} post share: {comp.get('brandAPostShare', 0)}%
- {request.brand_b_name} post share: {comp.get('brandBPostShare', 0)}%
=== TOP TERMS (word cloud) ===
{', '.join(request.top_terms[:15]) if request.top_terms else 'Not available'}
=== SAMPLE TOP POSTS (by engagement) ===
{top_posts_text}
=== SAMPLE AUDIENCE COMMENTS ===
{top_comments_text}
=== COVERAGE ASSESSMENT ===
- Status: {request.coverage.get('status','unknown') if request.coverage else 'unknown'}
- Score: {request.coverage.get('score','N/A')}%
- Posts with timestamps: {request.coverage.get('postsWithTimestamps',0) if request.coverage else 0}
- Note: {request.coverage.get('coverageNote','') if request.coverage else 'N/A'}
=== MONITORING PERIOD ===
{request.period or 'Recent scan window'}
=== CHART IMAGES FOR VISUAL ANALYSIS ===
Two chart images are attached to this request:
- Image 1: Sentiment Distribution Chart — grouped bar chart showing positive, neutral, and negative post counts for both brands side by side
- Image 2: Engagement Metrics Chart — grouped bar chart showing followers, total engagement, and avg likes per post for both brands
Study these charts carefully when writing the chart-specific insight fields below.
=== INSTRUCTIONS ===
Respond ONLY in valid JSON with exactly these keys. Every string field MUST contain substantive analysis (4-6 sentences minimum). Every list field MUST have 4-5 items. No markdown, no code fences.
{{
"executive_summary": "4-5 sentence synthesis of the competitive landscape. State who leads on each key dimension (followers, engagement, sentiment), quantify the gap with exact numbers, explain what structural advantage or content pattern is driving it, and name the single highest-leverage insight a strategist should act on immediately.",
"brand_a_insights": "4-6 sentences of deep analysis of {request.brand_a_name}. Cover: (1) content performance patterns and which themes drive the most engagement, (2) engagement quality (engagement-per-post vs raw volume), (3) their 2-3 strongest content pillars backed by the data, (4) their most visible weakness, and (5) one non-obvious opportunity hidden in the numbers.",
"brand_b_insights": "4-6 sentences of deep analysis of {request.brand_b_name}. Same depth as above — cover their competitive differentiation, where they outperform and underperform, what the engagement data reveals about their content quality, and one strategic move that would materially close the gap with {request.brand_a_name}.",
"content_strategy": "4-6 sentences of cross-brand content strategy. Name the specific themes (from the top terms and post samples) that drive the highest engagement across both brands. Identify which formats or caption styles correlate with higher engagement. Give one concrete recommendation for what each brand should do more of and one thing to stop.",
"competitive_analysis": "4-6 sentences of head-to-head competitive positioning. State exact market share of voice percentages. Calculate and compare engagement efficiency (engagement ÷ followers) for both brands. Analyse content frequency vs content quality tradeoffs. Identify which conversation topics each brand owns and which are contested.",
"audience_insights": "4-6 sentences on what the audience reveals through comments and engagement. Quote or paraphrase specific themes from the comment samples. Identify unmet information needs or repeated questions. Describe the language register (technical, conversational, emotional). Flag any sentiment patterns in comments that differ from post-level sentiment.",
"sentiment_deep_dive": "4-6 sentences going beyond raw percentages. Explain what specific content types or topics appear to DRIVE positive vs negative sentiment. Identify whether negative sentiment is brand-specific or reflects market-wide attitudes (e.g. vaccine hesitancy). Describe the neutral cohort — are they fence-sitters or low-intent? Give one tactic to shift neutral to positive.",
"risk_assessment": ["Specific, concrete risk 1 with severity level (High/Medium/Low)", "Risk 2 with severity", "Risk 3 with severity", "Risk 4 with severity", "Risk 5 with severity"],
"growth_opportunities": ["Specific, immediately actionable opportunity 1 with expected impact", "Opportunity 2", "Opportunity 3", "Opportunity 4", "Opportunity 5"],
"recommendations": ["Priority 1 — immediate action (next 30 days) with specific tactic", "Priority 2 — 30-60 days", "Priority 3 — 60-90 days", "Priority 4 — ongoing structural change", "Priority 5 — quick win achievable this week"],
"sentiment_chart_insight": ["State the exact positive/neutral/negative counts for both brands and compute the positive-rate and negative-rate for each.", "Explain what the ratio between positive and negative sentiment reveals about audience trust and content resonance for each brand.", "Analyse the size of the neutral group — what does it signal about fence-sitters or low-intent followers?", "Flag the most notable pattern visible in the chart (e.g. one brand with no negatives, unusually high neutral, large gap between brands) and explain why it matters strategically.", "Explain whether the sentiment difference is driven by content quality, posting volume, topic choices, or audience composition.", "Give one concrete tactic the leading brand should protect to maintain its sentiment advantage, and one the trailing brand should adopt immediately."],
"engagement_chart_insight": ["State the exact followers, total engagement, and avg likes values for both brands from the chart.", "Calculate the engagement-per-follower rate for each brand (total_engagement ÷ followers) and compare them — this reveals content quality independent of audience size.", "Explain what the gap between the followers differential and the engagement differential reveals — is the leader winning on reach, content resonance, or both?", "Analyse avg likes per post as a proxy for per-content quality and what the gap signals about each brand's ability to create high-performing individual posts.", "Identify whether the trailing brand's gap is primarily a reach problem (needs more followers) or a content quality problem (needs better posts per given audience).", "Name the single most actionable lever for the trailing brand: is it posting frequency, content theme, format, or audience growth?"],
"wordcloud_insight": ["Identify the 3-4 dominant topic clusters visible in the word list and name the specific terms that anchor each cluster.", "Explain what these clusters reveal about what the audience cares about most or what content consistently attracts engagement.", "Flag any term whose presence is surprising or whose absence is a strategic gap — what is the audience talking about that the brands are ignoring?", "Identify one underserved conversation topic: a gap between what the audience discusses and what the brand content covers.", "Suggest one specific content angle or series concept to exploit that gap and capture uncontested audience attention."]
}}
Base every insight on the actual data provided. Reference specific numbers. If data is thin or coverage is partial, acknowledge the limitation and recommend a re-scan.""" # noqa: E501
# Chart 1: simple charts for LLM visual analysis (multimodal input)
sentiment_llm_img = _render_sentiment_chart_for_llm(
a.sentiment, b.sentiment, request.brand_a_name, request.brand_b_name
)
engagement_llm_img = _render_engagement_chart_for_llm(
a, b, request.brand_a_name, request.brand_b_name
)
response = model.generate_content(
[sentiment_llm_img, engagement_llm_img, prompt],
generation_config={"temperature": 0, "max_output_tokens": 8192},
)
raw = response.text.strip()
data = _extract_json(raw)
# Chart 2: modern styled charts shown to the frontend
sentiment_b64 = _render_sentiment_chart_modern(
a.sentiment, b.sentiment, request.brand_a_name, request.brand_b_name
)
engagement_b64 = _render_engagement_chart_modern(
a, b, request.brand_a_name, request.brand_b_name
)
wordcloud_b64 = _render_wordcloud_chart(request.top_terms)
return DeepAnalysisResponse(
executive_summary=data.get("executive_summary", ""),
brand_a_insights=data.get("brand_a_insights", ""),
brand_b_insights=data.get("brand_b_insights", ""),
content_strategy=data.get("content_strategy", ""),
competitive_analysis=data.get("competitive_analysis", ""),
audience_insights=data.get("audience_insights", ""),
sentiment_deep_dive=data.get("sentiment_deep_dive", ""),
risk_assessment=data.get("risk_assessment", []),
growth_opportunities=data.get("growth_opportunities", []),
recommendations=data.get("recommendations", []),
sentiment_chart_insight=data.get("sentiment_chart_insight", []),
engagement_chart_insight=data.get("engagement_chart_insight", []),
wordcloud_insight=data.get("wordcloud_insight", []),
sentiment_chart_b64=sentiment_b64,
engagement_chart_b64=engagement_b64,
wordcloud_chart_b64=wordcloud_b64,
)
except json.JSONDecodeError as e:
logger.error(f"Deep analysis JSON parse error: {e}")
logger.error(f"Raw response (first 500 chars): {raw[:500] if raw else 'N/A'}")
return DeepAnalysisResponse(
executive_summary="Analysis generated but response could not be parsed. Please retry.",
brand_a_insights="", brand_b_insights="",
content_strategy="", competitive_analysis="",
audience_insights="", sentiment_deep_dive="",
risk_assessment=[], growth_opportunities=[], recommendations=[],
)
except Exception as e:
logger.error(f"Deep analysis error: {e}")
return DeepAnalysisResponse(
executive_summary=f"Analysis unavailable: {str(e)}",
brand_a_insights="", brand_b_insights="",
content_strategy="", competitive_analysis="",
audience_insights="", sentiment_deep_dive="",
risk_assessment=[], growth_opportunities=[], recommendations=[],
)
class KalventisPost(BaseModel):
caption: str
likes: int = 0
comments: int = 0
type: str = "image"
class KalventisAnalysisRequest(BaseModel):
posts: list[KalventisPost]
period: str = ""
class TopicItem(BaseModel):
name: str
mentions: int
momentum: str
summary: str
class KalventisAnalysisResponse(BaseModel):
topics: list[TopicItem]
content_summary: str
patterns: list[str]
recommendations: list[str]
@app.post("/api/v1/kalventis/overview-analysis")
async def kalventis_overview_analysis(request: KalventisAnalysisRequest) -> KalventisAnalysisResponse:
if not GEMINI_API_KEY:
return KalventisAnalysisResponse(
topics=[], content_summary="GEMINI_API_KEY not configured.",
patterns=[], recommendations=[],
)
try:
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel(GEMINI_MODEL)
posts_sample = request.posts[:15]
posts_text = "\n".join(
f"[{i+1}] {p.type}: {p.likes} likes, {p.comments} comments - {p.caption[:100]}"
for i, p in enumerate(posts_sample)
)
period_label = request.period or 'recent window'
prompt = f"""Analyze Instagram posts from @kenapaharusvaksin (Kalventis), an Indonesian vaccine education brand.
Posts ({period_label}):
{posts_text[:4000]}
Return ONLY valid JSON (no markdown):
{{
"topics": [{{"name": "topic", "mentions": N, "momentum": "growing|steady|declining", "summary": "sentence"}}],
"content_summary": "2-3 sentence summary of content strategy",
"patterns": ["pattern 1", "pattern 2", "pattern 3"],
"recommendations": ["action 1", "action 2", "action 3", "action 4"]
}}"""
result = model.generate_content(prompt)
raw = result.text.strip()
data = _extract_json(raw)
topics = [TopicItem(name=t.get("name",""), mentions=t.get("mentions",0), momentum=t.get("momentum","steady"), summary=t.get("summary","")) for t in data.get("topics",[])]
return KalventisAnalysisResponse(
topics=topics,
content_summary=data.get("content_summary",""),
patterns=data.get("patterns",[]),
recommendations=data.get("recommendations",[]),
)
except json.JSONDecodeError as e:
logger.error(f"Kalventis analysis JSON error: {e}")
return KalventisAnalysisResponse(
topics=[], content_summary="Analysis produced unparseable output. Please retry.",
patterns=[], recommendations=[],
)
except Exception as e:
logger.error(f"Kalventis analysis error: {e}")
return KalventisAnalysisResponse(
topics=[], content_summary=f"Analysis unavailable: {str(e)}",
patterns=[], recommendations=[],
)
# ---------------------------------------------------------------------------
# Full agentic analysis — competitive + topic + vision charts in one call
# ---------------------------------------------------------------------------
class AnalysisRecommendation(BaseModel):
title: str
message: str
severity: str # "high" | "medium" | "low" | "positive"
class ChartItem(BaseModel):
title: str
image_base64: str
analysis: str
class FullAnalysisRequest(BaseModel):
kalventis: AccountSnapshot
gsk: AccountSnapshot
top_topics: list[str] = []
news_count: int = 0
period: str = ""
top_words: list[str] = []
follower_ratio: float = 1.0
post_ratio: float = 1.0
posts: list[KalventisPost] = []
class FullAnalysisResponse(BaseModel):
analysis_text: str
key_findings: list[str]
recommendations: list[AnalysisRecommendation]
risk_indicators: list[str]
opportunities: list[str]
topics: list[TopicItem]
content_summary: str
patterns: list[str]
content_recommendations: list[str]
charts: list[ChartItem]
created_at: str
def _analyse_chart_vision(model: genai.GenerativeModel, b64: str, title: str) -> str:
try:
img = Image.open(io.BytesIO(base64.b64decode(b64)))
prompt = (
f"You are analyzing a '{title}' chart for Kalventis, an Indonesian vaccine brand's "
"social media analytics dashboard. In 2-3 concise sentences, describe the key insight "
"this chart reveals and what it means for the brand's content strategy."
)
return model.generate_content([prompt, img]).text.strip()
except Exception as e:
logger.error(f"Vision analysis failed for '{title}': {e}")
return ""
@app.post("/api/v1/full-analysis")
async def full_analysis(request: FullAnalysisRequest) -> FullAnalysisResponse:
if not GEMINI_API_KEY:
return FullAnalysisResponse(
analysis_text="Gemini API key not configured.",
key_findings=[], recommendations=[], risk_indicators=[], opportunities=[],
topics=[], content_summary="", patterns=[], content_recommendations=[],
charts=[], created_at=datetime.now(timezone.utc).isoformat(),
)
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel(GEMINI_MODEL)
kv = request.kalventis
gsk = request.gsk
kv_total = kv.sentiment.positive + kv.sentiment.neutral + kv.sentiment.negative
gsk_total = gsk.sentiment.positive + gsk.sentiment.neutral + gsk.sentiment.negative
kv_pos_rate = f"{(kv.sentiment.positive / max(1, kv_total) * 100):.1f}%"
gsk_pos_rate = f"{(gsk.sentiment.positive / max(1, gsk_total) * 100):.1f}%"
# --- Generate charts ---
sentiment_b64 = _render_sentiment_chart(kv.sentiment, gsk.sentiment, "Kalventis", "GSK")
engagement_b64 = _render_engagement_chart(kv, gsk, "Kalventis", "GSK")
wordcloud_b64 = _render_wordcloud_chart(request.top_words)
# --- Vision analysis for each chart ---
charts: list[ChartItem] = []
charts.append(ChartItem(
title="Sentiment Distribution",
image_base64=sentiment_b64,
analysis=_analyse_chart_vision(model, sentiment_b64, "Sentiment Distribution"),
))
charts.append(ChartItem(
title="Engagement Metrics",
image_base64=engagement_b64,
analysis=_analyse_chart_vision(model, engagement_b64, "Engagement Metrics"),
))
if wordcloud_b64:
charts.append(ChartItem(
title="Word Cloud",
image_base64=wordcloud_b64,
analysis=_analyse_chart_vision(model, wordcloud_b64, "Word Cloud"),
))
# --- Competitive analysis ---
competitive_prompt = f"""You are a senior social media analyst for Kalventis, an Indonesian vaccine awareness brand.
Analyze the following social listening data and respond ONLY in valid JSON (no markdown):
KALVENTIS (@kenapaharusvaksin) — Owned: Followers {kv.followers:,} | Posts {kv.posts_scraped} | Avg likes {kv.avg_likes} | Engagement {kv.total_engagement:,} | Sentiment {kv_pos_rate} positive
GSK (@ayokitavaksin) — Competitor: Followers {gsk.followers:,} | Posts {gsk.posts_scraped} | Avg likes {gsk.avg_likes} | Engagement {gsk.total_engagement:,} | Sentiment {gsk_pos_rate} positive
Follower ratio: {request.follower_ratio:.1f}x | Post ratio: {request.post_ratio:.1f}x
Topics: {', '.join(request.top_topics[:8]) or 'N/A'} | News monitored: {request.news_count}
Top terms: {', '.join(request.top_words[:10]) or 'N/A'} | Period: {request.period}
{{
"analysis_text": "3-4 paragraph markdown narrative covering competitive landscape, performance highlights, and strategic outlook",
"key_findings": ["concise finding 1", "finding 2", "finding 3", "finding 4"],
"recommendations": [
{{"title": "short title", "message": "actionable detail", "severity": "high|medium|low|positive"}},
{{"title": "...", "message": "...", "severity": "..."}},
{{"title": "...", "message": "...", "severity": "..."}},
{{"title": "...", "message": "...", "severity": "..."}}
],
"risk_indicators": ["specific risk 1", "risk 2", "risk 3"],
"opportunities": ["growth opportunity 1", "opportunity 2", "opportunity 3"]
}}"""
# --- Topic / content analysis ---
posts_text = "\n".join(