Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

162 lines
5.9 KiB
Python

from typing import Any
import structlog
logger = structlog.get_logger(__name__)
PH_QUERY_NODE_TYPE = "ph-query"
# Kinds the frontend's NotebookNodeQuery accepts as a top-level `attrs.query`.
# Anything else inside a ph-query node is almost certainly a programmatic mistake — an AI
# agent producing notebook content via the MCP API has no Pydantic guard between it and
# storage, so we apply one here.
_VALID_TOP_LEVEL_QUERY_KINDS = frozenset(
{
"DataVisualizationNode",
"DataTableNode",
"SavedInsightNode",
"InsightVizNode",
}
)
# Source kinds that an InsightVizNode is actually allowed to wrap (matches the Pydantic
# schema at insights/schema.py — InsightVizNode.source).
_VALID_INSIGHT_VIZ_SOURCE_KINDS = frozenset(
{
"TrendsQuery",
"FunnelsQuery",
"RetentionQuery",
"PathsQuery",
"StickinessQuery",
"LifecycleQuery",
"WebStatsTableQuery",
"WebOverviewQuery",
}
)
class InvalidNotebookQueryError(ValueError):
"""Raised when a ph-query node carries a query shape we can't safely store.
The message is exposed to the API caller (and therefore to AI agents constructing
notebook content), so it must spell out the expected shape clearly enough for the
caller to fix on retry.
"""
def normalize_notebook_query_nodes(content: Any) -> Any:
"""Walk a ProseMirror notebook document and validate every ph-query node.
For known, recoverable LLM mistakes (e.g. wrapping a SQL chart in an InsightVizNode)
we auto-correct in place and log. For anything else that fails validation, we raise
InvalidNotebookQueryError so the write is rejected with a helpful message.
The function is structure-preserving: it returns a new tree with corrections applied,
leaves nodes that aren't ph-query untouched, and is a no-op if `content` is not a dict.
"""
if not isinstance(content, dict):
return content
def walk(node: Any) -> Any:
if not isinstance(node, dict):
return node
if node.get("type") == PH_QUERY_NODE_TYPE:
attrs = node.get("attrs")
if isinstance(attrs, dict) and "query" in attrs:
normalized_query = _normalize_query(attrs["query"])
if normalized_query is not attrs["query"]:
node = {**node, "attrs": {**attrs, "query": normalized_query}}
children = node.get("content")
if isinstance(children, list):
node = {**node, "content": [walk(child) for child in children]}
return node
return walk(content)
def _normalize_query(query: Any) -> Any:
if not isinstance(query, dict):
return query
kind = query.get("kind")
if not isinstance(kind, str):
# No kind — likely a legacy or hand-crafted node. Leave it alone; older notebooks
# may rely on the frontend's tolerant handling and we don't want to break them.
return query
if kind == "InsightVizNode":
return _normalize_insight_viz_node(query)
if kind == "DataVisualizationNode":
return _normalize_data_visualization_node(query)
if kind not in _VALID_TOP_LEVEL_QUERY_KINDS:
# Other QuerySchema kinds (InsightsQLQuery, EventsQuery, ...) can be used as a top-level
# `attrs.query` in some legacy notebooks, so we don't reject — but we also don't
# try to auto-fix.
return query
return query
def _normalize_insight_viz_node(query: dict[str, Any]) -> dict[str, Any]:
source = query.get("source")
if not isinstance(source, dict):
raise InvalidNotebookQueryError(
"ph-query node has an InsightVizNode without a `source`. "
"InsightVizNode.source must be a TrendsQuery, FunnelsQuery, RetentionQuery, "
"PathsQuery, StickinessQuery, LifecycleQuery, WebStatsTableQuery, or WebOverviewQuery."
)
source_kind = source.get("kind")
if source_kind == "DataVisualizationNode":
# This is the bug we keep seeing from AI agents: a SQL chart got wrapped in an
# InsightVizNode (which is only valid for product-analytics insights). The inner
# DataVisualizationNode is already the correct top-level shape, so unwrap it.
logger.warning(
"notebook_query_auto_unwrapped_insight_viz_node",
reason="InsightVizNode wrapping DataVisualizationNode",
)
return _normalize_data_visualization_node(source)
if source_kind == "InsightsQLQuery":
# Same shape of mistake, one level up: agent wrapped a raw InsightsQLQuery in an
# InsightVizNode. The correct shell for a SQL chart is DataVisualizationNode.
logger.warning(
"notebook_query_auto_rewrapped_insightsql_query",
reason="InsightVizNode wrapping InsightsQLQuery",
)
return {"kind": "DataVisualizationNode", "source": source}
if source_kind not in _VALID_INSIGHT_VIZ_SOURCE_KINDS:
raise InvalidNotebookQueryError(
f"ph-query node has an InsightVizNode wrapping `{source_kind}`, which is not "
"a valid insight source. InsightVizNode.source must be one of: "
f"{', '.join(sorted(_VALID_INSIGHT_VIZ_SOURCE_KINDS))}."
)
return query
def _normalize_data_visualization_node(query: dict[str, Any]) -> dict[str, Any]:
source = query.get("source")
if not isinstance(source, dict):
raise InvalidNotebookQueryError(
"ph-query node has a DataVisualizationNode without a `source`. "
"DataVisualizationNode.source must be a InsightsQLQuery."
)
source_kind = source.get("kind")
if source_kind != "InsightsQLQuery":
raise InvalidNotebookQueryError(
f"ph-query node has a DataVisualizationNode wrapping `{source_kind}`, which "
"is not a valid SQL chart source. DataVisualizationNode.source must be a InsightsQLQuery."
)
return query