"""Web UI routes — Jinja2 templates served by FastAPI. Replicates all Flask webapp routes with DuckDB-backed data. """ import logging import os from datetime import datetime, timezone from pathlib import Path from typing import Optional from urllib.parse import quote from fastapi import APIRouter, Depends, Request, HTTPException from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates import duckdb import jinja2 from app.auth.access import is_user_admin, require_admin from app.auth.dependencies import get_current_user, get_optional_user, _get_db from app.instance_config import ( get_instance_name, get_instance_subtitle, get_datasets, get_theme, get_corporate_memory_config, get_home_route, get_gws_oauth_credentials, get_home_automode_visibility, get_instance_admin_email, get_atlassian_base_url, get_instance_brand, get_workspace_dir_name, get_instance_logo_svg, get_instance_overview, ) from app.web.connector_prompts import all_connector_prompts from app.api.me_debug import ( require_debug_auth_enabled, _read_session_token, _decoded_claims, _token_fingerprint, _last_sync_summary, ) from src.repositories.sync_state import SyncStateRepository from src.repositories.sync_settings import SyncSettingsRepository from src.repositories.knowledge import KnowledgeRepository from src.repositories.users import UserRepository from src.repositories.profiles import ProfileRepository def _resolved_home_route() -> str: """Lazy wrapper so tests/monkeypatch on env vars are honoured per-request.""" return get_home_route() _STATIC_DIR = Path(__file__).resolve().parent / "static" def _static_url(path: str) -> str: """Build /static/ with a cache-buster query string. Appends ``?v=`` so a redeploy that changes a CSS/JS file invalidates browser + proxy caches without operator intervention. Missing files return the bare URL — FastAPI's StaticFiles will surface the 404 normally. Cheap (one ``os.stat`` per template variable use). """ full = _STATIC_DIR / path try: v = int(full.stat().st_mtime) return f"/static/{path}?v={v}" except OSError: return f"/static/{path}" logger = logging.getLogger(__name__) router = APIRouter(tags=["web"]) TEMPLATES_DIR = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) # Make templates tolerant of missing variables (renders empty string instead of error) class _SilentUndefined(jinja2.Undefined): """Silently handle any access on undefined variables — returns empty/falsy.""" def __str__(self): return "" def __iter__(self): return iter([]) def __bool__(self): return False def __len__(self): return 0 def __getattr__(self, name): return self def __getitem__(self, name): return self def __call__(self, *args, **kwargs): return self def __int__(self): return 0 templates.env.undefined = _SilentUndefined # Add custom JSON filter that handles _SilentUndefined and _FlexDict import json as _json class _SafeEncoder(_json.JSONEncoder): def default(self, obj): if isinstance(obj, (_SilentUndefined, _FlexDict)): if isinstance(obj, _FlexDict) and dict.__len__(obj) > 0: return dict(obj) return None return super().default(obj) templates.env.policies["json.dumps_function"] = lambda obj, **kw: _json.dumps(obj, cls=_SafeEncoder, **kw) def _humanbytes(value, precision: int = 2) -> str: """Render a byte count as the largest binary-prefixed unit it fits in. Below 1 KiB → integer bytes; otherwise ``precision`` decimal places of KB / MB / GB / TB (binary, 1024-based). Used by the Store detail template (default 2-decimal precision for fine-grained file sizes) and by the /dashboard stat tiles (1-decimal precision for headline numbers). Intentionally permissive about input type so missing / undefined values render as ``0 B`` rather than crashing the page. """ try: n = int(value or 0) except (TypeError, ValueError): return "0 B" if n < 1024: return f"{n} B" kb = n / 1024 if kb < 1024: return f"{kb:.{precision}f} KB" mb = kb / 1024 if mb < 1024: return f"{mb:.{precision}f} MB" gb = mb / 1024 if gb < 1024: return f"{gb:.{precision}f} GB" tb = gb / 1024 return f"{tb:.{precision}f} TB" templates.env.filters["humanbytes"] = _humanbytes def _store_display_name(name: str | None) -> str: """Strip the archive-rename suffix from a store entity's display name so admin queue / my-stack / detail templates show the original label instead of the internal `__archived__` marker. Safe on plain (non-archived) names — no-op.""" from src.store_naming import strip_archive_suffix return strip_archive_suffix(name or "") templates.env.filters["store_display_name"] = _store_display_name # ---- PostHog template wiring ---- # Two Jinja globals injected into every render so the `_posthog.html` partial # (included from `base.html` and `base_login.html`) can render the browser # snippet — or render nothing when the integration is disabled. # # posthog_config process-level static config (host, project key, # replay flag, extra mask selector). Resolved # once on first access. # posthog_user_block(request) per-request identify payload honoring the # operator-chosen identify mode. Returns None # for anonymous renders. def _posthog_config_global() -> dict: from src.observability import get_posthog pc = get_posthog() if not pc.enabled: return {"enabled": False} return { "enabled": True, "host": pc.host, "api_key_public": pc.api_key_public, "replay_enabled": pc.replay_enabled, "replay_mask_selector_extra": pc.replay_mask_selector_extra, "environment": pc.environment, "release": pc.release, } def _posthog_user_block(request: Optional[Request]) -> Optional[dict]: from src.observability import get_posthog pc = get_posthog() if not pc.enabled: return None mode = pc.identify_mode if mode == "none": return None user = None if request is not None: try: user = getattr(request.state, "user", None) except Exception: user = None if not user: return None def _get(attr: str): if isinstance(user, dict): return user.get(attr) return getattr(user, attr, None) distinct_id = _get("id") or _get("user_id") or _get("email") if not distinct_id: return None props: dict = {} if mode in ("email", "full"): email = _get("email") if email: props["email"] = str(email) if mode == "full": name = _get("name") or _get("full_name") if name: props["name"] = str(name) return {"distinct_id": str(distinct_id), "props": props} templates.env.globals["posthog_config"] = _posthog_config_global() templates.env.globals["posthog_user_block"] = _posthog_user_block class _FlexDict(dict): """Dict that returns empty _FlexDict for missing keys and attributes. Prevents Jinja2 UndefinedError when templates access missing nested values.""" def __getattr__(self, name): try: return self[name] except KeyError: return _FlexDict() def __bool__(self): return bool(dict.__len__(self)) def __str__(self): return "" def __int__(self): return 0 def __float__(self): return 0.0 def __iter__(self): return iter(dict.values(self)) if dict.__len__(self) else iter([]) def __len__(self): return dict.__len__(self) def __call__(self, *args, **kwargs): return "" def __add__(self, other): return other def __radd__(self, other): return other def __sub__(self, other): return 0 - other if isinstance(other, (int, float)) else self def __rsub__(self, other): return other def __mul__(self, other): return 0 def __rmul__(self, other): return 0 def __truediv__(self, other): return 0 def __rtruediv__(self, other): return 0 def __mod__(self, other): return 0 def __eq__(self, other): return False if dict.__len__(self) == 0 else dict.__eq__(self, other) def __ne__(self, other): return True if dict.__len__(self) == 0 else dict.__ne__(self, other) def __lt__(self, other): return False def __gt__(self, other): return False def __le__(self, other): return True def __ge__(self, other): return True def __contains__(self, item): return dict.__contains__(self, item) if dict.__len__(self) else False def _flex(d): """Recursively convert dicts to _FlexDict for template compatibility.""" if isinstance(d, dict) and not isinstance(d, _FlexDict): return _FlexDict({k: _flex(v) for k, v in d.items()}) if isinstance(d, list): return [_flex(i) for i in d] return d _URL_MAP = { # Flask-style endpoint names → FastAPI URL paths "dashboard": "/dashboard", "catalog": "/catalog", "corporate_memory": "/corporate-memory", "corporate_memory_admin": "/admin/corporate-memory", "activity_center": "/activity-center", "admin_activity": "/admin/activity", "index": "/", "auth.login": "/login", "auth.logout": "/login", # No logout route — redirect to login "password_auth.login_email": "/auth/password/login", "password_auth.reset_request": "/auth/password/reset", "password_auth.request_access": "/auth/password/setup", "email_auth.login_email_form": "/login/email", "email_auth.send_magic_link": "/auth/email/send-link", "register": "/auth/password/setup", "setup": "/first-time-setup", } def _url_for_shim(endpoint: str, **kw) -> str: """Flask url_for compatibility — maps endpoint names to FastAPI paths.""" if endpoint == "static": filename = kw.get("filename", "") return f"/static/{filename}" return _URL_MAP.get(endpoint, f"/{endpoint}") def _read_agnes_ca_pem() -> Optional[str]: """Read the Agnes server's TLS fullchain for inlining into the setup prompt. Returns the PEM string when the cert needs trust-bootstrapping — self-signed (leaf issuer == subject), private-CA chain that doesn't terminate in a `certifi`-known root, or any case where we can't cheaply prove the OS would trust it. Returns None when the chain in the served fullchain.pem terminates in a publicly-trusted root that `certifi` already ships (Let's Encrypt's ISRG Root X1, DigiCert, etc.) — clients (Bun-compiled `claude.exe`, system git, Python with certifi) all accept the chain without help. Chain validation walks every cert in the served fullchain and succeeds the first time any cert's issuer matches a `certifi` root subject. That captures the standard fullchain shape (leaf + intermediate(s)) where `intermediate.issuer == publicly_trusted_root`, even though the leaf's *immediate* issuer is the intermediate (which is rarely shipped in trust stores — only roots are). Inlining a publicly-trusted cert is harmless (clients already trust it via OS roots), but it bloats the prompt and steers users into setting SSL_CERT_FILE unnecessarily, which narrows their Python TLS trust to just this host. So skip when we can confirm broad trust. Path is configurable via AGNES_TLS_FULLCHAIN_PATH (defaults to `/data/state/certs/fullchain.pem`, the location `agnes-tls-rotate.sh` writes on every VM and `docker-compose.host-mount.yml` rbinds into the app container). Missing / unreadable / unparseable → None, and the setup prompt falls back to its pre-cert behavior. """ path = Path(os.environ.get("AGNES_TLS_FULLCHAIN_PATH", "/data/state/certs/fullchain.pem")) try: if not path.is_file(): return None pem = path.read_text(encoding="utf-8") except OSError: return None if "-----BEGIN CERTIFICATE-----" not in pem: return None try: from cryptography import x509 chain = x509.load_pem_x509_certificates(pem.encode("utf-8")) if not chain: return None leaf = chain[0] if leaf.issuer == leaf.subject: # Self-signed — definitely needs bootstrap on the client. return pem # CA-signed leaf: walk every cert in the served fullchain (leaf + # intermediates) and check whether ANY of their issuers is in # `certifi`'s trust store. The first match means the chain # terminates in a publicly-trusted root, so the client OS / Bun # bundle / certifi already accept it. try: import certifi with open(certifi.where(), "rb") as fh: trust_pem = fh.read() except Exception: return pem # can't enumerate trust → assume bootstrap needed trusted_subjects = { ca.subject.rfc4514_string() for ca in x509.load_pem_x509_certificates(trust_pem) } for cert in chain: if cert.issuer.rfc4514_string() in trusted_subjects: return None # publicly trusted; client OS already accepts return pem except Exception: # pragma: no cover — defensive: bad PEM / x509 error logger.exception("Failed to evaluate Agnes TLS cert; skipping inline") return None def _build_context( request: Request, user: Optional[dict] = None, conn: Optional[duckdb.DuckDBPyConnection] = None, **extra, ) -> dict: """Build template context with config, user, and theme. `conn` is optional: when supplied alongside a logged-in `user`, the setup-prompt preview/clipboard payload is rendered with that user's RBAC-allowed Claude Code marketplace plugins inlined as install commands. Routes that don't render the env-setup-cta block can omit it. """ class ConfigProxy: INSTANCE_NAME = get_instance_name() INSTANCE_SUBTITLE = get_instance_subtitle() INSTANCE_COPYRIGHT = "" LOGO_SVG = get_instance_logo_svg() INSTANCE_OVERVIEW = get_instance_overview() TELEGRAM_BOT_USERNAME = os.environ.get("TELEGRAM_BOT_USERNAME", "") SSH_ALIAS = "data-analyst" SERVER_HOST = os.environ.get("SERVER_HOST", "") PROJECT_DIR = "data-analyst" # Drives whether the user dropdown renders the "Auth debug" link. # Same env var the route guard checks — keep them in lock-step so # the link never appears when the route would 404, and vice versa. DEBUG_AUTH_ENABLED = os.environ.get("AGNES_DEBUG_AUTH", "").strip().lower() in ( "1", "true", "yes", ) # Google Workspace prefix-mapping config — surfaced into templates # so client-side JS can derive a friendly display name from the # full Workspace email stored as the group's `name` (admin UI # strips the prefix and `@domain` for the big line, keeps the # full email as subtitle). Read at template render time so an # operator can flip these via env without an image rebuild. AGNES_GOOGLE_GROUP_PREFIX = os.environ.get( "AGNES_GOOGLE_GROUP_PREFIX", "" ) AGNES_GROUP_ADMIN_EMAIL = os.environ.get( "AGNES_GROUP_ADMIN_EMAIL", "" ) AGNES_GROUP_EVERYONE_EMAIL = os.environ.get( "AGNES_GROUP_EVERYONE_EMAIL", "" ) @staticmethod def theme_overrides(): theme = get_theme() # Return dict of CSS variable overrides (only non-empty values) if isinstance(theme, dict): return {k: v for k, v in theme.items() if v} return {} ctx_server_url = str(request.base_url).rstrip("/") # Lines for the "Setup a new Claude Code" preview/clipboard partial. # # When a DB connection is available, we go through render_agent_prompt_banner # which checks for an admin override first (stored in welcome_template) and # falls back to the live default from setup_instructions.resolve_lines(). # This guarantees that both /setup and /dashboard clipboard CTA always reflect # the same content — the override is honoured everywhere. # # When no conn is supplied (e.g. public pages that don't need a DB round-trip) # we fall back to resolve_lines() directly with anonymous/no-plugin context. if conn is not None: from src.welcome_template import render_agent_prompt_banner _script_text = render_agent_prompt_banner( conn, user=user, server_url=ctx_server_url ) setup_instructions_lines = _script_text.split("\n") else: # No DB connection — use the unauthenticated default (no override possible, # no marketplace plugins). from app.web.setup_instructions import resolve_lines from app.api.cli_artifacts import _find_wheel _wheel = _find_wheel() _wheel_filename = _wheel.name if _wheel else "agnes.whl" server_host = request.url.netloc ca_pem = _read_agnes_ca_pem() # Connector prompts wired through so the setup script's connector # step inlines them. all_connector_prompts() reads operator GWS # OAuth config so the GCP-frictionless branch fires when the # admin has provisioned a shared client_id+secret. _connector_prompts = all_connector_prompts( gws_oauth=get_gws_oauth_credentials(), instance_admin_email=get_instance_admin_email(), atlassian_base_url=get_atlassian_base_url(), instance_brand=get_instance_brand(), ) setup_instructions_lines = resolve_lines( _wheel_filename, plugin_install_names=[], server_host=server_host, ca_pem=ca_pem, connector_prompts=_connector_prompts, instance_brand=get_instance_brand(), workspace_dir=get_workspace_dir_name(), ) ctx = { "request": request, "config": ConfigProxy, "user": _flex(user) if user else _FlexDict(), "now": datetime.now, "static_url": _static_url, # Flask compatibility shims for templates "get_flashed_messages": lambda **kwargs: [], "url_for": lambda endpoint, **kw: _url_for_shim(endpoint, **kw), "session": _FlexDict({"user": user}) if user else _FlexDict(), "setup_instructions_lines": setup_instructions_lines, "server_url": ctx_server_url, # Resolved per AGNES_HOME_ROUTE env > instance.home_route YAML > # /dashboard. The shared navbar's "Dashboard" link uses this so a # single env flip routes the primary nav target between /home # (state-aware landing) and /dashboard (legacy table inventory). "home_route": _resolved_home_route(), # Branding: `instance_name` is the deploying org's display name # (page titles); `instance_brand` is the product name used in body # copy and CTAs ("Setup {brand}", "{brand} runs SELECT…"); `workspace_dir` # is the filesystem-safe folder name shown in `~/` and # baked into the clipboard setup script. All three default to the # Agnes-flavored values out of the box; Terraform can flip them via # env vars (AGNES_INSTANCE_BRAND / AGNES_WORKSPACE_DIR_NAME). "instance_name": get_instance_name(), "instance_brand": get_instance_brand(), "workspace_dir": get_workspace_dir_name(), # Whether /home renders the "Step 3 — turn on auto-accept mode" # install-block. Operator can hide it via AGNES_HOME_SHOW_AUTOMODE=0 # for cautious rollouts; same content stays on /setup-advanced. "home_automode": {"show": get_home_automode_visibility()}, } # Flex all extra context values for template compatibility # (but skip ones we just populated — extras with the same key win) for k, v in extra.items(): ctx[k] = _flex(v) if isinstance(v, (dict, list)) else v return ctx # ---- Navigation ---- @router.get("/", response_class=HTMLResponse) async def index(request: Request, user: Optional[dict] = Depends(get_optional_user)): if user: from app.instance_config import get_home_route return RedirectResponse(url=get_home_route(), status_code=302) return RedirectResponse(url="/login", status_code=302) @router.get("/first-time-setup", response_class=HTMLResponse) async def setup_wizard(request: Request, conn: duckdb.DuckDBPyConnection = Depends(_get_db)): """First-time setup wizard. Redirects to login if users already exist.""" try: user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] if user_count > 0: return RedirectResponse(url="/login", status_code=302) except Exception: pass # No users table yet — show setup return templates.TemplateResponse(request, "setup.html", _build_context(request)) @router.get("/login", response_class=HTMLResponse) async def login_page(request: Request): from app.auth.dependencies import is_local_dev_mode, _get_local_dev_user if is_local_dev_mode(): # Only short-circuit to the home route if the dev user is actually # seeded. Otherwise a 401 there would bounce back to /login and loop. from src.db import get_system_db conn = get_system_db() try: if _get_local_dev_user(conn): return RedirectResponse(url=get_home_route(), status_code=302) finally: conn.close() # Fall through to the normal login form so the missing-seed error is visible. next_path = request.query_params.get("next", "") if not next_path.startswith("/") or next_path.startswith("//"): next_path = "" providers = [] try: from app.auth.providers.google import is_available as google_available if google_available(): providers.append({"name": "google", "display_name": "Google", "icon": "google"}) except Exception: pass providers.append({"name": "password", "display_name": "Email & Password", "icon": "key"}) try: from app.auth.providers.email import is_available as email_available if email_available(): providers.append({"name": "email", "display_name": "Email Link", "icon": "mail"}) except Exception: pass # Convert to login_buttons format expected by template login_buttons = [] for p in providers: if p["name"] == "google": _url = "/auth/google/login" if next_path: _url += f"?next={quote(next_path, safe='')}" login_buttons.append({"url": _url, "text": "Sign in with Google", "css_class": "btn-primary", "icon_html": ""}) elif p["name"] == "password": _url = "/login/password" if next_path: _url += f"?next={quote(next_path, safe='')}" login_buttons.append({"url": _url, "text": "Sign in with Email & Password", "css_class": "btn-secondary", "icon_html": ""}) elif p["name"] == "email": _url = "/login/email" if next_path: _url += f"?next={quote(next_path, safe='')}" login_buttons.append({"url": _url, "text": "Sign in with Email Link", "css_class": "btn-secondary", "icon_html": ""}) ctx = _build_context(request, providers=providers, login_buttons=login_buttons, next_path=next_path) return templates.TemplateResponse(request, "login.html", ctx) @router.get("/login/password", response_class=HTMLResponse) async def login_password_page(request: Request): """Password login form (email + password).""" next_path = request.query_params.get("next", "") if not next_path.startswith("/") or next_path.startswith("//"): next_path = "" google_ok = False try: from app.auth.providers.google import is_available as google_available google_ok = google_available() except Exception: pass ctx = _build_context(request, google_available=google_ok, next_path=next_path) return templates.TemplateResponse(request, "login_email.html", ctx) @router.get("/login/email", response_class=HTMLResponse) async def login_email_page(request: Request): """Email magic link login form.""" next_path = request.query_params.get("next", "") if not next_path.startswith("/") or next_path.startswith("//"): next_path = "" google_ok = False try: from app.auth.providers.google import is_available as google_available google_ok = google_available() except Exception: pass ctx = _build_context(request, google_available=google_ok, next_path=next_path) return templates.TemplateResponse(request, "login_email.html", ctx) @router.get("/dashboard", response_class=HTMLResponse) async def dashboard( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): sync_repo = SyncStateRepository(conn) settings_repo = SyncSettingsRepository(conn) profile_repo = ProfileRepository(conn) all_states = sync_repo.get_all_states() enabled_datasets = settings_repo.get_enabled_datasets(user["id"]) datasets = get_datasets() # Stats. `total_tables` counts REGISTERED business tables, not synced # ones (a registry of 30 with 0 ever synced would otherwise render as # "0"). Internal source_type tables (agnes_*) live in their own card on # /catalog and are excluded from the headline counter. Columns + size # come from sync_state, which is the canonical source for "what's # actually on disk locally". total_tables = conn.execute( "SELECT COUNT(*) FROM table_registry WHERE COALESCE(source_type, '') != 'internal'" ).fetchone()[0] total_rows = sum(s.get("rows", 0) or 0 for s in all_states) total_columns = sum(s.get("columns", 0) or 0 for s in all_states) total_size_bytes = sum(s.get("file_size_bytes", 0) or 0 for s in all_states) # Build user_info object expected by dashboard template is_admin = is_user_admin(user["id"], conn) class UserInfo: def __init__(self): self.exists = True self.is_admin = is_admin # Legacy fields kept so existing templates don't blow up — admin is # implicitly analyst/privileged, non-admins are not. Granular roles # collapsed in v12. self.is_analyst = is_admin self.is_privileged = is_admin self.username = user.get("email", "").split("@")[0] self.home_dir = "" self.groups = [] ctx = _build_context( request, user=user, conn=conn, user_info=UserInfo(), username=user.get("email", "").split("@")[0], total_tables=total_tables, total_rows=total_rows, sync_states=all_states, enabled_datasets=enabled_datasets, datasets=datasets, account_status="active", account_details=None, telegram_status={"linked": False}, data_stats={ "tables": total_tables, "total_tables": total_tables, "columns": total_columns, "rows_display": f"{total_rows:,}" if total_rows else "0", "size_display": _humanbytes(total_size_bytes, precision=1) if total_size_bytes else "0 MB", "total_rows": total_rows, "last_updated": max( (s.get("last_sync") for s in all_states if s.get("last_sync")), default=None, ), "remote_tables": 0, "local_tables": total_tables, }, categories=[], metrics_data=[], desktop_status={"linked": False}, activity_summary={"total_sessions": 0, "total_queries": 0}, knowledge_stats={"total": 0, "approved": 0}, user_knowledge_stats={"authored": 0, "votes_given": 0}, ) return templates.TemplateResponse(request, "dashboard.html", ctx) @router.get("/home", response_class=HTMLResponse) async def home_page( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """State-aware /home — full inline install for not-onboarded users, clean nav hub once onboarded. The boolean drives template selection; no auto-transition (manual reload picks up the flip after ``agnes init`` POSTs ``/api/me/onboarded``). See origin: docs/brainstorms/home-page-requirements.md. """ row = conn.execute( "SELECT onboarded FROM users WHERE id = ?", [user["id"]] ).fetchone() onboarded = bool(row[0]) if row else False # Pull the latest published news intro for the bottom-of-page section. # Template renders the section only when intro is non-empty, so an # instance that has never published news shows nothing extra. from src.repositories.news_template import NewsTemplateRepository news = NewsTemplateRepository(conn).get_current_published() news_intro = news["intro"] if (news and news.get("intro")) else "" # Homepage status frame (Last sync, Sessions, Prompts, Tokens, Projects). # Gated on (a) operator flag instance.home.show_status_frame / # AGNES_HOME_SHOW_STATUS_FRAME (default on), AND (b) the user being # onboarded — first-day users see a clean install-hero before zero-value # stat cards. When either gate is closed we skip the DB read entirely. from app.api.me import compute_home_stats from app.instance_config import get_home_status_frame_visibility status_frame_enabled = get_home_status_frame_visibility() home_stats = ( compute_home_stats(conn, user, "24h") if (status_frame_enabled and onboarded) else None ) # Single template renders both states. The post-onboarding view keeps # the install-steps + connector prompts + auto-mode card visible — # they stay relevant for adding a second machine, a missing connector, # or re-running auto-mode setup. Hero copy + the self-mark control # branch on the boolean. The legacy `home_onboarded.html` is kept on # disk for a release as a fallback but no route renders it. ctx = _build_context( request, user=user, conn=conn, onboarded=onboarded, is_admin=is_user_admin(user["id"], conn), news_intro=news_intro, home_stats=home_stats, status_frame_enabled=status_frame_enabled, ) return templates.TemplateResponse(request, "home_not_onboarded.html", ctx) @router.get("/me/activity", response_class=HTMLResponse) async def me_activity_page( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Unified personal-activity page — consolidated replacement for the old ``/me/stats`` + ``/profile/sessions`` split. Four tabs (Sessions / Token usage / Data access / Sync activity) backed by ``/api/me/stats/*`` endpoints. The Sessions tab merges usage metrics with verification-pipeline status and download links. """ ctx = _build_context( request, user=user, conn=conn, is_admin=is_user_admin(user["id"], conn), ) return templates.TemplateResponse(request, "me_activity.html", ctx) @router.get("/me/stats", response_class=HTMLResponse) async def me_stats_redirect(request: Request): """Legacy redirect — ``/me/stats`` → ``/me/activity``.""" return RedirectResponse(url="/me/activity", status_code=301) @router.get("/news", response_class=HTMLResponse) async def news_page( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Permalink page for the latest published news. Renders empty-state copy when no version is published. Authed-only (same as /home). """ from src.repositories.news_template import NewsTemplateRepository news = NewsTemplateRepository(conn).get_current_published() ctx = _build_context( request, user=user, conn=conn, is_admin=is_user_admin(user["id"], conn), news=news, ) return templates.TemplateResponse(request, "news.html", ctx) @router.get("/admin/news", response_class=HTMLResponse) async def admin_news_editor( request: Request, user: dict = Depends(require_admin), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Admin authoring surface — current published banner, draft editor, versions table. JS hits the /api/admin/news/* endpoints for the write paths.""" from src.repositories.news_template import NewsTemplateRepository repo = NewsTemplateRepository(conn) ctx = _build_context( request, user=user, conn=conn, is_admin=True, news_current=repo.get_current_published(), news_draft=repo.get_active_draft(), news_versions=repo.list_versions(limit=50), ) return templates.TemplateResponse(request, "admin/news_editor.html", ctx) @router.get("/setup-advanced", response_class=HTMLResponse) async def setup_advanced_page( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Advanced setup reference — VS Code layout, recommended plugins, multi-model second opinions, custom skills, cost guidance. Pulls the deeper Chief-of-Stuff guide content out of /home so /home stays scannable for first-hour onboarding. Linked from /home's "Want to look around first?" explore card and from any deep-link anchors emitted by other pages (e.g. /home's auto-mode block points at #yolo). """ ctx = _build_context( request, user=user, conn=conn, is_admin=is_user_admin(user["id"], conn), ) return templates.TemplateResponse(request, "setup_advanced.html", ctx) @router.get("/catalog", response_class=HTMLResponse) async def catalog( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): sync_repo = SyncStateRepository(conn) settings_repo = SyncSettingsRepository(conn) profile_repo = ProfileRepository(conn) all_states = sync_repo.get_all_states() all_profiles = profile_repo.get_all() enabled_datasets = settings_repo.get_enabled_datasets(user["id"]) datasets = get_datasets() # Build catalog data from table_registry in DuckDB. Filter pre-render so # the page only lists tables the user actually has access to — Admin # group members see everything (can_access shortcut), other users see # only entries with a matching resource_grants(group, "table", id) row. try: from src.repositories.table_registry import TableRegistryRepository from app.auth.access import can_access from app.resource_types import ResourceType table_repo = TableRegistryRepository(conn) registered = table_repo.list_all() user_id = user.get("id", "") tables = [] internal_tables = [] for tc in registered: table_id = tc.get("id", "") if not can_access(user_id, ResourceType.TABLE.value, table_id, conn): continue table_data = { "id": table_id, "name": tc.get("name", ""), "description": tc.get("description", ""), "dataset": tc.get("bucket"), "source_type": tc.get("source_type") or "", "sync_strategy": tc.get("sync_strategy", "full_refresh"), "query_mode": tc.get("query_mode", "local"), "profile": all_profiles.get(table_id), } # Add sync state for state in all_states: if state["table_id"] == table_id: table_data["last_sync"] = state.get("last_sync") table_data["rows"] = state.get("rows") break # Agnes internal tables (agnes_sessions / agnes_telemetry / # agnes_audit) render in a dedicated card on /catalog rather # than under "Core Business Data" — they're system tables, # not business data, but analysts should still discover them # for `agnes query` so they need to live on the catalog page. if tc.get("source_type") == "internal": internal_tables.append(table_data) else: tables.append(table_data) except Exception as e: tables = [] internal_tables = [] logger.warning(f"Could not load catalog: {e}") # Build data_stats for catalog template (business-data card header). # `total_tables` must count REGISTERED business tables, not just # synced ones — a registry of 30 tables with 0 ever synced would # otherwise render as "0 tables" on the Core Business Data card. # `internal` source_type tables render in their own card; exclude # them here so the Core counter doesn't double-count system tables. total_rows = sum(s.get("rows", 0) or 0 for s in all_states) data_stats = { "total_tables": len(tables), "total_rows": total_rows, "total_columns": 0, "total_size": sum(s.get("file_size_bytes", 0) or 0 for s in all_states), "last_updated": max((s.get("last_sync") for s in all_states if s.get("last_sync")), default=None), } # Build business-data categories from `tables` (excludes internal). categories = {} for t in tables: ds = t.get("dataset") or "default" if ds not in categories: categories[ds] = {"name": ds, "tables": []} categories[ds]["tables"].append(t) catalog_data = [] for cat in categories.values(): cat["count"] = len(cat["tables"]) catalog_data.append(cat) # Internal-tables card. Single flat list — the three rows already # share one category ("Agnes Internal"), so no accordion grouping is # useful. Template renders them as a plain list under their own card. internal_card = None if internal_tables: internal_card = { "name": "Agnes Internal", "count": len(internal_tables), "tables": internal_tables, } ctx = _build_context( request, user=user, tables=tables, datasets=datasets, enabled_datasets=enabled_datasets, data_stats=data_stats, categories=catalog_data, catalog_data=catalog_data, internal_card=internal_card, metrics_data=[], sync_states=all_states, folder_mapping={}, ) return templates.TemplateResponse(request, "catalog.html", ctx) @router.get("/corporate-memory", response_class=HTMLResponse) async def corporate_memory( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Curated Memory web view — any authenticated user. This is the analyst-facing read surface for shared organizational knowledge: it lists ``approved`` / ``mandatory`` items plus the caller's own contributions, and sits in the primary nav next to Data Packages. The admin review queue (pending items, contradictions, duplicates) lives separately at ``/admin/corporate-memory`` behind ``require_admin``. Gating matches the underlying ``/api/memory/*`` endpoints, which already run on ``get_current_user`` — CLI / agent flows that POST a knowledge item or read ``/api/memory`` work for any authenticated user, so the web view does too. Admin-only affordances on this page (the pending-review banner) stay gated server-side: ``is_admin_view`` zeroes ``pending_review_count`` for non-admins. """ repo = KnowledgeRepository(conn) # v46: server-side initial render mirrors the JS fetch contract — items # are annotated with `dismissed_by_me` so the template can gray out the # ones the caller dismissed without a second round-trip. The full list # (incl. dismissed) is rendered; the toolbar "Hide dismissed" toggle # client-side filters via FilterState (or a server refetch with # hide_dismissed=true — both supported, JS uses fetch). items = repo.list_items(statuses=["approved", "mandatory"], limit=100) dismissed_set = set(repo.list_dismissed_ids(user["id"])) if user.get("id") else set() def _build_source_users_display(it: dict) -> list[dict]: """Derive ``[{name, initials}]`` from the stored ``source_user`` email. The template (and the in-template JS) iterate `item.source_users_display` to render contributor avatars; the repo only stores a single `source_user` email. Building the list here (singleton today) keeps the page from crashing with ``KeyError: slice(None, 3, None)`` when items exist, and gives the design room for multi-contributor aggregation later without another template churn. """ su = (it.get("source_user") or "").strip() if not su: return [] name = su.split("@", 1)[0] parts = [p for p in name.replace(".", " ").replace("_", " ").split() if p] if len(parts) >= 2: initials = (parts[0][0] + parts[1][0]).upper() elif parts: initials = parts[0][:2].upper() else: initials = name[:2].upper() return [{"name": name, "initials": initials}] # Enrich with votes + derived contributor-avatar list + per-item # dismissed-by-me flag (used to gray the row out + flip the action button). for item in items: votes = repo.get_votes(item["id"]) item["upvotes"] = votes["upvotes"] item["downvotes"] = votes["downvotes"] item["source_users_display"] = _build_source_users_display(item) item["dismissed_by_me"] = item["id"] in dismissed_set cm_config = get_corporate_memory_config() governance_mode = cm_config.get("distribution_mode") # Build stats + filter dropdowns from the full item set so the dropdowns # match the data the page is rendering. `categories` is derived from # what's actually in the store (free-text enum, grows over time). # `domains` is a CLOSED enum on the backend (VALID_DOMAINS in # app/api/memory.py), so we always offer the full list — earlier we # filtered to only domains with ≥1 item, which made the dropdown # collapse to a single "engineering" option on instances where only # one domain had been used. Operators should be able to pick any # valid domain even when the current store has none of it. from app.api.memory import VALID_DOMAINS all_items = repo.list_items(limit=10000) categories = sorted(set(i.get("category", "") for i in all_items if i.get("category"))) domains = list(VALID_DOMAINS) # #176: surface the pending review queue to admins. Without this the # main page silently filtered status='pending' items and operators had # no breadcrumb to /admin/corporate-memory. pending_count = sum(1 for i in all_items if i.get("status") == "pending") # "My contributions" — items the caller authored. Personal items are # always visible to their author regardless of audience filtering; # this is the surface the user uses to mark/unmark `is_personal`. user_email = user.get("email") or "" user_contributions = repo.get_user_contributions(user_email) if user_email else [] for item in user_contributions: votes = repo.get_votes(item["id"]) item["upvotes"] = votes["upvotes"] item["downvotes"] = votes["downvotes"] item["source_users_display"] = _build_source_users_display(item) item["dismissed_by_me"] = item["id"] in dismissed_set is_admin_view = is_user_admin(user["id"], conn) ctx = _build_context( request, user=user, knowledge_items=items, governance_mode=governance_mode, governance={"mode": governance_mode, "groups": cm_config.get("groups", {})}, categories=categories, domains=domains, stats={ "total": len(all_items), "approved": len([i for i in all_items if i.get("status") == "approved"]), # Template-facing aliases. Without these, the stats bar at the # top of /corporate-memory renders blank `value` divs ("Contributors" # / "Knowledge Items" with no number under them) because Jinja's # Undefined silently coerces to empty string. "contributors": len({i.get("source_user") for i in all_items if i.get("source_user")}), "knowledge_count": len([i for i in all_items if i.get("status") in ("approved", "mandatory")]), }, user_votes={}, is_km_admin=is_admin_view, user_contributions=user_contributions, user_stats={"authored": len(user_contributions), "votes_given": 0}, # Template expects knowledge as object with .items and .total_pages knowledge={"items": items, "total_pages": 1, "page": 1, "per_page": 100, "total": len(items)}, total_pages=1, current_page=1, page=1, per_page=100, # #176: pending banner is admin-only. pending_review_count=pending_count if is_admin_view else 0, ) return templates.TemplateResponse(request, "corporate_memory.html", ctx) @router.get("/admin/corporate-memory", response_class=HTMLResponse) async def corporate_memory_admin( request: Request, user: dict = Depends(require_admin), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Curated Memory review queue — admin-only. The governance surface paired with the user-facing ``/corporate-memory`` page: pending items awaiting review, contradictions, duplicate candidates, and the audit trail. Reached from the Admin nav dropdown. """ repo = KnowledgeRepository(conn) pending = repo.list_items(statuses=["pending"], limit=100) all_items = repo.list_items(limit=10000) status_counts = {} for item in all_items: s = item.get("status", "unknown") status_counts[s] = status_counts.get(s, 0) + 1 # Contradictions tab is server-rendered (no JS fetch on this tab — see # admin_corporate_memory.html). Fetch the unresolved set and enrich each # entry with the title/sensitivity of both sides so the template doesn't # need to re-query per row. contradictions = repo.list_contradictions(resolved=False) item_lookup = {it["id"]: it for it in all_items} for c in contradictions: for side in ("item_a_id", "item_b_id"): base = item_lookup.get(c.get(side)) or {} target = "item_a" if side == "item_a_id" else "item_b" c[target] = { "title": base.get("title", ""), "content": base.get("content", ""), "domain": base.get("domain"), "sensitivity": base.get("sensitivity"), "status": base.get("status"), "hidden": base.get("is_personal", False), } # Duplicate-candidate badge count (issue #62) — unresolved relations only. duplicates_count = conn.execute( "SELECT COUNT(*) FROM knowledge_item_relations " "WHERE relation_type = 'likely_duplicate' AND resolved = FALSE" ).fetchone()[0] # Mandate-form audience picker needs RBAC user_groups, not the # `corporate_memory.groups` YAML section — those are unrelated. # Template expects an array of {name, members_count} so it can render # `