From 2e9b4e6d63ba21f63335e58162aeb3d99df3607a Mon Sep 17 00:00:00 2001 From: Nick Karpov Date: Thu, 19 Mar 2026 16:20:53 -0700 Subject: [PATCH 1/9] feat(support): add support use case target with generator, agent, and scoring stream Add new `support` target to databricks.yml with full DAG: - Support request generator (deterministic synthetic data from delivered events) - Support triage agent (DSPy ReAct with UC function tools, policy-based guardrails) - Scoring stream with fake-it-till-up pattern and inference capping Based on work from PR #63. Lakebase + App stages to follow. Co-authored-by: Andre Landgraf Co-authored-by: Isaac --- databricks.yml | 66 ++ jobs/support_request_agent_stream.ipynb | 320 ++++++++ jobs/support_request_generator.ipynb | 191 +++++ stages/support_request_agent.ipynb | 688 ++++++++++++++++++ stages/support_request_agent_stream.ipynb | 89 +++ stages/support_request_generator_stream.ipynb | 90 +++ 6 files changed, 1444 insertions(+) create mode 100644 jobs/support_request_agent_stream.ipynb create mode 100644 jobs/support_request_generator.ipynb create mode 100644 stages/support_request_agent.ipynb create mode 100644 stages/support_request_agent_stream.ipynb create mode 100644 stages/support_request_generator_stream.ipynb diff --git a/databricks.yml b/databricks.yml index a382049..caf7775 100644 --- a/databricks.yml +++ b/databricks.yml @@ -339,6 +339,72 @@ targets: notebook_task: notebook_path: ${workspace.root_path}/stages/menu_supervisor + support: + resources: + jobs: + caspers: + name: "Casper's Initializer" + queue: + enabled: true + performance_target: PERFORMANCE_OPTIMIZED + + parameters: + - name: CATALOG + default: ${var.catalog} + - name: EVENTS_VOLUME + default: events + - name: LLM_MODEL + default: databricks-meta-llama-3-3-70b-instruct + - name: SUPPORT_AGENT_ENDPOINT_NAME + default: ${var.catalog}_support_agent + - name: SUPPORT_RATE + default: "0.18" + - name: SIMULATOR_SCHEMA + default: simulator + - name: START_DAY + default: "70" + - name: SPEED_MULTIPLIER + default: "60.0" + - name: SCHEDULE_MINUTES + default: "3" + - name: PIPELINE_SCHEDULE_MINUTES + default: "0" + + tasks: + - task_key: Canonical_Data + notebook_task: + notebook_path: ${workspace.root_path}/stages/canonical_data + + - task_key: Spark_Declarative_Pipeline + depends_on: + - task_key: Canonical_Data + notebook_task: + notebook_path: ${workspace.root_path}/stages/lakeflow + + - task_key: Support_Request_Agent + depends_on: + - task_key: Spark_Declarative_Pipeline + notebook_task: + notebook_path: ${workspace.root_path}/stages/support_request_agent + + - task_key: Support_Request_Generator_Stream + depends_on: + - task_key: Spark_Declarative_Pipeline + notebook_task: + notebook_path: ${workspace.root_path}/stages/support_request_generator_stream + + - task_key: Support_Request_Agent_Stream + depends_on: + - task_key: Support_Request_Generator_Stream + notebook_task: + notebook_path: ${workspace.root_path}/stages/support_request_agent_stream + + # - task_key: Support_Lakebase_And_App + # depends_on: + # - task_key: Support_Request_Agent_Stream + # notebook_task: + # notebook_path: ${workspace.root_path}/stages/support_lakebase + all: resources: jobs: diff --git a/jobs/support_request_agent_stream.ipynb b/jobs/support_request_agent_stream.ipynb new file mode 100644 index 0000000..8458a62 --- /dev/null +++ b/jobs/support_request_agent_stream.ipynb @@ -0,0 +1,320 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Support Request Agent Stream\n", + "\n", + "Stream support requests through the support agent endpoint and persist reports.\n", + "\n", + "Uses fake-it-till-up pattern: deterministic responses during backfill and when\n", + "the agent endpoint is not yet ready, real inference with capping once available." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "DATABRICKS_TOKEN = dbutils.notebook.entry_point.getDbutils().notebook().getContext().apiToken().getOrElse(None)\n", + "DATABRICKS_HOST = dbutils.notebook.entry_point.getDbutils().notebook().getContext().apiUrl().getOrElse(None)\n", + "\n", + "CATALOG = dbutils.widgets.get(\"CATALOG\")\n", + "SUPPORT_AGENT_ENDPOINT_NAME = dbutils.widgets.get(\"SUPPORT_AGENT_ENDPOINT_NAME\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%pip install openai" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import random\n", + "import os\n", + "\n", + "from pyspark.sql import functions as F\n", + "from pyspark.sql.types import StringType\n", + "from pyspark.sql.functions import udf\n", + "from pyspark.sql.window import Window\n", + "from openai import OpenAI\n", + "\n", + "CHECKPOINT_PATH = f\"/Volumes/{CATALOG}/support/checkpoints/support_request_agent_stream\"\n", + "MAX_INFERENCES_PER_BATCH = 50\n", + "\n", + "\n", + "def is_first_run():\n", + " \"\"\"Check if checkpoint exists (indicates this is NOT the first run)\"\"\"\n", + " return not os.path.exists(CHECKPOINT_PATH) or len(os.listdir(CHECKPOINT_PATH)) == 0\n", + "\n", + "\n", + "# ── Deterministic fallback responses ──────────────────────────────────────────\n", + "# These mirror the policy resolution logic from the agent, providing realistic\n", + "# structured responses when the endpoint is unavailable or during backfill.\n", + "\n", + "fake_responses = [\n", + " {\n", + " \"support_request_id\": \"\", \"user_id\": \"\", \"order_id\": \"\",\n", + " \"credit_recommendation\": {\"amount_usd\": 3.0, \"reason\": \"Goodwill credit for minor service issue.\"},\n", + " \"refund_recommendation\": {\"amount_usd\": 0.0, \"reason\": \"No verified severe failure.\"},\n", + " \"draft_response\": \"Thanks for contacting us, and I am really sorry about this experience. We are offering a $3.00 credit on this case.\",\n", + " \"past_interactions_summary\": \"No prior interactions found.\",\n", + " \"order_details_summary\": \"Standard delivery order.\",\n", + " \"decision_confidence\": \"medium\",\n", + " \"escalation_flag\": False,\n", + " },\n", + " {\n", + " \"support_request_id\": \"\", \"user_id\": \"\", \"order_id\": \"\",\n", + " \"credit_recommendation\": {\"amount_usd\": 5.0, \"reason\": \"Moderate service issue with customer impact.\"},\n", + " \"refund_recommendation\": {\"amount_usd\": 10.0, \"reason\": \"Moderate service issue with customer impact.\"},\n", + " \"draft_response\": \"Thanks for contacting us, and I am really sorry about this experience. We are offering a $10.00 refund and a $5.00 credit on this case.\",\n", + " \"past_interactions_summary\": \"No prior interactions found.\",\n", + " \"order_details_summary\": \"Delivery with reported delay.\",\n", + " \"decision_confidence\": \"high\",\n", + " \"escalation_flag\": False,\n", + " },\n", + " {\n", + " \"support_request_id\": \"\", \"user_id\": \"\", \"order_id\": \"\",\n", + " \"credit_recommendation\": {\"amount_usd\": 2.0, \"reason\": \"Minor issue or low-confidence compensable case.\"},\n", + " \"refund_recommendation\": {\"amount_usd\": 0.0, \"reason\": \"No severe failure identified.\"},\n", + " \"draft_response\": \"Thanks for contacting us, and I am really sorry about this experience. We are offering a $2.00 credit on this case.\",\n", + " \"past_interactions_summary\": \"No prior interactions found.\",\n", + " \"order_details_summary\": \"Standard delivery order.\",\n", + " \"decision_confidence\": \"medium\",\n", + " \"escalation_flag\": False,\n", + " },\n", + " {\n", + " \"support_request_id\": \"\", \"user_id\": \"\", \"order_id\": \"\",\n", + " \"credit_recommendation\": {\"amount_usd\": 5.0, \"reason\": \"Repeated or high-risk service failure.\"},\n", + " \"refund_recommendation\": {\"amount_usd\": 18.0, \"reason\": \"Repeated or high-risk service failure.\"},\n", + " \"draft_response\": \"I see this is at least the 2nd time you've had to contact us for similar issues, and that is not acceptable. We are offering a $18.00 refund and a $5.00 credit on this case.\",\n", + " \"past_interactions_summary\": \"Multiple prior contacts for similar issues.\",\n", + " \"order_details_summary\": \"Repeat issue with delivery.\",\n", + " \"decision_confidence\": \"high\",\n", + " \"escalation_flag\": False,\n", + " },\n", + " {\n", + " \"support_request_id\": \"\", \"user_id\": \"\", \"order_id\": \"\",\n", + " \"credit_recommendation\": {\"amount_usd\": 3.0, \"reason\": \"Abusive/spammy pattern detected; restricted compensation with escalation.\"},\n", + " \"refund_recommendation\": {\"amount_usd\": 0.0, \"reason\": \"Abusive/spammy pattern detected.\"},\n", + " \"draft_response\": \"Thanks for contacting us. We are escalating this case for manual review and will follow up quickly.\",\n", + " \"past_interactions_summary\": \"Pattern of escalation detected.\",\n", + " \"order_details_summary\": \"Order under review.\",\n", + " \"decision_confidence\": \"medium\",\n", + " \"escalation_flag\": True,\n", + " },\n", + "]\n", + "\n", + "\n", + "def get_fake_response(support_request_id: str, user_id: str, order_id: str) -> str:\n", + " \"\"\"Return a deterministic fake response with IDs filled in.\"\"\"\n", + " resp = random.choice(fake_responses).copy()\n", + " resp[\"support_request_id\"] = support_request_id\n", + " resp[\"user_id\"] = user_id\n", + " resp[\"order_id\"] = order_id\n", + " return json.dumps(resp)\n", + "\n", + "\n", + "def call_support_agent(payload_json: str) -> str:\n", + " \"\"\"Call the support agent endpoint with the case payload.\"\"\"\n", + " payload = json.loads(payload_json)\n", + " support_request_id = payload.get(\"support_request_id\", \"\")\n", + " user_id = payload.get(\"user_id\", \"\")\n", + " order_id = payload.get(\"order_id\", \"\")\n", + "\n", + " client = OpenAI(\n", + " api_key=DATABRICKS_TOKEN,\n", + " base_url=f\"{DATABRICKS_HOST}/serving-endpoints\",\n", + " )\n", + "\n", + " for _ in range(3):\n", + " try:\n", + " response_obj = client.responses.create(\n", + " model=SUPPORT_AGENT_ENDPOINT_NAME,\n", + " input=[{\"role\": \"user\", \"content\": payload_json}],\n", + " )\n", + " response = response_obj.output[-1].content[0].text\n", + " json.loads(response) # validate JSON\n", + " return response\n", + " except Exception:\n", + " continue\n", + "\n", + " # All retries failed — return fake\n", + " return get_fake_response(support_request_id, user_id, order_id)\n", + "\n", + "\n", + "call_support_agent_udf = udf(call_support_agent, StringType())\n", + "get_fake_response_udf = udf(get_fake_response, StringType())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spark.sql(f\"CREATE SCHEMA IF NOT EXISTS {CATALOG}.support\")\n", + "spark.sql(f\"CREATE VOLUME IF NOT EXISTS {CATALOG}.support.checkpoints\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spark.sql(f\"\"\"\n", + "CREATE TABLE IF NOT EXISTS {CATALOG}.support.support_agent_reports (\n", + " support_request_id STRING,\n", + " user_id STRING,\n", + " order_id STRING,\n", + " request_text STRING,\n", + " ts TIMESTAMP,\n", + " agent_response STRING\n", + ")\n", + "\"\"\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Enable CDC for Lakebase sync\n", + "table_name = f\"{CATALOG}.support.support_agent_reports\"\n", + "\n", + "try:\n", + " props = spark.sql(f\"SHOW TBLPROPERTIES {table_name}\").collect()\n", + " cdc_enabled = any(row.key == \"delta.enableChangeDataFeed\" and row.value == \"true\" for row in props)\n", + "except Exception:\n", + " cdc_enabled = False\n", + "\n", + "if not cdc_enabled:\n", + " print(f\"Enabling CDC on {table_name}\")\n", + " spark.sql(f\"ALTER TABLE {table_name} SET TBLPROPERTIES (delta.enableChangeDataFeed = true)\")\n", + "else:\n", + " print(f\"CDC already enabled on {table_name}, skipping\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def process_batch(batch_df, batch_id):\n", + " \"\"\"Process each micro-batch with inference capping.\n", + "\n", + " - First run (no checkpoint): ALL rows get fake responses (fast backfill)\n", + " - Subsequent runs: First N rows get real inference, rest get fake\n", + " \"\"\"\n", + " if batch_df.isEmpty():\n", + " return\n", + "\n", + " first_run = is_first_run()\n", + " row_count = batch_df.count()\n", + "\n", + " print(f\"Processing batch {batch_id}: {row_count} rows, first_run={first_run}\")\n", + "\n", + " # Build payload JSON from row columns for the agent\n", + " with_payload = batch_df.withColumn(\n", + " \"payload_json\",\n", + " F.to_json(\n", + " F.struct(\n", + " F.col(\"support_request_id\"),\n", + " F.col(\"user_id\"),\n", + " F.col(\"order_id\"),\n", + " F.col(\"request_type\"),\n", + " F.col(\"edge_case_type\"),\n", + " F.col(\"request_text\"),\n", + " )\n", + " ),\n", + " )\n", + "\n", + " if first_run:\n", + " # First run: ALL rows get fake responses (fast backfill)\n", + " print(f\" -> First run detected, using fake responses for all {row_count} rows\")\n", + " result_df = with_payload.select(\n", + " F.col(\"support_request_id\"),\n", + " F.col(\"user_id\"),\n", + " F.col(\"order_id\"),\n", + " F.col(\"request_text\"),\n", + " F.current_timestamp().alias(\"ts\"),\n", + " get_fake_response_udf(\n", + " F.col(\"support_request_id\"), F.col(\"user_id\"), F.col(\"order_id\")\n", + " ).alias(\"agent_response\"),\n", + " )\n", + " else:\n", + " # Subsequent runs: first N rows get real inference, rest get fake\n", + " windowed = with_payload.withColumn(\n", + " \"row_num\",\n", + " F.row_number().over(Window.orderBy(F.col(\"ts\"), F.col(\"support_request_id\"))),\n", + " )\n", + "\n", + " real_count = min(row_count, MAX_INFERENCES_PER_BATCH)\n", + " fake_count = max(0, row_count - MAX_INFERENCES_PER_BATCH)\n", + " print(f\" -> Real inference: {real_count} rows, fake: {fake_count} rows\")\n", + "\n", + " real_inference_df = windowed.filter(f\"row_num <= {MAX_INFERENCES_PER_BATCH}\").select(\n", + " F.col(\"support_request_id\"),\n", + " F.col(\"user_id\"),\n", + " F.col(\"order_id\"),\n", + " F.col(\"request_text\"),\n", + " F.current_timestamp().alias(\"ts\"),\n", + " call_support_agent_udf(F.col(\"payload_json\")).alias(\"agent_response\"),\n", + " )\n", + "\n", + " fake_response_df = windowed.filter(f\"row_num > {MAX_INFERENCES_PER_BATCH}\").select(\n", + " F.col(\"support_request_id\"),\n", + " F.col(\"user_id\"),\n", + " F.col(\"order_id\"),\n", + " F.col(\"request_text\"),\n", + " F.current_timestamp().alias(\"ts\"),\n", + " get_fake_response_udf(\n", + " F.col(\"support_request_id\"), F.col(\"user_id\"), F.col(\"order_id\")\n", + " ).alias(\"agent_response\"),\n", + " )\n", + "\n", + " result_df = real_inference_df.union(fake_response_df)\n", + "\n", + " result_df.write.mode(\"append\").saveAsTable(f\"{CATALOG}.support.support_agent_reports\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Read stream from raw support requests\n", + "support_requests = spark.readStream.table(f\"{CATALOG}.support.raw_support_requests\")\n", + "\n", + "support_requests.writeStream \\\n", + " .foreachBatch(process_batch) \\\n", + " .option(\"checkpointLocation\", CHECKPOINT_PATH) \\\n", + " .trigger(availableNow=True) \\\n", + " .start() \\\n", + " .awaitTermination()" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/jobs/support_request_generator.ipynb b/jobs/support_request_generator.ipynb new file mode 100644 index 0000000..1402179 --- /dev/null +++ b/jobs/support_request_generator.ipynb @@ -0,0 +1,191 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Support Request Generator\n", + "\n", + "Generate synthetic support requests from delivered orders with deterministic user IDs and edge-case distribution" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "CATALOG = dbutils.widgets.get(\"CATALOG\")\n", + "SUPPORT_RATE = float(dbutils.widgets.get(\"SUPPORT_RATE\"))\n", + "LLM_MODEL = dbutils.widgets.get(\"LLM_MODEL\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spark.sql(f\"CREATE SCHEMA IF NOT EXISTS {CATALOG}.support\")\n", + "spark.sql(f\"CREATE VOLUME IF NOT EXISTS {CATALOG}.support.checkpoints\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spark.sql(\n", + " f\"\"\"\n", + " CREATE TABLE IF NOT EXISTS {CATALOG}.support.raw_support_requests (\n", + " support_request_id STRING,\n", + " user_id STRING,\n", + " order_id STRING,\n", + " ts TIMESTAMP,\n", + " request_type STRING,\n", + " request_text STRING,\n", + " edge_case_type STRING,\n", + " request_attempt INT,\n", + " generated_by STRING\n", + " )\n", + " \"\"\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pyspark.sql import functions as F\n", + "\n", + "# Deterministic repeatable user IDs and controlled edge-case distribution.\n", + "# - ~10% create an extra follow-up request for same order/user\n", + "# - ~3% create two extra follow-ups (spam/repeat bursts)\n", + "# - ~5% spammy/escalation phrasing\n", + "# - ~2% abusive phrasing\n", + "source = (\n", + " spark.readStream\n", + " .table(f\"{CATALOG}.lakeflow.all_events\")\n", + " .filter(F.col(\"event_type\") == \"delivered\")\n", + " .filter(F.rand() < SUPPORT_RATE)\n", + " .withColumn(\"seed\", F.abs(F.hash(F.col(\"order_id\"))))\n", + " .withColumn(\"hash_bucket\", F.col(\"seed\") % F.lit(25))\n", + " .withColumn(\"user_id\", F.concat(F.lit(\"user-\"), F.format_string(\"%03d\", F.col(\"hash_bucket\"))))\n", + " .withColumn(\n", + " \"request_type\",\n", + " F.expr(\n", + " \"\"\"\n", + " CASE\n", + " WHEN hash_bucket % 5 = 0 THEN 'delivery_delay'\n", + " WHEN hash_bucket % 5 = 1 THEN 'missing_items'\n", + " WHEN hash_bucket % 5 = 2 THEN 'food_quality'\n", + " WHEN hash_bucket % 5 = 3 THEN 'billing'\n", + " ELSE 'general_support'\n", + " END\n", + " \"\"\"\n", + " ),\n", + " )\n", + " .withColumn(\n", + " \"edge_case_type\",\n", + " F.expr(\n", + " \"\"\"\n", + " CASE\n", + " WHEN seed % 100 < 2 THEN 'abusive_language'\n", + " WHEN seed % 100 < 7 THEN 'spammy_escalation'\n", + " WHEN seed % 100 < 15 THEN 'repeat_follow_up'\n", + " WHEN seed % 100 < 21 THEN 'non_compensable_confusion'\n", + " ELSE 'standard'\n", + " END\n", + " \"\"\"\n", + " ),\n", + " )\n", + " .withColumn(\n", + " \"repeat_multiplier\",\n", + " F.expr(\n", + " \"\"\"\n", + " CASE\n", + " WHEN seed % 100 < 3 THEN 3\n", + " WHEN seed % 100 < 13 THEN 2\n", + " ELSE 1\n", + " END\n", + " \"\"\"\n", + " ),\n", + " )\n", + ")\n", + "\n", + "expanded = (\n", + " source\n", + " .withColumn(\"repeat_idx\", F.explode(F.sequence(F.lit(1), F.col(\"repeat_multiplier\"))))\n", + " .withColumn(\"support_request_id\", F.expr(\"uuid()\"))\n", + " .withColumn(\"request_attempt\", F.col(\"repeat_idx\"))\n", + " .withColumn(\n", + " \"request_text\",\n", + " F.expr(\n", + " \"\"\"\n", + " CASE\n", + " WHEN edge_case_type = 'abusive_language' THEN\n", + " 'This is unacceptable and ridiculous. Your team keeps messing this up. Fix this now.'\n", + " WHEN edge_case_type = 'spammy_escalation' AND request_attempt > 1 THEN\n", + " 'Third message: still unresolved. Refund me immediately and escalate this to a supervisor now.'\n", + " WHEN edge_case_type = 'spammy_escalation' THEN\n", + " 'Second request today. Please resolve this immediately. I need clear compensation details now.'\n", + " WHEN edge_case_type = 'repeat_follow_up' AND request_attempt > 1 THEN\n", + " 'Following up again because this is not the first time this happened. Please review previous cases and offer a fair resolution.'\n", + " WHEN request_type = 'delivery_delay' AND request_attempt > 1 THEN\n", + " 'This is another delay issue for me. I need a concrete resolution with refund and credit details.'\n", + " WHEN request_type = 'delivery_delay' THEN\n", + " 'My delivery arrived much later than expected. Can you help with a credit or refund?'\n", + " WHEN request_type = 'missing_items' AND request_attempt > 1 THEN\n", + " 'This is the second time items were missing from my order. Please make this right with a concrete offer.'\n", + " WHEN request_type = 'missing_items' THEN\n", + " 'My order was missing one or more items. Please review and advise next steps.'\n", + " WHEN request_type = 'food_quality' THEN\n", + " 'I had a quality issue with my food. I would like support and possible compensation.'\n", + " WHEN request_type = 'billing' THEN\n", + " 'I think there is a billing issue on my recent order. Can you verify the charge?'\n", + " WHEN edge_case_type = 'non_compensable_confusion' THEN\n", + " 'I changed my mind after ordering and wanted to check if I can still get a courtesy credit.'\n", + " ELSE\n", + " 'I need help with a recent order. Please review my request and let me know what you can do.'\n", + " END\n", + " \"\"\"\n", + " ),\n", + " )\n", + " .select(\n", + " \"support_request_id\",\n", + " \"user_id\",\n", + " \"order_id\",\n", + " F.current_timestamp().alias(\"ts\"),\n", + " \"request_type\",\n", + " \"request_text\",\n", + " \"edge_case_type\",\n", + " \"request_attempt\",\n", + " F.lit(\"generator-v2\").alias(\"generated_by\"),\n", + " )\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "expanded.writeStream \\\n", + " .option(\"checkpointLocation\", f\"/Volumes/{CATALOG}/support/checkpoints/support_request_generator\") \\\n", + " .trigger(availableNow=True) \\\n", + " .table(f\"{CATALOG}.support.raw_support_requests\")" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/stages/support_request_agent.ipynb b/stages/support_request_agent.ipynb new file mode 100644 index 0000000..eab6d9b --- /dev/null +++ b/stages/support_request_agent.ipynb @@ -0,0 +1,688 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Support Request Agent\n", + "\n", + "Build and deploy a support-request triage agent with order context and user interaction history.\n", + "\n", + "Outputs include:\n", + "- credit recommendation\n", + "- refund recommendation\n", + "- draft customer response\n", + "- past interactions summary\n", + "- order details summary" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Tool & View Registration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "CATALOG = dbutils.widgets.get(\"CATALOG\")\n", + "LLM_MODEL = dbutils.widgets.get(\"LLM_MODEL\")\n", + "SUPPORT_AGENT_ENDPOINT_NAME = dbutils.widgets.get(\"SUPPORT_AGENT_ENDPOINT_NAME\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%sql\n", + "CREATE SCHEMA IF NOT EXISTS ${CATALOG}.ai;\n", + "CREATE SCHEMA IF NOT EXISTS ${CATALOG}.support;" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%sql\n", + "CREATE OR REPLACE FUNCTION ${CATALOG}.ai.get_order_overview(oid STRING)\n", + "RETURNS TABLE (\n", + " order_id STRING,\n", + " location STRING,\n", + " items_json STRING,\n", + " customer_address STRING,\n", + " order_created_ts TIMESTAMP\n", + ")\n", + "RETURN\n", + " SELECT\n", + " order_id,\n", + " location,\n", + " get_json_object(body, '$.items') AS items_json,\n", + " get_json_object(body, '$.customer_addr') AS customer_address,\n", + " try_to_timestamp(ts) AS order_created_ts\n", + " FROM ${CATALOG}.lakeflow.all_events\n", + " WHERE order_id = oid AND event_type = 'order_created'\n", + " LIMIT 1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%sql\n", + "CREATE OR REPLACE FUNCTION ${CATALOG}.ai.get_order_timing(oid STRING)\n", + "RETURNS TABLE (\n", + " order_id STRING,\n", + " order_created_ts TIMESTAMP,\n", + " delivered_ts TIMESTAMP,\n", + " delivery_duration_minutes FLOAT\n", + ")\n", + "RETURN\n", + " WITH order_events AS (\n", + " SELECT order_id, event_type, try_to_timestamp(ts) AS event_ts\n", + " FROM ${CATALOG}.lakeflow.all_events\n", + " WHERE order_id = oid\n", + " )\n", + " SELECT\n", + " order_id,\n", + " MIN(CASE WHEN event_type='order_created' THEN event_ts END) AS order_created_ts,\n", + " MAX(CASE WHEN event_type='delivered' THEN event_ts END) AS delivered_ts,\n", + " CAST((UNIX_TIMESTAMP(MAX(CASE WHEN event_type='delivered' THEN event_ts END)) - UNIX_TIMESTAMP(MIN(CASE WHEN event_type='order_created' THEN event_ts END))) / 60 AS FLOAT) AS delivery_duration_minutes\n", + " FROM order_events\n", + " GROUP BY order_id" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%sql\n", + "CREATE OR REPLACE FUNCTION ${CATALOG}.ai.get_user_support_history(uid STRING)\n", + "RETURNS TABLE (\n", + " support_request_id STRING,\n", + " order_id STRING,\n", + " ts TIMESTAMP,\n", + " request_text STRING,\n", + " agent_response STRING\n", + ")\n", + "RETURN\n", + " SELECT support_request_id, order_id, ts, request_text, agent_response\n", + " FROM ${CATALOG}.support.support_agent_reports\n", + " WHERE user_id = uid\n", + " ORDER BY ts DESC\n", + " LIMIT 10" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%sql\n", + "CREATE OR REPLACE FUNCTION ${CATALOG}.ai.get_user_request_history(uid STRING)\n", + "RETURNS TABLE (\n", + " support_request_id STRING,\n", + " order_id STRING,\n", + " ts TIMESTAMP,\n", + " request_type STRING,\n", + " edge_case_type STRING,\n", + " request_text STRING\n", + ")\n", + "RETURN\n", + " SELECT support_request_id, order_id, ts, request_type, edge_case_type, request_text\n", + " FROM ${CATALOG}.support.raw_support_requests\n", + " WHERE user_id = uid\n", + " ORDER BY ts DESC\n", + " LIMIT 10" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%pip install -U -qqqq typing_extensions dspy-ai mlflow unitycatalog-openai[databricks] openai databricks-sdk databricks-agents pydantic\n", + "%restart_python" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "CATALOG = dbutils.widgets.get(\"CATALOG\")\n", + "LLM_MODEL = dbutils.widgets.get(\"LLM_MODEL\")\n", + "SUPPORT_AGENT_ENDPOINT_NAME = dbutils.widgets.get(\"SUPPORT_AGENT_ENDPOINT_NAME\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import mlflow\n", + "import sys\n", + "\n", + "sys.path.append('../utils')\n", + "from uc_state import add\n", + "\n", + "dev_experiment = mlflow.set_experiment(f\"/Shared/{CATALOG}_support_agent_dev\")\n", + "add(CATALOG, \"experiments\", {\"experiment_id\": dev_experiment.experiment_id, \"name\": dev_experiment.name})\n", + "print(f\"Using dev experiment: {dev_experiment.name} (ID: {dev_experiment.experiment_id})\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import re\n", + "from IPython.core.magic import register_cell_magic\n", + "\n", + "@register_cell_magic\n", + "def writefilev(line, cell):\n", + " \"\"\"\n", + " %%writefilev file.py\n", + " Allows {{var}} substitutions while leaving normal {} intact.\n", + " \"\"\"\n", + " filename = line.strip()\n", + "\n", + " def replacer(match):\n", + " expr = match.group(1)\n", + " return str(eval(expr, globals(), locals()))\n", + "\n", + " content = re.sub(r\"\\{\\{(.*?)\\}\\}\", replacer, cell)\n", + "\n", + " with open(filename, \"w\") as f:\n", + " f.write(content)\n", + " print(f\"Wrote file with substitutions: {filename}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%%writefilev agent.py\n", + "import json\n", + "import re\n", + "import warnings\n", + "from typing import Any, Dict, Literal, Optional\n", + "from uuid import uuid4\n", + "\n", + "import dspy\n", + "import mlflow\n", + "from mlflow.pyfunc import ResponsesAgent\n", + "from mlflow.types.responses import ResponsesAgentRequest, ResponsesAgentResponse\n", + "from pydantic import BaseModel, Field\n", + "from unitycatalog.ai.core.base import get_uc_function_client\n", + "\n", + "warnings.filterwarnings(\"ignore\", message=\".*Ignoring the default notebook Spark session.*\")\n", + "\n", + "mlflow.dspy.autolog(log_traces=True)\n", + "\n", + "LLM_MODEL = \"{{LLM_MODEL}}\"\n", + "CATALOG = \"{{CATALOG}}\"\n", + "\n", + "lm = dspy.LM(f\"databricks/{LLM_MODEL}\", max_tokens=2000)\n", + "dspy.configure(lm=lm)\n", + "uc_client = get_uc_function_client()\n", + "\n", + "\n", + "class SupportReport(BaseModel):\n", + " support_request_id: str\n", + " user_id: str\n", + " order_id: str\n", + " credit_recommendation: Optional[dict] = None\n", + " refund_recommendation: Optional[dict] = None\n", + " draft_response: str\n", + " past_interactions_summary: str\n", + " order_details_summary: str\n", + " decision_confidence: Literal[\"high\", \"medium\", \"low\"] = Field(default=\"medium\")\n", + " escalation_flag: bool = Field(default=False)\n", + "\n", + "\n", + "class SupportTriage(dspy.Signature):\n", + " \"\"\"Produce a final support resolution report using full context.\n", + "\n", + " Required inputs to reason over:\n", + " - support request text and edge-case metadata\n", + " - order details and delivery timing\n", + " - prior support history for this user\n", + "\n", + " Decision policy:\n", + " - Severe repeat/high-risk issue: provide concrete refund + credit and apology.\n", + " - Moderate issue: provide bounded partial refund and/or credit with rationale.\n", + " - Minor/non-compensable issue: typically no refund, small goodwill credit or clear denial.\n", + " - Spam/abusive patterns: avoid excessive compensation, set escalation_flag=true.\n", + "\n", + " Never return placeholder text like \"we will look into this\".\n", + " Always return a final customer-ready message with concrete next steps.\n", + " \"\"\"\n", + "\n", + " support_request: str = dspy.InputField(desc=\"Structured case payload JSON string\")\n", + " support_request_id: str = dspy.OutputField()\n", + " user_id: str = dspy.OutputField()\n", + " order_id: str = dspy.OutputField()\n", + " credit_recommendation_json: str = dspy.OutputField(desc=\"JSON object with amount_usd/reason\")\n", + " refund_recommendation_json: str = dspy.OutputField(desc=\"JSON object with amount_usd/reason\")\n", + " draft_response: str = dspy.OutputField()\n", + " past_interactions_summary: str = dspy.OutputField()\n", + " order_details_summary: str = dspy.OutputField()\n", + " decision_confidence: str = dspy.OutputField(desc=\"high, medium, low\")\n", + " escalation_flag: str = dspy.OutputField(desc=\"true or false\")\n", + "\n", + "\n", + "def get_order_overview(order_id: str) -> str:\n", + " return str(uc_client.execute_function(f\"{CATALOG}.ai.get_order_overview\", {\"oid\": order_id}).value)\n", + "\n", + "\n", + "def get_order_timing(order_id: str) -> str:\n", + " return str(uc_client.execute_function(f\"{CATALOG}.ai.get_order_timing\", {\"oid\": order_id}).value)\n", + "\n", + "\n", + "def get_user_support_history(user_id: str) -> str:\n", + " return str(uc_client.execute_function(f\"{CATALOG}.ai.get_user_support_history\", {\"uid\": user_id}).value)\n", + "\n", + "\n", + "def get_user_request_history(user_id: str) -> str:\n", + " return str(uc_client.execute_function(f\"{CATALOG}.ai.get_user_request_history\", {\"uid\": user_id}).value)\n", + "\n", + "\n", + "def _parse_case_payload(raw: str) -> Dict[str, Any]:\n", + " try:\n", + " payload = json.loads(raw)\n", + " return payload if isinstance(payload, dict) else {}\n", + " except Exception:\n", + " parsed: Dict[str, Any] = {}\n", + " for key in [\"support_request_id\", \"user_id\", \"order_id\", \"text\"]:\n", + " match = re.search(rf\"{key}=([^\\s]+)\", raw)\n", + " if match:\n", + " parsed[key] = match.group(1)\n", + " if \"text\" in parsed:\n", + " parsed[\"request_text\"] = parsed[\"text\"]\n", + " return parsed\n", + "\n", + "\n", + "def _safe_float(value: Any, default: float = 0.0) -> float:\n", + " try:\n", + " return float(value)\n", + " except Exception:\n", + " return default\n", + "\n", + "\n", + "def _safe_int(value: Any, default: int = 0) -> int:\n", + " try:\n", + " return int(value)\n", + " except Exception:\n", + " return default\n", + "\n", + "\n", + "def _compute_policy_resolution(payload: Dict[str, Any]) -> Dict[str, Any]:\n", + " request_text = str(payload.get(\"request_text\", \"\"))\n", + " edge_case_type = str(payload.get(\"edge_case_type\", \"standard\"))\n", + "\n", + " repeat_count = _safe_int(payload.get(\"repeat_complaints_30d\"), 0)\n", + " risk_score = _safe_float(payload.get(\"risk_score\"), 0.25)\n", + " policy_limit = max(0.0, _safe_float(payload.get(\"policy_limit_usd\"), 20.0))\n", + "\n", + " text_lower = request_text.lower()\n", + " mentions_missing = \"missing\" in text_lower\n", + " mentions_delay = \"late\" in text_lower or \"delay\" in text_lower\n", + " abusive = edge_case_type == \"abusive_language\"\n", + " spammy = edge_case_type == \"spammy_escalation\"\n", + "\n", + " if abusive or (spammy and repeat_count >= 4 and risk_score < 0.4):\n", + " refund_amount = 0.0\n", + " credit_amount = min(3.0, policy_limit)\n", + " escalation = True\n", + " confidence = \"medium\"\n", + " reason = \"Abusive/spammy pattern detected; restricted compensation with escalation.\"\n", + " elif repeat_count >= 2 or risk_score >= 0.75 or (mentions_missing and mentions_delay):\n", + " refund_amount = round(min(policy_limit, max(policy_limit * 0.9, 15.0)), 2)\n", + " credit_amount = round(min(10.0, max(5.0, policy_limit * 0.25)), 2)\n", + " escalation = False\n", + " confidence = \"high\"\n", + " reason = \"Repeated or high-risk service failure.\"\n", + " elif repeat_count == 1 or risk_score >= 0.45 or mentions_missing or mentions_delay:\n", + " refund_amount = round(min(policy_limit, max(policy_limit * 0.5, 6.0)), 2)\n", + " credit_amount = round(min(7.0, max(3.0, policy_limit * 0.15)), 2)\n", + " escalation = False\n", + " confidence = \"high\"\n", + " reason = \"Moderate service issue with customer impact.\"\n", + " else:\n", + " refund_amount = 0.0\n", + " credit_amount = round(min(5.0, max(2.0, policy_limit * 0.1)), 2)\n", + " escalation = False\n", + " confidence = \"medium\"\n", + " reason = \"Minor issue or low-confidence compensable case.\"\n", + "\n", + " return {\n", + " \"refund_amount\": refund_amount,\n", + " \"credit_amount\": credit_amount,\n", + " \"escalation\": escalation,\n", + " \"confidence\": confidence,\n", + " \"reason\": reason,\n", + " \"repeat_count\": repeat_count,\n", + " }\n", + "\n", + "\n", + "def _ordinal(value: int) -> str:\n", + " if 10 <= value % 100 <= 20:\n", + " suffix = \"th\"\n", + " else:\n", + " suffix = {1: \"st\", 2: \"nd\", 3: \"rd\"}.get(value % 10, \"th\")\n", + " return f\"{value}{suffix}\"\n", + "\n", + "\n", + "def _compose_customer_message(payload: Dict[str, Any], policy: Dict[str, Any]) -> str:\n", + " request_text = str(payload.get(\"request_text\", \"\")).strip()\n", + " repeat_count = policy[\"repeat_count\"]\n", + " refund_amount = policy[\"refund_amount\"]\n", + " credit_amount = policy[\"credit_amount\"]\n", + "\n", + " lead = \"Thanks for contacting us, and I am really sorry about this experience.\"\n", + " if repeat_count >= 2:\n", + " lead = (\n", + " f\"I see this is at least the {_ordinal(repeat_count)} time you've had to contact us for similar issues, \"\n", + " \"and that is not acceptable.\"\n", + " )\n", + "\n", + " resolution_parts = []\n", + " if refund_amount > 0:\n", + " resolution_parts.append(f\"a ${refund_amount:.2f} refund\")\n", + " if credit_amount > 0:\n", + " resolution_parts.append(f\"a ${credit_amount:.2f} credit\")\n", + "\n", + " if resolution_parts:\n", + " resolution = \"We are offering \" + \" and \".join(resolution_parts) + \" on this case.\"\n", + " else:\n", + " resolution = \"We are escalating this case for manual review and will follow up quickly.\"\n", + "\n", + " context_line = f\"Issue noted: {request_text}\" if request_text else \"Issue noted from your support ticket.\"\n", + "\n", + " return f\"{lead} {resolution} {context_line}\"\n", + "\n", + "\n", + "class SupportModule(dspy.Module):\n", + " def __init__(self):\n", + " super().__init__()\n", + " self.react = dspy.ReAct(\n", + " signature=SupportTriage,\n", + " tools=[\n", + " get_order_overview,\n", + " get_order_timing,\n", + " get_user_support_history,\n", + " get_user_request_history,\n", + " ],\n", + " max_iters=10,\n", + " )\n", + "\n", + " def forward(self, support_request: str) -> SupportReport:\n", + " payload = _parse_case_payload(support_request)\n", + " result = self.react(support_request=support_request)\n", + "\n", + " policy = _compute_policy_resolution(payload)\n", + " final_message = _compose_customer_message(payload, policy)\n", + "\n", + " credit_recommendation = {\n", + " \"amount_usd\": policy[\"credit_amount\"],\n", + " \"reason\": policy[\"reason\"],\n", + " }\n", + " refund_recommendation = {\n", + " \"amount_usd\": policy[\"refund_amount\"],\n", + " \"reason\": policy[\"reason\"],\n", + " }\n", + "\n", + " return SupportReport(\n", + " support_request_id=str(payload.get(\"support_request_id\") or result.support_request_id),\n", + " user_id=str(payload.get(\"user_id\") or result.user_id),\n", + " order_id=str(payload.get(\"order_id\") or result.order_id),\n", + " credit_recommendation=credit_recommendation,\n", + " refund_recommendation=refund_recommendation,\n", + " draft_response=final_message,\n", + " past_interactions_summary=str(result.past_interactions_summary),\n", + " order_details_summary=str(result.order_details_summary),\n", + " decision_confidence=policy[\"confidence\"],\n", + " escalation_flag=bool(policy[\"escalation\"]),\n", + " )\n", + "\n", + "\n", + "class DSPySupportAgent(ResponsesAgent):\n", + " def __init__(self):\n", + " self.module = SupportModule()\n", + "\n", + " def predict(self, request: ResponsesAgentRequest) -> ResponsesAgentResponse:\n", + " user_message = None\n", + " for msg in request.input:\n", + " msg_dict = msg.model_dump() if hasattr(msg, \"model_dump\") else msg\n", + " if msg_dict.get(\"role\") == \"user\":\n", + " user_message = msg_dict.get(\"content\", \"\")\n", + " break\n", + " if not user_message:\n", + " raise ValueError(\"No user message found\")\n", + "\n", + " result = self.module(support_request=user_message)\n", + " return ResponsesAgentResponse(\n", + " output=[self.create_text_output_item(text=result.model_dump_json(), id=str(uuid4()))],\n", + " custom_outputs=request.custom_inputs,\n", + " )\n", + "\n", + "\n", + "AGENT = DSPySupportAgent()\n", + "mlflow.models.set_model(AGENT)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "\n", + "sample_order_id = None\n", + "for attempt in range(12):\n", + " rows = spark.sql(f\"\"\"\n", + " SELECT order_id \n", + " FROM {CATALOG}.lakeflow.all_events \n", + " WHERE event_type='delivered'\n", + " LIMIT 1\n", + " \"\"\").collect()\n", + " if rows:\n", + " sample_order_id = rows[0]['order_id']\n", + " break\n", + " print(f\"No delivered events yet (attempt {attempt+1}/12). Waiting 30s for pipeline data...\")\n", + " time.sleep(30)\n", + "\n", + "if not sample_order_id:\n", + " raise RuntimeError(\n", + " f\"No delivered events found in {CATALOG}.lakeflow.all_events after 6 minutes. \"\n", + " \"Ensure the Canonical_Data and Lakeflow pipeline stages completed and processed data.\"\n", + " )\n", + "\n", + "print(f\"Sample order_id: {sample_order_id}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import mlflow\n", + "from mlflow.models.resources import DatabricksFunction, DatabricksServingEndpoint\n", + "from pkg_resources import get_distribution\n", + "\n", + "payload = {\n", + " \"support_request_id\": \"sample-1\",\n", + " \"user_id\": \"user-001\",\n", + " \"order_id\": sample_order_id,\n", + " \"request_type\": \"missing_items\",\n", + " \"edge_case_type\": \"standard\",\n", + " \"request_text\": \"My order was missing one or more items. Please review and advise next steps.\",\n", + "}\n", + "\n", + "resources = [DatabricksServingEndpoint(endpoint_name=LLM_MODEL)]\n", + "for fn in [\n", + " f\"{CATALOG}.ai.get_order_overview\",\n", + " f\"{CATALOG}.ai.get_order_timing\",\n", + " f\"{CATALOG}.ai.get_user_support_history\",\n", + " f\"{CATALOG}.ai.get_user_request_history\",\n", + "]:\n", + " resources.append(DatabricksFunction(function_name=fn))\n", + "\n", + "input_example = {\"input\": [{\"role\": \"user\", \"content\": json.dumps(payload)}]}\n", + "\n", + "conda_env = {\n", + " \"channels\": [\"conda-forge\"],\n", + " \"dependencies\": [\n", + " \"python=3.11\",\n", + " \"pip\",\n", + " {\n", + " \"pip\": [\n", + " \"mlflow==3.6\",\n", + " f\"typing_extensions=={get_distribution('typing_extensions').version}\",\n", + " f\"dspy-ai=={get_distribution('dspy-ai').version}\",\n", + " f\"unitycatalog-openai[databricks]=={get_distribution('unitycatalog-openai').version}\",\n", + " f\"pydantic=={get_distribution('pydantic').version}\",\n", + " ]\n", + " }\n", + " ],\n", + " \"name\": \"mlflow-env\"\n", + "}\n", + "\n", + "with mlflow.start_run():\n", + " logged_agent_info = mlflow.pyfunc.log_model(\n", + " name=\"support_agent\",\n", + " python_model=\"agent.py\",\n", + " input_example=input_example,\n", + " resources=resources,\n", + " conda_env=conda_env,\n", + " )\n", + "\n", + "mlflow.set_active_model(model_id=logged_agent_info.model_id)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Log to Unity Catalog" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mlflow.set_registry_uri(\"databricks-uc\")\n", + "\n", + "UC_MODEL_NAME = f\"{CATALOG}.ai.support_agent\"\n", + "\n", + "uc_registered_model_info = mlflow.register_model(\n", + " model_uri=logged_agent_info.model_uri, name=UC_MODEL_NAME\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Deploy to Model Serving" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import mlflow\n", + "from datetime import timedelta\n", + "\n", + "from databricks import agents\n", + "from databricks.sdk import WorkspaceClient\n", + "from databricks.sdk.service.serving import EndpointStateReady\n", + "\n", + "import sys\n", + "sys.path.append('../utils')\n", + "from uc_state import add\n", + "\n", + "prod_experiment = mlflow.set_experiment(f\"/Shared/{CATALOG}_support_agent_prod\")\n", + "add(CATALOG, \"experiments\", {\"experiment_id\": prod_experiment.experiment_id, \"name\": prod_experiment.name})\n", + "\n", + "deployment_info = agents.deploy(\n", + " model_name=UC_MODEL_NAME,\n", + " model_version=uc_registered_model_info.version,\n", + " scale_to_zero=False,\n", + " endpoint_name=SUPPORT_AGENT_ENDPOINT_NAME,\n", + " environment_vars={\"MLFLOW_EXPERIMENT_ID\": str(prod_experiment.experiment_id)},\n", + ")\n", + "\n", + "workspace = WorkspaceClient()\n", + "ready_endpoint = workspace.serving_endpoints.wait_get_serving_endpoint_not_updating(\n", + " name=SUPPORT_AGENT_ENDPOINT_NAME,\n", + " timeout=timedelta(minutes=30),\n", + ")\n", + "\n", + "if ready_endpoint.state.ready != EndpointStateReady.READY:\n", + " raise RuntimeError(\n", + " f\"Endpoint {SUPPORT_AGENT_ENDPOINT_NAME} is {ready_endpoint.state.ready} after deployment; retry or investigate.\"\n", + " )\n", + "\n", + "print(f\"Endpoint {SUPPORT_AGENT_ENDPOINT_NAME} is READY\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Record in State" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "add(CATALOG, \"endpoints\", deployment_info)\n", + "print(f\"Registered {SUPPORT_AGENT_ENDPOINT_NAME} in UC state\")" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/stages/support_request_agent_stream.ipynb b/stages/support_request_agent_stream.ipynb new file mode 100644 index 0000000..2109659 --- /dev/null +++ b/stages/support_request_agent_stream.ipynb @@ -0,0 +1,89 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Support Request Agent Stream\n", + "\n", + "This notebook creates a scheduled job to process support requests through the support agent" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%pip install --upgrade databricks-sdk" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from databricks.sdk import WorkspaceClient\n", + "import databricks.sdk.service.jobs as j\n", + "import os\n", + "\n", + "w = WorkspaceClient()\n", + "\n", + "CATALOG = dbutils.widgets.get(\"CATALOG\")\n", + "\n", + "notebook_abs_path = os.path.abspath(\"../jobs/support_request_agent_stream\")\n", + "notebook_dbx_path = notebook_abs_path.replace(\n", + " os.environ.get(\"DATABRICKS_WORKSPACE_ROOT\", \"/Workspace\"),\n", + " \"/Workspace\"\n", + ")\n", + "\n", + "job_name = f\"Support Request Agent Stream ({CATALOG})\"\n", + "\n", + "task_def = [\n", + " j.Task(\n", + " task_key=\"support_request_agent_stream\",\n", + " notebook_task=j.NotebookTask(\n", + " notebook_path=notebook_dbx_path,\n", + " base_parameters={\n", + " \"CATALOG\": CATALOG,\n", + " \"SUPPORT_AGENT_ENDPOINT_NAME\": dbutils.widgets.get(\"SUPPORT_AGENT_ENDPOINT_NAME\"),\n", + " },\n", + " )\n", + " )\n", + "]\n", + "schedule_def = j.CronSchedule(\n", + " quartz_cron_expression=\"0 0/10 * * * ?\",\n", + " timezone_id=\"UTC\",\n", + " pause_status=j.PauseStatus.UNPAUSED,\n", + ")\n", + "\n", + "existing = [jb for jb in w.jobs.list(name=job_name) if jb.settings.name == job_name]\n", + "if existing:\n", + " job_id = existing[0].job_id\n", + " w.jobs.reset(job_id=job_id, new_settings=j.JobSettings(\n", + " name=job_name, tasks=task_def, schedule=schedule_def,\n", + " ))\n", + " print(f\"\\u267b\\ufe0f Updated existing job_id={job_id}\")\n", + "else:\n", + " job = w.jobs.create(name=job_name, tasks=task_def, schedule=schedule_def)\n", + " job_id = job.job_id\n", + " import sys\n", + " sys.path.append('../utils')\n", + " from uc_state import add\n", + " add(CATALOG, \"jobs\", job)\n", + " print(f\"\\u2705 Created job_id={job_id}\")\n", + "\n", + "w.jobs.run_now(job_id=job_id)\n", + "print(f\"\\U0001f680 Started run of {job_name}\")" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/stages/support_request_generator_stream.ipynb b/stages/support_request_generator_stream.ipynb new file mode 100644 index 0000000..2c48eb1 --- /dev/null +++ b/stages/support_request_generator_stream.ipynb @@ -0,0 +1,90 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Support Request Generator Stream\n", + "\n", + "This notebook creates a scheduled job to generate synthetic support requests from delivered orders" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%pip install --upgrade databricks-sdk" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from databricks.sdk import WorkspaceClient\n", + "import databricks.sdk.service.jobs as j\n", + "import os\n", + "\n", + "w = WorkspaceClient()\n", + "\n", + "CATALOG = dbutils.widgets.get(\"CATALOG\")\n", + "\n", + "notebook_abs_path = os.path.abspath(\"../jobs/support_request_generator\")\n", + "notebook_dbx_path = notebook_abs_path.replace(\n", + " os.environ.get(\"DATABRICKS_WORKSPACE_ROOT\", \"/Workspace\"),\n", + " \"/Workspace\"\n", + ")\n", + "\n", + "job_name = f\"Support Request Generator ({CATALOG})\"\n", + "\n", + "task_def = [\n", + " j.Task(\n", + " task_key=\"support_request_generator\",\n", + " notebook_task=j.NotebookTask(\n", + " notebook_path=notebook_dbx_path,\n", + " base_parameters={\n", + " \"CATALOG\": CATALOG,\n", + " \"SUPPORT_RATE\": dbutils.widgets.get(\"SUPPORT_RATE\"),\n", + " \"LLM_MODEL\": dbutils.widgets.get(\"LLM_MODEL\"),\n", + " },\n", + " )\n", + " )\n", + "]\n", + "schedule_def = j.CronSchedule(\n", + " quartz_cron_expression=\"0 0/10 * * * ?\",\n", + " timezone_id=\"UTC\",\n", + " pause_status=j.PauseStatus.UNPAUSED,\n", + ")\n", + "\n", + "existing = [jb for jb in w.jobs.list(name=job_name) if jb.settings.name == job_name]\n", + "if existing:\n", + " job_id = existing[0].job_id\n", + " w.jobs.reset(job_id=job_id, new_settings=j.JobSettings(\n", + " name=job_name, tasks=task_def, schedule=schedule_def,\n", + " ))\n", + " print(f\"\\u267b\\ufe0f Updated existing job_id={job_id}\")\n", + "else:\n", + " job = w.jobs.create(name=job_name, tasks=task_def, schedule=schedule_def)\n", + " job_id = job.job_id\n", + " import sys\n", + " sys.path.append('../utils')\n", + " from uc_state import add\n", + " add(CATALOG, \"jobs\", job)\n", + " print(f\"\\u2705 Created job_id={job_id}\")\n", + "\n", + "w.jobs.run_now(job_id=job_id)\n", + "print(f\"\\U0001f680 Started run of {job_name}\")" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file From b05f1a9900e0b62f41151034be7dfab6f560f8b8 Mon Sep 17 00:00:00 2001 From: Nick Karpov Date: Thu, 19 Mar 2026 16:42:31 -0700 Subject: [PATCH 2/9] fix(support): fix DAG dependencies and location column in agent SQL function - Agent stream now depends on SDP directly (not generator), matching the refund pattern where all stages fan out from SDP - Fix get_order_overview to JOIN simulator.locations for location name (all_events has location_id, not location) Co-authored-by: Andre Landgraf Co-authored-by: Isaac --- databricks.yml | 2 +- stages/support_request_agent.ipynb | 22 +--------------------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/databricks.yml b/databricks.yml index caf7775..ae231bb 100644 --- a/databricks.yml +++ b/databricks.yml @@ -395,7 +395,7 @@ targets: - task_key: Support_Request_Agent_Stream depends_on: - - task_key: Support_Request_Generator_Stream + - task_key: Spark_Declarative_Pipeline notebook_task: notebook_path: ${workspace.root_path}/stages/support_request_agent_stream diff --git a/stages/support_request_agent.ipynb b/stages/support_request_agent.ipynb index eab6d9b..72fa54c 100644 --- a/stages/support_request_agent.ipynb +++ b/stages/support_request_agent.ipynb @@ -50,27 +50,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "%sql\n", - "CREATE OR REPLACE FUNCTION ${CATALOG}.ai.get_order_overview(oid STRING)\n", - "RETURNS TABLE (\n", - " order_id STRING,\n", - " location STRING,\n", - " items_json STRING,\n", - " customer_address STRING,\n", - " order_created_ts TIMESTAMP\n", - ")\n", - "RETURN\n", - " SELECT\n", - " order_id,\n", - " location,\n", - " get_json_object(body, '$.items') AS items_json,\n", - " get_json_object(body, '$.customer_addr') AS customer_address,\n", - " try_to_timestamp(ts) AS order_created_ts\n", - " FROM ${CATALOG}.lakeflow.all_events\n", - " WHERE order_id = oid AND event_type = 'order_created'\n", - " LIMIT 1" - ] + "source": "%sql\nCREATE OR REPLACE FUNCTION ${CATALOG}.ai.get_order_overview(oid STRING)\nRETURNS TABLE (\n order_id STRING,\n location STRING,\n items_json STRING,\n customer_address STRING,\n order_created_ts TIMESTAMP\n)\nRETURN\n SELECT\n ae.order_id,\n loc.name AS location,\n get_json_object(ae.body, '$.items') AS items_json,\n get_json_object(ae.body, '$.customer_addr') AS customer_address,\n try_to_timestamp(ae.ts) AS order_created_ts\n FROM ${CATALOG}.lakeflow.all_events ae\n LEFT JOIN ${CATALOG}.simulator.locations loc ON ae.location_id = loc.location_id\n WHERE ae.order_id = oid AND ae.event_type = 'order_created'\n LIMIT 1" }, { "cell_type": "code", From 2c9b03acbf99d6aad32b20d0947fadf88860eea3 Mon Sep 17 00:00:00 2001 From: Nick Karpov Date: Thu, 19 Mar 2026 16:44:07 -0700 Subject: [PATCH 3/9] fix(support): agent stages depend on generator, not SDP Generator is the split point - both Support_Request_Agent (leaf) and Support_Request_Agent_Stream fan out from Support_Request_Generator_Stream. Co-authored-by: Andre Landgraf Co-authored-by: Isaac --- databricks.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/databricks.yml b/databricks.yml index ae231bb..9f643a7 100644 --- a/databricks.yml +++ b/databricks.yml @@ -381,21 +381,21 @@ targets: notebook_task: notebook_path: ${workspace.root_path}/stages/lakeflow - - task_key: Support_Request_Agent + - task_key: Support_Request_Generator_Stream depends_on: - task_key: Spark_Declarative_Pipeline notebook_task: - notebook_path: ${workspace.root_path}/stages/support_request_agent + notebook_path: ${workspace.root_path}/stages/support_request_generator_stream - - task_key: Support_Request_Generator_Stream + - task_key: Support_Request_Agent depends_on: - - task_key: Spark_Declarative_Pipeline + - task_key: Support_Request_Generator_Stream notebook_task: - notebook_path: ${workspace.root_path}/stages/support_request_generator_stream + notebook_path: ${workspace.root_path}/stages/support_request_agent - task_key: Support_Request_Agent_Stream depends_on: - - task_key: Spark_Declarative_Pipeline + - task_key: Support_Request_Generator_Stream notebook_task: notebook_path: ${workspace.root_path}/stages/support_request_agent_stream From 79dd993005e5a9e26b7e8f905b02144aa736df3f Mon Sep 17 00:00:00 2001 From: Nick Karpov Date: Thu, 19 Mar 2026 17:51:04 -0700 Subject: [PATCH 4/9] feat(support): add Lakebase + App stage and support console app - Add support_lakebase stage: Lakebase instance, synced table for support_agent_reports, OLTP tables (operator_actions, support_replies, request_status, response_ratings), warehouse, app deployment - Port supportconsolek React/TS app from PR #63 - Dynamic app.yaml generation with computed warehouse/endpoint values - Add sync exclusions for supportconsolek build artifacts - Wire Support_Lakebase_And_App into DAG (depends on agent stream) Co-authored-by: Andre Landgraf Co-authored-by: Isaac --- .../supportconsolek/.env.example | 4 + .../supportconsolek/.gitignore | 10 + .../supportconsolek/.prettierignore | 36 + .../supportconsolek/.prettierrc.json | 12 + apps/supportconsolek/supportconsolek/app.yaml | 14 + .../supportconsolek/appkit.plugins.json | 41 + .../supportconsolek/client/components.json | 21 + .../supportconsolek/client/index.html | 18 + .../supportconsolek/client/postcss.config.js | 6 + .../client/public/apple-touch-icon.png | Bin 0 -> 2547 bytes .../client/public/favicon-16x16.png | Bin 0 -> 302 bytes .../client/public/favicon-192x192.png | Bin 0 -> 2762 bytes .../client/public/favicon-32x32.png | Bin 0 -> 492 bytes .../client/public/favicon-48x48.png | Bin 0 -> 686 bytes .../client/public/favicon-512x512.png | Bin 0 -> 10325 bytes .../supportconsolek/client/public/favicon.svg | 6 + .../client/public/site.webmanifest | 19 + .../supportconsolek/client/src/App.tsx | 860 + .../client/src/ErrorBoundary.tsx | 75 + .../supportconsolek/client/src/index.css | 63 + .../supportconsolek/client/src/lib/utils.ts | 6 + .../supportconsolek/client/src/main.tsx | 19 + .../supportconsolek/client/src/vite-env.d.ts | 1 + .../supportconsolek/client/tailwind.config.ts | 10 + .../supportconsolek/client/vite.config.ts | 25 + .../supportconsolek/databricks.yml | 20 + .../supportconsolek/eslint.config.js | 91 + .../supportconsolek/package-lock.json | 14167 ++++++++++++++++ .../supportconsolek/package.json | 85 + .../supportconsolek/playwright.config.ts | 26 + .../supportconsolek/server/lib/lakebase.ts | 63 + .../supportconsolek/server/server.ts | 11 + .../supportconsolek/server/support-plugin.ts | 1085 ++ .../supportconsolek/tests/smoke.spec.ts | 108 + .../supportconsolek/vitest.config.ts | 16 + databricks.yml | 13 +- stages/support_lakebase.ipynb | 504 + 37 files changed, 17430 insertions(+), 5 deletions(-) create mode 100644 apps/supportconsolek/supportconsolek/.env.example create mode 100644 apps/supportconsolek/supportconsolek/.gitignore create mode 100644 apps/supportconsolek/supportconsolek/.prettierignore create mode 100644 apps/supportconsolek/supportconsolek/.prettierrc.json create mode 100644 apps/supportconsolek/supportconsolek/app.yaml create mode 100644 apps/supportconsolek/supportconsolek/appkit.plugins.json create mode 100644 apps/supportconsolek/supportconsolek/client/components.json create mode 100644 apps/supportconsolek/supportconsolek/client/index.html create mode 100644 apps/supportconsolek/supportconsolek/client/postcss.config.js create mode 100644 apps/supportconsolek/supportconsolek/client/public/apple-touch-icon.png create mode 100644 apps/supportconsolek/supportconsolek/client/public/favicon-16x16.png create mode 100644 apps/supportconsolek/supportconsolek/client/public/favicon-192x192.png create mode 100644 apps/supportconsolek/supportconsolek/client/public/favicon-32x32.png create mode 100644 apps/supportconsolek/supportconsolek/client/public/favicon-48x48.png create mode 100644 apps/supportconsolek/supportconsolek/client/public/favicon-512x512.png create mode 100644 apps/supportconsolek/supportconsolek/client/public/favicon.svg create mode 100644 apps/supportconsolek/supportconsolek/client/public/site.webmanifest create mode 100644 apps/supportconsolek/supportconsolek/client/src/App.tsx create mode 100644 apps/supportconsolek/supportconsolek/client/src/ErrorBoundary.tsx create mode 100644 apps/supportconsolek/supportconsolek/client/src/index.css create mode 100644 apps/supportconsolek/supportconsolek/client/src/lib/utils.ts create mode 100644 apps/supportconsolek/supportconsolek/client/src/main.tsx create mode 100644 apps/supportconsolek/supportconsolek/client/src/vite-env.d.ts create mode 100644 apps/supportconsolek/supportconsolek/client/tailwind.config.ts create mode 100644 apps/supportconsolek/supportconsolek/client/vite.config.ts create mode 100644 apps/supportconsolek/supportconsolek/databricks.yml create mode 100644 apps/supportconsolek/supportconsolek/eslint.config.js create mode 100644 apps/supportconsolek/supportconsolek/package-lock.json create mode 100644 apps/supportconsolek/supportconsolek/package.json create mode 100644 apps/supportconsolek/supportconsolek/playwright.config.ts create mode 100644 apps/supportconsolek/supportconsolek/server/lib/lakebase.ts create mode 100644 apps/supportconsolek/supportconsolek/server/server.ts create mode 100644 apps/supportconsolek/supportconsolek/server/support-plugin.ts create mode 100644 apps/supportconsolek/supportconsolek/tests/smoke.spec.ts create mode 100644 apps/supportconsolek/supportconsolek/vitest.config.ts create mode 100644 stages/support_lakebase.ipynb diff --git a/apps/supportconsolek/supportconsolek/.env.example b/apps/supportconsolek/supportconsolek/.env.example new file mode 100644 index 0000000..bc164a3 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/.env.example @@ -0,0 +1,4 @@ +DATABRICKS_HOST=https://... +DATABRICKS_APP_PORT=8000 +DATABRICKS_APP_NAME=caspers-supportconsole +FLASK_RUN_HOST=0.0.0.0 diff --git a/apps/supportconsolek/supportconsolek/.gitignore b/apps/supportconsolek/supportconsolek/.gitignore new file mode 100644 index 0000000..f2abc32 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules/ +client/dist/ +dist/ +build/ +.env +.databricks/ +.smoke-test/ +test-results/ +playwright-report/ diff --git a/apps/supportconsolek/supportconsolek/.prettierignore b/apps/supportconsolek/supportconsolek/.prettierignore new file mode 100644 index 0000000..7d3d77c --- /dev/null +++ b/apps/supportconsolek/supportconsolek/.prettierignore @@ -0,0 +1,36 @@ +# Dependencies +node_modules + +# Build outputs +dist +build +client/dist +.next +.databricks/ + +# Environment files +.env +.env.local +.env.*.local + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Coverage +coverage + +# Cache +.cache +.turbo + +# Lock files +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Vendor +vendor diff --git a/apps/supportconsolek/supportconsolek/.prettierrc.json b/apps/supportconsolek/supportconsolek/.prettierrc.json new file mode 100644 index 0000000..d95a63f --- /dev/null +++ b/apps/supportconsolek/supportconsolek/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf", + "bracketSpacing": true, + "jsxSingleQuote": false +} diff --git a/apps/supportconsolek/supportconsolek/app.yaml b/apps/supportconsolek/supportconsolek/app.yaml new file mode 100644 index 0000000..95576a0 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/app.yaml @@ -0,0 +1,14 @@ +command: ['npm', 'run', 'start'] +env: + - name: PGPORT + value: "5432" + - name: PGDATABASE + value: "databricks_postgres" + - name: PGSSLMODE + value: "require" + - name: LAKEBASE_ENDPOINT + value: "projects/casperskitchens-support-db/branches/production/endpoints/primary" + - name: DATABRICKS_WAREHOUSE_ID + value: "e5ed18828056f3cf" + - name: SUPPORT_AGENT_ENDPOINT_NAME + value: "caspers_support_agent" \ No newline at end of file diff --git a/apps/supportconsolek/supportconsolek/appkit.plugins.json b/apps/supportconsolek/supportconsolek/appkit.plugins.json new file mode 100644 index 0000000..67f3874 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/appkit.plugins.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + "version": "1.0", + "plugins": { + "analytics": { + "name": "analytics", + "displayName": "Analytics Plugin", + "description": "SQL query execution against Databricks SQL Warehouses", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "sql_warehouse", + "alias": "SQL Warehouse", + "resourceKey": "sql-warehouse", + "description": "SQL Warehouse for executing analytics queries", + "permission": "CAN_USE", + "fields": { + "id": { + "env": "DATABRICKS_WAREHOUSE_ID", + "description": "SQL Warehouse ID" + } + } + } + ], + "optional": [] + } + }, + "server": { + "name": "server", + "displayName": "Server Plugin", + "description": "HTTP server with Express, static file serving, and Vite dev mode support", + "package": "@databricks/appkit", + "requiredByTemplate": true, + "resources": { + "required": [], + "optional": [] + } + } + } +} diff --git a/apps/supportconsolek/supportconsolek/client/components.json b/apps/supportconsolek/supportconsolek/client/components.json new file mode 100644 index 0000000..13e1db0 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/client/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/supportconsolek/supportconsolek/client/index.html b/apps/supportconsolek/supportconsolek/client/index.html new file mode 100644 index 0000000..26f3416 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/client/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + caspers-supportconsole + + +
+ + + diff --git a/apps/supportconsolek/supportconsolek/client/postcss.config.js b/apps/supportconsolek/supportconsolek/client/postcss.config.js new file mode 100644 index 0000000..51a6e4e --- /dev/null +++ b/apps/supportconsolek/supportconsolek/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +}; diff --git a/apps/supportconsolek/supportconsolek/client/public/apple-touch-icon.png b/apps/supportconsolek/supportconsolek/client/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..32053bd2d17ca6d4cdcfa2e2390cdc4db61658c1 GIT binary patch literal 2547 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 literal 0 HcmV?d00001 diff --git a/apps/supportconsolek/supportconsolek/client/public/favicon-16x16.png b/apps/supportconsolek/supportconsolek/client/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..d7c16eb471f63a06f45119cebeb085dcc6ce3252 GIT binary patch literal 302 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 Uz+N|&sQ>@~07*qoM6N<$f;65h(*OVf literal 0 HcmV?d00001 diff --git a/apps/supportconsolek/supportconsolek/client/public/favicon-512x512.png b/apps/supportconsolek/supportconsolek/client/public/favicon-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..14d7d209db562940b4e78d23fce203d69afe7c38 GIT binary patch literal 10325 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 boicPbM8)qSin-XseaGC)+VuG$w_E=Y^dlWB literal 0 HcmV?d00001 diff --git a/apps/supportconsolek/supportconsolek/client/public/favicon.svg b/apps/supportconsolek/supportconsolek/client/public/favicon.svg new file mode 100644 index 0000000..cb30c1e --- /dev/null +++ b/apps/supportconsolek/supportconsolek/client/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/supportconsolek/supportconsolek/client/public/site.webmanifest b/apps/supportconsolek/supportconsolek/client/public/site.webmanifest new file mode 100644 index 0000000..03106ce --- /dev/null +++ b/apps/supportconsolek/supportconsolek/client/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "{{.project_name}}", + "short_name": "{{.project_name}}", + "icons": [ + { + "src": "/favicon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/apps/supportconsolek/supportconsolek/client/src/App.tsx b/apps/supportconsolek/supportconsolek/client/src/App.tsx new file mode 100644 index 0000000..aa5ea08 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/client/src/App.tsx @@ -0,0 +1,860 @@ +/** + * ⚠️ BEFORE MODIFYING THIS FILE: + * + * 1. Create SQL files in config/queries/ + * 2. Run `npm run typegen` to generate query types + * 3. Check appKitTypes.d.ts for available types + * + * Common Mistakes: + * - DataTable does NOT accept `data` or `columns` props + * - Charts use `xKey` and `yKey`, NOT `seriesKey`/`nameKey`/`valueKey` + * - useAnalyticsQuery has no `enabled` option - use conditional rendering + */ +import { useEffect, useRef, useState } from "react"; +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Input, + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + Textarea, +} from "@databricks/appkit-ui/react"; +import { Toaster, toast } from "sonner"; + +type Summary = { requests: number; actions: number; replies: number }; + +type Recommendation = { + amount_usd?: number | null; + reason?: string; +}; + +type Report = { + draft_response?: string; + past_interactions_summary?: string; + order_details_summary?: string; + decision_confidence?: string; + escalation_flag?: boolean; + refund_recommendation?: Recommendation | null; + credit_recommendation?: Recommendation | null; +}; + +type CaseState = { + case_status: "pending" | "in_progress" | "done" | "blocked"; + next_action: string; + has_reply: boolean; + has_refund: boolean; + has_credit: boolean; + action_count: number; + reply_count: number; + regen_count: number; + last_action_type?: string | null; + last_event_at?: string | null; + latest_report_source?: string | null; +}; + +type TimelineEvent = { + event_type: string; + event_at: string; + actor?: string | null; + details?: Record; +}; + +type RegenerationItem = { + regenerated_report_id: number; + operator_context?: string | null; + actor?: string | null; + created_at: string; + report: Report; +}; + +type ResponseRating = { + rating_id: number; + rating: "thumbs_up" | "thumbs_down"; + reason_code?: string | null; + feedback_notes?: string | null; + actor?: string | null; + created_at: string; +}; + +type RequestItem = { + support_request_id: string; + user_id: string; + user_display_name?: string | null; + order_id: string; + ts: string; + request_text?: string | null; + report: Report; + case_state?: CaseState; +}; + +type RequestDetails = RequestItem & { + actions: Array>; + replies: Array>; + ratings?: ResponseRating[]; + latest_rating?: ResponseRating | null; + regenerations?: RegenerationItem[]; + timeline?: TimelineEvent[]; +}; + +type NoticeState = { + kind: "success" | "error"; + message: string; + supportRequestId?: string; +} | null; + +const RATING_REASON_OPTIONS: Array<{ value: string; label: string }> = [ + { value: "incorrect_facts", label: "Incorrect facts in response" }, + { value: "wrong_refund_amount", label: "Wrong refund amount" }, + { value: "wrong_credit_amount", label: "Wrong credit amount" }, + { value: "should_escalate", label: "Should have escalated" }, + { value: "should_not_escalate", label: "Should not have escalated" }, + { value: "poor_tone", label: "Poor tone or wording" }, + { value: "unclear_response", label: "Unclear or incomplete response" }, + { value: "other", label: "Other" }, +]; + +function App() { + const PAGE_SIZE = 50; + const [summary, setSummary] = useState(null); + const [requests, setRequests] = useState([]); + const [totalRequests, setTotalRequests] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selected, setSelected] = useState(null); + const [replyText, setReplyText] = useState(""); + const [operatorContext, setOperatorContext] = useState(""); + const [actor, setActor] = useState(""); + const [refundAmount, setRefundAmount] = useState(""); + const [creditAmount, setCreditAmount] = useState(""); + const [ratingChoice, setRatingChoice] = useState<"thumbs_up" | "thumbs_down">("thumbs_up"); + const [ratingReason, setRatingReason] = useState(""); + const [ratingNotes, setRatingNotes] = useState(""); + const [loading, setLoading] = useState(false); + const [detailsLoading, setDetailsLoading] = useState(false); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [pendingAction, setPendingAction] = useState<"apply_refund" | "apply_credit" | "send_reply" | "regenerate" | "rate_response" | null>(null); + const [notice, setNotice] = useState(null); + const [error, setError] = useState(null); + const drawerScrollRef = useRef(null); + + useEffect(() => { + void refresh(); + }, [currentPage]); + + const formatCurrency = (value?: number | null) => + typeof value === "number" ? `$${value.toFixed(2)}` : "No recommendation"; + + const formatTs = (value: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "Recent"; + } + return date.toLocaleString(); + }; + + const statusLabel = (status: CaseState["case_status"] | undefined) => { + if (status === "done") return "Done"; + if (status === "in_progress") return "In Progress"; + if (status === "blocked") return "Blocked"; + return "Pending"; + }; + + const statusVariant = (status: CaseState["case_status"] | undefined): "default" | "secondary" | "outline" => { + if (status === "done") return "default"; + if (status === "in_progress") return "secondary"; + return "outline"; + }; + + const nextActionLabel = (nextAction: string | undefined) => { + const mapping: Record = { + review_report: "Review report", + apply_resolution_or_regenerate: "Apply resolution or re-gen", + send_customer_reply: "Send customer reply", + monitor: "Monitor case", + investigate_blocker: "Investigate blocker", + continue_investigation: "Continue investigation", + }; + return mapping[nextAction ?? ""] ?? "Review report"; + }; + + const suggestedRefund = selected?.report?.refund_recommendation?.amount_usd ?? null; + const suggestedCredit = selected?.report?.credit_recommendation?.amount_usd ?? null; + const selectedCaseState = selected?.case_state; + const latestRating = selected?.latest_rating; + const latestRatingLabel = latestRating?.rating === "thumbs_up" + ? "Agent Rating: Thumbs Up" + : latestRating?.rating === "thumbs_down" + ? "Agent Rating: Thumbs Down" + : "Agent Rating: Not rated"; + + const appliedRefund = selected?.actions.find((a) => a.action_type === "apply_refund"); + const appliedCredit = selected?.actions.find((a) => a.action_type === "apply_credit"); + + const parseErrorMessage = async (res: Response): Promise => { + try { + const body = (await res.json()) as { message?: string; error?: string }; + return body.message || body.error || `Request failed (${res.status})`; + } catch { + return `Request failed (${res.status})`; + } + }; + + const showNotice = (nextNotice: Exclude) => { + setNotice(nextNotice); + if (nextNotice.kind === "success") { + toast.success(nextNotice.message); + } else { + toast.error(nextNotice.message); + } + }; + + const refresh = async () => { + setLoading(true); + setError(null); + try { + const offset = (currentPage - 1) * PAGE_SIZE; + const [summaryRes, reqRes] = await Promise.all([ + fetch("/api/support/summary"), + fetch(`/api/support/requests?limit=${PAGE_SIZE}&offset=${offset}`), + ]); + if (!summaryRes.ok || !reqRes.ok) { + throw new Error("Failed to load support data"); + } + const summaryJson = (await summaryRes.json()) as Summary; + const reqJson = (await reqRes.json()) as { items?: RequestItem[]; total?: number }; + setSummary(summaryJson); + setRequests(reqJson.items || []); + setTotalRequests(reqJson.total ?? 0); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + const openDetails = async ( + supportRequestId: string, + options?: { showLoading?: boolean; preserveScroll?: boolean }, + ) => { + const showLoading = options?.showLoading ?? true; + const preserveScroll = options?.preserveScroll ?? false; + const previousScrollTop = preserveScroll ? drawerScrollRef.current?.scrollTop ?? 0 : 0; + setIsDrawerOpen(true); + if (showLoading) { + setDetailsLoading(true); + } + setNotice((prev) => (prev?.supportRequestId === supportRequestId ? prev : null)); + try { + const res = await fetch(`/api/support/requests/${supportRequestId}`); + if (!res.ok) { + throw new Error("Failed to load request details"); + } + const json = (await res.json()) as RequestDetails; + setSelected(json); + setReplyText(json?.report?.draft_response ?? ""); + setRefundAmount( + typeof json?.report?.refund_recommendation?.amount_usd === "number" + ? String(json.report.refund_recommendation.amount_usd) + : "", + ); + setCreditAmount( + typeof json?.report?.credit_recommendation?.amount_usd === "number" + ? String(json.report.credit_recommendation.amount_usd) + : "", + ); + setRatingChoice(json?.latest_rating?.rating === "thumbs_down" ? "thumbs_down" : "thumbs_up"); + setRatingReason(json?.latest_rating?.reason_code ?? ""); + setRatingNotes(json?.latest_rating?.feedback_notes ?? ""); + setError(null); + if (preserveScroll) { + requestAnimationFrame(() => { + if (drawerScrollRef.current) { + drawerScrollRef.current.scrollTop = previousScrollTop; + } + }); + } + return true; + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + return false; + } finally { + if (showLoading) { + setDetailsLoading(false); + } + } + }; + + const totalPages = Math.max(1, Math.ceil(totalRequests / PAGE_SIZE)); + + const applyAction = async (actionType: "apply_refund" | "apply_credit", amount: string) => { + if (!selected) return; + if (!amount || Number.isNaN(Number(amount))) return; + setPendingAction(actionType); + setNotice(null); + try { + const res = await fetch("/api/support/actions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + support_request_id: selected.support_request_id, + order_id: selected.order_id, + user_id: selected.user_id, + action_type: actionType, + amount_usd: Number(amount), + actor: actor || null, + payload: { source: "appkit-ui" }, + }), + }); + if (!res.ok) { + throw new Error(await parseErrorMessage(res)); + } + showNotice({ + kind: "success", + message: actionType === "apply_credit" ? "Credits applied successfully." : "Refund applied successfully.", + supportRequestId: selected.support_request_id, + }); + await openDetails(selected.support_request_id, { showLoading: false, preserveScroll: true }); + await refresh(); + } catch (e) { + showNotice({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } finally { + setPendingAction(null); + } + }; + + const sendReply = async () => { + if (!selected) return; + setPendingAction("send_reply"); + setNotice(null); + try { + const res = await fetch("/api/support/replies", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + support_request_id: selected.support_request_id, + order_id: selected.order_id, + user_id: selected.user_id, + message_text: replyText, + sent_by: actor || null, + }), + }); + if (!res.ok) { + throw new Error(await parseErrorMessage(res)); + } + showNotice({ + kind: "success", + message: "Reply sent successfully.", + supportRequestId: selected.support_request_id, + }); + await openDetails(selected.support_request_id, { showLoading: false, preserveScroll: true }); + await refresh(); + } catch (e) { + showNotice({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } finally { + setPendingAction(null); + } + }; + + const regenerateReport = async () => { + if (!selected) return; + setPendingAction("regenerate"); + setNotice(null); + try { + const res = await fetch("/api/support/regenerate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + support_request_id: selected.support_request_id, + order_id: selected.order_id, + user_id: selected.user_id, + actor: actor || null, + operator_context: operatorContext || null, + current_report: selected.report, + }), + }); + if (!res.ok) { + throw new Error(await parseErrorMessage(res)); + } + const body = (await res.json()) as { warning?: string }; + showNotice({ + kind: "success", + message: body.warning + ? `Report regenerated with fallback. ${body.warning}` + : "Report regenerated with operator context.", + supportRequestId: selected.support_request_id, + }); + await openDetails(selected.support_request_id, { showLoading: false, preserveScroll: true }); + await refresh(); + } catch (e) { + showNotice({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } finally { + setPendingAction(null); + } + }; + + const submitRating = async () => { + if (!selected) return; + setPendingAction("rate_response"); + setNotice(null); + try { + const res = await fetch("/api/support/ratings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + support_request_id: selected.support_request_id, + order_id: selected.order_id, + user_id: selected.user_id, + rating: ratingChoice, + reason_code: ratingReason || null, + feedback_notes: ratingNotes || null, + actor: actor || null, + }), + }); + if (!res.ok) { + throw new Error(await parseErrorMessage(res)); + } + showNotice({ + kind: "success", + message: "Agent response rating saved.", + supportRequestId: selected.support_request_id, + }); + await openDetails(selected.support_request_id, { showLoading: false, preserveScroll: true }); + await refresh(); + } catch (e) { + showNotice({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } finally { + setPendingAction(null); + } + }; + + return ( +
+ +
+
+

Support Console

+

+ Triage support requests, review agent analysis, and take operator actions. +

+
+ +
+ + {error && ( + + Error: {error} + + )} + +
+ + + Requests + + +
{summary?.requests ?? "-"}
+
+
+ + + Actions + + +
{summary?.actions ?? "-"}
+
+
+ + + Replies + + +
{summary?.replies ?? "-"}
+
+
+
+ +
+ + + Support Requests + + + {loading && requests.length === 0 && ( + <> + {Array.from({ length: 4 }).map((_, idx) => ( +
+
+
+
+
+ ))} + + )} + {requests.length === 0 && ( +
+ No support requests yet. +
+ )} + {requests.map((r) => { + const previewSource = + r.report?.draft_response || + r.report?.order_details_summary || + "Support request ready for review."; + const preview = previewSource.replace(/\s+/g, " ").trim(); + return ( +
+
+
+
+ {r.user_display_name ?? "Customer"} +
+ + {statusLabel(r.case_state?.case_status)} + + + Next: {nextActionLabel(r.case_state?.next_action)} + + {r.case_state?.has_reply && Replied} + {r.case_state?.has_refund && Refund Applied} + {r.case_state?.has_credit && Credits Applied} + + Refund: {formatCurrency(r.report?.refund_recommendation?.amount_usd)} + + + Credit: {formatCurrency(r.report?.credit_recommendation?.amount_usd)} + +
+
+ Updated {formatTs(r.case_state?.last_event_at ?? r.ts)} + + Replies {r.case_state?.reply_count ?? 0} + + + Actions {r.case_state?.action_count ?? 0} + + + Re-gens {r.case_state?.regen_count ?? 0} + +
+
{preview}
+
+ +
+ ); + })} +
+
+ Page {currentPage} of {totalPages} +
+
+ + +
+
+ + +
+ + +
+ + Request Details + + Review agent analysis and take operator actions for the selected support case. + + +
+ {notice && ( + + + {notice.message} + + + )} + {detailsLoading &&
Loading request details...
} + {!detailsLoading && !selected && ( +
Select a request from the list.
+ )} + {!detailsLoading && selected && ( + <> +
+
+ {selected.user_display_name ?? "Customer"} + + Updated: {formatTs(selectedCaseState?.last_event_at ?? selected.ts)} + + + Confidence: {selected.report?.decision_confidence ?? "unknown"} + + + {statusLabel(selectedCaseState?.case_status)} + +
+
+
+
History
+
+ + Current Status: {statusLabel(selectedCaseState?.case_status)} + + Next: {nextActionLabel(selectedCaseState?.next_action)} + Last Action: {selectedCaseState?.last_action_type ?? "none"} + {latestRatingLabel} +
+
+ {(selected.timeline ?? []).slice(0, 8).map((event, idx) => ( +
+
{event.event_type.replaceAll("_", " ")}
+
+ {formatTs(event.event_at)}{event.actor ? ` · ${event.actor}` : ""} +
+
+ ))} + {(selected.timeline ?? []).length === 0 && ( +
No activity yet.
+ )} +
+
+ +
+
Support Request & Actions
+
+
Raw Support Message
+
+ {selected.request_text || "Raw support message not available for this request yet."} +
+
+
+
Suggested Agent Response
+