mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Each of these was checked against the live database before it moved,
because a member name is free to rename and a stored value is not.
Members only -- the value never said hog, so nothing in Postgres changes:
TaggerType.HOG / EvaluationType.HOG the column already holds `script`;
`HOG = "script", "Script"` was a
member disagreeing with its own value
FEATURE_FLAGS.HOG the flag key `script` is read by the
flag service and does not move
HEDGEFN_SKIN_SPIDERHOG same, key `mascot-skin-spiderscript`
_HOGLAND_MARKER already `/var/lib/script`; renamed to
_BOX_MARKER, which is what the
function returns and what the comment
beside it already called it
Value too, having established nothing stores it: `InsightType.HOG = 'HOG'`
is derived from `query.kind === 'ScriptQuery'`, never written -- the one
writer, queryNodeToFilter, has no ScriptQuery entry, and zero of the 28
insight rows carry it in `filters.insight`.
Two readers keyed on that value had to move with it, and neither would
have been caught by renaming the enum alone: INSIGHT_TYPE_URLS spells
`HOG:` as a plain key while its siblings are computed, and the Slack
unfurl map keys the same string in Python.
The rest is copy a person reads -- "HOG code exceeds maximum size" is an
API error message, not an identifier.
Left alone deliberately: `ReviewHog` and `HogDesk`, which survive only in
applied migrations' `choices=` lists. The live members are already
`REVIEW = "review"` and `Desk = "desk"`, and Django compares choices in
Python without ever writing them, so those lists are a snapshot of a
field that has not looked like that for some time.
Co-authored-by: Hanzo Dev <[email protected]>
314 lines
12 KiB
Python
314 lines
12 KiB
Python
import re
|
|
import json
|
|
from typing import Optional
|
|
|
|
from langchain_core.messages import HumanMessage, SystemMessage
|
|
from pydantic import BaseModel, Field
|
|
|
|
from insights.insightsql import errors as insightsql_errors
|
|
from insights.insightsql.parser import parse_program
|
|
|
|
from insights.cdp.validation import compile_script
|
|
|
|
from products.cdp.backend.prompts import (
|
|
DESTINATION_LIMITATIONS_MESSAGE,
|
|
EVENT_PROPERTY_TAXONOMY_MESSAGE,
|
|
EVENT_TAXONOMY_MESSAGE,
|
|
FILTER_TAXONOMY_MESSAGE,
|
|
IDENTITY_MESSAGE_SCRIPT,
|
|
INPUT_SCHEMA_TYPES_MESSAGE,
|
|
INSIGHTS_EXAMPLE_MESSAGE,
|
|
INSIGHTS_FUNCTION_FILTERS_ASSISTANT_ROOT_SYSTEM_PROMPT,
|
|
INSIGHTS_FUNCTION_FILTERS_SYSTEM_PROMPT,
|
|
INSIGHTS_FUNCTION_INPUTS_ASSISTANT_ROOT_SYSTEM_PROMPT,
|
|
INSIGHTS_FUNCTION_INPUTS_SYSTEM_PROMPT,
|
|
INSIGHTS_GRAMMAR_MESSAGE,
|
|
INSIGHTS_TRANSFORMATION_ASSISTANT_ROOT_SYSTEM_PROMPT,
|
|
PERSON_TAXONOMY_MESSAGE,
|
|
TRANSFORMATION_LIMITATIONS_MESSAGE,
|
|
)
|
|
from products.insights_ai.backend.max_tool import MaxTool
|
|
from products.insights_ai.backend.model import MaxChatOpenAI, PydanticOutputParserException
|
|
|
|
|
|
class CreateIQLTransformationFunctionArgs(BaseModel):
|
|
instructions: str = Field(description="The instructions for what transformation to create.")
|
|
|
|
|
|
class IQLTransformationOutput(BaseModel):
|
|
script_code: str
|
|
|
|
|
|
class CreateInsightsFunctionFiltersArgs(BaseModel):
|
|
instructions: str = Field(description="The instructions for what filters to create.")
|
|
|
|
|
|
class InsightsFunctionFiltersOutput(BaseModel):
|
|
filters: dict
|
|
|
|
|
|
class CreateIQLTransformationFunctionTool(MaxTool):
|
|
name: str = "create_script_transformation_function" # Must match a value in AssistantTool enum
|
|
description: str = (
|
|
"Write or edit the script code to create your desired function and apply it to the current editor"
|
|
)
|
|
args_schema: type[BaseModel] = CreateIQLTransformationFunctionArgs
|
|
context_prompt_template: str = (
|
|
INSIGHTS_TRANSFORMATION_ASSISTANT_ROOT_SYSTEM_PROMPT
|
|
+ "\n\n"
|
|
+ TRANSFORMATION_LIMITATIONS_MESSAGE
|
|
+ "\n\n"
|
|
+ DESTINATION_LIMITATIONS_MESSAGE
|
|
)
|
|
|
|
def _run_impl(self, instructions: str) -> tuple[str, str]:
|
|
current_script_code = self.context.get("current_script_code", "")
|
|
|
|
system_content = (
|
|
IDENTITY_MESSAGE_SCRIPT
|
|
+ "\n\n<example_script_code>\n"
|
|
+ INSIGHTS_EXAMPLE_MESSAGE
|
|
+ "\n</example_script_code>\n\n"
|
|
+ "\n\n<script_grammar>\n"
|
|
+ INSIGHTS_GRAMMAR_MESSAGE
|
|
+ "\n</script_grammar>\n\n"
|
|
+ "\n\n<current_script_code>\n"
|
|
+ current_script_code
|
|
+ "\n</current_script_code>"
|
|
+ "\n\nReturn ONLY the script code inside <script_code> tags. Do not add any other text or explanation."
|
|
)
|
|
|
|
user_content = "Write a Script transformation or tweak the current one to satisfy this request: " + instructions
|
|
|
|
messages = [SystemMessage(content=system_content), HumanMessage(content=user_content)]
|
|
|
|
final_error: Optional[Exception] = None
|
|
for _ in range(3):
|
|
try:
|
|
result = self._model.invoke(messages)
|
|
parsed_result = self._parse_output(result.content)
|
|
break
|
|
except PydanticOutputParserException as e:
|
|
# Add error feedback to system message for retry
|
|
system_content += f"\n\nAvoid this error: {str(e)}"
|
|
messages[0] = SystemMessage(content=system_content)
|
|
final_error = e
|
|
else:
|
|
raise final_error
|
|
|
|
return "```script\n" + parsed_result.script_code + "\n```", parsed_result.script_code
|
|
|
|
@property
|
|
def _model(self):
|
|
return MaxChatOpenAI(
|
|
model="gpt-4.1",
|
|
temperature=0.3,
|
|
disable_streaming=True,
|
|
user=self._user,
|
|
team=self._team,
|
|
billable=True,
|
|
inject_context=False,
|
|
)
|
|
|
|
def _parse_output(self, output: str) -> IQLTransformationOutput:
|
|
match = re.search(r"<script_code>(.*?)</script_code>", output, re.DOTALL)
|
|
if not match:
|
|
# The model may have returned the code without tags, or with markdown
|
|
script_code = re.sub(
|
|
r"^\s*```script\s*\n(.*?)\n\s*```\s*$", r"\1", output, flags=re.DOTALL | re.MULTILINE
|
|
).strip()
|
|
else:
|
|
script_code = match.group(1).strip()
|
|
|
|
if not script_code:
|
|
raise PydanticOutputParserException(
|
|
llm_output=output, validation_message="The model returned an empty script code response."
|
|
)
|
|
|
|
try:
|
|
compile_script(script_code, "transformation")
|
|
except Exception:
|
|
# Try to get a more specific error by parsing directly
|
|
try:
|
|
parse_program(script_code)
|
|
except insightsql_errors.SyntaxError as parse_err:
|
|
raise PydanticOutputParserException(
|
|
llm_output=script_code,
|
|
validation_message=f"The Script code failed to compile: {parse_err}",
|
|
)
|
|
raise PydanticOutputParserException(
|
|
llm_output=script_code,
|
|
validation_message="The Script code failed to compile.",
|
|
)
|
|
|
|
return IQLTransformationOutput(script_code=script_code)
|
|
|
|
|
|
class CreateInsightsFunctionFiltersTool(MaxTool):
|
|
name: str = "create_insights_function_filters" # Must match a value in AssistantTool enum
|
|
description: str = (
|
|
"Create or edit filters for script functions to specify which events and properties trigger the function"
|
|
)
|
|
args_schema: type[BaseModel] = CreateInsightsFunctionFiltersArgs
|
|
context_prompt_template: str = INSIGHTS_FUNCTION_FILTERS_ASSISTANT_ROOT_SYSTEM_PROMPT
|
|
|
|
def _run_impl(self, instructions: str) -> tuple[str, str]:
|
|
current_filters = self.context.get("current_filters", "{}")
|
|
function_type = self.context.get("function_type", "destination")
|
|
|
|
system_content = (
|
|
INSIGHTS_FUNCTION_FILTERS_SYSTEM_PROMPT
|
|
+ f"\n\nCurrent filters: {current_filters}"
|
|
+ f"\nFunction type: {function_type}"
|
|
+ "\n\n<event_taxonomy>\n"
|
|
+ EVENT_TAXONOMY_MESSAGE
|
|
+ "\n</event_taxonomy>\n\n"
|
|
+ "\n\n<event_property_taxonomy>\n"
|
|
+ EVENT_PROPERTY_TAXONOMY_MESSAGE
|
|
+ "\n</event_property_taxonomy>\n\n"
|
|
+ "\n\n<person_property_taxonomy>\n"
|
|
+ PERSON_TAXONOMY_MESSAGE
|
|
+ "\n</person_property_taxonomy>\n\n"
|
|
+ "\n\n<filter_taxonomy>\n"
|
|
+ FILTER_TAXONOMY_MESSAGE
|
|
+ "\n</filter_taxonomy>"
|
|
)
|
|
|
|
user_content = f"Create filters for this script function: {instructions}"
|
|
|
|
messages = [SystemMessage(content=system_content), HumanMessage(content=user_content)]
|
|
|
|
final_error: Optional[Exception] = None
|
|
for _ in range(3):
|
|
try:
|
|
result = self._model.invoke(messages)
|
|
parsed_result = self._parse_output(result.content)
|
|
break
|
|
except PydanticOutputParserException as e:
|
|
# Add error feedback to system message for retry
|
|
system_content += f"\n\nAvoid this error: {str(e)}"
|
|
messages[0] = SystemMessage(content=system_content)
|
|
final_error = e
|
|
else:
|
|
raise final_error
|
|
|
|
return f"```json\n{json.dumps(parsed_result.filters, indent=2)}\n```", json.dumps(parsed_result.filters)
|
|
|
|
@property
|
|
def _model(self):
|
|
return MaxChatOpenAI(
|
|
model="gpt-4.1", temperature=0.3, disable_streaming=True, user=self._user, team=self._team, billable=True
|
|
)
|
|
|
|
def _parse_output(self, output: str) -> InsightsFunctionFiltersOutput:
|
|
match = re.search(r"<filters>(.*?)</filters>", output, re.DOTALL)
|
|
if not match:
|
|
# The model may have returned the JSON without tags, or with markdown
|
|
json_str = re.sub(
|
|
r"^\s*```json\s*\n(.*?)\n\s*```\s*$", r"\1", output, flags=re.DOTALL | re.MULTILINE
|
|
).strip()
|
|
else:
|
|
json_str = match.group(1).strip()
|
|
|
|
if not json_str:
|
|
raise PydanticOutputParserException(
|
|
llm_output=output, validation_message="The model returned an empty filters response."
|
|
)
|
|
|
|
try:
|
|
filters = json.loads(json_str)
|
|
except json.JSONDecodeError as e:
|
|
raise PydanticOutputParserException(
|
|
llm_output=json_str, validation_message=f"The filters JSON failed to parse: {str(e)}"
|
|
)
|
|
|
|
return InsightsFunctionFiltersOutput(filters=filters)
|
|
|
|
|
|
class CreateInsightsFunctionInputsArgs(BaseModel):
|
|
instructions: str = Field(description="The instructions for what inputs to generate or modify.")
|
|
|
|
|
|
class InsightsFunctionInputsOutput(BaseModel):
|
|
inputs_schema: list = Field(description="The generated inputs schema for the script function")
|
|
|
|
|
|
class CreateInsightsFunctionInputsTool(MaxTool):
|
|
name: str = "create_insights_function_inputs"
|
|
description: str = (
|
|
"Generate or modify input variables for script functions based on the current code and requirements"
|
|
)
|
|
args_schema: type[BaseModel] = CreateInsightsFunctionInputsArgs
|
|
context_prompt_template: str = INSIGHTS_FUNCTION_INPUTS_ASSISTANT_ROOT_SYSTEM_PROMPT
|
|
|
|
def _run_impl(self, instructions: str) -> tuple[str, list]:
|
|
current_inputs_schema = self.context.get("current_inputs_schema", [])
|
|
script_code = self.context.get("script_code", "")
|
|
|
|
system_content = (
|
|
INSIGHTS_FUNCTION_INPUTS_SYSTEM_PROMPT
|
|
+ f"\n\nCurrent script code:\n{script_code}"
|
|
+ f"\nCurrent inputs schema:\n{current_inputs_schema}"
|
|
+ "\n\n<input_schema_types>\n"
|
|
+ INPUT_SCHEMA_TYPES_MESSAGE
|
|
+ "\n</input_schema_types>"
|
|
)
|
|
|
|
user_content = f"Create or modify the input variables for this function: {instructions}"
|
|
|
|
messages = [SystemMessage(content=system_content), HumanMessage(content=user_content)]
|
|
|
|
final_error: Optional[Exception] = None
|
|
for _ in range(3):
|
|
try:
|
|
result = self._model.invoke(messages)
|
|
parsed_result = self._parse_output(result.content)
|
|
break
|
|
except PydanticOutputParserException as e:
|
|
system_content += f"\n\nAvoid this error: {str(e)}"
|
|
messages[0] = SystemMessage(content=system_content)
|
|
final_error = e
|
|
else:
|
|
raise final_error
|
|
|
|
# Format the output for display
|
|
import json
|
|
|
|
formatted_json = json.dumps(parsed_result.inputs_schema, indent=2)
|
|
return f"```json\n{formatted_json}\n```", parsed_result.inputs_schema
|
|
|
|
@property
|
|
def _model(self):
|
|
return MaxChatOpenAI(
|
|
model="gpt-4.1", temperature=0.3, disable_streaming=True, user=self._user, team=self._team, billable=True
|
|
)
|
|
|
|
def _parse_output(self, output: str) -> InsightsFunctionInputsOutput:
|
|
import json
|
|
|
|
match = re.search(r"<inputs_schema>(.*?)</inputs_schema>", output, re.DOTALL)
|
|
if not match:
|
|
# Try to find JSON array in the output
|
|
json_match = re.search(r"\[[\s\S]*\]", output)
|
|
if json_match:
|
|
json_str = json_match.group(0)
|
|
else:
|
|
raise PydanticOutputParserException(
|
|
llm_output=output, validation_message="Could not find inputs_schema in the response."
|
|
)
|
|
else:
|
|
json_str = match.group(1).strip()
|
|
|
|
try:
|
|
inputs_schema = json.loads(json_str)
|
|
if not isinstance(inputs_schema, list):
|
|
raise PydanticOutputParserException(
|
|
llm_output=output, validation_message="Inputs schema must be a list."
|
|
)
|
|
except json.JSONDecodeError as e:
|
|
raise PydanticOutputParserException(
|
|
llm_output=output, validation_message=f"Invalid JSON in inputs schema: {str(e)}"
|
|
)
|
|
|
|
return InsightsFunctionInputsOutput(inputs_schema=inputs_schema)
|