agnes-the-ai-analyst/app/api/claude_md.py
ZdenekSrotyr 103efb69f0 chore(cli-rename): replace stale da verbs in active code paths
Bring admin UI, audit-log messages, code comments, and analyst-facing
skill docs in line with the post-bootstrap CLI surface (`agnes pull`,
`agnes push`, `agnes init`, `agnes snapshot create`). The legacy
`_LEGACY_STRINGS` detection tuple in `app/api/claude_md.py` and the hook
upgrade markers in `cli/lib/hooks.py` are intentionally left as-is —
they exist precisely to flag pre-rewrite content for re-authoring.

Strip "(folded from `da metrics list`)" / "(lifted from `da metrics
show`)" / "Replaces the old `da analyst status`" docstring noise — the
rename history is in CHANGELOG.md, not in module docstrings.
2026-05-04 21:10:43 +02:00

230 lines
9.1 KiB
Python

"""REST endpoints for the agent-workspace-prompt (analyst CLAUDE.md).
- GET /api/welcome : analyst-facing rendered CLAUDE.md (auth required)
- GET /api/admin/workspace-prompt-template : raw template override + live default (admin)
- PUT /api/admin/workspace-prompt-template : set override (admin)
- DELETE /api/admin/workspace-prompt-template : reset to default (admin)
- POST /api/admin/workspace-prompt-template/preview : live preview without persisting (admin)
"""
import datetime
import logging
from typing import Optional
import duckdb
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from jinja2 import Environment, StrictUndefined, TemplateError
from pydantic import BaseModel, Field
from app.auth.access import require_admin
from app.auth.dependencies import _get_db, get_current_user
from src.repositories.claude_md_template import ClaudeMdTemplateRepository
from src.claude_md import build_claude_md_context, compute_default_claude_md, render_claude_md
logger = logging.getLogger(__name__)
router = APIRouter(tags=["claude_md"])
# Stub context used to validate that a saved template renders end-to-end,
# not just that it parses. Mirrors the shape of build_claude_md_context() output.
# user is an authenticated user so templates that reference user.* are validated.
_VALIDATION_STUB_CONTEXT = {
"instance": {"name": "Example", "subtitle": "Example Org"},
"server": {"url": "https://example.com", "hostname": "example.com"},
"sync_interval": "1h",
"data_source": {"type": "keboola"},
"tables": [{"name": "orders", "description": "Sample orders", "query_mode": "local"}],
"metrics": {"count": 3, "categories": ["revenue", "growth"]},
"marketplaces": [{"slug": "example", "name": "Example Marketplace", "plugins": [{"name": "plugin-a"}]}],
"user": {
"id": "u",
"email": "user@example.com",
"name": "User",
"is_admin": False,
"groups": ["Everyone"],
},
"now": datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
"today": "2026-01-01",
}
# Same stub with an anonymous-style user context to validate templates against
# the case where a user dict is present but minimal (analyst). The CLAUDE.md
# endpoint always requires auth, so user is never None — but templates may
# accidentally reference fields that aren't in the context.
_VALIDATION_STUB_CONTEXT_ANON = {
**{k: v for k, v in _VALIDATION_STUB_CONTEXT.items() if k != "user"},
"user": {
"id": "u2",
"email": "anon@example.com",
"name": "",
"is_admin": False,
"groups": ["Everyone"],
},
}
# Substrings that, when found in an admin-saved CLAUDE.md override, signal
# the override is stale relative to the post-clean-bootstrap CLI surface.
# Surfaced via TemplateGetResponse.legacy_strings_detected so the admin UI
# can render a yellow banner prompting re-authoring.
_LEGACY_STRINGS = (
"data/parquet",
"da sync",
"da fetch",
"da analyst setup",
"da metrics list",
"da metrics show",
)
def _scan_legacy_strings(text: str) -> list[str]:
"""Return sorted unique substrings from _LEGACY_STRINGS present in text."""
return sorted({s for s in _LEGACY_STRINGS if s in text})
class ClaudeMdResponse(BaseModel):
content: str
class TemplateGetResponse(BaseModel):
content: Optional[str]
default: str # live default rendered with calling admin's context
updated_at: Optional[str] = None
updated_by: Optional[str] = None
# Substrings from _LEGACY_STRINGS detected in the saved override (if any).
# Empty when no override is set or when the override is clean. Surfaced
# so the admin UI can prompt re-authoring after a CLI surface rename.
legacy_strings_detected: list[str] = []
class TemplatePutRequest(BaseModel):
content: str = Field(..., min_length=1, max_length=200_000)
class TemplatePreviewRequest(BaseModel):
content: str = Field(..., min_length=1, max_length=200_000)
# ---------------------------------------------------------------------------
# Analyst-facing endpoint — returns rendered CLAUDE.md
# ---------------------------------------------------------------------------
@router.get("/api/welcome", response_model=ClaudeMdResponse)
async def get_welcome(
request: Request,
server_url: Optional[str] = Query(None, description="Server URL used in rendered CLAUDE.md"),
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Return the rendered CLAUDE.md for the authenticated analyst.
The CLI calls this endpoint during ``agnes init`` to write
``<workspace>/CLAUDE.md``. The content is RBAC-filtered per the
calling user.
``server_url`` query param lets the CLI pass the origin it knows so
the rendered content references the correct server URL rather than the
request host (which may differ behind a proxy).
"""
effective_url = server_url or str(request.base_url).rstrip("/")
try:
content = render_claude_md(conn, user=user, server_url=effective_url)
except TemplateError as exc:
logger.warning("render_claude_md failed (template error): %s", exc)
raise HTTPException(status_code=500, detail=f"Template render error: {exc}")
except Exception:
logger.exception("render_claude_md failed (unexpected)")
raise HTTPException(status_code=500, detail="Internal error rendering CLAUDE.md")
return ClaudeMdResponse(content=content)
# ---------------------------------------------------------------------------
# Admin endpoints — CRUD for the workspace-prompt template override
# ---------------------------------------------------------------------------
@router.get("/api/admin/workspace-prompt-template", response_model=TemplateGetResponse)
async def admin_get_workspace_template(
request: Request,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
row = ClaudeMdTemplateRepository(conn).get()
server_url = str(request.base_url).rstrip("/")
live_default = compute_default_claude_md(conn, user=user, server_url=server_url)
legacy_hits = _scan_legacy_strings(row["content"] or "")
return TemplateGetResponse(
content=row["content"],
default=live_default,
updated_at=row["updated_at"].isoformat() if row["updated_at"] else None,
updated_by=row["updated_by"],
legacy_strings_detected=legacy_hits,
)
@router.put("/api/admin/workspace-prompt-template")
async def admin_put_workspace_template(
payload: TemplatePutRequest,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Save an admin override for the analyst CLAUDE.md template.
Two-pass Jinja2 validation (autoescape=False, StrictUndefined):
- Pass 1: render with an authenticated user stub — catches undefined
placeholders and syntax errors.
- Pass 2: render with a minimal anon-style user stub — catches templates
that hard-depend on admin-only context fields.
"""
env = Environment(undefined=StrictUndefined, autoescape=False)
try:
template = env.from_string(payload.content)
template.render(**_VALIDATION_STUB_CONTEXT)
except TemplateError as e:
raise HTTPException(status_code=400, detail=f"Template invalid: {e}")
try:
template.render(**_VALIDATION_STUB_CONTEXT_ANON)
except TemplateError as e:
raise HTTPException(
status_code=400,
detail=(
f"Template fails for non-admin analyst users: {e}. "
"Wrap user-dependent expressions in {{% if user.is_admin %}}...{{% endif %}} "
"or ensure the template renders correctly for all users."
),
)
ClaudeMdTemplateRepository(conn).set(payload.content, updated_by=user["email"])
return {"status": "ok"}
@router.delete("/api/admin/workspace-prompt-template", status_code=204)
async def admin_reset_workspace_template(
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
ClaudeMdTemplateRepository(conn).reset(updated_by=user["email"])
return Response(status_code=204)
@router.post("/api/admin/workspace-prompt-template/preview", response_model=ClaudeMdResponse)
async def admin_preview_workspace_template(
payload: TemplatePreviewRequest,
request: Request,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Render arbitrary template content against the live RBAC context for the
calling admin, without persisting. Used by the /admin/workspace-prompt editor's
Preview button so admins can see their edits before saving."""
env = Environment(undefined=StrictUndefined, autoescape=False)
try:
template = env.from_string(payload.content)
ctx = build_claude_md_context(
conn, user=user, server_url=str(request.base_url).rstrip("/")
)
rendered = template.render(**ctx)
except TemplateError as e:
raise HTTPException(status_code=400, detail=f"Template invalid: {e}")
return ClaudeMdResponse(content=rendered)