Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions enterprise-usage-analyzer/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
.env*

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

# Slack fixtures contain real internal channel content; never commit them.
/data/slack-fixtures.json
/data/raw-mcp/
5 changes: 5 additions & 0 deletions enterprise-usage-analyzer/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
1 change: 1 addition & 0 deletions enterprise-usage-analyzer/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
173 changes: 173 additions & 0 deletions enterprise-usage-analyzer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# LTX Studio Enterprise Client Usage Analyzer

Internal analytics tool for CSM / Sales / leadership reviewing enterprise client
usage on LTX Studio. Local-only.

## Stack

- Next.js 16 (App Router, TypeScript)
- BigQuery via `@google-cloud/bigquery` (server-side route handlers only)
- Anthropic Claude (`claude-sonnet-4-5`) for qualitative insights + chat
- **Slack Web API** (optional third source — internal client chatter folded into insights)
- Tailwind v4, shadcn/ui (manual install, no runtime registry calls), Recharts
- TanStack Query for client data fetching, Zustand for selection state
- pnpm

## Privacy boundary

Data leaves your machine to exactly two destinations:
1. **Google BigQuery** (`ltx-dwh-prod-processed`) — your own GCP data
2. **Anthropic API** — for qualitative summaries and chat

Next.js telemetry is disabled. No analytics, no Sentry, no third-party fonts.

## Setup

```bash
# 1. Auth to BigQuery via your gcloud user
gcloud auth application-default login

# 2. Configure env (already done if you used the bootstrapped .env.local)
cp .env.example .env.local
# fill in ANTHROPIC_API_KEY

# 3. Install deps
pnpm install

# 4. Smoke-test the data layer (validates McCann_Paris numbers vs screenshot)
pnpm run smoke

# 5. Run the dev server (once UI is in place)
pnpm dev
```

## Data layer

Each query lives in `src/lib/bigquery/queries/` and is fully parameterized
(no string-interpolated user input). Org name is normalized via the SQL
fragment in `src/lib/bigquery/normalize.ts`:

```sql
CASE
WHEN organization_name = 'McCann_NY' THEN 'McCann_NY'
WHEN organization_name LIKE '%McCann%' THEN 'McCann_Paris'
WHEN organization_name = 'Bent Image Lab' THEN 'Bent Image Labs'
WHEN organization_name IN ('EōS Fitness', 'EOS Fitness') THEN 'EOS Fitness'
ELSE organization_name
END
```

The actions table only carries the parent-tenant enterprise name, so
client filtering on actions goes through an `lt_id` join from the users
table (see `clientLtIdsCte()` in `normalize.ts`). The calls table uses
`EXISTS` over `UNNEST(organization_names)` with the same normalization.

Model name pivot mapping is in `src/lib/model-mapping.ts`.

## Slack data source (optional)

Slack is a third evidence source for the insights pipeline and chat panel,
alongside BigQuery usage data and Gong call transcripts. When configured, the
LLM fuses Slack chatter into every section of the briefing, with verbatim
quotes (same trust-layer treatment as call transcripts).

**Channel convention**

| Channel | Behaviour |
|---|---|
| `#ltx-studio-enterprise` (main, configurable) | Pulled for every client; messages substring-filtered against the client name |
| `#customer-<slug>` (per client) | Pulled in full; every message in the channel is treated as about that client. Slug = lowercase + non-alphanumerics → hyphens. `Bent Image Labs` → `customer-bent-image-labs`, `McCann_Paris` → `customer-mccann-paris`, `Meta` → `customer-meta` |

The user/bot token must already be a member of the channels you want to read
— no auto-invite. Thread replies are expanded for any matching parent.

**Setup**

```bash
# In .env.local
SLACK_USER_TOKEN=xoxp-... # user OAuth token (required)
SLACK_MAIN_CHANNEL=ltx-studio-enterprise # optional override
SLACK_CLIENT_CHANNEL_PREFIX=customer- # optional override
```

Required user-token scopes: `channels:read`, `channels:history`, `groups:read`,
`groups:history`, `users:read`, `team:read`.

**Demo mode (fixtures — no Slack token required)**

If you don't have a `SLACK_USER_TOKEN` yet (e.g. while waiting on workspace
admin approval) you can still demo the full Slack-aware insights and chat by
pointing the analyzer at a local JSON file:

```bash
# In .env.local
SLACK_FIXTURES_PATH=./data/slack-fixtures.json
SLACK_MAIN_CHANNEL=ltx-studio-enterprise
SLACK_CLIENT_CHANNEL_PREFIX=customer-
```

When `SLACK_FIXTURES_PATH` is set, `fetchRelevantSlackMessages()` reads
channel snapshots from that file instead of calling `slack.com/api`.
Everything downstream — insights citations, slack_quotes blocks, chat
`query_slack` tool, UI pills — behaves identically to live mode.

The fixture file itself (`data/slack-fixtures.json`) is **gitignored**
because it contains real internal channel content (deal sizes, named
contacts, legal back-and-forth). It is not committed to this repo. To get
a copy for the demo, ping the PR author on Slack and they'll DM you the
file. Drop it into `enterprise-usage-analyzer/data/slack-fixtures.json`
and start the dev server — that's it.

To build a fresh fixture from raw Slack MCP / Web API dumps:

```bash
# Put one .txt per channel under data/raw-mcp/ (filename = channel name,
# e.g. customer-meta.txt, ltx-studio-enterprise.txt). Each file holds the
# "detailed"-format response from the Slack MCP / API.
pnpm tsx scripts/build-slack-fixtures.ts
# → data/slack-fixtures.json
```

Both `data/slack-fixtures.json` and `data/raw-mcp/` are gitignored.

**Graceful fallback**

When **neither** `SLACK_USER_TOKEN` nor `SLACK_FIXTURES_PATH` is set, the
Slack data source is silently disabled — no UI clutter, insights fall back
to usage + calls only, and the `query_slack` chat tool returns
`not_configured: true` so the LLM can tell the user honestly that Slack
isn't connected rather than claiming "no chatter found".

**Citations**

Insights cite Slack with `slack:<channel-name>:<ts>` ids. Each cited
message produces a verbatim purple-bordered quote in the UI, mirroring the
amber transcript quotes. Citation ids are stable across runs.

## Project layout

```
src/
lib/
bigquery/
client.ts - BigQuery client wrapper
normalize.ts - org-name CASE + lt_id CTE helper
queries/
clients.ts - dropdown list
kpis.ts - 4 KPI tiles (+derived)
time-series.ts - 4 chart series with day/week/month
users.ts - user roster table
per-user-per-model.ts - pivot
calls.ts - call transcripts
slack/
client.ts - Slack Web API wrapper, 429 retry
channels.ts - channel discovery + name→id resolver
users.ts - lazy user-id → display-name resolver
normalize.ts - client name → channel slug
fetch.ts - hybrid resolver (customer channel + main mentions)
cache.ts - 60s TTL cache to avoid repeat hits
types.ts - SlackMessage, SlackChannel, citation id type
model-mapping.ts - model_short_name -> display column
scripts/
smoke-test.ts - validates McCann_Paris vs screenshot
```
18 changes: 18 additions & 0 deletions enterprise-usage-analyzer/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";

const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);

export default eslintConfig;
7 changes: 7 additions & 0 deletions enterprise-usage-analyzer/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
};

export default nextConfig;
44 changes: 44 additions & 0 deletions enterprise-usage-analyzer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "enterprise-usage-analyzer",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"smoke": "node --env-file=.env.local --import tsx scripts/smoke-test.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.95.1",
"@google-cloud/bigquery": "^8.3.0",
"@tanstack/react-query": "^5.100.9",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^1.14.0",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"zod": "^4.4.3",
"zustand": "^5.0.13"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20.19.40",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
}
}
Loading