feat: SITREP automated daily AI briefing system

This commit is contained in:
jae 2026-04-06 15:22:45 +00:00
parent 5fee28a80a
commit 2c7ef1bff7
5 changed files with 1447 additions and 10 deletions

View file

@ -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
View 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
View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Restore markdown-safe chars
html = html.replace(/&gt; /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();
}
})();

View file

@ -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">&gt; Short-burst situation reports — quick updates on current operations.</p> <p class="section-header-sub">&gt; 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>