atlas-os/server/intelligence.py
Atlas Developer 8c1efe6e30 feat: Atlas OS v2.0 — space-grade animated README, full productified repo
- assets/header.svg: animated SVG header (twinkling stars, orbiting service dots,
  Atlas A logo with glow, float/pulse animations, corner accents)
- README.md: animated banner, full 39-app service catalog, Mermaid architecture,
  AI stack table, tech stack, quick-deploy, repo structure, security model
- ARCHITECTURE.md: one-box philosophy, 5-tier service layers, data flow diagram,
  Caddy SSO pattern, AI mesh topology, Docker Compose topology
- SERVICES.md: all 55 containers + 20 systemd services documented
- CHANGELOG.md: full build history from v0.5 to v2.0
- assets/logo.svg: Atlas A logo with gradient
- deploy/install.sh: one-command installer (Docker + Caddy + Tailscale + stacks)
- deploy/compose/atlas-core.yml: Authentik + Nextcloud + Stalwart + Forgejo + Vaultwarden
- deploy/caddy/Caddyfile.template: Authentik SSO pattern, sanitized
- deploy/README.md: full deployment guide
- server/: sanitized atlas_brain.py, cockpit_shim.py, intelligence.py, meridian.py,
  atlas_router_api.py, cli-atlas.py (all secrets stripped, env var patterns)
- server/systemd/: 6 atlas-* service unit files
- server/README.md: deployment and configuration guide

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:33:18 +02:00

172 lines
6.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Atlas Intelligence Engine
Runs on atlas-01. Pulls Odoo invoices, runs Monte Carlo H2 forecast,
outputs /opt/atlas/setup/intelligence.json (served at /feeds/intelligence.json).
Schedule: cron every hour or on-demand.
"""
import json, os, random, sys, xmlrpc.client
from datetime import datetime, timezone
from urllib.request import urlopen, Request
from urllib.error import URLError
ODOO_URL = 'http://<TAILSCALE_IP>:8069'
ODOO_DB = 'atlas'
ODOO_USER = 'chaib@atlascorporation.nl'
ODOO_PASS = os.environ['ODOO_PASS']
OUT_PATH = '/opt/atlas/setup/intelligence.json'
# ── Verified 2025 actuals (from Odoo export + bank reconciliation) ────────────
ACTUALS_2025 = {
'2025-01': 237, '2025-02': 225, '2025-03': 0, '2025-04': 255,
'2025-05': 1625,'2025-06': 533, '2025-07': 660, '2025-08': -200,
'2025-09': 1300,'2025-10': 1400,'2025-11': 280, '2025-12': 200,
}
ACTUALS_2026_FALLBACK = {
'2026-01': 68, '2026-02': 977, '2026-03': 4206,
'2026-04': 1479,'2026-05': 1627,'2026-06': 371,
}
def fetch_odoo():
"""Pull posted invoices from Odoo via XMLRPC, return {YYYY-MM: amount} dicts."""
try:
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
if not uid:
return None, None
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
moves = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
'account.move', 'search_read',
[[['move_type','in',['out_invoice','out_refund']],
['state','=','posted'],
['invoice_date','>=','2025-01-01']]],
{'fields':['invoice_date','amount_untaxed','move_type'],
'limit':500}
)
if not moves:
return None, None
by_month_2025, by_month_2026 = {}, {}
for m in moves:
if not m.get('invoice_date'):
continue
ym = m['invoice_date'][:7]
amt = m['amount_untaxed'] * (-1 if m['move_type'] == 'out_refund' else 1)
if ym.startswith('2025'):
by_month_2025[ym] = by_month_2025.get(ym, 0) + amt
elif ym.startswith('2026'):
by_month_2026[ym] = by_month_2026.get(ym, 0) + amt
return (
{k: round(v) for k,v in by_month_2025.items()} or None,
{k: round(v) for k,v in by_month_2026.items()} or None,
)
except Exception as e:
print(f'[intelligence] Odoo pull failed: {e}', file=sys.stderr)
return None, None
def monte_carlo(actuals_2026, n=10000):
"""Project H2 2026 (JulDec) from recent velocity + POS MRR growth."""
# Base: last 3 completed months (exclude partial current month)
months_sorted = sorted(k for k in actuals_2026 if actuals_2026[k] > 0)
recent = [actuals_2026[k] for k in months_sorted[-3:]] if months_sorted else [1500]
base_mean = sum(recent) / len(recent)
base_std = max(base_mean * 0.35, 300)
# AtlasPOS: 1 active venue now, conservative 1 new venue/month
pos_base = 79
pos_growth_per_mo = 79 # 1 new venue/month
results = []
for _ in range(n):
total = 0
for mo in range(6): # Jul through Dec
consulting = max(0, random.gauss(base_mean, base_std))
new_venues = max(0, int(random.gauss(1, 0.5)))
pos_mrr = pos_base + pos_growth_per_mo * mo + new_venues * 79
total += consulting + pos_mrr
results.append(total)
results.sort()
full_yr_actuals = sum(actuals_2026.values())
targets = {
'beat_2025': sum(ACTUALS_2025.values()),
'break_even_monthly': 3000,
}
return {
'p10': round(results[int(n * .10)]),
'p50': round(results[int(n * .50)]),
'p90': round(results[int(n * .90)]),
'full_year_p50': round(full_yr_actuals + results[int(n * .50)]),
'prob_beat_full_2025_pct': round(
sum(1 for r in results if full_yr_actuals + r > targets['beat_2025']) / n * 100
),
'months': ['Jul','Aug','Sep','Oct','Nov','Dec'],
}
def ltv(arpu=79, margin=0.90, churn=0.04):
return {
'arpu': arpu, 'margin_pct': int(margin*100),
'churn_assumption_pct': int(churn*100),
'ltv': round(arpu * margin / churn),
'lifetime_months': round(1/churn),
'cac_payback_3x_rule': round(arpu * margin / churn / 3),
}
def main():
a2025, a2026 = fetch_odoo()
if not a2025: a2025 = ACTUALS_2025
if not a2026: a2026 = ACTUALS_2026_FALLBACK
source = 'odoo' if (a2026 is not ACTUALS_2026_FALLBACK or a2025 is not ACTUALS_2025) else 'hardcoded'
ytd26 = sum(a2026.values())
same25 = sum(a2025.get(k.replace('2026','2025'), 0) for k in a2026)
yoy = round((ytd26 - same25) / max(abs(same25), 1) * 100)
fy25 = sum(a2025.values())
out = {
'generated': datetime.now(timezone.utc).isoformat(),
'data_source': source,
'revenue': {
'actuals_2025': a2025,
'actuals_2026': a2026,
'ytd_2026': ytd26,
'ytd_2025_same_period': same25,
'full_year_2025': fy25,
'remaining_to_beat_2025': max(0, fy25 - ytd26),
'yoy_pct': yoy,
},
'mrr': {
'arpu': 79,
'active_venues': 1,
'current_mrr': 79,
'target_venues': 52,
'mrr_at_target': 52 * 79,
},
'forecast_h2': monte_carlo(a2026),
'ltv': ltv(),
'market': {
'verified_facts': [
{'label':'NL GDP growth','value':'+1.4%','source':'CPB 2025'},
{'label':'Horeca turnover growth','value':'+3.4%','source':'CBS 2024'},
{'label':'Real consumer spending','value':'+11.5%','source':'ING 2025'},
{'label':'Contactless payments','value':'94%','source':'DNB end-2024'},
{'label':'Pin transactions 2024','value':'5.77B (+2.9%)','source':'Betaalvereniging NL'},
],
'tam_tilburg_horeca': '~600 venues',
'ccv_entry_price': '€25€34.50/mo',
'expansion_verdict': 'Sign 1 AtlasPOS venue/week → 52 venues by Jun 2027 → €4,108/mo MRR',
},
}
os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
with open(OUT_PATH, 'w') as f:
json.dump(out, f, indent=2)
print(f'[intelligence] Written → {OUT_PATH}')
print(f'[intelligence] YTD 2026: €{ytd26:,} | YoY: {yoy:+}% | source: {source}')
if __name__ == '__main__':
main()