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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Database
DATABASE_URL=postgresql://chatintel:chatintel@postgres:5432/chatintel

# Auth
JWT_SECRET=change-me-in-production-use-a-long-random-string
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=10080

# LLM (for evaluation and auto-labeling)
# Set one of these based on your provider
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GOOGLE_API_KEY=
# LiteLLM model string, e.g. "gpt-4o-mini", "claude-3-5-haiku-20241022", "gemini/gemini-2.0-flash"
DEFAULT_LLM_MODEL=gpt-4o-mini

# File storage
# "local" uses ./uploads dir; "s3" uses MinIO/S3
STORAGE_BACKEND=local
STORAGE_LOCAL_PATH=/app/uploads
# MinIO/S3 (only needed if STORAGE_BACKEND=s3)
S3_ENDPOINT_URL=http://minio:9000
S3_BUCKET_NAME=chatintel
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin

# Redis (Celery broker)
REDIS_URL=redis://redis:6379/0

# CORS
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000

# Web
VITE_API_BASE_URL=http://localhost:8000
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.env
.DS_Store
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.venv/
venv/
node_modules/
dist/
.next/
uploads/
*.log
71 changes: 70 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,71 @@
# chatintel
Open source Evaluation & Observability platform

Open source conversation intelligence platform. Upload customer chat logs, analyze sentiment and metrics, and run LLM-as-judge evaluations — all self-hosted.

## Features

- **Ingestion** — upload CSV, JSON, or JSONL conversation files
- **Conversations** — browse, search, and filter your data
- **Dashboard** — metrics over time with label breakdowns
- **Labels** — tag and categorise conversations
- **Evaluations** — LLM-as-judge scoring (helpfulness, tone, resolution, clarity)

## Quick start

```bash
cp .env.example .env
# Edit .env — set JWT_SECRET and at least one LLM API key
docker compose up
```

Open http://localhost:3000, create an account, and upload your first file.

## Data format

ChatIntel accepts any CSV or JSON file with conversation data. Minimum required fields:

| Field | Description |
|---|---|
| `conversation_id` | Unique ID for the conversation |
| `role` | Message sender: `user` or `assistant` |
| `content` | Message text |

Optional fields: `user_id`, `platform`, `status`, `sentiment`, `satisfaction_score`, `start_time`, `end_time`.

## Configuration

| Variable | Description |
|---|---|
| `JWT_SECRET` | Secret for signing auth tokens (required) |
| `DEFAULT_LLM_MODEL` | LiteLLM model string, e.g. `gpt-4o-mini` |
| `OPENAI_API_KEY` | OpenAI key for evaluations |
| `ANTHROPIC_API_KEY` | Anthropic key for evaluations |
| `GOOGLE_API_KEY` | Google key for evaluations |
| `STORAGE_BACKEND` | `local` (default) or `s3` |

## Development

```bash
# API
cd api && pip install -r requirements.txt
cp ../.env.example .env
alembic upgrade head
uvicorn chatintel.main:app --reload

# Web
cd web && npm install
npm run dev
```

## Architecture

```
web/ React + Vite + shadcn/ui
api/ FastAPI + SQLAlchemy + PostgreSQL
```

Background file processing runs via Celery workers. No cloud dependencies required.

## License

Apache 2.0
12 changes: 12 additions & 0 deletions api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM python:3.12-slim

WORKDIR /app

RUN apt-get update && apt-get install -y libpq-dev gcc && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN mkdir -p /app/uploads
38 changes: 38 additions & 0 deletions api/alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql://chatintel:chatintel@postgres:5432/chatintel

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
42 changes: 42 additions & 0 deletions api/alembic/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context

config = context.config

if config.config_file_name is not None:
fileConfig(config.config_file_name)

# Override sqlalchemy.url from env if present
db_url = os.environ.get("DATABASE_URL")
if db_url:
config.set_main_option("sqlalchemy.url", db_url)

from chatintel.database import Base
target_metadata = Base.metadata


def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()


def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()


if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
23 changes: 23 additions & 0 deletions api/alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}

"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}


def upgrade() -> None:
${upgrades if upgrades else "pass"}


def downgrade() -> None:
${downgrades if downgrades else "pass"}
105 changes: 105 additions & 0 deletions api/alembic/versions/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""initial schema

Revision ID: 0001
Revises:
Create Date: 2025-01-01 00:00:00

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB

revision = "0001"
down_revision = None
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", UUID(as_uuid=False), primary_key=True),
sa.Column("email", sa.String(), nullable=False, unique=True),
sa.Column("password_hash", sa.String(), nullable=False),
sa.Column("full_name", sa.String()),
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
)
op.create_index("ix_users_email", "users", ["email"])

op.create_table(
"data_sources",
sa.Column("id", UUID(as_uuid=False), primary_key=True),
sa.Column("user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("filename", sa.String(), nullable=False),
sa.Column("file_path", sa.String(), nullable=False),
sa.Column("file_type", sa.String(), nullable=False),
sa.Column("status", sa.String(), nullable=False, server_default="pending"),
sa.Column("row_count", sa.Integer()),
sa.Column("error_message", sa.Text()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
)

op.create_table(
"conversations",
sa.Column("id", UUID(as_uuid=False), primary_key=True),
sa.Column("user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("conversation_id", sa.String(), nullable=False),
sa.Column("external_user_id", sa.String()),
sa.Column("messages", JSONB()),
sa.Column("status", sa.String()),
sa.Column("platform", sa.String()),
sa.Column("sentiment", sa.String()),
sa.Column("satisfaction_score", sa.Numeric()),
sa.Column("message_count", sa.Integer()),
sa.Column("start_time", sa.DateTime(timezone=True)),
sa.Column("end_time", sa.DateTime(timezone=True)),
sa.Column("duration_seconds", sa.Numeric()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.UniqueConstraint("user_id", "conversation_id", name="uq_conversations_user_conv"),
)
op.create_index("ix_conversations_user_id", "conversations", ["user_id"])
op.create_index("ix_conversations_end_time", "conversations", ["end_time"])

op.create_table(
"labels",
sa.Column("id", UUID(as_uuid=False), primary_key=True),
sa.Column("user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("description", sa.Text()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.UniqueConstraint("user_id", "name", name="uq_labels_user_name"),
)

op.create_table(
"conversation_labels",
sa.Column("conversation_id", UUID(as_uuid=False), sa.ForeignKey("conversations.id", ondelete="CASCADE"), primary_key=True),
sa.Column("label_id", UUID(as_uuid=False), sa.ForeignKey("labels.id", ondelete="CASCADE"), primary_key=True),
sa.Column("classified_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
)

op.create_table(
"evaluations",
sa.Column("id", UUID(as_uuid=False), primary_key=True),
sa.Column("conversation_id", UUID(as_uuid=False), sa.ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("evaluator_model", sa.String(), nullable=False),
sa.Column("criteria", sa.String(), nullable=False),
sa.Column("score", sa.Numeric()),
sa.Column("reasoning", sa.Text()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
)
op.create_index("ix_evaluations_conversation_id", "evaluations", ["conversation_id"])


def downgrade() -> None:
op.drop_table("evaluations")
op.drop_table("conversation_labels")
op.drop_table("labels")
op.drop_table("conversations")
op.drop_table("data_sources")
op.drop_table("users")
Empty file added api/chatintel/__init__.py
Empty file.
35 changes: 35 additions & 0 deletions api/chatintel/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from datetime import datetime, timedelta, timezone
from typing import Optional

from jose import jwt, JWTError
from passlib.context import CryptContext

from .config import settings

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def hash_password(password: str) -> str:
return pwd_context.hash(password)


def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)


def create_access_token(user_id: str, email: str, is_admin: bool) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {
"sub": user_id,
"email": email,
"is_admin": is_admin,
"exp": expire,
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)


def decode_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except JWTError:
return None
Loading