178 lines
5.8 KiB
Python
178 lines
5.8 KiB
Python
"""Render the analyst-onboarding welcome prompt (CLAUDE.md).
|
|
|
|
Two layers:
|
|
1. Template source — admin override from welcome_template.content,
|
|
or the shipped default at config/claude_md_template.txt.
|
|
2. Render context — built from instance config, table_registry,
|
|
metric_definitions, and the calling user's RBAC-filtered marketplaces.
|
|
|
|
The Jinja2 environment uses StrictUndefined so that any typo in the
|
|
template raises immediately rather than rendering empty strings.
|
|
"""
|
|
# See also: surfaced as the "Agent Setup Prompt" admin editor at /admin/agent-prompt.
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
import duckdb
|
|
from jinja2 import Environment, StrictUndefined
|
|
|
|
from app.instance_config import (
|
|
get_data_source_type,
|
|
get_instance_name,
|
|
get_instance_subtitle,
|
|
get_sync_interval,
|
|
)
|
|
from src.marketplace_filter import resolve_allowed_plugins
|
|
from src.repositories.welcome_template import WelcomeTemplateRepository
|
|
|
|
_DEFAULT_TEMPLATE_PATH = (
|
|
Path(__file__).resolve().parent.parent / "config" / "claude_md_template.txt"
|
|
)
|
|
|
|
|
|
def _load_default_template() -> str:
|
|
if _DEFAULT_TEMPLATE_PATH.exists():
|
|
return _DEFAULT_TEMPLATE_PATH.read_text(encoding="utf-8")
|
|
# Last-resort embedded fallback if the OSS template file is missing
|
|
# from the install (e.g., partial Docker COPY).
|
|
return (
|
|
"# {{ instance.name }} — AI Data Analyst\n\n"
|
|
"This workspace is connected to {{ server.url }}.\n"
|
|
"Data refreshes every {{ sync_interval }}.\n"
|
|
)
|
|
|
|
|
|
def _list_tables(conn: duckdb.DuckDBPyConnection) -> list[dict[str, Any]]:
|
|
try:
|
|
rows = conn.execute(
|
|
"""SELECT name, description, query_mode
|
|
FROM table_registry
|
|
ORDER BY name"""
|
|
).fetchall()
|
|
except duckdb.CatalogException:
|
|
return []
|
|
return [
|
|
{"name": r[0], "description": r[1] or "", "query_mode": r[2] or "local"}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def _metrics_summary(conn: duckdb.DuckDBPyConnection) -> dict[str, Any]:
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT category, COUNT(*) FROM metric_definitions GROUP BY category"
|
|
).fetchall()
|
|
except duckdb.CatalogException:
|
|
return {"count": 0, "categories": []}
|
|
return {
|
|
"count": sum(r[1] for r in rows),
|
|
"categories": sorted({r[0] for r in rows if r[0]}),
|
|
}
|
|
|
|
|
|
def _marketplaces_for_user(
|
|
conn: duckdb.DuckDBPyConnection, user: dict[str, Any]
|
|
) -> list[dict[str, Any]]:
|
|
"""Return marketplaces with the plugins the user is allowed to see.
|
|
|
|
Delegates RBAC filtering entirely to resolve_allowed_plugins, which
|
|
returns List[dict] with marketplace_slug, original_name, etc.
|
|
Results are grouped by marketplace slug; display names are fetched
|
|
from marketplace_registry in a single query.
|
|
"""
|
|
try:
|
|
allowed = resolve_allowed_plugins(conn, user)
|
|
except duckdb.CatalogException:
|
|
return []
|
|
if not allowed:
|
|
return []
|
|
|
|
# Build slug → display name lookup from registry
|
|
slugs = list({p["marketplace_slug"] for p in allowed})
|
|
placeholders = ",".join(["?"] * len(slugs))
|
|
try:
|
|
name_rows = conn.execute(
|
|
f"SELECT id, name FROM marketplace_registry WHERE id IN ({placeholders})",
|
|
slugs,
|
|
).fetchall()
|
|
slug_to_name: dict[str, str] = {r[0]: r[1] for r in name_rows}
|
|
except duckdb.CatalogException:
|
|
slug_to_name = {}
|
|
|
|
grouped: dict[str, dict[str, Any]] = {}
|
|
for plugin in allowed:
|
|
slug = plugin["marketplace_slug"]
|
|
bucket = grouped.setdefault(
|
|
slug,
|
|
{
|
|
"slug": slug,
|
|
"name": slug_to_name.get(slug, slug),
|
|
"plugins": [],
|
|
},
|
|
)
|
|
bucket["plugins"].append({"name": plugin["original_name"]})
|
|
|
|
return list(grouped.values())
|
|
|
|
|
|
def build_context(
|
|
conn: duckdb.DuckDBPyConnection,
|
|
*,
|
|
user: dict[str, Any],
|
|
server_url: str,
|
|
) -> dict[str, Any]:
|
|
"""Compose the Jinja2 render context. Pure, no side effects.
|
|
|
|
Note: ``now`` is tz-aware UTC; DB-sourced timestamps elsewhere in the
|
|
codebase are naive (DuckDB stores ``TIMESTAMP``, not ``TIMESTAMPTZ``).
|
|
Don't subtract or compare them inside templates without normalising.
|
|
"""
|
|
now = datetime.now(timezone.utc)
|
|
parsed = urlparse(server_url)
|
|
return {
|
|
"instance": {
|
|
"name": get_instance_name(),
|
|
"subtitle": get_instance_subtitle(),
|
|
},
|
|
"server": {
|
|
"url": server_url,
|
|
"hostname": parsed.hostname or "",
|
|
},
|
|
"sync_interval": get_sync_interval(),
|
|
"data_source": {"type": get_data_source_type()},
|
|
"tables": _list_tables(conn),
|
|
"metrics": _metrics_summary(conn),
|
|
"marketplaces": _marketplaces_for_user(conn, user),
|
|
"user": {
|
|
"id": user.get("id", ""),
|
|
"email": user.get("email", ""),
|
|
"name": user.get("name") or "",
|
|
"is_admin": bool(user.get("is_admin")),
|
|
"groups": user.get("groups") or [],
|
|
},
|
|
"now": now,
|
|
"today": date.today().isoformat(),
|
|
}
|
|
|
|
|
|
def _resolve_template_source(conn: duckdb.DuckDBPyConnection) -> str:
|
|
row = WelcomeTemplateRepository(conn).get()
|
|
return row["content"] if row.get("content") else _load_default_template()
|
|
|
|
|
|
def render_welcome(
|
|
conn: duckdb.DuckDBPyConnection,
|
|
*,
|
|
user: dict[str, Any],
|
|
server_url: str,
|
|
) -> str:
|
|
"""Resolve the active template and render it for the given user."""
|
|
source = _resolve_template_source(conn)
|
|
env = Environment(undefined=StrictUndefined, autoescape=False)
|
|
template = env.from_string(source)
|
|
return template.render(**build_context(conn, user=user, server_url=server_url))
|