feat: SITREP automated daily AI briefing system
This commit is contained in:
parent
5fee28a80a
commit
2c7ef1bff7
5 changed files with 1447 additions and 10 deletions
83
api/app.py
83
api/app.py
|
|
@ -1086,5 +1086,88 @@ def api_radar_refresh():
|
||||||
RADAR_CACHE['last_fetch'] = time.time()
|
RADAR_CACHE['last_fetch'] = time.time()
|
||||||
return jsonify({'status': 'ok', 'total': len(RADAR_CACHE['items'])})
|
return jsonify({'status': 'ok', 'total': len(RADAR_CACHE['items'])})
|
||||||
|
|
||||||
|
# ─── SITREP: Daily AI Briefing ────────────────────────
|
||||||
|
SITREP_DIR = DATA_DIR / 'sitreps'
|
||||||
|
|
||||||
|
@app.route('/api/sitrep')
|
||||||
|
def api_sitrep():
|
||||||
|
"""Return today's SITREP, or most recent if not yet generated. ?date=YYYY-MM-DD for specific date."""
|
||||||
|
date_param = request.args.get('date', '').strip()
|
||||||
|
sitrep_dir = SITREP_DIR
|
||||||
|
if not sitrep_dir.exists():
|
||||||
|
return jsonify({'error': 'No SITREPs available yet', 'sitrep': None}), 404
|
||||||
|
|
||||||
|
if date_param:
|
||||||
|
target = sitrep_dir / f'{date_param}.json'
|
||||||
|
if target.exists():
|
||||||
|
return jsonify(json.loads(target.read_text()))
|
||||||
|
return jsonify({'error': f'No SITREP for {date_param}', 'sitrep': None}), 404
|
||||||
|
|
||||||
|
# Try today first, then most recent
|
||||||
|
today = datetime.datetime.utcnow().strftime('%Y-%m-%d')
|
||||||
|
today_file = sitrep_dir / f'{today}.json'
|
||||||
|
if today_file.exists():
|
||||||
|
data = json.loads(today_file.read_text())
|
||||||
|
data['is_today'] = True
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
# Find most recent
|
||||||
|
files = sorted(sitrep_dir.glob('*.json'), reverse=True)
|
||||||
|
if files:
|
||||||
|
data = json.loads(files[0].read_text())
|
||||||
|
data['is_today'] = False
|
||||||
|
data['notice'] = f"Today's SITREP not yet generated. Showing most recent: {data.get('date', 'unknown')}"
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
return jsonify({'error': 'No SITREPs available yet', 'sitrep': None}), 404
|
||||||
|
|
||||||
|
@app.route('/api/sitrep/list')
|
||||||
|
def api_sitrep_list():
|
||||||
|
"""Return list of all available SITREP dates for archive browsing."""
|
||||||
|
sitrep_dir = SITREP_DIR
|
||||||
|
if not sitrep_dir.exists():
|
||||||
|
return jsonify({'dates': [], 'total': 0})
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for f in sorted(sitrep_dir.glob('*.json'), reverse=True):
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text())
|
||||||
|
entries.append({
|
||||||
|
'date': data.get('date', f.stem),
|
||||||
|
'headline': data.get('headline', ''),
|
||||||
|
'model': data.get('model', 'unknown'),
|
||||||
|
'sources_used': data.get('sources_used', 0)
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
entries.append({'date': f.stem, 'headline': '', 'model': 'unknown', 'sources_used': 0})
|
||||||
|
|
||||||
|
return jsonify({'dates': entries, 'total': len(entries)})
|
||||||
|
|
||||||
|
@app.route('/api/sitrep/generate', methods=['POST'])
|
||||||
|
def api_sitrep_generate():
|
||||||
|
"""Manually trigger SITREP generation."""
|
||||||
|
try:
|
||||||
|
import subprocess as sp
|
||||||
|
script = Path(__file__).parent / 'sitrep_generator.py'
|
||||||
|
if not script.exists():
|
||||||
|
return jsonify({'error': 'sitrep_generator.py not found'}), 500
|
||||||
|
result = sp.run(
|
||||||
|
['python3', str(script)],
|
||||||
|
capture_output=True, text=True, timeout=120,
|
||||||
|
cwd=str(script.parent)
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
today = datetime.datetime.utcnow().strftime('%Y-%m-%d')
|
||||||
|
sitrep_file = SITREP_DIR / f'{today}.json'
|
||||||
|
if sitrep_file.exists():
|
||||||
|
return jsonify({'status': 'ok', 'message': 'SITREP generated', 'date': today, 'log': result.stdout[-500:] if result.stdout else ''})
|
||||||
|
return jsonify({'status': 'ok', 'message': 'Generator ran but no file produced', 'log': result.stdout[-500:] if result.stdout else ''})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Generator failed', 'stderr': result.stderr[-500:] if result.stderr else '', 'stdout': result.stdout[-500:] if result.stdout else ''}), 500
|
||||||
|
except sp.TimeoutExpired:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Generation timed out (120s)'}), 504
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||||
|
|
|
||||||
472
api/sitrep_generator.py
Normal file
472
api/sitrep_generator.py
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""SITREP Generator — Automated Daily AI Briefing for jaeswift.xyz
|
||||||
|
|
||||||
|
Fetches tech news from RSS/JSON feeds and crypto data from Binance,
|
||||||
|
sends to Venice AI for military-style summarisation, saves as JSON.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 sitrep_generator.py # Generate today's SITREP
|
||||||
|
python3 sitrep_generator.py 2026-04-06 # Generate for specific date
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# ─── Configuration ────────────────────────────────────
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
DATA_DIR = BASE_DIR / 'data'
|
||||||
|
SITREP_DIR = DATA_DIR / 'sitreps'
|
||||||
|
KEYS_FILE = DATA_DIR / 'apikeys.json'
|
||||||
|
|
||||||
|
SITREP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s [SITREP] %(levelname)s: %(message)s',
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S'
|
||||||
|
)
|
||||||
|
log = logging.getLogger('sitrep')
|
||||||
|
|
||||||
|
# Request headers
|
||||||
|
HEADERS = {
|
||||||
|
'User-Agent': 'JAESWIFT-SITREP/1.0 (https://jaeswift.xyz)'
|
||||||
|
}
|
||||||
|
|
||||||
|
REDDIT_HEADERS = {
|
||||||
|
'User-Agent': 'JAESWIFT-SITREP/1.0 by jaeswift'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Feed Sources ─────────────────────────────────────
|
||||||
|
RSS_FEEDS = {
|
||||||
|
'hackernews': {
|
||||||
|
'url': 'https://hnrss.org/frontpage?count=50',
|
||||||
|
'type': 'rss',
|
||||||
|
'label': 'Hacker News'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
REDDIT_FEEDS = {
|
||||||
|
'r_technology': {
|
||||||
|
'url': 'https://www.reddit.com/r/technology/hot.json?limit=30',
|
||||||
|
'label': 'r/technology'
|
||||||
|
},
|
||||||
|
'r_programming': {
|
||||||
|
'url': 'https://www.reddit.com/r/programming/hot.json?limit=30',
|
||||||
|
'label': 'r/programming'
|
||||||
|
},
|
||||||
|
'r_netsec': {
|
||||||
|
'url': 'https://www.reddit.com/r/netsec/hot.json?limit=20',
|
||||||
|
'label': 'r/netsec'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LOBSTERS_URL = 'https://lobste.rs/hottest.json'
|
||||||
|
|
||||||
|
CRYPTO_SYMBOLS = ['SOLUSDT', 'BTCUSDT', 'ETHUSDT']
|
||||||
|
BINANCE_TICKER_URL = 'https://api.binance.com/api/v3/ticker/24hr'
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Fetch Functions ──────────────────────────────────
|
||||||
|
def fetch_hn_stories():
|
||||||
|
"""Fetch Hacker News top stories via RSS."""
|
||||||
|
stories = []
|
||||||
|
try:
|
||||||
|
feed = feedparser.parse(RSS_FEEDS['hackernews']['url'])
|
||||||
|
for entry in feed.entries:
|
||||||
|
pub = ''
|
||||||
|
if hasattr(entry, 'published_parsed') and entry.published_parsed:
|
||||||
|
pub = time.strftime('%Y-%m-%dT%H:%M:%SZ', entry.published_parsed)
|
||||||
|
stories.append({
|
||||||
|
'title': entry.get('title', 'Untitled'),
|
||||||
|
'url': entry.get('link', ''),
|
||||||
|
'source': 'Hacker News',
|
||||||
|
'published': pub,
|
||||||
|
'summary': (entry.get('summary', '') or '')[:300]
|
||||||
|
})
|
||||||
|
log.info(f'Fetched {len(stories)} stories from Hacker News')
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'HN fetch error: {e}')
|
||||||
|
return stories
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_reddit_posts(sub_key, sub_config):
|
||||||
|
"""Fetch Reddit posts via .json endpoint."""
|
||||||
|
posts = []
|
||||||
|
try:
|
||||||
|
resp = requests.get(sub_config['url'], headers=REDDIT_HEADERS, timeout=15)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
children = data.get('data', {}).get('children', [])
|
||||||
|
for child in children:
|
||||||
|
d = child.get('data', {})
|
||||||
|
if d.get('stickied'):
|
||||||
|
continue
|
||||||
|
created = d.get('created_utc', 0)
|
||||||
|
pub = datetime.fromtimestamp(created, tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') if created else ''
|
||||||
|
posts.append({
|
||||||
|
'title': d.get('title', 'Untitled'),
|
||||||
|
'url': d.get('url', ''),
|
||||||
|
'source': sub_config['label'],
|
||||||
|
'published': pub,
|
||||||
|
'summary': (d.get('selftext', '') or '')[:300],
|
||||||
|
'score': d.get('score', 0),
|
||||||
|
'num_comments': d.get('num_comments', 0),
|
||||||
|
'permalink': f"https://reddit.com{d.get('permalink', '')}"
|
||||||
|
})
|
||||||
|
log.info(f"Fetched {len(posts)} posts from {sub_config['label']}")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Reddit fetch error ({sub_config['label']}): {e}")
|
||||||
|
return posts
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_lobsters():
|
||||||
|
"""Fetch Lobste.rs hottest stories via JSON."""
|
||||||
|
stories = []
|
||||||
|
try:
|
||||||
|
resp = requests.get(LOBSTERS_URL, headers=HEADERS, timeout=15)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
for item in data[:30]:
|
||||||
|
stories.append({
|
||||||
|
'title': item.get('title', 'Untitled'),
|
||||||
|
'url': item.get('url', '') or item.get('comments_url', ''),
|
||||||
|
'source': 'Lobste.rs',
|
||||||
|
'published': item.get('created_at', ''),
|
||||||
|
'summary': (item.get('description', '') or '')[:300],
|
||||||
|
'score': item.get('score', 0),
|
||||||
|
'tags': item.get('tags', [])
|
||||||
|
})
|
||||||
|
log.info(f'Fetched {len(stories)} stories from Lobste.rs')
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'Lobsters fetch error: {e}')
|
||||||
|
return stories
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_crypto_data():
|
||||||
|
"""Fetch crypto market data from Binance."""
|
||||||
|
crypto = {}
|
||||||
|
for symbol in CRYPTO_SYMBOLS:
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
BINANCE_TICKER_URL,
|
||||||
|
params={'symbol': symbol},
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
ticker = symbol.replace('USDT', '')
|
||||||
|
crypto[ticker] = {
|
||||||
|
'price': round(float(data.get('lastPrice', 0)), 2),
|
||||||
|
'change': round(float(data.get('priceChangePercent', 0)), 2),
|
||||||
|
'high_24h': round(float(data.get('highPrice', 0)), 2),
|
||||||
|
'low_24h': round(float(data.get('lowPrice', 0)), 2),
|
||||||
|
'volume': round(float(data.get('volume', 0)), 2)
|
||||||
|
}
|
||||||
|
log.info(f"{ticker}: ${crypto[ticker]['price']} ({crypto[ticker]['change']}%)")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'Binance fetch error ({symbol}): {e}')
|
||||||
|
ticker = symbol.replace('USDT', '')
|
||||||
|
crypto[ticker] = {'price': 0, 'change': 0, 'high_24h': 0, 'low_24h': 0, 'volume': 0}
|
||||||
|
return crypto
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AI Generation ────────────────────────────────────
|
||||||
|
def build_ai_prompt(all_stories, crypto):
|
||||||
|
"""Build the prompt for Venice AI with all fetched data."""
|
||||||
|
lines = []
|
||||||
|
lines.append('=== RAW INTELLIGENCE DATA FOR SITREP GENERATION ===')
|
||||||
|
lines.append(f'Date: {datetime.now(timezone.utc).strftime("%d %B %Y")}')
|
||||||
|
lines.append(f'Time: {datetime.now(timezone.utc).strftime("%H%M")} HRS UTC')
|
||||||
|
lines.append('')
|
||||||
|
|
||||||
|
# Group stories by source
|
||||||
|
sources = {}
|
||||||
|
for s in all_stories:
|
||||||
|
src = s.get('source', 'Unknown')
|
||||||
|
if src not in sources:
|
||||||
|
sources[src] = []
|
||||||
|
sources[src].append(s)
|
||||||
|
|
||||||
|
lines.append('=== TECHNOLOGY & PROGRAMMING FEEDS ===')
|
||||||
|
for src_name in ['Hacker News', 'r/technology', 'r/programming', 'Lobste.rs']:
|
||||||
|
if src_name in sources:
|
||||||
|
lines.append(f'\n--- {src_name.upper()} ---')
|
||||||
|
for i, s in enumerate(sources[src_name][:25], 1):
|
||||||
|
score_str = f" [score:{s.get('score', '?')}]" if s.get('score') else ''
|
||||||
|
lines.append(f"{i}. {s['title']}{score_str}")
|
||||||
|
if s.get('summary'):
|
||||||
|
lines.append(f" Summary: {s['summary'][:150]}")
|
||||||
|
|
||||||
|
lines.append('\n=== CYBERSECURITY FEEDS ===')
|
||||||
|
if 'r/netsec' in sources:
|
||||||
|
lines.append('--- R/NETSEC ---')
|
||||||
|
for i, s in enumerate(sources['r/netsec'][:15], 1):
|
||||||
|
lines.append(f"{i}. {s['title']}")
|
||||||
|
if s.get('summary'):
|
||||||
|
lines.append(f" Summary: {s['summary'][:150]}")
|
||||||
|
|
||||||
|
# Also extract security-related stories from other feeds
|
||||||
|
sec_keywords = ['security', 'vulnerability', 'exploit', 'hack', 'breach',
|
||||||
|
'malware', 'ransomware', 'CVE', 'zero-day', 'phishing',
|
||||||
|
'encryption', 'privacy', 'backdoor', 'patch', 'attack']
|
||||||
|
sec_stories = []
|
||||||
|
for s in all_stories:
|
||||||
|
if s.get('source') == 'r/netsec':
|
||||||
|
continue
|
||||||
|
title_lower = s.get('title', '').lower()
|
||||||
|
if any(kw in title_lower for kw in sec_keywords):
|
||||||
|
sec_stories.append(s)
|
||||||
|
if sec_stories:
|
||||||
|
lines.append('\n--- SECURITY-RELATED FROM OTHER FEEDS ---')
|
||||||
|
for i, s in enumerate(sec_stories[:10], 1):
|
||||||
|
lines.append(f"{i}. [{s['source']}] {s['title']}")
|
||||||
|
|
||||||
|
lines.append('\n=== CRYPTO MARKET DATA ===')
|
||||||
|
for ticker, data in crypto.items():
|
||||||
|
direction = '▲' if data['change'] >= 0 else '▼'
|
||||||
|
lines.append(
|
||||||
|
f"{ticker}: ${data['price']:,.2f} {direction} {data['change']:+.2f}% "
|
||||||
|
f"| 24h High: ${data['high_24h']:,.2f} | 24h Low: ${data['low_24h']:,.2f} "
|
||||||
|
f"| Volume: {data['volume']:,.0f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
SITREP_SYSTEM_PROMPT = """You are a military intelligence analyst preparing a classified daily situation report (SITREP) for a special operations technology unit. Your callsign is JAE-SIGINT.
|
||||||
|
|
||||||
|
Write in terse, professional military briefing style. Use abbreviations common to military communications. Be direct, analytical, and occasionally darkly witty.
|
||||||
|
|
||||||
|
Format the SITREP in markdown with the following EXACT structure:
|
||||||
|
|
||||||
|
# DAILY SITREP — [DATE]
|
||||||
|
**CLASSIFICATION: OPEN SOURCE // JAESWIFT SIGINT**
|
||||||
|
**DTG:** [Date-Time Group in military format, e.g., 060700ZAPR2026]
|
||||||
|
**PREPARED BY:** JAE-SIGINT / AUTOMATED COLLECTION
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SECTOR ALPHA — TECHNOLOGY
|
||||||
|
|
||||||
|
Summarise the top 8-10 most significant technology stories. Group loosely by theme (AI/ML, infrastructure, programming languages, open source, industry moves). Each item should be 1-2 sentences max. Use bullet points. Prioritise stories by significance and novelty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SECTOR BRAVO — CYBERSECURITY
|
||||||
|
|
||||||
|
Summarise any cybersecurity-related stories. Include CVEs, breaches, new tools, threat intel. If few dedicated security stories, note the relatively quiet SIGINT environment. 4-8 items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SECTOR CHARLIE — CRYPTO MARKETS
|
||||||
|
|
||||||
|
Report crypto prices with 24h movement. Note any significant moves (>5% change). Include any crypto-related news from the feeds. Brief market sentiment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ASSESSMENT
|
||||||
|
|
||||||
|
2-3 sentences providing overall analysis. What trends are emerging? What should the operator be watching? Include one forward-looking statement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**// END TRANSMISSION //**
|
||||||
|
**NEXT SCHEDULED SITREP: [TOMORROW'S DATE] 0700Z**
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Keep total length under 1500 words
|
||||||
|
- Do NOT invent stories or data — only summarise what's provided
|
||||||
|
- If data is sparse for a sector, acknowledge it briefly
|
||||||
|
- Use markdown formatting (headers, bold, bullets, horizontal rules)
|
||||||
|
- Include the exact crypto prices provided — do not round them differently
|
||||||
|
- For each tech story, mention the source in brackets like [HN] [Reddit] [Lobsters]"""
|
||||||
|
|
||||||
|
|
||||||
|
def generate_with_venice(user_prompt):
|
||||||
|
"""Call Venice AI to generate the SITREP."""
|
||||||
|
try:
|
||||||
|
keys = json.loads(KEYS_FILE.read_text())
|
||||||
|
venice_key = keys.get('venice', {}).get('api_key', '')
|
||||||
|
venice_model = keys.get('venice', {}).get('model', 'llama-3.3-70b')
|
||||||
|
if not venice_key:
|
||||||
|
log.error('Venice API key not found in apikeys.json')
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
log.info(f'Calling Venice AI (model: {venice_model})...')
|
||||||
|
resp = requests.post(
|
||||||
|
'https://api.venice.ai/api/v1/chat/completions',
|
||||||
|
headers={
|
||||||
|
'Authorization': f'Bearer {venice_key}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
'model': venice_model,
|
||||||
|
'messages': [
|
||||||
|
{'role': 'system', 'content': SITREP_SYSTEM_PROMPT},
|
||||||
|
{'role': 'user', 'content': user_prompt}
|
||||||
|
],
|
||||||
|
'max_tokens': 2048,
|
||||||
|
'temperature': 0.7
|
||||||
|
},
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
result = resp.json()
|
||||||
|
content = result['choices'][0]['message']['content']
|
||||||
|
log.info(f'Venice AI response received ({len(content)} chars)')
|
||||||
|
return content, venice_model
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'Venice AI error: {e}')
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_headline(content):
|
||||||
|
"""Extract or generate a one-line headline from the SITREP content."""
|
||||||
|
# Try to pull the first significant bullet point
|
||||||
|
lines = content.split('\n')
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith('- ') or stripped.startswith('* '):
|
||||||
|
headline = stripped.lstrip('-* ').strip()
|
||||||
|
if len(headline) > 20:
|
||||||
|
# Trim to reasonable length
|
||||||
|
if len(headline) > 120:
|
||||||
|
headline = headline[:117] + '...'
|
||||||
|
return headline
|
||||||
|
return 'Daily intelligence briefing — technology, cybersecurity, and crypto markets'
|
||||||
|
|
||||||
|
|
||||||
|
def build_fallback_sitrep(all_stories, crypto):
|
||||||
|
"""Build a raw fallback SITREP when Venice AI is unavailable."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
lines = []
|
||||||
|
lines.append(f'# DAILY SITREP — {now.strftime("%d %B %Y").upper()}')
|
||||||
|
lines.append('**CLASSIFICATION: OPEN SOURCE // JAESWIFT SIGINT**')
|
||||||
|
lines.append(f'**DTG:** {now.strftime("%d%H%MZ%b%Y").upper()}')
|
||||||
|
lines.append('**PREPARED BY:** JAE-SIGINT / RAW FEED (AI UNAVAILABLE)')
|
||||||
|
lines.append('')
|
||||||
|
lines.append('---')
|
||||||
|
lines.append('')
|
||||||
|
lines.append('> ⚠️ **NOTICE:** AI summarisation unavailable. Raw intelligence feed follows.')
|
||||||
|
lines.append('')
|
||||||
|
lines.append('## SECTOR ALPHA — TECHNOLOGY')
|
||||||
|
lines.append('')
|
||||||
|
|
||||||
|
tech_stories = [s for s in all_stories if s.get('source') != 'r/netsec']
|
||||||
|
for s in tech_stories[:15]:
|
||||||
|
lines.append(f"- **[{s['source']}]** {s['title']}")
|
||||||
|
|
||||||
|
lines.append('')
|
||||||
|
lines.append('---')
|
||||||
|
lines.append('')
|
||||||
|
lines.append('## SECTOR BRAVO — CYBERSECURITY')
|
||||||
|
lines.append('')
|
||||||
|
|
||||||
|
sec_stories = [s for s in all_stories if s.get('source') == 'r/netsec']
|
||||||
|
sec_keywords = ['security', 'vulnerability', 'exploit', 'hack', 'breach',
|
||||||
|
'malware', 'ransomware', 'CVE', 'zero-day', 'phishing']
|
||||||
|
for s in all_stories:
|
||||||
|
if s.get('source') != 'r/netsec' and any(kw in s.get('title', '').lower() for kw in sec_keywords):
|
||||||
|
sec_stories.append(s)
|
||||||
|
for s in sec_stories[:10]:
|
||||||
|
lines.append(f"- **[{s['source']}]** {s['title']}")
|
||||||
|
if not sec_stories:
|
||||||
|
lines.append('- No cybersecurity stories intercepted this cycle.')
|
||||||
|
|
||||||
|
lines.append('')
|
||||||
|
lines.append('---')
|
||||||
|
lines.append('')
|
||||||
|
lines.append('## SECTOR CHARLIE — CRYPTO MARKETS')
|
||||||
|
lines.append('')
|
||||||
|
|
||||||
|
for ticker, data in crypto.items():
|
||||||
|
direction = '🟢' if data['change'] >= 0 else '🔴'
|
||||||
|
lines.append(
|
||||||
|
f"- **{ticker}**: ${data['price']:,.2f} {direction} {data['change']:+.2f}%"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append('')
|
||||||
|
lines.append('---')
|
||||||
|
lines.append('')
|
||||||
|
lines.append('## ASSESSMENT')
|
||||||
|
lines.append('')
|
||||||
|
lines.append('AI analysis unavailable. Operator should review raw feed data above for emerging patterns.')
|
||||||
|
lines.append('')
|
||||||
|
lines.append('---')
|
||||||
|
lines.append('')
|
||||||
|
lines.append('**// END TRANSMISSION //**')
|
||||||
|
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main Generation Pipeline ────────────────────────
|
||||||
|
def generate_sitrep(target_date=None):
|
||||||
|
"""Main pipeline: fetch data → AI summarise → save JSON."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
date_str = target_date or now.strftime('%Y-%m-%d')
|
||||||
|
output_path = SITREP_DIR / f'{date_str}.json'
|
||||||
|
|
||||||
|
log.info(f'=== SITREP GENERATION STARTED for {date_str} ===')
|
||||||
|
|
||||||
|
# 1. Fetch all data
|
||||||
|
log.info('Phase 1: Fetching intelligence data...')
|
||||||
|
hn_stories = fetch_hn_stories()
|
||||||
|
reddit_stories = []
|
||||||
|
for key, config in REDDIT_FEEDS.items():
|
||||||
|
reddit_stories.extend(fetch_reddit_posts(key, config))
|
||||||
|
lobster_stories = fetch_lobsters()
|
||||||
|
crypto = fetch_crypto_data()
|
||||||
|
|
||||||
|
all_stories = hn_stories + reddit_stories + lobster_stories
|
||||||
|
total_sources = len(all_stories)
|
||||||
|
log.info(f'Total stories collected: {total_sources}')
|
||||||
|
|
||||||
|
if total_sources == 0:
|
||||||
|
log.error('No stories fetched from any source. Aborting.')
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. Build AI prompt and generate
|
||||||
|
log.info('Phase 2: Generating AI briefing...')
|
||||||
|
user_prompt = build_ai_prompt(all_stories, crypto)
|
||||||
|
ai_content, model_used = generate_with_venice(user_prompt)
|
||||||
|
|
||||||
|
# 3. Build SITREP content (AI or fallback)
|
||||||
|
if ai_content:
|
||||||
|
content = ai_content
|
||||||
|
headline = generate_headline(content)
|
||||||
|
else:
|
||||||
|
log.warning('Venice AI failed — using fallback raw SITREP')
|
||||||
|
content = build_fallback_sitrep(all_stories, crypto)
|
||||||
|
headline = 'Raw intelligence feed — AI summarisation unavailable'
|
||||||
|
model_used = 'fallback'
|
||||||
|
|
||||||
|
# 4. Save JSON
|
||||||
|
sitrep_data = {
|
||||||
|
'date': date_str,
|
||||||
|
'generated_at': now.strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||||
|
'headline': headline,
|
||||||
|
'content': content,
|
||||||
|
'crypto': crypto,
|
||||||
|
'sources_used': total_sources,
|
||||||
|
'model': model_used or 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
output_path.write_text(json.dumps(sitrep_data, indent=2))
|
||||||
|
log.info(f'SITREP saved to {output_path}')
|
||||||
|
log.info(f'=== SITREP GENERATION COMPLETE for {date_str} ===')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
target = sys.argv[1] if len(sys.argv) > 1 else None
|
||||||
|
success = generate_sitrep(target)
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
465
css/sitrep.css
Normal file
465
css/sitrep.css
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
/* ─── SITREP: Daily AI Briefing ─────────────────── */
|
||||||
|
|
||||||
|
.sitrep-container {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 2rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Classification Banner ────────────────────── */
|
||||||
|
.sitrep-classification {
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(201, 162, 39, 0.08);
|
||||||
|
border: 1px solid rgba(201, 162, 39, 0.25);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
color: #c9a227;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Date Header ──────────────────────────────── */
|
||||||
|
.sitrep-date-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-date-title {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #00cc33;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-date-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-nav-btn {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-nav-btn:hover {
|
||||||
|
background: rgba(0, 204, 51, 0.06);
|
||||||
|
border-color: rgba(0, 204, 51, 0.3);
|
||||||
|
color: #00cc33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-nav-btn.disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Notice Bar ───────────────────────────────── */
|
||||||
|
.sitrep-notice {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background: rgba(201, 162, 39, 0.06);
|
||||||
|
border-left: 3px solid #c9a227;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(201, 162, 39, 0.8);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-notice.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Crypto Ticker Bar ────────────────────────── */
|
||||||
|
.sitrep-crypto-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-ticker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-symbol {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-price {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-change {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-change.positive {
|
||||||
|
color: #00cc33;
|
||||||
|
background: rgba(0, 204, 51, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-change.negative {
|
||||||
|
color: #ff3333;
|
||||||
|
background: rgba(255, 51, 51, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 1.2rem;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Main Briefing Panel ──────────────────────── */
|
||||||
|
.sitrep-briefing {
|
||||||
|
background: #161616;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
padding: 2rem 2.5rem;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-briefing::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, #00cc33, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Briefing Content Markdown Rendering ──────── */
|
||||||
|
.sitrep-content {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content h1 {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #00cc33;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content h2 {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #00cc33;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 1.5rem 0 1rem 0;
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
border-top: 1px solid rgba(0, 204, 51, 0.15);
|
||||||
|
border-bottom: 1px solid rgba(0, 204, 51, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content h3 {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #c9a227;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 1.2rem 0 0.6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content p {
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content strong {
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content em {
|
||||||
|
color: rgba(201, 162, 39, 0.8);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content ul,
|
||||||
|
.sitrep-content ol {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content li {
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
padding-left: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content li::marker {
|
||||||
|
color: rgba(0, 204, 51, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content hr {
|
||||||
|
border: none;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(0, 204, 51, 0.2), transparent);
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content a {
|
||||||
|
color: #00cc33;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(0, 204, 51, 0.3);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content a:hover {
|
||||||
|
border-color: #00cc33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content code {
|
||||||
|
background: rgba(0, 204, 51, 0.08);
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #00cc33;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content blockquote {
|
||||||
|
border-left: 3px solid #c9a227;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin: 0.8rem 0;
|
||||||
|
background: rgba(201, 162, 39, 0.04);
|
||||||
|
color: rgba(201, 162, 39, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Meta Footer ──────────────────────────────── */
|
||||||
|
.sitrep-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-meta-label {
|
||||||
|
color: rgba(255, 255, 255, 0.15);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Archive Panel ────────────────────────────── */
|
||||||
|
.sitrep-archive {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-archive-header {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-archive-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-archive-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-archive-item:hover {
|
||||||
|
background: rgba(0, 204, 51, 0.04);
|
||||||
|
border-color: rgba(0, 204, 51, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-archive-item.active {
|
||||||
|
background: rgba(0, 204, 51, 0.06);
|
||||||
|
border-color: rgba(0, 204, 51, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-date {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #c9a227;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
min-width: 100px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-headline {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-sources {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Loading State ────────────────────────────── */
|
||||||
|
.sitrep-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(0, 204, 51, 0.5);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
animation: sitrep-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sitrep-pulse {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Error State ──────────────────────────────── */
|
||||||
|
.sitrep-error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 51, 51, 0.7);
|
||||||
|
background: rgba(255, 51, 51, 0.04);
|
||||||
|
border: 1px solid rgba(255, 51, 51, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Generate Button ──────────────────────────── */
|
||||||
|
.sitrep-generate-btn {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
background: rgba(0, 204, 51, 0.06);
|
||||||
|
border: 1px solid rgba(0, 204, 51, 0.2);
|
||||||
|
color: rgba(0, 204, 51, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-generate-btn:hover {
|
||||||
|
background: rgba(0, 204, 51, 0.12);
|
||||||
|
border-color: rgba(0, 204, 51, 0.5);
|
||||||
|
color: #00cc33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-generate-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Responsive ───────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sitrep-container {
|
||||||
|
padding: 0 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-briefing {
|
||||||
|
padding: 1.5rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-date-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-crypto-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-meta {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content h1 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-content h2 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-headline {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.sitrep-content {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sitrep-date-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
365
js/sitrep.js
Normal file
365
js/sitrep.js
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
/* ─── SITREP: Daily AI Briefing Frontend ─────────── */
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const API_BASE = '/api/sitrep';
|
||||||
|
let archiveDates = [];
|
||||||
|
let currentDate = null;
|
||||||
|
|
||||||
|
// ─── DOM Elements ────────────────────────────────
|
||||||
|
const els = {
|
||||||
|
dateTitle: document.getElementById('sitrepDateTitle'),
|
||||||
|
notice: document.getElementById('sitrepNotice'),
|
||||||
|
noticeText: document.getElementById('sitrepNoticeText'),
|
||||||
|
cryptoBar: document.getElementById('sitrepCryptoBar'),
|
||||||
|
briefing: document.getElementById('sitrepBriefing'),
|
||||||
|
content: document.getElementById('sitrepContent'),
|
||||||
|
metaSources: document.getElementById('metaSources'),
|
||||||
|
metaModel: document.getElementById('metaModel'),
|
||||||
|
metaGenerated: document.getElementById('metaGenerated'),
|
||||||
|
archiveList: document.getElementById('sitrepArchiveList'),
|
||||||
|
btnPrev: document.getElementById('sitrepPrev'),
|
||||||
|
btnNext: document.getElementById('sitrepNext'),
|
||||||
|
btnGenerate: document.getElementById('sitrepGenerate')
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Markdown to HTML ────────────────────────────
|
||||||
|
function mdToHtml(md) {
|
||||||
|
if (!md) return '<p class="sitrep-error">No content available.</p>';
|
||||||
|
|
||||||
|
let html = md;
|
||||||
|
|
||||||
|
// Escape HTML entities (but preserve markdown)
|
||||||
|
html = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
// Restore markdown-safe chars
|
||||||
|
html = html.replace(/> /gm, '> '); // blockquotes
|
||||||
|
|
||||||
|
// Horizontal rules
|
||||||
|
html = html.replace(/^---+$/gm, '<hr>');
|
||||||
|
html = html.replace(/^\*\*\*+$/gm, '<hr>');
|
||||||
|
|
||||||
|
// Headers (must be before bold processing)
|
||||||
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||||
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||||
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||||||
|
|
||||||
|
// Bold + Italic
|
||||||
|
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||||
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
html = html.replace(/(?<![\w*])\*([^*\n]+?)\*(?![\w*])/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Inline code
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
|
||||||
|
// Links [text](url)
|
||||||
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||||||
|
|
||||||
|
// Blockquotes (multi-line support)
|
||||||
|
html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
|
||||||
|
// Merge consecutive blockquotes
|
||||||
|
html = html.replace(/<\/blockquote>\n<blockquote>/g, '<br>');
|
||||||
|
|
||||||
|
// Unordered lists
|
||||||
|
html = html.replace(/^[\-\*] (.+)$/gm, '<li>$1</li>');
|
||||||
|
// Wrap consecutive <li> in <ul>
|
||||||
|
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
|
||||||
|
|
||||||
|
// Ordered lists
|
||||||
|
html = html.replace(/^\d+\. (.+)$/gm, '<oli>$1</oli>');
|
||||||
|
html = html.replace(/((?:<oli>.*<\/oli>\n?)+)/g, function(match) {
|
||||||
|
return '<ol>' + match.replace(/<\/?oli>/g, function(tag) {
|
||||||
|
return tag.replace('oli', 'li');
|
||||||
|
}) + '</ol>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paragraphs — wrap remaining loose text lines
|
||||||
|
const lines = html.split('\n');
|
||||||
|
const result = [];
|
||||||
|
let inBlock = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) {
|
||||||
|
if (!inBlock) result.push('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Skip if already an HTML block element
|
||||||
|
if (/^<(h[1-6]|ul|ol|li|blockquote|hr|p|div)/.test(line)) {
|
||||||
|
result.push(line);
|
||||||
|
inBlock = /^<(ul|ol|blockquote)/.test(line);
|
||||||
|
} else if (/<\/(ul|ol|blockquote)>$/.test(line)) {
|
||||||
|
result.push(line);
|
||||||
|
inBlock = false;
|
||||||
|
} else {
|
||||||
|
result.push('<p>' + line + '</p>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Date Formatting ─────────────────────────────
|
||||||
|
function formatSitrepDate(dateStr) {
|
||||||
|
if (!dateStr) return 'UNKNOWN DATE';
|
||||||
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
|
const months = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
|
||||||
|
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||||
|
const month = months[d.getUTCMonth()];
|
||||||
|
const year = d.getUTCFullYear();
|
||||||
|
return `DAILY SITREP // ${day} ${month} ${year} // 0700 HRS`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatArchiveDate(dateStr) {
|
||||||
|
if (!dateStr) return '???';
|
||||||
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
|
const months = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
|
||||||
|
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${day} ${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts) {
|
||||||
|
if (!ts) return '—';
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toUTCString().replace('GMT', 'UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Crypto Ticker ───────────────────────────────
|
||||||
|
function renderCrypto(crypto) {
|
||||||
|
if (!crypto || !els.cryptoBar) return;
|
||||||
|
|
||||||
|
const tickers = Object.entries(crypto);
|
||||||
|
if (!tickers.length) {
|
||||||
|
els.cryptoBar.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
tickers.forEach(([symbol, data], idx) => {
|
||||||
|
const price = parseFloat(data.price) || 0;
|
||||||
|
const change = parseFloat(data.change) || 0;
|
||||||
|
const direction = change >= 0 ? '▲' : '▼';
|
||||||
|
const cls = change >= 0 ? 'positive' : 'negative';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="crypto-ticker">
|
||||||
|
<span class="crypto-symbol">${symbol}</span>
|
||||||
|
<span class="crypto-price">$${price.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}</span>
|
||||||
|
<span class="crypto-change ${cls}">${direction} ${change >= 0 ? '+' : ''}${change.toFixed(2)}%</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
if (idx < tickers.length - 1) {
|
||||||
|
html += '<div class="crypto-divider"></div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
els.cryptoBar.innerHTML = html;
|
||||||
|
els.cryptoBar.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Navigation ──────────────────────────────────
|
||||||
|
function updateNav() {
|
||||||
|
if (!archiveDates.length || !currentDate) {
|
||||||
|
if (els.btnPrev) els.btnPrev.classList.add('disabled');
|
||||||
|
if (els.btnNext) els.btnNext.classList.add('disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates = archiveDates.map(d => d.date);
|
||||||
|
const idx = dates.indexOf(currentDate);
|
||||||
|
|
||||||
|
if (els.btnPrev) {
|
||||||
|
if (idx < dates.length - 1) {
|
||||||
|
els.btnPrev.classList.remove('disabled');
|
||||||
|
els.btnPrev.onclick = () => loadSitrep(dates[idx + 1]);
|
||||||
|
} else {
|
||||||
|
els.btnPrev.classList.add('disabled');
|
||||||
|
els.btnPrev.onclick = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (els.btnNext) {
|
||||||
|
if (idx > 0) {
|
||||||
|
els.btnNext.classList.remove('disabled');
|
||||||
|
els.btnNext.onclick = () => loadSitrep(dates[idx - 1]);
|
||||||
|
} else {
|
||||||
|
els.btnNext.classList.add('disabled');
|
||||||
|
els.btnNext.onclick = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateArchiveHighlight() {
|
||||||
|
if (!els.archiveList) return;
|
||||||
|
els.archiveList.querySelectorAll('.sitrep-archive-item').forEach(item => {
|
||||||
|
item.classList.toggle('active', item.dataset.date === currentDate);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Load SITREP ─────────────────────────────────
|
||||||
|
async function loadSitrep(date) {
|
||||||
|
if (els.content) {
|
||||||
|
els.content.innerHTML = '<div class="sitrep-loading">DECRYPTING TRANSMISSION...</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = date ? `${API_BASE}?date=${date}` : API_BASE;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
if (els.content) {
|
||||||
|
els.content.innerHTML = `<div class="sitrep-error">⚠ ${err.error || 'SITREP not available'}</div>`;
|
||||||
|
}
|
||||||
|
if (els.cryptoBar) els.cryptoBar.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
currentDate = data.date;
|
||||||
|
|
||||||
|
// Date header
|
||||||
|
if (els.dateTitle) {
|
||||||
|
els.dateTitle.textContent = formatSitrepDate(data.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notice
|
||||||
|
if (els.notice && els.noticeText) {
|
||||||
|
if (data.notice) {
|
||||||
|
els.noticeText.textContent = data.notice;
|
||||||
|
els.notice.classList.add('visible');
|
||||||
|
} else {
|
||||||
|
els.notice.classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crypto bar
|
||||||
|
renderCrypto(data.crypto);
|
||||||
|
|
||||||
|
// Briefing content
|
||||||
|
if (els.content) {
|
||||||
|
els.content.innerHTML = mdToHtml(data.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta
|
||||||
|
if (els.metaSources) els.metaSources.textContent = data.sources_used || '—';
|
||||||
|
if (els.metaModel) els.metaModel.textContent = data.model || '—';
|
||||||
|
if (els.metaGenerated) els.metaGenerated.textContent = formatTimestamp(data.generated_at);
|
||||||
|
|
||||||
|
// Nav
|
||||||
|
updateNav();
|
||||||
|
updateArchiveHighlight();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('SITREP load error:', e);
|
||||||
|
if (els.content) {
|
||||||
|
els.content.innerHTML = '<div class="sitrep-error">⚠ TRANSMISSION FAILED — Unable to reach API</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Load Archive List ────────────────────────────
|
||||||
|
async function loadArchive() {
|
||||||
|
if (!els.archiveList) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/list`);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
archiveDates = data.dates || [];
|
||||||
|
|
||||||
|
if (!archiveDates.length) {
|
||||||
|
els.archiveList.innerHTML = '<div style="padding:0.5rem;color:rgba(255,255,255,0.2);font-size:0.75rem;">No archived SITREPs yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
archiveDates.forEach(entry => {
|
||||||
|
const isActive = entry.date === currentDate ? ' active' : '';
|
||||||
|
html += `
|
||||||
|
<div class="sitrep-archive-item${isActive}" data-date="${entry.date}" onclick="window.__loadSitrep('${entry.date}')">
|
||||||
|
<span class="archive-date">${formatArchiveDate(entry.date)}</span>
|
||||||
|
<span class="archive-headline">${escapeHtml(entry.headline || '—')}</span>
|
||||||
|
<span class="archive-sources">${entry.sources_used || 0} SRC</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
els.archiveList.innerHTML = html;
|
||||||
|
updateNav();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Archive load error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Generate SITREP ─────────────────────────────
|
||||||
|
async function generateSitrep() {
|
||||||
|
if (!els.btnGenerate) return;
|
||||||
|
|
||||||
|
els.btnGenerate.disabled = true;
|
||||||
|
els.btnGenerate.textContent = 'GENERATING...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/generate`, { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
els.btnGenerate.textContent = '✓ GENERATED';
|
||||||
|
// Reload
|
||||||
|
setTimeout(() => {
|
||||||
|
loadSitrep();
|
||||||
|
loadArchive();
|
||||||
|
els.btnGenerate.textContent = 'GENERATE NOW';
|
||||||
|
els.btnGenerate.disabled = false;
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
els.btnGenerate.textContent = '✗ FAILED';
|
||||||
|
console.error('Generate error:', data);
|
||||||
|
setTimeout(() => {
|
||||||
|
els.btnGenerate.textContent = 'GENERATE NOW';
|
||||||
|
els.btnGenerate.disabled = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Generate error:', e);
|
||||||
|
els.btnGenerate.textContent = '✗ ERROR';
|
||||||
|
setTimeout(() => {
|
||||||
|
els.btnGenerate.textContent = 'GENERATE NOW';
|
||||||
|
els.btnGenerate.disabled = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Utility ──────────────────────────────────────
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API (for archive clicks) ─────────────
|
||||||
|
window.__loadSitrep = loadSitrep;
|
||||||
|
|
||||||
|
// ─── Init ────────────────────────────────────────
|
||||||
|
function init() {
|
||||||
|
// Load today's SITREP
|
||||||
|
loadSitrep();
|
||||||
|
|
||||||
|
// Load archive
|
||||||
|
loadArchive();
|
||||||
|
|
||||||
|
// Generate button
|
||||||
|
if (els.btnGenerate) {
|
||||||
|
els.btnGenerate.addEventListener('click', generateSitrep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on DOM ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
<link rel="stylesheet" href="/css/section.css">
|
<link rel="stylesheet" href="/css/section.css">
|
||||||
|
<link rel="stylesheet" href="/css/sitrep.css?v=20260406">
|
||||||
|
<style>body{background:#0a0a0a;}</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="scanline-overlay"></div>
|
<div class="scanline-overlay"></div>
|
||||||
|
|
@ -37,19 +39,68 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="section-header" style="padding-top: calc(var(--nav-height) + 1.5rem);">
|
<section class="section-header" style="padding-top: calc(var(--nav-height) + 1.5rem);">
|
||||||
<div class="section-header-label">TRANSMISSIONS // OUTBOUND SIGNALS</div>
|
<div class="section-header-label">TRANSMISSIONS // DAILY INTELLIGENCE</div>
|
||||||
<h1 class="section-header-title">SITREP</h1>
|
<h1 class="section-header-title">SITREP</h1>
|
||||||
<p class="section-header-sub">> Short-burst situation reports — quick updates on current operations.</p>
|
<p class="section-header-sub">> Automated daily situation report — AI-generated briefing on technology, cybersecurity, and crypto markets</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="subpage-content">
|
<div class="sitrep-container">
|
||||||
<div class="subpage-placeholder">
|
|
||||||
<div class="placeholder-icon">◇</div>
|
<!-- Classification Banner -->
|
||||||
<div class="placeholder-status">UNDER CONSTRUCTION</div>
|
<div class="sitrep-classification">
|
||||||
<div class="placeholder-text">This section is being prepared. Content deployment imminent.</div>
|
CLASSIFICATION: OPEN SOURCE // JAESWIFT SIGINT // AUTOMATED COLLECTION
|
||||||
<div class="placeholder-classification">CLASSIFICATION: PENDING // STATUS: STANDBY</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<!-- Date Header + Navigation -->
|
||||||
|
<div class="sitrep-date-header">
|
||||||
|
<div class="sitrep-date-title" id="sitrepDateTitle">LOADING TRANSMISSION...</div>
|
||||||
|
<div class="sitrep-date-nav">
|
||||||
|
<button class="sitrep-nav-btn disabled" id="sitrepPrev" title="Previous SITREP">◄ PREV</button>
|
||||||
|
<button class="sitrep-nav-btn disabled" id="sitrepNext" title="Next SITREP">NEXT ►</button>
|
||||||
|
<button class="sitrep-generate-btn" id="sitrepGenerate" title="Manually generate today's SITREP">GENERATE NOW</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notice Bar -->
|
||||||
|
<div class="sitrep-notice" id="sitrepNotice">
|
||||||
|
<span id="sitrepNoticeText"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Crypto Ticker Bar -->
|
||||||
|
<div class="sitrep-crypto-bar" id="sitrepCryptoBar" style="display:none;"></div>
|
||||||
|
|
||||||
|
<!-- Main Briefing Panel -->
|
||||||
|
<div class="sitrep-briefing" id="sitrepBriefing">
|
||||||
|
<div class="sitrep-content" id="sitrepContent">
|
||||||
|
<div class="sitrep-loading">DECRYPTING TRANSMISSION...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meta Footer -->
|
||||||
|
<div class="sitrep-meta">
|
||||||
|
<div class="sitrep-meta-item">
|
||||||
|
<span class="sitrep-meta-label">SOURCES:</span>
|
||||||
|
<span id="metaSources">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="sitrep-meta-item">
|
||||||
|
<span class="sitrep-meta-label">MODEL:</span>
|
||||||
|
<span id="metaModel">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="sitrep-meta-item">
|
||||||
|
<span class="sitrep-meta-label">GENERATED:</span>
|
||||||
|
<span id="metaGenerated">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Archive Panel -->
|
||||||
|
<div class="sitrep-archive">
|
||||||
|
<div class="sitrep-archive-header">TRANSMISSION ARCHIVE</div>
|
||||||
|
<div class="sitrep-archive-list" id="sitrepArchiveList">
|
||||||
|
<div style="padding:0.5rem;color:rgba(255,255,255,0.2);font-family:'JetBrains Mono',monospace;font-size:0.75rem;">Loading archive...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="footer-container">
|
<div class="footer-container">
|
||||||
|
|
@ -66,5 +117,6 @@
|
||||||
<script src="/js/wallet-connect.js"></script>
|
<script src="/js/wallet-connect.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="/js/clock.js"></script>
|
<script src="/js/clock.js"></script>
|
||||||
|
<script src="/js/sitrep.js?v=20260406"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Loading…
Add table
Reference in a new issue