Spaces:
Running
Running
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 +4 -0
- .gitignore +4 -1
- README.md +60 -20
- docker-compose.monitoring.yml +47 -0
- infra/monitoring/customercore-dashboard.json +251 -0
- infra/monitoring/grafana-dashboards.yml +11 -0
- infra/monitoring/grafana-datasources.yml +8 -0
- infra/monitoring/prometheus.yml +9 -0
- src/api/models.py +3 -0
- src/api/routers/triage.py +37 -0
- src/api/ui.py +54 -11
- src/db/repository.py +5 -1
- src/ml/train_churn.py +113 -76
- src/responsible_ai/privacy_vault.py +16 -2
- supabase/migrations/001_initial_schema.sql +3 -0
- tests/unit/test_phase12_repository.py +46 -0
.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 |
[](https://github.com/saibalajinamburi/CustomerCore/actions)
|
| 17 |
[](https://opensource.org/licenses/MIT)
|
| 18 |
[](#responsible-ai)
|
| 19 |
-
[](https://huggingface.co/spaces/
|
| 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
|
| 30 |
-
| **
|
| 31 |
-
| **
|
| 32 |
-
| **
|
|
|
|
|
|
|
| 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
|
| 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** |
|
| 75 |
-
| **Experiment Tracking**| MLflow hosted on DagsHub |
|
| 76 |
| **LLM Observability** | Langfuse (prompt management and tracing) & LangSmith |
|
| 77 |
-
| **Observability**
|
| 78 |
-
| **CI/CD** | GitHub Actions with
|
| 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 |
-
|
|
|
|
| 101 |
```bash
|
| 102 |
docker compose up -d
|
| 103 |
```
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
```bash
|
| 106 |
uvicorn src.api.main:app --reload --port 8080
|
| 107 |
```
|
| 108 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 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 |
[](https://github.com/saibalajinamburi/CustomerCore/actions)
|
| 17 |
[](https://opensource.org/licenses/MIT)
|
| 18 |
[](#responsible-ai)
|
| 19 |
+
[](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) |  |
|
| 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
|
| 1093 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 1238 |
|
| 1239 |
// Outage
|
| 1240 |
-
document.getElementById('metric-outage').innerText = data.
|
| 1241 |
|
| 1242 |
// Churn Risk
|
| 1243 |
-
|
| 1244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
|
| 61 |
-
y_proba = clf.predict_proba(X_test)[:, 1]
|
| 62 |
|
| 63 |
-
#
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 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 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|