Spaces:
Running
Running
Commit
·
bdb271a
0
Parent(s):
Fresh upload with LFS
Browse files- .gitattributes +2 -0
- .gitignore +13 -0
- alisto_project/Dockerfile +24 -0
- alisto_project/backend/alisto.db +0 -0
- alisto_project/backend/app.py +210 -0
- alisto_project/backend/augment_data.py +172 -0
- alisto_project/backend/images/alert.png +0 -0
- alisto_project/backend/images/alert1.png +0 -0
- alisto_project/backend/images/bg.jpg +3 -0
- alisto_project/backend/images/bg1.jpg +3 -0
- alisto_project/backend/images/bg2-bw.jpg +3 -0
- alisto_project/backend/images/bg2-logan.jpg +3 -0
- alisto_project/backend/images/bg2.jpg +3 -0
- alisto_project/backend/images/earthquake.png +0 -0
- alisto_project/backend/images/earthquake1.png +0 -0
- alisto_project/backend/index.html +260 -0
- alisto_project/backend/ingest_reddit.py +423 -0
- alisto_project/backend/init_db.py +28 -0
- alisto_project/backend/login.html +92 -0
- alisto_project/backend/map.html +44 -0
- alisto_project/backend/map_script.js +219 -0
- alisto_project/backend/models.py +64 -0
- alisto_project/backend/models/tfidf_ensemble.pkl +3 -0
- alisto_project/backend/my_generator.py +72 -0
- alisto_project/backend/ner_extractor.py +105 -0
- alisto_project/backend/script.js +594 -0
- alisto_project/backend/simulate_feed.py +127 -0
- alisto_project/backend/style.css +763 -0
- alisto_project/backend/templates.py +43 -0
- alisto_project/backend/train_ensemble.py +71 -0
- alisto_project/backend/train_model.py +136 -0
- alisto_project/data/reddit_disaster_posts.csv +0 -0
- alisto_project/data/seed_data.txt +0 -0
- alisto_project/requirements.txt +17 -0
- alisto_project/start.sh +9 -0
- instance/alisto.db +0 -0
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ignore the virtual environment folder
|
| 2 |
+
.venv/
|
| 3 |
+
venv/
|
| 4 |
+
env/
|
| 5 |
+
|
| 6 |
+
# Ignore compiled python files (junk)
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.pyc
|
| 9 |
+
|
| 10 |
+
# Ignore environment variables (sensitive passwords/keys)
|
| 11 |
+
.env
|
| 12 |
+
|
| 13 |
+
alisto_project/backend/models/roberta_model/
|
alisto_project/Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.9
|
| 2 |
+
FROM python:3.9
|
| 3 |
+
|
| 4 |
+
# Set up the folder
|
| 5 |
+
WORKDIR /code
|
| 6 |
+
|
| 7 |
+
# Copy requirements and install them
|
| 8 |
+
COPY ./requirements.txt /code/requirements.txt
|
| 9 |
+
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
| 10 |
+
|
| 11 |
+
# Copy all your code files
|
| 12 |
+
COPY . .
|
| 13 |
+
|
| 14 |
+
# Create a writable folder for the AI model to download into
|
| 15 |
+
# (Hugging Face requires this permission step)
|
| 16 |
+
RUN mkdir -p /tmp/cache
|
| 17 |
+
RUN chmod 777 /tmp/cache
|
| 18 |
+
ENV TRANSFORMERS_CACHE=/tmp/cache
|
| 19 |
+
|
| 20 |
+
# Give permission to run the start script
|
| 21 |
+
RUN chmod +x /code/start.sh
|
| 22 |
+
|
| 23 |
+
# Start the "All-in-One" script
|
| 24 |
+
CMD ["/code/start.sh"]
|
alisto_project/backend/alisto.db
ADDED
|
Binary file (36.9 kB). View file
|
|
|
alisto_project/backend/app.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import csv
|
| 3 |
+
import io
|
| 4 |
+
from flask import Flask, render_template, jsonify, request, Response, redirect, url_for
|
| 5 |
+
from flask_cors import CORS
|
| 6 |
+
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
|
| 7 |
+
from werkzeug.security import generate_password_hash, check_password_hash
|
| 8 |
+
from sqlalchemy import func
|
| 9 |
+
from models import db, DisasterPost
|
| 10 |
+
|
| 11 |
+
# 1. CONFIG: FLAT STRUCTURE (Look in current directory '.')
|
| 12 |
+
# initializes the Flask application
|
| 13 |
+
app = Flask(__name__, static_url_path='', static_folder='.', template_folder='.')
|
| 14 |
+
# sets a secret key for session management and security
|
| 15 |
+
app.secret_key = "alisto_secret_key_secure"
|
| 16 |
+
|
| 17 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 18 |
+
DB_PATH = os.path.join(BASE_DIR, 'alisto.db')
|
| 19 |
+
# configures the application to use the SQLite database
|
| 20 |
+
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_PATH}'
|
| 21 |
+
# disables modification tracking to save resources
|
| 22 |
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 23 |
+
# enables Cross-Origin Resource Sharing for API requests
|
| 24 |
+
CORS(app)
|
| 25 |
+
# initializes the SQLAlchemy database object with the Flask app
|
| 26 |
+
db.init_app(app)
|
| 27 |
+
|
| 28 |
+
# 2. AUTH CONFIG
|
| 29 |
+
# initializes Flask-Login manager for user session handling
|
| 30 |
+
login_manager = LoginManager()
|
| 31 |
+
login_manager.init_app(app)
|
| 32 |
+
# sets the default view for unauthenticated users
|
| 33 |
+
login_manager.login_view = 'login_page'
|
| 34 |
+
|
| 35 |
+
# defines the User model for database and login functionality
|
| 36 |
+
class User(UserMixin, db.Model):
|
| 37 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 38 |
+
username = db.Column(db.String(100), unique=True)
|
| 39 |
+
password = db.Column(db.String(100))
|
| 40 |
+
|
| 41 |
+
# flask-Login callback to reload the user object from the user ID stored in the session
|
| 42 |
+
@login_manager.user_loader
|
| 43 |
+
def load_user(user_id):
|
| 44 |
+
return User.query.get(int(user_id))
|
| 45 |
+
|
| 46 |
+
# creates the default 'admin' user if it does not already exist in the database
|
| 47 |
+
def create_admin():
|
| 48 |
+
with app.app_context():
|
| 49 |
+
db.create_all()
|
| 50 |
+
if not User.query.filter_by(username='admin').first():
|
| 51 |
+
hashed = generate_password_hash('admin123', method='pbkdf2:sha256')
|
| 52 |
+
db.session.add(User(username='admin', password=hashed))
|
| 53 |
+
db.session.commit()
|
| 54 |
+
print("✅ Admin Created: admin / admin123")
|
| 55 |
+
|
| 56 |
+
# 3. ROUTES
|
| 57 |
+
|
| 58 |
+
# route to display the login page template
|
| 59 |
+
@app.route('/login')
|
| 60 |
+
def login_page():
|
| 61 |
+
if current_user.is_authenticated:
|
| 62 |
+
return redirect(url_for('index'))
|
| 63 |
+
return render_template('login.html')
|
| 64 |
+
|
| 65 |
+
# route to display the main dashboard (requires login)
|
| 66 |
+
@app.route('/')
|
| 67 |
+
@login_required
|
| 68 |
+
def index():
|
| 69 |
+
return render_template('index.html')
|
| 70 |
+
|
| 71 |
+
# route to display the live map view (requires login)
|
| 72 |
+
@app.route('/map')
|
| 73 |
+
@login_required
|
| 74 |
+
def map_view():
|
| 75 |
+
return render_template('map.html')
|
| 76 |
+
|
| 77 |
+
# --- API ROUTES ---
|
| 78 |
+
# api endpoint for user authentication via POST request
|
| 79 |
+
@app.route('/api/login', methods=['POST'])
|
| 80 |
+
def login_api():
|
| 81 |
+
data = request.get_json()
|
| 82 |
+
user = User.query.filter_by(username=data.get('username')).first()
|
| 83 |
+
# checks hashed password and logs the user in on success
|
| 84 |
+
if user and check_password_hash(user.password, data.get('password')):
|
| 85 |
+
login_user(user)
|
| 86 |
+
return jsonify({"message": "Success"})
|
| 87 |
+
return jsonify({"message": "Invalid credentials"}), 401
|
| 88 |
+
|
| 89 |
+
# api endpoint for logging out the current user
|
| 90 |
+
@app.route('/api/logout')
|
| 91 |
+
@login_required
|
| 92 |
+
def logout():
|
| 93 |
+
logout_user()
|
| 94 |
+
return redirect(url_for('login_page'))
|
| 95 |
+
|
| 96 |
+
# api endpoint to fetch filtered, sorted, and paginated disaster posts
|
| 97 |
+
@app.route('/api/posts')
|
| 98 |
+
@login_required
|
| 99 |
+
def get_posts():
|
| 100 |
+
search_query = request.args.get('query')
|
| 101 |
+
sort_order = request.args.get('sort', 'newest')
|
| 102 |
+
view_mode = request.args.get('view', 'active')
|
| 103 |
+
urgency_filter = request.args.get('urgency', 'all')
|
| 104 |
+
type_filter = request.args.get('type', 'all')
|
| 105 |
+
assist_filter = request.args.get('assist', 'all')
|
| 106 |
+
|
| 107 |
+
posts_query = DisasterPost.query
|
| 108 |
+
posts_query = DisasterPost.query
|
| 109 |
+
|
| 110 |
+
# applies full-text search filter across title, content, and location
|
| 111 |
+
if search_query:
|
| 112 |
+
pattern = f"%{search_query}%"
|
| 113 |
+
posts_query = posts_query.filter(DisasterPost.title.ilike(pattern) | DisasterPost.content.ilike(pattern) | DisasterPost.location.ilike(pattern))
|
| 114 |
+
|
| 115 |
+
# --- MODIFIED VIEW MODE FILTER LOGIC ---
|
| 116 |
+
# filters to show only resolved/archived posts
|
| 117 |
+
if view_mode == 'archived':
|
| 118 |
+
posts_query = posts_query.filter(DisasterPost.status == 'Resolved')
|
| 119 |
+
|
| 120 |
+
# filters to show only active (New or Verified) posts
|
| 121 |
+
elif view_mode == 'active':
|
| 122 |
+
posts_query = posts_query.filter(DisasterPost.status.in_(['New', 'Verified']))
|
| 123 |
+
|
| 124 |
+
# applies filter based on the 'urgency_level' parameter
|
| 125 |
+
if urgency_filter != 'all': posts_query = posts_query.filter(DisasterPost.urgency_level == urgency_filter)
|
| 126 |
+
|
| 127 |
+
# applies sorting by timestamp (newest or oldest)
|
| 128 |
+
if sort_order == 'oldest': posts_query = posts_query.order_by(DisasterPost.timestamp.asc())
|
| 129 |
+
else: posts_query = posts_query.order_by(DisasterPost.timestamp.desc())
|
| 130 |
+
|
| 131 |
+
# applies filter based on the 'urgency_level' parameter
|
| 132 |
+
if urgency_filter != 'all': posts_query = posts_query.filter(DisasterPost.urgency_level == urgency_filter)
|
| 133 |
+
|
| 134 |
+
# applies filter based on the 'disaster_type' parameter
|
| 135 |
+
if type_filter != 'all': posts_query = posts_query.filter(DisasterPost.disaster_type == type_filter)
|
| 136 |
+
# applies filter based on the 'assistance_type' parameter
|
| 137 |
+
if assist_filter != 'all': posts_query = posts_query.filter(DisasterPost.assistance_type == assist_filter)
|
| 138 |
+
|
| 139 |
+
# returns the first 100 posts matching the filters as a JSON array
|
| 140 |
+
return jsonify([p.to_dict() for p in posts_query.limit(100).all()])
|
| 141 |
+
|
| 142 |
+
# api endpoint to update the status (New, Verified, Resolved) of a specific post ID
|
| 143 |
+
@app.route('/api/posts/<int:post_id>/status', methods=['POST'])
|
| 144 |
+
@login_required
|
| 145 |
+
def update_status(post_id):
|
| 146 |
+
data = request.get_json()
|
| 147 |
+
post = DisasterPost.query.get(post_id)
|
| 148 |
+
# finds the post and updates its 'status' field
|
| 149 |
+
if post:
|
| 150 |
+
post.status = data.get('status')
|
| 151 |
+
db.session.commit()
|
| 152 |
+
return jsonify({"success": True})
|
| 153 |
+
return jsonify({"error": "Not found"}), 404
|
| 154 |
+
|
| 155 |
+
# api endpoint to fetch statistics (counts by disaster type and urgency level) for charts
|
| 156 |
+
@app.route('/api/stats')
|
| 157 |
+
@login_required
|
| 158 |
+
def get_stats():
|
| 159 |
+
# queries the count of active posts grouped by disaster type
|
| 160 |
+
disaster_counts = db.session.query(DisasterPost.disaster_type, func.count(DisasterPost.id)).filter(DisasterPost.status.in_(['New', 'Verified'])).group_by(DisasterPost.disaster_type).all()
|
| 161 |
+
# queries the count of active posts grouped by urgency level
|
| 162 |
+
urgency_counts = db.session.query(DisasterPost.urgency_level, func.count(DisasterPost.id)).filter(DisasterPost.status.in_(['New', 'Verified'])).group_by(DisasterPost.urgency_level).all()
|
| 163 |
+
# returns both sets of counts as a single JSON object
|
| 164 |
+
return jsonify({"disaster_types": dict(disaster_counts), "urgency_levels": dict(urgency_counts)})
|
| 165 |
+
|
| 166 |
+
# api endpoint to export the full post database as a CSV file
|
| 167 |
+
@app.route('/api/export')
|
| 168 |
+
@login_required
|
| 169 |
+
def export_csv():
|
| 170 |
+
# queries ALL posts in the database, regardless of status
|
| 171 |
+
posts = DisasterPost.query.order_by(DisasterPost.timestamp.desc()).all()
|
| 172 |
+
|
| 173 |
+
output = io.StringIO()
|
| 174 |
+
writer = csv.writer(output)
|
| 175 |
+
|
| 176 |
+
# writes the header row with all necessary triage columns
|
| 177 |
+
writer.writerow([
|
| 178 |
+
'ID', 'Time', 'Location', 'Contact Number',
|
| 179 |
+
'Disaster Type', 'Assistance Type', 'Urgency', 'Status', 'Content'
|
| 180 |
+
])
|
| 181 |
+
|
| 182 |
+
# iterates through posts and writes data rows to the CSV output stream
|
| 183 |
+
for p in posts:
|
| 184 |
+
writer.writerow([
|
| 185 |
+
p.id,
|
| 186 |
+
p.timestamp,
|
| 187 |
+
p.location,
|
| 188 |
+
p.contact_number,
|
| 189 |
+
p.disaster_type,
|
| 190 |
+
p.assistance_type,
|
| 191 |
+
p.urgency_level,
|
| 192 |
+
p.status,
|
| 193 |
+
p.content
|
| 194 |
+
])
|
| 195 |
+
|
| 196 |
+
output.seek(0)
|
| 197 |
+
# returns the CSV data as an attachment for download
|
| 198 |
+
return Response(output, mimetype="text/csv", headers={"Content-Disposition": "attachment;filename=alisto_full_report.csv"})
|
| 199 |
+
|
| 200 |
+
# api endpoint to check the current user's authentication status and return their username
|
| 201 |
+
@app.route('/api/user_status')
|
| 202 |
+
def user_status():
|
| 203 |
+
if current_user.is_authenticated:
|
| 204 |
+
return jsonify({"is_logged_in": True, "username": current_user.username})
|
| 205 |
+
return jsonify({"is_logged_in": False})
|
| 206 |
+
|
| 207 |
+
# runs the application on host 0.0.0.0 and creates the admin user on startup
|
| 208 |
+
if __name__ == '__main__':
|
| 209 |
+
create_admin()
|
| 210 |
+
app.run(debug=True, port=5000)
|
alisto_project/backend/augment_data.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import os
|
| 3 |
+
import random
|
| 4 |
+
import re
|
| 5 |
+
import my_generator as gen
|
| 6 |
+
|
| 7 |
+
# Config
|
| 8 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 9 |
+
SEED_PATH = os.path.join(BASE_DIR, '../data/seed_data.txt')
|
| 10 |
+
CSV_PATH = os.path.join(BASE_DIR, '../data/reddit_disaster_posts.csv')
|
| 11 |
+
# sets the target total size of the final dataset
|
| 12 |
+
TOTAL_ROWS = 5000
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------
|
| 15 |
+
# 1. SLANG & AUGMENTATION LIBRARY
|
| 16 |
+
# ---------------------------------------------------------
|
| 17 |
+
# defines common Taglish words and their text-speak/slang equivalents
|
| 18 |
+
SLANG_MAP = {
|
| 19 |
+
"tulong": ["help", "saklolo", "tulong po", "help pls"],
|
| 20 |
+
"kami": ["kmi", "kme", "tayo"],
|
| 21 |
+
"dito": ["d2", "dto", "here"],
|
| 22 |
+
"baha": ["flood", "tubig", "pagbaha"],
|
| 23 |
+
"rescue": ["save us", "pasundo", "saklolo"],
|
| 24 |
+
"please": ["pls", "plz", "paki", "parang awa nyo na"],
|
| 25 |
+
"wala": ["la", "wla", "zero"],
|
| 26 |
+
"sa": ["s", "sa may"],
|
| 27 |
+
"ang": ["ung", "yung", "ang"],
|
| 28 |
+
"hindi": ["di", "d", "hndi"],
|
| 29 |
+
"kayo": ["kau", "nyo"],
|
| 30 |
+
"may": ["meron", "my"],
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
# replaces common words with text-speak or slang based on SLANG_MAP
|
| 34 |
+
def apply_slang(text):
|
| 35 |
+
words = text.split()
|
| 36 |
+
new_words = []
|
| 37 |
+
for word in words:
|
| 38 |
+
lower_word = word.lower().replace(".", "").replace("!", "")
|
| 39 |
+
if lower_word in SLANG_MAP and random.random() > 0.5:
|
| 40 |
+
new_words.append(random.choice(SLANG_MAP[lower_word]))
|
| 41 |
+
else:
|
| 42 |
+
new_words.append(word)
|
| 43 |
+
return " ".join(new_words)
|
| 44 |
+
|
| 45 |
+
# randomly swaps two adjacent words to simulate panic typing errors
|
| 46 |
+
def shuffle_sentence(text):
|
| 47 |
+
words = text.split()
|
| 48 |
+
if len(words) > 3:
|
| 49 |
+
idx = random.randint(0, len(words) - 2)
|
| 50 |
+
words[idx], words[idx+1] = words[idx+1], words[idx]
|
| 51 |
+
return " ".join(words)
|
| 52 |
+
|
| 53 |
+
# adds formatting noise (random caps, repetition, punctuation spam) to simulate distress
|
| 54 |
+
def add_noise(text):
|
| 55 |
+
# 1. Random Caps
|
| 56 |
+
if random.random() > 0.7:
|
| 57 |
+
text = text.upper()
|
| 58 |
+
elif random.random() > 0.7:
|
| 59 |
+
text = text.lower()
|
| 60 |
+
|
| 61 |
+
# 2. Urgent Repetition (for positives)
|
| 62 |
+
if "help" in text.lower() or "tulong" in text.lower():
|
| 63 |
+
if random.random() > 0.7:
|
| 64 |
+
text = text + " TULONG!"
|
| 65 |
+
|
| 66 |
+
# 3. Punctuation Spam
|
| 67 |
+
if random.random() > 0.6:
|
| 68 |
+
text += "!!" if random.random() > 0.5 else "..."
|
| 69 |
+
|
| 70 |
+
return text
|
| 71 |
+
|
| 72 |
+
# creates multiple variations of a single input text using all augmentation methods
|
| 73 |
+
def generate_variations(text, num_variations=3):
|
| 74 |
+
variations = [text] # Keep original
|
| 75 |
+
|
| 76 |
+
for _ in range(num_variations):
|
| 77 |
+
# Method A: Slang
|
| 78 |
+
var = apply_slang(text)
|
| 79 |
+
# Method B: Noise
|
| 80 |
+
var = add_noise(var)
|
| 81 |
+
# Method C: Shuffle (rarely)
|
| 82 |
+
if random.random() > 0.8:
|
| 83 |
+
var = shuffle_sentence(var)
|
| 84 |
+
|
| 85 |
+
variations.append(var)
|
| 86 |
+
|
| 87 |
+
return list(set(variations)) # Unique only
|
| 88 |
+
|
| 89 |
+
# ---------------------------------------------------------
|
| 90 |
+
# 2. CORE LOGIC
|
| 91 |
+
# ---------------------------------------------------------
|
| 92 |
+
# cleans and loads the initial seed data from the text file
|
| 93 |
+
def clean_and_load_seed_data(filepath):
|
| 94 |
+
if not os.path.exists(filepath):
|
| 95 |
+
print(f"⚠️ Warning: {filepath} not found.")
|
| 96 |
+
return []
|
| 97 |
+
|
| 98 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 99 |
+
content = f.read()
|
| 100 |
+
|
| 101 |
+
# removes internal tags or bracketed information
|
| 102 |
+
pattern = re.compile(r"\[.*?\]")
|
| 103 |
+
content = pattern.sub("", content)
|
| 104 |
+
|
| 105 |
+
clean_rows = []
|
| 106 |
+
raw_lines = content.split('\n')
|
| 107 |
+
buffer = ""
|
| 108 |
+
|
| 109 |
+
for line in raw_lines:
|
| 110 |
+
line = line.strip()
|
| 111 |
+
if not line: continue
|
| 112 |
+
|
| 113 |
+
# checks for the classification label at the end of the line
|
| 114 |
+
if line.endswith('|0') or line.endswith('|1'):
|
| 115 |
+
full_line = (buffer + " " + line).strip()
|
| 116 |
+
try:
|
| 117 |
+
text, label = full_line.rsplit('|', 1)
|
| 118 |
+
clean_rows.append({'text': text.strip(), 'label': int(label)})
|
| 119 |
+
except: pass
|
| 120 |
+
buffer = ""
|
| 121 |
+
else:
|
| 122 |
+
buffer += line + " "
|
| 123 |
+
|
| 124 |
+
return clean_rows
|
| 125 |
+
|
| 126 |
+
# main function to orchestrate the data augmentation and saving process
|
| 127 |
+
def create_database():
|
| 128 |
+
print("--- ALISTO: Phase 2 Data Augmentation (Lean & Mean Mode) ---")
|
| 129 |
+
|
| 130 |
+
# 1. Load Seed Data
|
| 131 |
+
seed_rows = clean_and_load_seed_data(SEED_PATH)
|
| 132 |
+
print(f"🌱 Loaded {len(seed_rows)} original seed rows.")
|
| 133 |
+
|
| 134 |
+
# 2. MULTIPLY SEED DATA
|
| 135 |
+
final_rows = []
|
| 136 |
+
print("🧬 Cloning and mutating seed data...")
|
| 137 |
+
# generates multiple variations for each original seed row
|
| 138 |
+
for row in seed_rows:
|
| 139 |
+
# Generate 8 variations per real row
|
| 140 |
+
variations = generate_variations(row['text'], num_variations=8)
|
| 141 |
+
for var in variations:
|
| 142 |
+
final_rows.append({'text': var, 'label': row['label']})
|
| 143 |
+
|
| 144 |
+
print(f" ↳ Expanded seed data to {len(final_rows)} rows.")
|
| 145 |
+
|
| 146 |
+
# 3. Fill the rest with Synthetic Templates
|
| 147 |
+
# calculates how many synthetic rows are needed to meet the TOTAL_ROWS target
|
| 148 |
+
remaining = TOTAL_ROWS - len(final_rows)
|
| 149 |
+
|
| 150 |
+
# generates synthetic positive and negative posts using my_generator
|
| 151 |
+
if remaining > 0:
|
| 152 |
+
print(f"🤖 Generating {remaining} TRICKY synthetic rows to fill dataset...")
|
| 153 |
+
for _ in range(remaining // 2):
|
| 154 |
+
final_rows.append({'text': add_noise(gen.build_positive()), 'label': 1})
|
| 155 |
+
final_rows.append({'text': add_noise(gen.build_negative()), 'label': 0})
|
| 156 |
+
else:
|
| 157 |
+
print("🤖 Seed data expansion is sufficient. Skipping synthetic generation.")
|
| 158 |
+
|
| 159 |
+
# 4. Save
|
| 160 |
+
df = pd.DataFrame(final_rows)
|
| 161 |
+
# shuffles the dataset and removes duplicates before saving
|
| 162 |
+
final_df = df.sample(frac=1).reset_index(drop=True)
|
| 163 |
+
final_df.drop_duplicates(subset=['text'], inplace=True)
|
| 164 |
+
|
| 165 |
+
# saves the final dataset to a CSV file
|
| 166 |
+
final_df.to_csv(CSV_PATH, index=False)
|
| 167 |
+
print(f"✅ Success! Saved {len(final_df)} rows to {CSV_PATH}")
|
| 168 |
+
print(" (Note: Dataset is optimized for quality over quantity)")
|
| 169 |
+
|
| 170 |
+
# executes the main function when the script is run
|
| 171 |
+
if __name__ == "__main__":
|
| 172 |
+
create_database()
|
alisto_project/backend/images/alert.png
ADDED
|
alisto_project/backend/images/alert1.png
ADDED
|
alisto_project/backend/images/bg.jpg
ADDED
|
Git LFS Details
|
alisto_project/backend/images/bg1.jpg
ADDED
|
Git LFS Details
|
alisto_project/backend/images/bg2-bw.jpg
ADDED
|
Git LFS Details
|
alisto_project/backend/images/bg2-logan.jpg
ADDED
|
Git LFS Details
|
alisto_project/backend/images/bg2.jpg
ADDED
|
Git LFS Details
|
alisto_project/backend/images/earthquake.png
ADDED
|
alisto_project/backend/images/earthquake1.png
ADDED
|
alisto_project/backend/index.html
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>ALISTO</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700;900&family=Roboto:wght@400;500&display=swap" rel="stylesheet" />
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 9 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 10 |
+
<link rel="stylesheet" href="style.css">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div id="login-modal" class="modal-overlay hidden">
|
| 14 |
+
<div class="modal-content small-modal">
|
| 15 |
+
<div class="modal-header">
|
| 16 |
+
<h2>Responder Login</h2>
|
| 17 |
+
<button id="close-login-btn" class="icon-btn-ghost"><i class="fa-solid fa-xmark"></i></button>
|
| 18 |
+
</div>
|
| 19 |
+
<div class="login-form">
|
| 20 |
+
<input type="text" id="username" placeholder="Username" class="login-input">
|
| 21 |
+
<input type="password" id="password" placeholder="Password" class="login-input">
|
| 22 |
+
<button id="login-submit-btn" class="action-btn resolve-btn full-width">Log In</button>
|
| 23 |
+
<p id="login-error" style="color: #ff4444; margin-top: 10px; font-size: 0.9em;"></p>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div id="logout-modal" class="modal-overlay hidden">
|
| 29 |
+
<div class="modal-content" style="max-width: 400px; text-align: center;">
|
| 30 |
+
<div class="modal-header" style="justify-content: center; margin-bottom: 10px;">
|
| 31 |
+
<h2 style="color: #ed4801;">Confirm Logout</h2>
|
| 32 |
+
</div>
|
| 33 |
+
<p style="color: #ccc; margin-bottom: 30px;">Are you sure you want to end your session?</p>
|
| 34 |
+
|
| 35 |
+
<div class="action-buttons" style="justify-content: center; gap: 15px;">
|
| 36 |
+
<button id="cancel-logout-btn" class="action-btn" style="background: #444; padding: 10px 20px;">Cancel</button>
|
| 37 |
+
<button id="confirm-logout-btn" class="action-btn confirm-btn" style="padding: 10px 20px;">Yes, Logout</button>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div id="new-alert-notification" class="alert-popup hidden">
|
| 43 |
+
<i class="fa-solid fa-bell"></i>
|
| 44 |
+
<span id="notification-message">New URGENT Alert Received!</span>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<audio id="alert-sound" src="https://assets.mixkit.co/active_storage/sfx/2869/2869-preview.mp3" preload="auto"></audio>
|
| 48 |
+
|
| 49 |
+
<div id="stats-modal" class="modal-overlay hidden">
|
| 50 |
+
<div class="modal-content">
|
| 51 |
+
<div class="modal-header">
|
| 52 |
+
<h2>Situation Report</h2>
|
| 53 |
+
<button id="close-stats-btn" class="icon-btn-ghost"><i class="fa-solid fa-xmark"></i></button>
|
| 54 |
+
</div>
|
| 55 |
+
<div class="charts-container">
|
| 56 |
+
<div class="chart-wrapper">
|
| 57 |
+
<h3>Active Incidents by Type</h3>
|
| 58 |
+
<canvas id="typeChart"></canvas>
|
| 59 |
+
</div>
|
| 60 |
+
<div class="chart-wrapper">
|
| 61 |
+
<h3>Urgency Breakdown</h3>
|
| 62 |
+
<canvas id="urgencyChart"></canvas>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<header class="main-header">
|
| 69 |
+
<div class="logo">
|
| 70 |
+
<span class="logo-main">ALISTO</span>
|
| 71 |
+
<span class="logo-sub">Alert System</span>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div class="search-container">
|
| 75 |
+
<i class="fa-solid fa-xmark clear-icon hidden"></i>
|
| 76 |
+
<input type="text" placeholder="Search incidents..." class="search-input">
|
| 77 |
+
<i class="fa-solid fa-magnifying-glass search-icon clickable" data_tooltip="Search"></i>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<nav class="main-nav">
|
| 81 |
+
<a href="index.html" class="nav-link active" data_tooltip="Go to Posts">Dashboard</a>
|
| 82 |
+
<a href="map.html" class="nav-link" data_tooltip="Go to Live Map">Live Map</a>
|
| 83 |
+
<a href="#" id="nav-login-btn" class="nav-link" style="color: #ed4801;">Login</a>
|
| 84 |
+
|
| 85 |
+
<div class="profile-container" id="profile-container-wrap" style="display: none;">
|
| 86 |
+
|
| 87 |
+
<div id="profile-toggle" class="profile-icon">
|
| 88 |
+
<i class="fa-solid fa-user"></i>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<div id="profile-dropdown" class="profile-dropdown hidden">
|
| 92 |
+
<div class="profile-info-header">
|
| 93 |
+
<div class="profile-icon-large">
|
| 94 |
+
<i class="fa-solid fa-user"></i>
|
| 95 |
+
</div>
|
| 96 |
+
<div id="dropdown-username" class="dropdown-username">Officer ID</div>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<hr class="dropdown-separator">
|
| 100 |
+
|
| 101 |
+
<button id="dropdown-logout-btn" class="dropdown-logout-btn">
|
| 102 |
+
<i class="fa-solid fa-right-from-bracket"></i> Logout
|
| 103 |
+
</button>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
</nav>
|
| 107 |
+
</header>
|
| 108 |
+
|
| 109 |
+
<section class="hero">
|
| 110 |
+
|
| 111 |
+
<div class="sidebar-wrapper">
|
| 112 |
+
<div class="filter-bar">
|
| 113 |
+
|
| 114 |
+
<button id="show-stats-btn" class="pulse-btn" data_tooltip="View Real-time Alert Statistics" data_tooltip_align="left">
|
| 115 |
+
<i class="fa-solid fa-chart-pie"></i>
|
| 116 |
+
</button>
|
| 117 |
+
|
| 118 |
+
<div class="separator"></div>
|
| 119 |
+
|
| 120 |
+
<div class="filter-bar-c">
|
| 121 |
+
<div class="filter-bar-r">
|
| 122 |
+
|
| 123 |
+
<div class="filter-wrap1" data_tooltip="Sort alerts by Time">
|
| 124 |
+
<select id="sort-select" class="filter-select">
|
| 125 |
+
<option value="newest" title="Hi">Newest</option>
|
| 126 |
+
<option value="oldest">Oldest</option>
|
| 127 |
+
</select>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
<div class="filter-wrap2" data_tooltip="Filter by Urgency Level">
|
| 131 |
+
<select id="urgency-select" class="filter-select">
|
| 132 |
+
<option value="all">All Levels</option>
|
| 133 |
+
<option value="High">High</option>
|
| 134 |
+
<option value="Medium">Medium</option>
|
| 135 |
+
<option value="Low">Low</option>
|
| 136 |
+
</select>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
<div class="filter-wrap3" data_tooltip="Filter alerts by status">
|
| 140 |
+
<select id="view-select" class="filter-select">
|
| 141 |
+
<option value="all">All Status</option>
|
| 142 |
+
<option value="active">Active</option>
|
| 143 |
+
<!-- <option value="active">Verified</option> -->
|
| 144 |
+
<option value="archived">Resolved</option>
|
| 145 |
+
</select>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<div class="filter-bar-r">
|
| 150 |
+
<div class="filter-wrap4" data_tooltip="Filter by Disaster Type">
|
| 151 |
+
<select id="type-select" class="filter-select">
|
| 152 |
+
<option value="all">All Disaster Types</option>
|
| 153 |
+
<option value="Flood">Flood</option>
|
| 154 |
+
<option value="Typhoon">Typhoon</option>
|
| 155 |
+
<option value="Earthquake">Earthquake</option>
|
| 156 |
+
<option value="Fire">Fire</option>
|
| 157 |
+
<option value="Volcano">Volcano</option>
|
| 158 |
+
<option value="Landslide">Landslide</option>
|
| 159 |
+
<option value="General Emergency">General</option>
|
| 160 |
+
</select>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div class="filter-wrap5" data_tooltip="Filter by Assistance Needed">
|
| 164 |
+
<select id="assist-select" class="filter-select">
|
| 165 |
+
<option value="all">All Assistances</option>
|
| 166 |
+
<option value="Rescue">Rescue</option>
|
| 167 |
+
<option value="Medical">Medical</option>
|
| 168 |
+
<option value="Evacuation">Evacuation</option>
|
| 169 |
+
<option value="Food/Water">Food/Water</option>
|
| 170 |
+
<option value="General Assistance">General</option>
|
| 171 |
+
</select>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
<button id="export-btn" class="icon-btn" data_tooltip="Download Report (CSV)">
|
| 177 |
+
<i class="fa-solid fa-download"></i>
|
| 178 |
+
</button>
|
| 179 |
+
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
<div class="sidebar" id="incident-feed">
|
| 183 |
+
<p style="color: white; padding: 20px; text-align: center;">Loading alerts...</p>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<div class="active-alert-counter">
|
| 187 |
+
<span style="font-size: 0.9em; color: #ccc;">Active Alerts: </span>
|
| 188 |
+
<span id="dashboard-alert-count" style="font-weight: 700; color: #ffc107;">0</span>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<div class="detail-box" id="postInfo">
|
| 194 |
+
<div class="detail-header-r">
|
| 195 |
+
<h2 class="detail-title" id="detail-title">Select an Alert</h2>
|
| 196 |
+
|
| 197 |
+
<div class="action-buttons">
|
| 198 |
+
<button id="verify-btn" class="action-btn1 verify-btn" onclick="updateStatus('Verified')" data_tooltip="Mark this alert as Verified (Confirmed)">
|
| 199 |
+
<i class="fa-solid fa-check"></i>
|
| 200 |
+
<span class="btn-text">Verify</span>
|
| 201 |
+
</button>
|
| 202 |
+
<button id="resolve-btn" class="action-btn1 resolve-btn" onclick="updateStatus('Resolved')" data_tooltip="Mark this alert as Resolved and dismiss">
|
| 203 |
+
<i class="fa-solid fa-box-archive"></i>
|
| 204 |
+
<span class="btn-text">Resolve</span>
|
| 205 |
+
</button>
|
| 206 |
+
<a href="#" id="detail-link" target="_blank" class="detail-redirect" data_tooltip="Go to Reddit Post">
|
| 207 |
+
<i class="fa-brands fa-reddit-alien"></i> Post
|
| 208 |
+
</a>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
<hr class="detail-line">
|
| 213 |
+
|
| 214 |
+
<div class="detail-important-r">
|
| 215 |
+
<div class="detail-row1-c">
|
| 216 |
+
<div>
|
| 217 |
+
<div class="detail-label" style="margin-top: 10px;">Assistance Needed</div>
|
| 218 |
+
<div id="detail-assistance" style="color: #ed4801; font-weight: bold; text-transform: uppercase;">-</div>
|
| 219 |
+
</div>
|
| 220 |
+
<div>
|
| 221 |
+
<div class="detail-label">Location / Address</div>
|
| 222 |
+
<span id="detail-location" style="color: white;">-</span>
|
| 223 |
+
</div>
|
| 224 |
+
|
| 225 |
+
<div class="detail-row1-col2-r">
|
| 226 |
+
<div class="contact-c">
|
| 227 |
+
<div class="detail-label">Contact Person</div>
|
| 228 |
+
<span id="detail-contact-name" style="color: white;">-</span>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
<div class="contact-c">
|
| 232 |
+
<div class="detail-label">Contact Number</div>
|
| 233 |
+
<span id="detail-contact-number" style="color: white;">-</span>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
|
| 238 |
+
<div style="text-align: right;">
|
| 239 |
+
<div class="detail-label">Posted</div>
|
| 240 |
+
<div class="time" id="detail-time">_ mins ago at __:__ __</div>
|
| 241 |
+
|
| 242 |
+
<!-- <div class="detail-label" style="margin-top: 10px;">Assistance Needed</div>
|
| 243 |
+
<div id="detail-assistance" style="color: #ed4801; font-weight: bold; text-transform: uppercase;">-</div> -moved above -->
|
| 244 |
+
|
| 245 |
+
<div class="detail-label" style="margin-top: 10px;">Status</div>
|
| 246 |
+
<div id="detail-urgency" style="color: white;">-</div>
|
| 247 |
+
<div id="detail-status" class="status-badge">New</div>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
<div class="detail-content" id="detail-body">
|
| 252 |
+
Click on an alert from the sidebar to view full details here.
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
</section>
|
| 257 |
+
|
| 258 |
+
<script src="script.js"></script>
|
| 259 |
+
</body>
|
| 260 |
+
</html>
|
alisto_project/backend/ingest_reddit.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncpraw
|
| 2 |
+
import asyncio
|
| 3 |
+
import os
|
| 4 |
+
import torch
|
| 5 |
+
import pickle
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch.nn.functional as F
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
from flask import Flask
|
| 11 |
+
from models import db, DisasterPost
|
| 12 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 13 |
+
from ner_extractor import extract_entities
|
| 14 |
+
|
| 15 |
+
# 1. Config & Setup
|
| 16 |
+
# defines the subreddits to be monitored by the scraper
|
| 17 |
+
SUBREDDITS = "AlistoSimulation"
|
| 18 |
+
# SUBREDDITS = "Philippines+NaturalDisasters+DisasterUpdatePH+Assistance+Typhoon+AlistoSimulation"
|
| 19 |
+
|
| 20 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 21 |
+
# loads environment variables from .env file
|
| 22 |
+
load_dotenv(os.path.join(BASE_DIR, '../.env'))
|
| 23 |
+
|
| 24 |
+
# initializes the Flask application context for database access
|
| 25 |
+
app = Flask(__name__)
|
| 26 |
+
DB_PATH = os.path.join(BASE_DIR, 'alisto.db')
|
| 27 |
+
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_PATH}'
|
| 28 |
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 29 |
+
# sets a timeout for stable database connection
|
| 30 |
+
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {'connect_args': {'timeout': 15}}
|
| 31 |
+
db.init_app(app)
|
| 32 |
+
|
| 33 |
+
# 2. Load Models
|
| 34 |
+
print("Loading ALISTO Brains...")
|
| 35 |
+
MODEL_DIR = os.path.join(BASE_DIR, 'models')
|
| 36 |
+
ROBERTA_DIR = os.path.join(MODEL_DIR, 'roberta_model')
|
| 37 |
+
TFIDF_PATH = os.path.join(MODEL_DIR, 'tfidf_ensemble.pkl')
|
| 38 |
+
|
| 39 |
+
# A. RoBERTa (XLM-R Multilingual)
|
| 40 |
+
# loads the RoBERTa tokenizer and sequence classification model (Context Expert)
|
| 41 |
+
try:
|
| 42 |
+
tokenizer = AutoTokenizer.from_pretrained(ROBERTA_DIR)
|
| 43 |
+
roberta_model = AutoModelForSequenceClassification.from_pretrained(ROBERTA_DIR)
|
| 44 |
+
device = torch.device("cpu") # determines the device (CPU/GPU) for model execution
|
| 45 |
+
roberta_model.to(device)
|
| 46 |
+
roberta_model.eval()
|
| 47 |
+
print("✅ Context Expert (XLM-R) loaded")
|
| 48 |
+
except Exception as e:
|
| 49 |
+
print(f"❌ Error loading RoBERTa: {e}")
|
| 50 |
+
exit()
|
| 51 |
+
|
| 52 |
+
# B. TF-IDF (The Gatekeeper)
|
| 53 |
+
# loads the pre-trained TF-IDF vectorizer and ensemble model (Gatekeeper)
|
| 54 |
+
try:
|
| 55 |
+
with open(TFIDF_PATH, 'rb') as f:
|
| 56 |
+
tfidf_model = pickle.load(f)
|
| 57 |
+
print("✅ Gatekeeper (TF-IDF) loaded")
|
| 58 |
+
except Exception as e:
|
| 59 |
+
print(f"❌ Error loading TF-IDF: {e}")
|
| 60 |
+
tfidf_model = None
|
| 61 |
+
|
| 62 |
+
# 3. Reference Lists (Kept from your original)
|
| 63 |
+
# list of Philippine locations used for basic geo-validation
|
| 64 |
+
PHILIPPINE_LOCATIONS = [
|
| 65 |
+
"Philippines", "PH", "Luzon", "Visayas", "Mindanao", "Metro Manila", "NCR",
|
| 66 |
+
"Manila", "Quezon City", "Makati", "Taguig", "Pasig", "Mandaluyong",
|
| 67 |
+
"Marikina", "Las Pinas", "Las Piñas", "Muntinlupa", "Caloocan",
|
| 68 |
+
"Paranaque", "Parañaque", "Valenzuela", "Pasay", "Malabon",
|
| 69 |
+
"Navotas", "San Juan", "Pateros",
|
| 70 |
+
"Cavite", "Naic", "Bacoor", "Imus", "Dasmarinas", "Dasmariñas",
|
| 71 |
+
"General Trias", "Tagaytay", "Kawit", "Noveleta", "Rosario", "Tanza",
|
| 72 |
+
"Silang", "Trece Martires", "Laguna", "Calamba", "Santa Rosa", "Binan",
|
| 73 |
+
"Biñan", "San Pedro", "Cabuyao", "Los Banos", "Los Baños", "Rizal",
|
| 74 |
+
"Antipolo", "Cainta", "Taytay", "San Mateo", "Binangonan", "Batangas",
|
| 75 |
+
"Bulacan", "Pampanga", "Tarlac", "Cebu", "Iloilo", "Tacloban",
|
| 76 |
+
"Davao", "Cagayan", "Bicol", "Albay", "Isabela"
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
# function to process a single Reddit submission through all filters and save it
|
| 80 |
+
async def process_post(post):
|
| 81 |
+
"""handles logic for a single Reddit submission (filtering, AI, saving)"""
|
| 82 |
+
try:
|
| 83 |
+
full_text = f"{post.title} {post.selftext}"
|
| 84 |
+
|
| 85 |
+
# A. Check for Duplicates & Credibility (Unchanged logic)
|
| 86 |
+
# checks for existing post ID in the database
|
| 87 |
+
with app.app_context():
|
| 88 |
+
exists = DisasterPost.query.filter_by(reddit_id=post.id).first()
|
| 89 |
+
if exists: return
|
| 90 |
+
# blocks posts from suspicious new/low-karma accounts
|
| 91 |
+
if not is_credible_user(post):
|
| 92 |
+
print(f"\n------------------- DEBUG REJECTION -------------------")
|
| 93 |
+
print(f"❌ REJECTED POST ID: {post.id} (Title: {post.title[:30]})")
|
| 94 |
+
print(f"REASON: Credibility Check (Account too new/Low Karma)")
|
| 95 |
+
print(f"---------------------------------------------------------\n")
|
| 96 |
+
return
|
| 97 |
+
|
| 98 |
+
# B. Logic Filter (First Defense) (Unchanged logic)
|
| 99 |
+
# runs simple keyword checks to filter news/financial/irrelevant content
|
| 100 |
+
is_bad, reason = is_news_or_irrelevant(full_text)
|
| 101 |
+
if is_bad:
|
| 102 |
+
print(f"\n------------------- DEBUG REJECTION -------------------")
|
| 103 |
+
print(f"❌ REJECTED POST ID: {post.id} (Title: {post.title[:30]})")
|
| 104 |
+
print(f"REASON: Logic Filter (Common Sense Layer) Categorized as: {reason}")
|
| 105 |
+
print(f"---------------------------------------------------------\n")
|
| 106 |
+
return
|
| 107 |
+
|
| 108 |
+
# C. AI Analysis (Unchanged logic)
|
| 109 |
+
# runs the cascade AI check (TF-IDF then RoBERTa)
|
| 110 |
+
is_urgent, score, source = predict_urgency(full_text)
|
| 111 |
+
if not is_urgent:
|
| 112 |
+
print(f"\n------------------- DEBUG REJECTION -------------------")
|
| 113 |
+
print(f"❌ REJECTED POST ID: {post.id} (Title: {post.title[:30]})")
|
| 114 |
+
print(f"REASON: AI Confidence too low Score: {score:.2%} (Source: {source})")
|
| 115 |
+
print(f"---------------------------------------------------------\n")
|
| 116 |
+
return
|
| 117 |
+
|
| 118 |
+
# D. Entity Extraction
|
| 119 |
+
# extracts location, contact number, and contact person name
|
| 120 |
+
ner_results = extract_entities(full_text)
|
| 121 |
+
locations = ner_results.get('locations', [])
|
| 122 |
+
contact_num = ner_results.get('contact', None)
|
| 123 |
+
contact_person_name = ner_results.get('contact_person_name', None)
|
| 124 |
+
|
| 125 |
+
# E. Final Triage and Data Preparation
|
| 126 |
+
# assigns location and determines disaster/assistance type
|
| 127 |
+
location = locations[0] if locations else "Unknown Location"
|
| 128 |
+
disaster_type = get_disaster_type(full_text)
|
| 129 |
+
assistance_type = get_assistance_type(full_text)
|
| 130 |
+
|
| 131 |
+
# 1. Calculate Dynamic Urgency (NEW)
|
| 132 |
+
# assigns High, Medium, or Low urgency based on severity keywords
|
| 133 |
+
dynamic_urgency = assign_dynamic_urgency(full_text)
|
| 134 |
+
|
| 135 |
+
# 2. Finalize Author (Fallback Logic)
|
| 136 |
+
# defaults to Reddit username if no contact name is explicitly extracted
|
| 137 |
+
reddit_username = str(post.author) if post.author else "Unknown"
|
| 138 |
+
final_author = contact_person_name if contact_person_name else reddit_username
|
| 139 |
+
|
| 140 |
+
# 3. Print Final Alert Confirmation
|
| 141 |
+
print(f"""------------------- ALERT SAVED -------------------\n🚨 ALERT ({score:.2%}): {disaster_type} in {location} Urgency: {dynamic_urgency} \n---------------------------------------------------------""")
|
| 142 |
+
|
| 143 |
+
# F. Single Database Creation and Commit
|
| 144 |
+
# creates and commits the final DisasterPost object to the database
|
| 145 |
+
new_post = DisasterPost(
|
| 146 |
+
reddit_id=post.id,
|
| 147 |
+
title=post.title,
|
| 148 |
+
content=post.selftext or post.title,
|
| 149 |
+
author=final_author,
|
| 150 |
+
location=location,
|
| 151 |
+
contact_number=contact_num,
|
| 152 |
+
disaster_type=disaster_type,
|
| 153 |
+
assistance_type=assistance_type,
|
| 154 |
+
urgency_level=dynamic_urgency,
|
| 155 |
+
is_help_request=True,
|
| 156 |
+
timestamp=datetime.utcfromtimestamp(post.created_utc)
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
with app.app_context():
|
| 160 |
+
db.session.add(new_post)
|
| 161 |
+
db.session.commit()
|
| 162 |
+
|
| 163 |
+
except Exception as e:
|
| 164 |
+
print(f"Post Processing Error for {post.id}: {e}")
|
| 165 |
+
|
| 166 |
+
# validates if the extracted location is relevant to the Philippines
|
| 167 |
+
def check_for_philippine_location(location_list):
|
| 168 |
+
if not location_list: return False
|
| 169 |
+
ph_locations = [loc.lower() for loc in PHILIPPINE_LOCATIONS]
|
| 170 |
+
for extracted_loc in location_list:
|
| 171 |
+
# Check partial match (e.g., "Marikina City" matches "Marikina")
|
| 172 |
+
for known_loc in ph_locations:
|
| 173 |
+
if known_loc in extracted_loc.lower() or extracted_loc.lower() in known_loc:
|
| 174 |
+
return True
|
| 175 |
+
return False
|
| 176 |
+
|
| 177 |
+
# classifies the type of disaster based on severity keywords
|
| 178 |
+
def get_disaster_type(text):
|
| 179 |
+
text_lower = text.lower()
|
| 180 |
+
mapping = {
|
| 181 |
+
"Earthquake": ["quake", "lindol", "shake", "aftershock"],
|
| 182 |
+
"Landslide": ["landslide", "guho", "mudslide", "natabunan"],
|
| 183 |
+
"Volcano": ["volcano", "lava", "ash", "magma", "taal", "mayon"],
|
| 184 |
+
"Fire": ["fire", "sunog", "burn", "smoke"],
|
| 185 |
+
"Typhoon": ["typhoon", "bagyo", "storm", "wind", "signal", "ulysses", "odette"],
|
| 186 |
+
"Flood": ["flood", "baha", "water", "river", "drown", "lubog", "taas ng tubig"]
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
for dtype, keywords in mapping.items():
|
| 190 |
+
if any(k in text_lower for k in keywords):
|
| 191 |
+
return dtype
|
| 192 |
+
return "General Emergency"
|
| 193 |
+
|
| 194 |
+
# classifies the specific type of assistance needed (e.g., Medical, Rescue, Food)
|
| 195 |
+
def get_assistance_type(text):
|
| 196 |
+
"""determines the specific help needed using Nested Priority"""
|
| 197 |
+
text = text.lower()
|
| 198 |
+
|
| 199 |
+
# --- 1. IMMEDIATE RESCUE (Life Threatening) ---
|
| 200 |
+
rescue_kw = [
|
| 201 |
+
"rescue", "saklolo", "trapped", "stuck", "stranded",
|
| 202 |
+
"bubong", "roof", "boat", "bangka", "drowning", "lunod",
|
| 203 |
+
"di makalabas", "unable to leave"
|
| 204 |
+
]
|
| 205 |
+
if any(k in text for k in rescue_kw):
|
| 206 |
+
|
| 207 |
+
critical_medical_override_kw = [
|
| 208 |
+
"bleeding", "unconscious", "head injury", "head wound",
|
| 209 |
+
"severely bleeding", "stroke", "heart attack", "trauma"
|
| 210 |
+
]
|
| 211 |
+
if any(k in text for k in critical_medical_override_kw):
|
| 212 |
+
return "Medical"
|
| 213 |
+
|
| 214 |
+
return "Rescue" # if no critical medical keywords found
|
| 215 |
+
|
| 216 |
+
# --- 2. MEDICAL (Specific Needs/Ambulance) ---
|
| 217 |
+
# handles standalone medical needs if no rescue keywords were found
|
| 218 |
+
medical_kw = [
|
| 219 |
+
"medical", "doctor", "gamot", "medicine", "insulin", "dialysis",
|
| 220 |
+
"hospital", "oxygen", "pregnant", "labor", "manganganak", "ambulance",
|
| 221 |
+
"first aid", "pills", "medication"
|
| 222 |
+
]
|
| 223 |
+
if any(k in text for k in medical_kw):
|
| 224 |
+
return "Medical"
|
| 225 |
+
|
| 226 |
+
# --- 3. EVACUATION (Shelter/Transport) ---
|
| 227 |
+
# classifies the need for temporary shelter or transport
|
| 228 |
+
evac_kw = [
|
| 229 |
+
"evacuate", "evacuation", "shelter", "center", "likas", "tents",
|
| 230 |
+
"matutuluyan", "alis", "transportation", "walang matutuluyan"
|
| 231 |
+
]
|
| 232 |
+
if any(k in text for k in evac_kw):
|
| 233 |
+
return "Evacuation"
|
| 234 |
+
|
| 235 |
+
# --- 4. FOOD & WATER (Logistics) ---
|
| 236 |
+
# classifies the need for essential supplies (food, water, formula)
|
| 237 |
+
food_kw = [
|
| 238 |
+
"food", "pagkain", "water", "tubig", "gutom", "hungry", "relief",
|
| 239 |
+
"goods", "makakain", "inumin", "groceries", "supplies", "supply", "wala ng stock",
|
| 240 |
+
"gatas", "milk", "formula", "baby supplies", "ubos na", "wala na", "stock", "stock ng"
|
| 241 |
+
]
|
| 242 |
+
if any(k in text for k in food_kw):
|
| 243 |
+
return "Food/Water"
|
| 244 |
+
|
| 245 |
+
return "General Assistance"
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# --- LOGIC FILTERS (The "Common Sense" Layer) ---
|
| 249 |
+
# runs simple logic checks to filter out news reports and non-urgent context
|
| 250 |
+
def is_news_or_irrelevant(text):
|
| 251 |
+
text_lower = text.lower()
|
| 252 |
+
|
| 253 |
+
# 1. NEWS & REPORTS
|
| 254 |
+
news_indicators = [
|
| 255 |
+
"breaking:", "just in:", "news:", "update:", "report:",
|
| 256 |
+
"casualties", "death toll", "according to", "reported that",
|
| 257 |
+
"suspension", "declared", "signal no", "public advisory",
|
| 258 |
+
"weather update", "volcano alert", "mmda", "pagasa"
|
| 259 |
+
]
|
| 260 |
+
|
| 261 |
+
# 2. MONEY / SELLING
|
| 262 |
+
financial_indicators = [
|
| 263 |
+
"gcash", "paypal", "budget", "loan", "selling",
|
| 264 |
+
"fundraising", "donate", "send funds"
|
| 265 |
+
]
|
| 266 |
+
|
| 267 |
+
# 3. IRRELEVANT CONTEXT
|
| 268 |
+
irrelevant_contexts = [
|
| 269 |
+
"how can i help", "where to donate", "thoughts and prayers",
|
| 270 |
+
"keep safe", "god bless", "praying for", "discussion:", "opinion:"
|
| 271 |
+
]
|
| 272 |
+
|
| 273 |
+
# Logic Checks
|
| 274 |
+
if any(ind in text_lower for ind in news_indicators):
|
| 275 |
+
return True, "News/Report"
|
| 276 |
+
|
| 277 |
+
# blocks financial requests unless life-threatening keywords are also present
|
| 278 |
+
has_financial = any(ind in text_lower for ind in financial_indicators)
|
| 279 |
+
is_life_death = any(k in text_lower for k in ["trapped", "lubog", "roof", "rescue", "drowning", "stuck"])
|
| 280 |
+
|
| 281 |
+
if has_financial and not is_life_death:
|
| 282 |
+
return True, "Financial/Non-Urgent"
|
| 283 |
+
|
| 284 |
+
# blocks posts containing non-urgent discussion or commentary
|
| 285 |
+
if any(ctx in text_lower for ctx in irrelevant_contexts):
|
| 286 |
+
return True, "Context/NotUrgent"
|
| 287 |
+
|
| 288 |
+
return False, None
|
| 289 |
+
|
| 290 |
+
# runs the two-stage AI classification check (TF-IDF then RoBERTa)
|
| 291 |
+
def predict_urgency(text):
|
| 292 |
+
|
| 293 |
+
# 1. Gatekeeper (TF-IDF)
|
| 294 |
+
# quickly rejects posts with extremely low urgency confidence (below 10%)
|
| 295 |
+
if tfidf_model:
|
| 296 |
+
tfidf_probs = tfidf_model.predict_proba([text])[0]
|
| 297 |
+
tfidf_conf = tfidf_probs[1]
|
| 298 |
+
|
| 299 |
+
# If the fast model is sure it's junk, skip the heavy lifting
|
| 300 |
+
if tfidf_conf < 0.20:
|
| 301 |
+
return False, tfidf_conf, "TF-IDF Reject"
|
| 302 |
+
|
| 303 |
+
# 2. Context Expert (RoBERTa)
|
| 304 |
+
# runs the slower, context-aware model for final classification
|
| 305 |
+
inputs = tokenizer(text, return_tensors="pt", truncation=True, padding=True, max_length=128)
|
| 306 |
+
with torch.no_grad():
|
| 307 |
+
outputs = roberta_model(**inputs)
|
| 308 |
+
probs = F.softmax(outputs.logits, dim=-1)
|
| 309 |
+
roberta_conf = probs[0][1].item() # Probability of 'Rescue Request'
|
| 310 |
+
|
| 311 |
+
# final acceptance threshold (40%) for the RoBERTa model
|
| 312 |
+
return (roberta_conf > 0.4), roberta_conf, "RoBERTa"
|
| 313 |
+
|
| 314 |
+
# assigns the final severity level (High, Medium, Low) based on severity keywords
|
| 315 |
+
def assign_dynamic_urgency(text):
|
| 316 |
+
text_lower = text.lower()
|
| 317 |
+
|
| 318 |
+
# 1. HIGH URGENCY (Immediate Life-Threatening Event or Critical Medical Need)
|
| 319 |
+
high_keywords = [
|
| 320 |
+
"bleeding", "unconscious", "severely injured", "severe injury", "life threatening",
|
| 321 |
+
"insulin", "oxygen", "ambulance", "urgent medicine", "doctor", "hospital",
|
| 322 |
+
|
| 323 |
+
"trap", "trapped", "bubong", "collapsed", "di mapigilan", "drowning",
|
| 324 |
+
"lampas tao", "lubog", "delikado", "baha na", "mamatay"
|
| 325 |
+
]
|
| 326 |
+
if any(k in text_lower for k in high_keywords):
|
| 327 |
+
return "High"
|
| 328 |
+
|
| 329 |
+
# 2. MEDIUM URGENCY (Time-Sensitive, Logistical Crisis)
|
| 330 |
+
medium_keywords = [
|
| 331 |
+
"stranded", "running out", "evacuate", "kailangan agad", "lowbat",
|
| 332 |
+
"paubos", "senior", "bedridden", "disabled", "gatas", "formula"
|
| 333 |
+
]
|
| 334 |
+
if any(k in text_lower for k in medium_keywords):
|
| 335 |
+
return "Medium"
|
| 336 |
+
|
| 337 |
+
# 3. LOW URGENCY (General Supplies/Warning)
|
| 338 |
+
# posts that pass the AI but lack the above severity indicators fall here
|
| 339 |
+
return "Low"
|
| 340 |
+
|
| 341 |
+
# blocks posts from accounts created less than 2 days ago or with negative karma
|
| 342 |
+
def is_credible_user(post):
|
| 343 |
+
try:
|
| 344 |
+
author = post.author
|
| 345 |
+
|
| 346 |
+
# checks if author is deleted or unknown
|
| 347 |
+
if not author:
|
| 348 |
+
return False
|
| 349 |
+
|
| 350 |
+
# 1. Check Account Age (Must be older than 2 days)
|
| 351 |
+
created_time = datetime.utcfromtimestamp(author.created_utc)
|
| 352 |
+
account_age = datetime.utcnow() - created_time
|
| 353 |
+
|
| 354 |
+
if account_age.days < 2:
|
| 355 |
+
print(f" ⚠️ Blocked: Account too new ({account_age.days} days)")
|
| 356 |
+
return False
|
| 357 |
+
|
| 358 |
+
# 2. Check Karma (Must not be negative)
|
| 359 |
+
total_karma = author.comment_karma + author.link_karma
|
| 360 |
+
if total_karma < -5:
|
| 361 |
+
print(f" ⚠️ Blocked: Negative Karma ({total_karma})")
|
| 362 |
+
return False
|
| 363 |
+
|
| 364 |
+
return True
|
| 365 |
+
|
| 366 |
+
except Exception as e:
|
| 367 |
+
# allows posts to pass if Reddit API fails to get user info
|
| 368 |
+
return True
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
# 4. Main Scraper Loop
|
| 372 |
+
# orchestrates the entire scraping process (historical scan + real-time stream)
|
| 373 |
+
async def scrape_reddit():
|
| 374 |
+
print("Connecting to Reddit API...")
|
| 375 |
+
|
| 376 |
+
client_id = os.getenv("REDDIT_CLIENT_ID")
|
| 377 |
+
client_secret = os.getenv("REDDIT_CLIENT_SECRET")
|
| 378 |
+
|
| 379 |
+
if not client_id or not client_secret:
|
| 380 |
+
print("❌ Error: Client ID or Secret missing in .env")
|
| 381 |
+
return
|
| 382 |
+
|
| 383 |
+
# initializes PRAW using the secure Client Credentials Flow (read-only)
|
| 384 |
+
reddit = asyncpraw.Reddit(
|
| 385 |
+
client_id=client_id,
|
| 386 |
+
client_secret=client_secret,
|
| 387 |
+
user_agent=os.getenv("REDDIT_USER_AGENT", "script:alisto_bot:v3.0")
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
try:
|
| 391 |
+
subreddit = await reddit.subreddit(SUBREDDITS)
|
| 392 |
+
print(f"👁️ ALISTO ACTIVE: Monitoring r/{SUBREDDITS}...")
|
| 393 |
+
|
| 394 |
+
# --- PHASE 1: FETCH LATEST EXISTING POSTS (e.g., last 500) ---
|
| 395 |
+
print("🔍 Scanning last 500 posts for missed alerts...")
|
| 396 |
+
# iterates over the last 500 posts asynchronously
|
| 397 |
+
async for post in subreddit.new(limit=500):
|
| 398 |
+
await process_post(post)
|
| 399 |
+
|
| 400 |
+
print("✅ Historical scan complete")
|
| 401 |
+
|
| 402 |
+
# --- PHASE 2: START REAL-TIME STREAM (Forever Loop) ---
|
| 403 |
+
print("📡 Starting real-time stream for new submissions...")
|
| 404 |
+
|
| 405 |
+
# starts the continuous loop to monitor for new submissions
|
| 406 |
+
async for post in subreddit.stream.submissions(skip_existing=False):
|
| 407 |
+
await process_post(post)
|
| 408 |
+
|
| 409 |
+
except Exception as e:
|
| 410 |
+
print(f"Global Scraper Error: {e}")
|
| 411 |
+
finally:
|
| 412 |
+
await reddit.close()
|
| 413 |
+
print("Scraper stopped")
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
# executes the main scraping loop when the script is run
|
| 417 |
+
if __name__ == "__main__":
|
| 418 |
+
try:
|
| 419 |
+
loop = asyncio.new_event_loop()
|
| 420 |
+
asyncio.set_event_loop(loop)
|
| 421 |
+
loop.run_until_complete(scrape_reddit())
|
| 422 |
+
except KeyboardInterrupt:
|
| 423 |
+
print("\n🛑 Stopped by user")
|
alisto_project/backend/init_db.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from flask import Flask
|
| 3 |
+
from models import db
|
| 4 |
+
|
| 5 |
+
app = Flask(__name__)
|
| 6 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 7 |
+
DB_PATH = os.path.join(BASE_DIR, 'alisto.db')
|
| 8 |
+
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_PATH}'
|
| 9 |
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 10 |
+
db.init_app(app)
|
| 11 |
+
|
| 12 |
+
def reset_database():
|
| 13 |
+
print("--- RESETTING DATABASE ---")
|
| 14 |
+
|
| 15 |
+
# 1. Delete the old file if it exists
|
| 16 |
+
if os.path.exists(DB_PATH):
|
| 17 |
+
os.remove(DB_PATH)
|
| 18 |
+
print(f"Deleted old database: {DB_PATH}")
|
| 19 |
+
else:
|
| 20 |
+
print("No old database found.")
|
| 21 |
+
|
| 22 |
+
# 2. Create fresh tables
|
| 23 |
+
with app.app_context():
|
| 24 |
+
db.create_all()
|
| 25 |
+
print("✅ Success: Created new empty 'alisto.db' with correct columns.")
|
| 26 |
+
|
| 27 |
+
if __name__ == "__main__":
|
| 28 |
+
reset_database()
|
alisto_project/backend/login.html
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>ALISTO | Command Login</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700;900&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
|
| 8 |
+
<link rel="stylesheet" href="style.css">
|
| 9 |
+
<style>
|
| 10 |
+
/* Specific overrides for Login Page centering */
|
| 11 |
+
body.login-page {
|
| 12 |
+
display: flex;
|
| 13 |
+
align-items: center;
|
| 14 |
+
justify-content: center;
|
| 15 |
+
height: 100vh;
|
| 16 |
+
background: #121212 url('images/bg2-logan.jpg') center/cover no-repeat;
|
| 17 |
+
}
|
| 18 |
+
.login-card {
|
| 19 |
+
position: relative; z-index: 2;
|
| 20 |
+
background: rgba(30, 30, 30, 0.9);
|
| 21 |
+
padding: 40px;
|
| 22 |
+
border-radius: 15px;
|
| 23 |
+
border: 1px solid #ed4801;
|
| 24 |
+
width: 100%; max-width: 400px;
|
| 25 |
+
text-align: center;
|
| 26 |
+
box-shadow: 0 20px 50px rgba(0,0,0,0.8);
|
| 27 |
+
}
|
| 28 |
+
.overlay {
|
| 29 |
+
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
| 30 |
+
background: rgba(0, 0, 0, 0.75); backdrop-filter: blur(5px); z-index: 1;
|
| 31 |
+
}
|
| 32 |
+
input {
|
| 33 |
+
width: 100%; padding: 15px; margin-bottom: 15px;
|
| 34 |
+
background: rgba(255,255,255,0.1); border: 1px solid #444;
|
| 35 |
+
border-radius: 8px; color: white; font-size: 1rem; outline: none;
|
| 36 |
+
}
|
| 37 |
+
button {
|
| 38 |
+
width: 100%; padding: 15px;
|
| 39 |
+
background: linear-gradient(135deg, #ed4801, #ff7e42);
|
| 40 |
+
color: white; border: none; border-radius: 8px;
|
| 41 |
+
font-size: 1.1rem; font-weight: bold; cursor: pointer;
|
| 42 |
+
}
|
| 43 |
+
</style>
|
| 44 |
+
</head>
|
| 45 |
+
<body class="login-page">
|
| 46 |
+
<div class="overlay"></div>
|
| 47 |
+
|
| 48 |
+
<div class="login-card">
|
| 49 |
+
<h1 style="color: #ed4801; font-family: 'Montserrat'; margin: 0;">ALISTO</h1>
|
| 50 |
+
<p style="color: #ccc; letter-spacing: 2px; font-size: 0.8em; text-transform: uppercase; margin-bottom: 30px;">Command Center</p>
|
| 51 |
+
|
| 52 |
+
<p id="error-msg" style="color: #ff4444; font-size: 0.9em; min-height: 20px;"></p>
|
| 53 |
+
|
| 54 |
+
<form id="loginForm">
|
| 55 |
+
<input type="text" id="username" placeholder="Officer ID" required>
|
| 56 |
+
<input type="password" id="password" placeholder="Password" required>
|
| 57 |
+
<button type="submit">Login</button>
|
| 58 |
+
</form>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<script>
|
| 62 |
+
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
| 63 |
+
e.preventDefault();
|
| 64 |
+
const u = document.getElementById('username').value;
|
| 65 |
+
const p = document.getElementById('password').value;
|
| 66 |
+
const btn = document.querySelector('button');
|
| 67 |
+
const err = document.getElementById('error-msg');
|
| 68 |
+
|
| 69 |
+
btn.innerText = "Authenticating...";
|
| 70 |
+
|
| 71 |
+
try {
|
| 72 |
+
const res = await fetch('/api/login', {
|
| 73 |
+
method: 'POST',
|
| 74 |
+
headers: {'Content-Type': 'application/json'},
|
| 75 |
+
body: JSON.stringify({username: u, password: p})
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
if (res.ok) {
|
| 79 |
+
window.location.href = "/";
|
| 80 |
+
} else {
|
| 81 |
+
const data = await res.json();
|
| 82 |
+
err.innerText = data.message || "Access Denied";
|
| 83 |
+
btn.innerText = "ACCESS SYSTEM";
|
| 84 |
+
}
|
| 85 |
+
} catch (e) {
|
| 86 |
+
err.innerText = "Connection Failed";
|
| 87 |
+
btn.innerText = "ACCESS SYSTEM";
|
| 88 |
+
}
|
| 89 |
+
});
|
| 90 |
+
</script>
|
| 91 |
+
</body>
|
| 92 |
+
</html>
|
alisto_project/backend/map.html
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>ALISTO | Live Map</title>
|
| 7 |
+
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700;900&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
|
| 9 |
+
<link rel="stylesheet" href="style.css">
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
<style>
|
| 13 |
+
/* Specific Map Page Styles */
|
| 14 |
+
body { overflow: hidden; }
|
| 15 |
+
|
| 16 |
+
#map {
|
| 17 |
+
width: 100%;
|
| 18 |
+
height: 100vh;
|
| 19 |
+
z-index: 1;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
</style>
|
| 23 |
+
</head>
|
| 24 |
+
<body>
|
| 25 |
+
|
| 26 |
+
<div class="map-sidebar">
|
| 27 |
+
<a href="index.html" class="back-btn">← Back to Dashboard</a>
|
| 28 |
+
<h1 style="font-family: Montserrat; color: #ed4801; margin-bottom: 5px;">LIVE MAP</h1>
|
| 29 |
+
<p style="font-size: 0.8em; color: #ccc;">Real-time disaster tracking</p>
|
| 30 |
+
<hr style="border: 0; border-top: 1px solid #555; margin: 15px 0;">
|
| 31 |
+
|
| 32 |
+
<div id="status-text">Connecting to Alisto...</div>
|
| 33 |
+
|
| 34 |
+
<div class="stat-item">
|
| 35 |
+
<span class="stat-highlight" id="alert-count">0</span> Active Alerts
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<div id="map"></div>
|
| 40 |
+
|
| 41 |
+
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
| 42 |
+
<script src="map_script.js"></script>
|
| 43 |
+
</body>
|
| 44 |
+
</html>
|
alisto_project/backend/map_script.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 1. EXTENDED COORDINATE DATABASE
|
| 2 |
+
// defines a static database of coordinates for Philippine cities and provinces
|
| 3 |
+
const cityCoords = {
|
| 4 |
+
// --- NCR ---
|
| 5 |
+
"Manila": [14.5995, 120.9842],
|
| 6 |
+
"Quezon City": [14.6760, 121.0437],
|
| 7 |
+
"Makati": [14.5547, 121.0244],
|
| 8 |
+
"Taguig": [14.5176, 121.0509],
|
| 9 |
+
"Pasig": [14.5763, 121.0851],
|
| 10 |
+
"Mandaluyong": [14.5794, 121.0359],
|
| 11 |
+
"Marikina": [14.6333, 121.0980],
|
| 12 |
+
"Las Pinas": [14.4445, 120.9939],
|
| 13 |
+
"Muntinlupa": [14.4081, 121.0415],
|
| 14 |
+
"Caloocan": [14.6401, 120.9745],
|
| 15 |
+
"Parañaque": [14.4793, 121.0198],
|
| 16 |
+
"Valenzuela": [14.7011, 120.9830],
|
| 17 |
+
"Pasay": [14.5378, 121.0014],
|
| 18 |
+
"Malabon": [14.6625, 120.9512],
|
| 19 |
+
"Navotas": [14.6732, 120.9350],
|
| 20 |
+
"San Juan": [14.6019, 121.0355],
|
| 21 |
+
"Pateros": [14.5454, 121.0687],
|
| 22 |
+
|
| 23 |
+
// --- CAVITE ---
|
| 24 |
+
"Cavite": [14.2831, 120.9168],
|
| 25 |
+
"Naic": [14.3168, 120.7628],
|
| 26 |
+
"Bacoor": [14.4624, 120.9645],
|
| 27 |
+
"Imus": [14.4297, 120.9367],
|
| 28 |
+
"Dasmarinas": [14.3294, 120.9367],
|
| 29 |
+
"General Trias": [14.3876, 120.8842],
|
| 30 |
+
"Tagaytay": [14.1153, 120.9621],
|
| 31 |
+
"Kawit": [14.4448, 120.9022],
|
| 32 |
+
"Noveleta": [14.4263, 120.8820],
|
| 33 |
+
"Rosario": [14.4153, 120.8532],
|
| 34 |
+
"Tanza": [14.3949, 120.8532],
|
| 35 |
+
"Silang": [14.2312, 120.9746],
|
| 36 |
+
"Trece Martires": [14.2883, 120.8677],
|
| 37 |
+
|
| 38 |
+
// --- LAGUNA ---
|
| 39 |
+
"Laguna": [14.2166, 121.1667],
|
| 40 |
+
"Calamba": [14.2142, 121.1553],
|
| 41 |
+
"Santa Rosa": [14.3121, 121.1132],
|
| 42 |
+
"Binan": [14.3400, 121.0827],
|
| 43 |
+
"San Pedro": [14.3644, 121.0370],
|
| 44 |
+
"Cabuyao": [14.2796, 121.1219],
|
| 45 |
+
"Los Banos": [14.1708, 121.2413],
|
| 46 |
+
|
| 47 |
+
// --- RIZAL ---
|
| 48 |
+
"Rizal": [14.5906, 121.2236],
|
| 49 |
+
"Antipolo": [14.5844, 121.1763],
|
| 50 |
+
"Cainta": [14.5760, 121.1213],
|
| 51 |
+
"Taytay": [14.5623, 121.1376],
|
| 52 |
+
"San Mateo": [14.6963, 121.1215],
|
| 53 |
+
"Binangonan": [14.4759, 121.1893],
|
| 54 |
+
|
| 55 |
+
// --- BULACAN ---
|
| 56 |
+
"Bulacan": [14.8524, 120.8228],
|
| 57 |
+
"Malolos": [14.8527, 120.8160],
|
| 58 |
+
"Meycauayan": [14.7356, 120.9622],
|
| 59 |
+
"San Jose del Monte": [14.8143, 121.0427],
|
| 60 |
+
"Bocaue": [14.8066, 120.9256],
|
| 61 |
+
|
| 62 |
+
// --- MAJOR PROVINCES / CITIES ---
|
| 63 |
+
"Pampanga": [15.0359, 120.6924],
|
| 64 |
+
"Tarlac": [15.4802, 120.5979],
|
| 65 |
+
"Batangas": [13.7565, 121.0583],
|
| 66 |
+
"Baguio": [16.4023, 120.5960],
|
| 67 |
+
"Cebu": [10.3157, 123.8854],
|
| 68 |
+
"Iloilo": [10.7202, 122.5621],
|
| 69 |
+
"Davao": [7.1907, 125.4553],
|
| 70 |
+
"Cagayan": [17.6133, 121.7302],
|
| 71 |
+
"Bicol": [13.4350, 123.4100],
|
| 72 |
+
"Albay": [13.1391, 123.7438],
|
| 73 |
+
"Tacloban": [11.2442, 125.0039],
|
| 74 |
+
"Zamboanga": [6.9214, 122.0790],
|
| 75 |
+
"Palawan": [9.8349, 118.7384],
|
| 76 |
+
"Mindoro": [13.0264, 121.2227],
|
| 77 |
+
"Isabela": [16.9754, 121.8107],
|
| 78 |
+
"Pangasinan": [15.9236, 120.3392],
|
| 79 |
+
"Philippines": [12.8797, 121.7740] // CENTER OF PH
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
let map;
|
| 83 |
+
let markers = [];
|
| 84 |
+
|
| 85 |
+
// initializes the map view and starts fetching data
|
| 86 |
+
document.addEventListener("DOMContentLoaded", () => {
|
| 87 |
+
// sets the initial map center on the CALABARZON/NCR area
|
| 88 |
+
map = L.map('map').setView([14.40, 121.00], 10);
|
| 89 |
+
|
| 90 |
+
// adds the dark-themed tile layer to the map
|
| 91 |
+
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
| 92 |
+
attribution: '© OpenStreetMap © CARTO',
|
| 93 |
+
subdomains: 'abcd',
|
| 94 |
+
maxZoom: 19
|
| 95 |
+
}).addTo(map);
|
| 96 |
+
|
| 97 |
+
console.log("ALISTO Map: Loaded");
|
| 98 |
+
// fetches the first set of post data
|
| 99 |
+
fetchDataAndPlot();
|
| 100 |
+
// sets a timer to refresh data every 30 seconds
|
| 101 |
+
setInterval(fetchDataAndPlot, 30000);
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
// fetches the list of active disaster posts from the API
|
| 105 |
+
function fetchDataAndPlot() {
|
| 106 |
+
fetch('/api/posts')
|
| 107 |
+
.then(response => response.json())
|
| 108 |
+
.then(data => {
|
| 109 |
+
// updates the alert count in the map sidebar
|
| 110 |
+
updateSidebar(data);
|
| 111 |
+
// plots the markers on the map
|
| 112 |
+
plotMarkers(data);
|
| 113 |
+
})
|
| 114 |
+
.catch(err => console.error("Map Fetch Error:", err));
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// updates the displayed alert count and system status in the floating sidebar
|
| 118 |
+
function updateSidebar(data) {
|
| 119 |
+
const countEl = document.getElementById('alert-count');
|
| 120 |
+
const statusEl = document.getElementById('status-text');
|
| 121 |
+
if(countEl) countEl.innerText = data.length;
|
| 122 |
+
if(statusEl) statusEl.innerText = "System Active";
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// clears existing markers and plots new circular markers for each post
|
| 126 |
+
function plotMarkers(posts) {
|
| 127 |
+
// removes all existing markers from the map
|
| 128 |
+
markers.forEach(m => map.removeLayer(m));
|
| 129 |
+
markers = [];
|
| 130 |
+
|
| 131 |
+
// iterates through all posts to find coordinates and draw markers
|
| 132 |
+
posts.forEach(async post => {
|
| 133 |
+
// asynchronously finds the latitude and longitude for the location
|
| 134 |
+
let coords = await getCoordinatesSmart(post.location);
|
| 135 |
+
|
| 136 |
+
if (coords) {
|
| 137 |
+
// sets color and size based on post urgency level
|
| 138 |
+
const urgencyColor = post.urgency_level === 'High' ? '#ff4444' : '#ed4801';
|
| 139 |
+
const radius = post.urgency_level === 'High' ? 14 : 8;
|
| 140 |
+
|
| 141 |
+
// creates and adds a circular marker (L.circleMarker) to the map
|
| 142 |
+
const circle = L.circleMarker(coords, {
|
| 143 |
+
color: urgencyColor,
|
| 144 |
+
fillColor: urgencyColor,
|
| 145 |
+
fillOpacity: 0.7,
|
| 146 |
+
radius: radius
|
| 147 |
+
}).addTo(map);
|
| 148 |
+
|
| 149 |
+
const timeStr = new Date(post.timestamp).toLocaleTimeString();
|
| 150 |
+
|
| 151 |
+
// binds a detailed pop-up box to the circular marker
|
| 152 |
+
circle.bindPopup(`
|
| 153 |
+
<div style="font-family: 'Roboto', sans-serif; color: #333; min-width: 200px;">
|
| 154 |
+
<strong style="text-transform:uppercase; color: #d32f2f; font-size: 1.1em;">
|
| 155 |
+
${post.disaster_type}
|
| 156 |
+
</strong>
|
| 157 |
+
|
| 158 |
+
<div style="color: #ed4801; font-weight: 700; font-size: 0.9em; margin-bottom: 4px; text-transform: uppercase;">
|
| 159 |
+
⚠ ${post.assistance_type || "General Help"}
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<span style="font-size: 0.9em; color: #555;">📍 ${post.location}</span>
|
| 163 |
+
|
| 164 |
+
<hr style="margin:8px 0; border:0; border-top:1px solid #ccc;">
|
| 165 |
+
|
| 166 |
+
<div style="font-size: 0.9em; margin-bottom: 5px; font-weight: 500;">
|
| 167 |
+
"${post.title}"
|
| 168 |
+
</div>
|
| 169 |
+
|
| 170 |
+
<small style="color: #888;">${timeStr}</small>
|
| 171 |
+
</div>
|
| 172 |
+
`);
|
| 173 |
+
// adds the new marker to the global array
|
| 174 |
+
markers.push(circle);
|
| 175 |
+
}
|
| 176 |
+
});
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// --- NEW SMART FINDER (Hybrid: Static List + API) ---
|
| 180 |
+
// function to look up coordinates, prioritizing the static list then Nominatim API
|
| 181 |
+
async function getCoordinatesSmart(locationStr) {
|
| 182 |
+
if (!locationStr) return cityCoords["Philippines"];
|
| 183 |
+
|
| 184 |
+
// 1. Try Static List (Instant)
|
| 185 |
+
// direct match check
|
| 186 |
+
const exactMatch = Object.keys(cityCoords).find(k => k.toLowerCase() === locationStr.toLowerCase());
|
| 187 |
+
if (exactMatch) return cityCoords[exactMatch];
|
| 188 |
+
|
| 189 |
+
// fuzzy match check
|
| 190 |
+
const fuzzyKey = Object.keys(cityCoords).find(city => locationStr.toLowerCase().includes(city.toLowerCase()));
|
| 191 |
+
if (fuzzyKey) return cityCoords[fuzzyKey];
|
| 192 |
+
|
| 193 |
+
// 2. Try OpenStreetMap API (Dynamic)
|
| 194 |
+
// checks local cache before making a network request
|
| 195 |
+
if (!window.coordCache) window.coordCache = {};
|
| 196 |
+
if (window.coordCache[locationStr]) return window.coordCache[locationStr];
|
| 197 |
+
|
| 198 |
+
try {
|
| 199 |
+
console.log(`Fetching coords for: ${locationStr}...`);
|
| 200 |
+
// fetches coordinates from the Nominatim API
|
| 201 |
+
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${locationStr}, Philippines`);
|
| 202 |
+
const data = await response.json();
|
| 203 |
+
|
| 204 |
+
// processes and caches the result
|
| 205 |
+
if (data && data.length > 0) {
|
| 206 |
+
const lat = parseFloat(data[0].lat);
|
| 207 |
+
const lon = parseFloat(data[0].lon);
|
| 208 |
+
const result = [lat, lon];
|
| 209 |
+
window.coordCache[locationStr] = result;
|
| 210 |
+
return result;
|
| 211 |
+
}
|
| 212 |
+
} catch (e) {
|
| 213 |
+
console.error("Geocoding failed:", e);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
// 3. Fallback
|
| 217 |
+
// returns the center of the Philippines if geocoding fails
|
| 218 |
+
return cityCoords["Philippines"];
|
| 219 |
+
}
|
alisto_project/backend/models.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask_sqlalchemy import SQLAlchemy
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
|
| 4 |
+
# initializes the SQLAlchemy object for the application
|
| 5 |
+
db = SQLAlchemy()
|
| 6 |
+
|
| 7 |
+
# defines the main data model for a scraped disaster post
|
| 8 |
+
class DisasterPost(db.Model):
|
| 9 |
+
# sets the name of the database table
|
| 10 |
+
__tablename__ = 'posts'
|
| 11 |
+
|
| 12 |
+
# primary key for the table
|
| 13 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 14 |
+
# unique ID from Reddit to prevent duplicates
|
| 15 |
+
reddit_id = db.Column(db.String(50), unique=True, nullable=False)
|
| 16 |
+
# title of the Reddit post
|
| 17 |
+
title = db.Column(db.String(300), nullable=False)
|
| 18 |
+
# full body content of the post
|
| 19 |
+
content = db.Column(db.Text, nullable=True)
|
| 20 |
+
# stores the contact person's name or Reddit username
|
| 21 |
+
author = db.Column(db.String(50), nullable=True)
|
| 22 |
+
# timestamp when the post was created (UTC)
|
| 23 |
+
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
| 24 |
+
|
| 25 |
+
# Intelligence Fields
|
| 26 |
+
# extracted location or address of the incident
|
| 27 |
+
location = db.Column(db.String(100), nullable=True)
|
| 28 |
+
# extracted mobile or contact number
|
| 29 |
+
contact_number = db.Column(db.String(50), nullable=True)
|
| 30 |
+
# AI-classified type of disaster (e.g., Flood, Fire)
|
| 31 |
+
disaster_type = db.Column(db.String(50), nullable=True)
|
| 32 |
+
# AI-classified specific assistance needed (e.g., Medical, Rescue)
|
| 33 |
+
assistance_type = db.Column(db.String(50), nullable=True)
|
| 34 |
+
# dynamically assigned severity level (High, Medium, Low)
|
| 35 |
+
urgency_level = db.Column(db.String(20), nullable=True)
|
| 36 |
+
# boolean flag indicating if the post is a help request
|
| 37 |
+
is_help_request = db.Column(db.Boolean, default=False)
|
| 38 |
+
|
| 39 |
+
# Metadata
|
| 40 |
+
# operational status of the post (New, Verified, Resolved)
|
| 41 |
+
status = db.Column(db.String(20), default="New")
|
| 42 |
+
|
| 43 |
+
# converts the model object into a dictionary format for API responses
|
| 44 |
+
def to_dict(self):
|
| 45 |
+
# converts the timestamp to ISO 8601 format
|
| 46 |
+
iso_time = self.timestamp.isoformat()
|
| 47 |
+
# ensures the time string ends with 'Z' for standardized UTC representation
|
| 48 |
+
if not iso_time.endswith("Z"):
|
| 49 |
+
iso_time += "Z"
|
| 50 |
+
|
| 51 |
+
return {
|
| 52 |
+
"id": self.id,
|
| 53 |
+
"reddit_id": self.reddit_id,
|
| 54 |
+
"title": self.title,
|
| 55 |
+
"content": self.content,
|
| 56 |
+
"author": self.author,
|
| 57 |
+
"timestamp": iso_time,
|
| 58 |
+
"location": self.location,
|
| 59 |
+
"contact_number": self.contact_number or "Check Post",
|
| 60 |
+
"disaster_type": self.disaster_type,
|
| 61 |
+
"assistance_type": self.assistance_type,
|
| 62 |
+
"urgency_level": self.urgency_level,
|
| 63 |
+
"status": self.status
|
| 64 |
+
}
|
alisto_project/backend/models/tfidf_ensemble.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d09e788d8ecc671cef44bcc67d2f0054b9937e68b27751459455fafbba541f8c
|
| 3 |
+
size 405802
|
alisto_project/backend/my_generator.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
|
| 3 |
+
# ---------------------------------------------------------
|
| 4 |
+
# DATA POOLS
|
| 5 |
+
# ---------------------------------------------------------
|
| 6 |
+
# list of locations used for synthetic data generation
|
| 7 |
+
LOCATIONS = [
|
| 8 |
+
"Manila", "Marikina", "Quezon City", "Pasig", "Cainta", "Rizal",
|
| 9 |
+
"Bulacan", "Pampanga", "Cavite", "Batangas", "Laguna", "San Mateo",
|
| 10 |
+
"Montalban", "Antipolo", "Valenzuela", "Malabon", "Navotas", "Las Pinas",
|
| 11 |
+
"Taguig", "Makati", "Caloocan", "Muntinlupa", "Paranaque", "Pasay"
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
# list of disaster types used for synthetic data generation
|
| 15 |
+
DISASTERS = [
|
| 16 |
+
"baha", "flood", "flooding", "tubig",
|
| 17 |
+
"bagyo", "typhoon", "storm", "hangin", "ulan",
|
| 18 |
+
"sunog", "fire", "smoke", "apoy",
|
| 19 |
+
"lindol", "earthquake", "quake", "aftershock",
|
| 20 |
+
"landslide", "guho", "mudslide"
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
# list of critical keywords used in both urgent and non-urgent samples
|
| 24 |
+
URGENT_KEYWORDS = [
|
| 25 |
+
"tulong", "help", "saklolo", "rescue", "emergency", "evacuate",
|
| 26 |
+
"need food", "trapped", "stranded", "stuck", "lubog", "buntis"
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
# ---------------------------------------------------------
|
| 30 |
+
# GENERATORS
|
| 31 |
+
# ---------------------------------------------------------
|
| 32 |
+
|
| 33 |
+
# generates a synthetic urgent post (positive sample)
|
| 34 |
+
def build_positive():
|
| 35 |
+
kw = random.choice(URGENT_KEYWORDS)
|
| 36 |
+
disaster = random.choice(DISASTERS)
|
| 37 |
+
loc = random.choice(LOCATIONS)
|
| 38 |
+
|
| 39 |
+
templates = [
|
| 40 |
+
f"{kw.upper()} please! {disaster} dito sa {loc}!",
|
| 41 |
+
f"{loc} need {kw}, {disaster} is rising!",
|
| 42 |
+
f"Kami po sa {loc} ay need ng {kw}, sobrang taas ng {disaster}!",
|
| 43 |
+
f"{kw.upper()}! {disaster} sa {loc}, di kami makalabas!",
|
| 44 |
+
f"Na-trap kami sa {loc} dahil sa {disaster}, {kw} please!",
|
| 45 |
+
f"{disaster} alert in {loc}, we need {kw} asap!",
|
| 46 |
+
f"Wala na kaming matatakbuhan sa {loc}, {disaster} na! {kw}!"
|
| 47 |
+
]
|
| 48 |
+
return random.choice(templates)
|
| 49 |
+
|
| 50 |
+
# generates a synthetic non-urgent post (negative sample/trap)
|
| 51 |
+
def build_negative():
|
| 52 |
+
kw = random.choice(URGENT_KEYWORDS) # uses urgent keywords in a safe context
|
| 53 |
+
disaster = random.choice(DISASTERS)
|
| 54 |
+
loc = random.choice(LOCATIONS)
|
| 55 |
+
|
| 56 |
+
category = random.choice(["news", "donation", "past", "question"])
|
| 57 |
+
|
| 58 |
+
# templates that structure the urgent keyword as general news or a report
|
| 59 |
+
if category == "news":
|
| 60 |
+
return f"Update: {kw} operations for {disaster} victims in {loc} are ongoing."
|
| 61 |
+
|
| 62 |
+
# templates that structure the urgent keyword as a donation or offering help
|
| 63 |
+
elif category == "donation":
|
| 64 |
+
return f"We are sending {kw} and relief goods to {loc}. Stay strong!"
|
| 65 |
+
|
| 66 |
+
# templates that structure the urgent keyword using past tense
|
| 67 |
+
elif category == "past":
|
| 68 |
+
return f"Remembering when we were {kw}ed during the last {disaster} in {loc}."
|
| 69 |
+
|
| 70 |
+
# templates that structure the urgent keyword as a general question or advice
|
| 71 |
+
else:
|
| 72 |
+
return f"Guys in {loc}, do you need {kw}? Comment below."
|
alisto_project/backend/ner_extractor.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from transformers import AutoTokenizer, AutoModelForTokenClassification, pipeline
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
# Load NER model once
|
| 5 |
+
model_name = "Davlan/xlm-roberta-base-ner-hrl"
|
| 6 |
+
# loads the tokenizer for the multilingual NER model
|
| 7 |
+
tokenizer = AutoTokenizer.from_pretrained(model_name, force_download=True)
|
| 8 |
+
# loads the pre-trained token classification model (the NER engine)
|
| 9 |
+
model = AutoModelForTokenClassification.from_pretrained(model_name, force_download=True)
|
| 10 |
+
# creates a Hugging Face pipeline for easy entity recognition
|
| 11 |
+
nlp = pipeline("ner", model=model, tokenizer=tokenizer, aggregation_strategy="simple")
|
| 12 |
+
|
| 13 |
+
# function to extract the contact person's name using Regex
|
| 14 |
+
def extract_contact_person_name(text):
|
| 15 |
+
# regex pattern to find name following contact/name keywords
|
| 16 |
+
name_pattern = re.compile(
|
| 17 |
+
r'(?:contact\s*person|contact\s*name|contact|name):\s*(.*?)(?=\s*(?:Mobile|Number|Needs|09\d{2}|[A-Z]{3,}:|\n|$))',
|
| 18 |
+
re.IGNORECASE | re.DOTALL
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
match = name_pattern.search(text)
|
| 22 |
+
if match:
|
| 23 |
+
# returns the captured name after removing parenthetical nicknames and periods
|
| 24 |
+
name = match.group(1).strip()
|
| 25 |
+
return re.sub(r'\s*\(.*\)\s*$', '', name).strip().rstrip('.')
|
| 26 |
+
return None
|
| 27 |
+
|
| 28 |
+
# function to extract mobile or landline phone numbers using Regex
|
| 29 |
+
def extract_contact_number(text):
|
| 30 |
+
|
| 31 |
+
# regex for common Philippine mobile and landline patterns
|
| 32 |
+
phone_pattern = re.compile(r'(09\d{2}\s?-?\s?\d{3}\s?-?\s?\d{4}|(?:\+63|63)?\d{10})')
|
| 33 |
+
|
| 34 |
+
match = phone_pattern.search(text)
|
| 35 |
+
if match:
|
| 36 |
+
# returns the captured phone number
|
| 37 |
+
return match.group(0).strip()
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
# list of keywords often incorrectly classified as locations by the NER model
|
| 41 |
+
NON_LOC_KEYWORDS = ["S.O.S", "PLS", "HELP", "URGENT", "ALERT", "LOCATION", "ADDRESS", "ASAP"]
|
| 42 |
+
|
| 43 |
+
# function to extract the detailed address line by prioritizing explicit labels
|
| 44 |
+
def extract_address_line(text):
|
| 45 |
+
# regex pattern to capture address text following keywords, stopping at noise or new lines
|
| 46 |
+
address_pattern = re.compile(
|
| 47 |
+
r'(?:location|address|loc|near):\s*(.*?)(?=\s*\n|\s*\(|\s*Contact|\s*Mobile|\s*Number|\s*kami|\s*yung|\s*bahay|\s*kmi|\s*near\s|$)',
|
| 48 |
+
re.IGNORECASE
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
match = address_pattern.search(text)
|
| 52 |
+
if match:
|
| 53 |
+
# returns the cleaned address line
|
| 54 |
+
return match.group(1).strip().rstrip('.')
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
# main function that orchestrates all entity extraction and filtering
|
| 58 |
+
def extract_entities(text):
|
| 59 |
+
|
| 60 |
+
# 1. Attempt to extract high-confidence address line (Regex)
|
| 61 |
+
explicit_address = extract_address_line(text)
|
| 62 |
+
|
| 63 |
+
# 2. Run general NER extraction
|
| 64 |
+
# runs the text through the Hugging Face NER pipeline
|
| 65 |
+
results = nlp(text)
|
| 66 |
+
locations = []
|
| 67 |
+
|
| 68 |
+
# processes NER results and filters out generic noise keywords
|
| 69 |
+
for entity in results:
|
| 70 |
+
# 'LOC' is the label for Locations in XLM-R NER
|
| 71 |
+
if entity['entity_group'] == 'LOC':
|
| 72 |
+
clean_loc = entity['word'].replace("##", "").strip()
|
| 73 |
+
|
| 74 |
+
# --- FILTERING LOGIC (Skip noise) ---
|
| 75 |
+
# skips locations that are too short
|
| 76 |
+
if len(clean_loc) <= 2:
|
| 77 |
+
continue
|
| 78 |
+
|
| 79 |
+
# skips locations matching known non-location keywords (e.g., S.O.S.)
|
| 80 |
+
clean_loc_upper = clean_loc.upper().replace('.', '')
|
| 81 |
+
if clean_loc_upper in NON_LOC_KEYWORDS:
|
| 82 |
+
continue
|
| 83 |
+
# --- END FILTERING LOGIC ---
|
| 84 |
+
|
| 85 |
+
if clean_loc not in locations:
|
| 86 |
+
locations.append(clean_loc)
|
| 87 |
+
|
| 88 |
+
# 3. Prioritize the explicit address if found (inserts at index 0)
|
| 89 |
+
# ensures the regex-found address is always the primary location
|
| 90 |
+
if explicit_address:
|
| 91 |
+
locations.insert(0, explicit_address)
|
| 92 |
+
|
| 93 |
+
# 4. Extract Contact Number
|
| 94 |
+
contact_number = extract_contact_number(text)
|
| 95 |
+
|
| 96 |
+
# 5. Extract Contact Person Name (NEW)
|
| 97 |
+
contact_name = extract_contact_person_name(text) # <--- NEW CALL
|
| 98 |
+
|
| 99 |
+
# returns all extracted data in a dictionary format
|
| 100 |
+
return {
|
| 101 |
+
"locations": locations,
|
| 102 |
+
"contact": contact_number,
|
| 103 |
+
"contact_person_name": contact_name,
|
| 104 |
+
"raw": results
|
| 105 |
+
}
|
alisto_project/backend/script.js
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// GLOBAL VARIABLES
|
| 2 |
+
let typeChart = null;
|
| 3 |
+
let urgencyChart = null;
|
| 4 |
+
let lastClickedId = null;
|
| 5 |
+
let knownPostIds = new Set();
|
| 6 |
+
let readPostIds = new Set();
|
| 7 |
+
let isLoggedIn = false; // track login state
|
| 8 |
+
let isInitialLoadComplete = false; // flag to prevent false alerts on page load
|
| 9 |
+
let currentUsername = ''; // stores the logged-in user's username
|
| 10 |
+
|
| 11 |
+
document.addEventListener("DOMContentLoaded", function() {
|
| 12 |
+
console.log("ALISTO Dashboard Loaded");
|
| 13 |
+
|
| 14 |
+
// 1. Check Login Status Immediately
|
| 15 |
+
checkLoginStatus();
|
| 16 |
+
|
| 17 |
+
// 2. Init Data
|
| 18 |
+
initCharts();
|
| 19 |
+
fetchPosts();
|
| 20 |
+
fetchStats();
|
| 21 |
+
|
| 22 |
+
setInterval(() => { fetchPosts(); fetchStats(); }, 30000);
|
| 23 |
+
|
| 24 |
+
// 3. Filter Listeners
|
| 25 |
+
document.getElementById('sort-select').addEventListener('change', () => fetchPosts());
|
| 26 |
+
document.getElementById('view-select').addEventListener('change', () => fetchPosts());
|
| 27 |
+
document.getElementById('urgency-select').addEventListener('change', () => fetchPosts());
|
| 28 |
+
document.getElementById('type-select').addEventListener('change', () => fetchPosts());
|
| 29 |
+
document.getElementById('assist-select').addEventListener('change', () => fetchPosts());
|
| 30 |
+
|
| 31 |
+
// 4. Export (Check login first)
|
| 32 |
+
document.getElementById('export-btn').addEventListener('click', () => {
|
| 33 |
+
if(!isLoggedIn) { alert("Responders Only. Please Log In."); return; }
|
| 34 |
+
window.location.href = '/api/export';
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
// 5. Stats Modal
|
| 38 |
+
const statsModal = document.getElementById('stats-modal');
|
| 39 |
+
document.getElementById('show-stats-btn').addEventListener('click', () => {
|
| 40 |
+
statsModal.classList.remove('hidden');
|
| 41 |
+
fetchStats();
|
| 42 |
+
});
|
| 43 |
+
document.getElementById('close-stats-btn').addEventListener('click', () => statsModal.classList.add('hidden'));
|
| 44 |
+
|
| 45 |
+
// 6. Login Modal Logic
|
| 46 |
+
setupLoginLogic();
|
| 47 |
+
setupSearch();
|
| 48 |
+
setupProfileDropdown();
|
| 49 |
+
|
| 50 |
+
// 🚨 The manual button listeners were correctly removed from here in the previous step.
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
// ----------------------------------------------------------------------
|
| 54 |
+
// ACTION BUTTON VISUAL SYNC LOGIC
|
| 55 |
+
// ----------------------------------------------------------------------
|
| 56 |
+
|
| 57 |
+
function updateActionButtons(postStatus) {
|
| 58 |
+
const verifyBtn = document.getElementById('verify-btn');
|
| 59 |
+
const resolveBtn = document.getElementById('resolve-btn');
|
| 60 |
+
|
| 61 |
+
if (!verifyBtn || !resolveBtn) return; // Safety check
|
| 62 |
+
|
| 63 |
+
// 1. Reset all active states first (CRITICAL STEP)
|
| 64 |
+
verifyBtn.classList.remove('is-verified');
|
| 65 |
+
resolveBtn.classList.remove('is-resolved');
|
| 66 |
+
|
| 67 |
+
// Reset button text
|
| 68 |
+
document.querySelector('#verify-btn .btn-text').textContent = 'Verify';
|
| 69 |
+
document.querySelector('#resolve-btn .btn-text').textContent = 'Resolve';
|
| 70 |
+
|
| 71 |
+
// 2. Apply Active State Classes based *only* on the current status
|
| 72 |
+
|
| 73 |
+
if (postStatus === 'Verified') {
|
| 74 |
+
// If Verified, apply the 'is-verified' style
|
| 75 |
+
verifyBtn.classList.add('is-verified');
|
| 76 |
+
document.querySelector('#verify-btn .btn-text').textContent = 'Verified';
|
| 77 |
+
|
| 78 |
+
} else if (postStatus === 'Resolved') {
|
| 79 |
+
// If Resolved, apply the 'is-resolved' style
|
| 80 |
+
resolveBtn.classList.add('is-resolved');
|
| 81 |
+
document.querySelector('#resolve-btn .btn-text').textContent = 'Resolved';
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// ----------------------------------------------------------------------
|
| 86 |
+
// AUTHENTICATION LOGIC
|
| 87 |
+
// ----------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
// checks the user's current login status via API
|
| 90 |
+
function checkLoginStatus() {
|
| 91 |
+
fetch('/api/user_status')
|
| 92 |
+
.then(res => res.json())
|
| 93 |
+
.then(data => {
|
| 94 |
+
isLoggedIn = data.is_logged_in;
|
| 95 |
+
currentUsername = data.username || '';
|
| 96 |
+
updateUIForAuth();
|
| 97 |
+
});
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// toggles the visibility of login links, profile dropdown, and action buttons
|
| 101 |
+
function updateUIForAuth() {
|
| 102 |
+
const navBtn = document.getElementById('nav-login-btn');
|
| 103 |
+
const profileWrap = document.getElementById('profile-container-wrap');
|
| 104 |
+
const dropdownUsername = document.getElementById('dropdown-username');
|
| 105 |
+
const actionButtonsContainer = document.querySelector('.action-buttons');
|
| 106 |
+
|
| 107 |
+
if (isLoggedIn) {
|
| 108 |
+
// LOGGED IN: Show Profile Icon, Update Name, Hide Login Link
|
| 109 |
+
if (navBtn) navBtn.style.display = 'none';
|
| 110 |
+
if (profileWrap) profileWrap.style.display = 'flex'; // Show the profile icon container
|
| 111 |
+
if (dropdownUsername) dropdownUsername.innerText = currentUsername;
|
| 112 |
+
if (actionButtonsContainer) actionButtonsContainer.style.visibility = 'visible';
|
| 113 |
+
} else {
|
| 114 |
+
// LOGGED OUT: Show Login Link, Hide Profile Icon
|
| 115 |
+
if (navBtn) navBtn.style.display = 'inline-block';
|
| 116 |
+
if (profileWrap) profileWrap.style.display = 'none'; // Hide the profile icon container
|
| 117 |
+
if (actionButtonsContainer) actionButtonsContainer.style.visibility = 'hidden';
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// handles click events for the new profile icon and logout button inside the dropdown
|
| 122 |
+
function setupProfileDropdown() {
|
| 123 |
+
const profileToggle = document.getElementById('profile-toggle');
|
| 124 |
+
const profileDropdown = document.getElementById('profile-dropdown');
|
| 125 |
+
const logoutBtn = document.getElementById('dropdown-logout-btn');
|
| 126 |
+
|
| 127 |
+
// Get the reference to the existing logout modal element
|
| 128 |
+
const logoutModal = document.getElementById('logout-modal');
|
| 129 |
+
|
| 130 |
+
// 1. Toggle visibility when clicking the icon (Unchanged)
|
| 131 |
+
if (profileToggle) {
|
| 132 |
+
profileToggle.addEventListener('click', (e) => {
|
| 133 |
+
e.stopPropagation();
|
| 134 |
+
profileDropdown.classList.toggle('hidden');
|
| 135 |
+
});
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// 2. Logout button handler (MODIFIED BLOCK)
|
| 139 |
+
if (logoutBtn) {
|
| 140 |
+
logoutBtn.addEventListener('click', (e) => {
|
| 141 |
+
e.preventDefault();
|
| 142 |
+
|
| 143 |
+
// Action: Show the confirmation modal instead of redirecting
|
| 144 |
+
if (logoutModal) {
|
| 145 |
+
logoutModal.classList.remove('hidden');
|
| 146 |
+
} else {
|
| 147 |
+
// Fallback, should not happen if index.html is correct
|
| 148 |
+
window.location.href = '/api/logout';
|
| 149 |
+
}
|
| 150 |
+
});
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// 3. Close when clicking outside (Unchanged)
|
| 154 |
+
document.addEventListener('click', (e) => {
|
| 155 |
+
if (profileToggle && profileDropdown && !profileToggle.contains(e.target) && !profileDropdown.contains(e.target)) {
|
| 156 |
+
if (!profileDropdown.classList.contains('hidden')) {
|
| 157 |
+
profileDropdown.classList.add('hidden');
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
});
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
// sets up handlers for the login and logout modals
|
| 164 |
+
function setupLoginLogic() {
|
| 165 |
+
const loginModal = document.getElementById('login-modal');
|
| 166 |
+
const logoutModal = document.getElementById('logout-modal');
|
| 167 |
+
const navBtn = document.getElementById('nav-login-btn');
|
| 168 |
+
const closeBtn = document.getElementById('close-login-btn');
|
| 169 |
+
const submitBtn = document.getElementById('login-submit-btn');
|
| 170 |
+
|
| 171 |
+
// 1. NAV BUTTON CLICK HANDLER
|
| 172 |
+
navBtn.addEventListener('click', (e) => {
|
| 173 |
+
e.preventDefault();
|
| 174 |
+
if (isLoggedIn) {
|
| 175 |
+
logoutModal.classList.remove('hidden');
|
| 176 |
+
} else {
|
| 177 |
+
loginModal.classList.remove('hidden');
|
| 178 |
+
}
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
// 2. LOGOUT MODAL HANDLERS
|
| 182 |
+
document.getElementById('confirm-logout-btn').addEventListener('click', () => {
|
| 183 |
+
window.location.href = '/api/logout';
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
document.getElementById('cancel-logout-btn').addEventListener('click', () => {
|
| 187 |
+
logoutModal.classList.add('hidden');
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
// 3. LOGIN MODAL HANDLERS
|
| 191 |
+
closeBtn.addEventListener('click', () => loginModal.classList.add('hidden'));
|
| 192 |
+
|
| 193 |
+
submitBtn.addEventListener('click', () => {
|
| 194 |
+
const u = document.getElementById('username').value;
|
| 195 |
+
const p = document.getElementById('password').value;
|
| 196 |
+
|
| 197 |
+
fetch('/api/login', {
|
| 198 |
+
method: 'POST',
|
| 199 |
+
headers: {'Content-Type': 'application/json'},
|
| 200 |
+
body: JSON.stringify({username: u, password: p})
|
| 201 |
+
})
|
| 202 |
+
.then(res => {
|
| 203 |
+
if(res.ok) return res.json();
|
| 204 |
+
throw new Error('Invalid credentials');
|
| 205 |
+
})
|
| 206 |
+
.then(data => {
|
| 207 |
+
isLoggedIn = true;
|
| 208 |
+
updateUIForAuth();
|
| 209 |
+
loginModal.classList.add('hidden');
|
| 210 |
+
document.getElementById('username').value = '';
|
| 211 |
+
document.getElementById('password').value = '';
|
| 212 |
+
document.getElementById('login-error').innerText = '';
|
| 213 |
+
})
|
| 214 |
+
.catch(err => {
|
| 215 |
+
document.getElementById('login-error').innerText = "Invalid Username or Password";
|
| 216 |
+
});
|
| 217 |
+
});
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// calculates and formats the time difference for 'X hrs ago'
|
| 221 |
+
function formatRelativeTime(timestamp) {
|
| 222 |
+
const now = new Date();
|
| 223 |
+
const posted = new Date(timestamp);
|
| 224 |
+
const diffMs = now - posted;
|
| 225 |
+
const diffMins = Math.floor(diffMs / 60000);
|
| 226 |
+
|
| 227 |
+
if (isNaN(diffMins)) return "Unknown time";
|
| 228 |
+
if (diffMins < 1) return "Just now";
|
| 229 |
+
if (diffMins < 60) return diffMins + " mins ago";
|
| 230 |
+
if (diffMins < 1440) {
|
| 231 |
+
const hours = Math.floor(diffMins / 60);
|
| 232 |
+
return hours + (hours === 1 ? " hr ago" : " hrs ago"); // Use "hrs ago"
|
| 233 |
+
}
|
| 234 |
+
const days = Math.floor(diffMins / 1440);
|
| 235 |
+
return days + (days === 1 ? " day ago" : " days ago");
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
// ----------------------------------------------------------------------
|
| 239 |
+
// RENDER LOGIC
|
| 240 |
+
// ----------------------------------------------------------------------
|
| 241 |
+
|
| 242 |
+
// renders the list of incident posts in the sidebar feed
|
| 243 |
+
function renderSidebar(data) {
|
| 244 |
+
const sidebar = document.getElementById('incident-feed');
|
| 245 |
+
if (!sidebar) return;
|
| 246 |
+
sidebar.innerHTML = '';
|
| 247 |
+
|
| 248 |
+
const countEl = document.getElementById('dashboard-alert-count');
|
| 249 |
+
if (countEl) {
|
| 250 |
+
countEl.innerText = data.length;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
if (data.length === 0) {
|
| 254 |
+
sidebar.innerHTML = `<p style="color: #ccc; padding: 20px; text-align: center;">No alerts found.</p>`;
|
| 255 |
+
return;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
data.forEach(post => {
|
| 259 |
+
const box = document.createElement('div');
|
| 260 |
+
box.className = 'alert-box';
|
| 261 |
+
|
| 262 |
+
if (post.id === lastClickedId) box.classList.add('selected');
|
| 263 |
+
else if (post.status === 'New' && !readPostIds.has(post.id)) box.classList.add('unread');
|
| 264 |
+
|
| 265 |
+
const relativeTimeStr = formatRelativeTime(post.timestamp);
|
| 266 |
+
// const timeStr = new Date(post.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
| 267 |
+
|
| 268 |
+
let statusColor = '#ed4801'; /*ed4801*/
|
| 269 |
+
if (post.status === 'Verified') statusColor = '#ffc107'; /*00C851*/
|
| 270 |
+
if (post.status === 'Resolved') statusColor = '#00C851'; /*33b5e5*/
|
| 271 |
+
|
| 272 |
+
let statusText = `(${post.status})`;
|
| 273 |
+
if (post.status === 'New' && readPostIds.has(post.id)) statusText = "";
|
| 274 |
+
|
| 275 |
+
// Determine Badge Color Class
|
| 276 |
+
let assistClass = 'assist-badge'; // Default
|
| 277 |
+
const type = (post.assistance_type || "").toLowerCase();
|
| 278 |
+
if (type.includes('medical')) assistClass += ' assist-medical';
|
| 279 |
+
else if (type.includes('rescue')) assistClass += ' assist-rescue';
|
| 280 |
+
else if (type.includes('food')) assistClass += ' assist-food';
|
| 281 |
+
else if (type.includes('evac')) assistClass += ' assist-evac';
|
| 282 |
+
|
| 283 |
+
box.innerHTML = `
|
| 284 |
+
<div class="alert-icon" style="background-color: ${statusColor}"></div>
|
| 285 |
+
<div class="box-text">
|
| 286 |
+
<div class="box-title">
|
| 287 |
+
${post.disaster_type}
|
| 288 |
+
<span style="font-size:0.7em; opacity:0.7">${statusText}</span>
|
| 289 |
+
</div>
|
| 290 |
+
|
| 291 |
+
<div style="display: flex; align-items: center; margin-top: 4px; gap: 5px;">
|
| 292 |
+
<span class="${assistClass}">${post.assistance_type || "General"}</span>
|
| 293 |
+
<div class="box-subtitle" style="margin-top:0;">${post.location} • ${relativeTimeStr}</div>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
`;
|
| 297 |
+
|
| 298 |
+
box.addEventListener('click', () => {
|
| 299 |
+
const detailBox = document.getElementById('postInfo');
|
| 300 |
+
if (lastClickedId === post.id) {
|
| 301 |
+
detailBox.classList.remove('active');
|
| 302 |
+
box.classList.remove('selected');
|
| 303 |
+
lastClickedId = null;
|
| 304 |
+
renderSidebar(data);
|
| 305 |
+
} else {
|
| 306 |
+
document.querySelectorAll('.alert-box').forEach(el => el.classList.remove('selected'));
|
| 307 |
+
box.classList.add('selected');
|
| 308 |
+
lastClickedId = post.id;
|
| 309 |
+
readPostIds.add(post.id);
|
| 310 |
+
|
| 311 |
+
updateDetailView(post);
|
| 312 |
+
detailBox.classList.add('active');
|
| 313 |
+
|
| 314 |
+
// CRITICAL: Check auth again to show/hide buttons for this specific detail view
|
| 315 |
+
updateUIForAuth();
|
| 316 |
+
|
| 317 |
+
renderSidebar(data);
|
| 318 |
+
}
|
| 319 |
+
});
|
| 320 |
+
sidebar.appendChild(box);
|
| 321 |
+
});
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
// updates the detail panel with the selected post's information
|
| 325 |
+
function updateDetailView(post) {
|
| 326 |
+
const setText = (id, text) => {
|
| 327 |
+
const el = document.getElementById(id);
|
| 328 |
+
if (el) el.innerText = text;
|
| 329 |
+
};
|
| 330 |
+
|
| 331 |
+
setText('detail-title', post.title);
|
| 332 |
+
setText('detail-location', post.location || "Unknown");
|
| 333 |
+
setText('detail-assistance', post.assistance_type || "General");
|
| 334 |
+
setText('detail-contact-name', post.author ? (
|
| 335 |
+
// Check if the name contains a space (suggests a full name)
|
| 336 |
+
// If no space is found, assume it is a username and prepend 'u/'
|
| 337 |
+
post.author.includes(' ') ? post.author : `u/${post.author}`
|
| 338 |
+
) : "Unknown");
|
| 339 |
+
setText('detail-contact-number', post.contact_number || "Check Post");
|
| 340 |
+
setText('detail-body', post.content);
|
| 341 |
+
setText('detail-status', post.status);
|
| 342 |
+
|
| 343 |
+
const statusBadge = document.getElementById('detail-status');
|
| 344 |
+
if (statusBadge) {
|
| 345 |
+
// Clear all previous status classes
|
| 346 |
+
statusBadge.classList.remove('status-new', 'status-verified', 'status-resolved');
|
| 347 |
+
|
| 348 |
+
// Apply the correct new class
|
| 349 |
+
if (post.status) {
|
| 350 |
+
statusBadge.classList.add(`status-${post.status.toLowerCase()}`);
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
const link = document.getElementById('detail-link');
|
| 355 |
+
if (link) {
|
| 356 |
+
const isSimulated = typeof post.reddit_id === 'string' && (post.reddit_id.startsWith('fake') || post.reddit_id.startsWith('sim'));
|
| 357 |
+
link.href = isSimulated ? '#' : `https://reddit.com/comments/${post.reddit_id.replace('t3_', '')}`;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
const urgEl = document.getElementById('detail-urgency');
|
| 361 |
+
if (urgEl) {
|
| 362 |
+
urgEl.innerText = post.urgency_level;
|
| 363 |
+
urgEl.style.color = post.urgency_level === 'High' ? '#ff4444' : '#00C851';
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
const timeEl = document.getElementById('detail-time');
|
| 367 |
+
if (timeEl) {
|
| 368 |
+
// 1. Calculate relative and exact time strings
|
| 369 |
+
const posted = new Date(post.timestamp);
|
| 370 |
+
const relativeTime = formatRelativeTime(post.timestamp); // e.g., "2 hrs ago"
|
| 371 |
+
|
| 372 |
+
// 2. Format the exact time (e.g., 10:34 PM)
|
| 373 |
+
const exactTimeStr = posted.toLocaleTimeString([], {
|
| 374 |
+
hour: '2-digit',
|
| 375 |
+
minute: '2-digit',
|
| 376 |
+
hour12: true
|
| 377 |
+
});
|
| 378 |
+
|
| 379 |
+
// 3. Generate multi-line HTML structure with inline CSS
|
| 380 |
+
timeEl.innerHTML = `
|
| 381 |
+
<div style="font-size: 1.1em; font-weight: bold; color: white;">${exactTimeStr}</div>
|
| 382 |
+
|
| 383 |
+
<div style="font-size: 0.85em; color: #ed4801; margin-top: 2px; text-transform: uppercase">${relativeTime}</div>
|
| 384 |
+
`;
|
| 385 |
+
|
| 386 |
+
// The redundant if/else logic block for combining text is now removed.
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
// Synchronize buttons with the loaded post status
|
| 390 |
+
updateActionButtons(post.status);
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
// ----------------------------------------------------------------------
|
| 394 |
+
// STANDARD FUNCTIONS (FINAL WORKING VERSION)
|
| 395 |
+
// ----------------------------------------------------------------------
|
| 396 |
+
|
| 397 |
+
// handles the logic for updating the post status (Verify/Resolve)
|
| 398 |
+
function updateStatus(intendedStatus) {
|
| 399 |
+
if (!lastClickedId) return;
|
| 400 |
+
const badge = document.getElementById('detail-status');
|
| 401 |
+
// FIX: Read status and clean it by converting to lowercase and trimming whitespace
|
| 402 |
+
const currentStatus = badge ? badge.innerText.trim() : 'New';
|
| 403 |
+
let finalStatus;
|
| 404 |
+
|
| 405 |
+
if (intendedStatus === 'Verified') {
|
| 406 |
+
// ... (rest of the logic)
|
| 407 |
+
// Check against the current, clean status
|
| 408 |
+
if (currentStatus.toLowerCase() === 'verified') {
|
| 409 |
+
finalStatus = 'New';
|
| 410 |
+
} else {
|
| 411 |
+
finalStatus = 'Verified';
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
} else if (intendedStatus === 'Resolved') {
|
| 415 |
+
// ... (rest of the logic)
|
| 416 |
+
// Check against the current, clean status
|
| 417 |
+
if (currentStatus.toLowerCase() === 'resolved') {
|
| 418 |
+
finalStatus = 'New';
|
| 419 |
+
} else {
|
| 420 |
+
finalStatus = 'Resolved';
|
| 421 |
+
}
|
| 422 |
+
} else {
|
| 423 |
+
return;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
// Safety check: ensure we are actually changing the status
|
| 427 |
+
if (finalStatus === currentStatus) {
|
| 428 |
+
return;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
// --- API CALL AND UI UPDATE ---
|
| 432 |
+
fetch(`/api/posts/${lastClickedId}/status`, {
|
| 433 |
+
method: 'POST',
|
| 434 |
+
headers: { 'Content-Type': 'application/json' },
|
| 435 |
+
body: JSON.stringify({ status: finalStatus })
|
| 436 |
+
})
|
| 437 |
+
.then(res => {
|
| 438 |
+
if(res.status === 401) { alert("Unauthorized. Please log in."); return; }
|
| 439 |
+
// If the API call returns success (200), proceed with UI updates based on the finalStatus.
|
| 440 |
+
return res.json();
|
| 441 |
+
})
|
| 442 |
+
.then(data => {
|
| 443 |
+
if(data) {
|
| 444 |
+
fetchPosts();
|
| 445 |
+
fetchStats();
|
| 446 |
+
|
| 447 |
+
if (badge) {
|
| 448 |
+
// 1. Update text
|
| 449 |
+
badge.innerText = finalStatus;
|
| 450 |
+
|
| 451 |
+
// 2. Apply color class (visual sync fix)
|
| 452 |
+
badge.classList.remove('status-new', 'status-verified', 'status-resolved');
|
| 453 |
+
badge.classList.add(`status-${finalStatus.toLowerCase()}`);
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
// 3. Update buttons' appearance
|
| 457 |
+
updateActionButtons(finalStatus);
|
| 458 |
+
}
|
| 459 |
+
})
|
| 460 |
+
.catch(error => {
|
| 461 |
+
console.error("Status Update Failed:", error);
|
| 462 |
+
alert("Failed to update status due to network error.");
|
| 463 |
+
});
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
// pop up notificaiton for new alerts
|
| 467 |
+
function showNotification(message) {
|
| 468 |
+
const popup = document.getElementById('new-alert-notification');
|
| 469 |
+
const msgEl = document.getElementById('notification-message');
|
| 470 |
+
if (!popup || !msgEl) return;
|
| 471 |
+
|
| 472 |
+
msgEl.innerText = message;
|
| 473 |
+
|
| 474 |
+
// 1. Prepare for animation (ensure it's visible but off-screen)
|
| 475 |
+
popup.classList.remove('hidden');
|
| 476 |
+
|
| 477 |
+
// 2. Force reflow to ensure CSS animation starts correctly
|
| 478 |
+
void popup.offsetWidth;
|
| 479 |
+
|
| 480 |
+
// 3. Trigger slide-in animation
|
| 481 |
+
popup.classList.add('visible');
|
| 482 |
+
|
| 483 |
+
// 4. Set timeout to slide out after 6 seconds
|
| 484 |
+
setTimeout(() => {
|
| 485 |
+
popup.classList.remove('visible');
|
| 486 |
+
|
| 487 |
+
// 5. Hide completely after animation finishes (0.5s transition time in CSS)
|
| 488 |
+
setTimeout(() => {
|
| 489 |
+
popup.classList.add('hidden');
|
| 490 |
+
}, 500);
|
| 491 |
+
}, 6000); // Display for 6 seconds
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
// initializes and draws the chart.js graphs for stats modal
|
| 495 |
+
function initCharts() {
|
| 496 |
+
const ctxType = document.getElementById('typeChart');
|
| 497 |
+
const ctxUrg = document.getElementById('urgencyChart');
|
| 498 |
+
if (!ctxType || !ctxUrg) return;
|
| 499 |
+
|
| 500 |
+
typeChart = new Chart(ctxType.getContext('2d'), {
|
| 501 |
+
type: 'doughnut',
|
| 502 |
+
data: { labels: [], datasets: [{ data: [], backgroundColor: ['#ed4801', '#33b5e5', '#00C851', '#ffbb33', '#aa66cc'], borderWidth: 0 }] },
|
| 503 |
+
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: 'white' } } } }
|
| 504 |
+
});
|
| 505 |
+
|
| 506 |
+
urgencyChart = new Chart(ctxUrg.getContext('2d'), {
|
| 507 |
+
type: 'bar',
|
| 508 |
+
data: { labels: ['High', 'Low/Med'], datasets: [{ label: 'Count', data: [0, 0], backgroundColor: ['#ff4444', '#00C851'], borderRadius: 5 }] },
|
| 509 |
+
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: 'white' } }, x: { ticks: { color: 'white' } } }, plugins: { legend: { display: false } } }
|
| 510 |
+
});
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
// fetches statistical data from the API
|
| 514 |
+
function fetchStats() { fetch('/api/stats').then(res=>res.json()).then(data=>updateCharts(data)).catch(err=>console.error(err)); }
|
| 515 |
+
|
| 516 |
+
// updates the chart data and redraws the graphs
|
| 517 |
+
function updateCharts(data) { if(!typeChart || !urgencyChart) return; const types = data.disaster_types || {}; typeChart.data.labels = Object.keys(types); typeChart.data.datasets[0].data = Object.values(types); typeChart.update(); const levels = data.urgency_levels || {}; urgencyChart.data.datasets[0].data = [levels['High']||0, (levels['Low']||0)+(levels['Medium']||0)]; urgencyChart.update(); }
|
| 518 |
+
|
| 519 |
+
// fetches post data from the API based on current filter selections
|
| 520 |
+
function fetchPosts(q='') {
|
| 521 |
+
const sort = document.getElementById('sort-select')?.value||'newest';
|
| 522 |
+
const view = document.getElementById('view-select')?.value||'active';
|
| 523 |
+
const urgency = document.getElementById('urgency-select')?.value||'all';
|
| 524 |
+
const type = document.getElementById('type-select')?.value||'all';
|
| 525 |
+
const assist = document.getElementById('assist-select')?.value||'all';
|
| 526 |
+
const searchVal = document.querySelector('.search-input')?.value||'';
|
| 527 |
+
let url = `/api/posts?sort=${sort}&view=${view}&urgency=${urgency}&type=${type}&assist=${assist}`;
|
| 528 |
+
if(searchVal) url += `&query=${encodeURIComponent(searchVal)}`;
|
| 529 |
+
url += `&_=${new Date().getTime()}`;
|
| 530 |
+
fetch(url).then(r=>r.json()).then(d=>{renderSidebar(d); checkAudioAlert(d);}).catch(e=>console.error(e));
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
// checks for new high-urgency alerts and plays sound + shows notification
|
| 534 |
+
function checkAudioAlert(p){
|
| 535 |
+
const a=document.getElementById('alert-sound');
|
| 536 |
+
if(!a)return;
|
| 537 |
+
a.volume = 0.1;
|
| 538 |
+
let newAlertFound = false;
|
| 539 |
+
|
| 540 |
+
if (!isInitialLoadComplete) {
|
| 541 |
+
// 1. On the very first load after page navigation/refresh:
|
| 542 |
+
// Populate the known set with ALL current IDs and skip the alert.
|
| 543 |
+
p.forEach(x => knownPostIds.add(x.id));
|
| 544 |
+
isInitialLoadComplete = true;
|
| 545 |
+
return;
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
p.forEach(x => {
|
| 549 |
+
if(x.urgency_level === 'High' && !knownPostIds.has(x.id) && x.status !== 'Resolved') {
|
| 550 |
+
newAlertFound = true;
|
| 551 |
+
}
|
| 552 |
+
});
|
| 553 |
+
|
| 554 |
+
if(newAlertFound) {
|
| 555 |
+
a.play().catch(e=>{});
|
| 556 |
+
showNotification("NEW HIGH PRIORITY ALERT: Check Feed");
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
p.forEach(x => {
|
| 560 |
+
knownPostIds.add(x.id);
|
| 561 |
+
});
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
// sets up the search input logic with clear button
|
| 565 |
+
function setupSearch(){ const i=document.querySelector('.search-input'), c=document.querySelector('.clear-icon'); if(!i)return; i.addEventListener('input',()=>{if(i.value)c?.classList.remove('hidden');else{c?.classList.add('hidden');fetchPosts();}}); i.addEventListener('keydown',e=>{if(e.key==='Enter')fetchPosts();}); c?.addEventListener('click',()=>{i.value='';c.classList.add('hidden');fetchPosts();}); }
|
| 566 |
+
|
| 567 |
+
// Attach listeners once DOM is ready
|
| 568 |
+
(function () {
|
| 569 |
+
// Select all elements with data_tooltip attribute
|
| 570 |
+
const tooltipElements = document.querySelectorAll('[data_tooltip]');
|
| 571 |
+
|
| 572 |
+
tooltipElements.forEach(el => {
|
| 573 |
+
// When clicked: hide tooltip immediately by adding class
|
| 574 |
+
el.addEventListener('mousedown', (e) => {
|
| 575 |
+
// Add class so CSS hides tooltip; use mousedown for immediate feedback
|
| 576 |
+
el.classList.add('tooltip-hidden');
|
| 577 |
+
|
| 578 |
+
// Also remove focus so :focus doesn't keep hiding/showing unpredictably
|
| 579 |
+
if (typeof el.blur === 'function') {
|
| 580 |
+
el.blur();
|
| 581 |
+
}
|
| 582 |
+
});
|
| 583 |
+
|
| 584 |
+
// When mouse leaves the element: remove the hiding class so future hovers work
|
| 585 |
+
el.addEventListener('mouseleave', (e) => {
|
| 586 |
+
el.classList.remove('tooltip-hidden');
|
| 587 |
+
});
|
| 588 |
+
|
| 589 |
+
// Also remove hidden class on touchend for touch devices (optional)
|
| 590 |
+
el.addEventListener('touchend', () => {
|
| 591 |
+
el.classList.remove('tooltip-hidden');
|
| 592 |
+
});
|
| 593 |
+
});
|
| 594 |
+
})();
|
alisto_project/backend/simulate_feed.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import os
|
| 3 |
+
import pickle
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn.functional as F
|
| 6 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 7 |
+
from ingest_reddit import is_news_or_irrelevant, get_disaster_type, check_for_philippine_location
|
| 8 |
+
from ner_extractor import extract_entities
|
| 9 |
+
|
| 10 |
+
# ---------------------------------------------------------
|
| 11 |
+
# CONFIG & SETUP
|
| 12 |
+
# ---------------------------------------------------------
|
| 13 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 14 |
+
MODEL_DIR = os.path.join(BASE_DIR, 'models')
|
| 15 |
+
ROBERTA_DIR = os.path.join(MODEL_DIR, 'roberta_model')
|
| 16 |
+
TFIDF_PATH = os.path.join(MODEL_DIR, 'tfidf_ensemble.pkl')
|
| 17 |
+
|
| 18 |
+
# ---------------------------------------------------------
|
| 19 |
+
# LOAD BRAINS
|
| 20 |
+
# ---------------------------------------------------------
|
| 21 |
+
print("--- ALISTO: Loading Simulator ---")
|
| 22 |
+
|
| 23 |
+
tokenizer = None
|
| 24 |
+
roberta_model = None
|
| 25 |
+
tfidf_model = None
|
| 26 |
+
|
| 27 |
+
# 1. Load XLM-R (Context Expert)
|
| 28 |
+
try:
|
| 29 |
+
if os.path.exists(ROBERTA_DIR):
|
| 30 |
+
tokenizer = AutoTokenizer.from_pretrained(ROBERTA_DIR)
|
| 31 |
+
roberta_model = AutoModelForSequenceClassification.from_pretrained(ROBERTA_DIR)
|
| 32 |
+
roberta_model.eval()
|
| 33 |
+
print("✅ XLM-R Loaded")
|
| 34 |
+
else:
|
| 35 |
+
print("❌ Failed to load XLM-R (Folder missing)")
|
| 36 |
+
except Exception as e:
|
| 37 |
+
print(f"❌ Error loading XLM-R: {e}")
|
| 38 |
+
|
| 39 |
+
# 2. Load TF-IDF (Gatekeeper)
|
| 40 |
+
try:
|
| 41 |
+
if os.path.exists(TFIDF_PATH):
|
| 42 |
+
with open(TFIDF_PATH, 'rb') as f:
|
| 43 |
+
tfidf_model = pickle.load(f)
|
| 44 |
+
print("✅ TF-IDF Loaded")
|
| 45 |
+
else:
|
| 46 |
+
print("❌ Failed to load TF-IDF (File missing)")
|
| 47 |
+
except Exception as e:
|
| 48 |
+
print(f"❌ Error loading TF-IDF: {e}")
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------
|
| 51 |
+
# PREDICTION LOGIC (Must match ingest_reddit.py)
|
| 52 |
+
# ---------------------------------------------------------
|
| 53 |
+
def predict_urgency(text):
|
| 54 |
+
# 1. Gatekeeper (TF-IDF)
|
| 55 |
+
if tfidf_model:
|
| 56 |
+
probs = tfidf_model.predict_proba([text])[0]
|
| 57 |
+
tfidf_conf = probs[1]
|
| 58 |
+
|
| 59 |
+
if tfidf_conf < 0.20:
|
| 60 |
+
return False, tfidf_conf, "TF-IDF Reject"
|
| 61 |
+
|
| 62 |
+
# 2. Context Expert (RoBERTa)
|
| 63 |
+
if roberta_model and tokenizer:
|
| 64 |
+
inputs = tokenizer(text, return_tensors="pt", truncation=True, padding=True, max_length=128)
|
| 65 |
+
with torch.no_grad():
|
| 66 |
+
outputs = roberta_model(**inputs)
|
| 67 |
+
r_probs = F.softmax(outputs.logits, dim=-1)
|
| 68 |
+
roberta_conf = r_probs[0][1].item()
|
| 69 |
+
|
| 70 |
+
return (roberta_conf > 0.5), roberta_conf, "RoBERTa"
|
| 71 |
+
|
| 72 |
+
return False, 0.0, "No Model"
|
| 73 |
+
|
| 74 |
+
# ---------------------------------------------------------
|
| 75 |
+
# TEST DATA
|
| 76 |
+
# ---------------------------------------------------------
|
| 77 |
+
TEST_POSTS = [
|
| 78 |
+
# --- SHOULD BE ACCEPTED ---
|
| 79 |
+
"Tulong po, stuck kami sa bubong ng bahay, tumataas tubig sa Marikina!",
|
| 80 |
+
"Rescue needed at Provident Village, 3 kids trapped inside ceiling.",
|
| 81 |
+
"Wala na kaming matatakbuhan, lampas tao na ang baha sa Cainta.",
|
| 82 |
+
"Emergency! Landslide blocked the road in Baguio, need extraction.",
|
| 83 |
+
"Please help us, flood entering 2nd floor in San Mateo Rizal.",
|
| 84 |
+
|
| 85 |
+
# --- SHOULD BE REJECTED ---
|
| 86 |
+
"Breaking News: Typhoon Signal No 4 raised in Bicol.",
|
| 87 |
+
"Open for donations via GCash for typhoon victims.",
|
| 88 |
+
"Looking for volunteers to repack relief goods at Ateneo.",
|
| 89 |
+
"Stay safe everyone, praying for all affected.",
|
| 90 |
+
"Discussion: Why is the government so slow?",
|
| 91 |
+
"My heart breaks seeing the flood photos."
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
+
def run_simulation():
|
| 95 |
+
print("\n--- 🟢 STARTING SIMULATION ---\n")
|
| 96 |
+
|
| 97 |
+
for text in TEST_POSTS:
|
| 98 |
+
print(f"📝 Post: {text[:60]}...")
|
| 99 |
+
|
| 100 |
+
# A. Logic Filter
|
| 101 |
+
is_bad, reason = is_news_or_irrelevant(text)
|
| 102 |
+
if is_bad:
|
| 103 |
+
print(f" ❌ BLOCKED by Logic: {reason}")
|
| 104 |
+
print("-" * 50)
|
| 105 |
+
time.sleep(0.5)
|
| 106 |
+
continue
|
| 107 |
+
|
| 108 |
+
# B. AI Prediction
|
| 109 |
+
is_urgent, score, source = predict_urgency(text)
|
| 110 |
+
|
| 111 |
+
if is_urgent:
|
| 112 |
+
# C. Entity Extraction
|
| 113 |
+
ner = extract_entities(text)
|
| 114 |
+
locs = ner.get('locations', [])
|
| 115 |
+
disaster = get_disaster_type(text)
|
| 116 |
+
|
| 117 |
+
print(f" ✅ ACCEPTED ({source} Conf: {score:.2%})")
|
| 118 |
+
print(f" 📍 Location: {locs}")
|
| 119 |
+
print(f" 🌊 Type: {disaster}")
|
| 120 |
+
else:
|
| 121 |
+
print(f" ❌ REJECTED by AI (Conf: {score:.2%})")
|
| 122 |
+
|
| 123 |
+
print("-" * 50)
|
| 124 |
+
time.sleep(1)
|
| 125 |
+
|
| 126 |
+
if __name__ == "__main__":
|
| 127 |
+
run_simulation()
|
alisto_project/backend/style.css
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* --- GLOBAL RESET & FONTS --- */
|
| 2 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 3 |
+
|
| 4 |
+
body {
|
| 5 |
+
font-family: 'Roboto', sans-serif;
|
| 6 |
+
color: #fff;
|
| 7 |
+
background: #28282B;
|
| 8 |
+
overflow: hidden;
|
| 9 |
+
height: 100vh;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
h1, h2, h3 { font-family: 'Montserrat', sans-serif; }
|
| 13 |
+
|
| 14 |
+
/* --- HEADER --- */
|
| 15 |
+
/* --- HEADER STYLES --- */
|
| 16 |
+
.main-header {
|
| 17 |
+
position: fixed;
|
| 18 |
+
top: 0;
|
| 19 |
+
left: 0;
|
| 20 |
+
width: 100%;
|
| 21 |
+
height: 60px;
|
| 22 |
+
padding: 0 2vw;
|
| 23 |
+
|
| 24 |
+
background: rgba(0, 0, 0, 0.9);
|
| 25 |
+
backdrop-filter: blur(8px);
|
| 26 |
+
z-index: 999;
|
| 27 |
+
|
| 28 |
+
display: flex;
|
| 29 |
+
justify-content: space-between;
|
| 30 |
+
align-items: center;
|
| 31 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
| 32 |
+
|
| 33 |
+
/* FIX: Prevent layout breaking on very small screens */
|
| 34 |
+
min-width: 600px;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/* 1. LOGO (Locked) */
|
| 38 |
+
.logo {
|
| 39 |
+
display: flex;
|
| 40 |
+
flex-direction: column;
|
| 41 |
+
line-height: 1;
|
| 42 |
+
flex-shrink: 0; /* Never shrink */
|
| 43 |
+
margin-right: 20px;
|
| 44 |
+
}
|
| 45 |
+
.logo-main { font-family: 'Montserrat', sans-serif; font-size: 1.8em; font-weight: 900; color: #ed4801; }
|
| 46 |
+
.logo-sub { font-size: 0.65em; font-weight: 400; color: #ccc; letter-spacing: 2px; text-transform: uppercase; margin-top: 2px; }
|
| 47 |
+
|
| 48 |
+
/* 2. NAVIGATION (Locked) */
|
| 49 |
+
.main-nav {
|
| 50 |
+
display: flex;
|
| 51 |
+
align-items: center;
|
| 52 |
+
flex-shrink: 0; /* Never shrink */
|
| 53 |
+
margin-left: 20px;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.main-nav .nav-link {
|
| 57 |
+
color: #ccc;
|
| 58 |
+
text-decoration: none;
|
| 59 |
+
font-weight: 500;
|
| 60 |
+
font-size: 0.9em;
|
| 61 |
+
padding: 8px 15px;
|
| 62 |
+
border-radius: 6px;
|
| 63 |
+
transition: all 0.3s ease;
|
| 64 |
+
margin-left: 10px;
|
| 65 |
+
|
| 66 |
+
/* FIX: Keep text on one line */
|
| 67 |
+
white-space: nowrap;
|
| 68 |
+
}
|
| 69 |
+
.main-nav .nav-link {
|
| 70 |
+
color: #ccc;
|
| 71 |
+
text-decoration: none;
|
| 72 |
+
font-weight: 500;
|
| 73 |
+
font-size: 0.9em;
|
| 74 |
+
padding: 8px 15px;
|
| 75 |
+
border-radius: 6px;
|
| 76 |
+
transition: all 0.3s ease;
|
| 77 |
+
margin-left: 10px;
|
| 78 |
+
|
| 79 |
+
/* FIX: Keep text on one line */
|
| 80 |
+
white-space: nowrap;
|
| 81 |
+
|
| 82 |
+
/* FIX: Add invisible border so it doesn't jump when active */
|
| 83 |
+
border: 1px solid transparent;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.main-nav .nav-link:hover {
|
| 87 |
+
color: white;
|
| 88 |
+
background-color: rgba(255, 255, 255, 0.1);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.main-nav .nav-link.active {
|
| 92 |
+
color: #ed4801;
|
| 93 |
+
border: 1px solid #ed4801;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/* --- SEARCH BAR (Stabilized) --- */
|
| 97 |
+
.search-container {
|
| 98 |
+
display: flex;
|
| 99 |
+
align-items: center;
|
| 100 |
+
background: rgba(255, 255, 255, 0.1);
|
| 101 |
+
border-radius: 20px;
|
| 102 |
+
padding: 8px 15px;
|
| 103 |
+
|
| 104 |
+
/* FIX: Remove 'clamp'. Use standard width so it stops jittering. */
|
| 105 |
+
width: 100%;
|
| 106 |
+
max-width: 400px;
|
| 107 |
+
|
| 108 |
+
transition: background 0.3s ease;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.search-container:hover {
|
| 112 |
+
background: rgba(255, 255, 255, 0.15);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.search-input {
|
| 116 |
+
flex-grow: 1;
|
| 117 |
+
border: none;
|
| 118 |
+
background: transparent;
|
| 119 |
+
color: white;
|
| 120 |
+
font-size: 0.9em;
|
| 121 |
+
padding: 0;
|
| 122 |
+
outline: none;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.search-icon {
|
| 126 |
+
color: #ed4801;
|
| 127 |
+
margin-left: 5px;
|
| 128 |
+
cursor: pointer;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.clear-icon {
|
| 132 |
+
color: #ccc;
|
| 133 |
+
margin-right: 10px;
|
| 134 |
+
cursor: pointer;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.clear-icon.hidden {
|
| 138 |
+
display: none;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/* --- HERO --- */
|
| 142 |
+
.hero {
|
| 143 |
+
margin-top: 60px;
|
| 144 |
+
height: calc(100vh - 60px);
|
| 145 |
+
width: 100%;
|
| 146 |
+
background: url('images/bg2-logan.jpg') center/cover no-repeat fixed;
|
| 147 |
+
|
| 148 |
+
/* FLEXBOX MAGIC */
|
| 149 |
+
display: flex;
|
| 150 |
+
align-items: stretch; /* Make both columns full height */
|
| 151 |
+
flex-wrap: nowrap; /* Desktop: Side-by-side. Mobile: We change this via media query */
|
| 152 |
+
|
| 153 |
+
padding: 0;
|
| 154 |
+
gap: 0;
|
| 155 |
+
}
|
| 156 |
+
/* --- SIDEBAR WRAPPER --- */
|
| 157 |
+
.sidebar-wrapper {
|
| 158 |
+
display: flex;
|
| 159 |
+
flex-direction: column;
|
| 160 |
+
flex: 0 0 400px;
|
| 161 |
+
width: 400px;
|
| 162 |
+
|
| 163 |
+
height: 100%;
|
| 164 |
+
|
| 165 |
+
background: rgba(0, 0, 0, 0.6);
|
| 166 |
+
border-right: 1px solid rgba(255, 255, 255, 0.2);
|
| 167 |
+
backdrop-filter: blur(5px);
|
| 168 |
+
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5);
|
| 169 |
+
/* overflow: hidden; */
|
| 170 |
+
z-index: 10;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/* --- FILTER BAR --- */
|
| 174 |
+
.filter-bar {
|
| 175 |
+
display: flex; gap: 8px; padding: 15px;
|
| 176 |
+
background: rgba(0,0,0,0.3); border-bottom: 1px solid rgba(255,255,255,0.1);
|
| 177 |
+
align-items: center;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.filter-bar-c {
|
| 181 |
+
display: flex; gap: 8px; width: 100%;
|
| 182 |
+
justify-content: center; align-items: center; flex-direction: column;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
.filter-bar-r {
|
| 187 |
+
display: flex; gap: 8px; width: 100%;
|
| 188 |
+
justify-content: space-between; align-items: center; flex-direction: row;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.filter-select { background: rgba(99, 97, 97, 0.1); color: #acabab; border: 1px solid #555; border-radius: 4px; padding: 5px; font-size: 0.8rem; outline: none; cursor: pointer; flex-grow: 1; min-width: 0; }
|
| 192 |
+
.pulse-btn, .icon-btn { flex-shrink: 0; }
|
| 193 |
+
.icon-btn { background: #ed4801; color: white; border: none; border-radius: 8px; width: 30px; height: 30px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
| 194 |
+
.pulse-btn { background: linear-gradient(135deg, #ed4801, #ff7e42); color: white; border: none; border-radius: 50%; width: 35px; height: 35px; cursor: pointer; box-shadow: 0 0 10px rgba(237, 72, 1, 0.5); display: flex; align-items: center; justify-content: center; animation: pulse-glow 2s infinite; }
|
| 195 |
+
@keyframes pulse-glow { 0% { box-shadow: 0 0 0 0 rgba(237, 72, 1, 0.7); } 70% { box-shadow: 0 0 0 10px rgba(237, 72, 1, 0); } }
|
| 196 |
+
.separator { width: 1px; background: rgba(255,255,255,0.2); margin: 0 5px; height: 20px; }
|
| 197 |
+
|
| 198 |
+
/* --- SIDEBAR LIST --- */
|
| 199 |
+
.sidebar {
|
| 200 |
+
width: 100%; height: 100%;
|
| 201 |
+
background: transparent; border: none; box-shadow: none;
|
| 202 |
+
padding: 10px; overflow-y: auto; overflow-x: hidden;
|
| 203 |
+
}
|
| 204 |
+
.sidebar::-webkit-scrollbar { width: 6px; }
|
| 205 |
+
.sidebar::-webkit-scrollbar-thumb { background: rgba(237, 72, 1, 0.5); border-radius: 10px; }
|
| 206 |
+
|
| 207 |
+
/* --- ALERT BOXES --- */
|
| 208 |
+
.alert-box { display: flex; align-items: center; background: rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 16px; margin-bottom: 10px; cursor: pointer; transition: transform 0.2s ease, background 0.2s; border-left: 4px solid transparent; }
|
| 209 |
+
.alert-box:hover { background: rgba(255, 255, 255, 0.2); transform: translateX(5px); }
|
| 210 |
+
.alert-box.selected { background: rgba(237, 72, 1, 0.3); border-left: 4px solid #ed4801; }
|
| 211 |
+
.alert-box.unread { background: rgba(255, 255, 255, 0.15); border-left: 3px solid #ed4801; opacity: 1; }
|
| 212 |
+
.alert-icon { width: 12px; height: 12px; background-color: #ed4801; border-radius: 50%; margin-right: 15px; flex-shrink: 0; }
|
| 213 |
+
.box-text { display: flex; flex-direction: column; }
|
| 214 |
+
.box-title { font-size: 0.9rem; font-weight: 700; margin-bottom: 4px; color: #fff; text-transform: uppercase; }
|
| 215 |
+
.box-subtitle { font-size: 0.8rem; color: #ccc; }
|
| 216 |
+
.active-alert-counter{ padding: 5px 10px 5px 10px; text-align: left; }
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
/* --- DETAIL BOX (ANIMATED) --- */
|
| 220 |
+
.detail-box {
|
| 221 |
+
display: flex;
|
| 222 |
+
flex-direction: column;
|
| 223 |
+
|
| 224 |
+
/* FIX: Take up all remaining space */
|
| 225 |
+
flex: 1;
|
| 226 |
+
|
| 227 |
+
/* FIX: Prevents the box from forcing the screen wider if text is long */
|
| 228 |
+
min-width: 0;
|
| 229 |
+
|
| 230 |
+
/* Keep it floating */
|
| 231 |
+
height: calc(100% - 40px); /* Full height minus margins */
|
| 232 |
+
margin: 20px;
|
| 233 |
+
|
| 234 |
+
background: rgba(0,0,0,0.9);
|
| 235 |
+
padding: 30px;
|
| 236 |
+
border-radius: 15px;
|
| 237 |
+
color: #fff;
|
| 238 |
+
border: 1px solid #ed4801;
|
| 239 |
+
z-index: 20;
|
| 240 |
+
|
| 241 |
+
/* Animation: Hidden by default */
|
| 242 |
+
opacity: 0;
|
| 243 |
+
transform-origin: top right;
|
| 244 |
+
|
| 245 |
+
transform: scale(0.9) translateX(0);
|
| 246 |
+
transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
| 247 |
+
pointer-events: none;
|
| 248 |
+
backdrop-filter: blur(5px);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
/* Animation: Visible state */
|
| 252 |
+
.detail-box.active {
|
| 253 |
+
opacity: 1;
|
| 254 |
+
transform: scale(1) translateX(0);
|
| 255 |
+
pointer-events: auto;
|
| 256 |
+
box-shadow: -10px 10px 30px rgba(0,0,0,0.5);
|
| 257 |
+
}
|
| 258 |
+
/* --- DETAIL CONTENT --- */
|
| 259 |
+
.detail-header-r { display: flex; flex-direction: row; justify-content: space-between; margin: 0 0 10px; align-items: center; }
|
| 260 |
+
.detail-title { font-size: 24px; font-weight: 700; margin: 0; width: 70%; }
|
| 261 |
+
.detail-redirect { font-size: 14px; color: #f6510a; text-decoration: none; border: 1px solid #f6510a; padding: 5px 10px; border-radius: 4px; transition: 0.3s; }
|
| 262 |
+
.detail-redirect:hover { background: #f6510a; color: white; }
|
| 263 |
+
.detail-line { border: none; border-top: 1px solid #ed4801; margin-bottom: 20px; }
|
| 264 |
+
.detail-important-r { display: flex; flex-direction: row; justify-content: space-between; margin-top: 10px; }
|
| 265 |
+
.detail-row1-c { display: flex; flex-direction: column; gap: 15px; flex: 1; }
|
| 266 |
+
.detail-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: #888; margin-bottom: 5px; }
|
| 267 |
+
.detail-content { margin-top: 50px; line-height: 1.6; font-size: 0.95rem; max-height: 400px; overflow-y: auto; color: #ddd; }
|
| 268 |
+
.detail-row1-col2-r { display: flex; flex-direction: row; gap: 20px; align-items: flex-start; }
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
.detail-content::-webkit-scrollbar { width: 6px; }
|
| 272 |
+
.detail-content::-webkit-scrollbar-track { background: transparent; }
|
| 273 |
+
.detail-content::-webkit-scrollbar-thumb { background: rgba(237, 72, 1, 0.5); border-radius: 10px; }
|
| 274 |
+
.detail-content::-webkit-scrollbar-thumb:hover { background: rgba(237, 72, 1, 1); }
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
/* Buttons */
|
| 278 |
+
.action-buttons { display: flex; gap: 10px; }
|
| 279 |
+
.action-btn { border: none; padding: 5px 12px; border-radius: 4px; color: white; font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; gap: 5px; }
|
| 280 |
+
.verify-btn { background: #00C851; } .verify-btn:hover { background: #00e25b; }
|
| 281 |
+
.resolve-btn { background: #33b5e5; } .resolve-btn:hover { background: #4ec2ec; }
|
| 282 |
+
.detail-redirect { border: 1px solid #ed4801; color: #ed4801; padding: 5px 12px; border-radius: 4px; text-decoration: none; font-size: 0.8rem; display: flex; align-items: center; justify-content: center; }
|
| 283 |
+
.detail-redirect:hover { background: #ed4801; color: white; }
|
| 284 |
+
/* .status-badge { margin-top: 5px; font-size: 0.8rem; padding: 2px 8px; border-radius: 4px; background: #555; display: inline-block; } */
|
| 285 |
+
.confirm-btn { background: #ed4801; } .resolve-btn:hover { background: #4ec2ec; }
|
| 286 |
+
|
| 287 |
+
/* --- STATUS BADGE COLOR CLASSES --- */
|
| 288 |
+
/* These classes are applied via JavaScript to synchronize with button clicks */
|
| 289 |
+
|
| 290 |
+
.status-badge {
|
| 291 |
+
margin-top: 5px;
|
| 292 |
+
font-size: 0.8rem;
|
| 293 |
+
padding: 2px 8px;
|
| 294 |
+
border-radius: 4px;
|
| 295 |
+
background: #555;
|
| 296 |
+
display: inline-block;
|
| 297 |
+
font-weight: bold; /* Added for clarity */
|
| 298 |
+
text-transform: uppercase;
|
| 299 |
+
transition: background-color 0.2s; /* Added transition */
|
| 300 |
+
color: white; /* Base text color */
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
/* Status: NEW (Default State/Unverified) */
|
| 304 |
+
.status-new {
|
| 305 |
+
background-color: #555; /* Amber/Yellow ffc107 333 */
|
| 306 |
+
color: #ffffff;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
/* Status: VERIFIED */
|
| 310 |
+
.status-verified {
|
| 311 |
+
background-color: #ffc107; /* Green */
|
| 312 |
+
color: #ffffff;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
/* Status: RESOLVED */
|
| 316 |
+
.status-resolved {
|
| 317 |
+
background-color: #28a745; /* Gray 28a745*/
|
| 318 |
+
color: #ffffff;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
/* Base Style for all action buttons (like 'Post' or 'Verify') */
|
| 322 |
+
.action-btn1 {
|
| 323 |
+
/* Basic button styling (adjust padding, font, etc., as needed) */
|
| 324 |
+
padding: 6px 12px;
|
| 325 |
+
border-radius: 4px;
|
| 326 |
+
font-weight: 500;
|
| 327 |
+
cursor: pointer;
|
| 328 |
+
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
|
| 329 |
+
|
| 330 |
+
/* Ensure icon and text are aligned */
|
| 331 |
+
display: inline-flex;
|
| 332 |
+
align-items: center;
|
| 333 |
+
gap: 5px; /* Spacing between icon and text */
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
/* 1. Default (Unverified) State: Green Outline only */
|
| 337 |
+
#verify-btn {
|
| 338 |
+
background-color: transparent;
|
| 339 |
+
color: #ffc107; /* Green text color */
|
| 340 |
+
border: 1px solid #ffc107; /* Green border (outline) */
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
/* 2. Verified State: Entirely Green */
|
| 344 |
+
#verify-btn.is-verified {
|
| 345 |
+
background-color: #ffc107; /* Solid Green background */
|
| 346 |
+
color: #ffffff; /* White text/icon */
|
| 347 |
+
border-color: #ffc107; /* Maintain border color */
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
/* 3. Hover States */
|
| 351 |
+
#verify-btn:hover:not(.is-verified) {
|
| 352 |
+
background-color: rgba(40, 167, 69, 0.1); /* Light green background on hover */
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
#verify-btn.is-verified:hover {
|
| 356 |
+
/* Make the button slightly darker green on hover when it's already verified */
|
| 357 |
+
background-color: #c77f0c;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
/* --- RESOLVE BUTTON --- */
|
| 361 |
+
/* 1. Default (Unresolved) State: Blue Outline only */
|
| 362 |
+
#resolve-btn {
|
| 363 |
+
background-color: transparent;
|
| 364 |
+
color: #28a745; /* Blue text color */
|
| 365 |
+
border: 1px solid #28a745; /* Blue border (outline) */
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
/* 2. Resolved State: Entirely Blue */
|
| 369 |
+
#resolve-btn.is-resolved {
|
| 370 |
+
background-color: #28a745; /* Solid Blue background */
|
| 371 |
+
color: #ffffff; /* White text/icon */
|
| 372 |
+
border-color: #28a745;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
/* 3. Hover States (When Unresolved) */
|
| 376 |
+
#resolve-btn:hover:not(.is-resolved) {
|
| 377 |
+
background-color: rgba(0, 123, 255, 0.1); /* Light blue background on hover */
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
/* 4. Hover States (When Already Resolved) */
|
| 381 |
+
#resolve-btn.is-resolved:hover {
|
| 382 |
+
/* Make the button slightly darker blue on hover when it's already resolved */
|
| 383 |
+
background-color: #157349;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
/* --- MODAL --- */
|
| 388 |
+
.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(5px); z-index: 2000; display: flex; align-items: center; justify-content: center; opacity: 1; transition: opacity 0.3s ease; }
|
| 389 |
+
.modal-overlay.hidden { opacity: 0; pointer-events: none; display: none !important; }
|
| 390 |
+
.modal-content { background: #1a1a1a; border: 1px solid #444; padding: 30px; border-radius: 12px; width: 90%; max-width: 900px; }
|
| 391 |
+
.modal-header { display: flex; justify-content: space-between; margin-bottom: 20px; }
|
| 392 |
+
.charts-container { display: flex; flex-wrap: wrap; gap: 20px; justify-content: center; margin-top: 20px; }
|
| 393 |
+
.chart-wrapper { background: rgba(255, 255, 255, 0.05); border-radius: 12px; padding: 20px; flex: 1; min-width: 300px; max-height: 400px; display: flex; flex-direction: column; align-items: center; }
|
| 394 |
+
canvas { max-width: 100%; max-height: 300px; }
|
| 395 |
+
.icon-btn-ghost { background: transparent; border: none; color: #888; font-size: 1.5rem; cursor: pointer; }
|
| 396 |
+
.hidden { display: none !important; }
|
| 397 |
+
|
| 398 |
+
/* --- RESPONSIVE ADJUSTMENT --- */
|
| 399 |
+
@media (max-width: 850px) {
|
| 400 |
+
/* Unlock the body scrolling for small screens */
|
| 401 |
+
body {
|
| 402 |
+
overflow: auto;
|
| 403 |
+
height: auto;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.hero {
|
| 407 |
+
/* Stack vertically instead of side-by-side */
|
| 408 |
+
flex-direction: column;
|
| 409 |
+
height: auto;
|
| 410 |
+
min-height: 100vh;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.sidebar-wrapper {
|
| 414 |
+
/* Sidebar becomes full width banner at the top */
|
| 415 |
+
width: 100%;
|
| 416 |
+
flex: none; /* Turn off the fixed 350px logic */
|
| 417 |
+
height: 300px; /* Fixed height for list */
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.detail-box {
|
| 421 |
+
/* Box sits below sidebar */
|
| 422 |
+
width: 95%; /* Almost full width */
|
| 423 |
+
margin: 10px auto; /* Center it */
|
| 424 |
+
height: 500px; /* Fixed height for content */
|
| 425 |
+
flex: none;
|
| 426 |
+
|
| 427 |
+
/* Reset transform for mobile so it doesn't fly in weirdly */
|
| 428 |
+
transform: none;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
/* Ensure active state just shows opacity on mobile */
|
| 432 |
+
.detail-box.active {
|
| 433 |
+
transform: none;
|
| 434 |
+
}
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
/* --- LOGIN MODAL STYLES --- */
|
| 438 |
+
.small-modal { max-width: 400px; }
|
| 439 |
+
|
| 440 |
+
.login-form {
|
| 441 |
+
display: flex; flex-direction: column; gap: 15px;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.login-input {
|
| 445 |
+
background: rgba(255,255,255,0.1);
|
| 446 |
+
border: 1px solid #444;
|
| 447 |
+
color: white;
|
| 448 |
+
padding: 12px;
|
| 449 |
+
border-radius: 6px;
|
| 450 |
+
font-size: 1rem;
|
| 451 |
+
outline: none;
|
| 452 |
+
}
|
| 453 |
+
.login-input:focus { border-color: #ed4801; }
|
| 454 |
+
|
| 455 |
+
.full-width {
|
| 456 |
+
width: 100%;
|
| 457 |
+
justify-content: center;
|
| 458 |
+
padding: 12px;
|
| 459 |
+
font-size: 1rem;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
/* --- SIDEBAR ASSISTANCE BADGE --- */
|
| 463 |
+
.assist-badge {
|
| 464 |
+
display: inline-block;
|
| 465 |
+
font-size: 0.7rem;
|
| 466 |
+
font-weight: 700;
|
| 467 |
+
text-transform: uppercase;
|
| 468 |
+
padding: 2px 6px;
|
| 469 |
+
border-radius: 4px;
|
| 470 |
+
margin-right: 6px;
|
| 471 |
+
background: rgba(255, 255, 255, 0.1);
|
| 472 |
+
color: #ccc;
|
| 473 |
+
border: 1px solid #555;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
/* Specific Colors */
|
| 477 |
+
.assist-medical { color: #ff4444; border-color: #ff4444; background: rgba(255, 68, 68, 0.1); }
|
| 478 |
+
.assist-rescue { color: #ffbb33; border-color: #ffbb33; background: rgba(255, 187, 51, 0.1); }
|
| 479 |
+
.assist-food { color: #00C851; border-color: #00C851; background: rgba(0, 200, 81, 0.1); }
|
| 480 |
+
.assist-evac { color: #33b5e5; border-color: #33b5e5; background: rgba(51, 181, 229, 0.1); }
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
/* --- CUSTOM TOOLTIP STYLES --- */
|
| 484 |
+
|
| 485 |
+
/* 1. Base style for any element that has a tooltip */
|
| 486 |
+
[data_tooltip] {
|
| 487 |
+
position: relative;
|
| 488 |
+
cursor: pointer;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
/* 2. Create the tooltip content (::after) ALWAYS, but invisible */
|
| 492 |
+
[data_tooltip]::after {
|
| 493 |
+
content: attr(data_tooltip);
|
| 494 |
+
position: absolute;
|
| 495 |
+
top: calc(100% + 15px);
|
| 496 |
+
|
| 497 |
+
/* DEFAULT POSITIONING: Centered over parent */
|
| 498 |
+
left: 50%;
|
| 499 |
+
right: auto;
|
| 500 |
+
transform: translateX(-50%);
|
| 501 |
+
margin: 0;
|
| 502 |
+
|
| 503 |
+
/* Appearance & Visibility */
|
| 504 |
+
background-color: #ed4801;
|
| 505 |
+
color: #fff;
|
| 506 |
+
white-space: nowrap;
|
| 507 |
+
padding: 6px 10px;
|
| 508 |
+
border-radius: 4px;
|
| 509 |
+
font-size: 0.75rem;
|
| 510 |
+
font-weight: 500;
|
| 511 |
+
opacity: 0;
|
| 512 |
+
pointer-events: none;
|
| 513 |
+
transition: opacity 0.2s;
|
| 514 |
+
transition-delay: 0s; /* Instant disappearance */
|
| 515 |
+
z-index: 9999;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
/* 4a. LEFT EDGE OVERRIDE */
|
| 519 |
+
[data_tooltip_align="left"]::after {
|
| 520 |
+
left: 0;
|
| 521 |
+
right: auto;
|
| 522 |
+
transform: none;
|
| 523 |
+
margin-left: 10px;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
/* 4b. RIGHT EDGE OVERRIDE */
|
| 527 |
+
[data_tooltip_align="right"]::after {
|
| 528 |
+
right: 0;
|
| 529 |
+
left: auto;
|
| 530 |
+
transform: none;
|
| 531 |
+
margin-left: 0;
|
| 532 |
+
margin-right: 10px;
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
/* 5. ON HOVER — animate in (Fade only) */
|
| 536 |
+
[data_tooltip]:hover::after {
|
| 537 |
+
opacity: 1;
|
| 538 |
+
transition-delay: 0.5s !important;
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
/* Add this to your existing CSS */
|
| 542 |
+
[data_tooltip].tooltip-hidden:hover::after {
|
| 543 |
+
opacity: 0 !important;
|
| 544 |
+
transition-delay: 0s !important;
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
/* 5. Tooltip visibility on hover */
|
| 548 |
+
/* [data_tooltip]:hover::after,
|
| 549 |
+
[data_tooltip]:hover::before {
|
| 550 |
+
opacity: 1;
|
| 551 |
+
} */
|
| 552 |
+
|
| 553 |
+
/* Arrow — ALSO always created */
|
| 554 |
+
/* [data_tooltip]::before {
|
| 555 |
+
content: '';
|
| 556 |
+
position: absolute;
|
| 557 |
+
top: 100%;
|
| 558 |
+
left: 50%;
|
| 559 |
+
|
| 560 |
+
transform: translateX(-50%);
|
| 561 |
+
|
| 562 |
+
border-left: 6px solid transparent;
|
| 563 |
+
border-right: 6px solid transparent;
|
| 564 |
+
border-bottom: 6px solid #ed4801;
|
| 565 |
+
opacity: 0;
|
| 566 |
+
|
| 567 |
+
transition: opacity 0.2s;
|
| 568 |
+
transition-delay: 0s;
|
| 569 |
+
z-index: 9999;
|
| 570 |
+
} */
|
| 571 |
+
|
| 572 |
+
/* Floating Sidebar for Map Page */
|
| 573 |
+
.map-sidebar {
|
| 574 |
+
position: absolute;
|
| 575 |
+
right: 20px;
|
| 576 |
+
top: 20px;
|
| 577 |
+
width: 300px;
|
| 578 |
+
background: rgba(0, 0, 0, 0.85);
|
| 579 |
+
padding: 20px;
|
| 580 |
+
border-radius: 12px;
|
| 581 |
+
border: 1px solid #ed4801;
|
| 582 |
+
z-index: 1000; /* Sit on top of map */
|
| 583 |
+
color: white;
|
| 584 |
+
backdrop-filter: blur(5px);
|
| 585 |
+
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
.back-btn {
|
| 589 |
+
display: inline-block;
|
| 590 |
+
color: #ccc;
|
| 591 |
+
text-decoration: none;
|
| 592 |
+
margin-bottom: 15px;
|
| 593 |
+
font-size: 0.9em;
|
| 594 |
+
transition: color 0.2s;
|
| 595 |
+
}
|
| 596 |
+
.back-btn:hover { color: #ed4801; }
|
| 597 |
+
|
| 598 |
+
.stat-item {
|
| 599 |
+
margin-top: 10px;
|
| 600 |
+
font-size: 0.85rem;
|
| 601 |
+
color: #aaa;
|
| 602 |
+
}
|
| 603 |
+
.stat-highlight {
|
| 604 |
+
color: white;
|
| 605 |
+
font-weight: bold;
|
| 606 |
+
font-size: 1.1rem;
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
.profile-container {
|
| 610 |
+
position: relative;
|
| 611 |
+
margin-left: 10px;
|
| 612 |
+
height: 35px;
|
| 613 |
+
}
|
| 614 |
+
.profile-icon {
|
| 615 |
+
width: 35px;
|
| 616 |
+
height: 35px;
|
| 617 |
+
background: #ed4801; /* Orange profile background */
|
| 618 |
+
border-radius: 50%;
|
| 619 |
+
display: flex;
|
| 620 |
+
align-items: center;
|
| 621 |
+
justify-content: center;
|
| 622 |
+
color: white;
|
| 623 |
+
font-size: 1.1em;
|
| 624 |
+
cursor: pointer;
|
| 625 |
+
transition: box-shadow 0.2s;
|
| 626 |
+
}
|
| 627 |
+
.profile-icon:hover {
|
| 628 |
+
box-shadow: 0 0 0 4px rgba(237, 72, 1, 0.3);
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
/* Dropdown Menu Box */
|
| 632 |
+
.profile-dropdown {
|
| 633 |
+
position: absolute;
|
| 634 |
+
top: 45px;
|
| 635 |
+
right: 0;
|
| 636 |
+
width: 250px;
|
| 637 |
+
background: #1a1a1a;
|
| 638 |
+
border: 1px solid #333;
|
| 639 |
+
border-radius: 8px;
|
| 640 |
+
box-shadow: 0 8px 16px rgba(0,0,0,0.5);
|
| 641 |
+
z-index: 1000;
|
| 642 |
+
padding: 15px;
|
| 643 |
+
transform: translateY(10px);
|
| 644 |
+
transition: opacity 0.2s, transform 0.2s;
|
| 645 |
+
}
|
| 646 |
+
.profile-dropdown.hidden {
|
| 647 |
+
display: none;
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
/* Dropdown Header Info */
|
| 651 |
+
.profile-info-header {
|
| 652 |
+
display: flex;
|
| 653 |
+
flex-direction: column;
|
| 654 |
+
align-items: center;
|
| 655 |
+
padding-bottom: 10px;
|
| 656 |
+
}
|
| 657 |
+
.profile-icon-large {
|
| 658 |
+
width: 50px;
|
| 659 |
+
height: 50px;
|
| 660 |
+
background: #ff7e42;
|
| 661 |
+
border-radius: 50%;
|
| 662 |
+
display: flex;
|
| 663 |
+
align-items: center;
|
| 664 |
+
justify-content: center;
|
| 665 |
+
color: white;
|
| 666 |
+
font-size: 1.8em;
|
| 667 |
+
margin-bottom: 8px;
|
| 668 |
+
}
|
| 669 |
+
.dropdown-username {
|
| 670 |
+
font-weight: bold;
|
| 671 |
+
color: white;
|
| 672 |
+
font-size: 1.1em;
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
/* Logout Button */
|
| 676 |
+
.dropdown-separator {
|
| 677 |
+
border: none;
|
| 678 |
+
border-top: 1px solid #333;
|
| 679 |
+
margin: 10px 0;
|
| 680 |
+
}
|
| 681 |
+
.dropdown-logout-btn {
|
| 682 |
+
width: 100%;
|
| 683 |
+
background: #ed4801;
|
| 684 |
+
color: white;
|
| 685 |
+
border: none;
|
| 686 |
+
padding: 8px;
|
| 687 |
+
border-radius: 4px;
|
| 688 |
+
font-weight: 500;
|
| 689 |
+
cursor: pointer;
|
| 690 |
+
display: flex;
|
| 691 |
+
align-items: center;
|
| 692 |
+
justify-content: center;
|
| 693 |
+
gap: 8px;
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
@media (max-width: 1200px) {
|
| 697 |
+
|
| 698 |
+
/* Target the container holding the Verify/Resolve/Post buttons */
|
| 699 |
+
.detail-header-r .action-buttons {
|
| 700 |
+
/* Change the direction to vertical stacking */
|
| 701 |
+
flex-direction: column;
|
| 702 |
+
|
| 703 |
+
/* Ensure buttons stretch to full width of the container */
|
| 704 |
+
align-items: stretch;
|
| 705 |
+
|
| 706 |
+
/* Add gap for visual separation */
|
| 707 |
+
gap: 8px;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
/* Ensure the detail redirect link (Post button) also behaves like a block button */
|
| 711 |
+
.detail-redirect {
|
| 712 |
+
justify-content: center; /* Center the text/icon in the full-width button */
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
/* Ensure the action buttons themselves stretch */
|
| 716 |
+
.action-btn1 {
|
| 717 |
+
width: 100%; /* Ensure the buttons take full width */
|
| 718 |
+
}
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
/* style.css (Add these new selectors) */
|
| 722 |
+
|
| 723 |
+
.alert-popup {
|
| 724 |
+
/* Position the box: Fixed allows it to float with scrolling */
|
| 725 |
+
position: fixed;
|
| 726 |
+
top: 70px; /* Just below the 60px high header + 10px margin */
|
| 727 |
+
right: 20px;
|
| 728 |
+
|
| 729 |
+
/* Appearance */
|
| 730 |
+
background: #ed4801; /* ALISTO Primary Color */
|
| 731 |
+
color: white;
|
| 732 |
+
padding: 15px 25px;
|
| 733 |
+
border-radius: 10px; /* Rounded corners */
|
| 734 |
+
|
| 735 |
+
/* Layout */
|
| 736 |
+
display: flex;
|
| 737 |
+
align-items: center;
|
| 738 |
+
gap: 12px;
|
| 739 |
+
|
| 740 |
+
/* Shadow and Stacking */
|
| 741 |
+
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
| 742 |
+
z-index: 1000;
|
| 743 |
+
|
| 744 |
+
/* Animation Control */
|
| 745 |
+
opacity: 0;
|
| 746 |
+
transform: translateX(100%); /* Start off-screen to the right */
|
| 747 |
+
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
.alert-popup.visible {
|
| 751 |
+
opacity: 1;
|
| 752 |
+
transform: translateX(0); /* Slide into view */
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
/* Ensure the initial hidden state prevents any display issues */
|
| 756 |
+
.hidden {
|
| 757 |
+
display: none !important;
|
| 758 |
+
}
|
| 759 |
+
/* NOTE: You may need to remove 'display: none !important' from your existing
|
| 760 |
+
.hidden CSS class in style.css if you want to rely only on the opacity/transform
|
| 761 |
+
for the initial state, but for robust functionality, we often keep 'display: none'
|
| 762 |
+
for the .hidden state and only remove it for the sliding animation to work.
|
| 763 |
+
We'll use a .visible class to trigger the animation. */
|
alisto_project/backend/templates.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# templates.py
|
| 2 |
+
|
| 3 |
+
LOCATIONS = [
|
| 4 |
+
"Marikina", "Quezon City", "Manila", "Pasig", "Cainta", "Antipolo",
|
| 5 |
+
"Batangas", "Tagaytay", "Davao", "Cebu", "Bicol", "Albay",
|
| 6 |
+
"Pampanga", "Bulacan", "Laguna", "Cavite", "Tacloban", "Iligan",
|
| 7 |
+
"Navotas", "Malabon", "San Mateo", "Rizal", "Valenzuela", "Muntinlupa",
|
| 8 |
+
"Las Pinas", "Makati", "Mandaluyong", "San Juan", "Catanduanes",
|
| 9 |
+
"Isabela", "Cagayan", "Iloilo", "Samar", "Leyte", "Mindoro",
|
| 10 |
+
"General Trias", "Dasmarinas", "Bacoor", "Imus", "Tondo"
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
DISASTERS = [
|
| 14 |
+
"baha", "flood", "flooding", "flash flood", "taas ng tubig",
|
| 15 |
+
"lindol", "earthquake", "quake", "aftershock", "yanig", "uga",
|
| 16 |
+
"bagyo", "typhoon", "storm", "winds", "malakas na hangin",
|
| 17 |
+
"sunog", "fire", "nasusunog", "apoy", "fire alert",
|
| 18 |
+
"landslide", "guho", "mudslide", "falling rocks",
|
| 19 |
+
"volcano", "ashfall", "eruption", "asupre", "smog"
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
NEEDS = [
|
| 23 |
+
"rescue", "tulong", "help", "saklolo", "assist", "save us",
|
| 24 |
+
"food", "pagkain", "water", "tubig", "relief goods", "makakain",
|
| 25 |
+
"medical", "gamot", "medic", "doctor", "ambulance", "oxygen", "first aid",
|
| 26 |
+
"shelter", "evacuation", "matutuluyan", "tents", "bubong", "trapal",
|
| 27 |
+
"boat", "bangka", "firetruck", "bumbero", "rescuers"
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
# POSITIVE FRAGMENTS
|
| 31 |
+
POS_INTROS = ["EMERGENCY!", "HELP!", "S.O.S.", "URGENT:", "Please help,", "Tulong po,", "Saklolo,", "Rescue needed!"]
|
| 32 |
+
POS_SITUATIONS = ["trapped kami sa loob", "stuck sa bubong", "water is rising fast", "nasusunog ang bahay", "injured ang kasama ko", "stranded kami", "collapsed ang pader", "lubog na ang first floor"]
|
| 33 |
+
POS_REQUESTS = ["we need {need} immediately", "send {need} ASAP", "paki-dala ng {need}", "kailangan namin ng {need}", "need {need} now!"]
|
| 34 |
+
|
| 35 |
+
# NEGATIVE FRAGMENTS
|
| 36 |
+
NEG_NEWS_INTROS = ["BREAKING:", "Just in:", "News Update:", "Report:", "FYI:", "Advisory:", "Headlines:", "According to news,"]
|
| 37 |
+
NEG_NEWS_BODIES = ["{disaster} hits {loc}", "death toll in {loc} rises", "classes suspended in {loc}", "state of calamity declared in {loc}"]
|
| 38 |
+
|
| 39 |
+
NEG_ACT_INTROS = ["I want to donate", "Looking for volunteers", "Donation drive for", "Salute to", "Praying for", "Condolence to"]
|
| 40 |
+
NEG_ACT_BODIES = ["victims of {disaster} in {loc}", "our heroes in {loc}", "the brave rescuers in {loc}", "{disaster} survivors in {loc}"]
|
| 41 |
+
|
| 42 |
+
NEG_DISCUSS_INTROS = ["Why is the mayor", "The corruption in", "Remembering the", "Throwback to", "Discussion:", "Opinion:", "Is it safe in"]
|
| 43 |
+
NEG_DISCUSS_BODIES = ["response to {disaster} in {loc} is slow", "1990 {disaster} in {loc}", "{loc} politicians are useless", "road to {loc} is passable"]
|
alisto_project/backend/train_ensemble.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import pickle
|
| 3 |
+
import os
|
| 4 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 5 |
+
from sklearn.svm import LinearSVC
|
| 6 |
+
from sklearn.calibration import CalibratedClassifierCV
|
| 7 |
+
from sklearn.pipeline import make_pipeline
|
| 8 |
+
from sklearn.model_selection import train_test_split
|
| 9 |
+
from sklearn.metrics import accuracy_score, classification_report
|
| 10 |
+
|
| 11 |
+
# Paths
|
| 12 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 13 |
+
DATA_PATH = os.path.join(BASE_DIR, '../data/reddit_disaster_posts.csv')
|
| 14 |
+
MODEL_DIR = os.path.join(BASE_DIR, 'models')
|
| 15 |
+
|
| 16 |
+
# --- 1. CUSTOM TAGLISH STOP WORDS ---
|
| 17 |
+
# removing these prevents the model from cheating by memorizing common grammar words
|
| 18 |
+
TAGLISH_STOP_WORDS = [
|
| 19 |
+
'ang', 'mga', 'ng', 'sa', 'na', 'ko', 'mo', 'ba', 'ka', 'yung',
|
| 20 |
+
'ni', 'no', 'at', 'o', 'kay', 'to', 'po', 'pa', 'din', 'rin',
|
| 21 |
+
'naman', 'nyo', 'nila', 'namin', 'kasi', 'kame', 'kami', 'tayo',
|
| 22 |
+
'sana', 'lang', 'talaga', 'di', 'eh', 'oh', 'ah', 'yun', 'yan',
|
| 23 |
+
'the', 'is', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'for'
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
def train_tfidf():
|
| 27 |
+
print(f"Loading data from: {DATA_PATH}")
|
| 28 |
+
if not os.path.exists(DATA_PATH):
|
| 29 |
+
print("Error: CSV not found.")
|
| 30 |
+
return
|
| 31 |
+
|
| 32 |
+
df = pd.read_csv(DATA_PATH)
|
| 33 |
+
df['text'] = df['text'].astype(str).fillna('')
|
| 34 |
+
df = df.dropna(subset=['label'])
|
| 35 |
+
|
| 36 |
+
X = df['text']
|
| 37 |
+
y = df['label'].astype(int)
|
| 38 |
+
|
| 39 |
+
print(f"Training TF-IDF + SVM Ensemble on {len(df)} posts...")
|
| 40 |
+
|
| 41 |
+
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
|
| 42 |
+
|
| 43 |
+
# --- 2. THE UPGRADED PIPELINE ---
|
| 44 |
+
model = make_pipeline(
|
| 45 |
+
TfidfVectorizer(
|
| 46 |
+
stop_words=TAGLISH_STOP_WORDS,
|
| 47 |
+
max_features=5000,
|
| 48 |
+
ngram_range=(1, 3),
|
| 49 |
+
sublinear_tf=True
|
| 50 |
+
),
|
| 51 |
+
CalibratedClassifierCV(LinearSVC(dual=False, class_weight='balanced'), method='sigmoid')
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
model.fit(X_train, y_train)
|
| 55 |
+
|
| 56 |
+
# Evaluate
|
| 57 |
+
y_pred = model.predict(X_test)
|
| 58 |
+
acc = accuracy_score(y_test, y_pred)
|
| 59 |
+
print(f"✅ TF-IDF Model Accuracy: {acc * 100:.2f}%")
|
| 60 |
+
print("Classification Report:")
|
| 61 |
+
print(classification_report(y_test, y_pred))
|
| 62 |
+
|
| 63 |
+
# Save
|
| 64 |
+
save_path = os.path.join(MODEL_DIR, 'tfidf_ensemble.pkl')
|
| 65 |
+
with open(save_path, 'wb') as f:
|
| 66 |
+
pickle.dump(model, f)
|
| 67 |
+
|
| 68 |
+
print(f"Model saved to: {save_path}")
|
| 69 |
+
|
| 70 |
+
if __name__ == "__main__":
|
| 71 |
+
train_tfidf()
|
alisto_project/backend/train_model.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import torch
|
| 3 |
+
import numpy as np
|
| 4 |
+
import os
|
| 5 |
+
from sklearn.model_selection import train_test_split
|
| 6 |
+
from sklearn.metrics import accuracy_score, precision_recall_fscore_support
|
| 7 |
+
from transformers import (
|
| 8 |
+
AutoTokenizer,
|
| 9 |
+
AutoModelForSequenceClassification,
|
| 10 |
+
Trainer,
|
| 11 |
+
TrainingArguments,
|
| 12 |
+
EarlyStoppingCallback
|
| 13 |
+
)
|
| 14 |
+
from torch import nn
|
| 15 |
+
|
| 16 |
+
# 1. Config
|
| 17 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 18 |
+
DATA_PATH = os.path.join(BASE_DIR, '../data/reddit_disaster_posts.csv')
|
| 19 |
+
MODEL_OUTPUT_DIR = os.path.join(BASE_DIR, 'models/roberta_model')
|
| 20 |
+
|
| 21 |
+
# --- THE UPGRADE: Multilingual Brain (English + Tagalog) ---
|
| 22 |
+
MODEL_NAME = 'xlm-roberta-base'
|
| 23 |
+
|
| 24 |
+
print(f"--- ALISTO: Training Multilingual Brain ({MODEL_NAME}) ---")
|
| 25 |
+
|
| 26 |
+
# 2. Load Data
|
| 27 |
+
if not os.path.exists(DATA_PATH):
|
| 28 |
+
print("❌ Error: CSV file not found. Run augment_data.py first!")
|
| 29 |
+
exit()
|
| 30 |
+
|
| 31 |
+
df = pd.read_csv(DATA_PATH)
|
| 32 |
+
df = df.dropna(subset=['text', 'label'])
|
| 33 |
+
texts = df['text'].tolist()
|
| 34 |
+
labels = df['label'].tolist()
|
| 35 |
+
|
| 36 |
+
print(f"Loaded {len(df)} samples.")
|
| 37 |
+
|
| 38 |
+
# 3. Split (80% Train, 20% Validation)
|
| 39 |
+
train_texts, val_texts, train_labels, val_labels = train_test_split(
|
| 40 |
+
texts, labels, test_size=0.2, random_state=42, stratify=labels
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# 4. Tokenize
|
| 44 |
+
print(f"Downloading tokenizer for {MODEL_NAME}...")
|
| 45 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
|
| 46 |
+
|
| 47 |
+
def tokenize_function(texts):
|
| 48 |
+
return tokenizer(texts, padding=True, truncation=True, max_length=128)
|
| 49 |
+
|
| 50 |
+
train_encodings = tokenize_function(train_texts)
|
| 51 |
+
val_encodings = tokenize_function(val_texts)
|
| 52 |
+
|
| 53 |
+
# 5. Dataset Class
|
| 54 |
+
class DisasterDataset(torch.utils.data.Dataset):
|
| 55 |
+
def __init__(self, encodings, labels):
|
| 56 |
+
self.encodings = encodings
|
| 57 |
+
self.labels = labels
|
| 58 |
+
|
| 59 |
+
def __getitem__(self, idx):
|
| 60 |
+
item = {key: torch.tensor(val[idx]) for key, val in self.encodings.items()}
|
| 61 |
+
item['labels'] = torch.tensor(self.labels[idx])
|
| 62 |
+
return item
|
| 63 |
+
|
| 64 |
+
def __len__(self):
|
| 65 |
+
return len(self.labels)
|
| 66 |
+
|
| 67 |
+
train_dataset = DisasterDataset(train_encodings, train_labels)
|
| 68 |
+
val_dataset = DisasterDataset(val_encodings, val_labels)
|
| 69 |
+
|
| 70 |
+
# --- CUSTOM TRAINER WITH WEIGHTED LOSS ---
|
| 71 |
+
# Punishes the model 3x more if it misses a Rescue Request (False Negative)
|
| 72 |
+
class WeightedTrainer(Trainer):
|
| 73 |
+
def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None, **kwargs):
|
| 74 |
+
labels = inputs.get("labels")
|
| 75 |
+
outputs = model(**inputs)
|
| 76 |
+
logits = outputs.get("logits")
|
| 77 |
+
|
| 78 |
+
# [1.0, 3.0] -> Label 1 is 3x more important than Label 0
|
| 79 |
+
loss_fct = nn.CrossEntropyLoss(weight=torch.tensor([1.0, 3.0]).to(model.device))
|
| 80 |
+
loss = loss_fct(logits.view(-1, self.model.config.num_labels), labels.view(-1))
|
| 81 |
+
|
| 82 |
+
return (loss, outputs) if return_outputs else loss
|
| 83 |
+
|
| 84 |
+
# Metrics
|
| 85 |
+
def compute_metrics(pred):
|
| 86 |
+
labels = pred.label_ids
|
| 87 |
+
preds = pred.predictions.argmax(-1)
|
| 88 |
+
precision, recall, f1, _ = precision_recall_fscore_support(labels, preds, average='binary')
|
| 89 |
+
acc = accuracy_score(labels, preds)
|
| 90 |
+
return {
|
| 91 |
+
'accuracy': acc,
|
| 92 |
+
'f1': f1,
|
| 93 |
+
'precision': precision,
|
| 94 |
+
'recall': recall
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
# 6. Model Initialization
|
| 98 |
+
print(f"Downloading base model {MODEL_NAME}...")
|
| 99 |
+
model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME, num_labels=2)
|
| 100 |
+
|
| 101 |
+
# 7. Training Args
|
| 102 |
+
training_args = TrainingArguments(
|
| 103 |
+
output_dir='./results',
|
| 104 |
+
num_train_epochs=15,
|
| 105 |
+
per_device_train_batch_size=8,
|
| 106 |
+
per_device_eval_batch_size=8,
|
| 107 |
+
warmup_steps=500,
|
| 108 |
+
weight_decay=0.01,
|
| 109 |
+
learning_rate=2e-5,
|
| 110 |
+
logging_dir='./logs',
|
| 111 |
+
logging_steps=50,
|
| 112 |
+
eval_strategy="epoch",
|
| 113 |
+
save_strategy="epoch",
|
| 114 |
+
load_best_model_at_end=True,
|
| 115 |
+
metric_for_best_model="f1",
|
| 116 |
+
seed=42
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
# 8. Train
|
| 120 |
+
trainer = WeightedTrainer(
|
| 121 |
+
model=model,
|
| 122 |
+
args=training_args,
|
| 123 |
+
train_dataset=train_dataset,
|
| 124 |
+
eval_dataset=val_dataset,
|
| 125 |
+
compute_metrics=compute_metrics,
|
| 126 |
+
callbacks=[EarlyStoppingCallback(early_stopping_patience=2)]
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
print("Starting training (XLM-R + Weighted Loss)...")
|
| 130 |
+
trainer.train()
|
| 131 |
+
|
| 132 |
+
# 9. Save
|
| 133 |
+
print(f"Saving upgraded model to {MODEL_OUTPUT_DIR}...")
|
| 134 |
+
model.save_pretrained(MODEL_OUTPUT_DIR)
|
| 135 |
+
tokenizer.save_pretrained(MODEL_OUTPUT_DIR)
|
| 136 |
+
print("✅ Multilingual Brain Training Complete.")
|
alisto_project/data/reddit_disaster_posts.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
alisto_project/data/seed_data.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
alisto_project/requirements.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask
|
| 2 |
+
flask-sqlalchemy
|
| 3 |
+
python-dotenv
|
| 4 |
+
pandas
|
| 5 |
+
numpy
|
| 6 |
+
scikit-learn
|
| 7 |
+
asyncpraw
|
| 8 |
+
torch
|
| 9 |
+
transformers
|
| 10 |
+
sentencepiece
|
| 11 |
+
protobuf
|
| 12 |
+
accelerate
|
| 13 |
+
flask_cors
|
| 14 |
+
flask-login
|
| 15 |
+
werkzeuggit rm -r --cached .
|
| 16 |
+
git add .
|
| 17 |
+
gunicorn
|
alisto_project/start.sh
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
# 1. Start the AI Scraper in the background (&)
|
| 4 |
+
# It will silently find alerts and save them to alisto.db
|
| 5 |
+
python ingest_reddit.py &
|
| 6 |
+
|
| 7 |
+
# 2. Start the Website in the foreground
|
| 8 |
+
# This keeps the server running
|
| 9 |
+
gunicorn -b 0.0.0.0:7860 app:app
|
instance/alisto.db
ADDED
|
Binary file (12.3 kB). View file
|
|
|