|
|
|
|
|
|
| import os
|
| import sys
|
| from dotenv import load_dotenv
|
|
|
|
|
| load_dotenv()
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
| 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 bibtexparser
|
| from bibtexparser.bwriter import BibTexWriter
|
| from bibtexparser.bibdatabase import BibDatabase
|
|
|
|
|
| import google.generativeai as genai
|
|
|
|
|
| from wordcloud import WordCloud
|
| import nltk
|
| from nltk.corpus import stopwords
|
| from nltk.tokenize import word_tokenize
|
|
|
|
|
|
|
|
|
| _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
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
| 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}")
|
|
|
|
|
| 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"
|
|
|
|
|
| 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
|
|
|
|
|
| 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."
|
|
|
|
|
|
|
|
|
| @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")
|
|
|
|
|
| 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}")
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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")]
|
|
|
|
|
| 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': '',
|
| '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_CACHE = {}
|
| _CROSSREF_CACHE_TTL = 3600
|
|
|
| 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):
|
| 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)
|
|
|
| items = data.get('message', {}).get('items', [])
|
| if not items:
|
| return pd.DataFrame()
|
|
|
| normalized_data = []
|
| for item in items:
|
|
|
| title_list = item.get('title', [])
|
| title = title_list[0] if title_list else 'No Title'
|
|
|
|
|
| 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)
|
|
|
|
|
| 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
|
|
|
|
|
| raw_abstract = item.get('abstract', '')
|
| abstract = re.sub(r'<[^>]+>', '', raw_abstract).strip() if raw_abstract else ''
|
|
|
|
|
| container = item.get('container-title', [])
|
| journal = container[0] if container else 'Crossref'
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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')
|
|
|
|
|
| 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)
|
|
|
|
|
| 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_el = art.find('ArticleTitle')
|
| title = ''.join(title_el.itertext()).strip() if title_el is not None else 'No Title'
|
|
|
|
|
| abstract_parts = art.findall('.//AbstractText')
|
| abstract = ' '.join(''.join(a.itertext()) for a in abstract_parts).strip()
|
|
|
|
|
| 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())
|
|
|
|
|
| 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_el = art.find('.//Journal/Title')
|
| journal = journal_el.text if journal_el is not None else 'PubMed'
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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_list = [
|
| author.findtext(f'{{{NS}}}name', '')
|
| for author in entry.findall(f'{{{NS}}}author')
|
| if author.findtext(f'{{{NS}}}name', '')
|
| ]
|
|
|
|
|
| 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
|
|
|
|
|
| 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_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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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',
|
| },
|
| 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,
|
| })
|
| except Exception:
|
| continue
|
|
|
| result_df = pd.DataFrame(normalized_data)
|
| print(f"BASE: {len(result_df)} hasil ditemukan untuk '{query}'")
|
| return result_df
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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()
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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', {})
|
|
|
|
|
| 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,
|
| })
|
| except Exception:
|
| continue
|
|
|
| result_df = pd.DataFrame(normalized_data)
|
| print(f"Zenodo: {len(result_df)} hasil ditemukan untuk '{query}'")
|
| return result_df
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
| interval_start = f"{start_year}-01-01"
|
| interval_end = f"{end_year}-12-31"
|
| cursor = 0
|
| fetched = 0
|
| page_size = 100
|
|
|
| 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
|
|
|
|
|
| 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
|
|
|
| result_df = pd.DataFrame(normalized_data)
|
| print(f"BioRxiv/MedRxiv: {len(result_df)} hasil ditemukan untuk '{query}'")
|
| return result_df
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| })
|
| except Exception:
|
| continue
|
|
|
| result_df = pd.DataFrame(normalized_data)
|
| print(f"PLOS: {len(result_df)} hasil ditemukan untuk '{query}'")
|
| return result_df
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
| 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):
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
| 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_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:
|
|
|
| _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):
|
| try:
|
| response = client.generate_content(full_prompt)
|
| return response.text.strip()
|
| except Exception as e:
|
| if '429' in str(e):
|
|
|
| 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()
|
|
|
|
|
| _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
|
|
|
| _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."
|
|
|
|
|
| _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)
|
| try:
|
| if user_key:
|
| os.environ[env_var] = 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:
|
|
|
| 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', '<br>')
|
| 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:
|
| 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', '<br>')
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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_CACHE = {}
|
| _UNPAYWALL_CACHE_TTL = 3600
|
| _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)
|
|
|
|
|
| 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
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
| _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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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: <strong>'{top_kw}'</strong> — muncul di {kw_pct}% artikel. "
|
| f"Menunjukkan fokus utama corpus penelitian ini."
|
| })
|
|
|
|
|
| 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 <strong>{growth:.0f}%/tahun</strong>. Puncak publikasi: <strong>{peak_yr}</strong>. Topik sangat aktif."
|
| trend_type = 'growing'
|
| elif growth < -10:
|
| trend_text = f"📉 Tren menurun sejak puncak di <strong>{peak_yr}</strong>. Kemungkinan topik jenuh atau bergeser."
|
| trend_type = 'declining'
|
| else:
|
| trend_text = f"➡️ Tren stabil. Puncak publikasi: <strong>{peak_yr}</strong>. Komunitas penelitian mature."
|
| trend_type = 'stable'
|
| insights.append({'icon': '📊', 'label': 'Tren Publikasi', 'type': trend_type, 'text': trend_text})
|
| except Exception:
|
| pass
|
|
|
|
|
| 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 <strong>{top_country}</strong> ({top_pct}%). "
|
| f"Total {n_countries} negara terwakili.{asean_note}"
|
| })
|
| except Exception:
|
| pass
|
|
|
|
|
| try:
|
| oa_series = df.get('is_oa', pd.Series([False]*len(df)))
|
| oa_pct = round(oa_series.fillna(False).mean() * 100)
|
| oa_text = (
|
| f"<strong>{oa_pct}%</strong> artikel open access — akses luas, mendukung replikasi."
|
| if oa_pct >= 50 else
|
| f"<strong>{oa_pct}%</strong> artikel open access — akses terbatas. Gunakan Unpaywall untuk PDF."
|
| )
|
| insights.append({'icon': '🔓', 'label': 'Open Access', 'type': 'oa', 'text': oa_text})
|
| except Exception:
|
| pass
|
|
|
|
|
| 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 <strong>{len(lda_topics)} klaster topik</strong>. Topik dominan: {lda_topics[0][:60]}..."
|
| })
|
|
|
|
|
| if use_ai and insights:
|
| try:
|
| summary_text = ' | '.join([i['text'].replace('<strong>', '').replace('</strong>', '') 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',
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| _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"
|
|
|
|
|
| if os.path.exists(filepath):
|
| print(f"🔊 TTS cache hit: {safe_name}.mp3")
|
| return url_path
|
|
|
|
|
| truncated = text.strip()[:800]
|
|
|
|
|
| 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',
|
| voice='alloy',
|
| 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)
|
|
|
|
|
| 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}
|
| """
|
|
|
| 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,
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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', [])
|
| 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.'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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', [])
|
| recs = analysis_results.get('recommendations', [])
|
| insights = analysis_results.get('insights', [])
|
| query = analysis_results.get('query', 'the studied domain')
|
|
|
|
|
| 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])
|
|
|
|
|
| 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?"
|
| )
|
|
|
|
|
| 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?",
|
| ]
|
|
|
|
|
| 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]
|
|
|
| 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
|
|
|
|
|
| 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()
|
|
|
|
|
| paper_refs = []
|
| paper_lines = []
|
| 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
|
|
|
|
|
| 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.'
|
|
|
|
|
| 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."
|
| )
|
|
|
|
|
| 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
|
|
|
| ASEAN_COUNTRIES = {'Indonesia', 'Malaysia', 'Thailand', 'Vietnam', 'Philippines',
|
| 'Singapore', 'Brunei', 'Myanmar', 'Cambodia', 'Laos'}
|
|
|
|
|
| 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)
|
|
|
|
|
| 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]
|
|
|
|
|
| _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_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')
|
|
|
|
|
|
|
| _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:
|
| 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
|
|
|
|
|
|
|
| try:
|
| if 'country_name' in df.columns and kw_list:
|
| represented = set(df['country_name'].dropna().unique())
|
|
|
| represented_asean = ASEAN_COUNTRIES & represented
|
|
|
| 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',
|
|
|
| 'missing_countries': missing_names,
|
| 'represented_countries': represented_names,
|
| })
|
| except Exception:
|
| pass
|
|
|
|
|
| if lda_topics and len(lda_topics) >= 3:
|
|
|
| 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',
|
| })
|
|
|
|
|
| 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',
|
| })
|
|
|
|
|
| priority_order = {'HIGH': 0, 'MEDIUM': 1, 'LOW': 2}
|
| recommendations.sort(key=lambda x: priority_order.get(x['priority'], 3))
|
|
|
|
|
| if use_ai and recommendations:
|
| for rec in recommendations[: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()
|
|
|
|
|
| _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 = {}
|
|
|
| _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())
|
| })
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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')
|
|
|
|
|
| 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')
|
|
|
|
|
| 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('')
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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')
|
|
|
|
|
| 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')
|
|
|
|
|
| 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')
|
|
|
|
|
| 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
|
|
|
|
|
| latest_keywords = []
|
| try:
|
| keywords_per_year = {}
|
| for year, group in df.groupby('yearPublished'):
|
|
|
| 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')
|
|
|
|
|
| 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='<b>%{y}</b><br>Jumlah: %{x}<extra></extra>')
|
| 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')
|
|
|
|
|
| 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_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]
|
|
|
| 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('<script>if(typeof network !== "undefined"){network.on("click", function(p){if(p.nodes.length>0){window.parent.postMessage({type:"author_click",name:p.nodes[0]},"*");}});}</script>')
|
| 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})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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')
|
|
|
|
|
| _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 []
|
|
|
|
|
| _ai_tasks = {}
|
|
|
|
|
| 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'}"
|
| )
|
|
|
|
|
| if _api_counts_tmp is not None:
|
| _ai_tasks['api_pie_explanation'] = (
|
| "Kontribusi API", query,
|
| f"Sumber: {_api_counts_tmp.to_dict('records')[:3]}"
|
| )
|
|
|
|
|
| _ai_tasks['wordcloud_explanation'] = (
|
| "Word Cloud", query,
|
| f"Kata kunci teratas: {', '.join([kw[0] for kw in top_20_keywords[:5]])}"
|
| )
|
|
|
|
|
| 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
|
|
|
|
|
| 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)."
|
| )
|
|
|
|
|
| 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
|
|
|
|
|
| if keywords_per_year_explanation := analysis_results.get('keywords_per_year_explanation', ''):
|
| pass
|
|
|
|
|
| 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."
|
| )
|
|
|
|
|
| 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
|
|
|
|
|
| if network_explanation and top_relations:
|
| _ai_tasks['network_explanation'] = (
|
| "Jaringan Konsep", query,
|
| f"Hubungan terkuat: {', '.join(top_relations)}."
|
| )
|
|
|
|
|
| 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
|
|
|
|
|
| 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)}."
|
| )
|
|
|
|
|
| if _topic_keywords_tmp:
|
| _ai_tasks['lda_explanation'] = (
|
| "Topik Klaster (LDA)", query,
|
| f"Ditemukan klaster: {'; '.join(_topic_keywords_tmp)}."
|
| )
|
|
|
|
|
| logger.info("Running %d AI explanation tasks in parallel...", len(_ai_tasks))
|
| _ai_results = _run_ai_explanations_parallel(_ai_tasks, ai_model, use_ai)
|
|
|
|
|
| for _k, _v in _ai_results.items():
|
| if _v:
|
| analysis_results[_k] = _v
|
|
|
| _metrics['stages_ok'].append('parallel_ai_explanations')
|
|
|
|
|
|
|
|
|
| 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'
|
| )
|
|
|
|
|
| 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')
|
|
|
|
|
|
|
| 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')
|
|
|
|
|
| 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')
|
|
|
|
|
| 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')
|
|
|
|
|
| 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')
|
|
|
|
|
| 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')
|
|
|
|
|
| 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', '')
|
|
|
|
|
| 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_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_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")
|
|
|
|
|
| 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', {})
|
|
|
|
|
| _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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| @app.route('/export_report/<int:search_id>')
|
| @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()
|
|
|
|
|
| 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,
|
| 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/<int:search_id>')
|
| @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,
|
|
|
| '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
|
|
|
|
|
| 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/<int:search_id>')
|
| @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/<int:search_id>')
|
| @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/<int:search_id>')
|
| @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')
|
| 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'))
|
|
|
|
|
|
|
|
|
|
|
|
|
| @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")
|
|
|
|
|
|
|
|
|
|
|
| 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."
|
| ),
|
| }
|
|
|
|
|
| if use_ai:
|
| try:
|
| prompt = _build_prompt(context_data)
|
| raw = call_ai(prompt, ai_model)
|
| if raw:
|
|
|
| import json as _json
|
|
|
| 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}")
|
|
|
|
|
| return _rule_based_narrative(context_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = 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()
|
|
|
|
|
| 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."
|
| )
|
|
|
|
|
| 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()
|
|
|
|
|
| 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
|
|
|
| clean_text = re.sub(r'<[^>]+>', '', ins.get('text', ''))
|
| p.add_run(clean_text)
|
|
|
|
|
| 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')}."
|
| )
|
|
|
|
|
| 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.')
|
|
|
|
|
| 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']}")
|
|
|
|
|
| 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))
|
|
|
|
|
| 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]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/<int:search_id>')
|
| @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'))
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
| 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'})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| }
|
|
|
|
|
| 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/<int:user_id>', 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()
|
|
|
|
|
| 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)
|
|
|
|
|
| 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})
|
|
|
|
|
|
|
| _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})
|
|
|
|
|
| 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,
|
| })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'
|
|
|
| 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})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| @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:
|
|
|
| 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)
|
|
|
|
|
| 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]),
|
| })
|
|
|
| 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)
|
|
|
|
|