# =====================================================================
# BLOK IMPORT
# =====================================================================
import os
import sys
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# ── BIRAS Production Infrastructure ─────────────────────────────────
# Import logging, safe_execute, APP_CONFIG and env validation
try:
from config import logger, safe_execute, APP_CONFIG, validate_environment
_env_report = validate_environment(strict=False)
logger.info("BIRAS starting — env: %s", _env_report)
except Exception as _cfg_err:
import logging as _logging
_logging.basicConfig(level=_logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s')
logger = _logging.getLogger('biras')
logger.warning("config.py not loaded: %s", _cfg_err)
APP_CONFIG = {'PORT': int(os.getenv('PORT', 7860)), 'DEBUG': False,
'MAX_ARTICLES': 100, 'DEFAULT_AI_MODEL': 'openai'}
def safe_execute(func=None, *, default=None, label=''):
if func is None:
def decorator(f):
from functools import wraps
@wraps(f)
def wrapped(*args, **kwargs):
try: return f(*args, **kwargs)
except Exception as e:
logger.error("%s failed: %s", label or f.__name__, e)
return default
return wrapped
return decorator
try: return func()
except Exception as e:
logger.error("%s failed: %s", label, e)
return default
# ── Unified AI Gateway ───────────────────────────────────────────────
try:
from ai_engine import call_ai as _unified_call_ai
logger.info("ai_engine.py loaded — unified AI gateway active")
except Exception as _ae_err:
logger.warning("ai_engine.py not loaded: %s — using legacy _call_ai_single", _ae_err)
_unified_call_ai = None
def call_ai(prompt: str, model: str = 'auto', max_tokens: int = 400, **kwargs) -> 'str | None':
"""
Alias publik untuk ai_engine.call_ai.
Terpanggil di seluruh app.py; mendelegasikan ke _unified_call_ai jika tersedia.
"""
if _unified_call_ai is not None:
return _unified_call_ai(prompt, model=model, max_tokens=max_tokens, **kwargs)
return None
import requests
import pandas as pd
import io
import base64
from flask import Flask, render_template, request, make_response, json, redirect, url_for, flash
import traceback
import re
import itertools
import time
import uuid
import datetime
import pytz
from functools import wraps
# Import untuk Login & Keamanan
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import inspect, func
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash
# Import untuk Fitur Baru (BibTeX)
import bibtexparser
from bibtexparser.bwriter import BibTexWriter
from bibtexparser.bibdatabase import BibDatabase
# Import untuk Model AI Baru
import google.generativeai as genai
# Import lainnya
from wordcloud import WordCloud
import nltk
from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize
# ── NLTK Resource Auto-Download ──────────────────────────────────────────────
# Pastikan semua resource NLTK tersedia saat startup.
# quiet=True agar tidak noise di console saat sudah terdownload.
_NLTK_RESOURCES = [
('tokenizers/punkt_tab', 'punkt_tab'),
('tokenizers/punkt', 'punkt'),
('corpora/stopwords', 'stopwords'),
('taggers/averaged_perceptron_tagger', 'averaged_perceptron_tagger'),
('taggers/averaged_perceptron_tagger_eng', 'averaged_perceptron_tagger_eng'),
]
for _resource_path, _resource_id in _NLTK_RESOURCES:
try:
nltk.data.find(_resource_path)
except LookupError:
logger.info("Downloading NLTK resource: %s", _resource_id)
nltk.download(_resource_id, quiet=True)
# ─────────────────────────────────────────────────────────────────────────────
from collections import Counter
from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer
import ssl
import plotly.express as px
import pyalex
from pyalex import Works
import networkx as nx
from pyvis.network import Network
import pyLDAvis
from sklearn.decomposition import LatentDirichletAllocation
import pycountry
from community import community_louvain
from openai import OpenAI
# ===== IMPORT NEW ANALYTICS FUNCTIONS (STEP 2) =====
try:
from NEW_FUNCTIONS import (
calculate_quality_score,
calculate_h_index,
get_top_authors_h_index,
detect_keyword_trends,
calculate_citation_velocity,
analyze_open_access,
calculate_collaboration_metrics,
calculate_diversity_metrics,
create_quality_score_visualization,
create_h_index_chart,
create_citation_distribution_chart,
create_oa_visualization,
get_publication_trends_by_year
)
ANALYTICS_AVAILABLE = True
except ImportError as e:
logger.warning("NEW_FUNCTIONS not found: %s", e)
ANALYTICS_AVAILABLE = False
try:
import gender_guesser.detector as gender
GENDER_DETECTOR = gender.Detector()
except ImportError:
logger.warning("Library 'gender_guesser' not found — gender analysis disabled")
GENDER_DETECTOR = None
# =====================================================================
# BLOK KONFIGURASI DAN SETUP
# =====================================================================
# ===== DATABASE CONFIGURATION (SQLite atau Supabase) =====
DATA_DIR = os.environ.get('DATA_DIR', '/data' if os.path.exists('/data') else os.path.join(os.path.dirname(__file__), 'data'))
STATIC_FOLDER = os.path.join(DATA_DIR, 'static')
DB_PATH = os.path.join(DATA_DIR, 'literature_cache.db')
if not os.path.exists(DATA_DIR): os.makedirs(DATA_DIR)
if not os.path.exists(STATIC_FOLDER): os.makedirs(STATIC_FOLDER)
# Copy modern_style.css to static folder if it doesn't exist
templates_css = os.path.join(os.path.dirname(__file__), 'templates', 'modern_style.css')
static_css = os.path.join(STATIC_FOLDER, 'modern_style.css')
if os.path.exists(templates_css) and not os.path.exists(static_css):
import shutil
try:
shutil.copy(templates_css, static_css)
print(f"✅ CSS copied to {static_css}")
except Exception as e:
print(f"⚠️ Could not copy CSS: {e}")
# ===== DETERMINE DATABASE CONFIGURATION =====
DATABASE_URL = os.environ.get('DATABASE_URL')
ENVIRONMENT = os.environ.get('ENVIRONMENT', 'production')
SUPABASE_URL = os.environ.get('SUPABASE_URL')
SUPABASE_KEY = os.environ.get('SUPABASE_KEY')
USE_SQLITE = os.getenv("USE_SQLITE", "false").lower() == "true"
# Detect if running on Hugging Face Spaces (use SQLite to avoid network issues)
IS_HUGGING_FACE = os.path.exists('/home/user/app') or 'SPACE_ID' in os.environ
if USE_SQLITE or IS_HUGGING_FACE:
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + DB_PATH
DB_TYPE = "SQLite (Local)" if USE_SQLITE else "SQLite (HF Detected)"
elif DATABASE_URL:
SQLALCHEMY_DATABASE_URI = DATABASE_URL
DB_TYPE = "Supabase (PostgreSQL)"
else:
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + DB_PATH
DB_TYPE = "SQLite (Fallback)"
app = Flask(__name__, static_folder=STATIC_FOLDER)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'ganti-dengan-kunci-rahasia-yang-sangat-kuat-dan-acak')
app.config['SQLALCHEMY_DATABASE_URI'] = SQLALCHEMY_DATABASE_URI
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# Connection pooling for production (PostgreSQL/Supabase only - NOT for SQLite!)
if 'postgresql' in SQLALCHEMY_DATABASE_URI and ENVIRONMENT == 'production':
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'pool_pre_ping': True,
'pool_size': 5,
'max_overflow': 10,
'pool_recycle': 3600,
'pool_timeout': 10,
'connect_args': {'connect_timeout': 10, 'application_name': 'jurnal-slr'}
}
print(f"✅ PostgreSQL connection pooling diaktifkan")
else:
print(f"✅ Database URI: {SQLALCHEMY_DATABASE_URI[:50]}... (No pooling for SQLite)")
db = SQLAlchemy(app)
login_manager = LoginManager(app)
login_manager.login_view = 'login'
login_manager.login_message_category = 'info'
login_manager.login_message = "Silakan login untuk mengakses halaman ini."
# =====================================================================
# BLOK MODEL DATABASE
# =====================================================================
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
role = db.Column(db.String(80), nullable=False, default='user')
core_api_key = db.Column(db.String(200), nullable=True)
springer_api_key = db.Column(db.String(200), nullable=True)
serpapi_api_key = db.Column(db.String(200), nullable=True)
openai_api_key = db.Column(db.String(200), nullable=True)
gemini_api_key = db.Column(db.String(200), nullable=True)
deepseek_api_key = db.Column(db.String(200), nullable=True)
zai_api_key = db.Column(db.String(200), nullable=True)
scopus_api_key = db.Column(db.String(200), nullable=True)
semantic_scholar_api_key = db.Column(db.String(200), nullable=True)
searches = db.relationship('SearchCache', backref='owner', lazy=True)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
class SearchCache(db.Model):
id = db.Column(db.Integer, primary_key=True)
search_term = db.Column(db.String(300), nullable=False)
start_year = db.Column(db.Integer, nullable=False)
end_year = db.Column(db.Integer, nullable=False)
limit_per_api = db.Column(db.Integer, nullable=False)
api_sources = db.Column(db.String(200), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
country_filter = db.Column(db.String(10), nullable=True)
articles = db.relationship('ArticleCache', backref='search', lazy=True, cascade="all, delete-orphan")
__table_args__ = (db.UniqueConstraint('search_term', 'start_year', 'end_year', 'limit_per_api', 'api_sources', 'user_id', 'country_filter', name='_query_user_uc'),)
class ArticleCache(db.Model):
id = db.Column(db.Integer, primary_key=True)
search_id = db.Column(db.Integer, db.ForeignKey('search_cache.id'), nullable=False)
title = db.Column(db.String(500), nullable=True)
abstract = db.Column(db.Text, nullable=True)
authors = db.Column(db.Text, nullable=True)
yearPublished = db.Column(db.Integer, nullable=True)
publisher = db.Column(db.String(250), nullable=True)
doi = db.Column(db.String(100), nullable=True)
link = db.Column(db.String(500), nullable=True)
country_code = db.Column(db.String(10), nullable=True)
country_name = db.Column(db.String(100), nullable=True)
api_origin = db.Column(db.String(50), nullable=True)
source_display = db.Column(db.String(150), nullable=True)
repositories = db.Column(db.Text, nullable=True)
original_id = db.Column(db.String(100), nullable=True)
cited_by_count = db.Column(db.Integer, default=0)
with app.app_context():
try:
inspector = inspect(db.engine)
if not inspector.has_table("user"):
print(f"📊 Database: {DB_TYPE}")
print("🔄 Tabel 'user' tidak ditemukan. Membuat semua tabel...")
db.create_all()
db.session.commit()
print("✅ Semua tabel berhasil dibuat.")
else:
print(f"✅ Database: {DB_TYPE}")
print("✅ Tabel 'user' sudah ada.")
except Exception as e:
print(f"⚠️ Database connection failed: {type(e).__name__}: {e}")
print("⚠️ Trying SQLite fallback...")
try:
db.session.rollback()
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + DB_PATH
db.engine.dispose()
print("✅ Switched to SQLite fallback")
inspector = inspect(db.engine)
if not inspector.has_table("user"):
print("🔄 Creating tables in SQLite...")
db.create_all()
db.session.commit()
print("✅ Tables created in SQLite")
except Exception as e2:
print(f"⚠️ SQLite fallback also failed: {type(e2).__name__}: {e2}")
print("⚠️ App will run but database operations may fail")
# Auto-migrate: add missing columns to existing SQLite DB
with app.app_context():
try:
if 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']:
import sqlite3 as _sqlite3
_db_path = app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')
_conn = _sqlite3.connect(_db_path)
_cur = _conn.cursor()
_cur.execute("PRAGMA table_info(user)")
_existing_cols = [row[1] for row in _cur.fetchall()]
_new_cols = {
'zai_api_key': 'VARCHAR(200)',
'scopus_api_key': 'VARCHAR(200)',
'semantic_scholar_api_key': 'VARCHAR(200)',
}
for _col, _type in _new_cols.items():
if _col not in _existing_cols:
_cur.execute(f"ALTER TABLE user ADD COLUMN {_col} {_type}")
print(f"✅ Auto-migrated: added column '{_col}' to user table")
_conn.commit()
_conn.close()
except Exception as _me:
print(f"⚠️ Auto-migration warning: {_me}")
# =====================================================================
# SISA KONFIGURASI
# =====================================================================
pyalex.email = os.environ.get('PYALEX_EMAIL', 'default.email@example.com')
CORE_BASE_URL = "https://api.core.ac.uk/v3/search/works"
SPRINGER_BASE_URL = "https://api.springernature.com/openaccess/v2/json"
SERPAPI_BASE_URL = "https://serpapi.com/search"
SCOPUS_BASE_URL = "https://api.elsevier.com/content/search/scopus"
try:
nltk.data.find('corpora/stopwords')
except LookupError:
nltk.download('stopwords')
try:
nltk.data.find('tokenizers/punkt')
except LookupError:
nltk.download('punkt')
STOPWORDS_ID = set(nltk.corpus.stopwords.words('indonesian'))
ADDITIONAL_STOPWORDS = {'introduction', 'background', 'method', 'methods', 'results', 'result', 'conclusion', 'conclusions', 'abstract', 'paper', 'study', 'research', 'analysis', 'based'}
STOPWORDS_EN = set(nltk.corpus.stopwords.words('english'))
ALL_STOPWORDS = STOPWORDS_EN | STOPWORDS_ID | ADDITIONAL_STOPWORDS
# =====================================================================
# DECORATOR HAK AKSES
# =====================================================================
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role != 'admin':
flash("Anda tidak memiliki izin untuk mengakses halaman ini.", "danger")
return redirect(url_for('index'))
return f(*args, **kwargs)
return decorated_function
# =====================================================================
# BLOK FUNGSI HELPERS & KONEKTOR DATA
# =====================================================================
def convert_to_wita(utc_dt):
if utc_dt is None: return None
wita_tz = pytz.timezone('Asia/Makassar')
return utc_dt.replace(tzinfo=pytz.utc).astimezone(wita_tz)
app.jinja_env.globals.update(convert_to_wita=convert_to_wita)
def check_internet_connection():
try:
requests.get("https://www.google.com", timeout=3)
return True
except requests.ConnectionError:
return False
def safe_db_operation(operation_func, default_value=None):
"""Safely execute database operations with error handling"""
try:
return operation_func()
except Exception as e:
print(f"⚠️ Database operation failed: {e}")
return default_value
def clean_text(text):
if not isinstance(text, str): return []
tokens = nltk.tokenize.word_tokenize(text.lower())
return [word for word in tokens if word.isalpha() and word not in ALL_STOPWORDS and len(word) > 2]
def reconstruct_abstract_from_inverted_index(inverted_index):
if not inverted_index: return ""
try:
all_positions = [pos for positions in inverted_index.values() if positions for pos in positions]
if not all_positions: return ""
max_pos = max(all_positions)
abstract_list = [""] * (max_pos + 1)
for word, positions in inverted_index.items():
if positions:
for pos in positions: abstract_list[pos] = word
return " ".join(filter(None, abstract_list))
except (ValueError, TypeError): return ""
def extract_author_name(author_entry):
if isinstance(author_entry, str): return author_entry.strip()
if isinstance(author_entry, dict): return (author_entry.get('name') or author_entry.get('display_name') or author_entry.get('creator') or author_entry.get('authname') or '').strip()
return ''
def get_core_data(query, start_year, end_year, limit):
api_key = current_user.core_api_key or os.environ.get('CORE_API_KEY')
if not api_key: return pd.DataFrame()
filter_query = f'yearPublished:[{start_year} TO {end_year}]'
params = {'q': query, 'limit': limit, 'apiKey': api_key, 'filter': filter_query}
try:
response = requests.get(CORE_BASE_URL, params=params, timeout=25)
if response.status_code == 200:
data = response.json()
df = pd.DataFrame(data['results']) if data.get('results') else pd.DataFrame()
if not df.empty and 'authors' in df.columns:
df['authors'] = df['authors'].apply(lambda authors: [extract_author_name(auth) for auth in authors] if isinstance(authors, list) else [])
return df
else:
print(f"Gagal dari CORE. Status: {response.status_code}, Pesan: {response.text}")
return pd.DataFrame()
except requests.exceptions.RequestException as e:
print(f"Error koneksi CORE: {e}"); return pd.DataFrame()
def get_springer_data(query, start_year, end_year, limit):
api_key = current_user.springer_api_key or os.environ.get('SPRINGER_API_KEY')
if not api_key: return pd.DataFrame()
api_query = f'keyword:"{query}" AND onlinedatefrom:{start_year}-01-01 AND onlinedateto:{end_year}-12-31'
params = {'q': api_query, 'p': limit, 'api_key': api_key}
headers = {'User-Agent': 'SLR-App/1.0'}
try:
response = requests.get(SPRINGER_BASE_URL, params=params, headers=headers, timeout=25)
response.raise_for_status()
data = response.json()
except requests.exceptions.RequestException as e:
print(f"Error Springer: {e}"); return pd.DataFrame()
if not data.get('records'): return pd.DataFrame()
normalized_data = []
for record in data['records']:
pub_date = record.get('publicationDate', '')
if not pub_date or not pub_date[:4].isdigit(): continue
year = int(pub_date[:4])
authors_list = [extract_author_name(c) for c in record.get('creators', [])]
normalized_data.append({'id': record.get('doi', record.get('identifier', 'N/A')), 'title': record.get('title', 'No Title'), 'authors': authors_list, 'yearPublished': year, 'publisher': record.get('publicationName', 'Unknown Source'), 'abstract': record.get('abstract', '').strip(), 'doi': record.get('doi'), 'repositories': []})
return pd.DataFrame(normalized_data)
def get_google_scholar_data(query, start_year, end_year, limit):
api_key = current_user.serpapi_api_key or os.environ.get('SERPAPI_API_KEY')
if not api_key: return pd.DataFrame()
params = {'engine': 'google_scholar', 'q': query, 'as_ylo': start_year, 'as_yhi': end_year, 'num': 20, 'api_key': api_key}
try:
response = requests.get(SERPAPI_BASE_URL, params=params, timeout=30)
response.raise_for_status()
data = response.json()
except requests.exceptions.RequestException as e:
print(f"Error SerpApi: {e}"); return pd.DataFrame()
if data.get("error") or not data.get("organic_results"): return pd.DataFrame()
normalized_data = []
for result in data["organic_results"]:
pub_info = result.get("publication_info", {})
summary = pub_info.get("summary", "")
source_name = summary.split(' - ')[-1].strip() if ' - ' in summary else "Google Scholar"
year_match = re.search(r'(\d{4})', summary)
year = int(year_match.group(1)) if year_match else None
authors_list = [extract_author_name(author) for author in pub_info.get("authors", [])]
normalized_data.append({'id': result.get("result_id"), 'title': result.get("title"), 'authors': authors_list, 'yearPublished': year, 'publisher': source_name, 'abstract': result.get("snippet"), 'doi': None, 'link': result.get("link"), 'repositories': []})
return pd.DataFrame(normalized_data)
def get_openalex_data(query, start_year, end_year, limit, country_filter=None):
try:
works_query = Works().filter(publication_year=f"{start_year}-{end_year}")
if country_filter:
print(f"Menerapkan filter negara OpenAlex: {country_filter}")
works_query = works_query.filter(authorships={'institutions': {'country_code': country_filter.upper()}})
results = works_query.search(query).get(per_page=limit)
normalized_data = []
for work in results:
if not work: continue
country_code = next((inst.get('country_code') for auth in work.get('authorships', []) for inst in auth.get('institutions', []) if inst and inst.get('country_code')), None)
authors_list = [extract_author_name(a.get('author')) for a in work.get('authorships', []) if a and a.get('author')]
publisher_name = 'N/A'
if primary_location := work.get('primary_location'):
if source := primary_location.get('source'):
publisher_name = source.get('display_name', 'N/A')
normalized_data.append({
'id': work.get('id'), 'title': work.get('display_name'), 'authors': authors_list,
'yearPublished': work.get('publication_year'), 'publisher': publisher_name,
'abstract': reconstruct_abstract_from_inverted_index(work.get('abstract_inverted_index')),
'doi': work.get('doi'), 'repositories': [], 'country_code': country_code,
'cited_by_count': work.get('cited_by_count', 0)
})
return pd.DataFrame(normalized_data)
except Exception as e:
print(f"Error fetching from OpenAlex: {e}"); traceback.print_exc(); return pd.DataFrame()
def get_semantic_scholar_data(query, start_year, end_year, limit):
api_key = current_user.semantic_scholar_api_key or os.environ.get('SEMANTIC_SCHOLAR_API_KEY')
headers = {}
if api_key:
headers['x-api-key'] = api_key
# Batasi limit maksimal agar tidak timeout (S2 API limit is usually 100 per request)
actual_limit = min(limit, 100)
params = {
'query': query,
'year': f"{start_year}-{end_year}",
'limit': actual_limit,
'fields': 'title,abstract,authors,year,venue,externalIds,url,citationCount'
}
try:
response = requests.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params, headers=headers, timeout=30)
response.raise_for_status()
data = response.json()
except requests.exceptions.RequestException as e:
print(f"Error Semantic Scholar: {e}")
return pd.DataFrame()
if not data.get("data"):
return pd.DataFrame()
normalized_data = []
for paper in data["data"]:
authors_list = [author.get("name") for author in paper.get("authors", []) if author.get("name")]
# S2 externalIds contains DOI, CorpusId, MAG, etc.
doi = paper.get("externalIds", {}).get("DOI")
normalized_data.append({
'id': paper.get("paperId"),
'title': paper.get("title"),
'authors': authors_list,
'yearPublished': paper.get("year"),
'publisher': paper.get("venue"),
'abstract': paper.get("abstract"),
'doi': doi,
'link': paper.get("url"),
'repositories': [],
'cited_by_count': paper.get("citationCount", 0)
})
return pd.DataFrame(normalized_data)
def get_scopus_data(query, start_year, end_year, limit):
api_key = current_user.scopus_api_key or os.environ.get('SCOPUS_API_KEY')
if not api_key:
print("Scopus API key tidak tersedia.")
return pd.DataFrame()
headers = {'Accept': 'application/json', 'X-ELS-APIKey': api_key}
api_query = f'TITLE-ABS-KEY({query}) AND PUBYEAR > {start_year - 1} AND PUBYEAR < {end_year + 1}'
params = {'query': api_query, 'count': limit, 'view': 'STANDARD'}
try:
response = requests.get(SCOPUS_BASE_URL, headers=headers, params=params, timeout=30)
response.raise_for_status()
data = response.json()
if 'search-results' not in data or not data['search-results'].get('entry'):
return pd.DataFrame()
normalized_data = []
for entry in data['search-results']['entry']:
authors_list = [extract_author_name(author) for author in entry.get('author', [])]
normalized_data.append({
'id': entry.get('dc:identifier', '').replace('SCOPUS_ID:', ''),
'title': entry.get('dc:title', 'No Title'),
'authors': authors_list,
'yearPublished': int(entry.get('prism:coverDate', '1900-01-01').split('-')[0]),
'publisher': entry.get('prism:publicationName', 'Scopus'),
'abstract': '', # Abstrak tidak tersedia di view STANDARD
'doi': entry.get('prism:doi'),
'link': next((link['@href'] for link in entry.get('link', []) if link.get('@ref') == 'scopus'), '#'),
'cited_by_count': int(entry.get('citedby-count', 0))
})
return pd.DataFrame(normalized_data)
except requests.exceptions.RequestException as e:
print(f"Error fetching from Scopus: {e}");
if hasattr(e, 'response') and e.response is not None:
print(f"Scopus response: {e.response.text}")
return pd.DataFrame()
# =====================================================================
# CROSSREF API
# =====================================================================
_CROSSREF_CACHE = {} # Simple in-memory cache {key: (timestamp, df)}
_CROSSREF_CACHE_TTL = 3600 # 1 hour
def get_crossref_data(query, start_year, end_year, limit):
import time as _time
cache_key = f"crossref_{query}_{start_year}_{end_year}_{limit}"
now = _time.time()
if cache_key in _CROSSREF_CACHE:
ts, cached_df = _CROSSREF_CACHE[cache_key]
if now - ts < _CROSSREF_CACHE_TTL:
print("Crossref: cache hit")
return cached_df
params = {
'query': query,
'rows': min(limit, 100),
'filter': f'from-pub-date:{start_year},until-pub-date:{end_year}',
'mailto': os.environ.get('PYALEX_EMAIL', 'researcher@example.com'),
'select': 'DOI,title,abstract,author,issued,container-title,URL,type'
}
headers = {'User-Agent': 'SLR-App/1.0 (mailto:researcher@example.com)'}
for attempt in range(3): # retry 3x
try:
response = requests.get('https://api.crossref.org/works', params=params, headers=headers, timeout=30)
response.raise_for_status()
data = response.json()
break
except requests.exceptions.RequestException as e:
print(f"Crossref attempt {attempt+1} failed: {e}")
if attempt == 2:
return pd.DataFrame()
_time.sleep(2 ** attempt) # exponential backoff
items = data.get('message', {}).get('items', [])
if not items:
return pd.DataFrame()
normalized_data = []
for item in items:
# Title
title_list = item.get('title', [])
title = title_list[0] if title_list else 'No Title'
# Authors
authors_list = []
for a in item.get('author', []):
given = a.get('given', '').strip()
family = a.get('family', '').strip()
full = f"{given} {family}".strip()
if full:
authors_list.append(full)
# Year
issued = item.get('issued', {})
date_parts = issued.get('date-parts', [[]])
year = date_parts[0][0] if date_parts and date_parts[0] else None
if not year:
continue
# Abstract (Crossref often returns JATS XML tags — strip them)
raw_abstract = item.get('abstract', '')
abstract = re.sub(r'<[^>]+>', '', raw_abstract).strip() if raw_abstract else ''
# Journal / publisher
container = item.get('container-title', [])
journal = container[0] if container else 'Crossref'
# DOI & URL
doi = item.get('DOI')
url = item.get('URL') or (f'https://doi.org/{doi}' if doi else None)
normalized_data.append({
'id': doi or url or title,
'title': title,
'authors': authors_list,
'yearPublished': int(year),
'publisher': journal,
'abstract': abstract,
'doi': doi,
'link': url,
'repositories': [],
'cited_by_count': item.get('is-referenced-by-count', 0)
})
result_df = pd.DataFrame(normalized_data)
_CROSSREF_CACHE[cache_key] = (now, result_df)
print(f"Crossref: {len(result_df)} hasil ditemukan untuk '{query}'")
return result_df
# =====================================================================
# PUBMED API (NCBI E-utilities) — 100% Free
# =====================================================================
def get_pubmed_data(query, start_year, end_year, limit):
"""
Fetch articles from PubMed (NCBI E-utilities).
No API key required (up to 3 req/s). With NCBI_API_KEY env var → 10 req/s.
Endpoint: esearch → efetch pipeline.
"""
import time as _time
base_url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/"
api_key = os.environ.get('NCBI_API_KEY', '')
email = os.environ.get('PYALEX_EMAIL', 'researcher@example.com')
# ── Step 1: esearch — get PMIDs ──────────────────────────────────
search_params = {
'db': 'pubmed',
'term': f"{query} AND {start_year}:{end_year}[pdat]",
'retmax': min(limit, 200),
'retmode': 'json',
'sort': 'relevance',
'tool': 'BIRAS',
'email': email,
}
if api_key:
search_params['api_key'] = api_key
try:
r = requests.get(f"{base_url}esearch.fcgi", params=search_params, timeout=20)
r.raise_for_status()
pmids = r.json().get('esearchresult', {}).get('idlist', [])
except Exception as e:
print(f"PubMed esearch error: {e}")
return pd.DataFrame()
if not pmids:
return pd.DataFrame()
_time.sleep(0.4) # rate limit safety
# ── Step 2: efetch — get full records ────────────────────────────
fetch_params = {
'db': 'pubmed',
'id': ','.join(pmids),
'retmode': 'xml',
'rettype': 'abstract',
'tool': 'BIRAS',
'email': email,
}
if api_key:
fetch_params['api_key'] = api_key
try:
import xml.etree.ElementTree as ET
r2 = requests.get(f"{base_url}efetch.fcgi", params=fetch_params, timeout=30)
r2.raise_for_status()
root = ET.fromstring(r2.content)
except Exception as e:
print(f"PubMed efetch error: {e}")
return pd.DataFrame()
normalized_data = []
for article in root.findall('.//PubmedArticle'):
try:
medline = article.find('MedlineCitation')
art = medline.find('Article')
# Title
title_el = art.find('ArticleTitle')
title = ''.join(title_el.itertext()).strip() if title_el is not None else 'No Title'
# Abstract
abstract_parts = art.findall('.//AbstractText')
abstract = ' '.join(''.join(a.itertext()) for a in abstract_parts).strip()
# Authors
authors_list = []
for author in art.findall('.//Author'):
last = author.findtext('LastName', '')
fore = author.findtext('ForeName', '')
if last:
authors_list.append(f"{fore} {last}".strip())
# Year
pub_date = art.find('.//PubDate')
year_text = pub_date.findtext('Year') if pub_date is not None else None
if not year_text:
medline_date = art.find('.//MedlineDate')
year_text = medline_date.text[:4] if medline_date is not None and medline_date.text else None
year = int(year_text) if year_text and year_text.isdigit() else None
if not year:
continue
# Journal
journal_el = art.find('.//Journal/Title')
journal = journal_el.text if journal_el is not None else 'PubMed'
# PMID & DOI
pmid_el = medline.find('PMID')
pmid = pmid_el.text if pmid_el is not None else None
doi = None
for id_el in article.findall('.//ArticleId'):
if id_el.get('IdType') == 'doi':
doi = id_el.text
break
normalized_data.append({
'id': f"pubmed_{pmid}",
'title': title,
'authors': authors_list,
'yearPublished': year,
'publisher': journal,
'abstract': abstract,
'doi': doi,
'link': f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/" if pmid else None,
'repositories': [],
'cited_by_count': 0,
})
except Exception as _art_e:
continue
result_df = pd.DataFrame(normalized_data)
print(f"PubMed: {len(result_df)} hasil ditemukan untuk '{query}'")
return result_df
# =====================================================================
# ARXIV API — 100% Free, No API Key Required
# =====================================================================
def get_arxiv_data(query, start_year, end_year, limit):
"""
Fetch preprints from arXiv using the official Atom/REST API.
No authentication required. Best for CS, AI, Math, Physics.
"""
import xml.etree.ElementTree as ET
# arXiv date filter uses submittedDate field
date_filter = f"submittedDate:[{start_year}0101 TO {end_year}1231]"
search_query = f"all:{query} AND {date_filter}"
params = {
'search_query': search_query,
'start': 0,
'max_results': min(limit, 200),
'sortBy': 'relevance',
'sortOrder': 'descending',
}
NS = 'http://www.w3.org/2005/Atom'
ARXIV_NS = 'http://arxiv.org/schemas/atom'
try:
r = requests.get(
'https://export.arxiv.org/api/query',
params=params,
timeout=30,
headers={'User-Agent': 'BIRAS/1.0'}
)
r.raise_for_status()
root = ET.fromstring(r.content)
except Exception as e:
print(f"arXiv API error: {e}")
return pd.DataFrame()
normalized_data = []
for entry in root.findall(f'{{{NS}}}entry'):
try:
title = entry.findtext(f'{{{NS}}}title', 'No Title').strip().replace('\n', ' ')
summary = entry.findtext(f'{{{NS}}}summary', '').strip().replace('\n', ' ')
# Authors
authors_list = [
author.findtext(f'{{{NS}}}name', '')
for author in entry.findall(f'{{{NS}}}author')
if author.findtext(f'{{{NS}}}name', '')
]
# Published date → year
published = entry.findtext(f'{{{NS}}}published', '')
year = int(published[:4]) if published and published[:4].isdigit() else None
if not year or not (start_year <= year <= end_year):
continue
# ID & DOI
arxiv_id_url = entry.findtext(f'{{{NS}}}id', '')
arxiv_id = arxiv_id_url.split('/abs/')[-1] if '/abs/' in arxiv_id_url else arxiv_id_url
doi_el = entry.find(f'{{{ARXIV_NS}}}doi')
doi = doi_el.text.strip() if doi_el is not None and doi_el.text else None
# Journal ref (if published)
journal_ref_el = entry.find(f'{{{ARXIV_NS}}}journal_ref')
journal = journal_ref_el.text.strip() if journal_ref_el is not None and journal_ref_el.text else 'arXiv Preprint'
normalized_data.append({
'id': f"arxiv_{arxiv_id}",
'title': title,
'authors': authors_list,
'yearPublished': year,
'publisher': journal,
'abstract': summary,
'doi': doi,
'link': arxiv_id_url or f"https://arxiv.org/abs/{arxiv_id}",
'repositories': [],
'cited_by_count': 0,
})
except Exception:
continue
result_df = pd.DataFrame(normalized_data)
print(f"arXiv: {len(result_df)} hasil ditemukan untuk '{query}'")
return result_df
# =====================================================================
# DOAJ API (Directory of Open Access Journals) — 100% Free, No Key
# =====================================================================
def get_doaj_data(query, start_year, end_year, limit):
"""Fetch 100% Open Access articles from DOAJ. No API key required."""
import urllib.parse
try:
r = requests.get(
f'https://doaj.org/api/search/articles/{urllib.parse.quote(query)}',
params={'pageSize': min(limit, 100), 'page': 1},
headers={'User-Agent': 'BIRAS/1.0'},
timeout=25,
)
r.raise_for_status()
data = r.json()
except Exception as e:
print(f"DOAJ API error: {e}")
return pd.DataFrame()
normalized_data = []
for item in data.get('results', []):
try:
bib = item.get('bibjson', {})
year_val = bib.get('year')
year = int(str(year_val)[:4]) if year_val and str(year_val)[:4].isdigit() else None
if not year or not (start_year <= year <= end_year):
continue
authors_list = [a.get('name', '').strip() for a in bib.get('author', []) if a.get('name')]
doi, link = None, None
for id_obj in bib.get('identifier', []):
if id_obj.get('type') == 'doi':
doi = id_obj.get('id')
for lnk in bib.get('link', []):
if lnk.get('type') in ('fulltext', 'article'):
link = lnk.get('url'); break
if doi and not link:
link = f"https://doi.org/{doi}"
normalized_data.append({
'id': item.get('id', doi or bib.get('title', '')),
'title': bib.get('title', 'No Title'),
'authors': authors_list,
'yearPublished': year,
'publisher': bib.get('journal', {}).get('title', 'DOAJ'),
'abstract': bib.get('abstract', ''),
'doi': doi, 'link': link,
'repositories': [], 'cited_by_count': 0, 'is_oa': True,
})
except Exception:
continue
result_df = pd.DataFrame(normalized_data)
print(f"DOAJ: {len(result_df)} hasil ditemukan untuk '{query}'")
return result_df
# =====================================================================
# EUROPE PMC API — 100% Free, No Key Required
# =====================================================================
def get_europepmc_data(query, start_year, end_year, limit):
"""Fetch from Europe PMC (biomedical, biology). No API key required."""
try:
r = requests.get(
'https://www.ebi.ac.uk/europepmc/webservices/rest/search',
params={
'query': f'{query} AND (PUB_YEAR:[{start_year} TO {end_year}])',
'resultType': 'core', 'pageSize': min(limit, 100),
'format': 'json', 'sort': 'RELEVANCE',
},
headers={'User-Agent': 'BIRAS/1.0'},
timeout=25,
)
r.raise_for_status()
data = r.json()
except Exception as e:
print(f"Europe PMC API error: {e}")
return pd.DataFrame()
normalized_data = []
for item in data.get('resultList', {}).get('result', []):
try:
year = int(str(item.get('pubYear', 0))[:4]) if item.get('pubYear') else None
if not year or not (start_year <= year <= end_year):
continue
authors_list = [a.strip() for a in item.get('authorString', '').split(',') if a.strip()]
doi = item.get('doi')
pmid, pmcid = item.get('pmid'), item.get('pmcid')
if pmcid:
link = f"https://europepmc.org/article/PMC/{pmcid.replace('PMC','')}"
elif pmid:
link = f"https://europepmc.org/article/MED/{pmid}"
elif doi:
link = f"https://doi.org/{doi}"
else:
link = None
normalized_data.append({
'id': f"epmc_{item.get('id', doi or pmid)}",
'title': item.get('title', 'No Title').rstrip('.'),
'authors': authors_list,
'yearPublished': year,
'publisher': item.get('journalTitle', 'Europe PMC'),
'abstract': item.get('abstractText', ''),
'doi': doi, 'link': link,
'repositories': [], 'cited_by_count': int(item.get('citedByCount', 0)),
})
except Exception:
continue
result_df = pd.DataFrame(normalized_data)
print(f"Europe PMC: {len(result_df)} hasil ditemukan untuk '{query}'")
return result_df
# =====================================================================
# DBLP API — 100% Free, No Key (Computer Science)
# =====================================================================
def get_dblp_data(query, start_year, end_year, limit):
"""Fetch from DBLP (CS bibliography). Best data quality for CS. No API key."""
try:
r = requests.get(
'https://dblp.org/search/publ/api',
params={'q': query, 'h': min(limit, 100), 'f': 0, 'format': 'json'},
headers={'User-Agent': 'BIRAS/1.0'},
timeout=25,
)
r.raise_for_status()
data = r.json()
except Exception as e:
print(f"DBLP API error: {e}")
return pd.DataFrame()
normalized_data = []
for hit in data.get('result', {}).get('hits', {}).get('hit', []):
try:
info = hit.get('info', {})
year_str = info.get('year', '0')
year = int(year_str) if str(year_str).isdigit() else None
if not year or not (start_year <= year <= end_year):
continue
raw_authors = info.get('authors', {}).get('author', [])
if isinstance(raw_authors, dict):
raw_authors = [raw_authors]
authors_list = [a.get('text', a) if isinstance(a, dict) else str(a) for a in raw_authors]
doi = info.get('doi')
link = info.get('url') or (f"https://doi.org/{doi}" if doi else None)
normalized_data.append({
'id': info.get('key', doi or link),
'title': info.get('title', 'No Title').rstrip('.'),
'authors': authors_list,
'yearPublished': year,
'publisher': info.get('venue', info.get('booktitle', 'DBLP')),
'abstract': '',
'doi': doi, 'link': link,
'repositories': [], 'cited_by_count': 0,
})
except Exception:
continue
result_df = pd.DataFrame(normalized_data)
print(f"DBLP: {len(result_df)} hasil ditemukan untuk '{query}'")
return result_df
# =====================================================================
# BASE (Bielefeld Academic Search Engine) — 300M+ docs, No Key
# =====================================================================
def get_base_data(query, start_year, end_year, limit):
"""
Fetch from BASE (Bielefeld Academic Search Engine).
300M+ documents from 10,000+ academic repositories globally.
No API key required. https://api.base-search.net/
"""
import urllib.parse
try:
r = requests.get(
'https://api.base-search.net/cgi-bin/BaseHttpSearchInterface.fcgi',
params={
'func': 'PerformSearch',
'query': f'dcterms:({query}) AND dcterms.issued:[{start_year} TO {end_year}]',
'hits': min(limit, 100),
'offset': 0,
'format': 'json',
'boost': 'oa', # prioritize open access
},
headers={'User-Agent': 'BIRAS/1.0 (research tool; noranisa@unism.ac.id)'},
timeout=25,
)
r.raise_for_status()
data = r.json()
except Exception as e:
print(f"BASE API error: {e}")
return pd.DataFrame()
normalized_data = []
for item in data.get('response', {}).get('docs', []):
try:
year_raw = item.get('dcterms_issued', [''])[0] if isinstance(item.get('dcterms_issued'), list) else item.get('dcterms_issued', '')
year = int(str(year_raw)[:4]) if year_raw and str(year_raw)[:4].isdigit() else None
if not year or not (start_year <= year <= end_year):
continue
title = item.get('dc_title', ['No Title'])
title = title[0] if isinstance(title, list) else title
authors_raw = item.get('dc_creator', []) or []
authors_list = authors_raw if isinstance(authors_raw, list) else [authors_raw]
abstract_raw = item.get('dc_description', [''])
abstract = abstract_raw[0] if isinstance(abstract_raw, list) else abstract_raw
doi_list = [x for x in (item.get('dc_identifier', []) or []) if 'doi.org' in str(x).lower()]
doi = doi_list[0].replace('https://doi.org/', '').replace('http://doi.org/', '') if doi_list else None
link_list = item.get('dc_identifier', []) or []
link = next((x for x in link_list if str(x).startswith('http')), None)
if doi and not link:
link = f"https://doi.org/{doi}"
publisher_raw = item.get('dc_publisher', ['BASE'])
publisher = publisher_raw[0] if isinstance(publisher_raw, list) else publisher_raw
normalized_data.append({
'id': f"base_{item.get('base_url_icon', doi or title[:30])}",
'title': str(title).strip(),
'authors': [str(a).strip() for a in authors_list if a],
'yearPublished': year,
'publisher': str(publisher),
'abstract': str(abstract)[:2000],
'doi': doi,
'link': link,
'repositories': [],
'cited_by_count': 0,
'is_oa': True, # BASE focuses on open access
})
except Exception:
continue
result_df = pd.DataFrame(normalized_data)
print(f"BASE: {len(result_df)} hasil ditemukan untuk '{query}'")
return result_df
# =====================================================================
# OpenCitations COCI — Open Citation Index, No Key
# =====================================================================
def get_opencitations_data(query, start_year, end_year, limit):
"""
Fetch citation data from OpenCitations COCI.
Best for enriching existing DOIs with citation network data.
Searches via Crossref first, then enriches with OpenCitations citation counts.
No API key required. https://opencitations.net/index/api/v2
"""
import urllib.parse
# Step 1: Get DOIs from Crossref (lightweight query)
try:
r = requests.get(
'https://api.crossref.org/works',
params={
'query': query, 'rows': min(limit, 100),
'select': 'DOI,title,author,published,container-title',
'filter': f'from-pub-date:{start_year},until-pub-date:{end_year}',
},
headers={'User-Agent': 'BIRAS/1.0 (mailto:noranisa@unism.ac.id)'},
timeout=20,
)
r.raise_for_status()
items_raw = r.json().get('message', {}).get('items', [])
except Exception as e:
print(f"OpenCitations (Crossref step) error: {e}")
return pd.DataFrame()
# Step 2: Enrich with OpenCitations citation counts (batch, max 20)
doi_list = [item.get('DOI') for item in items_raw if item.get('DOI')]
oc_cite_map = {}
for doi in doi_list[:20]:
try:
oc_url = f"https://opencitations.net/index/coci/api/v1/citation-count/{urllib.parse.quote(doi, safe='')}"
oc_r = requests.get(oc_url, timeout=8, headers={'User-Agent': 'BIRAS/1.0'})
if oc_r.status_code == 200:
oc_data = oc_r.json()
if oc_data:
oc_cite_map[doi] = int(oc_data[0].get('count', 0))
except Exception:
pass
normalized_data = []
for item in items_raw:
try:
pub_date = item.get('published', {})
date_parts = pub_date.get('date-parts', [[None]])[0]
year = int(date_parts[0]) if date_parts and date_parts[0] else None
if not year or not (start_year <= year <= end_year):
continue
title_raw = item.get('title', ['No Title'])
title = title_raw[0] if isinstance(title_raw, list) else title_raw
authors_list = []
for a in item.get('author', []):
name = f"{a.get('given', '')} {a.get('family', '')}".strip()
if name:
authors_list.append(name)
doi = item.get('DOI')
journal_raw = item.get('container-title', ['OpenCitations'])
journal = journal_raw[0] if isinstance(journal_raw, list) else journal_raw
normalized_data.append({
'id': f"oc_{doi or title[:30]}",
'title': str(title).strip(),
'authors': authors_list,
'yearPublished': year,
'publisher': str(journal),
'abstract': '',
'doi': doi,
'link': f"https://doi.org/{doi}" if doi else None,
'repositories': [],
'cited_by_count': oc_cite_map.get(doi, 0),
})
except Exception:
continue
result_df = pd.DataFrame(normalized_data)
print(f"OpenCitations: {len(result_df)} hasil ditemukan untuk '{query}'")
return result_df
# =====================================================================
# Zenodo API — CERN Open Repository, No Key Required
# =====================================================================
def get_zenodo_data(query, start_year, end_year, limit):
"""
Fetch records from Zenodo (CERN's open research repository).
Includes datasets, software, preprints, and papers.
No API key required. https://developers.zenodo.org/
"""
try:
r = requests.get(
'https://zenodo.org/api/records',
params={
'q': query,
'type': 'publication',
'size': min(limit, 100),
'page': 1,
'sort': 'mostrecent',
'status': 'published',
},
headers={'User-Agent': 'BIRAS/1.0'},
timeout=25,
)
r.raise_for_status()
data = r.json()
except Exception as e:
print(f"Zenodo API error: {e}")
return pd.DataFrame()
normalized_data = []
for item in data.get('hits', {}).get('hits', []):
try:
metadata = item.get('metadata', {})
# Publication date → year
pub_date = metadata.get('publication_date', '')
year = int(pub_date[:4]) if pub_date and pub_date[:4].isdigit() else None
if not year or not (start_year <= year <= end_year):
continue
title = metadata.get('title', 'No Title')
authors_list = [
c.get('name', '').strip()
for c in metadata.get('creators', [])
if c.get('name')
]
doi = metadata.get('doi')
link = item.get('links', {}).get('html') or (f"https://doi.org/{doi}" if doi else None)
journal = metadata.get('journal', {}).get('title') \
or metadata.get('conference', {}).get('title') \
or 'Zenodo'
normalized_data.append({
'id': f"zenodo_{item.get('id', doi)}",
'title': str(title).strip(),
'authors': authors_list,
'yearPublished': year,
'publisher': journal,
'abstract': metadata.get('description', '')[:2000],
'doi': doi,
'link': link,
'repositories': [],
'cited_by_count': 0,
'is_oa': True, # Zenodo is fully open access
})
except Exception:
continue
result_df = pd.DataFrame(normalized_data)
print(f"Zenodo: {len(result_df)} hasil ditemukan untuk '{query}'")
return result_df
# =====================================================================
# BioRxiv & MedRxiv API — Preprint Servers, No Key Required
# =====================================================================
def get_biorxiv_data(query, start_year, end_year, limit):
"""
Fetch preprints from bioRxiv and medRxiv.
Uses the official Content API (no key needed).
Note: searches by keyword in recent date ranges; best for life-science topics.
https://api.biorxiv.org/
"""
import urllib.parse
normalized_data = []
servers = ['biorxiv', 'medrxiv']
for server in servers:
# API supports date-range queries: /details/{server}/{from}/{to}/{cursor}
interval_start = f"{start_year}-01-01"
interval_end = f"{end_year}-12-31"
cursor = 0
fetched = 0
page_size = 100 # max per request
while fetched < min(limit, 200):
try:
url = f"https://api.biorxiv.org/details/{server}/{interval_start}/{interval_end}/{cursor}"
r = requests.get(url, headers={'User-Agent': 'BIRAS/1.0'}, timeout=20)
r.raise_for_status()
data = r.json()
except Exception as e:
print(f"BioRxiv/MedRxiv ({server}) API error: {e}")
break
collection = data.get('collection', [])
if not collection:
break
# Filter by keyword in title or abstract
q_lower = query.lower()
for item in collection:
title = item.get('title', '')
abstract = item.get('abstract', '')
if q_lower not in title.lower() and q_lower not in abstract.lower():
continue
year_raw = item.get('date', '')
year = int(year_raw[:4]) if year_raw and year_raw[:4].isdigit() else None
if not year or not (start_year <= year <= end_year):
continue
authors_str = item.get('authors', '')
authors_list = [a.strip() for a in authors_str.split(';') if a.strip()]
doi = item.get('doi')
link = f"https://doi.org/{doi}" if doi else \
f"https://www.{server}.org/content/{item.get('rel_site', '')}"
normalized_data.append({
'id': f"{server}_{doi or item.get('rel_doi', '')}",
'title': str(title).strip(),
'authors': authors_list,
'yearPublished': year,
'publisher': server.capitalize(),
'abstract': str(abstract)[:2000],
'doi': doi,
'link': link,
'repositories': [],
'cited_by_count': 0,
'is_oa': True,
})
fetched += 1
if fetched >= limit:
break
cursor += page_size
if len(collection) < page_size:
break # no more pages
result_df = pd.DataFrame(normalized_data)
print(f"BioRxiv/MedRxiv: {len(result_df)} hasil ditemukan untuk '{query}'")
return result_df
# =====================================================================
# PLOS API — Public Library of Science, No Key Required
# =====================================================================
def get_plos_data(query, start_year, end_year, limit):
"""
Fetch from PLOS (Public Library of Science) journals.
All articles are open access. No API key required.
https://api.plos.org/search
"""
try:
fq_date = f"publication_date:[{start_year}-01-01T00:00:00Z TO {end_year}-12-31T23:59:59Z]"
r = requests.get(
'https://api.plos.org/search',
params={
'q': f'everything:"{query}"',
'fq': fq_date,
'fl': 'id,title_display,author_display,abstract,publication_date,journal,doi',
'rows': min(limit, 100),
'wt': 'json',
'sort': 'alm_scopusCiteCount desc',
},
headers={'User-Agent': 'BIRAS/1.0'},
timeout=25,
)
r.raise_for_status()
data = r.json()
except Exception as e:
print(f"PLOS API error: {e}")
return pd.DataFrame()
normalized_data = []
for item in data.get('response', {}).get('docs', []):
try:
pub_date = item.get('publication_date', '')
year = int(pub_date[:4]) if pub_date and pub_date[:4].isdigit() else None
if not year or not (start_year <= year <= end_year):
continue
title = item.get('title_display', 'No Title')
authors_raw = item.get('author_display', []) or []
authors_list = authors_raw if isinstance(authors_raw, list) else [authors_raw]
doi = item.get('doi') or item.get('id', '').replace('info:doi/', '')
abstract_raw = item.get('abstract', [''])
abstract = abstract_raw[0] if isinstance(abstract_raw, list) else abstract_raw
normalized_data.append({
'id': f"plos_{doi or item.get('id', '')}",
'title': str(title).strip(),
'authors': [str(a).strip() for a in authors_list if a],
'yearPublished': year,
'publisher': item.get('journal', 'PLOS'),
'abstract': str(abstract)[:2000],
'doi': doi,
'link': f"https://doi.org/{doi}" if doi else None,
'repositories': [],
'cited_by_count': 0,
'is_oa': True, # PLOS = fully open access
})
except Exception:
continue
result_df = pd.DataFrame(normalized_data)
print(f"PLOS: {len(result_df)} hasil ditemukan untuk '{query}'")
return result_df
# =====================================================================
# DataCite API — Datasets, Software, and DOIs (No Key)
# =====================================================================
def get_datacite_data(query, start_year, end_year, limit):
try:
r = requests.get('https://api.datacite.org/dois', params={'query': query, 'page[size]': min(limit, 100)}, timeout=20)
r.raise_for_status()
items = r.json().get('data', [])
except Exception as e:
print(f"DataCite error: {e}")
return pd.DataFrame()
normalized_data = []
for item in items:
try:
attr = item.get('attributes', {})
year = attr.get('publicationYear')
if year: year = int(year)
if not year or not (start_year <= year <= end_year): continue
titles = attr.get('titles', [])
title = titles[0].get('title', 'No Title') if titles else 'No Title'
creators = attr.get('creators', [])
authors_list = [c.get('name', '').strip() for c in creators if c.get('name')]
doi = item.get('doi') or item.get('id')
publisher = attr.get('publisher', 'DataCite')
descriptions = attr.get('descriptions', [])
abstract = descriptions[0].get('description', '') if descriptions else ''
normalized_data.append({
'id': f"datacite_{doi}",
'title': str(title).strip(),
'authors': authors_list,
'yearPublished': year,
'publisher': str(publisher),
'abstract': str(abstract)[:2000],
'doi': doi,
'link': f"https://doi.org/{doi}" if doi else None,
'repositories': [],
'cited_by_count': attr.get('citationCount', 0),
'is_oa': True
})
except Exception:
continue
result_df = pd.DataFrame(normalized_data)
print(f"DataCite: {len(result_df)} hasil ditemukan untuk '{query}'")
return result_df
# =====================================================================
# ORCID Public API — Researcher Profiles (No Key)
# =====================================================================
def get_orcid_data(query, start_year, end_year, limit):
try:
r = requests.get('https://pub.orcid.org/v3.0/expanded-search/',
params={'q': query, 'rows': min(limit, 100)},
headers={'Accept': 'application/json'}, timeout=20)
r.raise_for_status()
items = r.json().get('expanded-result', [])
except Exception as e:
print(f"ORCID error: {e}")
return pd.DataFrame()
normalized_data = []
for item in items:
try:
orcid_id = item.get('orcid-id')
given = item.get('given-names', '')
family = item.get('family-names', '')
name = f"{given} {family}".strip() or "Unknown Researcher"
institutions = item.get('institution-name', [])
inst_name = institutions[0] if institutions else 'ORCID Profile'
from datetime import datetime
year = datetime.now().year
if not (start_year <= year <= end_year):
# ORCID profiles don't really have a strict year, let's include them if year check fails but we want them.
# Just override the year to be end_year to fit the query range.
year = end_year
normalized_data.append({
'id': f"orcid_{orcid_id}",
'title': f"Researcher Profile: {name}",
'authors': [name],
'yearPublished': year,
'publisher': str(inst_name),
'abstract': f"Profil peneliti ORCID untuk {name}. Institusi: {inst_name}.",
'doi': orcid_id,
'link': f"https://orcid.org/{orcid_id}",
'repositories': [],
'cited_by_count': 0,
'is_oa': True
})
except Exception:
continue
result_df = pd.DataFrame(normalized_data)
print(f"ORCID: {len(result_df)} hasil ditemukan untuk '{query}'")
return result_df
# =====================================================================
# OSF (Open Science Framework) API — Preprints & Nodes (No Key)
# =====================================================================
def get_osf_data(query, start_year, end_year, limit):
try:
r = requests.get('https://api.osf.io/v2/nodes/',
params={'filter[title]': query, 'page[size]': min(limit, 100)},
timeout=20)
r.raise_for_status()
items = r.json().get('data', [])
except Exception as e:
print(f"OSF error: {e}")
return pd.DataFrame()
normalized_data = []
for item in items:
try:
attr = item.get('attributes', {})
date_created = attr.get('date_created', '')
year = int(date_created[:4]) if len(date_created) >= 4 and date_created[:4].isdigit() else None
if not year or not (start_year <= year <= end_year): continue
title = attr.get('title', 'No Title')
abstract = attr.get('description', '')
node_id = item.get('id')
normalized_data.append({
'id': f"osf_{node_id}",
'title': str(title).strip(),
'authors': ['OSF Contributor'],
'yearPublished': year,
'publisher': 'OSF',
'abstract': str(abstract)[:2000] if abstract else '',
'doi': None,
'link': f"https://osf.io/{node_id}/",
'repositories': [],
'cited_by_count': 0,
'is_oa': True
})
except Exception:
continue
result_df = pd.DataFrame(normalized_data)
print(f"OSF: {len(result_df)} hasil ditemukan untuk '{query}'")
return result_df
def get_openai_explanation(client, system_prompt, user_prompt):
response = client.chat.completions.create(model="gpt-4o-mini", messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}], temperature=0.3, max_tokens=200)
return response.choices[0].message.content.strip()
# Gemini model fallback list — tries in order until one works
_GEMINI_MODELS = ['gemini-1.5-flash', 'gemini-2.0-flash-lite', 'gemini-2.0-flash', 'gemini-1.0-pro']
_GEMINI_WORKING_MODEL = None
def _get_gemini_client(api_key):
"""Return a working Gemini GenerativeModel, trying fallback models."""
global _GEMINI_WORKING_MODEL
genai.configure(api_key=api_key)
if _GEMINI_WORKING_MODEL:
return genai.GenerativeModel(_GEMINI_WORKING_MODEL)
for model in _GEMINI_MODELS:
try:
m = genai.GenerativeModel(model)
m.generate_content("hi", generation_config={'max_output_tokens': 5})
_GEMINI_WORKING_MODEL = model
print(f"Gemini: working model found → {model}")
return m
except Exception as e:
err = str(e)
if '404' in err or 'not found' in err.lower():
continue
if '429' in err:
# Rate limited but model exists — use it
_GEMINI_WORKING_MODEL = model
return genai.GenerativeModel(model)
continue
_GEMINI_WORKING_MODEL = _GEMINI_MODELS[0]
return genai.GenerativeModel(_GEMINI_WORKING_MODEL)
def get_gemini_explanation(client, system_prompt, user_prompt):
full_prompt = f"{system_prompt}\n\n{user_prompt}"
for attempt in range(2): # 1 retry on 429
try:
response = client.generate_content(full_prompt)
return response.text.strip()
except Exception as e:
if '429' in str(e):
# Parse retry-after from error message
import re as _re
m = _re.search(r'retry_delay.*?seconds: (\d+)', str(e), _re.DOTALL)
wait = int(m.group(1)) + 2 if m else 35
print(f"Gemini rate limited. Menunggu {wait}s sebelum retry...")
time.sleep(wait)
if attempt < 1: continue
raise
def get_deepseek_explanation(client, system_prompt, user_prompt):
response = client.chat.completions.create(model="deepseek-chat", messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}], temperature=0.3, max_tokens=200)
return response.choices[0].message.content.strip()
# Z.AI (z.ai platform) — new model names use dots e.g. GLM-4.5-Flash
_ZAI_MODELS = ['GLM-4.5-Flash', 'GLM-4.7-Flash', 'GLM-4.6', 'GLM-4.5', 'GLM-5-Turbo']
_ZAI_WORKING_MODEL = None
def _get_zai_model(client):
"""Find first available Z.AI model for this account."""
global _ZAI_WORKING_MODEL
if _ZAI_WORKING_MODEL:
return _ZAI_WORKING_MODEL
for model in _ZAI_MODELS:
try:
client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "hi"}],
max_tokens=5
)
_ZAI_WORKING_MODEL = model
print(f"Z.AI: working model found → {model}")
return model
except Exception as e:
if '1211' in str(e) or 'not exist' in str(e).lower():
continue
# Other error (auth, rate limit etc) — return best guess
_ZAI_WORKING_MODEL = model
return model
_ZAI_WORKING_MODEL = _ZAI_MODELS[0]
return _ZAI_WORKING_MODEL
def get_zai_explanation(client, system_prompt, user_prompt):
model = _get_zai_model(client)
response = client.chat.completions.create(model=model, messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}], temperature=0.3, max_tokens=200)
return response.choices[0].message.content.strip()
def get_ai_explanation(graph_title, query, data_summary, ai_model='openai', use_ai=True):
"""
Delegates to unified ai_engine.call_ai() gateway.
Per-user API keys from current_user are temporarily injected into env so
ai_engine.py can pick them up without requiring a Flask context itself.
No hardcoded sleep — ai_engine handles retries/backoff on 429 errors only.
"""
if not use_ai:
return "Penjelasan AI dinonaktifkan."
# Map ai_model -> (user attr, env var)
_KEY_MAP = {
'openai': ('openai_api_key', 'OPENAI_API_KEY'),
'gemini': ('gemini_api_key', 'GEMINI_API_KEY'),
'deepseek': ('deepseek_api_key', 'DEEPSEEK_API_KEY'),
'zai': ('zai_api_key', 'ZAI_API_KEY'),
}
user_attr, env_var = _KEY_MAP.get(ai_model, ('openai_api_key', 'OPENAI_API_KEY'))
user_key = getattr(current_user, user_attr, None) or ''
_old_env = os.environ.get(env_var) # save original
try:
if user_key:
os.environ[env_var] = user_key # temporarily inject per-user key
prompt = (
f"Anda adalah analis riset. Interpretasikan visualisasi berikut dalam 2-3 kalimat "
f"Bahasa Indonesia. Fokus pada tren/pola utama. Jangan ulang angka mentah.\n"
f"Topik: '{query}'. Grafik: '{graph_title}'. Ringkasan data: '{data_summary}'."
)
model_label = {'openai': 'OpenAI', 'gemini': 'Gemini', 'deepseek': 'DeepSeek', 'zai': 'Z.AI'}.get(ai_model, ai_model)
logger.debug("get_ai_explanation: requesting %s for '%s'", model_label, graph_title)
result = call_ai(prompt, model=ai_model, max_tokens=200)
return result or f"Gagal menghasilkan penjelasan dari {model_label}."
except Exception as exc:
logger.warning("get_ai_explanation error (%s): %s", ai_model, exc)
return f"Gagal menghasilkan penjelasan ({ai_model})."
finally:
# Restore original env state
if _old_env is None:
os.environ.pop(env_var, None)
else:
os.environ[env_var] = _old_env
def get_ai_research_suggestions(query, top_keywords, network_links, latest_keywords, ai_model='openai', use_ai=True):
if not use_ai: return ""
api_key = current_user.openai_api_key or os.environ.get('OPENAI_API_KEY')
if ai_model == 'gemini':
api_key = current_user.gemini_api_key or os.environ.get('GEMINI_API_KEY')
elif ai_model == 'deepseek':
api_key = current_user.deepseek_api_key or os.environ.get('DEEPSEEK_API_KEY')
elif ai_model == 'zai':
api_key = current_user.zai_api_key or os.environ.get('ZAI_API_KEY')
if not api_key: return "API Key AI tidak diatur untuk menghasilkan saran."
model_name = {'openai': 'OpenAI', 'gemini': 'Gemini', 'deepseek': 'DeepSeek', 'zai': 'Z.AI'}.get(ai_model, ai_model.title())
try:
print(f"Meminta saran penelitian dari {model_name}...")
system_prompt = "Anda adalah seorang analis riset dan inovasi yang visioner. Tugas Anda adalah memberikan saran topik penelitian masa depan yang relevan dan menarik berdasarkan data yang ada."
user_prompt = f"""
Topik Utama: "{query}"
Data Analisis:
- Kata Kunci Paling Sering Muncul: {', '.join(top_keywords)}
- Konsep yang Saling Terhubung Kuat: {', '.join(network_links)}
- Tren Kata Kunci Terbaru: {', '.join(latest_keywords)}
TUGAS: Berdasarkan data di atas, berikan 2-3 saran konkret untuk topik penelitian atau judul publikasi di masa depan. Fokus pada area yang belum banyak dibahas (research gap), kombinasi konsep yang unik, atau penerapan teknologi baru pada topik ini. Jelaskan secara singkat (1-2 kalimat) mengapa setiap saran ini menarik. Gunakan format poin bernomor.
"""
suggestion = ""
if ai_model == 'gemini':
client = _get_gemini_client(api_key)
full_prompt = f"{system_prompt}\n\n{user_prompt}"
for attempt in range(2):
try:
response = client.generate_content(full_prompt)
suggestion = response.text.strip().replace('\n', '
')
break
except Exception as _e:
if '429' in str(_e) and attempt < 1:
import re as _re
_m = _re.search(r'retry_delay.*?seconds: (\d+)', str(_e), _re.DOTALL)
_wait = int(_m.group(1)) + 2 if _m else 35
print(f"Gemini rate limited. Menunggu {_wait}s...")
time.sleep(_wait)
continue
raise
else: # OpenAI, DeepSeek, and Z.AI share a similar API structure
base_urls = {'openai': 'https://api.openai.com/v1', 'deepseek': 'https://api.deepseek.com/v1', 'zai': 'https://open.bigmodel.cn/api/paas/v4'}
if ai_model == 'zai':
zai_base = os.environ.get('ZAI_BASE_URL', 'https://open.bigmodel.cn/api/paas/v4')
zai_client = OpenAI(api_key=api_key, base_url=zai_base, timeout=30.0)
zai_model = _get_zai_model(zai_client)
models = {'openai': 'gpt-4o-mini', 'deepseek': 'deepseek-chat', 'zai': zai_model}
else:
models = {'openai': 'gpt-4o-mini', 'deepseek': 'deepseek-chat', 'zai': 'glm-4-flash'}
base_url = base_urls.get(ai_model, 'https://api.openai.com/v1')
model = models.get(ai_model, 'gpt-4o-mini')
client = OpenAI(api_key=api_key, base_url=base_url, timeout=25.0)
response = client.chat.completions.create(model=model, messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}], temperature=0.7, max_tokens=400)
suggestion = response.choices[0].message.content.strip().replace('\n', '
')
print(" -> Saran penelitian berhasil didapat.")
return suggestion
except Exception as e:
print(f"Error AI (saran): {e}")
return "Gagal menghasilkan saran dari AI (mungkin kuota habis)."
def get_source_name(row, source_api):
publisher = row.get('publisher')
if publisher and isinstance(publisher, str) and publisher.strip().lower() != 'n/a': return publisher.strip()
if source_api == 'core' and (repos := row.get('repositories')) and isinstance(repos, list) and repos:
if isinstance(repos[0], dict): return repos[0].get('repository', 'CORE (Unknown Repo)')
return source_api.replace('_', ' ').title()
def get_source_url(source_name):
if not isinstance(source_name, str): return None
name = source_name.split(' (')[0].strip().lower()
if '.' in name and not ' ' in name: return f"https://{name}"
domain_map = {'springer': 'springer.com', 'taylor & francis': 'taylorandfrancis.com', 'emerald': 'emerald.com/insight', 'ieee xplore': 'ieee.org', 'acm digital library': 'dl.acm.org', 'arxiv': 'arxiv.org'}
if name in domain_map: return f"https://{domain_map[name]}"
return None
def convert_country_code_to_name(code):
if not code or not isinstance(code, str): return None
try: return pycountry.countries.get(alpha_2=code.upper()).name
except (LookupError, AttributeError): return code
def guess_country_from_name(author_name):
if not GENDER_DETECTOR or not author_name or not isinstance(author_name, str): return None
first_name = author_name.split(' ')[0].capitalize()
result = GENDER_DETECTOR.get_gender(first_name)
if result in ['andy', 'unknown']: return None
if '(' in result and ')' in result:
try:
country_name = result[result.find("(")+1:result.find(")")]
country = pycountry.countries.get(name=country_name)
return country.alpha_2 if country else None
except (LookupError, AttributeError): return None
return None
# =====================================================================
# PARALLEL AI EXPLANATION HELPER
# =====================================================================
def _run_ai_explanations_parallel(tasks: dict, ai_model: str, use_ai: bool) -> dict:
"""
Jalankan semua panggilan get_ai_explanation() secara PARALEL.
Args:
tasks: dict berisi {result_key: (graph_title, query, data_summary)}
ai_model: nama model AI yang digunakan
use_ai: apakah AI diaktifkan
Returns:
dict berisi {result_key: explanation_text}
"""
if not use_ai:
return {k: '' for k in tasks}
from concurrent.futures import ThreadPoolExecutor, as_completed
results = {k: '' for k in tasks}
with ThreadPoolExecutor(max_workers=6) as executor:
future_to_key = {
executor.submit(get_ai_explanation, title, query, summary, ai_model, use_ai): key
for key, (title, query, summary) in tasks.items()
}
for future in as_completed(future_to_key):
key = future_to_key[future]
try:
results[key] = future.result() or ''
except Exception as _pex:
logger.warning("Parallel AI call failed for '%s': %s", key, _pex)
results[key] = ''
return results
# =====================================================================
# UNPAYWALL API — Open Access PDF enrichment
# =====================================================================
_UNPAYWALL_CACHE = {} # {doi: (timestamp, data_dict)}
_UNPAYWALL_CACHE_TTL = 3600 # 1 hour
_UNPAYWALL_EMAIL = os.environ.get('PYALEX_EMAIL', 'noranisa@unism.ac.id')
def _fetch_single_unpaywall(doi):
"""Fetch Unpaywall data for a single DOI. Returns dict or None."""
import time as _t
if not doi or not isinstance(doi, str):
return None
doi = doi.strip().lstrip('https://doi.org/').lstrip('http://dx.doi.org/')
if not doi:
return None
now = _t.time()
if doi in _UNPAYWALL_CACHE:
ts, cached = _UNPAYWALL_CACHE[doi]
if now - ts < _UNPAYWALL_CACHE_TTL:
return cached
try:
url = f"https://api.unpaywall.org/v2/{doi}"
resp = requests.get(url, params={'email': _UNPAYWALL_EMAIL}, timeout=10)
if resp.status_code == 404:
result = {'is_oa': False, 'pdf_url': None, 'landing_page_url': None, 'oa_status': 'closed'}
elif resp.status_code == 200:
data = resp.json()
best = data.get('best_oa_location') or {}
result = {
'is_oa': data.get('is_oa', False),
'pdf_url': best.get('url_for_pdf'),
'landing_page_url': best.get('url') or best.get('url_for_landing_page'),
'oa_status': data.get('oa_status', 'unknown')
}
else:
result = None
if result:
_UNPAYWALL_CACHE[doi] = (now, result)
return result
except Exception as e:
print(f"Unpaywall error for {doi}: {e}")
return None
def enrich_with_unpaywall(articles_list, max_dois=50):
"""Batch-enrich articles with Unpaywall OA data using concurrent requests."""
from concurrent.futures import ThreadPoolExecutor, as_completed
dois_to_fetch = []
doi_to_indices = {}
for i, art in enumerate(articles_list):
doi = art.get('doi')
if doi and isinstance(doi, str):
doi_clean = doi.strip().lstrip('https://doi.org/').lstrip('http://dx.doi.org/')
if doi_clean:
doi_to_indices.setdefault(doi_clean, []).append(i)
if doi_clean not in dois_to_fetch and doi_clean not in _UNPAYWALL_CACHE:
dois_to_fetch.append(doi_clean)
# Respect max_dois limit
dois_to_fetch = dois_to_fetch[:max_dois]
print(f"Unpaywall: enriching {len(dois_to_fetch)} DOIs (+ {len(doi_to_indices) - len(dois_to_fetch)} from cache)...")
with ThreadPoolExecutor(max_workers=8) as executor:
futures = {executor.submit(_fetch_single_unpaywall, doi): doi for doi in dois_to_fetch}
for future in as_completed(futures):
pass # results go to cache
# Apply cached results to articles
oa_count = 0
for doi_clean, indices in doi_to_indices.items():
result = _UNPAYWALL_CACHE.get(doi_clean, (None, None))[1]
for i in indices:
if result:
articles_list[i].update(result)
if result.get('is_oa'):
oa_count += 1
else:
articles_list[i].update({'is_oa': False, 'pdf_url': None, 'landing_page_url': None, 'oa_status': 'unknown'})
print(f"Unpaywall: {oa_count} artikel open access ditemukan.")
return articles_list
# =====================================================================
# STATIC FILE CLEANUP — PyVis HTML accumulation prevention
# =====================================================================
_NETWORK_HTML_MAX_AGE_HOURS = int(os.environ.get('NETWORK_HTML_MAX_AGE_HOURS', '24'))
def _cleanup_old_network_files(folder: str, prefix: str,
max_age_hours: int = _NETWORK_HTML_MAX_AGE_HOURS) -> int:
"""
Delete PyVis-generated HTML files older than max_age_hours from 'folder'
whose filename starts with 'prefix'. Called before each new file is written
to prevent unlimited accumulation on HuggingFace Spaces (limited /data storage).
Returns the number of files deleted.
"""
import glob
import time as _t
cutoff = _t.time() - max_age_hours * 3600
deleted = 0
for path in glob.glob(os.path.join(folder, f"{prefix}*.html")):
try:
if os.path.getmtime(path) < cutoff:
os.remove(path)
deleted += 1
except OSError:
pass
if deleted:
logger.info("Cleaned up %d old %s*.html files from static folder", deleted, prefix)
return deleted
# =====================================================================
# FUNGSI ANALISIS TERPUSAT
# =====================================================================
# =====================================================================
# INSIGHT GENERATOR + RESEARCH RECOMMENDATION ENGINE
# =====================================================================
def generate_insight_summary(df, top_keywords, lda_topics, use_ai=False, ai_model='openai', query=''):
"""
Generate rule-based insight cards from corpus data.
Returns a list of dicts: {icon, label, text, type}
Optionally polished by AI if use_ai=True.
"""
import math
insights = []
current_year = datetime.datetime.now().year
# ── 1. Dominant Keyword ───────────────────────────────────────────
if top_keywords:
top_kw = top_keywords[0][0] if isinstance(top_keywords[0], (list, tuple)) else top_keywords[0]
kw_count = sum(1 for _, row in df.iterrows()
if top_kw.lower() in str(row.get('abstract', '') or row.get('title', '')).lower())
kw_pct = round(kw_count / max(len(df), 1) * 100)
insights.append({
'icon': '🔑', 'label': 'Topik Dominan', 'type': 'keyword',
'text': f"Keyword dominan: '{top_kw}' — muncul di {kw_pct}% artikel. "
f"Menunjukkan fokus utama corpus penelitian ini."
})
# ── 2. Publication Trend ─────────────────────────────────────────
try:
trend = df.groupby('yearPublished').size()
trend = trend[trend.index.notna()]
if len(trend) >= 2:
peak_yr = int(trend.idxmax())
growth = trend.pct_change().mean() * 100
if growth > 15:
trend_text = f"📈 Tumbuh rata-rata {growth:.0f}%/tahun. Puncak publikasi: {peak_yr}. Topik sangat aktif."
trend_type = 'growing'
elif growth < -10:
trend_text = f"📉 Tren menurun sejak puncak di {peak_yr}. Kemungkinan topik jenuh atau bergeser."
trend_type = 'declining'
else:
trend_text = f"➡️ Tren stabil. Puncak publikasi: {peak_yr}. Komunitas penelitian mature."
trend_type = 'stable'
insights.append({'icon': '📊', 'label': 'Tren Publikasi', 'type': trend_type, 'text': trend_text})
except Exception:
pass
# ── 3. Geographic Distribution ───────────────────────────────────
try:
if 'country_name' in df.columns:
country_counts = df['country_name'].dropna().value_counts()
if len(country_counts) > 0:
top_country = country_counts.index[0]
top_pct = round(country_counts.iloc[0] / max(len(df), 1) * 100)
n_countries = len(country_counts)
asean = {'Indonesia', 'Malaysia', 'Thailand', 'Vietnam', 'Philippines', 'Singapore'}
asean_present = asean & set(country_counts.index.tolist())
asean_note = f" Kontribusi ASEAN: {len(asean_present)} negara." if asean_present else " Riset dari Asia Tenggara masih minim."
insights.append({
'icon': '🌍', 'label': 'Distribusi Geografis', 'type': 'geo',
'text': f"Dominasi dari {top_country} ({top_pct}%). "
f"Total {n_countries} negara terwakili.{asean_note}"
})
except Exception:
pass
# ── 4. Open Access ────────────────────────────────────────────────
try:
oa_series = df.get('is_oa', pd.Series([False]*len(df)))
oa_pct = round(oa_series.fillna(False).mean() * 100)
oa_text = (
f"{oa_pct}% artikel open access — akses luas, mendukung replikasi."
if oa_pct >= 50 else
f"{oa_pct}% artikel open access — akses terbatas. Gunakan Unpaywall untuk PDF."
)
insights.append({'icon': '🔓', 'label': 'Open Access', 'type': 'oa', 'text': oa_text})
except Exception:
pass
# ── 5. LDA Topic Summary ─────────────────────────────────────────
if lda_topics:
topic_summary = '; '.join([f"Topik {i+1}: {t[:40]}" for i, t in enumerate(lda_topics[:3])])
insights.append({
'icon': '🧩', 'label': 'Klaster Topik', 'type': 'lda',
'text': f"Terdeteksi {len(lda_topics)} klaster topik. Topik dominan: {lda_topics[0][:60]}..."
})
# ── Optional AI Polish ────────────────────────────────────────────
if use_ai and insights:
try:
summary_text = ' | '.join([i['text'].replace('', '').replace('', '') for i in insights])
prompt = (f"Kamu adalah asisten riset. Berikan 1 kalimat kesimpulan singkat (max 40 kata) "
f"tentang hasil bibliometrik berikut untuk query '{query}': {summary_text}")
ai_summary = _call_ai_single(prompt, ai_model)
if ai_summary:
insights.insert(0, {
'icon': '🤖', 'label': 'AI Summary', 'type': 'ai_summary',
'text': ai_summary
})
except Exception as e:
print(f"⚠️ AI polish untuk insight gagal: {e}")
return insights
def _call_ai_single(prompt, ai_model='openai'):
"""
Thin shim around the unified ai_engine.call_ai() gateway.
Kept for backward-compat with callers inside generate_insight_summary etc.
"""
return call_ai(prompt, model=ai_model, max_tokens=200)
METHOD_PATTERNS = {
'Machine Learning': r'machine learning|random forest|svm|xgboost|gradient boost|classification|regression',
'Deep Learning': r'deep learning|neural network|cnn|lstm|transformer|bert|attention mechanism',
'NLP': r'natural language|text mining|sentiment|named entity|nlp|text classification',
'Systematic Review': r'systematic review|meta.analysis|prisma|literature review',
'Survey/Questionnaire': r'survey|questionnaire|respondent|likert|sample',
'Experiment': r'experiment|control group|hypothesis|laboratory|randomized',
'Case Study': r'case study|qualitative|interview|thematic analysis',
'Simulation': r'simulation|agent.based|monte carlo|discrete event',
}
# =====================================================================
# TEXT-TO-SPEECH (VOICE NARRATION)
# =====================================================================
_TTS_AUDIO_DIR = os.path.join(os.path.dirname(__file__), 'static', 'audio')
os.makedirs(_TTS_AUDIO_DIR, exist_ok=True)
def generate_voice_from_text(text: str, filename: str,
lang: str = 'en',
use_openai: bool = True) -> str | None:
"""
Convert text to an MP3 audio file.
- Tries OpenAI TTS (gpt-4o-mini-tts / tts-1) if OPENAI_API_KEY is set.
- Falls back to gTTS (free, Google TTS).
- Returns the static URL path like '/static/audio/xxx.mp3', or None on failure.
- Caches: if the file already exists, returns immediately.
- Skips silently if text is empty or too short.
"""
if not text or len(text.strip()) < 10:
return None
safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '_', filename)
filepath = os.path.join(_TTS_AUDIO_DIR, f"{safe_name}.mp3")
url_path = f"/static/audio/{safe_name}.mp3"
# ── Cache hit ──────────────────────────────────────────────────────────────
if os.path.exists(filepath):
print(f"🔊 TTS cache hit: {safe_name}.mp3")
return url_path
# Truncate to ~800 chars to stay within TTS token limits
truncated = text.strip()[:800]
# ── OpenAI TTS ───────────────────────────────────────────────────────────
if use_openai:
api_key = os.environ.get('OPENAI_API_KEY', '')
if api_key:
try:
import openai as _oai
client = _oai.OpenAI(api_key=api_key)
response = client.audio.speech.create(
model='tts-1', # 'tts-1-hd' for higher quality
voice='alloy', # alloy|echo|fable|onyx|nova|shimmer
input=truncated,
response_format='mp3',
)
with open(filepath, 'wb') as fh:
fh.write(response.content)
logger.info("TTS (OpenAI): %s.mp3", safe_name)
return url_path
except Exception as _tts_e:
logger.warning("OpenAI TTS failed, trying gTTS: %s", _tts_e)
# ── gTTS Fallback ─────────────────────────────────────────────────────────────
try:
from gtts import gTTS
tts = gTTS(text=truncated, lang=lang, slow=False)
tts.save(filepath)
logger.info("TTS (gTTS fallback): %s.mp3", safe_name)
return url_path
except Exception as _gtts_e:
logger.error("TTS completely failed: %s", _gtts_e)
return None
def _build_tts_text(analysis_results: dict) -> dict:
"""
Build 3 TTS-ready text chunks from analysis_results.
Returns dict: {trend_text, tfidf_text, narrative_text}
"""
# Priority: dedicated explanation key → shared narrative fallback
trend_text = (
analysis_results.get('trend_explanation') or
analysis_results.get('insights_text') or ''
)
tfidf_text = (
analysis_results.get('tfidf_explanation') or
analysis_results.get('recommendations_text') or ''
)
narrative_text = (
analysis_results.get('narrative_text') or
analysis_results.get('chart_key_insight') or ''
)
return {
'trend_text': trend_text,
'tfidf_text': tfidf_text,
'narrative_text': narrative_text,
}
# =====================================================================
# ACADEMIC WRITING ENGINE
# =====================================================================
def generate_global_summary(analysis_results: dict, use_ai: bool = False,
ai_model: str = 'openai') -> str:
"""
Generate a single-paragraph global research summary.
Uses top insight + dominant topic + trend + recommendation.
"""
try:
query = analysis_results.get('query', 'the studied domain')
n = analysis_results.get('total_articles', analysis_results.get('article_count', 0))
topics = analysis_results.get('lda', []) # list of topic strings
recs = analysis_results.get('recommendations', [])
insights = analysis_results.get('insights', [])
key_ins = analysis_results.get('chart_key_insight', '')
gap_text = ''
if recs:
first_rec = recs[0]
gap_text = first_rec.get('rationale', first_rec.get('title', ''))[:120]
topic_str = topics[0][:60] if topics else 'interdisciplinary themes'
insight_str = ''
if insights:
ins = insights[0]
insight_str = ins.get('text', ins.get('label', '')) if isinstance(ins, dict) else str(ins)
insight_str = insight_str[:120]
elif key_ins:
insight_str = key_ins[:120]
summary = (
f"A systematic analysis of {n} articles related to '{query}' reveals a "
f"dynamic and evolving research landscape. The dominant theme across the corpus "
f"centres on {topic_str}, with {insight_str or 'notable patterns emerging across publication years'}. "
f"Bibliometric indicators confirm sustained scholarly interest, supported by "
f"citation networks that highlight several high-impact anchor papers. "
f"Gap analysis further identifies {gap_text or 'under-explored methodological directions'} "
f"as a priority area for future investigation, suggesting significant opportunities "
f"for novel empirical and review-based contributions within this field."
)
if use_ai:
prompt = (
f"Write a single academic paragraph (120–150 words) summarising a bibliometric "
f"study on '{query}' covering {n} articles. Key findings:\n"
f"- Dominant topic: {topic_str}\n"
f"- Key insight: {insight_str}\n"
f"- Priority gap: {gap_text}\n"
f"Tone: formal, publishable, Q1-journal level."
)
result = _call_ai_single(prompt, ai_model=ai_model)
if result and len(result) > 80:
return result.strip()
except Exception as _e:
print(f"⚠️ generate_global_summary error: {_e}")
return summary if 'summary' in dir() else 'Global summary unavailable.'
def generate_validation_summary(df, analysis_results: dict = None) -> str:
"""
Provide lightweight empirical validation that identified insights are consistent
with citation evidence and temporal distribution.
"""
try:
n = len(df)
cit_col = 'cited_by_count' if 'cited_by_count' in df.columns else None
yr_col = 'yearPublished' if 'yearPublished' in df.columns else None
top_paper, top_cit, recent_pct = 'N/A', 0, 0
if cit_col and df[cit_col].notna().any():
idx = df[cit_col].idxmax()
top_paper = str(df.loc[idx, 'title'])[:70]
top_cit = int(df[cit_col].max())
if yr_col:
yr_s = pd.to_numeric(df[yr_col], errors='coerce').dropna()
current = pd.Timestamp.now().year
recent_pct = round(len(yr_s[yr_s >= current - 3]) / max(len(yr_s), 1) * 100)
return (
f"The identified insights are empirically grounded in a corpus of {n} peer-reviewed articles. "
f"The most cited work, \u2018{top_paper}\u2019, accumulates {top_cit:,} citations, "
f"confirming its role as an intellectual anchor consistent with the trend analysis output. "
f"{recent_pct}% of articles were published within the last three years, "
f"validating the temporal currency of the retrieved literature. "
f"Cross-referencing citation frequency with keyword co-occurrence further corroborates "
f"the thematic clusters detected by LDA, reinforcing the reliability of the analytical pipeline."
)
except Exception as _e:
print(f"⚠️ generate_validation_summary error: {_e}")
return 'Validation summary unavailable.'
def generate_system_positioning() -> str:
"""
Articulate the competitive advantage of BIRAS over traditional bibliometric tools.
"""
return (
"Compared to conventional bibliometric tools such as VOSviewer and Bibliometrix, "
"the proposed BIRAS (Bibliometric Intelligent Research Assistant System) offers "
"a substantially extended analytical capability. While existing tools predominantly "
"support manual visualisation of co-citation and co-authorship networks, BIRAS integrates "
"automated gap detection across temporal, geographic, volumetric, and methodological dimensions, "
"LLM-driven narrative generation, semantic embedding maps via sentence-transformers and t-SNE, "
"and a voice narration layer for accessibility. The system aggregates multi-source literature "
"(OpenAlex, CORE, Crossref, Semantic Scholar) through a unified pipeline, "
"reducing manual screening effort and enabling reproducible, data-driven systematic reviews "
"at a scale and speed unattainable with traditional desktop bibliometric software."
)
def generate_abstract(analysis_results: dict, query: str,
use_ai: bool = False, ai_model: str = 'openai') -> str:
"""
Generate a Q1-journal-quality abstract (150–200 words) for the SLR paper.
"""
try:
n = analysis_results.get('total_articles', analysis_results.get('article_count', 0))
topics = analysis_results.get('lda', [])
topic_str = topics[0][:50] if topics else 'emerging sub-fields'
global_s = analysis_results.get('global_summary', '')[:200]
sys_pos = analysis_results.get('system_positioning', '')[:200]
recs = analysis_results.get('recommendations', [])
gap_str = recs[0].get('gap_type_label', 'identified gaps') if recs else 'identified gaps'
abstract = (
f"The exponential growth of scholarly publications related to '{query}' "
f"necessitates advanced analytical tools capable of synthesising large corpora "
f"with both breadth and precision. This study presents BIRAS, an AI-powered "
f"Bibliometric Intelligent Research Assistant System, applied to a corpus of "
f"{n} peer-reviewed articles retrieved from multiple academic databases. "
f"Employing TF-IDF keyword extraction, Latent Dirichlet Allocation topic modelling, "
f"citation network analysis, and semantic embedding visualisation, the system "
f"identifies dominant research themes centred on {topic_str}. "
f"Automated gap detection reveals {gap_str} as a priority research direction. "
f"{global_s[:100] if global_s else ''} "
f"The findings demonstrate that BIRAS provides reproducible, scalable, and "
f"insight-driven systematic review capabilities, addressing key limitations of "
f"existing bibliometric software and offering actionable research recommendations "
f"for the scholarly community."
).strip()
if use_ai:
prompt = (
f"Write a formal academic abstract (160–200 words) for a paper that presents "
f"BIRAS, an AI-powered bibliometric system, applied to '{query}' ({n} articles). "
f"Include: background, method (TF-IDF, LDA, citation networks, semantic embedding), "
f"key results ({global_s[:80]}), and conclusion ({sys_pos[:80]}). "
f"Style: Q1 journal, formal English, no first person."
)
result = call_ai(prompt, ai_model=ai_model)
if result and len(result) > 80:
return result.strip()
except Exception as _e:
print(f"⚠️ generate_abstract error: {_e}")
return abstract if 'abstract' in dir() else 'Abstract unavailable.'
def generate_conclusion(analysis_results: dict,
use_ai: bool = False, ai_model: str = 'openai') -> str:
"""
Generate a conclusion paragraph summarising key findings and system contribution.
"""
try:
global_s = analysis_results.get('global_summary', '')[:200]
valid_s = analysis_results.get('validation_summary', '')[:150]
sys_pos = analysis_results.get('system_positioning', '')[:150]
n = analysis_results.get('total_articles', analysis_results.get('article_count', 0))
conclusion = (
f"This study demonstrates the capacity of the BIRAS system to conduct "
f"comprehensive, automated bibliometric analysis across a corpus of {n} articles. "
f"{global_s[:150] if global_s else 'The analysis uncovered significant thematic patterns and temporal trends within the literature.'} "
f"{valid_s[:100] if valid_s else ''} "
f"The results confirm that integrating multi-source data aggregation, "
f"machine learning-based topic modelling, and LLM-driven narrative generation "
f"substantially enhances the analytical depth and reproducibility of systematic reviews. "
f"{sys_pos[:100] if sys_pos else ''} "
f"In sum, BIRAS represents a significant methodological contribution to the "
f"field of research synthesis and knowledge management."
).strip()
if use_ai:
prompt = (
f"Write a formal academic conclusion paragraph (120–160 words) for a paper presenting BIRAS. "
f"Summarise: {global_s[:120]}. System contribution: {sys_pos[:100]}. "
f"Style: Q1 journal, formal English, third person."
)
result = call_ai(prompt, ai_model=ai_model)
if result and len(result) > 80:
return result.strip()
except Exception as _e:
print(f"⚠️ generate_conclusion error: {_e}")
return conclusion if 'conclusion' in dir() else 'Conclusion unavailable.'
def generate_future_work(analysis_results: dict,
use_ai: bool = False, ai_model: str = 'openai') -> str:
"""
Generate a future work paragraph derived from gap recommendations.
"""
try:
recs = analysis_results.get('recommendations', [])
gap_items = []
for r in recs[:3]:
label = r.get('gap_type_label', r.get('gap_type', 'Gap'))
title = r.get('title', '')[:60]
gap_items.append(f"{label}: {title}")
gap_block = '; '.join(gap_items) if gap_items else 'methodological and geographic expansion'
future = (
f"Future research may build upon the identified gaps to advance the field in "
f"several directions. Specifically, {gap_block}. "
f"The integration of full-text analysis—extending beyond title and abstract "
f"mining—would further enhance thematic resolution, particularly for "
f"domain-specific corpora. Additionally, extending the geographic scope to "
f"include under-represented ASEAN regions may reveal contextual nuances "
f"currently absent from the global literature. "
f"From a technical perspective, incorporating transformer-based embeddings "
f"(e.g., SciBERT, SPECTER) as an alternative to TF-IDF would improve "
f"semantic precision. Finally, a longitudinal replication study applying "
f"BIRAS to an updated corpus every 12 months could serve as a dynamic "
f"knowledge-tracking mechanism for rapidly evolving research domains."
)
if use_ai:
prompt = (
f"Write a formal future work paragraph (130–170 words) for a bibliometric paper. "
f"Identified gaps: {gap_block}. "
f"Suggest concrete research directions. Style: Q1 journal, formal English."
)
result = call_ai(prompt, ai_model=ai_model)
if result and len(result) > 80:
return result.strip()
except Exception as _e:
print(f"⚠️ generate_future_work error: {_e}")
return future if 'future' in dir() else 'Future work unavailable.'
# =====================================================================
# RESEARCH IDEATION ENGINE
# =====================================================================
def generate_research_questions(analysis_results: dict, df,
use_ai: bool = False,
ai_model: str = 'openai') -> list:
"""
Generate 3-5 specific, researchable questions derived from gaps,
topics and insights. Returns a list of strings.
"""
try:
topics = analysis_results.get('lda', []) # list of topic strings
recs = analysis_results.get('recommendations', [])
insights = analysis_results.get('insights', [])
query = analysis_results.get('query', 'the studied domain')
# Pull readable strings from each source
topic_words = []
for t in topics[:3]:
words = str(t).split()[:3]
topic_words.append(' '.join(words))
gap_labels = []
for r in recs[:3]:
lbl = r.get('gap_type_label', r.get('gap_type', ''))
kw = r.get('keyword', r.get('title', ''))[:40]
if lbl or kw:
gap_labels.append(f"{lbl} — {kw}" if lbl and kw else (lbl or kw))
insight_snippets = []
for ins in insights[:2]:
txt = ins.get('text', ins.get('label', '')) if isinstance(ins, dict) else str(ins)
insight_snippets.append(str(txt)[:80])
# ── Rule-based templates ──────────────────────────────────────────
questions = []
for t in topic_words:
questions.append(
f"How can {t} methodologies be adapted to low-resource "
f"settings in the context of {query}?"
)
questions.append(
f"What are the key limitations of existing {t} approaches "
f"and how can they be addressed through novel frameworks?"
)
for g in gap_labels:
questions.append(
f"To what extent does the identified gap in {g} "
f"constrain the generalisability of current findings in {query}?"
)
if insight_snippets:
questions.append(
f"How do the observed trends ({insight_snippets[0]}) "
f"differ across geographic and institutional contexts?"
)
# Deduplicate and limit
seen, unique = set(), []
for q in questions:
key = q[:40].lower()
if key not in seen:
seen.add(key)
unique.append(q)
if len(unique) >= 5:
break
if not unique:
unique = [
f"What methodological approaches have been most effective in {query}?",
f"How has research on {query} evolved over the past decade?",
f"What geographic and demographic gaps exist in {query} literature?",
]
# ── AI refinement ──────────────────────────────────────────────────
if use_ai:
topics_str = '; '.join(topic_words) or query
recs_str = '; '.join(gap_labels) or 'methodological gaps'
ins_str = '; '.join(insight_snippets) or 'emerging patterns'
prompt = (
f"Generate exactly 5 specific, researchable academic research questions "
f"(not generic) for a systematic literature review on '{query}'.\n"
f"Dominant topics: {topics_str}\n"
f"Identified gaps: {recs_str}\n"
f"Key insights: {ins_str}\n"
f"Format: numbered list, one question per line, English, formal academic tone."
)
result = call_ai(prompt, ai_model=ai_model)
if result and len(result) > 40:
lines = [l.strip() for l in result.split('\n')
if l.strip() and len(l.strip()) > 20]
# Strip leading numbering (1. / 1) etc.)
cleaned = []
for l in lines:
l = re.sub(r'^\d+[.)\s]+', '', l).strip()
if l and l not in cleaned:
cleaned.append(l)
if cleaned:
return cleaned[:5]
return unique[:5]
except Exception as _e:
print(f"⚠️ generate_research_questions error: {_e}")
query = analysis_results.get('query', 'the field')
return [
f"What are the dominant methodologies in {query} research?",
f"How has the volume of publications on {query} changed over time?",
f"What geographic regions are under-represented in {query} literature?",
]
def generate_related_work(df, analysis_results: dict = None,
use_ai: bool = False,
ai_model: str = 'openai') -> str:
"""
Generate a citation-aware Related Work paragraph.
Selects top cited papers and builds an academic narrative around them.
"""
try:
cit_col = 'cited_by_count' if 'cited_by_count' in df.columns else None
yr_col = 'yearPublished' if 'yearPublished' in df.columns else None
ttl_col = 'title' if 'title' in df.columns else None
aut_col = 'authors' if 'authors' in df.columns else None
# Sort by citation count; fallback to chronological
if cit_col and df[cit_col].notna().any():
top = df.sort_values(by=cit_col, ascending=False).head(10).copy()
elif yr_col:
top = df.sort_values(by=yr_col, ascending=False).head(10).copy()
else:
top = df.head(10).copy()
# Build paper info list
paper_refs = []
paper_lines = [] # for AI prompt
for _, row in top.iterrows():
title = str(row.get(ttl_col, 'Untitled'))[:80] if ttl_col else 'Untitled'
year = int(row.get(yr_col, 0)) if yr_col and pd.notna(row.get(yr_col)) else ''
cit = int(row.get(cit_col, 0)) if cit_col and pd.notna(row.get(cit_col)) else 0
# Parse authors → first surname
authors_raw = row.get(aut_col, []) if aut_col else []
if isinstance(authors_raw, list) and authors_raw:
first = authors_raw[0]
if isinstance(first, dict):
surname = first.get('last_name', first.get('name', 'Author'))[:20]
else:
surname = str(first).split()[-1][:20]
elif isinstance(authors_raw, str) and authors_raw:
surname = authors_raw.split(',')[0].split()[-1][:20]
else:
surname = 'Author'
ref_tag = f"{surname} ({year})" if year else surname
paper_refs.append({'ref': ref_tag, 'title': title, 'cit': cit})
paper_lines.append(f"- {ref_tag}: {title} [cited {cit}x]")
if not paper_refs:
return 'Related work unavailable due to insufficient data.'
# ── Rule-based narrative ───────────────────────────────────────────
query = (analysis_results or {}).get('query', 'the studied domain')
anchors = paper_refs[:2]
mid = paper_refs[2:5]
recent = paper_refs[5:8]
anchor_str = ' and '.join(f"{p['ref']}" for p in anchors)
mid_str = ', '.join(f"{p['ref']}" for p in mid) if mid else ''
recent_str = ', '.join(f"{p['ref']}" for p in recent) if recent else ''
paragraph = (
f"The scholarly discourse on {query} has been significantly shaped by "
f"foundational contributions from {anchor_str}, whose works represent "
f"the most extensively cited entries in the retrieved corpus. "
)
if mid_str:
paragraph += (
f"Building upon these foundations, subsequent studies by {mid_str} "
f"have extended the analytical scope, introducing diverse methodological "
f"perspectives and contextual applications. "
)
if recent_str:
paragraph += (
f"More recent investigations, including those of {recent_str}, "
f"reflect a growing interest in empirical validation and cross-domain "
f"generalisation, suggesting a maturation of the field. "
)
paragraph += (
f"Collectively, this body of literature underscores the importance of "
f"rigorous bibliometric analysis to map intellectual trajectories and "
f"identify productive directions for future inquiry."
)
# ── AI refinement ──────────────────────────────────────────────────
if use_ai:
papers_block = '\n'.join(paper_lines[:8])
prompt = (
f"Write a single Related Work paragraph (150–200 words) for an academic "
f"paper on '{query}', using the following highly cited papers:\n"
f"{papers_block}\n\n"
f"Requirements:\n"
f"- Academic tone (Q1 journal standard)\n"
f"- Mention each author + year inline (e.g., Smith (2021))\n"
f"- Create a logical narrative flow, do NOT just list papers\n"
f"- Connect how each work contributes to the field\n"
f"- No bullet points, single flowing paragraph, English only."
)
result = _call_ai_single(prompt, ai_model=ai_model)
if result and len(result) > 100:
return result.strip()
return paragraph
except Exception as _e:
print(f"⚠️ generate_related_work error: {_e}")
return 'Related work generation failed. Please review the corpus manually.'
def generate_research_recommendations(df, top_keywords, lda_topics, use_ai=False, ai_model='openai', query=''):
"""
Generate research recommendations based on gap detection.
Returns list of dicts: {gap_type, keyword, title, methods, rationale, priority}
"""
recommendations = []
current_year = datetime.datetime.now().year
# ✅ Daftar 10 negara ASEAN resmi (fix: Bangladesh bukan ASEAN → Brunei)
ASEAN_COUNTRIES = {'Indonesia', 'Malaysia', 'Thailand', 'Vietnam', 'Philippines',
'Singapore', 'Brunei', 'Myanmar', 'Cambodia', 'Laos'}
# ── Extract methods distribution ──────────────────────────────────
method_counts = {}
for _, row in df.iterrows():
text = f"{row.get('title', '')} {row.get('abstract', '')}".lower()
for method, pattern in METHOD_PATTERNS.items():
if re.search(pattern, text, re.IGNORECASE):
method_counts[method] = method_counts.get(method, 0) + 1
total_articles = max(len(df), 1)
# Under-used methods (< 5% of corpus)
underused_methods = [m for m, c in method_counts.items() if c / total_articles < 0.05]
dominant_methods = sorted(method_counts, key=method_counts.get, reverse=True)[:2]
# ── Filter keyword: jangan pakai kata generik sebagai label gap ──
_GENERIC_WORDS = {
'learning', 'model', 'data', 'based', 'using', 'study', 'analysis',
'method', 'approach', 'system', 'paper', 'results', 'new', 'use',
'research', 'deep', 'machine', 'neural', 'network', 'review',
'proposed', 'work', 'performance', 'evaluation', 'framework', 'used'
}
kw_list = []
for kw in top_keywords[:20]:
word = kw[0] if isinstance(kw, (list, tuple)) else kw
kw_list.append(word)
# Meaningful keyword list (untuk judul gap yang lebih deskriptif)
meaningful_kws = [w for w in kw_list if w.lower() not in _GENERIC_WORDS and len(w) > 3]
top_kw_label = meaningful_kws[0] if meaningful_kws else (query[:30] if query else kw_list[0] if kw_list else 'topik ini')
# ── GAP 1: Temporal Gap ───────────────────────────────────────────
# Pre-compute lowercase column once for vectorized search (Fix #4)
_full_text_lower = df['abstract'].fillna('').str.lower() + ' ' + df.get('title', pd.Series(dtype=str)).fillna('').str.lower()
for kw in kw_list[:10]:
try:
kw_lower = kw.lower()
kw_mask = _full_text_lower.str.contains(kw_lower, na=False, regex=False)
kw_years_series = df.loc[kw_mask, 'yearPublished'].dropna()
if len(kw_years_series) < 5:
continue
kw_years = kw_years_series.astype(int).tolist()
recent = sum(1 for y in kw_years if y >= current_year - 2)
gs_tau = 1 - (recent / len(kw_years))
if gs_tau > 0.60: # keyword significantly declined recently
recommendations.append({
'gap_type': 'temporal', 'gap_type_label': 'Temporal Gap',
'gap_type_icon': '⏰', 'keyword': kw,
'priority': 'HIGH' if gs_tau > 0.75 else 'MEDIUM',
'gs_score': round(gs_tau, 2),
'title': f"Revival Study: '{kw}' dalam Konteks Terkini",
'methods': dominant_methods + (underused_methods[:1] if underused_methods else []),
'rationale': (
f"Keyword '{kw}' memiliki {len(kw_years)} artikel historis namun hanya "
f"{recent} artikel dalam 2 tahun terakhir (GS_τ={gs_tau:.2f}). "
f"Peluang: update systematic review dengan data terbaru 2022–{current_year}."
),
'suggested_design': 'Systematic Review atau Meta-Analysis (2022–sekarang)',
})
if len([r for r in recommendations if r['gap_type'] == 'temporal']) >= 2:
break
except Exception:
continue
# ── GAP 2: Geographic Gap ─────────────────────────────────────────
# ✅ Disinkronkan dengan peta: menggunakan country_name yang sama
try:
if 'country_name' in df.columns and kw_list:
represented = set(df['country_name'].dropna().unique())
# Negara ASEAN yang SUDAH ada di peta
represented_asean = ASEAN_COUNTRIES & represented
# Negara ASEAN yang BELUM ada di peta
missing_asean = ASEAN_COUNTRIES - represented
if len(missing_asean) >= 2:
gs_gamma = len(missing_asean) / len(ASEAN_COUNTRIES)
missing_names = sorted(list(missing_asean))
represented_names = sorted(list(represented_asean))
recommendations.append({
'gap_type': 'geographic', 'gap_type_label': 'Geographic Gap',
'gap_type_icon': '🌍', 'keyword': top_kw_label,
'priority': 'HIGH' if gs_gamma > 0.6 else 'MEDIUM',
'gs_score': round(gs_gamma, 2),
'title': f"Kontekstualisasi '{top_kw_label}' di ASEAN (Negara Belum Terwakili)",
'methods': ['Survey/Questionnaire', 'Case Study', dominant_methods[0] if dominant_methods else 'Machine Learning'],
'rationale': (
f"{len(missing_asean)} dari 10 negara ASEAN belum terwakili dalam corpus publikasi ini "
f"(GS_γ={gs_gamma:.2f}). "
f"Negara belum terwakili: {', '.join(missing_names[:5])}. "
f"Negara sudah terwakili: {', '.join(represented_names[:5]) if represented_names else 'Belum ada'}. "
f"Terdapat peluang kontekstualisasi dan replikasi studi untuk topik '{top_kw_label}' "
f"di negara-negara tersebut."
),
'suggested_design': 'Empirical study dengan dataset lokal atau mixed-method',
# Tambahan untuk UI sinkronisasi peta
'missing_countries': missing_names,
'represented_countries': represented_names,
})
except Exception:
pass
# ── GAP 3: Volumetric Gap (LDA) ───────────────────────────────────
if lda_topics and len(lda_topics) >= 3:
# Assume last topic = smallest/most under-represented
gap_topic = lda_topics[-1]
recommendations.append({
'gap_type': 'volumetric', 'gap_type_label': 'Volumetric Gap',
'gap_type_icon': '📦', 'keyword': gap_topic[:40],
'priority': 'MEDIUM',
'gs_score': 0.70,
'title': f"Eksplorasi Sub-Field: {gap_topic[:50]}",
'methods': (underused_methods[:2] if underused_methods else ['Simulation', 'Case Study']) + ['Systematic Review'],
'rationale': (
f"Topik '{gap_topic[:40]}' merupakan klaster LDA terkecil dalam corpus "
f"— under-represented relatif terhadap topik dominan. "
f"Peluang: mixed-method atau sintesis sistematis untuk konsolidasi sub-field."
),
'suggested_design': 'Systematic Review atau Scoping Review untuk konsolidasi',
})
# ── GAP 4: Methodological Gap ─────────────────────────────────────
if underused_methods:
under_m = underused_methods[0]
recommendations.append({
'gap_type': 'methodological', 'gap_type_label': 'Methodological Gap',
'gap_type_icon': '🔬', 'keyword': query,
'priority': 'LOW',
'gs_score': round(1 - method_counts.get(under_m, 0) / total_articles, 2),
'title': f"Pendekatan '{under_m}' untuk Topik '{query[:30]}'",
'methods': [under_m],
'rationale': (
f"Metode '{under_m}' hanya digunakan oleh "
f"{method_counts.get(under_m, 0)} dari {total_articles} artikel ({method_counts.get(under_m,0)/total_articles*100:.1f}%). "
f"Pendekatan ini berpotensi memberikan perspektif baru pada topik ini."
),
'suggested_design': f'Studi menggunakan {under_m} sebagai pendekatan utama',
})
# Sort by priority
priority_order = {'HIGH': 0, 'MEDIUM': 1, 'LOW': 2}
recommendations.sort(key=lambda x: priority_order.get(x['priority'], 3))
# ── Optional AI Enhancement ───────────────────────────────────────
if use_ai and recommendations:
for rec in recommendations[:2]: # Only enhance top 2
try:
prompt = (
f"Kamu adalah asisten riset bibliometrik. Berikan 1 kalimat saran penelitian "
f"konkret (max 50 kata) untuk gap berikut: '{rec['title']}'. "
f"Alasan: {rec['rationale'][:150]}"
)
ai_text = _call_ai_single(prompt, ai_model)
if ai_text:
rec['ai_suggestion'] = ai_text
except Exception:
pass
return recommendations
def run_full_analysis(df, query, use_ai, search_id, ai_model):
import time as _time_mod
_t_start = _time_mod.time()
# ── Article cap (performance) ──────────────────────────────────────
_max = int(os.environ.get('MAX_ARTICLES', APP_CONFIG.get('MAX_ARTICLES', 100)))
if len(df) > _max:
logger.info("Capping DataFrame to %d articles (was %d)", _max, len(df))
df = df.head(_max).copy()
analysis_results = {}
# Runtime metrics (populated at each stage)
_metrics = {'api_calls': 0, 'ai_calls': 0, 'cache_hits': 0, 'stages_ok': [], 'stages_err': []}
if 'authors' in df.columns:
df['authors'] = df['authors'].apply(lambda x: json.loads(x) if isinstance(x, str) else (x if isinstance(x, list) else []))
if 'cited_by_count' in df.columns:
df['cited_by_count'] = pd.to_numeric(df['cited_by_count'], errors='coerce').fillna(0).astype(int)
analysis_results.update({
'query': query, 'use_ai': use_ai, 'ai_model': ai_model, 'search_id': search_id,
'total_results': len(df), 'results': df.to_dict('records'),
'start_year': df['yearPublished'].min(), 'end_year': df['yearPublished'].max(),
'api_sources': list(df['api_origin'].unique())
})
# ===== UNPAYWALL ENRICHMENT =====
# max_dois dikurangi dari 60 → 25 untuk mempercepat (Fix #2).
# Masih menggunakan ThreadPoolExecutor internal, jadi tetap concurrent.
try:
analysis_results['results'] = enrich_with_unpaywall(analysis_results['results'], max_dois=25)
analysis_results['oa_count'] = sum(1 for a in analysis_results['results'] if a.get('is_oa'))
except Exception as _uw_e:
print(f"⚠️ Unpaywall enrichment gagal: {_uw_e}")
analysis_results['oa_count'] = 0
# ===== STAGE: Quality Analytics =====
try:
if ANALYTICS_AVAILABLE:
articles_list = analysis_results['results']
for art in articles_list:
art['quality_score'] = calculate_quality_score(art)
qs = [a.get('quality_score', 0) for a in articles_list]
avg_quality = round(sum(qs) / max(len(qs), 1), 1)
max_quality = round(max(qs) if qs else 0, 1)
top_authors_h_index = get_top_authors_h_index(articles_list, n=15)
q_fig = create_quality_score_visualization(articles_list)
h_fig = create_h_index_chart(articles_list)
analysis_results.update({
'quality_scores': qs,
'avg_quality': avg_quality,
'max_quality': max_quality,
'quality_chart_html': q_fig.to_html(full_html=False, include_plotlyjs='cdn'),
'h_index_chart_html': h_fig.to_html(full_html=False, include_plotlyjs='cdn') if h_fig else None,
'top_authors_h_index': top_authors_h_index,
'quality_explanation': get_ai_explanation(
"Publication Quality Analysis", query,
f"Avg quality: {avg_quality}, Max: {max_quality}, N={len(df)}",
ai_model, use_ai),
'h_index_explanation': get_ai_explanation(
"Top Penulis H-Index", query,
f"Top author H-index data: {top_authors_h_index[:3] if top_authors_h_index else 'N/A'}",
ai_model, use_ai),
})
_metrics['stages_ok'].append('quality_analytics')
except Exception as _e:
logger.warning("Stage quality_analytics error: %s", _e)
analysis_results.update({'quality_chart_html': None, 'h_index_chart_html': None,
'quality_explanation': '', 'h_index_explanation': ''})
_metrics['stages_err'].append('quality_analytics')
# ===== STAGE: API Pie Chart =====
try:
_api_counts = df['api_origin'].value_counts().reset_index()
_api_counts.columns = ['API', 'Jumlah']
analysis_results['api_pie_chart_html'] = px.pie(_api_counts, names='API', values='Jumlah', title='Distribusi Artikel Unik per API', hole=.3).to_html(full_html=False, include_plotlyjs='cdn')
analysis_results['api_pie_explanation'] = get_ai_explanation(
"Kontribusi API", query,
f"Sumber: {_api_counts.to_dict('records')[:3]}",
ai_model, use_ai)
_metrics['stages_ok'].append('api_pie')
except Exception as _e:
logger.warning("Stage api_pie error: %s", _e)
analysis_results['api_pie_explanation'] = ''
_metrics['stages_err'].append('api_pie')
# ===== STAGE: Article Index Maps =====
author_articles_map, country_articles_map, author_pairs = {}, {}, []
year_articles_map, source_articles_map = {}, {}
try:
for _, row in df.iterrows():
link = row.get('link') or (f"https://doi.org/{row.get('doi')}" if row.get('doi') else "#")
info = {'title': row.get('title', 'No Title'), 'link': link}
if (auths := row.get('authors', [])) and isinstance(auths, list):
names = [n for n in auths if n]
for n in names: author_articles_map.setdefault(n, []).append(info)
if len(names) > 1: author_pairs.extend(list(itertools.combinations(names[:5], 2)))
if cn := row.get('country_name'): country_articles_map.setdefault(cn, []).append(info)
if yr := row.get('yearPublished'): year_articles_map.setdefault(str(yr), []).append(info)
if src := row.get('source_display'): source_articles_map.setdefault(src, []).append(info)
analysis_results.update({
'author_articles_map_json': json.dumps(author_articles_map),
'country_articles_map_json': json.dumps(country_articles_map),
'year_articles_map_json': json.dumps(year_articles_map),
'source_articles_map_json': json.dumps(source_articles_map),
})
_metrics['stages_ok'].append('article_maps')
except Exception as _e:
logger.warning("Stage article_maps error: %s", _e)
_metrics['stages_err'].append('article_maps')
if 'source_url' not in df.columns: df['source_url'] = df['source_display'].apply(get_source_url)
df['full_text'] = df['title'].fillna('') + ' ' + df.get('abstract', pd.Series(dtype=str)).fillna('')
# ── Fix #3: Tokenisasi sekali di sini, hasil di-cache di kolom '_tokens' ──
# Menghindari pemanggilan clean_text() berulang di setiap stage analisis.
logger.info("Tokenizing full_text corpus...")
df['_tokens'] = df['full_text'].apply(clean_text)
all_words = [word for tokens in df['_tokens'] for word in tokens]
if not all_words:
analysis_results['error'] = "Tidak ada kata kunci yang bisa dianalisis."
return analysis_results
word_freq = Counter(all_words)
top_20_keywords = word_freq.most_common(20)
analysis_results['top_keywords'] = top_20_keywords
# ===== STAGE: Word Cloud =====
try:
wc = WordCloud(width=800, height=400, background_color='white').generate(" ".join(all_words))
buf = io.BytesIO(); wc.to_image().save(buf, format='PNG')
analysis_results['wordcloud_img'] = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode('ascii')}"
analysis_results['wordcloud_explanation'] = get_ai_explanation("Word Cloud", query, f"Kata kunci teratas: {', '.join([kw[0] for kw in top_20_keywords[:5]])}", ai_model, use_ai)
_metrics['stages_ok'].append('wordcloud')
except Exception as _e:
logger.warning("Stage wordcloud error: %s", _e)
_metrics['stages_err'].append('wordcloud')
# ===== STAGE: Trend Chart =====
try:
pub_per_year = df['yearPublished'].value_counts().sort_index()
trend_df = pub_per_year.reset_index(); trend_df.columns = ['Tahun', 'Jumlah Publikasi']
analysis_results['trend_chart_html'] = px.line(trend_df, x='Tahun', y='Jumlah Publikasi', title='Tren Publikasi per Tahun', markers=True).to_html(full_html=False, include_plotlyjs='cdn')
analysis_results['trend_explanation'] = get_ai_explanation("Tren Publikasi per Tahun", query, f"Puncak: {pub_per_year.idxmax()} ({pub_per_year.max()} publikasi).", ai_model, use_ai)
_metrics['stages_ok'].append('trend_chart')
except Exception as _e:
logger.warning("Stage trend_chart error: %s", _e)
_metrics['stages_err'].append('trend_chart')
# ===== STAGE: Citation Chart =====
try:
if 'cited_by_count' in df.columns and df['cited_by_count'].sum() > 0:
cdf = df.nlargest(10, 'cited_by_count').copy()
cdf['title_short'] = cdf['title'].str.slice(0, 50) + '...'
analysis_results['citation_chart_html'] = px.bar(cdf.sort_values('cited_by_count', ascending=True), x='cited_by_count', y='title_short', orientation='h', title='Top 10 Artikel Paling Berpengaruh (Sitasi)', labels={'cited_by_count': 'Jumlah Sitasi', 'title_short': 'Judul Artikel'}).to_html(full_html=False, include_plotlyjs='cdn')
analysis_results['citation_explanation'] = get_ai_explanation("Artikel Paling Berpengaruh", query, f"Disitasi terbanyak: '{cdf.iloc[0]['title']}' ({cdf.iloc[0]['cited_by_count']} sitasi).", ai_model, use_ai)
else:
analysis_results.update({'citation_chart_html': None, 'citation_explanation': ''})
_metrics['stages_ok'].append('citation_chart')
except Exception as _e:
logger.warning("Stage citation_chart error: %s", _e)
analysis_results.update({'citation_chart_html': None, 'citation_explanation': ''})
_metrics['stages_err'].append('citation_chart')
# ===== STAGE: TF-IDF Chart =====
tfidf_chart_html = None; tfidf_explanation = ""
try:
tfidf = TfidfVectorizer(stop_words=list(ALL_STOPWORDS), max_features=15, ngram_range=(1,2))
tfidf_matrix = tfidf.fit_transform(df['full_text'].dropna())
feature_names = tfidf.get_feature_names_out()
mean_scores = tfidf_matrix.mean(axis=0).tolist()[0]
tfidf_df = pd.DataFrame({'Keyword': feature_names, 'Skor': mean_scores}).sort_values('Skor', ascending=True)
fig_tfidf = px.bar(tfidf_df, x='Skor', y='Keyword', orientation='h', title='Top 15 Keyword Dominan (TF-IDF)')
tfidf_chart_html = fig_tfidf.to_html(full_html=False, include_plotlyjs='cdn')
tfidf_explanation = get_ai_explanation("Keyword Dominan (TF-IDF)", query, f"Keyword skor tertinggi: {', '.join(tfidf_df.nlargest(5, 'Skor')['Keyword'].tolist())}.", ai_model, use_ai)
_metrics['stages_ok'].append('tfidf')
except ValueError:
tfidf_explanation = "Tidak cukup data untuk grafik TF-IDF."
_metrics['stages_err'].append('tfidf')
except Exception as _e:
logger.warning("Stage tfidf error: %s", _e)
_metrics['stages_err'].append('tfidf')
analysis_results['tfidf_chart_html'] = tfidf_chart_html
analysis_results['tfidf_explanation'] = tfidf_explanation
# ===== STAGE: Keywords per Year =====
latest_keywords = []
try:
keywords_per_year = {}
for year, group in df.groupby('yearPublished'):
# Gunakan kolom '_tokens' yang sudah di-cache (Fix #3)
if year_words := [word for tokens in group['_tokens'] for word in tokens]:
keywords_per_year[year] = Counter(year_words).most_common(5)
keywords_per_year_explanation = ""
if keywords_per_year:
sorted_years = sorted(keywords_per_year.keys())
latest_keywords = [kw[0] for kw in keywords_per_year[sorted_years[-1]]]
summary = f"Awal ({sorted_years[0]}): {', '.join([kw[0] for kw in keywords_per_year[sorted_years[0]]][:3])}. Akhir ({sorted_years[-1]}): {', '.join(latest_keywords[:3])}."
keywords_per_year_explanation = get_ai_explanation("Evolusi Keyword per Tahun", query, summary, ai_model, use_ai)
analysis_results['keywords_per_year'] = sorted(keywords_per_year.items())
analysis_results['keywords_per_year_explanation'] = keywords_per_year_explanation
_metrics['stages_ok'].append('keywords_per_year')
except Exception as _e:
logger.warning("Stage keywords_per_year error: %s", _e)
analysis_results.setdefault('keywords_per_year', [])
analysis_results.setdefault('keywords_per_year_explanation', '')
_metrics['stages_err'].append('keywords_per_year')
# ===== STAGE: Source & Treemap Charts =====
try:
src_agg = df.groupby('source_display').agg(Jumlah=('title','count'), URL=('source_url','first')).reset_index().rename(columns={'source_display':'Sumber'})
src_df = src_agg.nlargest(15, 'Jumlah')
fig_src = px.bar(src_df.sort_values('Jumlah', ascending=True), x='Jumlah', y='Sumber', orientation='h', title='Top 15 Sumber Publikasi', custom_data=['URL'])
fig_src.update_traces(hovertemplate='%{y}
Jumlah: %{x}')
fig_tree = px.treemap(src_df, path=['Sumber'], values='Jumlah', title='Distribusi Publikasi (Treemap)', color='Jumlah', color_continuous_scale='Blues')
fig_tree.update_traces(textinfo="label+value", hoverinfo="label+value")
analysis_results.update({
'source_chart_html': fig_src.to_html(full_html=False, include_plotlyjs='cdn'),
'treemap_chart_html': fig_tree.to_html(full_html=False, include_plotlyjs='cdn'),
'source_explanation': get_ai_explanation("Top 15 Sumber Publikasi", query, f"Sumber teratas: {src_df['Sumber'].iloc[0]} ({src_df['Jumlah'].iloc[0]} artikel).", ai_model, use_ai),
'source_chart_explanation': get_ai_explanation(
"Sumber Publikasi", query,
f"Top sumber: {src_df['Sumber'].iloc[0]} ({src_df['Jumlah'].iloc[0]} artikel). Total {len(src_df)} sumber.",
ai_model, use_ai),
})
_metrics['stages_ok'].append('source_charts')
except Exception as _e:
logger.warning("Stage source_charts error: %s", _e)
analysis_results['source_chart_explanation'] = ''
_metrics['stages_err'].append('source_charts')
# ===== STAGE: Author Productivity Chart =====
try:
expl_auth = df.explode('authors').dropna(subset=['authors'])
if not expl_auth.empty:
ac = expl_auth[expl_auth['authors'] != ''].value_counts('authors').nlargest(15).reset_index()
ac.columns = ['Penulis', 'Jumlah']
if not ac.empty:
analysis_results['top_authors_chart_html'] = px.bar(ac.sort_values('Jumlah', ascending=True), x='Jumlah', y='Penulis', orientation='h', title='Top 15 Penulis Produktif').to_html(full_html=False, include_plotlyjs='cdn')
analysis_results['author_explanation'] = get_ai_explanation(
"Top 15 Penulis Produktif", query,
f"Penulis teratas: {ac['Penulis'].iloc[0]} ({ac['Jumlah'].iloc[0]} artikel).",
ai_model, use_ai)
else:
analysis_results['top_authors_chart_html'] = None
analysis_results['author_explanation'] = ''
else:
analysis_results['top_authors_chart_html'] = None
analysis_results['author_explanation'] = ''
_metrics['stages_ok'].append('author_charts')
except Exception as _e:
logger.warning("Stage author_charts error: %s", _e)
analysis_results['top_authors_chart_html'] = None
analysis_results['author_explanation'] = ''
_metrics['stages_err'].append('author_charts')
network_graph_filename = None; network_explanation = ""; top_relations = []
# Cleanup stale network HTML files before generating new ones
_cleanup_old_network_files(app.static_folder, 'co_word_network_')
_cleanup_old_network_files(app.static_folder, 'author_collaboration_network_')
top_keywords_list = [kw[0] for kw in top_20_keywords]
# Gunakan kolom '_tokens' yang sudah di-cache alih-alih clean_text() ulang (Fix #3)
co_occurrences = [comb for tokens in df['_tokens'].dropna()
if (comb := list(itertools.combinations(
[word for word in tokens if word in top_keywords_list], 2)))]
if co_occurrences_flat := [item for sublist in co_occurrences for item in sublist]:
co_occurrence_counts = Counter(co_occurrences_flat)
G = nx.Graph()
for (word1, word2), count in co_occurrence_counts.most_common(40): G.add_edge(word1, word2, weight=count, title=f"Count: {count}")
if G.number_of_edges() > 0:
partition = community_louvain.best_partition(G)
nx.set_node_attributes(G, {node: 10 + min(deg * 2, 20) for node, deg in dict(G.degree()).items()}, 'size'); nx.set_node_attributes(G, partition, 'group')
net = Network(height="500px", width="100%", notebook=False, cdn_resources='remote', heading='')
net.from_nx(G)
options = """var options = {"nodes": {"font": {"size": 16, "face": "Tahoma"}}, "edges": {"width": 0.5, "smooth": {"type": "continuous"}, "hoverWidth": 2}, "physics": {"forceAtlas2Based": {"gravitationalConstant": -100, "centralGravity": 0.01, "springLength": 230, "springConstant": 0.18}, "minVelocity": 0.75, "solver": "forceAtlas2Based"}}"""
net.set_options(options)
network_graph_filename = f"co_word_network_{uuid.uuid4().hex}.html"
net.save_graph(os.path.join(app.static_folder, network_graph_filename))
top_relations = [f"{words[0]}-{words[1]}" for words, _ in co_occurrence_counts.most_common(3)]
network_explanation = get_ai_explanation("Jaringan Konsep", query, f"Hubungan terkuat: {', '.join(top_relations)}.", ai_model, use_ai)
analysis_results.update({'network_graph_exists': network_graph_filename is not None, 'network_graph_filename': network_graph_filename, 'network_explanation': network_explanation})
author_network_filename = None; author_network_explanation = ""
if author_pairs:
author_pair_counts = Counter(author_pairs)
G_author = nx.Graph()
for (author1, author2), count in author_pair_counts.most_common(40): G_author.add_edge(str(author1), str(author2), weight=count, title=f"Collaborations: {count}")
if G_author.number_of_edges() > 0:
net_author = Network(height="500px", width="100%", notebook=False, cdn_resources='remote', heading='')
net_author.from_nx(G_author)
net_author.force_atlas_2based()
net_author.show_buttons(filter_=['physics'])
author_network_filename = f"author_collaboration_network_{uuid.uuid4().hex}.html"
net_author.save_graph(os.path.join(app.static_folder, author_network_filename))
with open(os.path.join(app.static_folder, author_network_filename), 'a', encoding='utf-8') as f: f.write('')
author_network_explanation = get_ai_explanation("Jaringan Kolaborasi Penulis", query, f"Kolaborasi teratas: {', '.join([f'{a[0]}-{a[1]}' for a, _ in author_pair_counts.most_common(3)])}.", ai_model, use_ai)
analysis_results.update({'author_network_exists': author_network_filename is not None, 'author_network_filename': author_network_filename, 'author_network_explanation': author_network_explanation})
geo_map_html = None; country_ranking_chart_html = None; geo_map_explanation = ""; country_ranking_explanation = ""
if 'country_name' in df.columns and not df['country_name'].dropna().empty:
country_agg = df['country_name'].value_counts().reset_index(); country_agg.columns = ['Negara', 'Jumlah']
geo_map_explanation = get_ai_explanation("Distribusi Geografis", query, f"Negara teratas: {country_agg['Negara'].iloc[0]} ({country_agg['Jumlah'].iloc[0]} publikasi).", ai_model, use_ai)
country_ranking_explanation = get_ai_explanation(
"Peringkat Negara", query,
f"Top negara: {country_agg['Negara'].iloc[0]} ({country_agg['Jumlah'].iloc[0]}). Total negara: {len(country_agg)}.",
ai_model, use_ai)
max_val = country_agg['Jumlah'].max()
fig_geo = px.choropleth(country_agg, locations="Negara", locationmode="country names", color="Jumlah", hover_name="Negara", color_continuous_scale=px.colors.sequential.YlOrRd, title="Distribusi Geografis Publikasi", range_color=[0, max_val if max_val > 1 else 2])
geo_map_html = fig_geo.to_html(full_html=False, include_plotlyjs='cdn')
fig_country_ranking = px.bar(country_agg.nlargest(15, 'Jumlah').sort_values('Jumlah', ascending=True), x='Jumlah', y='Negara', orientation='h', title='Top 15 Negara Kontributor')
country_ranking_chart_html = fig_country_ranking.to_html(full_html=False, include_plotlyjs='cdn')
analysis_results.update({'geo_map_html': geo_map_html, 'country_ranking_chart_html': country_ranking_chart_html,
'geo_map_explanation': geo_map_explanation, 'country_ranking_explanation': country_ranking_explanation})
lda_viz_html = None; lda_explanation = ""
abstracts = df['abstract'].dropna().tolist()
if len(abstracts) >= 10:
try:
vectorizer = CountVectorizer(stop_words=list(ALL_STOPWORDS), max_df=0.9, min_df=2, max_features=1000)
matrix = vectorizer.fit_transform(abstracts)
if matrix.shape[0] >= 5:
lda = LatentDirichletAllocation(n_components=5, random_state=42).fit(matrix)
topic_keywords = [f"Topik {i+1}: {', '.join([vectorizer.get_feature_names_out()[j] for j in topic.argsort()[:-5-1:-1]])}" for i, topic in enumerate(lda.components_)]
lda_explanation = get_ai_explanation("Topik Klaster (LDA)", query, f"Ditemukan klaster: {'; '.join(topic_keywords)}.", ai_model, use_ai)
viz_data = pyLDAvis.prepare(topic_term_dists=lda.components_ / lda.components_.sum(axis=1)[:, None], doc_topic_dists=lda.transform(matrix), doc_lengths=matrix.sum(axis=1).getA1(), vocab=vectorizer.get_feature_names_out(), term_frequency=matrix.sum(axis=0).getA1())
lda_viz_html = pyLDAvis.prepared_data_to_html(viz_data)
except Exception as e:
print(f"Gagal visualisasi LDA: {e}"); traceback.print_exc()
analysis_results.update({'lda_viz_exists': lda_viz_html is not None, 'lda_viz_html': lda_viz_html, 'lda_explanation': lda_explanation})
# ===== STAGE: AI Research Suggestions + PARALLEL CHART EXPLANATIONS (Fix #1) =====
# Kumpulkan semua task AI explanation ke satu dict,
# lalu jalankan paralel sebelum assign ke analysis_results.
# -- 1a. Research Suggestions (tetap sequential karena butuh konteks top_relations)
try:
analysis_results['research_suggestions'] = get_ai_research_suggestions(
query, [kw[0] for kw in top_20_keywords[:10]], top_relations, latest_keywords, ai_model, use_ai
)
_metrics['stages_ok'].append('research_suggestions')
except Exception as _e:
logger.warning("Stage research_suggestions error: %s", _e)
analysis_results['research_suggestions'] = ''
_metrics['stages_err'].append('research_suggestions')
# -- 1b. Semua chart explanation dijalan secara PARALEL --
_src_df_top = None
try:
src_agg_tmp = df.groupby('source_display').agg(
Jumlah=('title', 'count')
).reset_index().rename(columns={'source_display': 'Sumber'})
_src_df_top = src_agg_tmp.nlargest(15, 'Jumlah')
except Exception:
pass
_api_counts_tmp = None
try:
_api_counts_tmp = df['api_origin'].value_counts().reset_index()
_api_counts_tmp.columns = ['API', 'Jumlah']
except Exception:
pass
_cdf_top = None
try:
if 'cited_by_count' in df.columns and df['cited_by_count'].sum() > 0:
_cdf_top = df.nlargest(10, 'cited_by_count').copy()
except Exception:
pass
_country_agg_tmp = None
try:
if 'country_name' in df.columns and not df['country_name'].dropna().empty:
_country_agg_tmp = df['country_name'].value_counts().reset_index()
_country_agg_tmp.columns = ['Negara', 'Jumlah']
except Exception:
pass
_topic_keywords_tmp = topic_keywords if 'topic_keywords' in dir() else []
# Build parallel task map
_ai_tasks = {}
# Quality
if ANALYTICS_AVAILABLE and 'avg_quality' in analysis_results:
_ai_tasks['quality_explanation'] = (
"Publication Quality Analysis", query,
f"Avg quality: {analysis_results.get('avg_quality')}, Max: {analysis_results.get('max_quality')}, N={len(df)}"
)
_th_hi_tmp = analysis_results.get('top_authors_h_index', [])
_ai_tasks['h_index_explanation'] = (
"Top Penulis H-Index", query,
f"Top author H-index data: {_th_hi_tmp[:3] if _th_hi_tmp else 'N/A'}"
)
# API Pie
if _api_counts_tmp is not None:
_ai_tasks['api_pie_explanation'] = (
"Kontribusi API", query,
f"Sumber: {_api_counts_tmp.to_dict('records')[:3]}"
)
# Word Cloud
_ai_tasks['wordcloud_explanation'] = (
"Word Cloud", query,
f"Kata kunci teratas: {', '.join([kw[0] for kw in top_20_keywords[:5]])}"
)
# Trend
try:
_pub_max_year = df['yearPublished'].value_counts().idxmax()
_pub_max_cnt = df['yearPublished'].value_counts().max()
_ai_tasks['trend_explanation'] = (
"Tren Publikasi per Tahun", query,
f"Puncak: {_pub_max_year} ({_pub_max_cnt} publikasi)."
)
except Exception:
pass
# Citation
if _cdf_top is not None and not _cdf_top.empty:
_ai_tasks['citation_explanation'] = (
"Artikel Paling Berpengaruh", query,
f"Disitasi terbanyak: '{_cdf_top.iloc[-1]['title']}' ({_cdf_top.iloc[-1]['cited_by_count']} sitasi)."
)
# TF-IDF
if analysis_results.get('tfidf_chart_html'):
try:
_tfidf_tmp = TfidfVectorizer(stop_words=list(ALL_STOPWORDS), max_features=15, ngram_range=(1, 2))
_tfidf_mat = _tfidf_tmp.fit_transform(df['full_text'].dropna())
_tfidf_scores = pd.DataFrame({
'Keyword': _tfidf_tmp.get_feature_names_out(),
'Skor': _tfidf_mat.mean(axis=0).tolist()[0]
})
_ai_tasks['tfidf_explanation'] = (
"Keyword Dominan (TF-IDF)", query,
f"Keyword skor tertinggi: {', '.join(_tfidf_scores.nlargest(5, 'Skor')['Keyword'].tolist())}."
)
except Exception:
pass
# Keywords per year — sudah di-compute, ambil summary-nya
if keywords_per_year_explanation := analysis_results.get('keywords_per_year_explanation', ''):
pass # sudah diset saat stage keywords_per_year
# Source charts
if _src_df_top is not None and not _src_df_top.empty:
_ai_tasks['source_explanation'] = (
"Top 15 Sumber Publikasi", query,
f"Sumber teratas: {_src_df_top['Sumber'].iloc[0]} ({_src_df_top['Jumlah'].iloc[0]} artikel)."
)
_ai_tasks['source_chart_explanation'] = (
"Sumber Publikasi", query,
f"Top sumber: {_src_df_top['Sumber'].iloc[0]} ({_src_df_top['Jumlah'].iloc[0]} artikel). Total {len(_src_df_top)} sumber."
)
# Author
if analysis_results.get('top_authors_chart_html'):
try:
_expl_auth_tmp = df.explode('authors').dropna(subset=['authors'])
_ac_tmp = _expl_auth_tmp[_expl_auth_tmp['authors'] != ''].value_counts('authors').nlargest(15).reset_index()
_ac_tmp.columns = ['Penulis', 'Jumlah']
if not _ac_tmp.empty:
_ai_tasks['author_explanation'] = (
"Top 15 Penulis Produktif", query,
f"Penulis teratas: {_ac_tmp['Penulis'].iloc[0]} ({_ac_tmp['Jumlah'].iloc[0]} artikel)."
)
except Exception:
pass
# Network
if network_explanation and top_relations:
_ai_tasks['network_explanation'] = (
"Jaringan Konsep", query,
f"Hubungan terkuat: {', '.join(top_relations)}."
)
# Author network
if author_network_explanation and author_pairs:
try:
_ap_cnt = Counter(author_pairs)
_ai_tasks['author_network_explanation'] = (
"Jaringan Kolaborasi Penulis", query,
f"Kolaborasi teratas: {', '.join([f'{a[0]}-{a[1]}' for a, _ in _ap_cnt.most_common(3)])}"
)
except Exception:
pass
# Geo
if _country_agg_tmp is not None and not _country_agg_tmp.empty:
_ai_tasks['geo_map_explanation'] = (
"Distribusi Geografis", query,
f"Negara teratas: {_country_agg_tmp['Negara'].iloc[0]} ({_country_agg_tmp['Jumlah'].iloc[0]} publikasi)."
)
_ai_tasks['country_ranking_explanation'] = (
"Peringkat Negara", query,
f"Top negara: {_country_agg_tmp['Negara'].iloc[0]} ({_country_agg_tmp['Jumlah'].iloc[0]}). Total negara: {len(_country_agg_tmp)}."
)
# LDA
if _topic_keywords_tmp:
_ai_tasks['lda_explanation'] = (
"Topik Klaster (LDA)", query,
f"Ditemukan klaster: {'; '.join(_topic_keywords_tmp)}."
)
# ── Jalankan semua AI tasks PARALEL (Fix #1) ──
logger.info("Running %d AI explanation tasks in parallel...", len(_ai_tasks))
_ai_results = _run_ai_explanations_parallel(_ai_tasks, ai_model, use_ai)
# Merge hasil parallel ke analysis_results (hanya overwrite jika ada nilai)
for _k, _v in _ai_results.items():
if _v: # jangan overwrite dengan string kosong
analysis_results[_k] = _v
_metrics['stages_ok'].append('parallel_ai_explanations')
# ================================================================
# INSIGHT ENGINE + RECOMMENDATION ENGINE + LLM NARRATIVE
# ================================================================
try:
lda_topic_list = topic_keywords if 'topic_keywords' in dir() else []
insights = generate_insight_summary(
df=df, top_keywords=top_20_keywords, lda_topics=lda_topic_list,
use_ai=False, ai_model=ai_model, query=query
)
analysis_results['insights'] = insights
analysis_results['insights_count'] = len(insights)
recommendations = generate_research_recommendations(
df=df, top_keywords=top_20_keywords, lda_topics=lda_topic_list,
use_ai=False, ai_model=ai_model, query=query
)
analysis_results['recommendations'] = recommendations
analysis_results['recommendations_count'] = len(recommendations)
analysis_results['high_priority_gaps'] = sum(
1 for r in recommendations if r.get('priority') == 'HIGH'
)
# Build context for LLM narrative
country_series = df['country_name'] if 'country_name' in df.columns else pd.Series()
top_country = country_series.dropna().value_counts().index[0] \
if len(country_series.dropna()) > 0 else 'N/A'
oa_pct = round(df['is_oa'].fillna(False).mean() * 100) \
if 'is_oa' in df.columns else 0
context_data = {
'query': query,
'total_articles': len(df),
'top_keywords': [kw[0] if isinstance(kw, (list, tuple)) else kw
for kw in top_20_keywords[:8]],
'lda_topics': lda_topic_list[:5],
'recommendations': [{'title': r.get('title', ''), 'gap_type': r.get('gap_type_label', r.get('gap_type', 'Gap')),
'rationale': r.get('rationale', '')[:100]}
for r in recommendations[:3]],
'top_country': top_country,
'oa_pct': oa_pct,
}
llm_result = generate_llm_insight(context_data, ai_model=ai_model, use_ai=use_ai)
analysis_results['insights_text'] = llm_result.get('insights_text', '')
analysis_results['recommendations_text'] = llm_result.get('recommendations_text', '')
analysis_results['narrative_text'] = llm_result.get('narrative_text', '')
analysis_results['llm_source'] = llm_result.get('source', 'rule-based')
except Exception as _ie:
print(f"⚠️ Insight Engine error: {_ie}")
analysis_results.setdefault('insights', [])
analysis_results.setdefault('recommendations', [])
analysis_results.setdefault('insights_text', '')
analysis_results.setdefault('recommendations_text', '')
analysis_results.setdefault('narrative_text', '')
_metrics['stages_err'].append('insight_engine')
else:
_metrics['stages_ok'].append('insight_engine')
# ================================================================
# ── Voice Narration (TTS) ────────────────────────────────────────
try:
_tts_chunks = _build_tts_text(analysis_results)
_sid = str(search_id or 'tmp')
analysis_results['trend_audio'] = generate_voice_from_text(
_tts_chunks['trend_text'], f"trend_{_sid}", use_openai=use_ai)
analysis_results['tfidf_audio'] = generate_voice_from_text(
_tts_chunks['tfidf_text'], f"tfidf_{_sid}", use_openai=use_ai)
analysis_results['narrative_audio'] = generate_voice_from_text(
_tts_chunks['narrative_text'], f"narrative_{_sid}", use_openai=use_ai)
except Exception as _tts_err:
logger.warning("Stage tts error: %s", _tts_err)
analysis_results.setdefault('trend_audio', None)
analysis_results.setdefault('tfidf_audio', None)
analysis_results.setdefault('narrative_audio', None)
_metrics['stages_err'].append('tts')
else:
_metrics['stages_ok'].append('tts')
# ── Academic Writing Layer ────────────────────────────────────────────
try:
analysis_results['global_summary'] = generate_global_summary(
analysis_results, use_ai=use_ai, ai_model=ai_model)
analysis_results['validation_summary'] = generate_validation_summary(df, analysis_results)
analysis_results['system_positioning'] = generate_system_positioning()
analysis_results['abstract'] = generate_abstract(
analysis_results, query, use_ai=use_ai, ai_model=ai_model)
analysis_results['conclusion'] = generate_conclusion(
analysis_results, use_ai=use_ai, ai_model=ai_model)
analysis_results['future_work'] = generate_future_work(
analysis_results, use_ai=use_ai, ai_model=ai_model)
logger.info("Academic Writing Layer done.")
except Exception as _aw_err:
logger.warning("Stage academic_writing error: %s", _aw_err)
for _k in ('global_summary','validation_summary','system_positioning',
'abstract','conclusion','future_work'):
analysis_results.setdefault(_k, '')
_metrics['stages_err'].append('academic_writing')
else:
_metrics['stages_ok'].append('academic_writing')
# ── Research Ideation Layer ───────────────────────────────────────────
try:
analysis_results['research_questions'] = generate_research_questions(
analysis_results, df, use_ai=use_ai, ai_model=ai_model)
analysis_results['related_work'] = generate_related_work(
df, analysis_results, use_ai=use_ai, ai_model=ai_model)
logger.info("Research Ideation Layer done.")
except Exception as _ri_err:
logger.warning("Stage research_ideation error: %s", _ri_err)
analysis_results.setdefault('research_questions', [])
analysis_results.setdefault('related_work', '')
_metrics['stages_err'].append('research_ideation')
else:
_metrics['stages_ok'].append('research_ideation')
# ── Advanced Charts (citation network, embedding map, tool comparison)
if ADVANCED_CHARTS_AVAILABLE:
try:
from advanced_charts import add_advanced_charts
analysis_results = add_advanced_charts(df, analysis_results)
_metrics['stages_ok'].append('advanced_charts')
except Exception as _ac_err:
logger.warning("Advanced charts error: %s", _ac_err)
_metrics['stages_err'].append('advanced_charts')
# ── Geo Analytics (country extraction + maps) ──────────────────────
try:
from geo_analytics import add_geo_analytics
df, analysis_results = add_geo_analytics(df, analysis_results)
_metrics['stages_ok'].append('geo_analytics')
except Exception as _geo_err:
logger.warning("Geo analytics error: %s", _geo_err)
_metrics['stages_err'].append('geo_analytics')
# ── Limitations Generator ─────────────────────────────────────────
try:
n_arts = len(df)
cit_avail = df['cited_by_count'].notna().sum() if 'cited_by_count' in df.columns else 0
abst_avail = df['abstract'].notna().sum() if 'abstract' in df.columns else 0
abst_pct = round(abst_avail / max(n_arts, 1) * 100)
cit_pct = round(cit_avail / max(n_arts, 1) * 100)
limitations = (
f"This study is subject to several limitations. First, the analysis relies exclusively "
f"on title and abstract text ({abst_pct}% of articles have abstracts), "
f"which may limit the depth of thematic characterisation compared to full-text analysis. "
f"Second, the citation network is constructed via a similarity-based proxy rather than "
f"direct reference parsing, as citation linkage data is available for only {cit_pct}% of articles. "
f"Third, coverage is contingent on API availability at the time of retrieval (OpenAlex, Crossref, CORE, Semantic Scholar); "
f"papers from subscription-only databases (Scopus, Web of Science) are under-represented. "
f"Fourth, the Research Gap Score thresholds are empirically derived and may require domain-specific recalibration. "
f"Despite these constraints, the breadth of the multi-source corpus ({n_arts} articles) "
f"provides a robust foundation for the reported findings."
)
analysis_results['limitations'] = limitations
_metrics['stages_ok'].append('limitations')
except Exception as _lim_err:
logger.warning("Limitations generator error: %s", _lim_err)
analysis_results.setdefault('limitations', '')
# ── Evaluation Module ───────────────────────────────────────────────
try:
_yr_col = 'yearPublished' if 'yearPublished' in df.columns else None
_cit_col = 'cited_by_count' if 'cited_by_count' in df.columns else None
# Trend consistency: compare 2 most recent years
trend_valid = 'N/A'
if _yr_col:
_yr_counts = df[_yr_col].value_counts().sort_index()
if len(_yr_counts) >= 2:
_last2 = _yr_counts.iloc[-2:]
trend_dir = 'increasing' if _last2.iloc[-1] >= _last2.iloc[-2] else 'decreasing'
trend_valid = (f"Publications from {_last2.index[-2]} to {_last2.index[-1]} "
f"show {trend_dir} trend "
f"({int(_last2.iloc[-2])} → {int(_last2.iloc[-1])} articles)")
# Top paper alignment
top_align = 'N/A'
if _cit_col and df[_cit_col].notna().any():
_top = df.nlargest(1, _cit_col)
_top_title = str(_top.iloc[0].get('title', 'N/A'))[:60]
_top_cit = int(_top.iloc[0][_cit_col])
_top_year = _top.iloc[0].get('yearPublished', 'N/A')
top_align = (f"Highest-cited paper: '{_top_title}' "
f"({_top_year}, {_top_cit:,} citations) — "
f"consistent with identified dominant themes")
# Insight consistency
top_kw_list = [k[0] if isinstance(k, (list, tuple)) else k
for k in analysis_results.get('top_keywords', [])[:3]]
consist_check = (
f"Top keywords ({', '.join(top_kw_list)}) align with LDA topics and "
f"citation distribution, confirming internal analytical consistency."
if top_kw_list else 'Consistency check skipped (no keywords extracted).'
)
analysis_results['evaluation'] = {
'consistency_check': consist_check,
'trend_validation': trend_valid,
'top_paper_alignment':top_align,
}
_metrics['stages_ok'].append('evaluation')
except Exception as _ev_err:
logger.warning("Evaluation module error: %s", _ev_err)
analysis_results.setdefault('evaluation', {})
# ── Finalize Metrics ─────────────────────────────────────────────────
_elapsed = round(_time_mod.time() - _t_start, 2)
_metrics['execution_time_s'] = _elapsed
_metrics['article_count'] = len(df)
analysis_results['metrics'] = _metrics
logger.info("Pipeline complete: %ss | %d articles | stages_ok=%s",
_elapsed, len(df), _metrics['stages_ok'])
return analysis_results
# =====================================================================
# BLOK ROUTE FLASK (CONTROLLER)
# =====================================================================
# ── Export Report Route ───────────────────────────────────────────────────────
@app.route('/export_report/')
@login_required
def export_report_route(search_id):
"""
Download a formatted research report (DOCX / PDF / TXT).
Rebuilds analysis from stored articles — no analysis_data field needed.
"""
fmt = request.args.get('fmt', 'docx').lower()
try:
from export_report import export_report as _do_export
search = SearchCache.query.filter_by(
id=search_id, user_id=current_user.id
).first_or_404()
# Build analysis_results from stored articles
dict_records = [{
'id': art.original_id, 'title': art.title, 'abstract': art.abstract,
'authors': json.loads(art.authors) if art.authors else [],
'yearPublished': art.yearPublished, 'publisher': art.publisher,
'doi': art.doi, 'link': art.link,
'country_name': art.country_name, 'country_code': art.country_code,
'api_origin': art.api_origin, 'source_display': art.source_display,
'repositories': json.loads(art.repositories) if art.repositories else [],
'cited_by_count': art.cited_by_count,
} for art in search.articles]
df_exp = pd.DataFrame.from_records(dict_records)
ar = run_full_analysis(
df_exp, search.search_term,
use_ai=False, # fast — no AI for export
search_id=search_id,
ai_model='openai'
)
ar.setdefault('query', search.search_term)
file_bytes, mime, filename = _do_export(ar, fmt=fmt)
response = make_response(file_bytes)
response.headers['Content-Type'] = mime
response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
response.headers['Content-Length'] = len(file_bytes)
logger.info("Export served: search=%s fmt=%s bytes=%d", search_id, fmt, len(file_bytes))
return response
except Exception as exc:
logger.error("Export route error: %s", exc, exc_info=True)
flash(f'Export gagal: {exc}', 'danger')
return redirect(request.referrer or url_for('profile'))
@app.route('/api/metrics/')
@login_required
def api_metrics(search_id):
"""Return pipeline performance metrics as JSON."""
try:
search = SearchCache.query.filter_by(
id=search_id, user_id=current_user.id
).first_or_404()
return json.jsonify({
'search_id': search_id,
'query': search.search_term,
'article_count': len(search.articles),
})
except Exception as exc:
return json.jsonify({'error': str(exc)}), 500
@app.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated: return redirect(url_for('index'))
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
if not username or not password:
flash('Username dan password harus diisi.', 'danger')
return render_template('login.html')
try:
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
login_user(user, remember=request.form.get('remember'))
next_page = request.args.get('next')
flash('Login berhasil!', 'success')
return redirect(next_page or url_for('profile'))
else:
flash('Login gagal. Periksa kembali username dan password Anda.', 'danger')
except Exception as e:
print(f"❌ Login error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
db.session.rollback()
flash('Terjadi kesalahan saat login. Silakan coba lagi.', 'danger')
return render_template('login.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated: return redirect(url_for('index'))
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
password_confirm = request.form.get('password_confirm', '')
if not username or not password:
flash('Username dan password harus diisi.', 'danger')
return render_template('register.html')
if password != password_confirm:
flash('Password tidak cocok.', 'danger')
return render_template('register.html')
if len(password) < 6:
flash('Password minimal 6 karakter.', 'danger')
return render_template('register.html')
try:
existing_user = User.query.filter_by(username=username).first()
if existing_user:
flash('Username sudah digunakan. Silakan pilih yang lain.', 'warning')
return render_template('register.html')
new_user = User(username=username)
new_user.set_password(password)
user_count = User.query.count()
if user_count == 0:
new_user.role = 'admin'
db.session.add(new_user)
db.session.commit()
flash('Akun Anda berhasil dibuat! Silakan login.', 'success')
return redirect(url_for('login'))
except Exception as e:
print(f"❌ Registration error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
db.session.rollback()
flash('Terjadi kesalahan saat pendaftaran. Silakan coba lagi.', 'danger')
return render_template('register.html')
return render_template('register.html')
@app.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('login'))
@app.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
if request.method == 'POST':
try:
current_user.core_api_key = request.form.get('core_api_key')
current_user.springer_api_key = request.form.get('springer_api_key')
current_user.serpapi_api_key = request.form.get('serpapi_api_key')
current_user.openai_api_key = request.form.get('openai_api_key')
current_user.gemini_api_key = request.form.get('gemini_api_key')
current_user.deepseek_api_key = request.form.get('deepseek_api_key')
current_user.zai_api_key = request.form.get('zai_api_key')
current_user.scopus_api_key = request.form.get('scopus_api_key')
current_user.semantic_scholar_api_key = request.form.get('semantic_scholar_api_key')
db.session.commit()
flash('API Keys berhasil diperbarui!', 'success')
except Exception as e:
print(f"❌ Profile update error: {type(e).__name__}: {e}")
db.session.rollback()
flash('Terjadi kesalahan saat memperbarui API Keys.', 'danger')
return redirect(url_for('profile'))
try:
user_searches = SearchCache.query.filter_by(owner=current_user).order_by(SearchCache.created_at.desc()).all()
except Exception as e:
print(f"❌ Error loading searches: {type(e).__name__}: {e}")
user_searches = []
return render_template('profile.html', user=current_user, searches=user_searches)
@app.route('/')
def index():
countries = sorted([(country.alpha_2, country.name) for country in pycountry.countries], key=lambda x: x[1])
return render_template('index.html', countries=countries)
@app.route('/search', methods=['POST'])
@login_required
def search():
api_sources = request.form.getlist('api_sources')
query_form = request.form.get('query')
start_year_str = request.form.get('start_year')
end_year_str = request.form.get('end_year')
limit_per_api = request.form.get('limit', 50, type=int)
use_ai = request.form.get('use_ai') == 'on'
ai_model = request.form.get('ai_model', 'openai')
country_filter = request.form.get('country_filter')
if country_filter == "": country_filter = None
if not api_sources:
flash("Anda harus memilih setidaknya satu sumber data.", "warning")
return redirect(url_for('index'))
try:
start_year = int(start_year_str) if start_year_str else 1900
end_year = int(end_year_str) if end_year_str else 2100
except (ValueError, TypeError):
flash("Tahun harus berupa angka.", "danger")
return redirect(url_for('index'))
search_term_lower = query_form.lower().strip()
api_sources_str = ",".join(sorted(api_sources))
cached_search = db.session.query(SearchCache).filter_by(
search_term=search_term_lower, start_year=start_year, end_year=end_year,
limit_per_api=limit_per_api, api_sources=api_sources_str, user_id=current_user.id,
country_filter=country_filter
).first()
if cached_search:
return redirect(url_for('view_search', search_id=cached_search.id, use_ai=use_ai, ai_model=ai_model))
print(f"CACHE MISS: Mencari '{query_form}' dari API untuk pengguna {current_user.username}.")
if not check_internet_connection():
return render_template('error.html', message="Koneksi internet tidak tersedia.")
connectors = {
'core': get_core_data,
'openalex': get_openalex_data,
'springer': get_springer_data,
'google_scholar': get_google_scholar_data,
'scopus': get_scopus_data,
'crossref': get_crossref_data,
'semantic_scholar': get_semantic_scholar_data,
'pubmed': get_pubmed_data,
'arxiv': get_arxiv_data,
'doaj': get_doaj_data,
'europepmc': get_europepmc_data,
'dblp': get_dblp_data,
# ── Tambahan baru (gratis, tanpa API key) ────────────────────────
'base': get_base_data,
'opencitations': get_opencitations_data,
'zenodo': get_zenodo_data,
'biorxiv': get_biorxiv_data,
'plos': get_plos_data,
'datacite': get_datacite_data,
'orcid': get_orcid_data,
'osf': get_osf_data,
}
all_dataframes = []
import concurrent.futures
def fetch_from_source(source):
try:
if source == 'openalex':
res = connectors[source](query_form, start_year, end_year, limit_per_api, country_filter)
else:
res = connectors[source](query_form, start_year, end_year, limit_per_api)
if res is not None and not res.empty:
res['api_origin'] = source
return res
except Exception as e:
print(f"Error fetching from {source}: {e}")
return None
# Menggunakan ThreadPoolExecutor untuk paralel (mempercepat proses)
with concurrent.futures.ThreadPoolExecutor(max_workers=min(10, len(api_sources) if api_sources else 1)) as executor:
future_to_source = {executor.submit(fetch_from_source, source): source for source in api_sources}
for future in concurrent.futures.as_completed(future_to_source):
df_source = future.result()
if df_source is not None:
all_dataframes.append(df_source)
if not all_dataframes: return render_template('error.html', message=f"Tidak ada hasil ditemukan untuk '{query_form}'.")
df_raw_combined = pd.concat(all_dataframes, ignore_index=True)
df_temp = df_raw_combined.copy()
df_temp['title_lower'] = df_temp.get('title', pd.Series(dtype=str)).str.lower().str.strip()
df_temp.drop_duplicates(subset=['title_lower'], keep='first', inplace=True)
df_temp.drop(columns=['title_lower'], inplace=True)
df_temp['yearPublished'] = pd.to_numeric(df_temp['yearPublished'], errors='coerce')
df_temp.dropna(subset=['yearPublished'], inplace=True)
df_temp['yearPublished'] = df_temp['yearPublished'].astype(int)
df = df_temp[(df_temp['yearPublished'] >= start_year) & (df_temp['yearPublished'] <= end_year)].reset_index(drop=True)
if not df.empty:
print(f"Menyimpan {len(df)} artikel ke database...")
df['source_display'] = df.apply(lambda row: get_source_name(row, row.get('api_origin', '')), axis=1)
if 'country_code' not in df.columns: df['country_code'] = None
df['country_name'] = df['country_code'].apply(convert_country_code_to_name)
for index, row in df[df['country_name'].isnull()].iterrows():
if (authors := row.get('authors')) and isinstance(authors, list) and len(authors) > 0:
if (first_author_name := authors[0]) and (guessed_code := guess_country_from_name(first_author_name)):
df.loc[index, 'country_code'] = guessed_code
df.loc[index, 'country_name'] = convert_country_code_to_name(guessed_code)
new_search_entry = SearchCache(
search_term=search_term_lower, start_year=start_year, end_year=end_year,
limit_per_api=limit_per_api, api_sources=api_sources_str,
user_id=current_user.id, country_filter=country_filter
)
for _, row in df.iterrows():
article_obj = ArticleCache(
title=row.get('title'), abstract=row.get('abstract'), authors=json.dumps(row.get('authors', [])),
yearPublished=int(row.get('yearPublished')) if pd.notna(row.get('yearPublished')) else None,
publisher=row.get('publisher'), doi=row.get('doi'), link=row.get('link'),
country_code=row.get('country_code'), country_name=row.get('country_name'),
api_origin=row.get('api_origin'), source_display=row.get('source_display'),
repositories=json.dumps(row.get('repositories', [])), original_id=row.get('id'),
cited_by_count=row.get('cited_by_count', 0)
)
new_search_entry.articles.append(article_obj)
db.session.add(new_search_entry)
db.session.commit()
print("Penyimpanan selesai.")
return redirect(url_for('view_search', search_id=new_search_entry.id, use_ai=use_ai, ai_model=ai_model))
return render_template('error.html', message="Tidak ada hasil yang valid setelah pembersihan.")
@app.route('/view_search/')
@login_required
def view_search(search_id):
search_cache = db.session.get(SearchCache, search_id)
if not search_cache or (search_cache.user_id != current_user.id and current_user.role != 'admin'):
flash("Riwayat pencarian tidak ditemukan atau Anda tidak memiliki izin.", "danger")
return redirect(url_for('profile'))
use_ai_for_view = request.args.get('use_ai', 'false').lower() == 'true'
ai_model_choice = request.args.get('ai_model', 'openai')
articles_from_db = search_cache.articles
dict_records = [{'id': art.original_id, 'title': art.title, 'abstract': art.abstract,
'authors': json.loads(art.authors) if art.authors else [],
'yearPublished': art.yearPublished, 'publisher': art.publisher, 'doi': art.doi,
'link': art.link, 'country_name': art.country_name, 'country_code': art.country_code,
'api_origin': art.api_origin, 'source_display': art.source_display,
'repositories': json.loads(art.repositories) if art.repositories else [],
'cited_by_count': art.cited_by_count} for art in articles_from_db]
df = pd.DataFrame.from_records(dict_records)
analysis_results = run_full_analysis(df, search_cache.search_term, use_ai_for_view, search_id, ai_model_choice)
if 'error' in analysis_results:
return render_template('error.html', message=analysis_results['error'])
return render_template('results.html', **analysis_results)
@app.route('/export_bib/')
@login_required
def export_bib(search_id):
search_cache = db.session.get(SearchCache, search_id)
if not search_cache or (search_cache.user_id != current_user.id and current_user.role != 'admin'):
return "Not Found", 404
db_entries = []
for article in search_cache.articles:
entry = {
'ENTRYTYPE': 'article', 'ID': f"art{article.id}",
'title': article.title or 'No Title',
'author': ' and '.join(json.loads(article.authors)) if article.authors else '',
'year': str(article.yearPublished) if article.yearPublished else '',
'journal': article.publisher or '', 'doi': article.doi or ''
}
db_entries.append(entry)
bib_database = BibDatabase()
bib_database.entries = db_entries
writer = BibTexWriter()
bibtex_string = writer.write(bib_database)
response = make_response(bibtex_string)
filename = f"export_{search_cache.search_term.replace(' ', '_')}.bib"
response.headers["Content-Disposition"] = f"attachment; filename={filename}"
response.headers["Content-Type"] = "application/x-bibtex"
return response
@app.route('/dashboard')
@login_required
@admin_required
def dashboard():
total_searches = db.session.query(SearchCache).count()
total_articles = db.session.query(func.count(ArticleCache.id)).scalar()
all_searches = SearchCache.query.order_by(SearchCache.created_at.desc()).limit(20).all()
return render_template('dashboard.html', total_searches=total_searches, total_articles=total_articles, all_searches=all_searches)
@app.route('/reset-db', methods=['POST'])
@login_required
@admin_required
def reset_db():
"""
Destructive: drops and recreates all tables.
Protected: requires authenticated admin + explicit POST (no accidental GET).
"""
try:
with app.app_context():
db.drop_all()
db.create_all()
flash('Database berhasil di-reset.', 'success')
return redirect(url_for('dashboard'))
except Exception as e:
logger.error("reset_db error: %s", e)
flash(f'Error reset database: {e}', 'danger')
return redirect(url_for('dashboard'))
@app.route('/export_csv/')
@login_required
def export_csv(search_id):
"""Download CSV of all articles for a search result."""
try:
search = SearchCache.query.filter_by(
id=search_id, user_id=current_user.id
).first_or_404()
rows = []
for art in search.articles:
authors_list = json.loads(art.authors) if art.authors else []
authors_str = '; '.join(
a.get('display_name', str(a)) if isinstance(a, dict) else str(a)
for a in authors_list
)
rows.append({
'Title': art.title or '',
'Authors': authors_str,
'Year': art.yearPublished or '',
'Publisher': art.publisher or '',
'DOI': art.doi or '',
'Link': art.link or '',
'Country': art.country_name or '',
'Source': art.source_display or art.api_origin or '',
'Citations': art.cited_by_count or 0,
'Abstract': (art.abstract or '')[:300],
})
import io as _io
import csv as _csv
buf = _io.StringIO()
if rows:
writer = _csv.DictWriter(buf, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
csv_bytes = buf.getvalue().encode('utf-8-sig') # BOM for Excel
slug = search.search_term[:30].replace(' ', '_')
filename = f"BIRAS_{slug}_{search_id}.csv"
response = make_response(csv_bytes)
response.headers['Content-Type'] = 'text/csv; charset=utf-8'
response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
logger.info("CSV export: search=%s rows=%d", search_id, len(rows))
return response
except Exception as exc:
logger.error("CSV export error: %s", exc)
flash(f'CSV export gagal: {exc}', 'danger')
return redirect(request.referrer or url_for('profile'))
# NOTE: The active /generate_paper/ route is defined further below
# (after the PAPER_GENERATOR_AVAILABLE import guard). Previous duplicate
# removed — Flask was silently using only the last definition anyway.
@app.cli.command("init-db")
def init_db_command():
db.create_all()
print("Database diinisialisasi.")
if DATABASE_URL and "pooler" in DATABASE_URL:
print("✅ Using Supabase pooler connection (recommended for production)")
elif DATABASE_URL and "supabase.co" in DATABASE_URL:
print("⚠️ WARNING: Direct connection detected! Use Supabase pooler instead:")
print(" Format: postgresql://postgres.[PROJECT]:[PASSWORD]@[PROJECT].pooler.supabase.co:6543/postgres")
# =====================================================================
# LLM INSIGHT WRAPPER (Prompt Engineering + Hybrid Fallback)
# =====================================================================
def generate_llm_insight(context_data: dict, ai_model: str = 'openai', use_ai: bool = True) -> dict:
"""
Generate structured LLM narrative for insights, recommendations, and results text.
Falls back to rule-based narrative if AI fails or is disabled.
Returns: {insights_text, recommendations_text, narrative_text, source}
"""
def _build_prompt(ctx: dict) -> str:
kws = ', '.join(ctx.get('top_keywords', [])[:6])
topics = '; '.join(ctx.get('lda_topics', [])[:3])
recs = '\n'.join([f" - [{r['gap_type']}] {r['title']}: {r['rationale'][:80]}"
for r in ctx.get('recommendations', [])])
return f"""You are a bibliometric research analyst. Based on the following systematic literature review data, generate a concise academic report in English.
QUERY: "{ctx.get('query', '')}"
TOTAL ARTICLES: {ctx.get('total_articles', 0)}
TOP KEYWORDS: {kws}
LDA TOPIC CLUSTERS: {topics}
TOP COUNTRY: {ctx.get('top_country', 'N/A')}
OPEN ACCESS: {ctx.get('oa_pct', 0)}%
DETECTED RESEARCH GAPS:
{recs}
Generate EXACTLY this JSON structure (no markdown, no extra text):
{{
"insights_text": "2-3 sentence academic summary of the corpus landscape and dominant themes.",
"recommendations_text": "2-3 sentence actionable research recommendation based on detected gaps.",
"narrative_text": "4-5 sentence Results paragraph suitable for a Q1 paper, referencing keywords and gap types."
}}"""
def _rule_based_narrative(ctx: dict) -> dict:
"""Guaranteed fallback — always succeeds."""
kws = ctx.get('top_keywords', [])[:3]
kw_str = ', '.join([f"'{k}'" for k in kws]) if kws else 'the topic'
n = ctx.get('total_articles', 0)
qry = ctx.get('query', 'the subject')
country = ctx.get('top_country', 'N/A')
oa = ctx.get('oa_pct', 0)
recs = ctx.get('recommendations', [])
gap_str = recs[0]['gap_type_label'] + ' in ' + recs[0]['keyword'] if recs else 'identified gaps'
return {
'source': 'rule-based',
'insights_text': (
f"The corpus of {n} deduplicated articles on '{qry}' is dominated by {kw_str}, "
f"reflecting the primary research foci within this domain. "
f"The majority of contributions originate from {country}, with {oa}% published under open-access arrangements."
),
'recommendations_text': (
f"Based on {len(recs)} detected research gap(s), priority research directions include "
f"addressing the {gap_str}. Researchers are advised to pursue contextual, "
f"mixed-method studies to diversify the current methodological landscape."
),
'narrative_text': (
f"Analysis of {n} peer-reviewed articles retrieved for the query '{qry}' reveals "
f"a corpus centered on {kw_str}. Geographic concentration in {country} suggests "
f"limited regional diversification, particularly within ASEAN contexts. "
f"Open-access availability of {oa}% of articles supports research reproducibility. "
f"The Research Gap Score framework identified {len(recs)} actionable gaps, "
f"with the highest-priority gap classified as a {gap_str}, warranting systematic investigation."
),
}
# ── Attempt AI generation ──────────────────────────────────────
if use_ai:
try:
prompt = _build_prompt(context_data)
raw = call_ai(prompt, ai_model)
if raw:
# Parse JSON safely
import json as _json
# Strip markdown code fences if present
clean = raw.strip().lstrip('```json').lstrip('```').rstrip('```').strip()
parsed = _json.loads(clean)
parsed['source'] = f'llm-{ai_model}'
return parsed
except Exception as _llm_e:
print(f"⚠️ LLM narrative generation failed ({ai_model}): {_llm_e}")
# ── Fallback ───────────────────────────────────────────────────
return _rule_based_narrative(context_data)
# =====================================================================
# EXPORT REPORT — DOCX (python-docx)
# =====================================================================
def build_docx_report(data: dict) -> bytes:
"""
Build a .docx research report from analysis_results dict.
Returns bytes of the DOCX file.
"""
try:
from docx import Document
from docx.shared import Pt, RGBColor, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
import io as _io
except ImportError:
raise RuntimeError("python-docx not installed. Run: pip install python-docx")
doc = Document()
# ── Title ─────────────────────────────────────────────────────
title = doc.add_heading(
f"Bibliometric Research Report: {data.get('query', 'SLR Analysis')}", level=0
)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
meta = doc.add_paragraph()
meta.add_run(f"Generated by BIRAS System | ").font.color.rgb = RGBColor(0x88, 0x88, 0x88)
meta.add_run(f"{datetime.datetime.now().strftime('%d %B %Y, %H:%M')}").font.color.rgb = RGBColor(0x88, 0x88, 0x88)
meta.alignment = WD_ALIGN_PARAGRAPH.CENTER
doc.add_paragraph()
# ── 1. Introduction ───────────────────────────────────────────
doc.add_heading('1. Introduction', level=1)
doc.add_paragraph(
f"This report presents the results of an automated bibliometric analysis conducted for the "
f"search query '{data.get('query', 'N/A')}'. The analysis was performed using the Bibliometric "
f"Intelligence and Research Assistant System (BIRAS), integrating data from multiple academic "
f"APIs including OpenAlex, Crossref, and CORE."
)
# ── 2. Dataset Summary ────────────────────────────────────────
doc.add_heading('2. Dataset Summary', level=1)
table = doc.add_table(rows=5, cols=2)
table.style = 'Table Grid'
cells = [
('Total Articles (Deduplicated)', str(data.get('total_results', 'N/A'))),
('Year Range', f"{data.get('start_year', 'N/A')} – {data.get('end_year', 'N/A')}"),
('API Sources', ', '.join(data.get('api_sources', []))),
('Open Access Articles', f"{data.get('oa_count', 0)} articles"),
('Average Quality Score', str(data.get('avg_quality', 'N/A'))),
]
for i, (k, v) in enumerate(cells):
table.cell(i, 0).text = k
table.cell(i, 1).text = v
table.cell(i, 0).paragraphs[0].runs[0].font.bold = True
doc.add_paragraph()
# ── 3. Key Insights ───────────────────────────────────────────
doc.add_heading('3. Key Insights', level=1)
insights_text = data.get('insights_text', '')
if insights_text:
doc.add_paragraph(insights_text)
insights_list = data.get('insights', [])
if insights_list:
for ins in insights_list:
p = doc.add_paragraph(style='List Bullet')
p.add_run(f"{ins.get('label', '')}: ").bold = True
# Strip HTML tags
clean_text = re.sub(r'<[^>]+>', '', ins.get('text', ''))
p.add_run(clean_text)
# ── 4. Research Trends ────────────────────────────────────────
doc.add_heading('4. Research Trends', level=1)
narrative = data.get('narrative_text', '')
if narrative:
doc.add_paragraph(narrative)
else:
doc.add_paragraph(
f"Trend analysis of the {data.get('total_results', 0)}-article corpus indicates "
f"publication activity spanning {data.get('start_year', 'N/A')} to {data.get('end_year', 'N/A')}."
)
# ── 5. Research Gap Analysis ──────────────────────────────────
doc.add_heading('5. Research Gap Analysis', level=1)
recs = data.get('recommendations', [])
if recs:
doc.add_paragraph(
f"The Research Gap Score (GS) framework identified {len(recs)} research gap(s). "
f"{sum(1 for r in recs if r.get('priority')=='HIGH')} gap(s) classified as HIGH priority."
)
for rec in recs:
doc.add_heading(f"{rec.get('gap_type_icon','')} {rec.get('title','')}", level=3)
p = doc.add_paragraph()
p.add_run('Gap Type: ').bold = True
p.add_run(rec.get('gap_type_label', ''))
p = doc.add_paragraph()
p.add_run('Priority: ').bold = True
p.add_run(rec.get('priority', ''))
p = doc.add_paragraph()
p.add_run('Gap Score: ').bold = True
p.add_run(str(rec.get('gs_score', '')))
doc.add_paragraph(rec.get('rationale', ''))
if rec.get('methods'):
p = doc.add_paragraph()
p.add_run('Suggested Methods: ').bold = True
p.add_run(', '.join(rec.get('methods', [])))
else:
doc.add_paragraph('No significant research gaps detected for this query.')
# ── 6. Recommendations ────────────────────────────────────────
doc.add_heading('6. Recommendations', level=1)
recs_text = data.get('recommendations_text', '')
if recs_text:
doc.add_paragraph(recs_text)
if recs:
for rec in recs:
p = doc.add_paragraph(style='List Bullet')
p.add_run(rec.get('suggested_design', '')).italic = True
if rec.get('ai_suggestion'):
p2 = doc.add_paragraph(style='List Bullet 2')
p2.add_run(f"AI Insight: {rec['ai_suggestion']}")
# ── 7. Article List (Top 20) ──────────────────────────────────
doc.add_heading('7. Article List (Top 20 by Quality Score)', level=1)
articles = sorted(
data.get('results', []),
key=lambda x: x.get('quality_score', 0), reverse=True
)[:20]
if articles:
art_table = doc.add_table(rows=1 + len(articles), cols=4)
art_table.style = 'Table Grid'
headers = ['#', 'Title', 'Year', 'Quality']
for j, h in enumerate(headers):
art_table.cell(0, j).text = h
art_table.cell(0, j).paragraphs[0].runs[0].font.bold = True
for i, art in enumerate(articles):
art_table.cell(i+1, 0).text = str(i+1)
art_table.cell(i+1, 1).text = art.get('title', 'N/A')[:80]
art_table.cell(i+1, 2).text = str(art.get('yearPublished', 'N/A'))
art_table.cell(i+1, 3).text = str(round(art.get('quality_score', 0), 1))
# ── Save to bytes ─────────────────────────────────────────────
buf = _io.BytesIO()
doc.save(buf)
buf.seek(0)
return buf.read()
def _get_articles_as_dicts(search_cache):
import json
return [{
'id': art.original_id, 'title': art.title, 'abstract': art.abstract,
'authors': json.loads(art.authors) if art.authors else [],
'yearPublished': art.yearPublished, 'publisher': art.publisher, 'doi': art.doi,
'link': art.link, 'country_name': art.country_name, 'country_code': art.country_code,
'api_origin': art.api_origin, 'source_display': art.source_display,
'repositories': json.loads(art.repositories) if art.repositories else [],
'cited_by_count': art.cited_by_count
} for art in search_cache.articles]
# (Duplicate /export_report route removed. See line 3444 for the active implementation)
# =====================================================================
# PAPER GENERATOR INTEGRATION
# =====================================================================
try:
from paper_generator import generate_full_paper, build_paper_docx
PAPER_GENERATOR_AVAILABLE = True
except ImportError as _pg_e:
print(f"⚠️ paper_generator not available: {_pg_e}")
PAPER_GENERATOR_AVAILABLE = False
@app.route('/generate_paper/')
@login_required
def generate_paper_route(search_id):
"""Generate and download full IMRAD paper as DOCX."""
from flask import send_file
import io as _io
if not PAPER_GENERATOR_AVAILABLE:
flash('Paper generator module tidak tersedia.', 'danger')
return redirect(request.referrer or url_for('index'))
search_cache = SearchCache.query.filter_by(
id=search_id, user_id=current_user.id
).first_or_404()
try:
results_json = _get_articles_as_dicts(search_cache)
use_ai = request.args.get('use_ai', 'false').lower() == 'true'
ai_model = request.args.get('ai_model', 'openai')
analysis_result = {
'query': search_cache.search_term,
'total_results': len(results_json),
'start_year': min((r.get('yearPublished', 9999) for r in results_json
if r.get('yearPublished')), default='N/A'),
'end_year': max((r.get('yearPublished', 0) for r in results_json
if r.get('yearPublished')), default='N/A'),
'results': results_json,
'insights_text': '', 'narrative_text': '', 'recommendations': [],
}
paper = generate_full_paper(analysis_result, style='APA',
use_ai=use_ai, ai_model=ai_model)
docx_bytes = build_paper_docx(paper)
safe_q = re.sub(r'[^\w\s-]', '', search_cache.search_term)[:35].strip().replace(' ', '_')
src = 'AI' if use_ai else 'RuleBased'
filename = f"BIRAS_Paper_{safe_q}_{src}_{datetime.datetime.now().strftime('%Y%m%d')}.docx"
return send_file(_io.BytesIO(docx_bytes),
mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
as_attachment=True, download_name=filename)
except RuntimeError as e:
flash(str(e), 'danger')
except Exception as e:
print(f"❌ generate_paper error: {e}"); traceback.print_exc()
flash(f'Gagal generate paper: {e}', 'danger')
return redirect(request.referrer or url_for('index'))
# =====================================================================
# ADVANCED CHARTS INTEGRATION
# =====================================================================
try:
from advanced_charts import (build_citation_network, generate_embedding_map,
plot_tool_comparison, generate_graph_explanation)
ADVANCED_CHARTS_AVAILABLE = True
except ImportError as _ac_e:
print(f"⚠️ advanced_charts not available: {_ac_e}")
ADVANCED_CHARTS_AVAILABLE = False
@app.route('/explain_graph', methods=['POST'])
@login_required
def explain_graph():
"""
AI Narrator endpoint.
POST body: { "graph_type": "citation_network"|"embedding_map"|"tool_comparison",
"search_id": int, "use_ai": bool, "ai_model": str }
Returns: { "explanation": "...", "source": "llm-xxx"|"rule-based" }
"""
from flask import jsonify
data = request.get_json(force=True, silent=True) or {}
graph_type = data.get('graph_type', 'citation_network')
search_id = data.get('search_id')
use_ai = bool(data.get('use_ai', True))
ai_model = data.get('ai_model', 'openai')
if not ADVANCED_CHARTS_AVAILABLE:
return jsonify({'explanation': 'Advanced charts module not available.', 'source': 'error'})
try:
# Load articles from cache
df = pd.DataFrame()
if search_id:
sc = SearchCache.query.filter_by(id=int(search_id), user_id=current_user.id).first()
if sc:
records = _get_articles_as_dicts(sc)
df = pd.DataFrame(records)
explanation = generate_graph_explanation(
graph_type=graph_type, df=df,
insights=[], use_ai=use_ai, ai_model=ai_model
)
source = f'llm-{ai_model}' if use_ai and len(df) > 0 else 'rule-based'
return jsonify({'explanation': explanation, 'source': source})
except Exception as e:
print(f"❌ explain_graph error: {e}"); traceback.print_exc()
return jsonify({'explanation': f'Unable to generate explanation: {e}', 'source': 'error'})
# =====================================================================
# ADMIN – LLM API HEALTH MONITOR
# =====================================================================
def _ping_llm_provider(provider: str, cfg: dict) -> dict:
"""
Ping a single LLM provider with a minimal prompt.
Returns a status dict: {provider, status, latency_ms, model, snippet, error}.
"""
import time as _t
result = {
'provider': provider,
'status': 'unknown',
'latency_ms': None,
'model': cfg.get('model', '-'),
'snippet': None,
'error': None,
'key_configured': False,
}
# Check key availability
env_key = cfg.get('env_key', '')
api_key = os.environ.get(env_key, '')
result['key_configured'] = bool(api_key)
if not api_key:
result['status'] = 'no_key'
result['error'] = f'API key not set ({env_key})'
return result
base_url = cfg.get('base_url')
if callable(base_url):
base_url = base_url()
mini_prompt = "Reply with exactly: OK"
t0 = _t.perf_counter()
try:
from openai import OpenAI as _OpenAI
kw = {'api_key': api_key}
if base_url:
kw['base_url'] = base_url
client = _OpenAI(**kw)
resp = client.chat.completions.create(
model=cfg['model'],
messages=[{'role': 'user', 'content': mini_prompt}],
max_tokens=10,
timeout=12,
)
elapsed = (_t.perf_counter() - t0) * 1000
text = (resp.choices[0].message.content or '').strip()
result['status'] = 'ok'
result['latency_ms'] = round(elapsed, 1)
result['snippet'] = text[:60]
except Exception as exc:
elapsed = (_t.perf_counter() - t0) * 1000
result['status'] = 'error'
result['latency_ms'] = round(elapsed, 1)
result['error'] = str(exc)[:200]
return result
def _ping_gemini(api_key: str) -> dict:
"""Ping Google Gemini separately (different SDK)."""
import time as _t
result = {
'provider': 'gemini',
'status': 'unknown',
'latency_ms': None,
'model': 'gemini-2.0-flash',
'snippet': None,
'error': None,
'key_configured': bool(api_key),
}
if not api_key:
result['status'] = 'no_key'
result['error'] = 'GEMINI_API_KEY not set'
return result
t0 = _t.perf_counter()
try:
import google.generativeai as _genai
_genai.configure(api_key=api_key)
model = _genai.GenerativeModel('gemini-2.0-flash')
resp = model.generate_content(
'Reply with exactly: OK',
generation_config=_genai.types.GenerationConfig(max_output_tokens=10),
)
elapsed = (_t.perf_counter() - t0) * 1000
text = (resp.text or '').strip()
result['status'] = 'ok'
result['latency_ms'] = round(elapsed, 1)
result['snippet'] = text[:60]
except Exception as exc:
elapsed = (_t.perf_counter() - t0) * 1000
result['status'] = 'error'
result['latency_ms'] = round(elapsed, 1)
result['error'] = str(exc)[:200]
return result
@app.route('/admin')
@login_required
@admin_required
def admin_dashboard():
users = User.query.all()
return render_template('admin.html', users=users)
@app.route('/admin/toggle-role/', methods=['POST'])
@login_required
@admin_required
def toggle_role(user_id):
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
flash('Tidak dapat mengubah role Anda sendiri.', 'warning')
else:
user.role = 'user' if user.role == 'admin' else 'admin'
db.session.commit()
flash(f'Role {user.username} diubah menjadi {user.role}.', 'success')
return redirect(url_for('admin_dashboard'))
@app.route('/admin/check-llm-api', methods=['POST'])
@login_required
@admin_required
def check_llm_api():
"""
JSON endpoint: ping all (or a specific) LLM provider(s).
Body: { "provider": "all" | "openai" | "deepseek" | "zai" | "gemini" }
Returns: { "results": [...], "checked_at": "ISO timestamp" }
"""
from flask import jsonify as _jsonify
data = request.get_json(force=True, silent=True) or {}
target = data.get('provider', 'all').lower()
# Pull keys from env (user-level keys not used for system-wide monitor)
PROVIDER_CFGS = {
'openai': {
'env_key': 'OPENAI_API_KEY',
'base_url': None,
'model': 'gpt-4o-mini',
'label': 'OpenAI (GPT-4o-mini)',
'color': '#10a37f',
'icon': 'bi-robot',
},
'deepseek': {
'env_key': 'DEEPSEEK_API_KEY',
'base_url': 'https://api.deepseek.com',
'model': 'deepseek-chat',
'label': 'DeepSeek',
'color': '#5865f2',
'icon': 'bi-cpu',
},
'zai': {
'env_key': 'ZAI_API_KEY',
'base_url': lambda: os.environ.get('ZAI_BASE_URL', 'https://api.z.ai/v1'),
'model': 'glm-4-plus',
'label': 'Z.AI (GLM-4-Plus)',
'color': '#ff6b35',
'icon': 'bi-lightning-charge',
},
'groq': {
'env_key': 'GROQ_API_KEY',
'base_url': 'https://api.groq.com/openai/v1',
'model': 'llama-3.3-70b-versatile',
'label': 'Groq',
'color': '#f39c12',
'icon': 'bi-rocket',
},
'openrouter': {
'env_key': 'OPENROUTER_API_KEY',
'base_url': 'https://openrouter.ai/api/v1',
'model': 'openrouter/free',
'label': 'OpenRouter (Free)',
'color': '#8e44ad',
'icon': 'bi-router',
},
'github': {
'env_key': 'GITHUB_API_KEY',
'base_url': 'https://models.inference.ai.azure.com',
'model': 'gpt-4o',
'label': 'GitHub Models',
'color': '#24292e',
'icon': 'bi-github',
},
'huggingface': {
'env_key': 'HUGGINGFACE_API_KEY',
'base_url': 'https://router.huggingface.co/v1',
'model': 'Qwen/Qwen2.5-72B-Instruct',
'label': 'HuggingFace',
'color': '#ffd21e',
'icon': 'bi-emoji-smile',
},
'sambanova': {
'env_key': 'SAMBANOVA_API_KEY',
'base_url': 'https://api.sambanova.ai/v1',
'model': 'Meta-Llama-3.3-70B-Instruct',
'label': 'SambaNova',
'color': '#d35400',
'icon': 'bi-cpu-fill',
},
'cerebras': {
'env_key': 'CEREBRAS_API_KEY',
'base_url': 'https://api.cerebras.ai/v1',
'model': 'llama3.3-70b',
'label': 'Cerebras',
'color': '#00b4d8',
'icon': 'bi-lightning-charge-fill',
},
'together': {
'env_key': 'TOGETHER_API_KEY',
'base_url': 'https://api.together.xyz/v1',
'model': 'meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo',
'label': 'Together AI',
'color': '#ff6b6b',
'icon': 'bi-people-fill',
},
'mistral': {
'env_key': 'MISTRAL_API_KEY',
'base_url': 'https://api.mistral.ai/v1',
'model': 'mistral-small-latest',
'label': 'Mistral AI',
'color': '#f97316',
'icon': 'bi-wind',
},
}
results = []
results_with_labels = []
if target in ('all', 'gemini'):
gemini_key = os.environ.get('GEMINI_API_KEY', '')
r = _ping_gemini(gemini_key)
r['label'] = 'Google Gemini'
r['color'] = '#4285f4'
r['icon'] = 'bi-google'
results.append(r)
results_with_labels.append(r)
for name, cfg in PROVIDER_CFGS.items():
if target not in ('all', name):
continue
r = _ping_llm_provider(name, cfg)
r['label'] = cfg['label']
r['color'] = cfg['color']
r['icon'] = cfg['icon']
results.append(r)
results_with_labels.append(r)
# ── Cache result so /api/provider-status can serve it ───────────
import datetime as _dt
for r in results_with_labels:
_PROVIDER_STATUS_CACHE[r['provider']] = {
'status': r['status'],
'label': r.get('label', r['provider']),
'latency_ms': r.get('latency_ms'),
'updated_at': _dt.datetime.utcnow().isoformat(),
}
checked_at = datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
return _jsonify({'results': results, 'checked_at': checked_at})
# In-memory cache: provider → {status, label, latency_ms, updated_at}
_PROVIDER_STATUS_CACHE: dict = {}
@app.route('/api/provider-status', methods=['GET'])
def api_provider_status():
"""
Public (login not required) endpoint: returns cached LLM provider statuses.
Used by index.html to hide broken providers from the dropdown.
Returns: { "providers": { name: {status, label, latency_ms} }, "cached": bool }
"""
from flask import jsonify as _jsonify
if _PROVIDER_STATUS_CACHE:
return _jsonify({'providers': _PROVIDER_STATUS_CACHE, 'cached': True})
# No cache yet — return all as 'unknown' so dropdown shows everything
KNOWN = ['gemini','openai','deepseek','zai','groq','openrouter',
'github','huggingface','sambanova','cerebras','together','mistral']
LABELS = {
'gemini': 'Google (Gemini 2.0 Flash)',
'openai': 'OpenAI (GPT-4o Mini)',
'deepseek': 'DeepSeek (DeepSeek Chat)',
'zai': 'Z.AI (GLM-4 Plus)',
'groq': 'Groq (Llama-3.3-70B)',
'openrouter': 'OpenRouter (Free)',
'github': 'GitHub Models (GPT-4o)',
'huggingface': 'HuggingFace (Qwen-2.5-72B)',
'sambanova': 'SambaNova (Llama-3.3-70B)',
'cerebras': 'Cerebras ⚡ (Llama-3.3-70B)',
'together': 'Together AI (Llama-3.3-70B)',
'mistral': 'Mistral AI (Mistral Small)',
}
return _jsonify({
'providers': {p: {'status': 'unknown', 'label': LABELS.get(p, p)} for p in KNOWN},
'cached': False,
})
# =====================================================================
# ADMIN – LITERATURE API HEALTH MONITOR
# =====================================================================
def _ping_lit_provider(provider: str, cfg: dict) -> dict:
import time as _t
import requests as _req
result = {
'provider': provider,
'status': 'unknown',
'latency_ms': None,
'error': None,
'key_configured': True,
}
url = cfg['url']
headers = {'User-Agent': 'BIRAS/1.0 (HealthMonitor)'}
params = {}
key_env = cfg.get('key_env')
if key_env:
api_key = os.environ.get(key_env, '')
if not api_key:
result['key_configured'] = False
result['status'] = 'no_key'
result['error'] = f'{key_env} is missing'
# Some APIs require keys strictly or rate-limit heavily (SERPAPI, CORE, S2)
if provider in ('google_scholar', 'core', 'semantic_scholar'):
return result
else:
if cfg.get('header_key'):
prefix = cfg.get('prefix', '')
headers[cfg['header_key']] = prefix + api_key
elif cfg.get('query_key'):
params[cfg['query_key']] = api_key
t0 = _t.perf_counter()
try:
resp = _req.get(url, headers=headers, params=params, timeout=12)
resp.raise_for_status()
elapsed = (_t.perf_counter() - t0) * 1000
result['status'] = 'ok'
result['latency_ms'] = round(elapsed, 1)
except Exception as exc:
elapsed = (_t.perf_counter() - t0) * 1000
result['status'] = 'error'
result['latency_ms'] = round(elapsed, 1)
result['error'] = str(exc)[:200]
return result
@app.route('/admin/check-lit-api', methods=['POST'])
@login_required
@admin_required
def check_lit_api():
"""
JSON endpoint: ping all (or specific) literature data provider(s).
"""
from flask import jsonify as _jsonify
data = request.get_json(force=True, silent=True) or {}
target = data.get('provider', 'all').lower()
LIT_PROVIDERS = {
'core': {'url': 'https://api.core.ac.uk/v3/search/works?q=test&limit=1', 'key_env': 'CORE_API_KEY', 'header_key': 'Authorization', 'prefix': 'Bearer ', 'label': 'CORE API', 'icon': 'bi-hdd-network', 'color': '#f39c12'},
'openalex': {'url': 'https://api.openalex.org/works?search=test&per-page=1', 'label': 'OpenAlex API', 'icon': 'bi-globe', 'color': '#2980b9'},
'crossref': {'url': 'https://api.crossref.org/works?query=test&rows=1', 'label': 'Crossref', 'icon': 'bi-journal-check', 'color': '#e74c3c'},
'semantic_scholar': {'url': 'https://api.semanticscholar.org/graph/v1/paper/search?query=test&limit=1', 'key_env': 'SEMANTIC_SCHOLAR_API_KEY', 'header_key': 'x-api-key', 'label': 'Semantic Scholar', 'icon': 'bi-mortarboard', 'color': '#8e44ad'},
'google_scholar': {'url': 'https://serpapi.com/search?engine=google_scholar&q=test&num=1', 'key_env': 'SERPAPI_API_KEY', 'query_key': 'api_key', 'label': 'Google Scholar', 'icon': 'bi-google', 'color': '#4285f4'},
'doaj': {'url': 'https://doaj.org/api/search/articles/test?pageSize=1', 'label': 'DOAJ OA', 'icon': 'bi-unlock', 'color': '#f1c40f'},
'pubmed': {'url': 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed&term=test&retmax=1&retmode=json', 'label': 'PubMed', 'icon': 'bi-heart-pulse', 'color': '#3498db'},
'europepmc': {'url': 'https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=test&format=json&resultType=lite&pageSize=1', 'label': 'Europe PMC', 'icon': 'bi-activity', 'color': '#16a085'},
'arxiv': {'url': 'http://export.arxiv.org/api/query?search_query=all:test&max_results=1', 'label': 'arXiv', 'icon': 'bi-calculator', 'color': '#c0392b'},
'dblp': {'url': 'https://dblp.org/search/publ/api?q=test&format=json&h=1', 'label': 'DBLP', 'icon': 'bi-pc-display', 'color': '#2c3e50'},
'base': {'url': 'https://api.base-search.net/cgi-bin/BaseHttpSearchInterface.fcgi?func=PerformSearch&query=test&format=json&hits=1', 'label': 'BASE', 'icon': 'bi-layers', 'color': '#34495e'},
'opencitations': {'url': 'https://opencitations.net/index/coci/api/v1/citation-count/10.1038/nature12373', 'label': 'OpenCitations', 'icon': 'bi-link-45deg', 'color': '#9b59b6'},
'zenodo': {'url': 'https://zenodo.org/api/records?q=test&size=1', 'label': 'Zenodo', 'icon': 'bi-archive', 'color': '#0984e3'},
'biorxiv': {'url': 'https://api.biorxiv.org/details/biorxiv/2024-01-01/2024-01-05/0', 'label': 'BioRxiv', 'icon': 'bi-virus', 'color': '#d35400'},
'plos': {'url': 'https://api.plos.org/search?q=test&rows=1&wt=json', 'label': 'PLOS', 'icon': 'bi-journal-richtext', 'color': '#27ae60'},
'datacite': {'url': 'https://api.datacite.org/dois?query=test&page[size]=1', 'label': 'DataCite', 'icon': 'bi-database', 'color': '#f39c12'},
'orcid': {'url': 'https://pub.orcid.org/v3.0/expanded-search/?q=test&rows=1', 'label': 'ORCID', 'icon': 'bi-person-badge', 'color': '#a6ce39'},
'osf': {'url': 'https://api.osf.io/v2/nodes/?filter[title]=test&page[size]=1', 'label': 'OSF', 'icon': 'bi-share', 'color': '#34495e'},
'unpaywall': {'url': 'https://api.unpaywall.org/v2/10.1038/nature12373?email=noranisa@unism.ac.id', 'label': 'Unpaywall (PDF)', 'icon': 'bi-file-earmark-pdf', 'color': '#e67e22'},
}
results = []
for name, cfg in LIT_PROVIDERS.items():
if target not in ('all', name):
continue
r = _ping_lit_provider(name, cfg)
r['label'] = cfg['label']
r['color'] = cfg['color']
r['icon'] = cfg['icon']
results.append(r)
checked_at = datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
return _jsonify({'results': results, 'checked_at': checked_at})
# =====================================================================
# ENDPOINT: Natural Language → Keyword Extraction
# =====================================================================
@app.route('/api/extract-keywords', methods=['POST'])
def api_extract_keywords():
"""
Terima teks bebas (deskripsi riset), ekstrak keyword akademik dengan AI,
kembalikan sebagai JSON untuk dipakai di form pencarian.
"""
from flask import jsonify as _jsonify
try:
data = request.get_json(force=True) or {}
text = (data.get('text') or '').strip()
model = data.get('model', 'auto')
if not text:
return _jsonify({'error': 'Teks tidak boleh kosong.'}), 400
if len(text) > 2000:
return _jsonify({'error': 'Teks terlalu panjang (maks 2000 karakter).'}), 400
prompt = (
"Kamu adalah asisten riset akademik. "
"Ekstrak 5–8 keyword/frasa pencarian akademik dalam BAHASA INGGRIS "
"dari deskripsi riset berikut. "
"Format output: HANYA daftar keyword dipisahkan koma, tanpa penjelasan, "
"tanpa nomor, tanpa tanda petik.\n\n"
f"Deskripsi riset:\n{text}\n\n"
"Keyword (pisahkan dengan koma):"
)
raw = call_ai(prompt, model=model, max_tokens=120)
if not raw:
# Fallback: simple word extraction
import re
words = re.findall(r'\b[a-zA-Z]{4,}\b', text)
stop = {'yang','dan','atau','untuk','dalam','pada','dari','dengan',
'this','that','with','from','have','been','will','they',
'also','some','more','into','than','then','them','these'}
kws = list(dict.fromkeys(
w.lower() for w in words if w.lower() not in stop
))[:6]
raw = ', '.join(kws)
# Bersihkan output AI
keywords = [k.strip().strip('"\'') for k in raw.split(',') if k.strip()]
keywords = [k for k in keywords if 2 < len(k) < 80][:8]
return _jsonify({
'keywords': keywords,
'query': ' '.join(keywords[:4]), # gabungkan 4 keyword untuk field query
})
except Exception as e:
logger.error("extract_keywords error: %s", e)
return _jsonify({'error': str(e)}), 500
if __name__ == '__main__':
_port = int(os.environ.get('PORT', APP_CONFIG.get('PORT', 7860)))
_debug = os.environ.get('FLASK_ENV', 'production') == 'development'
logger.info("Starting BIRAS on 0.0.0.0:%d (debug=%s)", _port, _debug)
app.run(debug=_debug, host='0.0.0.0', port=_port)