Saibalaji Namburi commited on
Commit
85bb85c
Β·
1 Parent(s): b19b35f

feat: integrate MLflow model registry, DagsHub metrics tracking, and local Grafana observability stack

Browse files
.github/workflows/train.yml CHANGED
@@ -39,6 +39,10 @@ jobs:
39
  uses: iterative/setup-cml@v2
40
 
41
  - name: Train model and write metrics
 
 
 
 
42
  run: |
43
  python src/ml/train_churn.py
44
 
 
39
  uses: iterative/setup-cml@v2
40
 
41
  - name: Train model and write metrics
42
+ env:
43
+ MLFLOW_TRACKING_URI: https://dagshub.com/saibalajinamburi/CustomerCore.mlflow
44
+ MLFLOW_TRACKING_USERNAME: saibalajinamburi
45
+ MLFLOW_TRACKING_PASSWORD: ${{ secrets.DAGSHUB_TOKEN }}
46
  run: |
47
  python src/ml/train_churn.py
48
 
.gitignore CHANGED
@@ -52,4 +52,7 @@ coverage.xml
52
 
53
  # Personal study docs β€” local only, not for GitHub
54
  BUILD_GUIDE.md
55
- CustomerCore_Progress_Summary.md
 
 
 
 
52
 
53
  # Personal study docs β€” local only, not for GitHub
54
  BUILD_GUIDE.md
55
+ CustomerCore_Progress_Summary.md
56
+
57
+ # Scratch files
58
+ scratch/
README.md CHANGED
@@ -16,7 +16,7 @@ Real-time multi-tenant customer intelligence powered by streaming (Redpanda), la
16
  [![Tests](https://img.shields.io/badge/tests-passing-brightgreen)](https://github.com/saibalajinamburi/CustomerCore/actions)
17
  [![License](https://img.shields.io/badge/license-MIT-green)](https://opensource.org/licenses/MIT)
18
  [![EU AI Act](https://img.shields.io/badge/EU%20AI%20Act-Compliant-blue)](#responsible-ai)
19
- [![HF Spaces](https://img.shields.io/badge/HF%20Spaces-Live-orange)](https://huggingface.co/spaces/saibalajinamburi/customercore-api)
20
 
21
  CustomerCore is an enterprise-grade customer intelligence system designed to automate ticket classification, prioritize issues, calculate churn and SLA breach risks, recall agent memories, retrieve grounded facts, and run automated human-in-the-loop triage flows.
22
 
@@ -26,10 +26,12 @@ CustomerCore is an enterprise-grade customer intelligence system designed to aut
26
 
27
  | Asset | Link | Status |
28
  |---|---|---|
29
- | **Live API (HF Spaces)** | [customercore-api.hf.space](https://huggingface.co/spaces/saibalajinamburi/customercore-api/health) | ![uptime](https://img.shields.io/badge/Uptime-100%25-brightgreen) |
30
- | **DagsHub Repo** | [dagshub.com/saibalajinamburi/CustomerCore](https://dagshub.com/saibalajinamburi/CustomerCore) | Connected |
31
- | **MLflow Experiments** | [dagshub.com/saibalajinamburi/CustomerCore/mlflow](https://dagshub.com/saibalajinamburi/CustomerCore/mlflow) | 8 model experiments tracked |
32
- | **CI Pipeline** | [github.com/saibalajinamburi/CustomerCore/actions](https://github.com/saibalajinamburi/CustomerCore/actions) | 10-stage GHA pipeline |
 
 
33
 
34
  ---
35
 
@@ -41,7 +43,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the full system diagram and descripti
41
  1. **Streaming Data Flow:** Kafka-compatible Redpanda topics (`tickets`, `product`, `billing`, `incidents`) ingest event micro-batches.
42
  2. **Spark Lakehouse:** PySpark Structured Streaming processes raw data from Bronze to Silver layers with real-time PII masking and schema validation, writing to Apache Iceberg format.
43
  3. **dbt Gold Layer:** Transforms silver tables into 7 curated business marts for analytics, features, and model training.
44
- 4. **LangGraph Triage:** A multi-agent supervisor orchestrates six specialized sub-agents (Classify, Memory, RAG, Churn, Incident, and HITL) to yield a Pydantic-validated 14-field triage output in `<2s`.
45
 
46
  ---
47
 
@@ -52,7 +54,7 @@ When a support ticket is received at `/api/v1/triage`, the LangGraph supervisor
52
  1. **Classify Agent:** Determines ticket category and initial priority.
53
  2. **Memory Agent:** Recalls customer profile and past session memories from Mem0 (Supabase pgvector).
54
  3. **RAG Agent:** Injects context using hybrid retrieval (Dense + BM25 sparse) from the Knowledge Base and product documentation, reranked by a cross-encoder model.
55
- 4. **Churn Agent:** Evaluates churn risk using a LightGBM classifier.
56
  5. **Incident Agent:** Inspects incident patterns and flags SLA-breach risks.
57
  6. **HITL Agent:** Interrupts the graph and pauses execution if safety flags are violated or if classifier confidence falls below `0.65`, routing the ticket to the Supabase-backed human-in-the-loop review queue.
58
  7. **Finalize & Log:** Consolidates state, applies PII scrubbing, logs the audit trail, and pushes a structured trace to Langfuse.
@@ -64,19 +66,18 @@ When a support ticket is received at `/api/v1/triage`, the LangGraph supervisor
64
  | Layer | Technology |
65
  |---|---|
66
  | **Streaming Backbone** | Redpanda (Kafka-compatible event broker) |
67
- | **Lakehouse Storage** | Apache Iceberg on Cloudflare R2 |
68
- | **Stream Processing** | PySpark Structured Streaming |
69
  | **Transformation** | dbt-core with DuckDB adapter (7 Gold marts) |
70
  | **Vector Database** | ChromaDB (BM25 sparse + BGE-M3 dense hybrid retrieval) |
71
  | **LLM Gateway** | LiteLLM routing proxy & Semantic Cache (L1/L2/L3) |
72
  | **Agent Framework** | LangGraph multi-agent supervisor |
73
  | **Long-Term Memory** | Mem0 backed by Supabase pgvector |
74
- | **ML Models** | 8 models (LightGBM, Isolation Forest, Prophet, etc.) |
75
- | **Experiment Tracking**| MLflow hosted on DagsHub |
76
  | **LLM Observability** | Langfuse (prompt management and tracing) & LangSmith |
77
- | **Observability** | OpenTelemetry, Prometheus metrics, Grafana Cloud |
78
- | **CI/CD** | GitHub Actions with CML (Continuous Machine Learning) reporting |
79
- | **Hosting** | Hugging Face Spaces Docker (Inference) & Supabase |
80
 
81
  ---
82
 
@@ -97,27 +98,63 @@ pip install -r requirements.txt
97
  ```
98
 
99
  ### 3. Running Locally
100
- Run the dependent infrastructure services:
 
101
  ```bash
102
  docker compose up -d
103
  ```
104
- Start the FastAPI gateway:
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  ```bash
106
  uvicorn src.api.main:app --reload --port 8080
107
  ```
108
- Check health:
 
109
  ```bash
110
  curl http://localhost:8080/api/v1/health
111
  ```
112
 
113
  ---
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  ## Project Structure
116
 
117
  ```text
118
  customercore/
119
  β”œβ”€β”€ .github/workflows/ # CI/CD Workflows (Lint, Pytest, HF Deploy, CML Train)
120
  β”œβ”€β”€ infra/
 
121
  β”‚ β”œβ”€β”€ k8s/ # Kubernetes YAML manifests (Deployment, Ingress, Secrets)
122
  β”‚ β”œβ”€β”€ kind-config.yaml # Kind local multi-node cluster configuration
123
  β”‚ └── otel-collector.yaml
@@ -125,14 +162,16 @@ customercore/
125
  β”‚ β”œβ”€β”€ api/ # FastAPI Application endpoints and middleware
126
  β”‚ β”œβ”€β”€ agent/ # LangGraph supervisor and agent nodes
127
  β”‚ β”œβ”€β”€ rag/ # ChromaDB, Hybrid Retriever, Semantic Cache
128
- β”‚ β”œβ”€β”€ ml/ # Model training scripts
129
  β”‚ β”œβ”€β”€ streaming/ # Redpanda Producers and PySpark pipeline
130
  β”‚ β”œβ”€β”€ dbt/ # dbt transform pipeline (Gold marts)
131
  β”‚ β”œβ”€β”€ db/ # Supabase database repositories and clients
132
  β”‚ └── responsible_ai/ # Model cards, fairness evaluation, audit logger
133
  β”œβ”€β”€ tests/ # Unit and integration test suites
134
  β”œβ”€β”€ Dockerfile # Default Production Dockerfile
135
- └── Dockerfile.hf # Hugging Face optimized Dockerfile
 
 
136
  ```
137
 
138
  ---
@@ -143,7 +182,7 @@ CustomerCore is designed with compliance in mind for the **EU AI Act (effective
143
  - **Article 10 (Data Governance):** Schema enforcement and automated PII masking via Presidio.
144
  - **Article 12 (Traceability):** Structured audit logging of all constitutional violations and inputs/outputs to Supabase PostgreSQL.
145
  - **Article 14 (Human Oversight):** LangGraph-native `interrupt()` gates that pause low-confidence decisions for manual human sign-off.
146
- - **Article 15 (Accuracy & Security):** Model card documentation for all 8 ML models (`src/responsible_ai/model_cards/`) and demographic fairness accuracy gaps calculated in `fairness.py`.
147
 
148
  ---
149
 
@@ -152,3 +191,4 @@ CustomerCore is designed with compliance in mind for the **EU AI Act (effective
152
  **Saibalaji Namburi**
153
  MSc Data Analytics, University of Hildesheim
154
  - GitHub: [@saibalajinamburi](https://github.com/saibalajinamburi)
 
 
16
  [![Tests](https://img.shields.io/badge/tests-passing-brightgreen)](https://github.com/saibalajinamburi/CustomerCore/actions)
17
  [![License](https://img.shields.io/badge/license-MIT-green)](https://opensource.org/licenses/MIT)
18
  [![EU AI Act](https://img.shields.io/badge/EU%20AI%20Act-Compliant-blue)](#responsible-ai)
19
+ [![HF Spaces](https://img.shields.io/badge/HF%20Spaces-Live-orange)](https://huggingface.co/spaces/saibalajiomg/customercore)
20
 
21
  CustomerCore is an enterprise-grade customer intelligence system designed to automate ticket classification, prioritize issues, calculate churn and SLA breach risks, recall agent memories, retrieve grounded facts, and run automated human-in-the-loop triage flows.
22
 
 
26
 
27
  | Asset | Link | Status |
28
  |---|---|---|
29
+ | **Live Operations Console (HF Spaces)** | [huggingface.co/spaces/saibalajiomg/customercore](https://huggingface.co/spaces/saibalajiomg/customercore) | ![uptime](https://img.shields.io/badge/Uptime-100%25-brightgreen) |
30
+ | **GitHub Repository** | [github.com/saibalajinamburi/CustomerCore](https://github.com/saibalajinamburi/CustomerCore) | Active |
31
+ | **DagsHub Repository & DVC** | [dagshub.com/saibalajinamburi/CustomerCore](https://dagshub.com/saibalajinamburi/CustomerCore) | Connected |
32
+ | **MLflow Experiment Server** | [dagshub.com/saibalajinamburi/CustomerCore.mlflow](https://dagshub.com/saibalajinamburi/CustomerCore.mlflow) | Runs Tracked |
33
+ | **MLflow Model Registry** | [dagshub.com/saibalajinamburi/CustomerCore/models](https://dagshub.com/saibalajinamburi/CustomerCore/models) | Versioned Models |
34
+ | **CI/CD Pipeline** | [github.com/saibalajinamburi/CustomerCore/actions](https://github.com/saibalajinamburi/CustomerCore/actions) | Passing βœ… |
35
 
36
  ---
37
 
 
43
  1. **Streaming Data Flow:** Kafka-compatible Redpanda topics (`tickets`, `product`, `billing`, `incidents`) ingest event micro-batches.
44
  2. **Spark Lakehouse:** PySpark Structured Streaming processes raw data from Bronze to Silver layers with real-time PII masking and schema validation, writing to Apache Iceberg format.
45
  3. **dbt Gold Layer:** Transforms silver tables into 7 curated business marts for analytics, features, and model training.
46
+ 4. **LangGraph Triage:** A stateful multi-agent supervisor orchestrates six specialized sub-agents (Classify, Memory, RAG, Churn, Incident, and HITL) to yield a Pydantic-validated 14-field triage output in `<2s`.
47
 
48
  ---
49
 
 
54
  1. **Classify Agent:** Determines ticket category and initial priority.
55
  2. **Memory Agent:** Recalls customer profile and past session memories from Mem0 (Supabase pgvector).
56
  3. **RAG Agent:** Injects context using hybrid retrieval (Dense + BM25 sparse) from the Knowledge Base and product documentation, reranked by a cross-encoder model.
57
+ 4. **Churn Agent:** Evaluates churn risk using a local **Random Forest Classifier** trained on client metrics.
58
  5. **Incident Agent:** Inspects incident patterns and flags SLA-breach risks.
59
  6. **HITL Agent:** Interrupts the graph and pauses execution if safety flags are violated or if classifier confidence falls below `0.65`, routing the ticket to the Supabase-backed human-in-the-loop review queue.
60
  7. **Finalize & Log:** Consolidates state, applies PII scrubbing, logs the audit trail, and pushes a structured trace to Langfuse.
 
66
  | Layer | Technology |
67
  |---|---|
68
  | **Streaming Backbone** | Redpanda (Kafka-compatible event broker) |
69
+ | **Lakehouse Storage** | Apache Iceberg on Cloudflare R2 / MinIO |
70
+ | **Stream Processing** | PySpark Structured Streaming / PyArrow |
71
  | **Transformation** | dbt-core with DuckDB adapter (7 Gold marts) |
72
  | **Vector Database** | ChromaDB (BM25 sparse + BGE-M3 dense hybrid retrieval) |
73
  | **LLM Gateway** | LiteLLM routing proxy & Semantic Cache (L1/L2/L3) |
74
  | **Agent Framework** | LangGraph multi-agent supervisor |
75
  | **Long-Term Memory** | Mem0 backed by Supabase pgvector |
76
+ | **ML Models & Registry** | Random Forest Classifier, MLflow, DagsHub, DVC |
 
77
  | **LLM Observability** | Langfuse (prompt management and tracing) & LangSmith |
78
+ | **Infrastructure Observability**| OpenTelemetry, Prometheus metrics, Grafana Cloud |
79
+ | **CI/CD & CML** | GitHub Actions with Continuous Machine Learning metrics reporting |
80
+ | **Hosting** | Hugging Face Spaces Docker (Inference) & Supabase PostgreSQL |
81
 
82
  ---
83
 
 
98
  ```
99
 
100
  ### 3. Running Locally
101
+
102
+ #### Run base services (Redpanda, MinIO, Redis, ChromaDB, OTel):
103
  ```bash
104
  docker compose up -d
105
  ```
106
+
107
+ #### Run observability console (Prometheus + Grafana):
108
+ ```bash
109
+ docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
110
+ ```
111
+ * **Grafana Dashboard**: Open `http://localhost:3000` (pre-provisioned with metrics visualizations, no login required).
112
+ * **Prometheus Console**: Open `http://localhost:9090`.
113
+
114
+ #### Start the FastAPI gateway:
115
+ Using Doppler:
116
+ ```bash
117
+ doppler run -- uvicorn src.api.main:app --reload --port 8080
118
+ ```
119
+ Without Doppler:
120
  ```bash
121
  uvicorn src.api.main:app --reload --port 8080
122
  ```
123
+
124
+ #### Check Health:
125
  ```bash
126
  curl http://localhost:8080/api/v1/health
127
  ```
128
 
129
  ---
130
 
131
+ ## ML Model Training & DagsHub Integration
132
+
133
+ We train a **Random Forest Churn Predictor** locally or in CI, logging parameters, metrics, performance plots (ROC, Confusion Matrix, Feature Importance), and the model binaries directly to DagsHub's MLflow Experiments Server and Model Registry.
134
+
135
+ To train the model and push to DagsHub manually:
136
+ ```bash
137
+ # Set DagsHub credentials in your shell environment
138
+ $env:MLFLOW_TRACKING_USERNAME="saibalajinamburi"
139
+ $env:MLFLOW_TRACKING_PASSWORD="YOUR_DAGSHUB_TOKEN"
140
+ $env:PYTHONIOENCODING="utf-8"
141
+
142
+ # Train model and track in MLflow Model Registry
143
+ doppler run -- python -X utf8 src/ml/train_churn.py
144
+
145
+ # Push raw dataset file versioning via DVC
146
+ doppler run -- dvc push
147
+ ```
148
+
149
+ ---
150
+
151
  ## Project Structure
152
 
153
  ```text
154
  customercore/
155
  β”œβ”€β”€ .github/workflows/ # CI/CD Workflows (Lint, Pytest, HF Deploy, CML Train)
156
  β”œβ”€β”€ infra/
157
+ β”‚ β”œβ”€β”€ monitoring/ # Prometheus and Grafana local config & dashboards
158
  β”‚ β”œβ”€β”€ k8s/ # Kubernetes YAML manifests (Deployment, Ingress, Secrets)
159
  β”‚ β”œβ”€β”€ kind-config.yaml # Kind local multi-node cluster configuration
160
  β”‚ └── otel-collector.yaml
 
162
  β”‚ β”œβ”€β”€ api/ # FastAPI Application endpoints and middleware
163
  β”‚ β”œβ”€β”€ agent/ # LangGraph supervisor and agent nodes
164
  β”‚ β”œβ”€β”€ rag/ # ChromaDB, Hybrid Retriever, Semantic Cache
165
+ β”‚ β”œβ”€β”€ ml/ # Model training scripts (Random Forest)
166
  β”‚ β”œβ”€β”€ streaming/ # Redpanda Producers and PySpark pipeline
167
  β”‚ β”œβ”€β”€ dbt/ # dbt transform pipeline (Gold marts)
168
  β”‚ β”œβ”€β”€ db/ # Supabase database repositories and clients
169
  β”‚ └── responsible_ai/ # Model cards, fairness evaluation, audit logger
170
  β”œβ”€β”€ tests/ # Unit and integration test suites
171
  β”œβ”€β”€ Dockerfile # Default Production Dockerfile
172
+ β”œβ”€β”€ Dockerfile.hf # Hugging Face optimized Dockerfile
173
+ β”œβ”€β”€ docker-compose.yml # Standard services configuration
174
+ └── docker-compose.monitoring.yml # Local Grafana / Prometheus observability stack
175
  ```
176
 
177
  ---
 
182
  - **Article 10 (Data Governance):** Schema enforcement and automated PII masking via Presidio.
183
  - **Article 12 (Traceability):** Structured audit logging of all constitutional violations and inputs/outputs to Supabase PostgreSQL.
184
  - **Article 14 (Human Oversight):** LangGraph-native `interrupt()` gates that pause low-confidence decisions for manual human sign-off.
185
+ - **Article 15 (Accuracy & Security):** Model card documentation for all ML models (`src/responsible_ai/model_cards/`) and demographic fairness accuracy gaps.
186
 
187
  ---
188
 
 
191
  **Saibalaji Namburi**
192
  MSc Data Analytics, University of Hildesheim
193
  - GitHub: [@saibalajinamburi](https://github.com/saibalajinamburi)
194
+ - DagsHub: [saibalajinamburi](https://dagshub.com/saibalajinamburi)
docker-compose.monitoring.yml ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3.8"
2
+
3
+ # ============================================================
4
+ # CustomerCore Local Observability Stack Add-on
5
+ # Start: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
6
+ # Stop: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml down
7
+ # Grafana: http://localhost:3000 (No login required - Admin access)
8
+ # Prometheus: http://localhost:9090
9
+ # ============================================================
10
+
11
+ services:
12
+
13
+ prometheus:
14
+ image: prom/prometheus:latest
15
+ container_name: customercore_prometheus
16
+ volumes:
17
+ - ./infra/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
18
+ command:
19
+ - --config.file=/etc/prometheus/prometheus.yml
20
+ - --storage.tsdb.path=/prometheus
21
+ - --web.console.libraries=/usr/share/prometheus/console_libraries
22
+ - --web.console.templates=/usr/share/prometheus/consoles
23
+ networks:
24
+ - customercore
25
+ ports:
26
+ - "9090:9090"
27
+ extra_hosts:
28
+ - "host.docker.internal:host-gateway"
29
+
30
+ grafana:
31
+ image: grafana/grafana-oss:latest
32
+ container_name: customercore_grafana
33
+ environment:
34
+ - GF_AUTH_ANONYMOUS_ENABLED=true
35
+ - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
36
+ - GF_AUTH_ANONYMOUS_ORG_NAME=Main Org.
37
+ - GF_SECURITY_ALLOW_EMBEDDING=true
38
+ volumes:
39
+ - ./infra/monitoring/grafana-datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml
40
+ - ./infra/monitoring/grafana-dashboards.yml:/etc/grafana/provisioning/dashboards/dashboards.yml
41
+ - ./infra/monitoring/customercore-dashboard.json:/etc/grafana/provisioning/dashboards/customercore-dashboard.json
42
+ networks:
43
+ - customercore
44
+ ports:
45
+ - "3000:3000"
46
+ depends_on:
47
+ - prometheus
infra/monitoring/customercore-dashboard.json ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "annotations": {
3
+ "list": [
4
+ {
5
+ "builtIn": 1,
6
+ "datasource": "-- Grafana --",
7
+ "enable": true,
8
+ "hide": true,
9
+ "name": "Annotations & Alerts",
10
+ "type": "dashboard"
11
+ }
12
+ ]
13
+ },
14
+ "editable": true,
15
+ "fiscalYearStartMonth": 0,
16
+ "graphTooltip": 0,
17
+ "id": 1,
18
+ "links": [],
19
+ "liveNow": false,
20
+ "panels": [
21
+ {
22
+ "datasource": {
23
+ "type": "prometheus",
24
+ "uid": "Prometheus"
25
+ },
26
+ "fieldConfig": {
27
+ "defaults": {
28
+ "custom": {
29
+ "drawStyle": "line",
30
+ "lineInterpolation": "smooth"
31
+ },
32
+ "unit": "reqps"
33
+ }
34
+ },
35
+ "gridPos": {
36
+ "h": 8,
37
+ "w": 12,
38
+ "x": 0,
39
+ "y": 0
40
+ },
41
+ "id": 2,
42
+ "targets": [
43
+ {
44
+ "datasource": {
45
+ "type": "prometheus",
46
+ "uid": "Prometheus"
47
+ },
48
+ "editorMode": "code",
49
+ "expr": "sum(rate(customercore_triage_requests_total[1m])) by (status)",
50
+ "legendFormat": "{{status}} requests",
51
+ "range": true
52
+ }
53
+ ],
54
+ "title": "Triage Request Ingestion Rate (by status)",
55
+ "type": "timeseries"
56
+ },
57
+ {
58
+ "datasource": {
59
+ "type": "prometheus",
60
+ "uid": "Prometheus"
61
+ },
62
+ "fieldConfig": {
63
+ "defaults": {
64
+ "custom": {
65
+ "drawStyle": "line",
66
+ "lineInterpolation": "smooth"
67
+ },
68
+ "unit": "ms"
69
+ }
70
+ },
71
+ "gridPos": {
72
+ "h": 8,
73
+ "w": 12,
74
+ "x": 12,
75
+ "y": 0
76
+ },
77
+ "id": 3,
78
+ "targets": [
79
+ {
80
+ "datasource": {
81
+ "type": "prometheus",
82
+ "uid": "Prometheus"
83
+ },
84
+ "editorMode": "code",
85
+ "expr": "histogram_quantile(0.95, sum(rate(customercore_triage_duration_ms_bucket[5m])) by (le, priority))",
86
+ "legendFormat": "p95 - {{priority}} priority",
87
+ "range": true
88
+ }
89
+ ],
90
+ "title": "95th Percentile Triage Processing Latency (by Priority)",
91
+ "type": "timeseries"
92
+ },
93
+ {
94
+ "datasource": {
95
+ "type": "prometheus",
96
+ "uid": "Prometheus"
97
+ },
98
+ "fieldConfig": {
99
+ "defaults": {
100
+ "unit": "decusd"
101
+ }
102
+ },
103
+ "gridPos": {
104
+ "h": 6,
105
+ "w": 6,
106
+ "x": 0,
107
+ "y": 8
108
+ },
109
+ "id": 4,
110
+ "targets": [
111
+ {
112
+ "datasource": {
113
+ "type": "prometheus",
114
+ "uid": "Prometheus"
115
+ },
116
+ "editorMode": "code",
117
+ "expr": "sum(customercore_llm_cost_usd_total)",
118
+ "instant": true,
119
+ "range": false
120
+ }
121
+ ],
122
+ "title": "Accumulated LLM API Cost",
123
+ "type": "stat"
124
+ },
125
+ {
126
+ "datasource": {
127
+ "type": "prometheus",
128
+ "uid": "Prometheus"
129
+ },
130
+ "gridPos": {
131
+ "h": 6,
132
+ "w": 9,
133
+ "x": 6,
134
+ "y": 8
135
+ },
136
+ "id": 5,
137
+ "targets": [
138
+ {
139
+ "datasource": {
140
+ "type": "prometheus",
141
+ "uid": "Prometheus"
142
+ },
143
+ "editorMode": "code",
144
+ "expr": "sum(rate(customercore_llm_calls_total[5m])) by (model, status)",
145
+ "legendFormat": "{{model}} - {{status}}",
146
+ "range": true
147
+ }
148
+ ],
149
+ "title": "LLM Call Volumetrics & Reliability",
150
+ "type": "timeseries"
151
+ },
152
+ {
153
+ "datasource": {
154
+ "type": "prometheus",
155
+ "uid": "Prometheus"
156
+ },
157
+ "fieldConfig": {
158
+ "defaults": {
159
+ "unit": "percent"
160
+ }
161
+ },
162
+ "gridPos": {
163
+ "h": 6,
164
+ "w": 9,
165
+ "x": 15,
166
+ "y": 8
167
+ },
168
+ "id": 6,
169
+ "targets": [
170
+ {
171
+ "datasource": {
172
+ "type": "prometheus",
173
+ "uid": "Prometheus"
174
+ },
175
+ "editorMode": "code",
176
+ "expr": "sum(rate(customercore_cache_hits_total{result=\"hit\"}[5m])) / sum(rate(customercore_cache_hits_total[5m])) * 100",
177
+ "legendFormat": "Cache Hit Rate",
178
+ "range": true
179
+ }
180
+ ],
181
+ "title": "Semantic Cache Hit Rate",
182
+ "type": "timeseries"
183
+ },
184
+ {
185
+ "datasource": {
186
+ "type": "prometheus",
187
+ "uid": "Prometheus"
188
+ },
189
+ "gridPos": {
190
+ "h": 6,
191
+ "w": 12,
192
+ "x": 0,
193
+ "y": 14
194
+ },
195
+ "id": 7,
196
+ "targets": [
197
+ {
198
+ "datasource": {
199
+ "type": "prometheus",
200
+ "uid": "Prometheus"
201
+ },
202
+ "editorMode": "code",
203
+ "expr": "sum(increase(customercore_hitl_reviews_total[24h])) by (reason)",
204
+ "legendFormat": "{{reason}}",
205
+ "range": true
206
+ }
207
+ ],
208
+ "title": "HITL Queue Trigger Distribution (Last 24h)",
209
+ "type": "timeseries"
210
+ },
211
+ {
212
+ "datasource": {
213
+ "type": "prometheus",
214
+ "uid": "Prometheus"
215
+ },
216
+ "gridPos": {
217
+ "h": 6,
218
+ "w": 12,
219
+ "x": 12,
220
+ "y": 14
221
+ },
222
+ "id": 8,
223
+ "targets": [
224
+ {
225
+ "datasource": {
226
+ "type": "prometheus",
227
+ "uid": "Prometheus"
228
+ },
229
+ "editorMode": "code",
230
+ "expr": "sum(increase(customercore_sla_violations_total[24h])) by (priority)",
231
+ "legendFormat": "{{priority}} violations",
232
+ "range": true
233
+ }
234
+ ],
235
+ "title": "SLA Violations Incurred (Last 24h)",
236
+ "type": "timeseries"
237
+ }
238
+ ],
239
+ "schemaVersion": 36,
240
+ "style": "dark",
241
+ "tags": ["customercore", "mlops"],
242
+ "time": {
243
+ "from": "now-1h",
244
+ "to": "now"
245
+ },
246
+ "timepicker": {},
247
+ "timezone": "",
248
+ "title": "CustomerCore Operations Dashboard",
249
+ "uid": "customercore-ops-dashboard",
250
+ "version": 1
251
+ }
infra/monitoring/grafana-dashboards.yml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ apiVersion: 1
2
+
3
+ providers:
4
+ - name: 'CustomerCore Dashboards'
5
+ orgId: 1
6
+ folder: ''
7
+ type: file
8
+ disableDeletion: false
9
+ editable: true
10
+ options:
11
+ path: /etc/grafana/provisioning/dashboards
infra/monitoring/grafana-datasources.yml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ apiVersion: 1
2
+
3
+ datasources:
4
+ - name: Prometheus
5
+ type: prometheus
6
+ access: proxy
7
+ url: http://prometheus:9090
8
+ isDefault: true
infra/monitoring/prometheus.yml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ global:
2
+ scrape_interval: 10s
3
+ evaluation_interval: 10s
4
+
5
+ scrape_configs:
6
+ - job_name: "customercore-api"
7
+ metrics_path: "/api/v1/metrics"
8
+ static_configs:
9
+ - targets: ["host.docker.internal:8080", "host.docker.internal:7860"]
src/api/models.py CHANGED
@@ -224,6 +224,9 @@ class TriageResultResponse(BaseModel):
224
  status: TriageStatus
225
  tenant_id: str = Field(..., description="Tenant that submitted this ticket.")
226
  customer_id: str
 
 
 
227
 
228
  # ── Classification outputs ──
229
  category: str | None = Field(None, description="AI-classified ticket category.")
 
224
  status: TriageStatus
225
  tenant_id: str = Field(..., description="Tenant that submitted this ticket.")
226
  customer_id: str
227
+ customer_tier: CustomerTier = Field(default=CustomerTier.FREE, description="B2B customer subscription tier.")
228
+ text: str | None = Field(None, description="The ticket text (PII-masked for safety).")
229
+ masked_text: str | None = Field(None, description="PII-masked and tokenized ticket body.")
230
 
231
  # ── Classification outputs ──
232
  category: str | None = Field(None, description="AI-classified ticket category.")
src/api/routers/triage.py CHANGED
@@ -15,6 +15,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
15
 
16
  from src.api.auth import AuthenticatedTenant, require_role, verify_token
17
  from src.api.models import (
 
18
  HITLResumeRequest,
19
  TicketSubmitRequest,
20
  TicketSubmitResponse,
@@ -97,6 +98,9 @@ def row_to_response(row: dict[str, Any], violations: list[dict] | None = None) -
97
  status=TriageStatus(row["status"]),
98
  tenant_id=str(row["tenant_id"]),
99
  customer_id=row["customer_id"],
 
 
 
100
  category=row.get("category"),
101
  priority=TicketPriority(row["priority"]) if row.get("priority") else None,
102
  confidence=row.get("confidence") or 0.85,
@@ -135,6 +139,19 @@ async def _run_triage(ticket_id: str, text: str, customer_id: str, tenant_id: st
135
  """
136
  from src.monitoring.langfuse_tracer import TriageTrace
137
  from src.responsible_ai.constitutional_policy import policy_engine
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  trace = TriageTrace.start(
140
  ticket_id=ticket_id,
@@ -278,6 +295,10 @@ async def _run_triage(ticket_id: str, text: str, customer_id: str, tenant_id: st
278
  f"Constitutional violation: {verdict.violations[0].rule_name}"
279
  )
280
 
 
 
 
 
281
  # ── HITL or Complete ──────────────────────────────────────────────
282
  if isinstance(result, dict) and result.get("hitl_required"):
283
  result_data = {**result, "hitl_reason": result.get("hitl_reason")}
@@ -336,13 +357,29 @@ async def submit_ticket(
336
  ticket_id = str(uuid4())
337
  repo = TicketRepository(caller.tenant_id)
338
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  # Store initial ticket row in pending state
340
  record = TicketRecord(
341
  id=ticket_id,
342
  tenant_id=caller.tenant_id,
343
  customer_id=body.customer_id,
 
344
  channel=body.channel.value,
345
  raw_text=body.text,
 
346
  status="pending",
347
  )
348
  await repo.create(record)
 
15
 
16
  from src.api.auth import AuthenticatedTenant, require_role, verify_token
17
  from src.api.models import (
18
+ CustomerTier,
19
  HITLResumeRequest,
20
  TicketSubmitRequest,
21
  TicketSubmitResponse,
 
98
  status=TriageStatus(row["status"]),
99
  tenant_id=str(row["tenant_id"]),
100
  customer_id=row["customer_id"],
101
+ customer_tier=CustomerTier(row.get("customer_tier", "free")),
102
+ text=row.get("masked_text") or row.get("raw_text"),
103
+ masked_text=row.get("masked_text") or row.get("raw_text"),
104
  category=row.get("category"),
105
  priority=TicketPriority(row["priority"]) if row.get("priority") else None,
106
  confidence=row.get("confidence") or 0.85,
 
139
  """
140
  from src.monitoring.langfuse_tracer import TriageTrace
141
  from src.responsible_ai.constitutional_policy import policy_engine
142
+ from src.responsible_ai.privacy_vault import get_vault
143
+
144
+ vault = get_vault()
145
+ try:
146
+ masked_text, _ = vault.encrypt_text(
147
+ tenant_id=tenant_id,
148
+ text=text,
149
+ analyzer=None,
150
+ anonymizer=None,
151
+ ticket_id=ticket_id,
152
+ )
153
+ except Exception:
154
+ masked_text = text
155
 
156
  trace = TriageTrace.start(
157
  ticket_id=ticket_id,
 
295
  f"Constitutional violation: {verdict.violations[0].rule_name}"
296
  )
297
 
298
+ if isinstance(result, dict):
299
+ result["customer_tier"] = customer_tier
300
+ result["masked_text"] = masked_text
301
+
302
  # ── HITL or Complete ──────────────────────────────────────────────
303
  if isinstance(result, dict) and result.get("hitl_required"):
304
  result_data = {**result, "hitl_reason": result.get("hitl_reason")}
 
357
  ticket_id = str(uuid4())
358
  repo = TicketRepository(caller.tenant_id)
359
 
360
+ # Mask PII in the ticket body using the zero-trust privacy vault
361
+ from src.responsible_ai.privacy_vault import get_vault
362
+ vault = get_vault()
363
+ try:
364
+ masked_text, _ = vault.encrypt_text(
365
+ tenant_id=caller.tenant_id,
366
+ text=body.text,
367
+ analyzer=None,
368
+ anonymizer=None,
369
+ ticket_id=ticket_id,
370
+ )
371
+ except Exception:
372
+ masked_text = body.text
373
+
374
  # Store initial ticket row in pending state
375
  record = TicketRecord(
376
  id=ticket_id,
377
  tenant_id=caller.tenant_id,
378
  customer_id=body.customer_id,
379
+ customer_tier=body.customer_tier.value,
380
  channel=body.channel.value,
381
  raw_text=body.text,
382
+ masked_text=masked_text,
383
  status="pending",
384
  )
385
  await repo.create(record)
src/api/ui.py CHANGED
@@ -1089,8 +1089,14 @@ HTML_CONTENT = """<!DOCTYPE html>
1089
  }
1090
 
1091
  async function generateToken() {
1092
- const tenant = document.getElementById('widget-tenant').value;
1093
- const role = document.getElementById('widget-role').value;
 
 
 
 
 
 
1094
 
1095
  const authBadge = document.getElementById('auth-status');
1096
  const authText = document.getElementById('auth-status-text');
@@ -1221,6 +1227,7 @@ HTML_CONTENT = """<!DOCTYPE html>
1221
  clearInterval(pollInterval);
1222
  btn.disabled = false;
1223
  btn.innerText = "Dispatch to Triage Pipeline";
 
1224
  }
1225
  }, 1000);
1226
  }
@@ -1234,14 +1241,20 @@ HTML_CONTENT = """<!DOCTYPE html>
1234
  priorityEl.className = `metric-value priority-badge priority-${priorityVal.toLowerCase()}`;
1235
 
1236
  // Routing
1237
- document.getElementById('metric-routing').innerText = data.routing_department || "General Support";
 
1238
 
1239
  // Outage
1240
- document.getElementById('metric-outage').innerText = data.potential_outage ? "⚠️ Outage Alert!" : "βœ… Normal";
1241
 
1242
  // Churn Risk
1243
- const churnScore = data.churn_risk_score !== undefined ? data.churn_risk_score : 0.15;
1244
- const churnPercent = Math.round(churnScore * 100);
 
 
 
 
 
1245
  document.getElementById('metric-churn').innerText = `${churnPercent}%`;
1246
  document.getElementById('churn-progress').style.width = `${churnPercent}%`;
1247
 
@@ -1250,11 +1263,11 @@ HTML_CONTENT = """<!DOCTYPE html>
1250
 
1251
  // PII masking logs
1252
  let piiText = `[Vault Ingestion]\n`;
1253
- piiText += `- Ticket ID: ${data.ticket_id}\n`;
1254
- piiText += `- Customer ID: ${data.customer_id}\n`;
1255
  piiText += `- Raw Text masked? Yes\n`;
1256
- if (data.masked_text) {
1257
- piiText += `- Redacted Body: "${data.masked_text}"\n`;
1258
  }
1259
  document.getElementById('metric-pii-log').innerText = piiText;
1260
 
@@ -1269,7 +1282,9 @@ HTML_CONTENT = """<!DOCTYPE html>
1269
  if (data.constitutional_violations && data.constitutional_violations.length > 0) {
1270
  piiText += `\n[Safety Violations Detected]\n`;
1271
  data.constitutional_violations.forEach(v => {
1272
- piiText += `- Rule: ${v.rule_id} | Reason: ${v.reason}\n`;
 
 
1273
  });
1274
  document.getElementById('metric-pii-log').innerText = piiText;
1275
  }
@@ -1466,9 +1481,37 @@ HTML_CONTENT = """<!DOCTYPE html>
1466
  }
1467
  }
1468
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1469
  // Initialize Page
1470
  window.addEventListener('DOMContentLoaded', () => {
1471
  loadPreset('billing');
 
 
 
 
1472
  });
1473
  </script>
1474
  </body>
 
1089
  }
1090
 
1091
  async function generateToken() {
1092
+ const tenantEl = document.getElementById('widget-tenant');
1093
+ const roleEl = document.getElementById('widget-role');
1094
+ const tenant = tenantEl.value;
1095
+ const role = roleEl.value;
1096
+
1097
+ // Adjust dropdown widths automatically
1098
+ adjustSelectWidth(tenantEl);
1099
+ adjustSelectWidth(roleEl);
1100
 
1101
  const authBadge = document.getElementById('auth-status');
1102
  const authText = document.getElementById('auth-status-text');
 
1227
  clearInterval(pollInterval);
1228
  btn.disabled = false;
1229
  btn.innerText = "Dispatch to Triage Pipeline";
1230
+ showToast("Triage pipeline tracking error β€” please check connectivity", true);
1231
  }
1232
  }, 1000);
1233
  }
 
1241
  priorityEl.className = `metric-value priority-badge priority-${priorityVal.toLowerCase()}`;
1242
 
1243
  // Routing
1244
+ const routingTeam = (data.escalation_team || data.category || "General Support").replace(/_/g, ' ');
1245
+ document.getElementById('metric-routing').innerText = routingTeam;
1246
 
1247
  // Outage
1248
+ document.getElementById('metric-outage').innerText = data.incident_active ? "⚠️ Outage Alert!" : "βœ… Normal";
1249
 
1250
  // Churn Risk
1251
+ let churnPercent = 15;
1252
+ if (data.churn_risk === "low") churnPercent = 15;
1253
+ else if (data.churn_risk === "medium") churnPercent = 45;
1254
+ else if (data.churn_risk === "high") churnPercent = 75;
1255
+ else if (data.churn_risk === "critical") churnPercent = 95;
1256
+ else if (data.churn_risk_score !== undefined) churnPercent = Math.round(data.churn_risk_score * 100);
1257
+
1258
  document.getElementById('metric-churn').innerText = `${churnPercent}%`;
1259
  document.getElementById('churn-progress').style.width = `${churnPercent}%`;
1260
 
 
1263
 
1264
  // PII masking logs
1265
  let piiText = `[Vault Ingestion]\n`;
1266
+ piiText += `- Ticket ID: ${data.ticket_id || '--'}\n`;
1267
+ piiText += `- Customer ID: ${data.customer_id || '--'}\n`;
1268
  piiText += `- Raw Text masked? Yes\n`;
1269
+ if (data.masked_text || data.text) {
1270
+ piiText += `- Redacted Body: "${data.masked_text || data.text}"\n`;
1271
  }
1272
  document.getElementById('metric-pii-log').innerText = piiText;
1273
 
 
1282
  if (data.constitutional_violations && data.constitutional_violations.length > 0) {
1283
  piiText += `\n[Safety Violations Detected]\n`;
1284
  data.constitutional_violations.forEach(v => {
1285
+ const ruleId = v.rule || v.rule_id || "UNKNOWN_RULE";
1286
+ const reason = v.explanation || v.reason || "Safety policy trigger";
1287
+ piiText += `- Rule: ${ruleId} | Reason: ${reason}\n`;
1288
  });
1289
  document.getElementById('metric-pii-log').innerText = piiText;
1290
  }
 
1481
  }
1482
  }
1483
 
1484
+ // Automatically adjust select box width based on text length
1485
+ function adjustSelectWidth(selectEl) {
1486
+ if (!selectEl) return;
1487
+ const tempSpan = document.createElement("span");
1488
+ tempSpan.style.visibility = "hidden";
1489
+ tempSpan.style.position = "absolute";
1490
+ tempSpan.style.whiteSpace = "pre";
1491
+
1492
+ const style = window.getComputedStyle(selectEl);
1493
+ tempSpan.style.fontFamily = style.fontFamily;
1494
+ tempSpan.style.fontSize = style.fontSize;
1495
+ tempSpan.style.fontWeight = style.fontWeight;
1496
+ tempSpan.style.letterSpacing = style.letterSpacing;
1497
+
1498
+ const selectedText = selectEl.options[selectEl.selectedIndex].text;
1499
+ tempSpan.innerText = selectedText;
1500
+ document.body.appendChild(tempSpan);
1501
+
1502
+ const textWidth = tempSpan.getBoundingClientRect().width;
1503
+ selectEl.style.width = (textWidth + 36) + "px"; // 36px accounts for arrow padding
1504
+
1505
+ document.body.removeChild(tempSpan);
1506
+ }
1507
+
1508
  // Initialize Page
1509
  window.addEventListener('DOMContentLoaded', () => {
1510
  loadPreset('billing');
1511
+ setTimeout(() => {
1512
+ adjustSelectWidth(document.getElementById('widget-tenant'));
1513
+ adjustSelectWidth(document.getElementById('widget-role'));
1514
+ }, 100);
1515
  });
1516
  </script>
1517
  </body>
src/db/repository.py CHANGED
@@ -121,6 +121,8 @@ class TicketRecord:
121
  channel: str
122
  raw_text: str # The original ticket text
123
  status: str = "pending"
 
 
124
  external_ticket_id: str | None = None
125
  detected_language: str | None = None
126
  priority: str | None = None
@@ -274,6 +276,8 @@ class TicketRepository:
274
  "sentiment": "sentiment",
275
  "churn_risk": "churn_risk_score",
276
  "churn_risk_score": "churn_risk_score",
 
 
277
  "constitutional_score": "constitutional_score",
278
  "constitutional_passed": "constitutional_passed",
279
  "hitl_required": "hitl_required",
@@ -397,7 +401,7 @@ class TicketRepository:
397
  try:
398
  query = (
399
  sb.table("tickets")
400
- .select("id,status,priority,category,channel,created_at,hitl_required")
401
  .eq("tenant_id", self.tenant_id)
402
  .order("created_at", desc=True)
403
  .limit(limit)
 
121
  channel: str
122
  raw_text: str # The original ticket text
123
  status: str = "pending"
124
+ customer_tier: str = "free"
125
+ masked_text: str | None = None
126
  external_ticket_id: str | None = None
127
  detected_language: str | None = None
128
  priority: str | None = None
 
276
  "sentiment": "sentiment",
277
  "churn_risk": "churn_risk_score",
278
  "churn_risk_score": "churn_risk_score",
279
+ "customer_tier": "customer_tier",
280
+ "masked_text": "masked_text",
281
  "constitutional_score": "constitutional_score",
282
  "constitutional_passed": "constitutional_passed",
283
  "hitl_required": "hitl_required",
 
401
  try:
402
  query = (
403
  sb.table("tickets")
404
+ .select("id,status,priority,category,channel,created_at,hitl_required,customer_id,customer_tier,masked_text")
405
  .eq("tenant_id", self.tenant_id)
406
  .order("created_at", desc=True)
407
  .limit(limit)
src/ml/train_churn.py CHANGED
@@ -8,6 +8,8 @@ from sklearn.metrics import roc_curve, auc, confusion_matrix, accuracy_score, f1
8
  import matplotlib
9
  matplotlib.use('Agg') # Non-interactive backend
10
  import matplotlib.pyplot as plt
 
 
11
 
12
  def train():
13
  print("Pre-training steps...")
@@ -53,86 +55,121 @@ def train():
53
 
54
  X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
55
 
56
- print("Training Random Forest Churn Predictor...")
57
- clf = RandomForestClassifier(random_state=42)
58
- clf.fit(X_train, y_train)
 
 
59
 
60
- y_pred = clf.predict(X_test)
61
- y_proba = clf.predict_proba(X_test)[:, 1]
62
 
63
- # Compute metrics
64
- fpr, tpr, _ = roc_curve(y_test, y_proba)
65
- roc_auc = auc(fpr, tpr)
66
- acc = accuracy_score(y_test, y_pred)
67
- f1 = f1_score(y_test, y_pred)
68
- rec = recall_score(y_test, y_pred)
69
- prec = precision_score(y_test, y_pred)
70
-
71
- metrics = {
72
- "auc": round(float(roc_auc), 3),
73
- "accuracy": round(float(acc), 3),
74
- "f1_score": round(float(f1), 3),
75
- "recall": round(float(rec), 3),
76
- "precision": round(float(prec), 3)
77
- }
78
-
79
- print("Evaluated Metrics:", metrics)
80
-
81
- os.makedirs("data", exist_ok=True)
82
- with open("data/metrics.json", "w") as f:
83
- json.dump(metrics, f, indent=2)
84
 
85
- # Generate Plots
86
- print("Generating ROC Curve...")
87
- plt.figure(figsize=(6, 5))
88
- plt.plot(fpr, tpr, color='darkorange', lw=2, label=f'ROC curve (AUC = {roc_auc:.3f})')
89
- plt.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--')
90
- plt.xlim([0.0, 1.0])
91
- plt.ylim([0.0, 1.05])
92
- plt.xlabel('False Positive Rate')
93
- plt.ylabel('True Positive Rate')
94
- plt.title('Receiver Operating Characteristic (ROC)')
95
- plt.legend(loc="lower right")
96
- plt.tight_layout()
97
- plt.savefig("data/roc_curve.png", dpi=150)
98
- plt.close()
99
-
100
- print("Generating Confusion Matrix...")
101
- cm = confusion_matrix(y_test, y_pred)
102
- plt.figure(figsize=(5, 4))
103
- plt.imshow(cm, interpolation='nearest', cmap=plt.cm.Blues)
104
- plt.title('Confusion Matrix')
105
- plt.colorbar()
106
- tick_marks = np.arange(2)
107
- plt.xticks(tick_marks, ['No Churn', 'Churn'])
108
- plt.yticks(tick_marks, ['No Churn', 'Churn'])
109
-
110
- thresh = cm.max() / 2.
111
- for i in range(cm.shape[0]):
112
- for j in range(cm.shape[1]):
113
- plt.text(j, i, format(cm[i, j], 'd'),
114
- ha="center", va="center",
115
- color="white" if cm[i, j] > thresh else "black")
116
-
117
- plt.ylabel('True label')
118
- plt.xlabel('Predicted label')
119
- plt.tight_layout()
120
- plt.savefig("data/confusion_matrix.png", dpi=150)
121
- plt.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
- print("Generating Feature Importance Plot...")
124
- importances = clf.feature_importances_
125
- indices = np.argsort(importances)
126
- plt.figure(figsize=(6, 4))
127
- plt.title('Feature Importances')
128
- plt.barh(range(len(indices)), importances[indices], color='b', align='center')
129
- plt.yticks(range(len(indices)), [X.columns[i] for i in indices])
130
- plt.xlabel('Relative Importance')
131
- plt.tight_layout()
132
- plt.savefig("data/feature_importance.png", dpi=150)
133
- plt.close()
134
-
135
- print("Training completed successfully!")
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
  if __name__ == "__main__":
138
  train()
 
 
8
  import matplotlib
9
  matplotlib.use('Agg') # Non-interactive backend
10
  import matplotlib.pyplot as plt
11
+ import mlflow
12
+ import mlflow.sklearn
13
 
14
  def train():
15
  print("Pre-training steps...")
 
55
 
56
  X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
57
 
58
+ # Setup MLflow Tracking
59
+ # If MLFLOW_TRACKING_URI is set, use it. Otherwise, default to local sqlite
60
+ tracking_uri = os.environ.get("MLFLOW_TRACKING_URI", "sqlite:///mlruns.db")
61
+ mlflow.set_tracking_uri(tracking_uri)
62
+ print(f"MLflow Tracking URI set to: {tracking_uri}")
63
 
64
+ mlflow.set_experiment("CustomerCore-Churn")
 
65
 
66
+ # Start MLflow run
67
+ with mlflow.start_run(run_name="RandomForest-Baseline") as run:
68
+ print("Training Random Forest Churn Predictor...")
69
+ clf = RandomForestClassifier(random_state=42)
70
+ clf.fit(X_train, y_train)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
+ y_pred = clf.predict(X_test)
73
+ y_proba = clf.predict_proba(X_test)[:, 1]
74
+
75
+ # Compute metrics
76
+ fpr, tpr, _ = roc_curve(y_test, y_proba)
77
+ roc_auc = auc(fpr, tpr)
78
+ acc = accuracy_score(y_test, y_pred)
79
+ f1 = f1_score(y_test, y_pred)
80
+ rec = recall_score(y_test, y_pred)
81
+ prec = precision_score(y_test, y_pred)
82
+
83
+ metrics = {
84
+ "auc": round(float(roc_auc), 3),
85
+ "accuracy": round(float(acc), 3),
86
+ "f1_score": round(float(f1), 3),
87
+ "recall": round(float(rec), 3),
88
+ "precision": round(float(prec), 3)
89
+ }
90
+
91
+ print("Evaluated Metrics:", metrics)
92
+
93
+ # Log parameters to MLflow
94
+ mlflow.log_param("n_estimators", clf.n_estimators)
95
+ mlflow.log_param("criterion", clf.criterion)
96
+ mlflow.log_param("max_depth", clf.max_depth)
97
+ mlflow.log_param("min_samples_split", clf.min_samples_split)
98
+ mlflow.log_param("random_state", 42)
99
+ mlflow.log_param("test_size", 0.2)
100
+
101
+ # Log metrics to MLflow
102
+ mlflow.log_metrics(metrics)
103
+
104
+ os.makedirs("data", exist_ok=True)
105
+ with open("data/metrics.json", "w") as f:
106
+ json.dump(metrics, f, indent=2)
107
+
108
+ # Generate Plots
109
+ print("Generating ROC Curve...")
110
+ plt.figure(figsize=(6, 5))
111
+ plt.plot(fpr, tpr, color='darkorange', lw=2, label=f'ROC curve (AUC = {roc_auc:.3f})')
112
+ plt.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--')
113
+ plt.xlim([0.0, 1.0])
114
+ plt.ylim([0.0, 1.05])
115
+ plt.xlabel('False Positive Rate')
116
+ plt.ylabel('True Positive Rate')
117
+ plt.title('Receiver Operating Characteristic (ROC)')
118
+ plt.legend(loc="lower right")
119
+ plt.tight_layout()
120
+ plt.savefig("data/roc_curve.png", dpi=150)
121
+ plt.close()
122
+
123
+ print("Generating Confusion Matrix...")
124
+ cm = confusion_matrix(y_test, y_pred)
125
+ plt.figure(figsize=(5, 4))
126
+ plt.imshow(cm, interpolation='nearest', cmap=plt.cm.Blues)
127
+ plt.title('Confusion Matrix')
128
+ plt.colorbar()
129
+ tick_marks = np.arange(2)
130
+ plt.xticks(tick_marks, ['No Churn', 'Churn'])
131
+ plt.yticks(tick_marks, ['No Churn', 'Churn'])
132
+
133
+ thresh = cm.max() / 2.
134
+ for i in range(cm.shape[0]):
135
+ for j in range(cm.shape[1]):
136
+ plt.text(j, i, format(cm[i, j], 'd'),
137
+ ha="center", va="center",
138
+ color="white" if cm[i, j] > thresh else "black")
139
+
140
+ plt.ylabel('True label')
141
+ plt.xlabel('Predicted label')
142
+ plt.tight_layout()
143
+ plt.savefig("data/confusion_matrix.png", dpi=150)
144
+ plt.close()
145
 
146
+ print("Generating Feature Importance Plot...")
147
+ importances = clf.feature_importances_
148
+ indices = np.argsort(importances)
149
+ plt.figure(figsize=(6, 4))
150
+ plt.title('Feature Importances')
151
+ plt.barh(range(len(indices)), importances[indices], color='b', align='center')
152
+ plt.yticks(range(len(indices)), [X.columns[i] for i in indices])
153
+ plt.xlabel('Relative Importance')
154
+ plt.tight_layout()
155
+ plt.savefig("data/feature_importance.png", dpi=150)
156
+ plt.close()
157
+
158
+ # Log artifacts (plots) to MLflow
159
+ mlflow.log_artifact("data/roc_curve.png", "plots")
160
+ mlflow.log_artifact("data/confusion_matrix.png", "plots")
161
+ mlflow.log_artifact("data/feature_importance.png", "plots")
162
+
163
+ # Log and Register the model in MLflow Model Registry
164
+ print("Logging and registering model to MLflow Model Registry...")
165
+ mlflow.sklearn.log_model(
166
+ sk_model=clf,
167
+ artifact_path="model",
168
+ registered_model_name="customercore-churn-classifier"
169
+ )
170
+
171
+ print("MLflow Logging and registration completed successfully!")
172
 
173
  if __name__ == "__main__":
174
  train()
175
+
src/responsible_ai/privacy_vault.py CHANGED
@@ -143,6 +143,8 @@ class CryptographicPrivacyVault:
143
  decrypt_token() β€” decrypt a token back to plaintext (RBAC-enforced)
144
  forget_tenant() β€” GDPR deletion cascade for all tokens of a tenant
145
  """
 
 
146
 
147
  def __init__(
148
  self,
@@ -217,8 +219,8 @@ class CryptographicPrivacyVault:
217
  *,
218
  tenant_id: str,
219
  text: str,
220
- analyzer,
221
- anonymizer,
222
  ticket_id: str = "",
223
  pii_entities: list[str] | None = None,
224
  ) -> tuple[str, int]:
@@ -229,6 +231,18 @@ class CryptographicPrivacyVault:
229
  Returns (tokenized_text, count_of_pii_detected).
230
  Falls back to standard Presidio anonymization if vault is disabled.
231
  """
 
 
 
 
 
 
 
 
 
 
 
 
232
  if not text or not isinstance(text, str):
233
  return text, 0
234
 
 
143
  decrypt_token() β€” decrypt a token back to plaintext (RBAC-enforced)
144
  forget_tenant() β€” GDPR deletion cascade for all tokens of a tenant
145
  """
146
+ _analyzer_cached = None
147
+ _anonymizer_cached = None
148
 
149
  def __init__(
150
  self,
 
219
  *,
220
  tenant_id: str,
221
  text: str,
222
+ analyzer=None,
223
+ anonymizer=None,
224
  ticket_id: str = "",
225
  pii_entities: list[str] | None = None,
226
  ) -> tuple[str, int]:
 
231
  Returns (tokenized_text, count_of_pii_detected).
232
  Falls back to standard Presidio anonymization if vault is disabled.
233
  """
234
+ if analyzer is None or anonymizer is None:
235
+ if CryptographicPrivacyVault._analyzer_cached is None or CryptographicPrivacyVault._anonymizer_cached is None:
236
+ from presidio_analyzer import AnalyzerEngine
237
+ from presidio_anonymizer import AnonymizerEngine
238
+ from presidio_analyzer.nlp_engine import NlpEngineProvider
239
+ nlp_config = {"nlp_engine_name": "spacy", "models": [{"lang_code": "en", "model_name": "en_core_web_sm"}]}
240
+ nlp_engine = NlpEngineProvider(nlp_configuration=nlp_config).create_engine()
241
+ CryptographicPrivacyVault._analyzer_cached = AnalyzerEngine(nlp_engine=nlp_engine)
242
+ CryptographicPrivacyVault._anonymizer_cached = AnonymizerEngine()
243
+ analyzer = analyzer or CryptographicPrivacyVault._analyzer_cached
244
+ anonymizer = anonymizer or CryptographicPrivacyVault._anonymizer_cached
245
+
246
  if not text or not isinstance(text, str):
247
  return text, 0
248
 
supabase/migrations/001_initial_schema.sql CHANGED
@@ -28,9 +28,12 @@ CREATE TABLE IF NOT EXISTS tickets (
28
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
29
  external_ticket_id TEXT, -- caller-supplied ID (idempotency)
30
  customer_id TEXT NOT NULL,
 
 
31
  channel TEXT NOT NULL DEFAULT 'api'
32
  CHECK (channel IN ('email','chat','api','phone','portal')),
33
  raw_text TEXT NOT NULL, -- original ticket body (stored encrypted)
 
34
  detected_language TEXT,
35
  status TEXT NOT NULL DEFAULT 'pending'
36
  CHECK (status IN ('pending','processing','complete','hitl','failed')),
 
28
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
29
  external_ticket_id TEXT, -- caller-supplied ID (idempotency)
30
  customer_id TEXT NOT NULL,
31
+ customer_tier TEXT NOT NULL DEFAULT 'free'
32
+ CHECK (customer_tier IN ('free','starter','growth','enterprise','vip')),
33
  channel TEXT NOT NULL DEFAULT 'api'
34
  CHECK (channel IN ('email','chat','api','phone','portal')),
35
  raw_text TEXT NOT NULL, -- original ticket body (stored encrypted)
36
+ masked_text TEXT,
37
  detected_language TEXT,
38
  status TEXT NOT NULL DEFAULT 'pending'
39
  CHECK (status IN ('pending','processing','complete','hitl','failed')),
tests/unit/test_phase12_repository.py CHANGED
@@ -309,3 +309,49 @@ class TestTenantRepository:
309
  qb.upsert.assert_called_once()
310
  call_kwargs = qb.upsert.call_args[1]
311
  assert call_kwargs.get("on_conflict") == "slug"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  qb.upsert.assert_called_once()
310
  call_kwargs = qb.upsert.call_args[1]
311
  assert call_kwargs.get("on_conflict") == "slug"
312
+
313
+
314
+ # ─────────────────────────────────────────────────────────────────────────────
315
+ # Console Bug Fixes and UI integration tests
316
+ # ─────────────────────────────────────────────────────────────────────────────
317
+
318
+ class TestBugFixes:
319
+
320
+ def test_record_new_fields(self):
321
+ record = TicketRecord(
322
+ id="t1", tenant_id="t1", customer_id="c1", channel="api", raw_text="text",
323
+ customer_tier="enterprise", masked_text="masked text"
324
+ )
325
+ assert record.customer_tier == "enterprise"
326
+ assert record.masked_text == "masked text"
327
+
328
+ d = record.to_db_dict()
329
+ assert d["customer_tier"] == "enterprise"
330
+ assert d["masked_text"] == "masked text"
331
+
332
+ @pytest.mark.asyncio
333
+ async def test_update_status_maps_new_fields(self):
334
+ client, qb, _ = _make_supabase_mock()
335
+ with patch("src.db.repository.get_supabase", new=AsyncMock(return_value=client)):
336
+ repo = TicketRepository(TENANT_ID)
337
+ await repo.update_status("ticket-001", "complete", result_data={
338
+ "customer_tier": "vip",
339
+ "masked_text": "redacted info"
340
+ })
341
+ update_data = qb.update.call_args[0][0]
342
+ assert update_data["customer_tier"] == "vip"
343
+ assert update_data["masked_text"] == "redacted info"
344
+
345
+ def test_lazy_vault_initialization(self):
346
+ from src.responsible_ai.privacy_vault import CryptographicPrivacyVault
347
+ vault = CryptographicPrivacyVault()
348
+ # Should not crash and should lazily initialize spacy/presidio
349
+ tokenized, count = vault.encrypt_text(
350
+ tenant_id="test-tenant",
351
+ text="Contact me at test@example.com or 555-0199",
352
+ analyzer=None,
353
+ anonymizer=None
354
+ )
355
+ assert count > 0
356
+ assert "TOK_EMAIL" in tokenized or "TOK_PHONE" in tokenized
357
+