Ctrl K
ComunidadGuías…

demo/build_demo.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Build the static, clickable GitHub Pages demo.

Seeds a THROWAWAY database with the fake "Alex Demo" persona (a mid-level
software developer, everything in USD), enriches it so every module has a
coherent story — net worth growing for 30 months, the mortgage falling in
step, a career analysis, a market barometer, fake performance-marketing
reports, an advanced RSU deep-dive — snapshots every GET endpoint to a JSON
file, and assembles `dist/`: the real frontend plus those snapshots. api.js
serves GETs from the baked files when window.KIST_STATIC_DEMO is set, and
writes become a friendly toast.

The only real data: public market quotes (Yahoo, keyless) and the maintainer's
public GitHub activity (both visible to anyone anyway). Yahoo/GitHub calls are
best-effort — offline the demo simply has thinner charts.

Usage:  python3 demo/build_demo.py [--out dist]
"""
import json
import os
import random
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
from datetime import date, datetime, timedelta, timezone
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
OUT = Path(sys.argv[sys.argv.index("--out") + 1] if "--out" in sys.argv else ROOT / "dist")
RNG = random.Random(42)  # deterministic fake history
TODAY = date.today()

# --- throwaway data dir, seeded with the sample persona ---
data_dir = Path(tempfile.mkdtemp(prefix="kist-demo-"))
os.environ["FINANCE_PROJECT_DIR"] = str(data_dir)
subprocess.run([sys.executable, str(ROOT / "seed.py")], check=True,
               env={**os.environ}, cwd=ROOT)

sys.path.insert(0, str(ROOT / "server"))
import config  # noqa: E402
config.setup()
import engine_bridge as eb  # noqa: E402
import ai_learning  # noqa: E402
import planner  # noqa: E402
import market  # noqa: E402

# watchlist + risk-radar components + FX pairs the currency view needs
TICKERS = ["AAPL", "MSFT", "VWCE.DE", "EURPLN=X", "USDPLN=X", "EURUSD=X",
           "^VIX", "GC=F", "CL=F"]


def best_effort(label, fn, *a, **kw):
    try:
        return fn(*a, **kw)
    except Exception as e:
        print(f"  (skipped {label}: {str(e)[:80]})")
        return None


def month_iso(months_ago):
    y, m = TODAY.year, TODAY.month - months_ago
    while m <= 0:
        y, m = y - 1, m + 12
    return f"{y:04d}-{m:02d}"


# ================= enrich: a coherent 30-month story, all in USD ==============
print("Enriching demo data…")
planner.save_app_config({"wizard_completed": True, "base_currency": "USD",
                         "modules": {"debts": True, "taxes": True, "markets": True,
                                     "rsu": True, "business": True, "career": True,
                                     "property": True}})

for t in TICKERS:
    best_effort(f"yahoo {t}", market.fetch_yahoo_history, t, "2y")
try:
    import risk_radar
    best_effort("risk radar", risk_radar.backfill, 32)
except Exception:
    pass
best_effort("forecast journal", market.backfill_forecasts, 10)

# --- net worth: 30 months of growth (~388k → today's ~545k) ---
nw = 388_000.0
for back in range(30, 0, -1):
    nw += RNG.uniform(3_000, 7_500)          # savings + market drift
    if back in (24, 14):                      # two believable dips
        nw -= RNG.uniform(6_000, 11_000)
    eb._exec("insert into snapshots (date, type, data) values (?,?,?)",
             (month_iso(back) + "-01", "net_worth",
              json.dumps({"net_worth": round(nw, 2)})))

# --- mortgage history: 14 months, balance falling in step with the story ---
debt = eb._rows("select id, balance from debts")[0]
bal = 348_500.0
for back in range(14, 0, -1):
    principal = RNG.uniform(1_850, 2_150)
    bal -= principal
    eb._exec("insert into debt_values (id, debt_id, month, balance, principal_paid, "
             "interest_paid, note, created_at) values (?,?,?,?,?,?,?,?)",
             (f"demo-debt-{back}", debt["id"], month_iso(back), round(bal, 2),
              round(principal, 2), round(bal * 0.065 / 12, 2), "",
              datetime.now().isoformat()))
eb._exec("update debts set balance = ? where id = ?", (round(bal, 2), debt["id"]))

# --- per-item wealth history so item charts have depth too ---
growth = {"ETF portfolio (broker)": (52_000, 85_000),
          "Company shares (RSU)": (28_000, 60_000),
          "Pension account": (34_000, 45_000),
          "Apartment — Sample City": (585_000, 620_000)}
for it in eb._rows("select id, name from wealth_items"):
    if it["name"] not in growth:
        continue
    lo, hi = growth[it["name"]]
    for back in range(24, 0, -1):
        frac = (24 - back) / 24
        v = lo + (hi - lo) * frac + RNG.uniform(-1500, 1500)
        eb._exec("insert into wealth_values (id, item_id, date, value, created_at) "
                 "values (?,?,?,?,?)",
                 (f"demo-{it['id']}-{back}", it["id"], month_iso(back) + "-01",
                  round(v, 2), datetime.now().isoformat()))

# --- career: PM persona — Senior PM offers, PM barometer, principal/HoP analysis ---
eb._exec("delete from job_offers")  # replace the seed's EM offers
planner.add_offer({"company": "Acme Corp", "role": "Senior Product Manager",
                   "total_monthly": 15500, "work_model": "hybrid", "status": "new",
                   "received_at": month_iso(2) + "-10",
                   "notes": "Sample offer — demo data.", "tier": 2})
planner.add_offer({"company": "Globex", "role": "Senior Product Manager (Platform)",
                   "total_monthly": 17800, "work_model": "remote", "status": "interviewing",
                   "received_at": month_iso(1) + "-18",
                   "notes": "Sample offer — demo data.", "tier": 1})

planner.set_settings({"career_role_a": "Senior PM",
                      "career_role_b": "Head of Product"})
for back in range(8, 0, -1):
    best_effort("barometer", planner.add_barometer_point, {
        "month": month_iso(back),
        "em_openings": 40 + (8 - back) * 3 + RNG.randint(-4, 4),
        "head_openings": 11 + (8 - back) + RNG.randint(-2, 2),
        "region": "US/EU (remote)",
        "note": "sample data" if back == 8 else ""})

planner.set_settings({"analysis_career": json.dumps({
    "headline": "Senior PM with shipped outcomes — the fork ahead is Principal (IC craft "
                "at scale) vs Head of Product (org leverage); both pay, they compound "
                "differently.",
    "as_of": TODAY.isoformat(), "target_role": "Principal PM / Head of Product (2–3 yrs)",
    "comp_levels": [
        {"role": "Product Manager", "comp": "$125–155k", "you": False},
        {"role": "Senior PM", "comp": "$155–190k", "you": True},
        {"role": "Principal PM", "comp": "$190–235k", "you": False},
        {"role": "Head of Product", "comp": "$230–300k", "you": False}],
    "money_paths": [
        {"tag": "A", "title": "Principal track where you are", "verdict": "solid",
         "text": "Own the hardest product area end-to-end, write the strategy memos "
                 "leadership forwards; principal within ~2 years at +20–25% comp, "
                 "no management overhead."},
        {"tag": "B", "title": "Head of Product at a scale-up", "verdict": "highest EV",
         "text": "Series B–D companies pay a premium for a senior PM who has shipped "
                 "0→1 and run discovery; expect +35–50% plus meaningful equity — and "
                 "a genuinely different job (hiring, roadmap politics, board decks)."},
        {"tag": "C", "title": "Fractional / advisory", "verdict": "later",
         "text": "Fractional CPO rates are strong ($150–250/h) but the pipeline needs "
                 "a reputation that principal or HoP scope builds first."}],
    "head_of_eng": "Head of Product is a real option within 2–3 years — but only if "
                   "you enjoy hiring, stakeholder management and being measured on the "
                   "team's outcomes. If the craft is what you love, Principal pays "
                   "nearly as well without the meetings tax. Decide by trying: lead a "
                   "PM hire and a quarter of roadmap before you commit.",
    "ai_impact": [
        "Spec-writing, ticket grooming and basic analytics are commoditizing — the "
        "junior-PM tasks of 2020.",
        "PMs who prototype with AI tools compress discovery from weeks to days; make "
        "that visible.",
        "Judgment, narrative, and stakeholder trust stay human longest — that's the "
        "principal/HoP moat."],
    "skills": [
        {"skill": "Product strategy & narrative", "why": "The principal gate — memos that change the roadmap, not just describe it."},
        {"skill": "AI-assisted discovery", "why": "Prototype-first PMs ship evidence, not opinions — becoming the baseline."},
        {"skill": "Org leadership", "why": "Hiring and developing PMs — the Head-of-Product gate; testable before you commit."}],
    "skills_note": "Sample analysis written for the demo persona — in the real app "
                   "you author this yourself or with any AI assistant.",
    "roadmap": [
        {"period": "0–6 mo", "title": "Own the hardest product area",
         "text": "Take the messy, cross-team problem; write the strategy memo that frames it."},
        {"period": "6–18 mo", "title": "Test both forks",
         "text": "Lead one PM hire and a quarter of roadmap (HoP test) while shipping a principal-level bet."},
        {"period": "18–36 mo", "title": "Commit and cash in",
         "text": "Promo to Principal, or jump to Head of Product at a scale-up — whichever fork felt like energy, not drain."}],
    "philosophies": {
        "max": {"title": "Max earnings", "text":
                "Optimize comp aggressively: jump every 2–3 years, interview yearly "
                "to stay calibrated, chase the biggest scope you can hold."},
        "coast": {"title": "Coast & compound", "text":
                  "Stay where the work is sane, automate savings, let the portfolio "
                  "do the second job. Less money, more life."},
        "note": "Sample framing — the app doesn't pick for you; it shows what each "
                "path does to the numbers in Forecasts."},
    "next_steps": [
        "Write one strategy memo this quarter that proposes killing something.",
        "Volunteer to run the next PM hiring loop — cheapest Head-of-Product test.",
        "Update the market barometer monthly — trend beats snapshot.",
        "Re-run this analysis after the next comp review."],
    "sources": []})})

# --- side business: a few months of drone-services revenue/costs ---
for back, entries in {
    3: [("revenue", "service", 3800, "Aerial photo shoot — sample entry"),
        ("cost", "equipment", -950, "Spare propellers & batteries — sample entry")],
    2: [("revenue", "service", 4200, "Roof inspection flight — sample entry"),
        ("cost", "marketing", -180, "Meta ads — sample entry"),
        ("cost", "software", -49, "Editing suite subscription — sample entry")],
    1: [("revenue", "service", 3600, "Construction-site progress video — sample entry"),
        ("revenue", "content", 450, "Stock-footage royalties — sample entry"),
        ("cost", "marketing", -175, "Meta ads — sample entry")],
}.items():
    for kind, cat, amount, desc in entries:
        best_effort("biz entry", planner.add_biz_entry, {
            "date": month_iso(back) + "-14", "kind": kind, "category": cat,
            "amount": amount, "description": desc})

# --- brokerage portfolio for the XTB-style rule engine (duplicates + tech concentration) ---
planner.set_settings({"xtb_portfolio": json.dumps({"positions": [
    {"name": "NASDAQ 100 ETF", "container": "Plan 1", "value": 20000, "theme": "tech"},
    {"name": "NASDAQ 100 ETF", "container": "Plan 2", "value": 10000, "theme": "tech"},
    {"name": "MSCI World IT ETF", "container": "Plan 1", "value": 16000, "theme": "tech"},
    {"name": "Semiconductors ETF", "container": "Plan 2", "value": 10000, "theme": "tech"},
    {"name": "VWCE All-World ETF", "container": "Plan 2", "value": 20000, "theme": "world"},
    {"name": "S&P 500 ETF", "container": "Plan 1", "value": 9000, "theme": "us"}]})})

# --- open-source contribution research (generic sample) ---
planner.set_settings({"analysis_contributions": json.dumps({
    "goal": "Become a visible contributor to one well-known open-source project "
            "in the tools you already use every day.",
    "method": "Sample research: pick projects you actually run, sort by contributor "
              "activity and good-first-issue availability, start with docs/tests.",
    "repos": [
        {"name": "ggml-org/llama.cpp", "url": "https://github.com/ggml-org/llama.cpp",
         "activity": "very active", "lang": "C++", "difficulty": "hard",
         "why": "You run it locally (this app's AI) — start with docs or build fixes."},
        {"name": "simonw/llm", "url": "https://github.com/simonw/llm",
         "activity": "very active", "lang": "Python", "difficulty": "easy",
         "why": "Plugin ecosystem designed for outside contributors — ship a plugin."},
        {"name": "chartjs/Chart.js", "url": "https://github.com/chartjs/Chart.js",
         "activity": "active", "lang": "JavaScript", "difficulty": "medium",
         "why": "This app's chart library — reproducible bug reports are welcomed."}],
    "badges": [
        {"name": "Pull Shark", "how": "Two merged PRs anywhere", "status": "easy"},
        {"name": "Public Sponsor", "how": "Sponsor a maintainer you rely on ($1/mo counts)", "status": "instant"},
        {"name": "Starstruck", "how": "Publish a useful tool that earns 16+ stars", "status": "long game"}],
    "playbook": [
        "Pick the project you use most — familiarity beats prestige for a first PR.",
        "Start with a docs fix or a failing-test repro; maintainers merge those fast.",
        "Answer two Discussions questions — visibility compounds.",
        "Then take one good-first-issue and see it through review."]})})

# --- AI learning tab (Career): a sample assessment for Alex + a little progress of their own ---
planner.set_settings({"analysis_ai_learning": json.dumps({
    "as_of": TODAY.isoformat(), "score": 2.6, "target": 4,
    "score_label": "Practitioner, edging toward Builder",
    "scale": ai_learning.LEVELS,
    "verdict": "Alex prototypes with AI every week and already treats outputs as claims to verify, which is the "
               "hard part of level 2. Level 3 is close: two of the projects already call models from code. "
               "Level 4 is measurement: workflows exist but nobody scores them; one small eval set per workflow closes most of it.",
    "projects": [
        {"name": "alex/discovery-copilot", "grade": "B+",
         "what": "Interview-notes summariser with a retrieval step over past research.",
         "gap": "No eval set: nobody knows whether v2 beat v1. Add 30 labelled questions."},
        {"name": "alex/pm-metrics-bot", "grade": "B",
         "what": "Weekly product metrics digest written by a model from SQL results.",
         "gap": "Numbers are not cross-checked against the query output; add a verifier pass."},
        {"name": "alex/prompt-notebook", "grade": "C+",
         "what": "A folder of favourite prompts.",
         "gap": "Unversioned and unscored. Turn the five best into tested templates."}],
    "skills": [
        {"id": "prompting", "area": "Foundations", "name": "Structured prompting", "status": "have",
         "how": "Roles, examples and output schemas used daily; keep a versioned template library.",
         "link": "https://www.promptingguide.ai/"},
        {"id": "llm-limits", "area": "Foundations", "name": "Failure modes and limits", "status": "partial",
         "how": "Knows hallucination and context limits in theory; practise by writing down three failures per month."},
        {"id": "cost", "area": "Foundations", "name": "Token cost and latency budgeting", "status": "missing",
         "how": "Estimate cost per task before building; compare a small local model with a hosted one on the same task."},
        {"id": "rag", "area": "Building", "name": "Retrieval (RAG) basics", "status": "partial",
         "how": "Built one working pipeline; next is chunking experiments and hybrid keyword + embedding search.",
         "evidence": ["discovery-copilot · dep: chromadb", "discovery-copilot · commit: add hybrid search over research notes"],
         "link": "https://huggingface.co/learn"},
        {"id": "tools", "area": "Building", "name": "Tool use and agents", "status": "partial",
         "how": "Wire a model to two real tools (SQL, calendar) with a hard step limit and logged calls."},
        {"id": "local", "area": "Building", "name": "Running a local model", "status": "have",
         "how": "Runs a quantised model locally for private data.",
         "link": "https://github.com/ggml-org/llama.cpp"},
        {"id": "evals", "area": "Quality", "name": "Evals and regression sets", "status": "missing",
         "how": "A gold set of 30 to 50 cases per workflow, scored on every prompt or model change.",
         "evidence": [],
         "link": "https://hamel.dev/blog/posts/evals/"},
        {"id": "obs", "area": "Quality", "name": "Logging and review of real usage", "status": "partial",
         "how": "Prompt log exists; add ratings and a monthly review of the worst ten answers."},
        {"id": "safety", "area": "Quality", "name": "Data privacy and prompt injection", "status": "partial",
         "how": "Keeps private data local; learn to treat retrieved text as untrusted input."},
        {"id": "teach", "area": "Leadership", "name": "Teaching the team", "status": "missing",
         "how": "Run one 45-minute session on a workflow that saved real time, with the numbers."},
        {"id": "policy", "area": "Leadership", "name": "Team usage guidelines", "status": "missing",
         "how": "A one-page policy: what data may go where, what must be verified, who reviews."}],
    "certs": [
        {"id": "dlai", "name": "Short courses on agents and evals", "provider": "DeepLearning.AI",
         "cost": "free", "time": "2 to 3 h each", "format": "online", "priority": "high",
         "why": "Cheapest way to fill the evals and tool-use gaps with working notebooks.",
         "link": "https://www.deeplearning.ai/short-courses/"},
        {"id": "hf", "name": "Open-source ML course (retrieval and fine-tuning modules)", "provider": "Hugging Face",
         "cost": "free", "time": "self-paced, ~20 h", "format": "online", "priority": "medium",
         "why": "Gives the vocabulary to argue with an ML engineer about retrieval quality.",
         "link": "https://huggingface.co/learn"},
        {"id": "cohort", "name": "Applied AI evals cohort", "provider": "Independent instructors (sample entry)",
         "cost": "$800 to $1,500", "time": "4 weeks, 3 h/week", "format": "online", "priority": "medium",
         "why": "Worth the learning budget only if the evals gap is still open after the free courses.",
         "link": "https://hamel.dev/"},
        {"id": "conf", "name": "Regional applied-AI conference", "provider": "Sample entry",
         "cost": "$450 plus travel", "time": "2 days", "format": "in person", "priority": "low",
         "why": "Good for the network; talks are recorded, so skip unless you plan to present.",
         "link": "https://www.latent.space/"},
        {"id": "workshop", "name": "Hands-on agents workshop", "provider": "Sample entry",
         "cost": "$300", "time": "1 day", "format": "hybrid", "priority": "medium",
         "why": "One day with a mentor beats three weeks of tutorials for tool use.",
         "link": "https://www.deeplearning.ai/"},
        {"id": "generic", "name": "Generic 'AI for managers' certificate", "provider": "Sample entry",
         "cost": "$1,200", "time": "6 weeks", "format": "online", "priority": "skip",
         "why": "Broad slides, no artefacts; nothing you cannot get from the free courses above.",
         "link": "https://www.deeplearning.ai/"}],
    "format_note": "Sample entries: in your install the assessment lists courses you can actually reach, "
                   "with the employer learning budget in mind.",
    "sources": {
        "method": "Three layers so that staying current costs minutes, not evenings: a daily skim, a weekly deep read, a quarterly reset.",
        "daily": [
            {"name": "Simon Willison's weblog", "link": "https://simonwillison.net/",
             "what": "Hands-on notes on new models and tools, with working examples."}],
        "weekly": [
            {"name": "Latent Space", "link": "https://www.latent.space/",
             "what": "Interviews and write-ups from people shipping AI systems."},
            {"name": "Hamel Husain's blog", "link": "https://hamel.dev/blog/",
             "what": "Practical evals and debugging of LLM applications."}],
        "quarterly": [
            {"name": "Prompting Guide", "link": "https://www.promptingguide.ai/",
             "what": "Re-read the techniques list and retire what newer models made unnecessary."}],
        "services": [
            {"name": "Hugging Face papers", "link": "https://huggingface.co/papers",
             "what": "Daily curated research papers, upvoted by practitioners."},
            {"name": "GitHub trending (Python)", "link": "https://github.com/trending/python",
             "what": "What the open-source community is building this week."}]},
    "generated": {"by": "repos", "narration": "local model", "sources": {"local": 3, "gh": 0}},
    "routine": [
        "Monday: pick one workflow to measure this week.",
        "Friday: write the log entry, even if the result was negative.",
        "Monthly: review the ten worst answers from the prompt log.",
        "Quarterly: re-rate the skill map and archive what stopped mattering."],
    "plan_12m": [
        "Months 1 to 2: build a 30-case eval set for the discovery copilot and score v1 against v2.",
        "Months 3 to 4: add a verifier pass to the metrics bot and cut manual checking.",
        "Months 5 to 6: run a local vs hosted cost comparison on two workflows.",
        "Months 7 to 9: teach one 45-minute team session and write the team usage guideline.",
        "Months 10 to 12: publish a short write-up of the eval method and what it caught."]})})
planner.set_settings({"ai_learning_history": json.dumps([
    {"date": (TODAY - timedelta(days=90)).isoformat(), "score": 1.9, "level": 1, "have": 1, "partial": 5, "missing": 5,
     "repos": 2, "narration": "template"},
    {"date": (TODAY - timedelta(days=45)).isoformat(), "score": 2.3, "level": 2, "have": 2, "partial": 4, "missing": 5,
     "repos": 3, "narration": "local model"},
    {"date": TODAY.isoformat(), "score": 2.6, "level": 2, "have": 2, "partial": 5, "missing": 4,
     "repos": 3, "narration": "local model"}])})
planner.set_settings({"ai_learning_state": json.dumps({
    "skills": {"rag": "partial", "tools": "partial"},
    "certs": {"dlai": "in progress", "hf": "planned"},
    "log": [
        {"date": (TODAY - timedelta(days=41)).isoformat(),
         "text": "Wrote 12 interview-summary questions with expected answers; v1 passes 8 of 12. Baseline recorded."},
        {"date": (TODAY - timedelta(days=17)).isoformat(),
         "text": "Hybrid keyword + embedding search on the research folder: relevant notes in the top 5 went from 7 to 10 of 12. Adopting."},
        {"date": (TODAY - timedelta(days=4)).isoformat(),
         "text": "Metrics bot quoted a number that was not in the query result. Added a verifier pass; it flagged 2 of 9 digests."}]})})

# --- fake performance-marketing intelligence (business module) ---
MARKETING = {
    "weeks": [
        {"week": "2026-07-06 – 2026-07-12", "spend_eur": 184.5,
         "summary": "CTR up 14% after the new creative set; CPC steady at €0.42.",
         "recommendation": "Shift 20% of budget to the lookalike audience — it converts 1.8× better."},
        {"week": "2026-06-29 – 2026-07-05", "spend_eur": 176.2,
         "summary": "Weekend impressions dipped (holiday); conversions held.",
         "recommendation": "Keep budgets flat; revisit after the seasonal dip."},
        {"week": "2026-06-22 – 2026-06-28", "spend_eur": 190.0,
         "summary": "New landing page cut bounce rate from 61% to 48%.",
         "recommendation": "Roll the winning variant to all ad sets."}],
    "insights": [
        {"category": "audience", "platform": "meta", "confidence": 0.86, "is_active": True,
         "insight": "Lookalike 1% of past customers outperforms interest targeting 1.8× on ROAS."},
        {"category": "creative", "platform": "meta", "confidence": 0.74, "is_active": True,
         "insight": "Short vertical video beats static images on CPM-adjusted conversions."},
        {"category": "timing", "platform": "meta", "confidence": 0.61, "is_active": True,
         "insight": "Tue–Thu mornings deliver the cheapest qualified clicks."}],
    "hypotheses": [
        {"title": "UGC-style creative lowers CPA", "predicted_outcome": "CPA −20%",
         "success_metric": "CPA", "target_value": "≤ €9.50", "status": "active"},
        {"title": "Broad targeting + strong creative beats narrow interests",
         "predicted_outcome": "ROAS +15%", "success_metric": "ROAS",
         "target_value": "≥ 3.2", "status": "active"}],
    "total_spend_eur": 1420.7, "recent_spend_eur": 550.7, "recent_clicks": 1311,
}

# --- RSU sized for the mid-dev story (seed's grant would dwarf the salary) ---
best_effort("rsu resize", market.update_rsu,
            {"ticker": "AAPL", "grant_value_usd": 60000,
             "shares_held": 120, "shares_next_vest": 30})

# --- an "advanced" RSU vest deep-dive (sample research snapshot) ---
last_aapl = (market.prices("AAPL", days=5) or [{}])[-1].get("close", 230)
planner.set_settings({"rsu_vest_analysis": json.dumps({
    "vest_month": "August 2026", "as_of": TODAY.isoformat(),
    "price": round(float(last_aapl), 2),
    "headline": "Hold through the vest, then sell 60% within the trading window — "
                "concentration risk outweighs the upside of waiting.",
    "sections": [
        {"title": "Concentration math",
         "text": "Post-vest, employer stock would be ~11% of net worth and ~24% of the "
                 "liquid portfolio — above the 10% single-name ceiling this plan uses. "
                 "Selling 60% at vest brings it back inside the band; taxes are neutral "
                 "since the vest itself is the taxable event."},
        {"title": "What the market is pricing",
         "text": "Options imply ~29% annualized volatility into the next earnings print; "
                 "the 1-month band from this app's own forecast model is wide but "
                 "symmetric — no directional edge, so risk management decides, not timing."},
        {"title": "Scenario table",
         "text": "Bull (+15%): extra ~$3.4k on the held tranche. Bear (−15%): −$3.4k plus "
                 "the correlated hit to salary security. The asymmetry of holding "
                 "concentrated employer stock is behavioral, not financial."},
        {"title": "Execution",
         "text": "Sell in two tranches inside the window (day 1 and day 10) to average "
                 "out the post-vest dip pattern; route proceeds to the ETF auto-invest "
                 "the same week so the cash doesn't linger."}],
    "sources": []})})

# --- distilled experiences (sample lessons, so the feature shows populated) ---
best_effort("experience table", __import__("experience").ensure_tables)
for q, lesson in [
    ("Overpay the mortgage or invest?",
     "When comparing loan overpayment vs investing, compare the loan rate to the "
     "expected market return AFTER capital-gains tax — not gross. Overpaying a "
     "6–7% loan usually beats a taxed ~5% net market return."),
    ("Should I sell RSU at vest?",
     "Sell enough at vest to bring any single employer stock back under ~10% of the "
     "liquid portfolio; the vest is already the taxable event, so selling then is "
     "roughly tax-neutral and cuts concentration risk."),
    ("Is the currency signal telling me to convert now?",
     "Never convert on a single day's move — wait for the FX engine's threshold to "
     "trigger, and prefer natural hedges (spend income in the currency you earn) "
     "before an explicit conversion."),
]:
    best_effort("experience", __import__("experience").save, q, lesson)

# --- sample AI prompt-log entries, so the log + "💡 Learn from this" flow shows
# (these are the questions the sample lessons above were distilled from) ---
best_effort("llm_log table", __import__("llm_log").ensure_tables)
for q, a in [
    ("Should I overpay the mortgage or invest the surplus?",
     "With a ~6.5% mortgage vs a ~5% net expected market return (after 19% "
     "capital-gains tax), overpaying wins on a risk-adjusted basis — but keep the "
     "emergency fund intact first. Bottom line: keep the ETF auto-invest running and "
     "direct the annual bonus to overpayment."),
    ("Is now a good time to convert to EUR for the house deposit?",
     "No clear signal today — the pair is range-bound and the FX engine hasn't crossed "
     "its threshold. Prefer the natural hedge: let USD vests flow toward EUR. Bottom "
     "line: wait for the engine's trigger; don't convert on a single day's move."),
    ("Should I sell my RSU at the next vest?",
     "Employer stock would be ~24% of the liquid portfolio post-vest — above the 10% "
     "single-name ceiling. Sell ~60% at vest (the taxable event already happened, so "
     "it's roughly tax-neutral) and route the proceeds to the ETF auto-invest."),
]:
    best_effort("llm_log", __import__("llm_log").record, q,
                {"mode": "local", "rag_used": True,
                 "local": {"ok": True, "text": a}, "best": a})

# --- the AI improvement tab: ratings attributed to the lessons in context, three quality-eval
# runs and an indexed memory, so the tab tells the full loop instead of "nothing yet" ---
def _ai_story():
    import rag
    lessons = {r["question"]: r["id"] for r in eb._rows("select id, question from agent_experiences")}
    logs = eb._rows("select id, prompt from llm_log order by ts")
    by_topic = [("overpay", "Overpay the mortgage or invest?", 1),
                ("EUR", "Is the currency signal telling me to convert now?", -1),
                ("RSU", "Should I sell RSU at vest?", 1)]
    for word, lesson_q, rating in by_topic:
        row = next((r for r in logs if word in r["prompt"]), None)
        if row and lesson_q in lessons:
            eb._exec("update llm_log set rating=?, lessons=? where id=?",
                     (rating, json.dumps([lessons[lesson_q]]), row["id"]))
    eval_dir = data_dir / "eval"
    eval_dir.mkdir(exist_ok=True)
    (eval_dir / "gold-demo.json").write_text("[]")
    for days_ago, passed in ((40, 11), (19, 13), (3, 15)):
        ts = (datetime.now() - timedelta(days=days_ago)).strftime("%Y-%m-%d %H:%M")
        (eval_dir / f"run-{ts[:10]}.json").write_text(json.dumps(
            {"variant": "baseline", "model": "local-9b-q6 (demo)", "ts": ts, "passed": passed, "total": 16, "cases": {}}))
    rag.reindex()   # BM25 only on CI (no embedding server); clears the "new data" flag


best_effort("AI improvement story", _ai_story)

# --- market brief (handcrafted — no LLM in the build) ---
brief = {
    "headline": "Markets are drifting sideways while rate-cut expectations firm up; "
                "nothing in this demo portfolio needs action this week.",
    "as_of": TODAY.isoformat(), "generated_by": "demo snapshot",
    "regime": "Calm — carry on with the plan",
    "highlights": [
        {"icon": "📈", "title": "Equities steady", "text": "Broad indices near highs on soft-landing hopes."},
        {"icon": "💱", "title": "FX quiet", "text": "EUR/USD range-bound; no conversion signal."},
        {"icon": "🏦", "title": "Rates drifting down", "text": "Markets price gradual cuts into year-end."},
        {"icon": "🛢️", "title": "Oil calm", "text": "Supply steady; inflation pass-through muted."}],
    "geopolitics": [
        {"title": "Why this matters for a long-term plan",
         "text": "Sample context written for the demo: headlines move daily prices, "
                 "but the plan only reacts when thresholds are crossed."}],
    "positions": [
        {"ticker": "VWCE.DE", "stance": "hold", "text": "Core holding — keep the monthly auto-invest."},
        {"ticker": "AAPL", "stance": "hold", "text": "RSU exposure already large; don't add."},
        {"ticker": "EURUSD=X", "stance": "hold", "text": "No signal — wait for the engine's threshold."}],
    "method_note": "Demo brief — in the real app this is generated daily by YOUR local "
                   "model from cached quotes, schema-locked so it always renders.",
}
planner.set_settings({"analysis_market_brief_daily": json.dumps(brief),
                      "analysis_market_brief": json.dumps({**brief,
                          "as_of": (TODAY - timedelta(days=5)).isoformat(),
                          "headline": "Weekly view: same picture — stay the course. "
                                      "(Demo copy of the weekly brief.)"})})


# --- public GitHub activity of the maintainer (public data; synthetic fallback) ---
def github_activity_snapshot(days=90):
    counts, prs, issues, reviews = {}, 0, 0, 0
    try:
        for page in (1, 2, 3):
            req = urllib.request.Request(
                f"https://api.github.com/users/grzella/events/public?per_page=100&page={page}",
                headers={"User-Agent": "kist-demo-build"})
            with urllib.request.urlopen(req, timeout=10) as r:
                events = json.loads(r.read())
            if not events:
                break
            for ev in events:
                d = ev["created_at"][:10]
                if ev["type"] == "PushEvent":
                    counts[d] = counts.get(d, 0) + len(ev["payload"].get("commits") or [1])
                elif ev["type"] == "PullRequestEvent":
                    prs += 1
                elif ev["type"] == "IssuesEvent":
                    issues += 1
                elif ev["type"] == "PullRequestReviewEvent":
                    reviews += 1
        connected = bool(counts)
    except Exception as e:
        print(f"  (github events fallback: {str(e)[:60]})")
        connected = False
    if not connected:  # deterministic synthetic pattern
        for back in range(days):
            d = (TODAY - timedelta(days=back)).isoformat()
            counts[d] = max(0, RNG.randint(-2, 6))
        prs, issues, reviews = 4, 2, 3
    series = [{"date": (TODAY - timedelta(days=back)).isoformat(),
               "count": counts.get((TODAY - timedelta(days=back)).isoformat(), 0)}
              for back in range(days - 1, -1, -1)]
    active = [s for s in series if s["count"] > 0]
    streak = best = 0
    for s in series:
        streak = streak + 1 if s["count"] > 0 else 0
        best = max(best, streak)
    total = sum(s["count"] for s in series)
    return {"configured": True, "repos": 1, "days": days, "today": series[-1]["count"],
            "week": sum(s["count"] for s in series[-7:]),
            "streak": streak, "best_streak": best,
            "active_days": len(active),
            "active_pct": round(100 * len(active) / days),
            "total": total,
            "avg_per_active": round(total / max(1, len(active)), 1),
            "series": series,
            "github": {"connected": connected, "login": "grzella",
                       "prs": prs, "issues": issues, "reviews": reviews}}


# ================= snapshot every GET endpoint ================================
import app as kist_app  # noqa: E402

client = kist_app.app.test_client()


def fname(path_q):
    return re.sub(r"[^A-Za-z0-9._-]", "_", path_q.lstrip("/")) + ".json"


snap_dir = OUT / "demo-data"
if OUT.exists():
    shutil.rmtree(OUT)
snap_dir.mkdir(parents=True)

paths = sorted(str(r) for r in kist_app.app.url_map.iter_rules()
               if "GET" in r.methods and not r.arguments and str(r).startswith("/api"))
for t in TICKERS:
    paths += [f"/api/market/prices/{t}?days=100000",
              f"/api/market/analytics/{t}",
              f"/api/forecast/bands/{t}"]
# parametrized rules the crawl above can't enumerate
paths += ["/api/analysis/career", "/api/analysis/contributions", "/api/analysis/property"]
# trajectory scenario controls (Forecasts tab): the default combination plus every single
# control moved off its default, in the exact query order the UI sends. Any other combination
# falls back to the base snapshot in api.get() with a toast.
_TJ_DEFAULT = {"months": "24", "bonus": "1", "team": "0", "usd": "0", "real": "0"}
_TJ_ALT = {"months": ["12", "36"], "bonus": ["0"], "team": ["-30", "30"], "usd": ["-10", "10"], "real": ["1"]}
def _tj(q): return "/api/trajectory?" + "&".join(f"{k}={q[k]}" for k in ("months", "bonus", "team", "usd", "real"))
paths.append(_tj(_TJ_DEFAULT))
for k, alts in _TJ_ALT.items():
    for a in alts:
        paths.append(_tj({**_TJ_DEFAULT, k: a}))

# Some endpoints report on the LOCAL MACHINE running this script (e.g. backup
# destinations auto-detected from the filesystem: home dir, cloud-sync folder
# names that embed the account's email). On a clean CI runner they're always
# empty; run this script on your own laptop and they leak your real
# username/email into the committed-nowhere-but-deployed static demo. Scrub
# them unconditionally so the demo is safe regardless of where it's built.
def _demo_ai_improve(d):
    """No model runs in the static demo: the "Local model" row would always be red there.
    Say so instead, and recount the header from the rewritten rows."""
    for st in d.get("steps", []):
        if st["area"] == "model":
            st.update(state="ok", action=None,
                      detail="Static demo: no model runs here. In your install this row shows your llama-server and turns red when it stops.")
        if st["area"] == "eval" and st["state"] != "ok":
            st["detail"] = st["detail"].split(". Will not run")[0] + "."
    d["errors"] = sum(1 for st in d["steps"] if st["state"] == "error")
    d["todo"] = sum(1 for st in d["steps"] if st["state"] == "todo")
    return d


_SCRUB = {
    "/api/backup/status": lambda d: {**d, "destinations": [], "dir": ""},
    "/api/ai/improve": _demo_ai_improve,
    # local paths of the machine that builds the demo (restart command, checkout dir)
    "/api/health": lambda d: {**d, "restart_cmd": "./run.sh"},
    "/api/git": lambda d: {**d, "repo": ""},
}

ok = fail = 0
for p in paths:
    try:
        r = client.get(p)
        if r.status_code == 200 and r.is_json:
            payload = r.get_json()
            scrub = _SCRUB.get(p.split("?")[0])
            if scrub:
                payload = scrub(payload)
            (snap_dir / fname(p)).write_text(json.dumps(payload, ensure_ascii=False))
            ok += 1
        else:
            fail += 1
            print(f"  skip {p} ({r.status_code})")
    except Exception as e:
        fail += 1
        print(f"  skip {p} ({str(e)[:60]})")
print(f"Snapshots: {ok} saved, {fail} skipped")

# post-crawl patches: endpoints that need Supabase / gh CLI in a real install
(snap_dir / fname("/api/business/marketing")).write_text(json.dumps(MARKETING))
(snap_dir / fname("/api/github-activity")).write_text(
    json.dumps(github_activity_snapshot()))


# --- compute-only POSTs the Forecasts view fires (overpayment simulations):
# bake their responses under a path+body key that api.js reconstructs ---
def js_num(v):
    """Mirror JSON.stringify: integral floats print as ints (3500.0 → 3500)."""
    return int(v) if isinstance(v, float) and v.is_integer() else v


def post_snapshot(path, body):
    key = "post_" + path + "__" + json.dumps(body, separators=(",", ":"))
    r = client.post(path, json=body)
    if r.status_code == 200 and r.is_json:
        (snap_dir / (re.sub(r"[^A-Za-z0-9._-]", "_", key.lstrip("/")) + ".json")
         ).write_text(json.dumps(r.get_json(), ensure_ascii=False))
    else:
        print(f"  skip POST {path} ({r.status_code})")


def snap(n):
    return json.loads((snap_dir / fname(n)).read_text())


loan = next((d for d in snap("/api/debts")["debts"] if d["balance"] > 0), None)
if loan:
    bonus = js_num(float(snap("/api/settings").get("annual_bonus_net") or 20000))
    vest = js_num(float(snap("/api/rsu").get("next_vest_value_pln") or 0))
    for amount in {bonus, vest, js_num(bonus + vest)}:
        if amount < loan["balance"]:  # JS skips only amounts covering the whole balance
            post_snapshot("/api/forecast/mortgage", {
                "balance": js_num(loan["balance"]),
                "monthly_payment": js_num(loan["minimum_payment"]),
                "months_left": js_num(loan.get("months_left") or loan["schedule"]["months"]),
                "overpayment": amount})

# ================= assemble dist ==============================================
for d in ("css", "js", "vendor"):
    shutil.copytree(ROOT / "static" / d, OUT / d)
html = (ROOT / "static" / "index.html").read_text()
html = html.replace('"/static/', '"')
html = html.replace("<script", '<script>window.KIST_STATIC_DEMO=1</script>\n  <script', 1)
html = html.replace("<body>", """<body>
<div style="background:#ffd166;color:#1c1e26;text-align:center;padding:6px 12px;
  font-weight:600;font-size:.9em">🟡 Demo mode — sample data (fake persona
  “Alex Demo”), read-only. Nothing here is real or financial advice.</div>""", 1)
(OUT / "index.html").write_text(html)
(OUT / ".nojekyll").write_text("")

print(f"✅ Demo built → {OUT}")