diff --git a/AGENTS.md b/AGENTS.md index af121f0..31c538d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -648,6 +648,17 @@ Use full redeploy instead if: --- +## Support Target Operational Notes (Feb 2026) + +- `Support_Feature_Store` notebook path must be `${workspace.root_path}/stages/support_feature_store` (no `.py` suffix). +- `Support Response Evals Hourly` should remain enabled (`pause_status: UNPAUSED`) unless explicitly paused for incident response. +- The eval notebook in `demos/agent-compare-models/demo_materials/support-response-evals.ipynb` must tolerate output schema drift from `mlflow.genai.evaluate()` and should not fail the job when optional columns are absent. +- `casperskitchens.support.support_request_features` is the expected offline feature table. Verify with: + - `databricks tables exists casperskitchens.support.support_request_features -p DEFAULT` +- Online Table creation is currently blocked by Databricks platform deprecation messaging ("Online Table is being deprecated"). Treat online publish as a follow-up migration to Synced Tables-compatible workflows. + +--- + ## Current Work: Canonical Data Migration **Context**: Migrating from live generator to canonical dataset approach diff --git a/README.md b/README.md index 87a37c6..7f3074f 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,22 @@ Each event includes order ID, sequence number, timestamp, and location context. - **🎨 UX Prototyping**: Fully loaded platform for design iteration - **🎬 Demo Creation**: Unified narrative for new feature demonstrations +## Support Feature Store Notes + +For the support scenario (`-t support` target), feature serving is split from app OLTP storage: + +- App runtime tables (actions, replies, status) stay on Lakebase Autoscaling (v2). +- Features are materialized into the offline UC table `casperskitchens.support.support_request_features`. +- Current support features include deterministic signals (`repeat_complaints_30d`, `policy_limit_usd`) and a risk score currently computed via deterministic fallback logic in the stage. +- The support initializer now includes a dedicated `Support_Feature_Store` task and depends on it before `Support_Lakebase`. + +### Current Platform Behavior (Feb 2026) + +- `Support Response Evals Hourly` has been fixed and is now scheduled (`UNPAUSED`). +- The eval notebook was hardened to handle schema variations in `mlflow.genai.evaluate()` output without failing. +- Legacy Online Table creation is currently blocked by Databricks platform behavior ("Online Table is being deprecated"), so online publish is intentionally deferred. +- Next migration step is to map feature serving exposure to Synced Tables-compatible patterns. + ## Check out the [Casper's Kitchens Blog](https://databricks-solutions.github.io/caspers-kitchens/)! ## License diff --git a/apps/refund-manager/app.yaml b/apps/refund-manager/app.yaml deleted file mode 100644 index b60875e..0000000 --- a/apps/refund-manager/app.yaml +++ /dev/null @@ -1,8 +0,0 @@ -command: - - uvicorn - - app.main:app -env: - - name: DATABRICKS_WAREHOUSE_ID - value: 'e1a2914f603fc607' - - name: DATABRICKS_CATALOG - value: 'cazzper' diff --git a/apps/refund-manager/app/__init__.py b/apps/refund-manager/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/refund-manager/app/databricks_events.py b/apps/refund-manager/app/databricks_events.py deleted file mode 100644 index 05d409c..0000000 --- a/apps/refund-manager/app/databricks_events.py +++ /dev/null @@ -1,76 +0,0 @@ -# app/databricks_events.py -import os, json -from typing import List, Dict, Any -from databricks.sdk import WorkspaceClient -from databricks.sdk.service.sql import ( - ExecuteStatementRequestOnWaitTimeout, - StatementParameterListItem, - Disposition, - Format, -) - -_w = WorkspaceClient() - -WAREHOUSE_ID = os.getenv("DATABRICKS_WAREHOUSE_ID", "") -CATALOG = os.getenv("DATABRICKS_CATALOG", "") -SCHEMA = os.getenv("DATABRICKS_SCHEMA", "lakeflow") - -_COLS = [ - "body", "event_id", "event_type", "location_id", - "order_id", "sequence", "ts" -] - -def _state_name(resp) -> str | None: - st = getattr(resp, "status", None) - state = getattr(st, "state", None) - if state is None: - return None - # Enum-safe β†’ "SUCCEEDED" - name = getattr(state, "name", str(state)) - if "." in name: # e.g. "StatementState.SUCCEEDED" - name = name.split(".")[-1] - return name - -def fetch_order_events(order_id: str) -> List[Dict[str, Any]]: - stmt = f""" - SELECT body, event_id, event_type, location_id, order_id, sequence, ts - FROM {CATALOG}.{SCHEMA}.all_events - WHERE order_id = :oid - AND event_type <> 'driver_ping' - ORDER BY sequence ASC - """.strip() - - params = [StatementParameterListItem(name="oid", value=order_id)] - - resp = _w.statement_execution.execute_statement( - warehouse_id=WAREHOUSE_ID, - catalog=CATALOG, - schema=SCHEMA, - statement=stmt, - parameters=params, - wait_timeout="30s", - on_wait_timeout=ExecuteStatementRequestOnWaitTimeout.CONTINUE, - disposition=Disposition.INLINE, # ensure rows returned inline - format=Format.JSON_ARRAY, # ensure result.data_array - ) - - state = _state_name(resp) - if state and state not in {"SUCCEEDED", "SUCCESS", "COMPLETED"}: - msg = getattr(getattr(resp.status, "error", None), "message", "unknown error") - raise RuntimeError(f"Statement failed: state={state} message={msg}") - - data = resp.result.data_array if resp.result and resp.result.data_array else [] - - out: List[Dict[str, Any]] = [] - for row in data: - d = { _COLS[i]: row[i] for i in range(min(len(_COLS), len(row))) } - v = d.get("body") - if isinstance(v, str): - s = v.strip() - if s.startswith("{") and s.endswith("}"): - try: - d["body"] = json.loads(s) - except Exception: - pass - out.append(d) - return out diff --git a/apps/refund-manager/app/db.py b/apps/refund-manager/app/db.py deleted file mode 100644 index 93b5064..0000000 --- a/apps/refund-manager/app/db.py +++ /dev/null @@ -1,24 +0,0 @@ -# app/db.py -import os -from sqlalchemy import create_engine, event -from databricks.sdk.core import Config -from databricks.sdk import WorkspaceClient - -_cfg = Config() -_w = WorkspaceClient() # caches & refreshes tokens - -PGHOST = os.environ["PGHOST"] -PGPORT = os.environ.get("PGPORT", "5432") -PGDATABASE = os.environ["PGDATABASE"] -PGSSLMODE = os.environ.get("PGSSLMODE", "require") -# default to app client_id unless PGUSER is provided explicitly -PGUSER = os.environ.get("PGUSER", _cfg.client_id) - -DSN = f"postgresql+psycopg://{PGUSER}:@{PGHOST}:{PGPORT}/{PGDATABASE}?sslmode={PGSSLMODE}" - -engine = create_engine(DSN, future=True, pool_pre_ping=True) - -@event.listens_for(engine, "do_connect") -def _provide_token(dialect, conn_rec, cargs, cparams): - # Pass the app OAuth token as the password - cparams["password"] = _w.config.oauth_token().access_token diff --git a/apps/refund-manager/app/main.py b/apps/refund-manager/app/main.py deleted file mode 100644 index 03b8c1b..0000000 --- a/apps/refund-manager/app/main.py +++ /dev/null @@ -1,262 +0,0 @@ -# app/main.py -from pathlib import Path -from typing import Dict, Any, List -import os, json, traceback, logging -from collections import Counter - -from fastapi import FastAPI, HTTPException -from fastapi.responses import FileResponse, JSONResponse -from sqlalchemy import text, bindparam - -from .db import engine -from .models import RefundDecisionCreate, parse_agent_response, ERROR_SUGGESTION -from .databricks_events import fetch_order_events - -DEBUG = os.getenv("DEBUG") in ("1", "true", "TRUE", "yes", "on") -log = logging.getLogger("refund_manager") - -app = FastAPI(title="Refund Manager", version="2.0.0") - -# ─── Configurable schemas ───────────────────────────────────────────────────── -REFUNDS_SCHEMA = os.environ.get("REFUNDS_SCHEMA", "refunds") -RECS_SCHEMA = os.environ.get("RECS_SCHEMA", "recommender") - -def _qi(name: str) -> str: - return '"' + name.replace('"', '""') + '"' - -REFUNDS_TABLE = f"{_qi(REFUNDS_SCHEMA)}.refund_decisions" -RECS_TABLE = f"{_qi(RECS_SCHEMA)}.pg_recommendations" - -# ─── Startup: ensure refunds table ──────────────────────────────────────────── -DDL = f""" -CREATE SCHEMA IF NOT EXISTS {_qi(REFUNDS_SCHEMA)}; - -CREATE TABLE IF NOT EXISTS {REFUNDS_TABLE} ( - id BIGSERIAL PRIMARY KEY, - order_id TEXT NOT NULL, - decided_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), - amount_usd NUMERIC(10,2) NOT NULL CHECK (amount_usd >= 0), - refund_class TEXT NOT NULL CHECK (refund_class IN ('none','partial','full')), - reason TEXT NOT NULL, - decided_by TEXT, - source_suggestion JSONB -); -CREATE INDEX IF NOT EXISTS idx_refund_decisions_order_id ON {REFUNDS_TABLE}(order_id); -""" - -@app.on_event("startup") -def _startup(): - with engine.begin() as conn: - conn.exec_driver_sql(DDL) - -# ─── Static SPA ─────────────────────────────────────────────────────────────── -@app.get("/") -def index(): - path = Path(__file__).parent.parent / "index.html" - if not path.exists(): - raise HTTPException(status_code=404, detail="index.html not found") - return FileResponse(str(path)) - -# ─── Summary (robust to bad JSON) ──────────────────────────────────────────── -@app.get("/api/summary") -def summary(include_zero: bool = False): - suggestions_by_class = Counter() - suggested_total = 0.0 - filtered_count = 0 - - with engine.connect() as conn: - total = conn.exec_driver_sql(f"SELECT COUNT(*) FROM {RECS_TABLE}").scalar_one() - - # Pull raw strings and parse safely - rows = conn.execute(text(f""" - SELECT agent_response - FROM {RECS_TABLE} - WHERE agent_response IS NOT NULL - """)).fetchall() - - for (raw,) in rows: - sug = parse_agent_response(raw) - cls = sug.get("refund_class", "error") - refund_usd = sug.get("refund_usd", 0) - - # Filter out zero-dollar recommendations unless include_zero is True - if not include_zero and refund_usd == 0: - continue - - filtered_count += 1 - suggestions_by_class[cls] += 1 - if cls != "error": - try: - suggested_total += float(refund_usd or 0) - except Exception: - pass - - # Decisions summary (always show all decisions) - decisions_total = conn.exec_driver_sql(f"SELECT COUNT(*) FROM {REFUNDS_TABLE}").scalar_one() - decided_total_usd = conn.exec_driver_sql(f"SELECT COALESCE(SUM(amount_usd),0) FROM {REFUNDS_TABLE}").scalar_one() - dec_by_class_rows = conn.execute(text(f""" - SELECT refund_class, COUNT(*) AS c - FROM {REFUNDS_TABLE} - GROUP BY refund_class - """)).mappings().all() - decisions_by_class = {r["refund_class"]: r["c"] for r in dec_by_class_rows} - - return { - "recommendations_count": filtered_count, - "total_recommendations": total, - "suggestions_by_class": dict(suggestions_by_class), - "suggested_total_usd": round(suggested_total, 2), - "decisions_count": decisions_total, - "decisions_by_class": decisions_by_class, - "decided_total_usd": float(decided_total_usd or 0), - "pending_count": max(filtered_count - decisions_total, 0), - } - -# ─── Recommendations list (robust suggestions) ─────────────────────────────── -@app.get("/api/recommendations") -def list_recommendations(limit: int = 50, offset: int = 0, include_zero: bool = False): - with engine.connect() as conn: - # Fetch a larger batch to account for filtering - # We fetch enough to ensure we can fill the page after filtering - # Using a multiplier to handle the case where many rows are filtered out - fetch_limit = 1000 if not include_zero else limit + offset + 100 - - all_recs = conn.execute( - text(f""" - SELECT order_id, ts, order_ts, agent_response - FROM {RECS_TABLE} - ORDER BY order_ts DESC, order_id DESC - LIMIT :fetch_limit - """), - {"fetch_limit": fetch_limit}, - ).mappings().all() - - # Parse and filter recommendations - filtered_recs = [] - for r in all_recs: - sug = parse_agent_response(r["agent_response"]) - refund_usd = sug.get("refund_usd", 0) - - # Skip zero-dollar recommendations unless include_zero is True - if not include_zero and refund_usd == 0: - continue - - filtered_recs.append({ - "order_id": r["order_id"], - "ts": r["ts"], - "order_ts": r["order_ts"], - "agent_response": r["agent_response"], - "suggestion": sug, - }) - - # Apply pagination to filtered results - total_filtered = len(filtered_recs) - paginated_recs = filtered_recs[offset:offset + limit] - - # Fetch decisions for the paginated results - dec_map: Dict[str, Any] = {} - if paginated_recs: - order_ids = [r["order_id"] for r in paginated_recs] - decs = conn.execute( - text(f""" - SELECT DISTINCT ON (order_id) - order_id, id, decided_ts, amount_usd, refund_class, reason, decided_by - FROM {REFUNDS_TABLE} - WHERE order_id IN :ids - ORDER BY order_id, decided_ts DESC - """).bindparams(bindparam("ids", expanding=True)), - {"ids": tuple(order_ids)}, - ).mappings().all() - dec_map = {d["order_id"]: d for d in decs} - - items: List[Dict[str, Any]] = [] - for r in paginated_recs: - items.append({ - "order_id": r["order_id"], - "ts": r["ts"], - "order_ts": r["order_ts"], - "suggestion": r["suggestion"], # will be ERROR_SUGGESTION for bad rows - "decision": dec_map.get(r["order_id"]) or None, - "status": "applied" if r["order_id"] in dec_map else "pending", - }) - - return { - "items": items, - "limit": limit, - "offset": offset, - "total": total_filtered, - "has_more": offset + limit < total_filtered, - } - -# ─── Apply refund ──────────────────────────────────────────────────────────── -@app.post("/api/refunds") -def apply_refund(body: RefundDecisionCreate): - with engine.begin() as conn: - # latest suggestion (robust parse) - sug_row = conn.execute( - text(f""" - SELECT agent_response - FROM "{RECS_SCHEMA}".pg_recommendations - WHERE order_id = :oid - ORDER BY ts DESC, order_ts DESC - LIMIT 1 - """), - {"oid": body.order_id}, - ).mappings().first() - - from .models import parse_agent_response, ERROR_SUGGESTION # if not already imported at top - source_suggestion = parse_agent_response(sug_row["agent_response"]) if sug_row else dict(ERROR_SUGGESTION) - - row = conn.execute( - text(f""" - INSERT INTO "{REFUNDS_SCHEMA}".refund_decisions - (order_id, amount_usd, refund_class, reason, decided_by, source_suggestion) - VALUES - (:order_id, :amount_usd, :refund_class, :reason, :decided_by, CAST(:source_suggestion AS JSONB)) - RETURNING id, decided_ts - """), - { - "order_id": body.order_id, - "amount_usd": body.amount_usd, - "refund_class": body.refund_class, - "reason": body.reason, - "decided_by": body.decided_by, - "source_suggestion": json.dumps(source_suggestion), - }, - ).mappings().first() - - return JSONResponse({ - "id": row["id"], - "order_id": body.order_id, - "decided_ts": str(row["decided_ts"]), - "amount_usd": body.amount_usd, - "refund_class": body.refund_class, - "reason": body.reason, - "decided_by": body.decided_by, - }, status_code=201) - -# ─── Order events (Databricks SQL) ─────────────────────────────────────────── -@app.get("/api/orders/{order_id}/events") -def order_events(order_id: str, debug: int = 0): - try: - events = fetch_order_events(order_id) - return {"order_id": order_id, "events": events} - except Exception as e: - log.exception("order_events failed for %s", order_id) - if DEBUG or debug: - return JSONResponse( - status_code=500, - content={ - "error": "databricks_statement_failed", - "message": str(e), - "traceback": traceback.format_exc(), - }, - ) - raise HTTPException(status_code=500, detail="Databricks SQL query failed") - -# ─── Health ────────────────────────────────────────────────────────────────── -@app.get("/healthz") -def healthz(): - with engine.connect() as conn: - conn.exec_driver_sql("SELECT 1") - return {"ok": True} diff --git a/apps/refund-manager/app/models.py b/apps/refund-manager/app/models.py deleted file mode 100644 index 3435022..0000000 --- a/apps/refund-manager/app/models.py +++ /dev/null @@ -1,80 +0,0 @@ -# app/models.py -from typing import Any, Dict, Optional -from pydantic import BaseModel, Field, validator -import json -import math - -ERROR_SUGGESTION: Dict[str, Any] = { - "refund_usd": 0.0, - "refund_class": "error", - "reason": "agent did not return valid JSON", -} - -ALLOWED_CLASSES = {"none", "partial", "full"} - -class RefundDecisionCreate(BaseModel): - order_id: str - amount_usd: float = Field(ge=0) - refund_class: str - reason: str - decided_by: Optional[str] = None - - @validator("refund_class") - def _class_ok(cls, v): - if v not in {"none", "partial", "full"}: - raise ValueError("refund_class must be one of {'none','partial','full'}") - return v - -def _coerce_number(x: Any) -> Optional[float]: - try: - f = float(x) - return f if math.isfinite(f) else None - except Exception: - return None - -def parse_agent_response(raw: Optional[str]) -> Dict[str, Any]: - """ - Robustly parse/validate agent_response. - - Accepts only JSON objects with the expected keys. - - If invalid/malformed/missing keys or types => ERROR_SUGGESTION. - - Tries a simple 'trim-to-last-}' recovery for trailing junk. - """ - if not raw or not isinstance(raw, str): - return dict(ERROR_SUGGESTION) - - s = raw.strip() - # 1) try direct - obj = None - try: - obj = json.loads(s) - except Exception: - # 2) quick recovery if there is trailing junk after the last } - if "}" in s: - try: - obj = json.loads(s[: s.rfind("}") + 1]) - except Exception: - obj = None - - if not isinstance(obj, dict): - return dict(ERROR_SUGGESTION) - - # Validate fields - cls = str(obj.get("refund_class", "")).lower() - usd = _coerce_number(obj.get("refund_usd")) - reason = obj.get("reason") - - if cls not in ALLOWED_CLASSES: - # we mark invalid as error - return dict(ERROR_SUGGESTION) - - if usd is None or usd < 0: - # repair to 0 but keep class; however spec says on error use "error" - # since JSON was malformed or types wrong, return error suggestion - return dict(ERROR_SUGGESTION) - - # reason can be missing/empty; it's just a suggestion field, keep if present - if not isinstance(reason, str): - reason = "" - - # Valid suggestion - return {"refund_usd": float(usd), "refund_class": cls, "reason": reason} diff --git a/apps/refund-manager/index.html b/apps/refund-manager/index.html deleted file mode 100644 index 59da43b..0000000 --- a/apps/refund-manager/index.html +++ /dev/null @@ -1,520 +0,0 @@ - - - - - Refund Manager - - - - - - - - - - - -
-
-
-
-
- - - -
-
-

Refund Manager

- -
-
-
-
-
- -
- - -
-
-
Recommendations
-
β€”
-
β€”
-
-
-
Suggested Total
-
β€”
-
USD
-
-
-
Decisions Made
-
β€”
-
β€”
-
-
-
Pending Review
-
β€”
-
Awaiting action
-
-
- - -
-
-
-
-

Orders & Suggestions

-

Review and apply refund recommendations

-
-
- - - - pg_recommendations -
-
- -
- - - -
-
-
- - - - - - - - - - - - -
Order IDSuggested RefundReasonStatusDecisionActions
-
-
-
Latest recommendations shown first
-
- - -
-
-
-
- - - - - - - - - - diff --git a/apps/refund-manager/requirements.txt b/apps/refund-manager/requirements.txt deleted file mode 100644 index 19ef92e..0000000 --- a/apps/refund-manager/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -fastapi -uvicorn[standard] -sqlalchemy -psycopg[binary] -databricks-sdk -databricks-sql-connector 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/CLAUDE.md b/apps/supportconsolek/supportconsolek/CLAUDE.md new file mode 100644 index 0000000..69d5b13 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/CLAUDE.md @@ -0,0 +1,10 @@ +# AI Assistant Instructions + + +## Databricks AppKit + +This project uses Databricks AppKit packages. For AI assistant guidance on using these packages, refer to: + +- **@databricks/appkit** (Backend SDK): [./node_modules/@databricks/appkit/CLAUDE.md](./node_modules/@databricks/appkit/CLAUDE.md) +- **@databricks/appkit-ui** (UI Integration, Charts, Tables, SSE, and more.): [./node_modules/@databricks/appkit-ui/CLAUDE.md](./node_modules/@databricks/appkit-ui/CLAUDE.md) + diff --git a/apps/supportconsolek/supportconsolek/README.md b/apps/supportconsolek/supportconsolek/README.md new file mode 100644 index 0000000..faa012b --- /dev/null +++ b/apps/supportconsolek/supportconsolek/README.md @@ -0,0 +1,189 @@ +# Minimal Databricks App + +A minimal Databricks App powered by Databricks AppKit, featuring React, TypeScript, tRPC, and Tailwind CSS. + +## Prerequisites + +- Node.js 18+ and npm +- Databricks CLI (for deployment) +- Access to a Databricks workspace + +## Databricks Authentication + +### Local Development + +For local development, configure your environment variables by creating a `.env` file: + +```bash +cp env.example .env +``` + +Edit `.env` and set the following: + +```env +DATABRICKS_HOST=https://your-workspace.cloud.databricks.com +DATABRICKS_WAREHOUSE_ID=your-warehouse-id +DATABRICKS_APP_PORT=8000 +``` + +### CLI Authentication + +The Databricks CLI requires authentication to deploy and manage apps. Configure authentication using one of these methods: + +#### OAuth U2M + +Interactive browser-based authentication with short-lived tokens: + +```bash +databricks auth login --host https://your-workspace.cloud.databricks.com +``` + +This will open your browser to complete authentication. The CLI saves credentials to `~/.databrickscfg`. + +#### Configuration Profiles + +Use multiple profiles for different workspaces: + +```ini +[DEFAULT] +host = https://dev-workspace.cloud.databricks.com + +[production] +host = https://prod-workspace.cloud.databricks.com +client_id = prod-client-id +client_secret = prod-client-secret +``` + +Deploy using a specific profile: + +```bash +databricks bundle deploy -t prod --profile production +``` + +**Note:** Personal Access Tokens (PATs) are legacy authentication. OAuth is strongly recommended for better security. + +## Getting Started + +### Install Dependencies + +```bash +npm install +``` + +### Development + +Run the app in development mode with hot reload: + +```bash +npm run dev +``` + +The app will be available at the URL shown in the console output. + +### Build + +Build both client and server for production: + +```bash +npm run build +``` + +This creates: + +- `dist/server/` - Compiled server code +- `client/dist/` - Bundled client assets + +### Production + +Run the production build: + +```bash +npm start +``` + +## Code Quality + +```bash +# Type checking +npm run typecheck + +# Linting +npm run lint +npm run lint:fix + +# Formatting +npm run format +npm run format:fix +``` + +## Deployment with Databricks Asset Bundles + +### 1. Configure Bundle + +Update `databricks.yml` with your workspace settings: + +```yaml +targets: + dev: + workspace: + host: https://your-workspace.cloud.databricks.com + variables: + warehouse_id: your-warehouse-id +``` + +### 2. Validate Bundle + +```bash +databricks bundle validate +``` + +### 3. Deploy + +Deploy to the development target: + +```bash +databricks bundle deploy -t dev +``` + +### 4. Run + +Start the deployed app: + +```bash +databricks bundle run -t dev +``` + +## Support Console Runtime Notes + +- The server uses the AppKit Lakebase plugin (`lakebase()`) and `createLakebasePool()` from `@databricks/appkit` for connection management. +- Ensure the app service principal has schema/table ownership and grants on Lakebase schemas used by the app (`public` and `support` in this project). +- Support evals are run as a separate Databricks job (`Support Response Evals Hourly`) and are not required for app startup. + +### Deploy to Production + +1. Configure the production target in `databricks.yml` +2. Deploy to production: + +```bash +databricks bundle deploy -t prod +``` + +## Project Structure + +``` +* client/ # React frontend + * src/ # Source code + * public/ # Static assets +* server/ # Express backend + * server.ts # Server entry point + * trpc.ts # tRPC router +* shared/ # Shared types +* databricks.yml # Bundle configuration +``` + +## Tech Stack + +- **Frontend**: React 19, TypeScript, Vite, Tailwind CSS +- **Backend**: Node.js, Express, tRPC +- **UI Components**: Radix UI, shadcn/ui +- **Databricks**: App Kit SDK, Analytics SDK 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 0000000..32053bd Binary files /dev/null and b/apps/supportconsolek/supportconsolek/client/public/apple-touch-icon.png differ 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 0000000..d7c16eb Binary files /dev/null and b/apps/supportconsolek/supportconsolek/client/public/favicon-16x16.png differ diff --git a/apps/supportconsolek/supportconsolek/client/public/favicon-192x192.png b/apps/supportconsolek/supportconsolek/client/public/favicon-192x192.png new file mode 100644 index 0000000..8b4f18d Binary files /dev/null and b/apps/supportconsolek/supportconsolek/client/public/favicon-192x192.png differ diff --git a/apps/supportconsolek/supportconsolek/client/public/favicon-32x32.png b/apps/supportconsolek/supportconsolek/client/public/favicon-32x32.png new file mode 100644 index 0000000..46aa684 Binary files /dev/null and b/apps/supportconsolek/supportconsolek/client/public/favicon-32x32.png differ diff --git a/apps/supportconsolek/supportconsolek/client/public/favicon-48x48.png b/apps/supportconsolek/supportconsolek/client/public/favicon-48x48.png new file mode 100644 index 0000000..d2f89fb Binary files /dev/null and b/apps/supportconsolek/supportconsolek/client/public/favicon-48x48.png differ 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 0000000..14d7d20 Binary files /dev/null and b/apps/supportconsolek/supportconsolek/client/public/favicon-512x512.png differ 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
+