[PAI-759] chore: Add 'plane-pi' codebase to plane-ee/apps dir (#4379)

* add plane-pi folder to apps/

* chore: add Plane PI build-push workflow to GitHub Actions

* chore: update Plane PI build-push workflow with environment variables and job configuration

* chore: remove Plane PI build-push workflow from GitHub Actions

* chore: rename folder in apps

---------

Co-authored-by: akshat5302 <[email protected]>
Co-authored-by: sriramveeraghanta <[email protected]>
This commit is contained in:
sunder
2025-10-08 17:45:53 +05:30
committed by GitHub
co-authored by akshat5302 sriramveeraghanta
parent 6d955ef29e
commit 74fe1af85f
248 changed files with 46737 additions and 1 deletions
+25
View File
@@ -51,6 +51,7 @@ jobs:
dh_img_silo: ${{ steps.set_env_variables.outputs.DH_IMG_SILO }}
dh_img_backend: ${{ steps.set_env_variables.outputs.DH_IMG_BACKEND }}
dh_img_email: ${{ steps.set_env_variables.outputs.DH_IMG_EMAIL }}
dh_img_pi: ${{ steps.set_env_variables.outputs.dh_img_pi }}
harbor_push: ${{ steps.set_env_variables.outputs.HARBOR_PUSH }}
build_type: ${{steps.set_env_variables.outputs.BUILD_TYPE}}
@@ -78,6 +79,7 @@ jobs:
echo "DH_IMG_SILO=silo-cloud" >> $GITHUB_OUTPUT
echo "DH_IMG_BACKEND=backend-cloud" >> $GITHUB_OUTPUT
echo "DH_IMG_EMAIL=email-cloud" >> $GITHUB_OUTPUT
echo "dh_img_pi=plane-pi-cloud" >> $GITHUB_OUTPUT
echo "BUILD_TYPE=${{env.BUILD_TYPE}}" >> $GITHUB_OUTPUT
BUILD_RELEASE=false
@@ -336,6 +338,28 @@ jobs:
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_plane_pi:
name: Build-Push Plane PI Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
steps:
- name: Plane PI Build and Push
uses: makeplane/actions/[email protected]
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_pi }}
build-context: ./apps/pi
dockerfile-path: ./apps/pi/Dockerfile.api
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
publish_release:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Build Release
@@ -350,6 +374,7 @@ jobs:
branch_build_push_silo,
branch_build_push_api,
branch_build_push_email,
branch_build_push_plane_pi,
]
env:
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
+1 -1
View File
@@ -442,7 +442,7 @@ jobs:
branch_build_push_proxy,
branch_build_push_monitor,
branch_build_push_email,
branch_build_push_silo
branch_build_push_silo,
]
steps:
- name: Checkout Files
+8
View File
@@ -0,0 +1,8 @@
__pycache__
*.pyc
*.pyo
*.pyd
*.db
.env
.git
+131
View File
@@ -0,0 +1,131 @@
# Debug Settings
DEBUG=0
DEV_WORKSPACE_ID=cd4ab5a2-1a5f-4516-a6c6-8da1a9fa5be4
# FastAPI Configuration
PI_BASE_PATH=/pi
FASTAPI_APP_HOST=0.0.0.0
FASTAPI_APP_PORT=8000
FASTAPI_APP_WORKERS=1
SENTRY_DSN=https://...
SENTRY_ENVIRONMENT=
CORS_ALLOWED_ORIGINS='http://127.0.0.1:3000, http://localhost:3000, http://localhost:8000, http://127.0.0.1:8000, https://local.plane.so, https://pi-runway.plane.tools'
PLANE_PI_INTERNAL_API_SECRET= # For internal endpoints authentication
PLANE_PI_INTERNAL_API_URL=https://plane-pi.plane.so
DD_ENABLED=1
DD_ENV=dev
DD_SERVICE=plane-pi-api
DD_AGENT_HOST=
DD_TRACE_SAMPLE_RATE=0
# Plane API Configuration
PLANE_API_HOST=https://api.plane.so
PLANE_FRONTEND_URL=https://app.plane.so
PLANE_API_KEY=plane_api_xxx
SESSION_COOKIE_NAME=session-id
# Disco Configuration (FEATURE FLAG)
DISCO_HOST_URL=https://disco.plane.so
# Database Configuration
FOLLOWER_POSTGRES_URI=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# Plane PI Database
PLANE_PI_POSTGRES_USER=plane-pi
PLANE_PI_POSTGRES_PASSWORD=plane-pi
PLANE_PI_POSTGRES_HOST=localhost
PLANE_PI_POSTGRES_PORT=5432
PLANE_PI_POSTGRES_DB=plane-pi
PLANE_PI_DATABASE_URL=postgresql://${PLANE_PI_POSTGRES_USER}:${PLANE_PI_POSTGRES_PASSWORD}@${PLANE_PI_POSTGRES_HOST}:${PLANE_PI_POSTGRES_PORT}/${PLANE_PI_POSTGRES_DB}
# API Keys
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CLAUDE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
MISTRAL_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GROQ_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GEMINI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ASSEMBLYAI_API_KEY="xxxxxxxxxxxx"
LITE_LLM_HOST=https://litellm.plane.town
LITE_LLM_API_KEY=xxxxxxxxxxxxxxxxx
#OpenSearch Feed Config
FEED_ISSUES_DATA=0
FEED_PAGES_DATA=0
FEED_DOCS_DATA=0
FEED_SLICES=0
BATCH_SIZE=64
SCROLL_TIMEOUT=10m
OPENSEARCH_INDEX_PREFIX=plane_
## AWS Bedrock credentials
AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx
AWS_S3_BUCKET=xxx
AWS_S3_ENV=xxx
AWS_S3_REGION=xxx
# Documentation Settings
DOCS_WEBHOOK_SECRET=xxx
DOCS_GITHUB_API_TOKEN=xxxxx
# Agents
PRD_WRITER_CLIENT_ID=xxx
PRD_WRITER_CLIENT_SECRET=xxx
# OpenSearch Credentials
OPENSEARCH_URL=xxx
OPENSEARCH_USER=xxx
OPENSEARCH_PASSWORD=xxx
# OpenSearch Embedding Model
OPENSEARCH_ML_MODEL_ID=xxx
# ===========================================
# RabbitMQ Configuration (Production Example)
# ===========================================
# DEVELOPMENT - Use simple credentials for local development
RABBITMQ_USER=plane_dev_user
RABBITMQ_PASSWORD=dev_secure_password_2024
RABBITMQ_VHOST=/plane_dev
# PRODUCTION
# RABBITMQ_USER=plane_prod_celery_svc
# RABBITMQ_PASSWORD=X7k9mP2vR8sQ3nL6wE1tY4uI0oP5zA8bN7cV2xF9
# RABBITMQ_VHOST=/plane_production
# ===========================================
# Celery Configuration
# ===========================================
# Development
CELERY_BROKER_URL=pyamqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672/${RABBITMQ_VHOST}
# Production
# CELERY_BROKER_URL=pyamqp://plane_prod_celery_svc:X7k9mP2vR8sQ3nL6wE1tY4uI0oP5zA8bN7cV2xF9@rabbitmq:5672/plane_production
CELERY_RESULT_BACKEND=rpc://
CELERY_VECTOR_SYNC_INTERVAL=3
CELERY_VECTOR_SYNC_ENABLED=1
CELERY_WORKSPACE_PLAN_SYNC_ENABLED=1
CELERY_WORKSPACE_PLAN_SYNC_INTERVAL=86400
# ===========================================
# Feature Flag Configuration
# ===========================================
# Development
FEATURE_FLAG_SERVER_BASE_URL="https://preview.disco.plane.town"
FEATURE_FLAG_SERVER_AUTH_TOKEN="xxxxxxxxxxxx"
#Oauth
PLANE_OAUTH_CLIENT_ID=""
PLANE_OAUTH_CLIENT_SECRET=""
PLANE_OAUTH_REDIRECT_URI=""
PLANE_OAUTH_STATE_EXPIRY_SECONDS=82800
PLANE_OAUTH_URL_ENCRYPTION_KEY=""
ASSEMBLYAI_API_KEY="xxxxxxxxxxxx"
DEEPGRAM_API_KEY="xxxxxxxxxxxx"
+177
View File
@@ -0,0 +1,177 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
.ruff_cache/
mails
pgdata
gradio_app.py
*.env
/Analysis
# C extensions
*.so
pi/app/data/
*.ipynb
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
logs/
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# vscode
.vscode/
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
*.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
*.DS_Store
ell.db
node_modules
# test artifacts
pi/tests/api_test_results_*.json
+37
View File
@@ -0,0 +1,37 @@
repos:
- repo: local
hooks:
- id: lint-check
name: Ruff Linting Check
entry: ruff check .
language: system
always_run: true
pass_filenames: false
- id: import-sort
name: Sort Python Imports
entry: ruff
args: ["check", "--select", "I", "--fix", "."]
language: system
always_run: true
pass_filenames: false
- id: code-format
name: Format Python Code
entry: ruff format
args: ["--line-length", "150", "."]
language: system
always_run: true
pass_filenames: false
fail_fast: false
stages: [commit] # Only run during commit, not during CI
- id: cleanup-empty-lines
name: Clean Empty Lines at EOF
entry: bash -c 'for file in $(git diff --cached --name-only --diff-filter=ACM | grep -E "\.py$"); do sed -i "" -e :a -e "/^\n*$/{$d;N;};/\n$/ba" "$file"; done'
language: system
always_run: true
pass_filenames: false
- id: Type Check Python Code
name: mypy
entry: mypy --config-file mypy.ini --show-error-codes
language: system
always_run: true
pass_filenames: false
+34
View File
@@ -0,0 +1,34 @@
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libffi-dev \
libjpeg-dev \
gcc \
curl \
git \
wget \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt && \
pip install --no-cache-dir -e .
FROM python:3.12-slim AS base-runtime
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12 /usr/local/lib/python3.12
COPY --from=builder /usr/local/bin /usr/local/bin
COPY --from=builder /app/pi /app/pi
COPY --from=builder /app/bin /app/bin
ENV PYTHONPATH=/app
VOLUME [ "/var/tls" ]
CMD ["./bin/entrypoint-api.sh"]
+45
View File
@@ -0,0 +1,45 @@
.PHONY: build start stop logs restart clean
# Load .env file
include .env
export
# Define services
SERVICES := fastapi vectordb-feeder
# Default to local environment if not specified
# Set environment based on DEBUG value
ENV := $(if $(filter 9,$(DEBUG)),local,$(if $(filter 1,$(DEBUG)),preview,local))
COMPOSE_FILE := docker-compose-$(ENV).yml
# Generic commands that can take a service name as argument
build-%:
docker compose -f $(COMPOSE_FILE) up -d --build $*
start-%:
docker compose -f $(COMPOSE_FILE) up -d $*
stop-%:
docker compose -f $(COMPOSE_FILE) stop $*
logs-%:
docker compose -f $(COMPOSE_FILE) logs -f $*
# Generic commands for all services
build:
docker compose -f $(COMPOSE_FILE) up -d --build
start:
docker compose -f $(COMPOSE_FILE) up -d
stop:
docker compose down
logs:
docker compose -f $(COMPOSE_FILE) logs -f
restart:
docker compose down && docker compose -f $(COMPOSE_FILE) up -d
clean:
docker compose down -v
+1
View File
@@ -0,0 +1 @@
# Plane Intelligence
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
set -e
echo "Starting Plane PI API server..."
# Set default values if not provided
export FASTAPI_APP_HOST=${FASTAPI_APP_HOST:-0.0.0.0}
export FASTAPI_APP_PORT=${FASTAPI_APP_PORT:-8000}
echo "API Host: $FASTAPI_APP_HOST"
echo "API Port: $FASTAPI_APP_PORT"
# Start the FastAPI application
python -m pi.manage wait-for-db
python -m pi.manage runserver
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
set -e
echo "Starting Plane PI Celery Beat Scheduler..."
# Set default values if not provided
export CELERY_LOGLEVEL=${CELERY_LOGLEVEL:-info}
export CELERY_SCHEDULE_FILE=${CELERY_SCHEDULE_FILE:-/app/celerybeat-schedule/schedule.db}
echo "Log Level: $CELERY_LOGLEVEL"
echo "Schedule File: $CELERY_SCHEDULE_FILE"
# Ensure the schedule directory exists
mkdir -p $(dirname "$CELERY_SCHEDULE_FILE")
# Start the Celery beat scheduler
python -m pi.scripts.celery_runner beat --loglevel=$CELERY_LOGLEVEL --schedule=$CELERY_SCHEDULE_FILE
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
set -e
echo "Starting Plane PI Celery Worker..."
export CELERY_CONCURRENCY=${CELERY_CONCURRENCY:-2}
export CELERY_LOGLEVEL=${CELERY_LOGLEVEL:-info}
export CELERY_QUEUE=${CELERY_QUEUE:-${CELERY_DEFAULT_QUEUE:-plane_pi_queue}}
echo "Concurrency: $CELERY_CONCURRENCY"
echo "Log Level: $CELERY_LOGLEVEL"
echo "Queue: $CELERY_QUEUE"
python -m pi.scripts.celery_runner worker --concurrency=$CELERY_CONCURRENCY --loglevel=$CELERY_LOGLEVEL --queue=$CELERY_QUEUE
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
set -e
echo "Starting Plane PI Migrator..."
python -m pi.manage bootstrap-db
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -e
echo "Starting Plane PI Vector Database Feeder..."
# Display configuration
echo "Workspace ID: ${DEV_WORKSPACE_ID:-<not set>}"
echo "Feed Issues: ${FEED_ISSUES_DATA:-0}"
echo "Feed Pages: ${FEED_PAGES_DATA:-0}"
echo "Feed Docs: ${FEED_DOCS_DATA:-0}"
echo "Batch Size: ${BATCH_SIZE:-64}"
# Start the vectorization process
exec python3 -m pi.vectorizer.vectorize
+172
View File
@@ -0,0 +1,172 @@
x-common-env: &common-env
DEBUG: ${DEBUG}
FOLLOWER_POSTGRES_URI: ${FOLLOWER_POSTGRES_URI}
DOCS_GITHUB_API_TOKEN: ${DOCS_GITHUB_API_TOKEN}
OPENSEARCH_URL: ${OPENSEARCH_URL}
OPENSEARCH_USER: ${OPENSEARCH_USER}
OPENSEARCH_PASSWORD: ${OPENSEARCH_PASSWORD}
OPENSEARCH_ML_MODEL_ID: ${OPENSEARCH_ML_MODEL_ID}
OPENSEARCH_INDEX_PREFIX: ${OPENSEARCH_INDEX_PREFIX}
PLANE_PI_INTERNAL_API_URL: ${PLANE_PI_INTERNAL_API_URL}
PLANE_PI_INTERNAL_API_SECRET: ${PLANE_PI_INTERNAL_API_SECRET}
PLANE_PI_DATABASE_URL: ${PLANE_PI_DATABASE_URL}
x-app-env: &app-env
PI_BASE_PATH: ${PI_BASE_PATH}
FASTAPI_APP_HOST: ${FASTAPI_APP_HOST}
FASTAPI_APP_PORT: ${FASTAPI_APP_PORT}
FASTAPI_APP_WORKERS: ${FASTAPI_APP_WORKERS}
PLANE_API_HOST: ${PLANE_API_HOST}
PLANE_FRONTEND_URL: ${PLANE_FRONTEND_URL}
PLANE_OAUTH_CLIENT_ID: ${PLANE_OAUTH_CLIENT_ID}
PLANE_OAUTH_CLIENT_SECRET: ${PLANE_OAUTH_CLIENT_SECRET}
PLANE_OAUTH_REDIRECT_URI: ${PLANE_OAUTH_REDIRECT_URI}
PLANE_OAUTH_STATE_EXPIRY_SECONDS: ${PLANE_OAUTH_STATE_EXPIRY_SECONDS:-82800}
PLANE_OAUTH_URL_ENCRYPTION_KEY: ${PLANE_OAUTH_URL_ENCRYPTION_KEY}
DOCS_WEBHOOK_SECRET: ${DOCS_WEBHOOK_SECRET}
OPENAI_API_KEY: ${OPENAI_API_KEY}
CLAUDE_API_KEY: ${CLAUDE_API_KEY}
MISTRAL_API_KEY: ${MISTRAL_API_KEY}
GROQ_API_KEY: ${GROQ_API_KEY}
GEMINI_API_KEY: ${GEMINI_API_KEY}
LITE_LLM_HOST: ${LITE_LLM_HOST}
LITE_LLM_API_KEY: ${LITE_LLM_API_KEY}
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SESSION_COOKIE_NAME: ${SESSION_COOKIE_NAME}
PLANE_PI_POSTGRES_USER: ${PLANE_PI_POSTGRES_USER}
PLANE_PI_POSTGRES_PASSWORD: ${PLANE_PI_POSTGRES_PASSWORD}
PLANE_PI_POSTGRES_HOST: ${PLANE_PI_POSTGRES_HOST}
PLANE_PI_POSTGRES_PORT: ${PLANE_PI_POSTGRES_PORT}
PLANE_PI_POSTGRES_DB: ${PLANE_PI_POSTGRES_DB}
PLANE_PI_DATABASE_URL: ${PLANE_PI_DATABASE_URL}
PLANE_PI_INTERNAL_API_SECRET: ${PLANE_PI_INTERNAL_API_SECRET}
DEV_WORKSPACE_ID: ${DEV_WORKSPACE_ID}
DD_ENABLED: ${DD_ENABLED}
DD_ENV: ${DD_ENV}
DD_SERVICE: ${DD_SERVICE}
DD_AGENT_HOST: ${DD_AGENT_HOST}
DD_TRACE_SAMPLE_RATE: ${DD_TRACE_SAMPLE_RATE}
DD_LOGS_ENABLED: ${DD_LOGS_ENABLED:-false}
DD_TRACE_DEBUG: ${DD_TRACE_DEBUG:-false}
PLANE_PI_INTERNAL_API_URL: ${PLANE_PI_INTERNAL_API_URL}
FEATURE_FLAG_SERVER_BASE_URL: ${FEATURE_FLAG_SERVER_BASE_URL}
FEATURE_FLAG_SERVER_AUTH_TOKEN: ${FEATURE_FLAG_SERVER_AUTH_TOKEN}
ASSEMBLYAI_API_KEY: ${ASSEMBLYAI_API_KEY}
AWS_S3_BUCKET: ${AWS_S3_BUCKET}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_S3_ENV: ${AWS_S3_ENV}
AWS_S3_REGION: ${AWS_S3_REGION}
DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY}
x-feeder-env: &feeder-env
DEV_WORKSPACE_ID: ${DEV_WORKSPACE_ID}
FEED_ISSUES_DATA: ${FEED_ISSUES_DATA}
FEED_PAGES_DATA: ${FEED_PAGES_DATA}
FEED_DOCS_DATA: ${FEED_DOCS_DATA}
FEED_SLICES: ${FEED_SLICES}
BATCH_SIZE: ${BATCH_SIZE}
SCROLL_TIMEOUT: ${SCROLL_TIMEOUT}
x-celery-env: &celery-env
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-pyamqp://${RABBITMQ_USER:-admin}:${RABBITMQ_PASSWORD:-admin123}@rabbitmq:5672//}
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-rpc://}
CELERY_VECTOR_SYNC_ENABLED: ${CELERY_VECTOR_SYNC_ENABLED:-1}
CELERY_VECTOR_SYNC_INTERVAL: ${CELERY_VECTOR_SYNC_INTERVAL:-30}
CELERY_VECTOR_SYNC_MAX_RETRIES: ${CELERY_VECTOR_SYNC_MAX_RETRIES:-3}
CELERY_VECTOR_SYNC_RETRY_DELAY: ${CELERY_VECTOR_SYNC_RETRY_DELAY:-60}
CELERY_WORKSPACE_PLAN_SYNC_ENABLED: ${CELERY_WORKSPACE_PLAN_SYNC_ENABLED:-1}
CELERY_WORKSPACE_PLAN_SYNC_INTERVAL: ${CELERY_WORKSPACE_PLAN_SYNC_INTERVAL:-86400}
DEV_WORKSPACE_ID: ${DEV_WORKSPACE_ID}
PLANE_PI_INTERNAL_API_URL: ${PLANE_PI_INTERNAL_API_URL}
PLANE_PI_INTERNAL_API_SECRET: ${PLANE_PI_INTERNAL_API_SECRET}
PLANE_PI_DATABASE_URL: ${PLANE_PI_DATABASE_URL}
services:
# RabbitMQ - Message Broker for Celery
rabbitmq:
image: rabbitmq:3-management
container_name: plane-pi-rabbitmq
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-admin123}
RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-/}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "ping"]
interval: 30s
timeout: 10s
retries: 3
# FastAPI Application
fastapi:
build:
context: .
dockerfile: Dockerfile.api
ports:
- ${FASTAPI_APP_PORT}:${FASTAPI_APP_PORT}
volumes:
- ./pi:/app/pi
- ./bin:/app/bin
environment:
<<: [*app-env, *common-env, *celery-env]
depends_on:
- rabbitmq
command: ["/app/bin/entrypoint-api.sh"]
# Celery Worker - Processes vector sync tasks
celery-worker:
build:
context: .
dockerfile: Dockerfile.api
container_name: plane-pi-celery-worker
command: ["/app/bin/entrypoint-celery-worker.sh"]
volumes:
- ./pi:/app/pi
- ./bin:/app/bin
environment:
<<: [*common-env, *celery-env]
depends_on:
rabbitmq:
condition: service_healthy
restart: unless-stopped
# Celery Beat - Schedules periodic tasks
celery-beat:
build:
context: .
dockerfile: Dockerfile.api
container_name: plane-pi-celery-beat
command: ["/app/bin/entrypoint-celery-beat.sh"]
volumes:
- ./pi:/app/pi
- ./bin:/app/bin
- celery_beat_schedule:/app/celerybeat-schedule
environment:
<<: [*common-env, *celery-env]
depends_on:
rabbitmq:
condition: service_healthy
restart: unless-stopped
# Vector Database Feeder
# vectordb-feeder:
# build:
# context: .
# dockerfile: Dockerfile.api
# command: ["/app/bin/start-vectorizer.sh"]
# volumes:
# - ./pi:/app/pi
# - ./bin:/app/bin
# environment:
# <<: [*feeder-env, *common-env]
volumes:
rabbitmq_data:
celery_beat_schedule:
+17
View File
@@ -0,0 +1,17 @@
[mypy]
warn_unused_configs = True
files = pi
ignore_missing_imports = True
check_untyped_defs = True
explicit_package_bases = True
warn_unreachable = True
warn_redundant_casts = True
# Exclude the entire alembic folder
exclude = ^(pi/alembic/)
[mypy-pi.core.vectordb.application]
ignore_errors = True
[mypy-pi.app.models.base]
ignore_errors = True
+4
View File
@@ -0,0 +1,4 @@
from .config import logger
from .config import settings
__all__ = ["logger", "settings"]
View File
+3
View File
@@ -0,0 +1,3 @@
from .base import text2sql
__all__ = ["text2sql"]
+656
View File
@@ -0,0 +1,656 @@
import json
from importlib.resources import read_text
from typing import Any
from typing import Dict
from typing import List
from typing import Union
from typing import cast
from uuid import UUID
from langchain.schema import AIMessage
from langchain.schema import HumanMessage
from langchain.schema import SystemMessage
from pydantic import BaseModel
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from sqlglot import exp
from sqlglot import parse_one
from sqlmodel.ext.asyncio.session import AsyncSession
from pi import logger
from pi.app.models.enums import MessageMetaStepType
from pi.services.llm.error_handling import llm_error_handler
from pi.services.llm.llms import get_sql_agent_llm
from pi.services.schemas.chat import QueryFlowStore
from .prompts import TABLE_SELECTION
from .prompts import get_sql_generator
from .schemas import TableSelectionResponse
from .tools import _fix_group_by_order_by_mismatch_parsed
from .tools import _get_available_tables
from .tools import execute_sql_query
from .tools import fix_group_by_order_by_mismatch
from .tools import format_column_context
from .tools import generate_cte_query
from .tools import get_column_details
from .tools import get_table_schemas
log = logger.getChild(__name__)
console = Console()
def _create_message_with_attachments(content: str, attachment_blocks: list[dict[str, Any]] | None = None) -> HumanMessage:
"""Create a HumanMessage with optional attachment blocks."""
if attachment_blocks:
from pi.services.chat.utils import format_message_with_attachments
# format_message_with_attachments returns List[Dict[str, Any]]
# The content blocks are compatible with HumanMessage at runtime
formatted_blocks = format_message_with_attachments(content, attachment_blocks)
return HumanMessage(content=formatted_blocks) # type: ignore[arg-type]
else:
return HumanMessage(content=content)
def log_panel_info(title: str, content: str, style: str = "bold green"):
panel = Panel(content, title=title, style=style)
console.print(panel, end="")
def log_table_info(title: str, column_title: str, rows: list[str], column_style: str = "cyan"):
table = Table(title=title)
table.add_column(column_title, justify="left", style=column_style, no_wrap=True)
for row in rows:
table.add_row(row)
console.print(table, end="")
def log_relevant_tables_info(chat_id: str, relevant_tables: list[str], iteration: int):
content = f"{", ".join(relevant_tables)}"
log_panel_info(f"Relevant Tables {iteration} - ChatID: {chat_id}", content)
def log_final_relevant_tables_info(chat_id: str, relevant_tables: list[str]):
log_table_info(f"Relevant Tables for ChatID: {chat_id}", "Table Name", relevant_tables)
def log_generated_sql_info(chat_id: str, sql_query: str):
log_table_info(f"Generated SQL Query for ChatID: {chat_id}", "SQL Query", [sql_query], column_style="green")
# Define Message type
Message = Union[SystemMessage, HumanMessage, AIMessage]
# LLM Configuration
# Note: Create base models, but avoid setting tracking context on shared singletons.
# Derive per-call instances below to prevent context collisions.
table_selection_model = get_sql_agent_llm("table_selection")
sql_generation_model = get_sql_agent_llm("sql_generation")
# Note: structured_table_selection_model is now created dynamically in _perform_table_selection_llm_call
# to ensure proper token tracking context
# Table Mapping and Groupings
hallucinated_table_mapping: dict[str, dict[str, Any]] = json.loads(read_text("pi.agents.sql_agent.store", "hallucinated-table-mapping.json"))
related_table_groupings: dict[str, list[str]] = json.loads(read_text("pi.agents.sql_agent.store", "related-table-groupings.json"))
# Helper function for table selection LLM call with error handling
@llm_error_handler(fallback_message="TABLE_SELECTION_FAILURE", max_retries=2, temp_increment=0.1, log_context="[SQL_TABLE_SELECTION]")
async def _perform_table_selection_llm_call(
langchain_messages: List[Message], message_id: UUID, db: AsyncSession, llm_model: str | None = None
) -> Any:
"""Perform the actual LLM call for table selection with error handling."""
# Create structured model dynamically and set tracking context
# Use the provided model or fall back to the global one
if llm_model:
table_selection_model_instance = get_sql_agent_llm("table_selection", llm_model)
else:
table_selection_model_instance = table_selection_model
structured_table_selection_model = table_selection_model_instance.with_structured_output(TableSelectionResponse, include_raw=True) # type: ignore[arg-type]
structured_table_selection_model.set_tracking_context(message_id, db, MessageMetaStepType.SQL_TABLE_SELECTION) # type: ignore[attr-defined]
return await structured_table_selection_model.ainvoke(langchain_messages)
# Function to select relevant tables for SQL query generation
async def select_relevant_tables(
messages: List[Message], focus_id: str, db: AsyncSession, message_id: UUID, llm_model: str | None = None
) -> List[Dict[str, Any]]:
"""Select tables relevant to the user query, ensuring focus_id column is included.
Args:
messages: List of user messages containing the query
focus_id: Column ID to ensure is present (project_id or workspace_id)
db: Database session for token tracking
message_id: Message ID for tracking
Returns:
List containing the structured response with relevant tables
"""
updated_table_selection = (
TABLE_SELECTION
+ f"- Ensure that the {focus_id} column is present in the selected tables. If it's not, examine relationships and add related tables as necessary.\n\n" # noqa: E501
+ "Please proceed with your analysis and table selection."
)
# Prepare messages for the LLM
langchain_messages: List[Message] = [SystemMessage(content=updated_table_selection)]
for msg in messages:
if isinstance(msg, HumanMessage):
langchain_messages.append(msg)
# Use error handler for table selection LLM call
response = await _perform_table_selection_llm_call(langchain_messages, message_id, db, llm_model)
# Handle failure case
if response == "TABLE_SELECTION_FAILURE":
log.error("Table selection failed after all retries")
return [{"relevant_tables": []}]
# Get the parsed structured response for the actual data
parsed_response = response.get("parsed") if isinstance(response, dict) else response
response_dict: Dict[str, Any]
if isinstance(parsed_response, BaseModel):
# Convert Pydantic model to a plain dictionary.
response_dict = parsed_response.model_dump()
elif isinstance(parsed_response, dict):
# Already a dictionary cast for clarity.
response_dict = cast(Dict[str, Any], parsed_response)
else:
# Fallback to an empty dictionary for unexpected response types.
response_dict = {}
return [response_dict]
# Helper function for SQL generation LLM call with error handling
@llm_error_handler(fallback_message="SQL_GENERATION_FAILURE", max_retries=2, temp_increment=0.1, log_context="[SQL_GENERATION]")
async def _perform_sql_generation_llm_call(
langchain_messages: List[Message], message_id: UUID, db: AsyncSession, llm_model: str | None = None
) -> Any:
"""Perform the actual LLM call for SQL generation with error handling."""
# Derive a fresh per-call instance to avoid shared-instance tracking context overlap
if llm_model:
per_call_sql_model = get_sql_agent_llm("sql_generation", llm_model)
else:
per_call_sql_model = sql_generation_model
per_call_sql_model.set_tracking_context(message_id, db, MessageMetaStepType.SQL_GENERATION) # type: ignore[attr-defined]
return await per_call_sql_model.ainvoke(langchain_messages)
# Function to generate SQL query using LangChain
async def sql_generation(
messages: List[Message], modified_sql_generator: str, db: AsyncSession, message_id: UUID, llm_model: str | None = None
) -> str:
"""Generate SQL query based on user request and database schema.
Args:
messages: List of messages containing the user query
modified_sql_generator: Customized SQL generation prompt with schema details
db: Database session for token tracking
message_id: Message ID for tracking
Returns:
Generated SQL query as string
"""
# Prepare messages for the LLM
langchain_messages: List[Message] = [SystemMessage(content=modified_sql_generator)]
for msg in messages:
if isinstance(msg, HumanMessage):
langchain_messages.append(msg)
# Use error handler for SQL generation LLM call
response = await _perform_sql_generation_llm_call(langchain_messages, message_id, db, llm_model)
# Handle failure case
if response == "SQL_GENERATION_FAILURE":
log.error("SQL generation failed after all retries")
return "SELECT 'Error: Unable to generate SQL query due to processing limitations' as error_message;"
# Ensure we always return a string - fixed type handling
if hasattr(response, "content"):
return str(response.content)
else:
# Handle any other case by converting to string
return str(response)
# SQL generation function
async def text2sql(
db: AsyncSession,
query: str,
user_id: str,
query_flow_store: QueryFlowStore,
message_id: UUID,
project_id: str | None = None,
workspace_id: str | None = None,
chat_id: str | None = None,
vector_search_issue_ids: list[str] | None = None,
vector_search_page_ids: list[str] | None = None,
is_multi_agent: bool | None = None,
user_meta: dict[str, Any] | None = None,
conv_history: list[str] | None = None,
preset_tables: list[str] | None = None,
preset_sql_query: str | None = None,
preset_placeholders: list[str] | None = None,
attachment_blocks: list[dict[str, Any]] | None = None,
) -> tuple[Dict[str, Any], Dict[str, Any]]:
try:
# Initialize a single dictionary to collect all intermediate results
intermediate_results: Dict[str, Any] = {
"steps": [], # Initialize steps as an empty list to avoid KeyError later
"extracted_entity_ids": None,
"entity_urls": None,
}
# Create user message for both preset and regular flows
user_message = _create_message_with_attachments(query, attachment_blocks)
# Step One: Table Selection
relevant_tables: List[str]
if preset_tables:
# Use preset tables, skip LLM call
relevant_tables = preset_tables.copy()
query_flow_store["tool_response"] += f"Text2SSQL: Using Preset Tables: {relevant_tables}\n"
log.info(f"ChatID: {chat_id} - Using preset tables: {relevant_tables}")
else:
# Regular table selection with LLM
if project_id:
focus_id = "project_id"
else:
focus_id = "workspace_id"
messages: List[Message] = [user_message]
selection_res = []
relevant_tables = [] # await get_relevant_tables_from_cache(query)
if relevant_tables:
log.info(f"ChatID: {chat_id} - Relevant tables from cache: {relevant_tables}")
selection_res.append({"relevant_tables": relevant_tables})
else:
selection_res = await select_relevant_tables(
messages, focus_id=focus_id, db=db, message_id=message_id, llm_model=query_flow_store.get("llm")
)
if isinstance(selection_res, list) and selection_res and isinstance(selection_res[0], dict) and "relevant_tables" in selection_res[0]:
for idx, res in enumerate(selection_res):
iteration_relevant_tables = res["relevant_tables"] # Access as dictionary key
# log_relevant_tables_info(chat_id or "", iteration_relevant_tables, idx)
query_flow_store["tool_response"] += f"Text2SSQL: Relevant Tables {idx}: {iteration_relevant_tables}\n"
relevant_tables.extend(iteration_relevant_tables)
else:
log.error(f"ChatID: {chat_id} - Invalid format returned from table selection")
return (
{},
{
"sql_query": "Failed to retrieve data from the DB due to an error. Please try again later.",
"results": "Failed to retrieve data from the DB due to an error. Please try again later.",
"entity_urls": None,
},
)
relevant_tables = list(set(relevant_tables))
# Store table selection step in our intermediate results
intermediate_results["relevant_tables"] = relevant_tables
if not relevant_tables:
intermediate_results["relevant_tables"] = []
log.error(f"ChatID: {chat_id} - No relevant tables found in selection response.")
return (
intermediate_results,
{
"sql_query": "Failed to retrieve data from the DB due to an error. Please try again later.",
"results": "Failed to retrieve data from the DB due to an error. Please try again later.",
"entity_urls": None,
},
)
# Verifying relevant tables for known hallucinations
for table in relevant_tables:
if table in hallucinated_table_mapping:
relevant_tables.remove(table)
relevant_tables.extend(hallucinated_table_mapping[table])
# Adding related tables to handle cases where LLM misses out on some tables (especially the project_pages table)
for table in relevant_tables:
if table in related_table_groupings:
relevant_tables.extend(related_table_groupings[table])
# Add issue_assignees if both users and issues tables are present
# if "users" in relevant_tables and "issues" in relevant_tables and "issue_assignees" not in relevant_tables:
# relevant_tables.append("issue_assignees")
# Add issues table if issue_assignees is present
if "issue_assignees" in relevant_tables and "issues" not in relevant_tables:
relevant_tables.append("issues")
relevant_tables = list(set(relevant_tables))
# remove hallucinated tables that are not present in the table-list.json
all_tables = json.loads(read_text("pi.agents.sql_agent.store", "table-list.json"))
relevant_tables = [table for table in relevant_tables if table in all_tables]
# log_final_relevant_tables_info(chat_id or "", relevant_tables)
query_flow_store["tool_response"] += f"Text2SSQL: Final Table List: {relevant_tables}\n"
# Store table validation step in our intermediate results
intermediate_results["post_processed_relevant_tables"] = relevant_tables
# Step 2: Fetching whole schema for all the relevant tables
try:
relevant_tables_schemas = get_table_schemas(relevant_tables)
except Exception as e:
intermediate_results["schema_fetch_error"] = e
log.error(f"Error fetching table schemas for chat ID {chat_id}: {e}")
return (
intermediate_results,
{
"sql_query": "Failed to retrieve data from the DB due to an error. Please try again later.",
"results": "Failed to retrieve data from the DB due to an error. Please try again later.",
"entity_urls": None,
},
)
# Step 3: Adding context for sql generation
try:
column_context = get_column_details(relevant_tables)
formatted_column_context = format_column_context(column_context)
MODIFIED_SQL_GENERATOR = f"{get_sql_generator()}\n\n"
if len(column_context) > 0:
MODIFIED_SQL_GENERATOR += f"Below is the more detailed context of few columns:\n\n{formatted_column_context}\n"
# Add table descriptions for the relevant tables
table_descriptions = json.loads(read_text("pi.agents.sql_agent.store", "table-descriptions.json"))
for table in relevant_tables:
if table in table_descriptions:
desc = table_descriptions[table]
MODIFIED_SQL_GENERATOR += f"\n## Table `{table}` Description:\n"
MODIFIED_SQL_GENERATOR += f"**About**: {desc["about"]}\n"
MODIFIED_SQL_GENERATOR += f"**Contains**: {", ".join(desc["contains"])}\n"
MODIFIED_SQL_GENERATOR += f"**Does NOT contain**: {", ".join(desc["does_not_contain"])}\n"
except Exception as e:
intermediate_results["column_context_error"] = e
log.error(f"Error preparing SQL generation prompt for chat ID {chat_id}: {e}")
return (
intermediate_results,
{
"sql_query": "Failed to retrieve data from the DB due to an error. Please try again later.",
"results": "Failed to retrieve data from the DB due to an error. Please try again later.",
"entity_urls": None,
},
)
try:
column_affirmation = json.loads(read_text("pi.agents.sql_agent.store", "column-name-affirmations.json"))
for table, affirmations in column_affirmation.items():
if table in relevant_tables:
MODIFIED_SQL_GENERATOR += f"\n## Table `{table}`:\n"
for affirmation in affirmations:
MODIFIED_SQL_GENERATOR += f"\n{affirmation}\n"
except Exception as e:
log.error(f"Error fetching column affirmations for chat ID {chat_id}: {e}")
intermediate_results["column_affirmation_error"] = e
return (
intermediate_results,
{
"sql_query": "Failed to retrieve data from the DB due to an error. Please try again later.",
"results": "Failed to retrieve data from the DB due to an error. Please try again later.",
"entity_urls": None,
},
)
if is_multi_agent:
if vector_search_issue_ids:
MODIFIED_SQL_GENERATOR += "\n\nThe user is looking for work items with the following issue_ids:\n\n"
for issue_id in vector_search_issue_ids:
MODIFIED_SQL_GENERATOR += f"issue_id: {issue_id}\n"
if vector_search_page_ids:
MODIFIED_SQL_GENERATOR += "\n\nThe user is looking for pages with the following page_ids:\n\n"
for page_id in vector_search_page_ids:
MODIFIED_SQL_GENERATOR += f"page_id: {page_id}\n"
# Step 4: SQL Generation
generated_query: str
if preset_sql_query:
# Use preset SQL query, skip LLM call
generated_query = preset_sql_query.strip()
query_flow_store["tool_response"] += f"Text2SSQL: Using Preset SQL: {generated_query}\n"
# log.info(f"ChatID: {chat_id} - Using preset SQL query")
# Store SQL generation in our intermediate results
intermediate_results["generated_sql"] = generated_query
else:
# Regular SQL generation with LLM
try:
query_text = user_message.content
if project_id:
context_str = f"User's current project ID is {project_id}. "
elif workspace_id:
context_str = f"User's current workspace ID is {workspace_id}."
time_context_str = ""
if user_meta:
time_context = user_meta.get("time_context", "")
if time_context:
time_context_str = f"User's time context is: {str(time_context)}."
user_context = f"User's user_id is {user_id}. Consider this user_id only when the query is in first person or the user is referring to himself.\n{context_str}\n{time_context_str}" # noqa: E501
# Enhanced SQL generation prompt with conversation context
enhanced_sql_prompt = MODIFIED_SQL_GENERATOR
# Prepare the SQL query content
sql_content = (
f"Can you create SQL query for the user query: {query_text}\n\n"
f"Given the relevant tables and their schema:\n{relevant_tables_schemas}\n"
f"Below is some context about the user:\n\n{user_context}"
)
# Include attachments in SQL generation if present
sql_query_message = _create_message_with_attachments(sql_content, attachment_blocks)
sql_history: List[Message] = [sql_query_message]
sql_query = await sql_generation(
messages=sql_history,
modified_sql_generator=enhanced_sql_prompt,
db=db,
message_id=message_id,
llm_model=query_flow_store.get("llm"),
) # noqa: E501
generated_query = sql_query
# log_generated_sql_info(chat_id or "", generated_query or "")
# Store SQL generation in our intermediate results
intermediate_results["generated_sql"] = generated_query
except Exception as e:
log.error(f"Error during SQL generation for chat ID {chat_id}: {e}")
intermediate_results["sql_generation_error"] = e
query_flow_store["tool_response"] += f"\nText2SSQL: Error during SQL generation: {e}\n"
return (
intermediate_results,
{
"sql_query": "Failed to retrieve data from the DB due to an error. Please try again later.",
"results": "Failed to retrieve data from the DB due to an error. Please try again later.",
"entity_urls": None,
},
)
if not generated_query:
log.error(f"No SQL query was generated for chat ID {chat_id}.")
intermediate_results["generated_sql"] = []
return (
intermediate_results,
{
"sql_query": "Failed to retrieve data from the DB due to an error. Please try again later.",
"results": "Failed to retrieve data from the DB due to an error. Please try again later.",
"entity_urls": None,
},
)
# Generate CTE for tables that are not in the relevant tables but are in the generated query
try:
# Parse the generated query to extract table names
parsed_query = parse_one(generated_query, read="postgres")
generated_query_tables = set()
# Extract tables from all SELECT statements in the query
for select in parsed_query.find_all(exp.Select):
select_tables = _get_available_tables(select)
generated_query_tables.update(select_tables)
# Find extra tables that need to be added to CTE
extra_cte_tables = []
for table in generated_query_tables:
if table not in relevant_tables and table in all_tables:
extra_cte_tables.append(table)
tables_for_cte = relevant_tables + extra_cte_tables
except Exception as e:
log.warning(f"ChatID: {chat_id} - Could not parse generated query to extract tables: {e}")
# Fallback to using only relevant tables
tables_for_cte = relevant_tables
# Step 5: SQL Query Modification
try:
CTE_head = generate_cte_query(
member_id=user_id,
tables=tables_for_cte,
project_id=project_id,
workspace_id=workspace_id,
vector_search_issue_ids=vector_search_issue_ids,
vector_search_page_ids=vector_search_page_ids,
)
# Store CTE generation in our intermediate results
intermediate_results["cte_head"] = CTE_head
except Exception as e:
log.error(f"Error generating CTE query for chat ID {chat_id}: {e}")
intermediate_results["cte_generation_error"] = e
return (
intermediate_results,
{
"sql_query": "Failed to retrieve data from the DB due to an error. Please try again later.",
"results": "Failed to retrieve data from the DB due to an error. Please try again later.",
"entity_urls": None,
},
)
final_query = f"{CTE_head}\n{generated_query}"
# log.info("Final SQL Query:")
# log_generated_sql_info(chat_id or "", final_query or "")
# Parse final_query once for potential reuse in error handling
try:
parsed_final_query = parse_one(final_query, read="postgres")
except Exception as parse_error:
log.warning(f"ChatID: {chat_id} - Could not pre-parse final query: {parse_error}")
parsed_final_query = None
intermediate_results["final_query"] = final_query
# Step 6: SQL Execution
query_execution_result: Any
try:
# Handle placeholder substitution for preset queries
if preset_sql_query and preset_placeholders:
# Create parameter values list for preset queries
param_values = []
# Map placeholders to actual values
for placeholder in preset_placeholders:
if placeholder == "user_id":
param_values.append(user_id)
# Add more placeholder mappings as needed
# log.info(f"ChatID: {chat_id} - Executing preset query with parameters: {param_values}")
query_flow_store["tool_response"] += f"Text2SSQL: Executing with parameters: {param_values}\n"
# Execute with parameters using the modified execute_sql_query function
query_execution_result = await execute_sql_query(final_query, tuple(param_values))
else:
# Regular execution without parameters
query_execution_result = await execute_sql_query(final_query)
except Exception as e:
intermediate_results["sql_execution_error"] = e
log.error(f"Error executing SQL query for chat ID {chat_id}: {e} \n Generated SQL query that resulted in the error:\n {final_query}\n")
# Try to fix GROUP BY/ORDER BY issues and re-execute
try:
log.info(f"ChatID: {chat_id} - Attempting to fix GROUP BY/ORDER BY issues due to execution error")
# Apply GROUP BY fix using pre-parsed query if available
if parsed_final_query is not None:
fixed_query = _fix_group_by_order_by_mismatch_parsed(parsed_final_query, dialect="postgres")
else:
fixed_query = fix_group_by_order_by_mismatch(final_query, dialect="postgres")
# query_flow_store["tool_response"] += f"Text2SSQL: Query fixed, re-executing...\n"
# Try executing the fixed query
if preset_sql_query and preset_placeholders:
# Handle preset query with parameters
param_values = []
for placeholder in preset_placeholders:
if placeholder == "user_id":
param_values.append(user_id)
query_execution_result = await execute_sql_query(fixed_query, tuple(param_values))
else:
# Regular execution
query_execution_result = await execute_sql_query(fixed_query)
# Success! Update intermediate results and continue with normal flow
intermediate_results["fixed_query"] = fixed_query
intermediate_results["query_was_fixed"] = True
intermediate_results["final_query"] = fixed_query # Update to reflect the fixed query
# query_flow_store["tool_response"] += f"Text2SSQL: Fixed query executed successfully!\n"
# Update final_query for any downstream processing
final_query = fixed_query
log.info(f"ChatID: {chat_id} - Fixed query executed successfully!")
except Exception as fix_error:
# Either the fix failed or the fixed query also failed
if str(fix_error) != str(e): # Different error from the fix attempt
log.error(f"ChatID: {chat_id} - GROUP BY fix attempt also failed: {fix_error}")
# query_flow_store["tool_response"] += f"Text2SSQL: GROUP BY fix also failed: {fix_error}\n"
else:
log.error(f"ChatID: {chat_id} - GROUP BY fix didn't resolve the issue")
# query_flow_store["tool_response"] += f"Text2SSQL: GROUP BY fix didn't resolve the issue\n"
# Log the final error with both original and fixed queries for debugging
intermediate_results["fixed_query_execution_error"] = fix_error
log.error(f"Final error for chat ID {chat_id}: Original error: {e}, Fix error: {fix_error}")
# query_flow_store["tool_response"] += (
# f"Text2SSQL: Final error during SQL execution: {e}\nOriginal query:\n{final_query}\n"
# )
return (
intermediate_results,
{
"sql_query": "Failed to retrieve data from the DB due to an error. Please try again later.",
"results": "Failed to retrieve data from the DB due to an error. Please try again later.",
"entity_urls": None,
},
)
# logger.info(f"SQL Query Execution Result: {query_execution_result}")
# Format results with embedded URLs
# formatted_query_result = await format_as_bullet_points(query_execution_result)
response_data: Dict[str, Any] = {"sql_query": final_query, "results": query_execution_result}
return intermediate_results, response_data
except Exception as e:
log.error(f"Error in text2sql function: {e}")
query_flow_store["tool_response"] += f"Text2SSQL: error in text2sql function {e}\n"
query_execution_result = "There was an error pulling the data from the database. Please try again later."
return (intermediate_results, {"sql_query": query_execution_result, "results": query_execution_result, "entity_urls": None})
+187
View File
@@ -0,0 +1,187 @@
# flake8: noqa
import json
from importlib.resources import read_text
from typing import Any
# Lazy import to avoid circular dependency
# from pi.services.chat.prompts import plane_context
from pi.agents.sql_agent.tools import format_table_details
table_description_content: dict[str, dict[str, Any]] = json.loads(read_text("pi.agents.sql_agent.store", "table-descriptions.json"))
table_descriptions = format_table_details(table_description_content)
def _get_plane_context():
"""Get plane context with lazy import to avoid circular dependency."""
from pi.services.chat.prompts import plane_context
return plane_context
def _get_sql_generator():
"""Get SQL generator prompt with lazy context loading."""
return f"""
You are an expert PostgreSQL query generator. Your task is to generate an accurate PostgreSQL query based on the provided schema to answer the user's question at `Plane`, a project management software tool.
Plane Context:
{_get_plane_context()}
**You will be given relevant tables and their schema in the following JSON format:**
{{
"table_name": "corresponding schema for the table"
}}
**Instructions:**
1. **Analyze the Schema:** Carefully review each table's schema to understand the relationships between tables and the purpose of each column.
2. **Identify Relevant Tables and Columns:** Determine which tables and columns from the given schema are pertinent to fulfilling the user's request.
3. **Construct the SQL Query:**
- **Syntactic Correctness:** Ensure the SQL query is free from syntax errors.
- **Appropriate JOINs:** Utilize `JOIN` operations based on the relationships outlined in the provided schemas.
- **Avoid 'WITH' Clauses:** Do not use `WITH` clauses in the query.
- **Parameter Handling:** Do not use parameterized placeholders like $1, $2, etc. in the generated SQL. Use actual values, if available, directly in the query.
- **Filtering and Aggregation:**
- Apply necessary `WHERE` clauses, `GROUP BY`, `ORDER BY`, and other SQL clauses as required to address the user's intent.
- For every column used in SELECT, confirm whether it is part of GROUP BY or an aggregate function.
**Critical GROUP BY Rules:**
- Every single column shown in the SELECT clause that is not inside an aggregate function MUST be included in the GROUP BY clause
- This includes all columns from all tables, even primary keys and unique columns
- Example: If selecting a.col1, a.col2, b.col3, COUNT(*) then GROUP BY must include a.col1, a.col2, b.col3
- No column can be omitted from GROUP BY unless it's aggregated
- When avoiding duplicates without aggregation, use DISTINCT instead of GROUP BY.
- Do not use both DISTINCT and GROUP BY together unless specifically needed for complex aggregation scenarios.
- When using aggregate functions (COUNT, SUM, AVG, etc.), ensure every non-aggregated column in SELECT is in GROUP BY.
**ORDER BY Rules:**
- When using DISTINCT: ORDER BY columns must appear in the SELECT clause
- When using GROUP BY with aggregates: ORDER BY should reference columns from SELECT or use aggregate functions
- For regular SELECT queries: ORDER BY can reference any column from joined tables
- Always ensure ORDER BY columns exist in the available schema
- **Issue Key and Issue ID:**
- issue_id is the UUID of the issue, like 123e4567-e89b-12d3-a456-426614174000, etc., which is primarily used to retrieve the issue from the database.
- Issue key is the unique key of the issue, like PROJ-123, MOB-45, etc., which is primarily shown to the end user to refer to the issue.
- The issue key is generated by the system combining the 'project identifier' ('identifier' column from projects table) and the 'sequence_id' (from the issues table).
- **IMPORTANT**: If the issue_id (UUID) is available, ALWAYS use it directly to retrieve the issue from the database. Do NOT construct or use the issue key in such cases, as both refer to the same issue and using the UUID is more efficient and direct.
- Only use the issue key when the issue_id is not available and you need to construct a query based on the project identifier and sequence_id.
- In cases where neither issue_id nor issue key is available, you can use the issue title (if it is clear enough) to retrieve the issue from the database.
- Priority order: issue_id (UUID) > issue key > issue title
- **Human-Readable Output:**
- **Include primary-key UUIDs with proper aliases:**
- Always select the primary-key column id
- Cast UUIDs to text and use standardized aliases like tablename_id:
* For issues: cast issues.id as text and alias as issues_id
* For pages: cast pages.id as text and alias as pages_id
* For cycles: cast cycles.id as text and alias as cycles_id
* For modules: cast modules.id as text and alias as modules_id
- Example: `SELECT issues.id::text AS issues_id`
- This enables URL construction for the frontend so users can click on results
- Always include human-readable fields (e.g., name, title) in SELECT clause alongside IDs when available
- Priority fields to include:
- For users: first_name, last_name, or both - include both when full name display is needed (e.g., user listings, reports)
- For issues: name
- For projects: name
- For workspaces: name
- For states: name
- For labels: name
- Keep ID fields for proper joins but ensure the SELECT clause includes corresponding readable fields
- When joining tables, include descriptive fields from joined tables instead of just their IDs
- **Case Sensitivity:**
- For all text comparisons, ensure the query uses ILIKE instead of = or LIKE, unless the user explicitly specifies case-sensitive behavior.
- For numeric, date, UUID, or boolean fields, use = as appropriate.
- Ensure the query is optimized and does not apply case-insensitivity to non-text fields.
- Use wildcard characters (%) for pattern matching where necessary.
- **Issue Ownership vs Creation:**
- Ownership of an issue lies with the users assigned to it. Not with the ones who created it.
*Additional Instructions for Ownership and Creation:**
Phrases containing "my/mine" related to issues:
- "My issues" = Issues where the user is an assignee
- "My backlog" = Issues in backlog state where user is an assignee
- "Created by me" = Issues where created_by_id matches the user
- When both creation and ownership appear ("my issues that I created"), use BOTH
- JOIN with issue_assignees for ownership AND check created_by_id for creation
- **Date and Time:**
- All datetime columns in the database are stored as timestamps.
- Use CURRENT_TIMESTAMP instead of CURRENT_DATE when retrieving the current date and time.
- You will be provided with current date and time context in the user context section. Use this information to correctly interpret relative time references like "this month", "this year", "last week", "today", etc.
- **Optimization:** Optimize the query for performance where possible, avoiding unnecessary complexity.
- **Limit:** Always limit the query to a maximum of 20 rows. If the user explicitly specifies a limit less than 20, use the user-specified value. If no limit is specified, default to 20 rows.
4. **Adhere to Provided Schema:** Use only the tables and columns specified in the given schema. Do not introduce any additional tables or columns not mentioned.
5. **Output Formatting:**
- **Format:** DO NOT enclose the PostgreSQL query in triple backticks or code fences. PROVIDE JUST the plain SQL query as specified in the output format.
- **No Additional Text:** Do not include any explanations, comments, or additional text outside the SQL query block.
6. **Questions Related to Self**:
- If the user refers to himself (i, me, mine, or myself), always consider the user_id provided to you in the user context.
- If the user doesn't refer to himself, never consider his user_id. But, you should consider the workspace_id or project_id provided in the query.
7. **Links:**
- **IMPORTANT:** The `issue_links` table contains user-added external URLs (e.g., documentation links, reference materials) attached to issues. It does NOT contain URLs to view the issues themselves in Plane.
- Never use `issue_links` table to generate or retrieve URLs for viewing issues, pages, or other Plane entities.
- To enable users to click on and view issues/pages/etc., simply include the entity's ID (e.g., `issues.id::text AS issues_id`) in your SELECT clause. The application will automatically generate the proper Plane URLs from these IDs after query execution.
- The links to entities are programmatically generated by the system at a later stage, not stored in the database.
**Output Format:**
Provide the PostgreSQL query without any additional text or code fences. Do not include any explanations or additional text.
**Example Output Format:**
SELECT COUNT(*) FROM issues;
""" # noqa: E501
# Create a lazy-loaded SQL_GENERATOR using a function
def get_sql_generator():
return _get_sql_generator()
# For backward compatibility - this will be evaluated when first accessed
SQL_GENERATOR = None # Will be set lazily
TABLE_SELECTION = f"""
You are an AI Database Expert specializing in table selection for Plane, a project management software tool. Your task is to identify the most relevant tables from Plane's database for a given user query.
Here is the list of available tables in Plane's database with their descriptions:
<table_descriptions>
{table_descriptions}
</table_descriptions>
Your goal is to select only the tables that are directly relevant to fulfilling this query. Follow these steps:
1. Analyze the user's query to identify key elements and requirements.
2. Review each table in the table_descriptions, considering:
- The table's purpose and description
- What the table contains and does not contain
- The table's relationships with other tables
3. Categorize each table as "Highly Relevant", "Possibly Relevant", or "Not Relevant" to the query.
4. Select the tables that are essential for answering the query.
5. If a table contains the data directly related to the query, classify it as 'Highly Relevant' immediately.
6. Format your final response as a JSON object with a "relevant_tables" key containing an array of selected table names.
Before providing your final answer, wrap your analysis in <table_analysis> tags. In this analysis:
1. Summarize the key elements from the user query.
2. For each table in the table_descriptions:
a. Briefly describe its purpose
b. Explain its relevance (or lack thereof) to the query
c. Categorize it as "Highly Relevant", "Possibly Relevant", or "Not Relevant"
3. *Important Note*
Do not ignore tables whose descriptions directly match the query, even if they have relationships with other tables.
For example, for queries about "users," always consider the users table as Highly Relevant.
4. Justify your final selections based on this categorization
It's OK for this section to be quite long.
Example of the expected output format:
{{"relevant_tables": ["table1", "table2", "table3"]}}
Additional Instructions Related to Issue Ownership:
- Ownership of an issue lies with the users assigned to it. Not with the ones who created it.
- When determining ownership of an issue, always use the issue_assignees table as the primary source. Treat it as the most relevant table for any query about who an issue belongs to, such as ownership, assignments, or responsibility. Do not prioritize issues table alone for such cases.
- Phrases containing "my/mine" related to issues:
- "My issues" = Issues where the user is an assignee. Use issue_assignees for this.
- "My backlog" = Issues in backlog state where user is an assignee. Use issue_assignees for this.
- "Created by me" = Issues where created_by_id matches the user. Use issues table for this.
- When both creation and ownership appear ("my issues that I created"), use BOTH issue_assignees and issues.
Important requirements:
- Include ONLY tables that exist in the provided table_descriptions.
- Select only tables that are directly relevant to the query or necessary due to relationships.
- Do not generate SQL queries or any other extraneous information.
- Ensure your final output is a valid JSON object.
- Do not include any additional text or explanations outside the JSON object.
"""
+51
View File
@@ -0,0 +1,51 @@
# flake8: noqa
from typing import List
from typing import Literal
from typing import Dict
from typing import Optional
from pydantic import BaseModel
from pydantic import Field
# ===========================#
# ==== SQL AGENT =====#
# ===========================#
class SchemaRewriteResponse(BaseModel):
thinking: str = Field(description="Here you can think and talk to youself, and reason why and how you remove the redundant columns")
reflection: str = Field(description="Reflection on schema accuracy")
schema_creation: str = Field(
description="Here you can reflect on your previous thinking process and if you catch any mistakes, you can correct them, if you are confident, you can move forward to the next step. If you dont find any tables or columns and you are not confident about the schema might answer the user's question, you can reason them here and return NOT ENOUGH INFORMATION"
)
schema_refinement: str = Field(
description="If you think the schema is not accurate or wrong, you can do it here, else if the schema is accurate you can just skip this step"
)
final_schema: str = Field(
description="Here you can provide the final schema in SQL format without hallucinating any names of table and columns. Which will be next used to create the SQL query when paired with the user's question"
)
class TableSelectionResponse(BaseModel):
relevant_tables: List[str] = Field(description="List of relevant table names for the SQL query")
class QueryResultIds(BaseModel):
issues: List[str] = Field(default_factory=list, description="List of issue IDs from query results")
pages: List[str] = Field(default_factory=list, description="List of page IDs from query results")
cycles: List[str] = Field(default_factory=list, description="List of cycle IDs from query results")
modules: List[str] = Field(default_factory=list, description="List of module IDs from query results")
class EntityLink(BaseModel):
name: str = Field(description="Human-readable name of the entity")
id: str = Field(description="UUID of the entity")
url: str = Field(description="Deep-link URL to the entity")
type: str = Field(description="Type of entity (issue, page, cycle, module)")
class URLConstructionRequest(BaseModel):
entity_ids: QueryResultIds = Field(description="Entity IDs to construct URLs for")
api_base_url: str = Field(description="Base URL for the application")
workspace_slug: Optional[str] = Field(default=None, description="Workspace slug for URL construction")
project_id: Optional[str] = Field(default=None, description="Project ID for URL construction")
@@ -0,0 +1,11 @@
{
"users": [
"If you are using one of the columns: `first_name` or `last_name` in the query, note the presence of underscore."
],
"issues": [
"Note that the 'created_by_id' column doesn't indicate issue ownership."
],
"issue_worklogs": [
"The 'duration' field stores time in minutes, not hours or seconds."
]
}
@@ -0,0 +1,51 @@
{
"intake_issues": [
"issue_id"
],
"issue_comments": [
"issue_id"
],
"issue_attachments": [
"issue_id"
],
"cycle_issues": [
"issue_id"
],
"issue_mentions": [
"issue_id"
],
"issue_relations": [
"issue_id",
"related_issue_id"
],
"issue_sequences": [
"issue_id"
],
"issue_subscribers": [
"issue_id"
],
"issue_activities": [
"issue_id"
],
"issue_assignees": [
"issue_id"
],
"issue_labels": [
"issue_id"
],
"issue_links": [
"issue_id"
],
"issues": [
"id"
],
"module_issues": [
"issue_id"
],
"issue_worklogs": [
"issue_id"
],
"issue_property_values": [
"issue_id"
]
}
@@ -0,0 +1,17 @@
{
"pages": [
"id"
],
"page_labels": [
"page_id"
],
"project_pages": [
"page_id"
],
"page_logs": [
"page_id"
],
"page_versions": [
"page_id"
]
}
@@ -0,0 +1,6 @@
{
"issue_states": [
"issues",
"states"
]
}
@@ -0,0 +1,29 @@
{
"pages": [
"project_pages"
],
"project_attributes": [
"project_states"
],
"intakes": [
"intake_issues"
],
"issues": [
"states"
],
"issue_assignees": [
"users"
],
"issue_activities": [
"users"
],
"project_members": [
"users"
],
"workspace_members": [
"users"
],
"issue_relations": [
"issues"
]
}
@@ -0,0 +1,229 @@
[
{
"table": "intake_issues",
"columns": [
{
"name": "status",
"description": "Enumerated states representing the status of an issue in intake",
"enumerations": [
{
"identifier": -2,
"description": "Pending"
},
{
"identifier": -1,
"description": "Rejected"
},
{
"identifier": 0,
"description": "Snoozed"
},
{
"identifier": 1,
"description": "Accepted"
},
{
"identifier": 2,
"description": "Duplicate"
}
]
}
]
},
{
"table": "project_members",
"columns": [
{
"name": "role",
"description": "Enumerated role representing the role of a memebr in project",
"enumerations": [
{
"identifier": 5,
"description": "Guest"
},
{
"identifier": 15,
"description": "Member"
},
{
"identifier": 20,
"description": "Admin"
}
]
}
]
},
{
"table": "workspace_members",
"columns": [
{
"name": "role",
"description": "Enumerated role representing the role of a memebr in workspace",
"enumerations": [
{
"identifier": 5,
"description": "Guest"
},
{
"identifier": 15,
"description": "Member"
},
{
"identifier": 20,
"description": "Admin"
}
]
}
]
},
{
"table": "issue_relations",
"columns": [
{
"name": "relation_type",
"description": "The type of relationship between two issues.",
"distinct_values_in_column": [
"duplicate",
"finish_before",
"blocked_by",
"start_before",
"relates_to"
]
}
]
},
{
"table": "issue_activities",
"columns": [
{
"name": "verb",
"description": "The action that was performed on field present in 'field' column for the given issue.",
"distinct_values_in_column": [
"created",
"deleted",
"removed",
"updated"
]
},
{
"name": "field",
"description": "The name of the field on which activity is performed.",
"distinct_values_in_column": [
"archived_at",
"assignees",
"attachment",
"blocked_by",
"blocking",
"comment",
"cycles",
"description",
"draft",
"duplicate",
"estimate_point",
"finish_before",
"finish_after",
"intake",
"issue",
"labels",
"link",
"modules",
"name",
"parent",
"priority",
"project",
"reaction",
"relates_to",
"sequence_id",
"start_before",
"start_after",
"start_date",
"state",
"target_date",
"type",
"vote"
]
},
{
"name": "new_value",
"description": "The new value (string) of the field after the update."
},
{
"name": "old_value",
"description": "The old value (string) of the field before the update."
},
{
"name": "new_identifier",
"description": "The identifier (UUID) of the new value of the field after the update."
},
{
"name": "old_identifier",
"description": "The identifier (UUID) of the old value of the field before the update."
}
]
},
{
"table": "states",
"columns": [
{
"name": "group",
"description": "The name of the group to which the issue state belongs.",
"distinct_values_in_column": [
"started",
"backlog",
"completed",
"cancelled",
"unstarted"
]
}
]
},
{
"table": "issue_attachments",
"columns": [
{
"name": "attributes",
"description": "Attributes of the attachment (size, name).",
"fields_in_jsonb_object": [
"name",
"size"
]
}
]
},
{
"table": "project_states",
"columns": [
{
"name": "group",
"description": "The name of the group to which the project state belongs.",
"distinct_values_in_column": [
"monitoring",
"draft",
"completed",
"cancelled",
"planning",
"execution"
]
}
]
},
{
"table": "projects",
"columns": [
{
"name": "network",
"description": "Enumerated numbers representing the visibility of a project (public/ private).",
"enumerations": [
{
"identifier": 0,
"description": "Private"
},
{
"identifier": 2,
"description": "Public"
}
]
}
]
}
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
[
"intake_issues",
"intakes",
"importers",
"cycle_issues",
"estimate_points",
"estimates",
"issue_mentions",
"issue_relations",
"issue_sequences",
"issue_subscribers",
"issue_comments",
"issue_activities",
"issue_assignees",
"issue_attachments",
"issue_labels",
"issue_links",
"cycles",
"modules",
"issues",
"module_issues",
"module_links",
"labels",
"issue_views",
"issue_worklogs",
"module_members",
"project_identifiers",
"pages",
"page_labels",
"page_logs",
"page_versions",
"project_attributes",
"project_states",
"project_issue_types",
"project_members",
"project_pages",
"users",
"projects",
"states",
"issue_properties",
"issue_property_options",
"issue_property_values",
"issue_types",
"workspaces",
"workspace_members",
"team_space_members",
"team_space_labels",
"team_space_comments",
"team_spaces",
"team_space_pages",
"team_space_projects",
"stickies",
"issue_votes",
"initiative_activities",
"initiative_labels",
"initiative_projects",
"initiatives",
"initiative_links",
"initiative_comments",
"initiative_epics"
]
@@ -0,0 +1,674 @@
{
"intake_issues": {
"created_at": "2024-04-10T11:02:34.277260+00:00",
"updated_at": "2024-04-10T11:02:34.277274+00:00",
"id": "55aec070-afbc-4f95-b88a-f352b3cafda7",
"status": -2,
"snoozed_till": null,
"created_by_id": "562f4177-8552-45ae-8d3c-349ec383e057",
"duplicate_to_id": "e4ea287b-13e4-4b0e-9e45-510c46e666ae",
"intake_id": "ae1950ae-c419-4098-a3a0-c8578c728e9d",
"issue_id": "f1762e8b-f0f4-4b7d-a31d-e0aa0a737f7d",
"project_id": "836f1ae3-e8db-4269-b546-1af00abf39dd",
"updated_by_id": "8a0a0028-6c2d-418d-8751-34ea882d5f5c",
"workspace_id": "ac72dfd7-0174-4606-b2e4-f300ff48c28e",
"deleted_at": null
},
"intakes": {
"created_at": "2024-06-14T07:14:36.065975+00:00",
"updated_at": "2024-06-14T07:14:36.065984+00:00",
"id": "ad2aacaf-474f-4c04-b256-9c72d2865045",
"name": "Web Application Inbox",
"description": "",
"is_default": true,
"created_by_id": "bb84b50f-5b27-40e2-844b-ef178b7825b2",
"project_id": "8e5612fe-2736-45f9-ab62-c3de16fab15b",
"updated_by_id": "cfb922d3-fd86-45d4-993c-f7b5c33f3842",
"workspace_id": "1ac0e2d5-f2ba-4f63-b0c6-d309cf807f1a",
"deleted_at": null
},
"issue_comments": {
"created_at": "2024-03-19T10:04:57.565178+00:00",
"updated_at": "2024-03-19T10:04:57.565192+00:00",
"id": "a3341971-7169-4f75-bc72-733a5ac83846",
"comment_stripped": "@john and @nick to have a look at the architecture",
"created_by_id": "ef1826a6-4ddc-4cb3-a1d7-d6bcb4a51ccb",
"issue_id": "1a629b8c-7600-4fde-9426-31d4f793a480",
"project_id": "33780571-75c3-4ecb-a92c-d605924ee4da",
"updated_by_id": "01474895-428d-462e-961b-943ff6b7a615",
"workspace_id": "81a06dcd-3734-4c42-a780-6887333052cc",
"actor_id": "33dda971-7554-45ba-9bde-64fa4984214f",
"comment_html": "<p>@john and @nick to have a look at the architecture<br></p>",
"access": "INTERNAL",
"deleted_at": null
},
"issue_attachments": {
"created_at": "2023-04-20T13:46:39.358331+00:00",
"updated_at": "2023-04-20T13:46:39.358344+00:00",
"id": "b47964fa-c3e1-4f63-8a1d-a0b61527d559",
"attributes": {
"name": "ANEXO Ia - \u00c1sale_logs.pdf",
"size": 3215139
},
"created_by_id": "e8c5a915-ca25-45de-aefc-eb538f1187b6",
"issue_id": "1acfaf71-6d84-4470-8339-9abb205a60d1",
"project_id": "a0aed384-706c-4540-8027-99c5de2296bc",
"updated_by_id": "5e2e4888-8737-4ebd-923c-85785815b437",
"workspace_id": "8eee9874-4af1-4be5-9e15-ee4ce9ee02df",
"deleted_at": null
},
"importers": {
"created_at": "2023-05-30T19:52:16.183076+00:00",
"updated_at": "2023-05-30T19:52:18.967772+00:00",
"id": "b21b87a0-0819-4b4b-8447-415ac1970126",
"service": "github",
"status": "completed",
"data": {
"users": []
},
"created_by_id": "1c8c56d5-19af-4439-a5ee-30b1e2d519fc",
"initiated_by_id": "8573b2be-7a2c-40d9-9335-48b08588bfdd",
"project_id": "3130578b-7f2b-4eaa-9b07-b239018af601",
"token_id": "c2968366-75df-4901-af77-e12aa5de227f",
"updated_by_id": "6bfadacd-70e3-4acf-aa87-d4a24a3b12a4",
"workspace_id": "875345e6-ff5c-4ed6-bd36-be72acd2aacd",
"imported_data": {
"issues": [
"a981106a-287e-4bf3-8540-c141c1e21368"
],
"labels": [
"408bef25-f273-4046-9579-f0fe2a1fb4eb",
"862e67b7-5952-49d3-b1bb-8eeb8e0bd31f",
"47177ed8-c743-460b-bf08-4ba90a6406de",
"12f12cfe-22fe-4c2c-b8bf-c7d329f1264f",
"26fc92c0-d2e9-4668-bf39-05ee7578137d",
"8c8bd7b5-13bb-4e6f-a471-ecd8f5c49f91",
"ed3f868c-9cac-4a3a-b615-64a02b18ac03",
"5b1cb00c-3a85-405b-ab2f-c38641b320f0",
"93e4cf40-251c-448e-adfe-1974dbcc7644",
"94f6b3ce-d8b2-452f-9b26-0dbae0122f47"
]
},
"deleted_at": null
},
"cycle_issues": {
"created_at": "2024-01-10T09:13:26.509993+00:00",
"updated_at": "2024-01-10T09:13:26.510008+00:00",
"id": "f49edb8e-b5c5-4da4-879a-2af5c9880358",
"created_by_id": "ca75292a-305e-4b2a-a5a1-83e5020d7b28",
"cycle_id": "73b25478-0e65-49cf-8272-4a022a7f034a",
"issue_id": "99c35df2-798e-4e79-aa3b-2aa60a20a60c",
"project_id": "108616e6-0b20-4eec-a3c4-13674717d9f5",
"updated_by_id": "4f536333-a4df-4bb5-a663-d414aaf892ea",
"workspace_id": "23d9b164-7c0e-4d39-a177-690b89958442",
"deleted_at": null
},
"estimate_points": {
"created_at": "2023-04-17T13:57:45.653170+00:00",
"updated_at": "2023-04-17T13:57:45.653184+00:00",
"id": "a475c0c8-c22c-4a92-aaba-86809501bce6",
"key": 0,
"description": "",
"value": "2",
"created_by_id": "cde005c3-48ec-4745-8d38-233ff32c7dd2",
"estimate_id": "e23c2275-aed1-4598-b1f3-9105e3f2340c",
"project_id": "aa27b451-d11d-4d5c-87b1-96a7a8af0176",
"updated_by_id": "f7207710-442f-44d1-a82e-38bfcdcfc2ec",
"workspace_id": "fae64447-e087-4b14-bf8e-93dc6309ced3",
"deleted_at": null
},
"estimates": {
"created_at": "2024-06-24T10:37:41.846337+00:00",
"updated_at": "2024-06-24T10:37:41.846350+00:00",
"id": "3b74c030-3c4d-4c1b-a1f5-96b0dc975998",
"name": "Points",
"description": "",
"created_by_id": "e47362b6-520c-46f1-8077-e45ea68bffd4",
"project_id": "629f9fbf-7aba-4629-b95c-dfef7a2c3dfd",
"updated_by_id": "d681f47d-2bac-4e0a-8939-21640674e441",
"workspace_id": "0a8fd08f-66de-4047-b9f8-04341af671d7",
"type": "points",
"last_used": true,
"deleted_at": null
},
"issue_mentions": {
"created_at": "2023-12-19T09:28:17.412553+00:00",
"updated_at": "2023-12-19T09:28:17.412570+00:00",
"id": "500f168e-7cf4-4256-a9e1-96043fd268a8",
"created_by_id": "40ca3fa1-0fdb-40d3-bfb3-1c9363748e8d",
"issue_id": "2d524c54-abf0-42af-9fbc-c27f2bbedf12",
"mention_id": "c41e08da-cfa2-46da-aede-a4021977359c",
"project_id": "e6b6a8dc-f8b4-461f-ae4f-c07aeb11e9e2",
"updated_by_id": "92492104-da13-4775-84ea-67570212b803",
"workspace_id": "db4c66fc-cecd-4905-b98a-40b72212301d",
"deleted_at": null
},
"issue_relations": {
"created_at": "2023-09-29T12:13:34.794656+00:00",
"updated_at": "2023-09-29T12:13:34.794675+00:00",
"id": "3fe1440b-49db-47c9-b923-0b5a6027595c",
"relation_type": "blocked_by",
"created_by_id": "bd3cda62-6c06-4057-ab5d-0c1aaf4a3267",
"issue_id": "79c99fe7-2a8f-4a88-aea0-a2c56a297d9b",
"project_id": "2285e89b-1f3e-44cd-b657-ca61166841db",
"related_issue_id": "45027d23-3b0f-4ca7-834c-6635adb9b70f",
"updated_by_id": "8d1a68a1-2704-40a0-85a0-8e52dba63aed",
"workspace_id": "b0cca0c5-32ab-411b-b0e7-4268e1c64558",
"deleted_at": null
},
"issue_sequences": {
"created_at": "2022-11-16T14:26:32.108353+00:00",
"updated_at": "2022-11-16T14:26:32.108381+00:00",
"id": "5bb06b1d-0e67-46df-aa2f-7770416931e5",
"sequence": 1,
"deleted": false,
"created_by_id": "29c83b45-28bd-4541-8b59-46cc0c8ac74b",
"issue_id": "cf8371d6-7340-472d-bce8-8be6096460c7",
"project_id": "aeb6393d-afa9-4663-8d45-c9c9355f3f1d",
"updated_by_id": "30a34bc4-6263-40e3-a669-82b3e422acc0",
"workspace_id": "6bdc9ab9-40a9-4653-b087-a7f5c2b33889",
"deleted_at": null
},
"issue_subscribers": {
"created_at": "2024-01-08T14:03:11.430900+00:00",
"updated_at": "2024-01-08T14:03:11.430916+00:00",
"id": "ac601976-fbde-4d45-abbf-ed3d672c1ec7",
"created_by_id": "43e6a3b4-ab71-47af-b125-5a56139adb9d",
"issue_id": "c055b4f8-ca27-4a7e-a501-4a3953770065",
"project_id": "bc2e703d-646a-4fb1-959f-badfe6db99ac",
"subscriber_id": "fd388c6c-6b53-4f95-8db9-17a831429971",
"updated_by_id": "c6891b29-b2f1-48cb-886b-4a9acc4b85b3",
"workspace_id": "7a4ade3f-639b-4422-bb41-3910487809df",
"deleted_at": null
},
"issue_activities": {
"created_at": "2024-06-20T11:32:55.323459+00:00",
"updated_at": "2024-06-20T11:32:55.323471+00:00",
"id": "14b165b8-a10b-44aa-b05c-1a60f21e2919",
"verb": "updated",
"field": "state",
"old_value": "Backlog",
"new_value": "Done",
"comment": "updated the state to",
"attachments": [],
"created_by_id": "3b75e5c5-cfde-456e-acc2-99d5707d391a",
"issue_id": "13a052fc-9ada-4a92-b313-015da23877fd",
"issue_comment_id": "d6a524e4-aaa8-47f6-baed-f1fb4e414fca",
"project_id": "04fa884d-ecb1-4411-99ad-65e221697cde",
"updated_by_id": "956c7b4e-ea29-4d86-a885-90b60e81c1e9",
"workspace_id": "f18738bd-0676-420f-9286-927d0d6cfdb0",
"actor_id": "103b14e7-8b4d-4fe6-9d99-cbd14e412c0e",
"new_identifier": "da679c98-b671-4dc9-9013-2ef2d726a52c",
"old_identifier": "e185c6b9-651e-42c5-b484-c1a9a1e42ca8",
"deleted_at": null
},
"issue_assignees": {
"created_at": "2022-11-16T14:27:18.963391+00:00",
"updated_at": "2022-11-16T14:27:18.963412+00:00",
"id": "d9584770-1841-4798-b5fc-5b337bf30330",
"assignee_id": "30ecfb4d-75f4-49b6-ad40-f0713ccbba33",
"created_by_id": "e18b9d6a-cc4e-4c36-8407-c7ae5b9d2775",
"issue_id": "1b6c39e3-34b3-432d-80ba-84bc5da82d8f",
"project_id": "c5fe5c6f-4ec0-4097-87fa-b0266c1f5735",
"updated_by_id": "c3648e8a-0b54-4a28-80bd-a3f9ed1e699a",
"workspace_id": "2dcb3602-8c32-4ba3-a231-eb20edfc27c8",
"deleted_at": null
},
"issue_labels": {
"created_at": "2024-09-20T12:09:35.316085+00:00",
"updated_at": "2024-09-20T12:09:35.316095+00:00",
"id": "ab3f7bf3-5a72-4866-9db7-2e9bc13cef2c",
"created_by_id": "1efc6a9d-3fee-4a70-80cb-64402c07232e",
"issue_id": "4483613e-9256-4eef-8cf2-0b35998c7f4c",
"label_id": "0c9fe479-5b7a-4b18-ac34-a47f6a2ec433",
"project_id": "39f9aa44-7d7f-4bef-a1bc-4085ca7fc1c0",
"updated_by_id": "684ad548-ac25-4291-8adf-181724d8b04c",
"workspace_id": "ee591667-84e9-45fb-b216-25b07eaf6aa0",
"deleted_at": null
},
"issue_links": {
"created_at": "2023-07-14T23:08:35.624615+00:00",
"updated_at": "2023-07-14T23:08:35.624634+00:00",
"id": "de107660-8b06-4c98-8565-73f2f5b847a8",
"title": "Video",
"url": "https://google.com",
"created_by_id": "643b04d0-3edf-4931-beb7-6451a73c5acc",
"issue_id": "a319ca42-ae5f-482f-bae7-505b709e4c6f",
"project_id": "3ed36778-162d-426d-b2fb-a8adea7e3738",
"updated_by_id": "70971e95-e087-414b-954f-554d947b7fed",
"workspace_id": "84c5e666-d02f-4102-9061-e6c8e49d4747",
"deleted_at": null
},
"cycles": {
"created_at": "2023-08-21T22:28:33.185320+00:00",
"updated_at": "2023-08-21T22:28:33.185336+00:00",
"id": "8886bc18-8711-4398-ae9a-e05a29ba2cd9",
"name": "Agents",
"description": "",
"start_date": "2023-08-21T00:00:00+00:00",
"end_date": "2023-08-28T00:00:00+00:00",
"created_by_id": "f68486b4-4d8a-4730-bd99-a66243b682bc",
"owned_by_id": "3026ff00-8c11-4ca0-9f40-6129a3da1921",
"project_id": "483d0643-98df-4893-84e8-102102a960f6",
"updated_by_id": "1aff228c-0151-442a-b088-cd4d62cb3a6b",
"workspace_id": "d0938f29-cd78-4792-8c65-1e48d05239f6",
"archived_at": null,
"deleted_at": null,
"timezone": "UTC"
},
"modules": {
"created_at": "2023-08-21T19:46:20.564866+00:00",
"updated_at": "2023-08-21T19:46:20.564883+00:00",
"id": "ea247469-9275-4ed5-bbce-2289a0dce404",
"name": "Frontend Wrapper",
"description": "",
"description_text": null,
"description_html": null,
"start_date": null,
"target_date": null,
"status": "backlog",
"created_by_id": "dfc8d617-ce95-49cd-80ec-5fd081c0e180",
"lead_id": "ee22db46-a058-4545-ada8-803a0859daf0",
"project_id": "d067a94c-253b-4ea1-a317-6be9b470ee30",
"updated_by_id": "8f3371f0-488d-4acf-b2a3-29c3a35ce2c0",
"workspace_id": "f3c7b4b1-acf7-4669-94d8-542648546507",
"archived_at": null,
"deleted_at": null
},
"issues": {
"created_at": "2024-01-22T06:31:38.357426+00:00",
"updated_at": "2024-01-22T06:33:26.274782+00:00",
"id": "95778c44-a7f0-4afe-939d-f3796f950336",
"name": "Add slider for temperature adjustment for AI calls",
"description": "",
"priority": "none",
"start_date": null,
"target_date": null,
"sequence_id": "f8904de0-d5c9-4cee-8a69-3d02241b8712",
"created_by_id": "1a1473c7-311f-4a0b-b4d6-ed584d52ae95",
"parent_id": "b8489bcc-f496-4470-a21c-490eef05817b",
"project_id": "814ccebf-4339-41ff-9a2f-f664ebd244b8",
"state_id": "f2929a73-041a-4ca3-8593-908649f0571c",
"updated_by_id": "0400e95e-4e59-4b81-8025-fb976344ada7",
"workspace_id": "08970844-d31b-4812-a013-eaee800d55cf",
"description_html": "<p></p>",
"description_stripped": "",
"completed_at": null,
"point": null,
"archived_at": null,
"is_draft": false,
"estimate_point_id": "75f5dcda-794d-4385-a637-829232e7d0dd",
"type_id": "ff8e5ec6-ea82-4784-bc4e-2cb83b4674a3",
"deleted_at": null
},
"module_issues": {
"created_at": "2023-01-10T19:13:16.882041+00:00",
"updated_at": "2023-01-10T19:13:16.882063+00:00",
"id": "9b3b36a1-ff96-437a-8ea0-9d811fce190c",
"created_by_id": "8b2e9536-313f-459e-b82d-28c53641d003",
"issue_id": "11898f6d-463b-480c-88ca-f2645098e3e0",
"module_id": "b901ee4d-ad42-4631-8b85-6fa3ce8bf83b",
"project_id": "df77cfea-ac7a-403f-a0eb-ba534b18a98b",
"updated_by_id": "364feff9-36ff-4640-bcce-bae3d9d55896",
"workspace_id": "cb8131d6-9a04-4507-9c6b-0c4b4d435664",
"deleted_at": null
},
"module_links": {
"created_at": "2023-01-10T19:13:08.736058+00:00",
"updated_at": "2023-01-10T19:13:08.736082+00:00",
"id": "5ed5e546-f678-43ea-8da1-ad67b18d45cd",
"title": "Plane Mpdule",
"url": "https://app.plane.so/add/projects/72f7b410-ff9b-47bb-a642-074965cdbbe9/issues",
"created_by_id": "675881dc-3b0a-491f-8e5f-163aa8cda7ed",
"module_id": "59fb0ac3-f082-4b5e-8499-3463b397998b",
"project_id": "dff068a3-fa36-4200-b709-d19c38cb94f5",
"updated_by_id": "d4e357a9-bade-4b36-abe2-8f2b0ad8425d",
"workspace_id": "96b5d6df-b9df-4c9d-a443-de2a96a34fff",
"deleted_at": null
},
"labels": {
"created_at": "2023-12-18T12:14:41.158431+00:00",
"updated_at": "2023-12-18T12:14:41.158455+00:00",
"id": "bc984830-1ddf-4107-8363-4b6eec9fa3cc",
"name": "Tasks",
"description": "",
"created_by_id": "e6850928-cf47-4da2-af34-475fbec3f3c7",
"project_id": "9cae74b5-3de0-4ee4-8501-edccd53c1a8a",
"updated_by_id": "3e2a257c-cb57-475d-8da3-7876ebc4e3da",
"workspace_id": "a748655d-9958-4c5e-94a8-a07efa231568",
"parent_id": "cf77d4f5-c720-4948-99d6-2ba452381d52",
"deleted_at": null
},
"issue_views": {
"created_at": "2023-04-02T19:50:35.432670+00:00",
"updated_at": "2023-04-02T19:50:35.432702+00:00",
"id": "63460eb0-7519-4908-b00d-74b2000a77b9",
"name": "test",
"description": "ddddd",
"access": 1,
"created_by_id": "d19e36db-b630-4795-920f-eb8335f4d019",
"project_id": "829dad6e-c14f-402f-b159-384497104e29",
"updated_by_id": "03336bfb-8857-4a7c-90d2-b83a5f3fedfd",
"workspace_id": "f48d9bbd-3e76-42ae-8953-100409ecd8b7",
"is_locked": false,
"owned_by_id": "663a9fa1-41e9-437a-afd8-5f114169184f",
"deleted_at": null
},
"issue_worklogs": {
"created_at": "2024-07-30T08:12:07.373284+00:00",
"updated_at": "2024-07-30T08:12:07.373296+00:00",
"id": "5da8d033-df42-416d-abc5-ef5f889502d4",
"description": "Figuring out",
"duration": 90,
"created_by_id": "40fbfec5-9e81-4e3a-ace7-e6ffdb3a9b71",
"issue_id": "8b9343b9-ad65-4846-9bc3-c4d0ce19e44a",
"logged_by_id": "279fb487-99b7-4e3f-991a-858b1f61e03e",
"project_id": "ac858d9c-9190-4381-ace9-76f96491614b",
"updated_by_id": "c98b08b9-8eeb-4238-b233-7625e6e32755",
"workspace_id": "b6a14734-90c8-4d14-95c0-6443b7cc0d76",
"deleted_at": null
},
"module_members": {
"created_at": "2023-01-10T19:12:35.797168+00:00",
"updated_at": "2023-01-10T19:12:35.797190+00:00",
"id": "1146c240-e0e4-45cd-b93e-5f69475a71b1",
"created_by_id": "4fbef83a-fc21-47a6-a3b1-12f9c8eb0d6e",
"member_id": "671a83f7-f64d-4707-9999-266b09f59834",
"module_id": "bdaa1ff6-bc29-4acb-a353-57823d9baf7c",
"project_id": "835cdfe8-87fa-450d-902c-2b51322b3c7c",
"updated_by_id": "64fb8a3a-f12f-4ce0-9c6a-b08da0f349c9",
"workspace_id": "de022357-cd11-4296-9a04-c321ccf9910e",
"deleted_at": null
},
"project_identifiers": {
"id": 1499,
"created_at": "2023-05-31T01:30:27.584195+00:00",
"updated_at": "2023-05-31T01:30:27.584207+00:00",
"name": "DOMA",
"created_by_id": "51a29190-b725-4cc5-8610-2df6800364d4",
"project_id": "22d4ad98-48b7-4d1e-810e-f4f7f13ade1e",
"updated_by_id": "bf517ca3-0d59-4444-8f0f-21b0d0b5ba61",
"workspace_id": "47f8751b-31d7-4184-b92a-fccb650903b6",
"deleted_at": null
},
"pages": {
"created_at": "2024-04-25T15:11:11.381347+00:00",
"updated_at": "2024-04-25T15:11:11.381362+00:00",
"id": "8cfe65d4-dce6-484f-916e-e0df1dabc3a8",
"name": "Est",
"description": {},
"description_html": "<p></p>",
"description_stripped": null,
"access": 0,
"created_by_id": "03d92a01-7b25-4c96-8d66-3bbfcfbc470f",
"owned_by_id": "8185a125-320b-45c0-aa6f-61336445ba7b",
"updated_by_id": "24d96653-95b1-4414-a7b1-04bb4620fa06",
"workspace_id": "7a564e8b-1f97-4a14-a07e-0c71cc0c33bb",
"archived_at": null,
"is_locked": false,
"parent_id": "c7a39dd6-f072-4e1c-bc60-0949ce934e5d",
"is_global": false,
"deleted_at": null
},
"page_labels": {
"created_at": "2023-04-04T06:45:30.229230+00:00",
"updated_at": "2023-04-04T06:45:30.229251+00:00",
"id": "30e24d8c-3b91-4543-ba2f-6d730d7c63e6",
"created_by_id": "0396d641-58cd-46d0-90b4-97e82a6b96dd",
"label_id": "4e2578aa-c8af-451c-bd0b-4f0e770b6bfb",
"page_id": "cf5d1131-f94c-48d6-9d85-5452c815c45b",
"updated_by_id": "97a8fdc0-1904-4303-a840-99cfbf11caa9",
"workspace_id": "3614f714-a1af-4288-8ded-e518d0717191",
"deleted_at": null
},
"project_attributes": {
"created_at": "2024-08-20T08:58:01.153910+00:00",
"updated_at": "2024-08-20T08:58:01.153913+00:00",
"deleted_at": null,
"id": "41a8e902-983f-4e45-9269-5112e06ec8c0",
"priority": "none",
"start_date": null,
"target_date": null,
"created_by_id": "d1303d44-19ea-4018-a993-fec37b1ecc94",
"project_id": "b34cbe36-be97-423b-882b-d1c61063d5cb",
"state_id": "77152c92-0d2e-4093-911c-be4313c9100d",
"updated_by_id": "1a6be59f-a6d0-4e71-b57c-524a31deb8f2",
"workspace_id": "6eaf95b5-8bef-491d-a424-2aac6ef1e5c6"
},
"project_states": {
"created_at": "2024-08-20T08:58:01.081560+00:00",
"updated_at": "2024-08-20T08:58:01.081575+00:00",
"deleted_at": null,
"id": "5eabbec3-7c0c-4ded-a27c-7146d39ccc19",
"name": "Draft",
"description": "",
"sequence": 15000.0,
"group": "draft",
"default": true,
"created_by_id": "8d6ec6cb-a00c-4141-8294-9c65a3585d64",
"updated_by_id": "2d66f552-3905-4810-9537-ac0de80770cc",
"workspace_id": "f720d525-73aa-4467-b6e2-9203d83bab3d"
},
"project_issue_types": {
"created_at": "2024-08-20T08:59:40.814363+00:00",
"updated_at": "2024-08-20T08:59:40.814373+00:00",
"deleted_at": null,
"id": "2937f248-f0c2-489b-a7dc-0f4e06b173cc",
"level": 0,
"is_default": true,
"created_by_id": "9c956795-de67-4391-b2ba-ee27c3700b55",
"issue_type_id": "3932d15d-4965-47b6-a419-0fe17277f74c",
"project_id": "b0f0cdef-4d98-448d-9747-deb5f3876efd",
"updated_by_id": "9521ee87-d11b-4506-8123-8379e01bc7b1",
"workspace_id": "6f522c19-8106-4cc5-9cfd-27b2b43fc70b"
},
"project_members": {
"created_at": "2024-02-14T13:40:31.580715+00:00",
"updated_at": "2024-02-14T13:40:31.580725+00:00",
"id": "6bedc87e-7aa8-43fc-9ecc-8d31e76cc3c2",
"role": 20,
"created_by_id": "9dfa8941-e6d3-4d84-b3d5-18f5f30c3ce9",
"member_id": "30c49416-51f4-4c1c-ac09-fac76706d21e",
"project_id": "baa7625a-b695-47cb-a3a4-5048a02764e5",
"updated_by_id": "dcecd6e9-4baa-4821-bea2-f136e71393a3",
"workspace_id": "7d71f29f-2384-4af3-a6d8-9622e35caed3",
"sort_order": 65535.0,
"is_active": true,
"deleted_at": null
},
"project_pages": {
"created_at": "2024-06-24T10:02:30.584525+00:00",
"updated_at": "2024-06-24T10:02:30.584560+00:00",
"id": "9cd59961-a7ca-4934-bacf-b42d9aa74637",
"created_by_id": "62806a3c-f46c-425e-8ab0-1ee56585fafe",
"page_id": "6812f698-2540-439e-a099-9ed5e8626793",
"project_id": "a02b5470-a8fa-4ecb-a2d0-3d8bdba39240",
"updated_by_id": "dbe174ce-7cd1-47f6-8974-059d1de16498",
"workspace_id": "db6e0580-b857-4026-9b0c-092b0a78a71b",
"deleted_at": null
},
"users": {
"id": "16d4857e-19e1-4525-8298-ca08403ee468",
"username": "06d16bf9d1294fcb8a2b79c4312caf38",
"first_name": "Pat",
"last_name": "",
"date_joined": "2023-06-01T10:18:10.520319+00:00",
"created_at": "2023-06-01T10:18:10.520330+00:00",
"updated_at": "2024-07-13T13:20:56.739601+00:00",
"is_active": true,
"user_timezone": "Europe/Zurich",
"is_bot": false,
"display_name": "Patrick",
"bot_type": null
},
"projects": {
"created_at": "2024-03-26T07:37:44.293269+00:00",
"updated_at": "2024-03-26T07:37:52.526580+00:00",
"id": "20278297-f646-4060-97ff-e2c90d5b404a",
"name": "PnR",
"description": "",
"description_text": null,
"description_html": null,
"identifier": "PNR",
"created_by_id": "3c041b1f-4d47-4527-995e-1e0a962a808f",
"default_assignee_id": "e9d2bed4-1f75-4f66-8712-280149d78f2b",
"project_lead_id": "6c432043-0fcf-4ee5-aed4-227bfb59eee6",
"updated_by_id": "a84c5a0b-6f79-412a-a030-0c5e7b609f4d",
"workspace_id": "d8ff1d13-634d-4ca9-924d-0aaaf94c0ba3",
"cycle_view": true,
"module_view": true,
"issue_views_view": true,
"page_view": true,
"estimate_id": "6477e06b-9af8-453e-8487-8ea0084adc6f",
"intake_view": false,
"archive_in": 0,
"close_in": 0,
"default_state_id": "d97b95e0-7871-4b01-b969-b67671a2b443",
"archived_at": null,
"is_time_tracking_enabled": false,
"is_issue_type_enabled": false,
"deleted_at": null,
"guest_view_all_features": false,
"timezone": "UTC"
},
"states": {
"created_at": "2022-11-16T14:25:29.658858+00:00",
"updated_at": "2022-11-16T14:25:29.658882+00:00",
"id": "0140d539-4ca6-4189-80d4-ac85b256c0e7",
"name": "Backlog",
"description": "",
"slug": "",
"created_by_id": "afdbf8e3-07ae-4cef-b65d-71b18ec532dc",
"project_id": "d713021a-e08f-4fcb-bdc7-2f663b343468",
"updated_by_id": "c27bce7a-576e-4ddc-9ec5-cc0e670a96d9",
"workspace_id": "d49a34c0-dce7-46e8-a2b1-80ebff6a38cd",
"sequence": 15000.0,
"group": "backlog",
"default": false,
"is_triage": false,
"deleted_at": null
},
"issue_properties": {
"created_at": "2024-08-20T09:05:40.277316+00:00",
"updated_at": "2024-08-20T09:06:51.171870+00:00",
"id": "3f880431-4cf9-48db-b9f2-09af31a11e8b",
"name": "Marketing",
"display_name": "Marketing",
"description": null,
"sort_order": 145535.0,
"property_type": "OPTION",
"relation_type": null,
"is_required": false,
"default_value": [],
"is_active": true,
"is_multi": false,
"created_by_id": "2a0ffc9a-72cb-4cec-bc85-626b28686793",
"issue_type_id": "10a8e9da-fb05-4071-9bd8-bdb14894c22c",
"project_id": "3be790d3-4860-440e-8014-2f0fb1cac094",
"updated_by_id": "4fcff4e1-4f72-4b47-99c1-57b8e7653b52",
"workspace_id": "0a3d4aea-771d-4a8c-a3aa-25e1cff7e1d5"
},
"issue_property_options": {
"created_at": "2024-08-20T09:03:58.048213+00:00",
"updated_at": "2024-08-20T09:03:58.048225+00:00",
"id": "365eb975-6672-4a38-8d2d-3c26616ab244",
"name": "One",
"sort_order": 10000.0,
"description": "",
"is_active": true,
"is_default": false,
"created_by_id": "35da35c8-b174-47b6-b026-9af33c7cd518",
"parent_id": "c03d8df7-e5b3-48c1-8c24-0948adff38b2",
"project_id": "bb184d77-ad06-4684-b464-7d87a575f04d",
"property_id": "da78a194-38c9-4216-b04a-290a1a298cd4",
"updated_by_id": "a95a2902-ac52-446e-b460-52505d4cf075",
"workspace_id": "85d8e7f0-4f81-4f6c-ac79-8a95c26ce4eb",
"deleted_at": null
},
"issue_property_values": {
"created_at": "2024-08-20T09:37:25.846147+00:00",
"updated_at": "2024-08-20T09:37:25.846164+00:00",
"id": "6a549fb0-4ae1-4576-a2ef-6b16040e5a0e",
"value_text": " ",
"value_boolean": false,
"value_decimal": 0.0,
"value_datetime": null,
"value_uuid": null,
"created_by_id": "2c0f0bc3-5ca1-4378-a787-be792fd1008e",
"issue_id": "971d7183-bf35-4a1a-8e6c-77115df68c79",
"project_id": "070ba4c2-381e-4d95-8ca2-7d0bb1abbefc",
"property_id": "c04238b6-eee4-43b4-b4dc-c7c0f5e329bd",
"updated_by_id": "8ca4d972-5098-477c-a54c-7176ee4e61c9",
"value_option_id": "7e7102c9-fb8b-4c8f-898d-0224ab3a66a3",
"workspace_id": "33d1595e-4ea5-4c85-8af9-ffece4639a5e",
"deleted_at": null
},
"issue_types": {
"created_at": "2024-09-03T11:43:34.180682+00:00",
"updated_at": "2024-09-03T11:43:34.180695+00:00",
"id": "5bef5dde-098b-4c3b-be9f-f6844a6dd92c",
"name": "Issue",
"description": "Default issue type with the option to add new properties",
"created_by_id": "b5ed7f8e-2d54-4524-9b8a-1fdfc48ba00c",
"updated_by_id": "363951c1-8fec-48f3-86b5-c5cafdf577f6",
"workspace_id": "cb763218-2025-4f83-8738-ce36fc29443b",
"is_active": true,
"deleted_at": null,
"is_default": true,
"level": 0.0,
"is_epic": false
},
"workspaces": {
"created_at": "2023-04-10T05:42:53.511805+00:00",
"updated_at": "2023-04-10T05:42:53.511822+00:00",
"id": "1a421248-50a5-4c86-9def-0c1413766b08",
"name": "ncln",
"slug": "ncln",
"created_by_id": "25292009-e3e7-47b0-95f6-2b5bccc52087",
"owner_id": "d92cb732-d176-4a3d-a066-92d3ed20e2c8",
"updated_by_id": "c71c7e93-4393-480c-8347-f8f470d72609",
"organization_size": "5",
"deleted_at": null,
"timezone": "UTC"
},
"workspace_members": {
"created_at": "2023-12-14T11:47:08.000062+00:00",
"updated_at": "2023-12-14T11:47:08.000077+00:00",
"id": "10d0765e-5413-45f9-9a3c-ed80b5b666b2",
"role": 20,
"created_by_id": "439e3c73-5bb4-4f35-a165-49f9b95f6a9b",
"member_id": "b5dba3e2-7f48-42e8-a775-f78101e33cf3",
"updated_by_id": "e1c50b8a-dec3-4b67-a905-b576058231d0",
"workspace_id": "5771c701-bcd2-42b9-8d78-6b2224638939",
"company_role": "",
"is_active": true,
"deleted_at": null
},
"page_logs": {
"created_at": "2023-12-18T12:11:16.837571+00:00",
"updated_at": "2023-12-18T12:11:16.837593+00:00",
"id": "896ee511-ade1-4cd1-a138-72441e5666a3",
"transaction": "3a19eab7-430c-44e8-9112-af74e29feb83",
"entity_identifier": "3bb59373-cef7-4306-a91a-bd4889d088d5",
"entity_name": "issue",
"created_by_id": "c0bd0db2-b689-46d1-8d81-986d8cfd095a",
"page_id": "5065b380-0b7d-4047-8d48-49049f567953",
"updated_by_id": "368cd4b6-1b56-43d5-9595-48bb53e85c85",
"workspace_id": "3efeede2-f88e-42ac-8425-1b16ecc78486",
"deleted_at": null
},
"page_versions": {
"created_at": "2024-07-15T16:37:20.980441+00:00",
"updated_at": "2024-07-15T16:37:20.980454+00:00",
"id": "893ae6e5-b145-4e5d-95df-44a7c23d4859",
"last_saved_at": "2024-07-15T16:37:20.951009+00:00",
"description_html": "<p> The bugs are </p>",
"description_stripped": "The bugs are",
"created_by_id": "b7f5f8ee-e8b5-4ae6-9bd1-76ef40a007e9",
"owned_by_id": "4aa49d6b-a1ee-483a-8016-9008ffb2e927",
"page_id": "35a8fcfb-77b6-4879-94ef-6c1aed810ca0",
"updated_by_id": "d2cd920c-e75b-431f-abc6-255383f70e65",
"workspace_id": "e5d2768c-f9f4-4a52-a4bc-186a22754242",
"deleted_at": null
}
}
@@ -0,0 +1,61 @@
{
"intake_issues": " - Table: intake_issues\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - status: integer, NOT NULL\n - snoozed_till: timestamp with time zone, NULL\n - created_by_id: uuid, NULL\n - duplicate_to_id: uuid, NULL\n - intake_id: uuid, NOT NULL\n - issue_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n, NOT NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (duplicate_to_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (intake_id) REFERENCES intakees(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"intakes": " - Table: intakes\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - description: text, NOT NULL\n - is_default: boolean, NOT NULL\n - created_by_id: uuid, NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_comments": " - Table: issue_comments\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - comment_stripped: text, NOT NULL\n - created_by_id: uuid, NULL\n - issue_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - actor_id: uuid, NULL\n - comment_html: text, NOT NULL\n - access: character varying, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (actor_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_attachments": " - Table: issue_attachments\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - attributes: jsonb, NOT NULL\n - created_by_id: uuid, NULL\n - issue_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"importers": " - Table: importers\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - service: character varying, NOT NULL\n - status: character varying, NOT NULL\n - data: jsonb, NOT NULL\n - created_by_id: uuid, NULL\n - initiated_by_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - token_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - imported_data: jsonb, NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (initiated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (token_id) REFERENCES api_tokens(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"cycle_issues": " - Table: cycle_issues\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - created_by_id: uuid, NULL\n - cycle_id: uuid, NOT NULL\n - issue_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (cycle_id) REFERENCES cycles(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED",
"estimate_points": " - Table: estimate_points\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - key: integer, NOT NULL\n - description: text, NOT NULL\n - value: character varying, NOT NULL\n - created_by_id: uuid, NULL\n - estimate_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (estimate_id) REFERENCES estimates(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"estimates": " - Table: estimates\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - description: text, NOT NULL\n - created_by_id: uuid, NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - type: character varying, NOT NULL\n - last_used: boolean, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_mentions": " - Table: issue_mentions\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - created_by_id: uuid, NULL\n - issue_id: uuid, NOT NULL\n - mention_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (mention_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_relations": " - Table: issue_relations\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - relation_type: character varying, NOT NULL\n - created_by_id: uuid, NULL\n - issue_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - related_issue_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (related_issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_sequences": " - Table: issue_sequences\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - sequence: bigint, NOT NULL\n - deleted: boolean, NOT NULL\n - created_by_id: uuid, NULL\n - issue_id: uuid, NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Check: CHECK ((sequence >= 0))\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_subscribers": " - Table: issue_subscribers\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - created_by_id: uuid, NULL\n - issue_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - subscriber_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (subscriber_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_activities": " - Table: issue_activities\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - verb: character varying, NOT NULL\n - field: character varying, NULL\n - old_value: text, NULL\n - new_value: text, NULL\n - comment: text, NOT NULL\n - attachments: ARRAY, NOT NULL\n - issue_id: uuid, NULL\n - issue_comment_id: uuid, NULL\n - project_id: uuid, NOT NULL\n - workspace_id: uuid, NOT NULL\n - actor_id: uuid, NULL\n - new_identifier: uuid, NULL\n - old_identifier: uuid, NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (actor_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_comment_id) REFERENCES issue_comments(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_assignees": " - Table: issue_assignees\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - assignee_id: uuid, NOT NULL\n - created_by_id: uuid, NULL\n - issue_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (assignee_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_labels": " - Table: issue_labels\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - created_by_id: uuid, NULL\n - issue_id: uuid, NOT NULL\n - label_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (label_id) REFERENCES labels(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_links": " - Table: issue_links\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - title: character varying, NULL\n - url: text, NOT NULL\n - created_by_id: uuid, NULL\n - issue_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"cycles": " - Table: cycles\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - description: text, NOT NULL\n - start_date: timestamp with time zone, NULL\n - end_date: timestamp with time zone, NULL\n - created_by_id: uuid, NULL\n - owned_by_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - archived_at: timestamp with time zone, NULL\n - deleted_at: timestamp with time zone, NULL\n - timezone: character varying, NOT NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (owned_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"modules": " - Table: modules\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - description: text, NOT NULL\n - start_date: date, NULL\n - target_date: date, NULL\n - status: character varying, NOT NULL\n - created_by_id: uuid, NULL\n - lead_id: uuid, NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - archived_at: timestamp with time zone, NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (lead_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issues": " - Table: issues\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - priority: character varying, NOT NULL\n - start_date: date, NULL\n - target_date: date, NULL\n - sequence_id: integer, NOT NULL\n - created_by_id: uuid, NULL\n - parent_id: uuid, NULL\n - project_id: uuid, NOT NULL\n - state_id: uuid, NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - description_html: text, NOT NULL\n - description_stripped: text, NULL\n - completed_at: timestamp with time zone, NULL\n - point: integer, NULL\n - archived_at: date, NULL\n - is_draft: boolean, NOT NULL\n - estimate_point_id: uuid, NULL\n - type_id: uuid, NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (parent_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (state_id) REFERENCES states(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (estimate_point_id) REFERENCES estimate_points(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (type_id) REFERENCES issue_types(id) DEFERRABLE INITIALLY DEFERRED",
"module_issues": " - Table: module_issues\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - created_by_id: uuid, NULL\n - issue_id: uuid, NOT NULL\n - module_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (module_id) REFERENCES modules(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"module_links": " - Table: module_links\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - title: character varying, NULL\n - url: character varying, NOT NULL\n - created_by_id: uuid, NULL\n - module_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (module_id) REFERENCES modules(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"labels": " - Table: labels\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - description: text, NOT NULL\n - created_by_id: uuid, NULL\n - project_id: uuid, NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - parent_id: uuid, NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (parent_id) REFERENCES labels(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED",
"issue_views": " - Table: issue_views\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - description: text, NOT NULL\n - access: smallint, NOT NULL\n - created_by_id: uuid, NULL\n - project_id: uuid, NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - is_locked: boolean, NOT NULL\n - owned_by_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Check: CHECK ((access >= 0))\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (owned_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_worklogs": " - Table: issue_worklogs\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - description: text, NOT NULL\n - duration: integer, NOT NULL\n - created_by_id: uuid, NULL\n - issue_id: uuid, NOT NULL\n - logged_by_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (logged_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"module_members": " - Table: module_members\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - created_by_id: uuid, NULL\n - member_id: uuid, NOT NULL\n - module_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (member_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (module_id) REFERENCES modules(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"project_identifiers": " - Table: project_identifiers\n - Columns:\n - id: bigint, NOT NULL\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - name: character varying, NOT NULL\n - created_by_id: uuid, NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"pages": " - Table: pages\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - description_html: text, NOT NULL\n - description_stripped: text, NULL\n - access: smallint, NOT NULL\n - created_by_id: uuid, NULL\n - owned_by_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - archived_at: date, NULL\n - is_locked: boolean, NOT NULL\n - parent_id: uuid, NULL\n - is_global: boolean, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Check: CHECK ((access >= 0))\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (owned_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (parent_id) REFERENCES pages(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"page_labels": " - Table: page_labels\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - created_by_id: uuid, NULL\n - label_id: uuid, NOT NULL\n - page_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (label_id) REFERENCES labels(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (page_id) REFERENCES pages(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"project_attributes": " - Table: project_attributes\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - priority: character varying, NOT NULL\n - start_date: timestamp with time zone, NULL\n - target_date: timestamp with time zone, NULL\n - created_by_id: uuid, NULL\n - project_id: uuid, NOT NULL\n - state_id: uuid, NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (state_id) REFERENCES project_states(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"project_states": " - Table: project_states\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - description: text, NOT NULL\n - sequence: double precision, NOT NULL\n - group: character varying, NOT NULL\n - default: boolean, NOT NULL\n - created_by_id: uuid, NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"project_issue_types": " - Table: project_issue_types\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - level: integer, NOT NULL\n - is_default: boolean, NOT NULL\n - created_by_id: uuid, NULL\n - issue_type_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_type_id) REFERENCES issue_types(id) DEFERRABLE INITIALLY DEFERRED\n - Check: CHECK ((level >= 0))\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"project_members": " - Table: project_members\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - role: smallint, NOT NULL\n - created_by_id: uuid, NULL\n - member_id: uuid, NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - sort_order: double precision, NOT NULL\n - is_active: boolean, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (member_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Check: CHECK ((role >= 0))\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"project_pages": " - Table: project_pages\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - created_by_id: uuid, NULL\n - page_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (page_id) REFERENCES pages(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"users": " - Table: users\n - Columns:\n - id: uuid, NOT NULL\n - first_name: character varying, NOT NULL\n - last_name: character varying, NOT NULL\n - date_joined: timestamp with time zone, NOT NULL\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - is_active: boolean, NOT NULL\n - user_timezone: character varying, NOT NULL\n - is_bot: boolean, NOT NULL\n - display_name: character varying, NOT NULL\n - bot_type: character varying, NULL\n - Constraints:\n - Primary Key: PRIMARY KEY (id)\n",
"projects": " - Table: projects\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - description: text, NOT NULL\n - identifier: character varying, NOT NULL\n - created_by_id: uuid, NULL\n - default_assignee_id: uuid, NULL\n - project_lead_id: uuid, NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - cycle_view: boolean, NOT NULL\n - module_view: boolean, NOT NULL\n - issue_views_view: boolean, NOT NULL\n - page_view: boolean, NOT NULL\n - estimate_id: uuid, NULL\n - intake_view: boolean, NOT NULL\n - archive_in: integer, NOT NULL\n - close_in: integer, NOT NULL\n - default_state_id: uuid, NULL\n - network: smallint, NOT NULL\n - archived_at: timestamp with time zone, NULL\n - is_time_tracking_enabled: boolean, NOT NULL\n - is_issue_type_enabled: boolean, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - guest_view_all_features: boolean, NOT NULL\n - timezone: character varying, NOT NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (default_assignee_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_lead_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (default_state_id) REFERENCES states(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (estimate_id) REFERENCES estimates(id) DEFERRABLE INITIALLY DEFERRED",
"states": " - Table: states\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - description: text, NOT NULL\n - slug: character varying, NOT NULL\n - created_by_id: uuid, NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - sequence: double precision, NOT NULL\n - group: character varying, NOT NULL\n - default: boolean, NOT NULL\n - is_triage: boolean, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_properties": " - Table: issue_properties\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - display_name: character varying, NOT NULL\n - description: text, NULL\n - sort_order: double precision, NOT NULL\n - property_type: character varying, NOT NULL\n - relation_type: character varying, NULL\n - is_required: boolean, NOT NULL\n - default_value: ARRAY, NOT NULL\n - is_active: boolean, NOT NULL\n - is_multi: boolean, NOT NULL\n - created_by_id: uuid, NULL\n - issue_type_id: uuid, NOT NULL\n - project_id: uuid, NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_type_id) REFERENCES issue_types(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_property_options": " - Table: issue_property_options\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - sort_order: double precision, NOT NULL\n - description: text, NOT NULL\n - is_active: boolean, NOT NULL\n - is_default: boolean, NOT NULL\n - created_by_id: uuid, NULL\n - parent_id: uuid, NULL\n - project_id: uuid, NULL\n - property_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (parent_id) REFERENCES issue_property_options(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (property_id) REFERENCES issue_properties(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_property_values": " - Table: issue_property_values\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - value_text: text, NOT NULL\n - value_boolean: boolean, NOT NULL\n - value_decimal: double precision, NOT NULL\n - value_datetime: timestamp with time zone, NULL\n - value_uuid: uuid, NULL\n - created_by_id: uuid, NULL\n - issue_id: uuid, NOT NULL\n - project_id: uuid, NULL\n - property_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - value_option_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (property_id) REFERENCES issue_properties(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (value_option_id) REFERENCES issue_property_options(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_types": " - Table: issue_types\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - description: text, NOT NULL\n - created_by_id: uuid, NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - is_active: boolean, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - is_default: boolean, NOT NULL\n - level: double precision, NOT NULL\n - is_epic: boolean, NOT NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"workspaces": " - Table: workspaces\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - slug: character varying, NOT NULL\n - created_by_id: uuid, NULL\n - owner_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - organization_size: character varying, NULL\n - deleted_at: timestamp with time zone, NULL\n - timezone: character varying, NOT NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (owner_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n",
"workspace_members": " - Table: workspace_members\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - role: smallint, NOT NULL\n - created_by_id: uuid, NULL\n - member_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - company_role: text, NULL\n - is_active: boolean, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (member_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Check: CHECK ((role >= 0))\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"page_logs": " - Table: page_logs\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - transaction: uuid, NOT NULL\n - entity_identifier: uuid, NULL\n - entity_name: character varying, NOT NULL\n - created_by_id: uuid, NULL\n - page_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (page_id) REFERENCES pages(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"page_versions": " - Table: page_versions\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - last_saved_at: timestamp with time zone, NOT NULL\n - description_html: text, NOT NULL\n - description_stripped: text, NULL\n - created_by_id: uuid, NULL\n - owned_by_id: uuid, NOT NULL\n - page_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (owned_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (page_id) REFERENCES pages(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"team_space_projects": " - Table: team_space_projects\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - sort_order: integer, NOT NULL\n - created_by_id: uuid, NULL\n - project_id: uuid, NOT NULL\n - team_space_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (team_space_id) REFERENCES team_spaces(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"team_space_pages": " - Table: team_space_pages\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - sort_order: integer, NOT NULL\n - created_by_id: uuid, NULL\n - page_id: uuid, NOT NULL\n - team_space_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (page_id) REFERENCES pages(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (team_space_id) REFERENCES team_spaces(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"team_space_members": " - Table: team_space_members\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - sort_order: integer, NOT NULL\n - created_by_id: uuid, NULL\n - member_id: uuid, NOT NULL\n - team_space_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (member_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (team_space_id) REFERENCES team_spaces(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"team_space_labels": " - Table: team_space_labels\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - sort_order: integer, NOT NULL\n - created_by_id: uuid, NULL\n - label_id: uuid, NOT NULL\n - team_space_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (label_id) REFERENCES labels(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (team_space_id) REFERENCES team_spaces(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"team_space_comments": " - Table: team_space_comments\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - comment_stripped: text, NOT NULL\n - comment_json: jsonb, NOT NULL\n - comment_html: text, NOT NULL\n - actor_id: uuid, NULL\n - created_by_id: uuid, NULL\n - parent_id: uuid, NULL\n - team_space_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - comment_json sub-fields: \n - Constraints:\n - Foreign Key: FOREIGN KEY (actor_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (parent_id) REFERENCES team_space_comments(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (team_space_id) REFERENCES team_spaces(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"team_spaces": " - Table: team_spaces\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - description_json: jsonb, NOT NULL\n - description_html: text, NOT NULL\n - description_stripped: text, NULL\n - description_binary: bytea, NULL\n - created_by_id: uuid, NULL\n - lead_id: uuid, NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - description_json sub-fields: \n - logo_props sub-fields: emoji.value, emoji.url, in_use, emoji\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (lead_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"stickies": " - Table: stickies\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - name: text, NULL\n - description: jsonb, NOT NULL\n - description_html: text, NOT NULL\n - description_stripped: text, NULL\n - description_binary: bytea, NULL\n - created_by_id: uuid, NULL\n - owner_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - sort_order: double precision, NOT NULL\n - description sub-fields: \n - logo_props sub-fields: \n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (owner_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"issue_votes": " - Table: issue_votes\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - id: uuid, NOT NULL\n - vote: integer, NOT NULL\n - actor_id: uuid, NOT NULL\n - created_by_id: uuid, NULL\n - issue_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (actor_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (issue_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"initiative_activities": " - Table: initiative_activities\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - verb: character varying, NOT NULL\n - field: character varying, NULL\n - old_value: text, NULL\n - new_value: text, NULL\n - comment: text, NOT NULL\n - old_identifier: uuid, NULL\n - new_identifier: uuid, NULL\n - epoch: double precision, NULL\n - actor_id: uuid, NULL\n - created_by_id: uuid, NULL\n - initiative_id: uuid, NULL\n - initiative_comment_id: uuid, NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (initiative_comment_id) REFERENCES initiative_comments(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (actor_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (initiative_id) REFERENCES initiatives(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"initiative_labels": " - Table: initiative_labels\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - sort_order: double precision, NOT NULL\n - created_by_id: uuid, NULL\n - initiative_id: uuid, NOT NULL\n - label_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (initiative_id) REFERENCES initiatives(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (label_id) REFERENCES labels(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"initiative_projects": " - Table: initiative_projects\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - sort_order: double precision, NOT NULL\n - created_by_id: uuid, NULL\n - initiative_id: uuid, NOT NULL\n - project_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (initiative_id) REFERENCES initiatives(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (project_id) REFERENCES projects(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"initiatives": " - Table: initiatives\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - name: character varying, NOT NULL\n - description: text, NULL\n - description_html: text, NULL\n - description_stripped: text, NULL\n - description_binary: bytea, NULL\n - start_date: timestamp with time zone, NULL\n - end_date: timestamp with time zone, NULL\n - status: character varying, NOT NULL\n - created_by_id: uuid, NULL\n - lead_id: uuid, NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (lead_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"initiative_links": " - Table: initiative_links\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - title: character varying, NULL\n - url: text, NOT NULL\n - metadata: jsonb, NOT NULL\n - created_by_id: uuid, NULL\n - initiative_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - metadata sub-fields: \n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (initiative_id) REFERENCES initiatives(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"initiative_comments": " - Table: initiative_comments\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - comment_stripped: text, NOT NULL\n - comment_json: jsonb, NOT NULL\n - comment_html: text, NOT NULL\n - access: character varying, NOT NULL\n - external_source: character varying, NULL\n - external_id: character varying, NULL\n - actor_id: uuid, NULL\n - created_by_id: uuid, NULL\n - initiative_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - comment_json sub-fields: \n - Constraints:\n - Foreign Key: FOREIGN KEY (actor_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (initiative_id) REFERENCES initiatives(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED",
"initiative_epics": " - Table: initiative_epics\n - Columns:\n - created_at: timestamp with time zone, NOT NULL\n - updated_at: timestamp with time zone, NOT NULL\n - deleted_at: timestamp with time zone, NULL\n - id: uuid, NOT NULL\n - sort_order: double precision, NOT NULL\n - created_by_id: uuid, NULL\n - epic_id: uuid, NOT NULL\n - initiative_id: uuid, NOT NULL\n - updated_by_id: uuid, NULL\n - workspace_id: uuid, NOT NULL\n - Constraints:\n - Foreign Key: FOREIGN KEY (created_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (epic_id) REFERENCES issues(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (initiative_id) REFERENCES initiatives(id) DEFERRABLE INITIALLY DEFERRED\n - Primary Key: PRIMARY KEY (id)\n - Foreign Key: FOREIGN KEY (updated_by_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED\n - Foreign Key: FOREIGN KEY (workspace_id) REFERENCES workspaces(id) DEFERRABLE INITIALLY DEFERRED"
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = json
[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 = json
[formatter_json]
class = pythonjsonlogger.jsonlogger.JsonFormatter
format = %(asctime)s %(name)s %(levelname)s %(message)s
datefmt = %Y-%m-%d %H:%M:%S
+81
View File
@@ -0,0 +1,81 @@
"""Alembic environment configuration."""
# Python imports
from logging.config import fileConfig
from alembic import context
# External imports
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from pi.app.models import Chat # noqa: F401
from pi.app.models import Message # noqa: F401
from pi.app.models import MessageFeedback # noqa: F401
from pi.app.models import MessageFlowStep # noqa: F401
from pi.app.models import MessageMeta # noqa: F401
from pi.app.models import Transcription # noqa: F401
from pi.app.models import WorkspaceVectorization # noqa: F401
from pi.app.models.action_artifact import ActionArtifact # noqa: F401
from pi.app.models.base import BaseModel
from pi.app.models.oauth import PlaneOAuthState # noqa: F401
from pi.app.models.oauth import PlaneOAuthToken # noqa: F401
# Module imports
from pi.config import settings
config = context.config
# setting up the database url for Alembic to use
config.set_main_option("sqlalchemy.url", settings.database.connection_url())
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = BaseModel.metadata
# Exclude ell models
def include_object(obj, name, type_, reflected, compare_to):
# Exclude tables that are not part of our application models
if type_ == "table" and name.lower() in {
"serializedevaluation",
"serializedlmp",
"evaluationlabeler",
"invocation",
"serializedevaluationrun",
"serializedlmpuses",
"evaluationresultdatapoint",
"evaluationrunlabelersummary",
"invocationcontents",
"invocationtrace",
"evaluationlabel",
}:
return False # skip these tables
return True
def run_migrations_offline() -> None:
url = settings.database.connection_url()
context.configure(
url=url, include_object=include_object, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section)
if configuration:
configuration["sqlalchemy.url"] = settings.database.connection_url()
connectable = engine_from_config(configuration, prefix="sqlalchemy.", poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(connection=connection, include_object=include_object, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+20
View File
@@ -0,0 +1,20 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,51 @@
from typing import Sequence
from typing import Union
import sqlalchemy as sa
import sqlmodel
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0b378a3ddb8e"
down_revision: Union[str, None] = "f395eac7eb26"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"action_artifacts",
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_by_id", sa.Uuid(), nullable=True),
sa.Column("updated_by_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("entity", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
sa.Column("entity_id", sa.Uuid(), nullable=True),
sa.Column("action", sqlmodel.sql.sqltypes.AutoString(length=32), nullable=False),
sa.Column("data", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("is_executed", sa.Boolean(), nullable=False),
sa.Column("success", sa.Boolean(), nullable=False),
sa.Column("message_id", sa.UUID(), nullable=True),
sa.Column("chat_id", sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name="fk_action_artifacts_chat_id"),
sa.ForeignKeyConstraint(["message_id"], ["messages.id"], name="fk_action_artifacts_message_id"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_action_artifacts_chat_id"), "action_artifacts", ["chat_id"], unique=False)
op.create_index(op.f("ix_action_artifacts_id"), "action_artifacts", ["id"], unique=False)
op.create_index(op.f("ix_action_artifacts_message_id"), "action_artifacts", ["message_id"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_action_artifacts_message_id"), table_name="action_artifacts")
op.drop_index(op.f("ix_action_artifacts_id"), table_name="action_artifacts")
op.drop_index(op.f("ix_action_artifacts_chat_id"), table_name="action_artifacts")
op.drop_table("action_artifacts")
# ### end Alembic commands ###
@@ -0,0 +1,54 @@
from typing import Sequence
from typing import Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "1d2e3f4a5b6c"
down_revision: Union[str, None] = "0b378a3ddb8e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"message_clarifications",
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_by_id", sa.Uuid(), nullable=True),
sa.Column("updated_by_id", sa.Uuid(), nullable=True),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("chat_id", sa.Uuid(), nullable=False),
sa.Column("message_id", sa.Uuid(), nullable=False),
sa.Column("pending", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column("original_query", sa.Text(), nullable=False),
sa.Column("payload", sa.dialects.postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("categories", sa.dialects.postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("method_tool_names", sa.dialects.postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("bound_tool_names", sa.dialects.postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("answer_text", sa.Text(), nullable=True),
sa.Column("resolved_by_message_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("resolved_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.CheckConstraint("kind in ('action','retrieval')", name="ck_message_clarifications_kind"),
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name="fk_message_clarifications_chat_id"),
sa.ForeignKeyConstraint(["message_id"], ["messages.id"], name="fk_message_clarifications_message_id"),
sa.ForeignKeyConstraint(["resolved_by_message_id"], ["messages.id"], name="fk_message_clarifications_resolved_by_message_id"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("message_id", name="uq_message_clarifications_message_id"),
)
# Index for fast lookups of latest pending by chat
op.create_index(
"ix_message_clarifications_chat_pending_created",
"message_clarifications",
["chat_id", "pending", "created_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_message_clarifications_chat_pending_created", table_name="message_clarifications")
op.drop_table("message_clarifications")
@@ -0,0 +1,234 @@
from typing import Sequence
from typing import Union
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "2a2367c71b4d"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"agent_artifacts",
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_by_id", sa.Uuid(), nullable=True),
sa.Column("updated_by_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("agent_name", sa.Enum("PRD_WRITER", name="agentstype"), nullable=False),
sa.Column("workspace_id", sa.Uuid(), nullable=True),
sa.Column("project_id", sa.Uuid(), nullable=True),
sa.Column("issue_id", sa.Uuid(), nullable=True),
sa.Column("content", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("content_type", sa.Enum("MARKDOWN", name="agentartifactcontenttype"), nullable=False),
sa.Column("version", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_agent_artifacts_id"), "agent_artifacts", ["id"], unique=False)
op.create_table(
"chats",
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_by_id", sa.Uuid(), nullable=True),
sa.Column("updated_by_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("title", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("icon", sa.JSON(), nullable=True),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("workspace_id", sa.Uuid(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_chats_id"), "chats", ["id"], unique=False)
op.create_index(op.f("ix_chats_user_id"), "chats", ["user_id"], unique=False)
op.create_table(
"github_webhooks",
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_by_id", sa.Uuid(), nullable=True),
sa.Column("updated_by_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("commit_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column("source", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column("branch_name", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column("processed", sa.Boolean(), nullable=False),
sa.Column("files_processed", sa.Integer(), nullable=True),
sa.Column("error_message", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_github_webhooks_id"), "github_webhooks", ["id"], unique=False)
op.create_table(
"llm_models",
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_by_id", sa.Uuid(), nullable=True),
sa.Column("updated_by_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("provider", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column("model_key", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column("max_tokens", sa.Integer(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("model_key"),
)
op.create_index(op.f("ix_llm_models_id"), "llm_models", ["id"], unique=False)
op.create_table(
"llm_model_pricing",
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_by_id", sa.Uuid(), nullable=True),
sa.Column("updated_by_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("llm_model_id", sa.UUID(), nullable=True),
sa.Column("text_input_price", sa.Float(), nullable=True),
sa.Column("text_output_price", sa.Float(), nullable=True),
sa.Column("cached_text_input_price", sa.Float(), nullable=True),
sa.ForeignKeyConstraint(["llm_model_id"], ["llm_models.id"], name="fk_llm_model_pricing_llm_model_id"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_llm_model_pricing_id"), "llm_model_pricing", ["id"], unique=False)
op.create_table(
"messages",
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_by_id", sa.Uuid(), nullable=True),
sa.Column("updated_by_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("content", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("reasoning", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("user_type", sa.Enum("USER", "ASSISTANT", "SYSTEM", name="usertypechoices"), nullable=False),
sa.Column("parent_id", sa.UUID(), nullable=True),
sa.Column("relates_to", sa.UUID(), nullable=True),
sa.Column("llm_model_id", sa.UUID(), nullable=True),
sa.Column("chat_id", sa.UUID(), nullable=True),
sa.Column("llm_model", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name="fk_messages_chat_id"),
sa.ForeignKeyConstraint(["llm_model"], ["llm_models.model_key"], name="fk_messages_llm_model"),
sa.ForeignKeyConstraint(["llm_model_id"], ["llm_models.id"], name="fk_messages_llm_model_id"),
sa.ForeignKeyConstraint(["parent_id"], ["messages.id"], name="fk_messages_parent_id"),
sa.ForeignKeyConstraint(["relates_to"], ["messages.id"], name="fk_messages_relates_to"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_messages_chat_id"), "messages", ["chat_id"], unique=False)
op.create_index(op.f("ix_messages_id"), "messages", ["id"], unique=False)
op.create_index(op.f("ix_messages_sequence"), "messages", ["sequence"], unique=False)
op.create_table(
"message_feedbacks",
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_by_id", sa.Uuid(), nullable=True),
sa.Column("updated_by_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("type", sa.Enum("FEEDBACK", "REACTION", name="messagefeedbacktypechoices"), nullable=False),
sa.Column("feedback", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("reaction", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("feedback_message", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("message_id", sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(["message_id"], ["messages.id"], name="fk_message_feedbacks_message_id"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_message_feedbacks_id"), "message_feedbacks", ["id"], unique=False)
op.create_index(op.f("ix_message_feedbacks_user_id"), "message_feedbacks", ["user_id"], unique=False)
op.create_table(
"message_flow_steps",
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_by_id", sa.Uuid(), nullable=True),
sa.Column("updated_by_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("step_order", sa.Integer(), nullable=False),
sa.Column("step_type", sa.Enum("REWRITE", "ROUTING", "TOOL", "COMBINATION", name="flowsteptype"), nullable=False),
sa.Column("tool_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("content", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("execution_data", sa.JSON(), nullable=True),
sa.Column("message_id", sa.UUID(), nullable=False),
sa.Column("chat_id", sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name="fk_message_flow_steps_chat_id"),
sa.ForeignKeyConstraint(["message_id"], ["messages.id"], name="fk_message_flow_steps_message_id"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_message_flow_steps_id"), "message_flow_steps", ["id"], unique=False)
op.create_table(
"message_meta",
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_by_id", sa.Uuid(), nullable=True),
sa.Column("updated_by_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"step_type",
sa.Enum(
"INPUT",
"REWRITE",
"ROUTER",
"COMBINATION",
"SQL_TABLE_SELECTION",
"SQL_GENERATION",
"TITLE_GENERATION",
"TOOL_ORCHESTRATION",
name="messagemetasteptype",
),
nullable=False,
),
sa.Column("input_text_tokens", sa.Integer(), nullable=True),
sa.Column("input_text_price", sa.Float(), nullable=True),
sa.Column("output_text_tokens", sa.Integer(), nullable=True),
sa.Column("output_text_price", sa.Float(), nullable=True),
sa.Column("cached_input_text_tokens", sa.Integer(), nullable=True),
sa.Column("cached_input_text_price", sa.Float(), nullable=True),
sa.Column("message_id", sa.UUID(), nullable=True),
sa.Column("llm_model_id", sa.UUID(), nullable=True),
sa.Column("llm_model_pricing_id", sa.UUID(), nullable=True),
sa.ForeignKeyConstraint(["llm_model_id"], ["llm_models.id"], name="fk_message_meta_llm_model_id"),
sa.ForeignKeyConstraint(["llm_model_pricing_id"], ["llm_model_pricing.id"], name="fk_message_meta_llm_model_pricing_id"),
sa.ForeignKeyConstraint(["message_id"], ["messages.id"], name="fk_message_meta_message_id"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_message_meta_id"), "message_meta", ["id"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_message_meta_id"), table_name="message_meta")
op.drop_table("message_meta")
op.drop_index(op.f("ix_message_flow_steps_id"), table_name="message_flow_steps")
op.drop_table("message_flow_steps")
op.drop_index(op.f("ix_message_feedbacks_user_id"), table_name="message_feedbacks")
op.drop_index(op.f("ix_message_feedbacks_id"), table_name="message_feedbacks")
op.drop_table("message_feedbacks")
op.drop_index(op.f("ix_messages_sequence"), table_name="messages")
op.drop_index(op.f("ix_messages_id"), table_name="messages")
op.drop_index(op.f("ix_messages_chat_id"), table_name="messages")
op.drop_table("messages")
op.drop_index(op.f("ix_llm_model_pricing_id"), table_name="llm_model_pricing")
op.drop_table("llm_model_pricing")
op.drop_index(op.f("ix_llm_models_id"), table_name="llm_models")
op.drop_table("llm_models")
op.drop_index(op.f("ix_github_webhooks_id"), table_name="github_webhooks")
op.drop_table("github_webhooks")
op.drop_index(op.f("ix_chats_user_id"), table_name="chats")
op.drop_index(op.f("ix_chats_id"), table_name="chats")
op.drop_table("chats")
op.drop_index(op.f("ix_agent_artifacts_id"), table_name="agent_artifacts")
op.drop_table("agent_artifacts")
# ### end Alembic commands ###
@@ -0,0 +1,47 @@
from typing import Sequence
from typing import Union
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "43306d5dbfcb"
down_revision: Union[str, None] = "d6b5bf289cd4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"user_chat_preferences",
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_by_id", sa.Uuid(), nullable=True),
sa.Column("updated_by_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("is_focus_enabled", sa.Boolean(), nullable=False),
sa.Column("focus_project_id", sa.Uuid(), nullable=True),
sa.Column("focus_workspace_id", sa.Uuid(), nullable=True),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("chat_id", sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name="fk_user_chat_preferences_chat_id"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_user_chat_preferences_id"), "user_chat_preferences", ["id"], unique=False)
op.add_column("chats", sa.Column("is_favorite", sa.Boolean(), server_default=sa.text("false"), nullable=False))
op.add_column("chats", sa.Column("is_project_chat", sa.Boolean(), server_default=sa.text("false"), nullable=False))
op.add_column("messages", sa.Column("parsed_content", sqlmodel.sql.sqltypes.AutoString(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("messages", "parsed_content")
op.drop_column("chats", "is_project_chat")
op.drop_column("chats", "is_favorite")
op.drop_index(op.f("ix_user_chat_preferences_id"), table_name="user_chat_preferences")
op.drop_table("user_chat_preferences")
# ### end Alembic commands ###
@@ -0,0 +1,68 @@
from typing import Sequence
from typing import Union
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "6de9a46f74be"
down_revision: Union[str, None] = "ae536d49945a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"message_attachments",
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_by_id", sa.Uuid(), nullable=True),
sa.Column("updated_by_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("original_filename", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
sa.Column("content_type", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
sa.Column("file_size", sa.Integer(), nullable=False),
sa.Column("file_type", sa.String(length=50), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("file_path", sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=False),
sa.Column("workspace_id", sa.Uuid(), nullable=True),
sa.Column("chat_id", sa.Uuid(), nullable=True),
sa.Column("message_id", sa.UUID(), nullable=True),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(["message_id"], ["messages.id"], name="fk_message_attachments_message_id"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_message_attachments_chat_id"), "message_attachments", ["chat_id"], unique=False)
op.create_index(op.f("ix_message_attachments_id"), "message_attachments", ["id"], unique=False)
op.create_index(op.f("ix_message_attachments_message_id"), "message_attachments", ["message_id"], unique=False)
op.create_index(op.f("ix_message_attachments_user_id"), "message_attachments", ["user_id"], unique=False)
op.create_index(op.f("ix_message_attachments_workspace_id"), "message_attachments", ["workspace_id"], unique=False)
op.alter_column("message_feedbacks", "type", existing_type=sa.VARCHAR(length=50), nullable=True)
op.rename_table("workspacevectorization", "workspace_vectorizations")
# rename indexes too
op.execute("ALTER INDEX ix_workspacevectorization_id RENAME TO ix_workspace_vectorizations_id")
op.execute("ALTER INDEX ix_workspacevectorization_status RENAME TO ix_workspace_vectorizations_status")
op.execute("ALTER INDEX ix_workspacevectorization_workspace_id RENAME TO ix_workspace_vectorizations_workspace_id")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column("message_feedbacks", "type", existing_type=sa.VARCHAR(length=50), nullable=False)
op.drop_index(op.f("ix_message_attachments_workspace_id"), table_name="message_attachments")
op.drop_index(op.f("ix_message_attachments_user_id"), table_name="message_attachments")
op.drop_index(op.f("ix_message_attachments_message_id"), table_name="message_attachments")
op.drop_index(op.f("ix_message_attachments_id"), table_name="message_attachments")
op.drop_index(op.f("ix_message_attachments_chat_id"), table_name="message_attachments")
op.drop_table("message_attachments")
op.rename_table("workspace_vectorizations", "workspacevectorization")
op.execute("ALTER INDEX ix_workspace_vectorizations_id RENAME TO ix_workspacevectorization_id")
op.execute("ALTER INDEX ix_workspace_vectorizations_status RENAME TO ix_workspacevectorization_status")
op.execute("ALTER INDEX ix_workspace_vectorizations_workspace_id RENAME TO ix_workspacevectorization_workspace_id")
# ### end Alembic commands ###
@@ -0,0 +1,21 @@
from typing import Sequence
from typing import Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a9b8c7d6e5f4"
down_revision: Union[str, None] = "f4c8d9e1b2a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add workspace_in_context column to chats table (nullable for existing rows)
op.add_column("chats", sa.Column("workspace_in_context", sa.Boolean(), nullable=True))
def downgrade() -> None:
# Remove workspace_in_context column from chats table
op.drop_column("chats", "workspace_in_context")
@@ -0,0 +1,42 @@
from typing import Sequence
from typing import Union
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "ae536d49945a"
down_revision: Union[str, None] = "a9b8c7d6e5f4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"transcriptions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("transcription_text", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("transcription_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("audio_duration", sa.Integer(), nullable=False),
sa.Column("speech_model", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("processing_time", sa.Float(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("workspace_id", sa.Uuid(), nullable=False),
sa.Column("chat_id", sa.Uuid(), nullable=True),
sa.Column("transcription_cost_usd", sa.Float(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_transcriptions_id"), "transcriptions", ["id"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_transcriptions_id"), table_name="transcriptions")
op.drop_table("transcriptions")
# ### end Alembic commands ###
@@ -0,0 +1,213 @@
"""migrate enums to varchar
Revision ID: b18f9fa7c377
Revises: 43306d5dbfcb
Create Date: 2025-01-28
This migration converts all PostgreSQL enum type columns to VARCHAR columns
to avoid enum-related issues.
"""
from typing import Sequence
from typing import Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "b18f9fa7c377"
down_revision: Union[str, None] = "43306d5dbfcb"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Convert enum columns to VARCHAR columns."""
# 1. agent_artifacts.agent_name
op.alter_column(
"agent_artifacts", "agent_name", type_=sa.String(255), existing_type=postgresql.ENUM("PRD_WRITER", name="agentstype"), nullable=False
)
# 2. agent_artifacts.content_type
op.alter_column(
"agent_artifacts",
"content_type",
type_=sa.String(50),
existing_type=postgresql.ENUM("MARKDOWN", name="agentartifactcontenttype"),
nullable=False,
server_default="markdown",
)
# 3. messages.user_type
op.alter_column(
"messages",
"user_type",
type_=sa.String(50),
existing_type=postgresql.ENUM("USER", "ASSISTANT", "SYSTEM", name="usertypechoices"),
nullable=False,
)
# 4. message_feedbacks.type
op.alter_column(
"message_feedbacks",
"type",
type_=sa.String(50),
existing_type=postgresql.ENUM("FEEDBACK", "REACTION", name="messagefeedbacktypechoices"),
nullable=False,
)
# 5. message_flow_steps.step_type
op.alter_column(
"message_flow_steps",
"step_type",
type_=sa.String(50),
existing_type=postgresql.ENUM("REWRITE", "ROUTING", "TOOL", "COMBINATION", name="flowsteptype"),
nullable=False,
)
# 6. message_meta.step_type
op.alter_column(
"message_meta",
"step_type",
type_=sa.String(50),
existing_type=postgresql.ENUM(
"INPUT",
"REWRITE",
"ROUTER",
"COMBINATION",
"SQL_TABLE_SELECTION",
"SQL_GENERATION",
"TITLE_GENERATION",
"TOOL_ORCHESTRATION",
name="messagemetasteptype",
),
nullable=False,
)
# 7. workspacevectorization.status
op.alter_column(
"workspacevectorization",
"status",
type_=sa.String(50),
existing_type=postgresql.ENUM("queued", "running", "success", "failed", name="vectorizationstatus"),
nullable=False,
server_default="queued",
)
# Now we need to update the values from UPPERCASE to lowercase
# This is needed because the Python enums use lowercase values
# Update agent_artifacts
op.execute("UPDATE agent_artifacts SET agent_name = LOWER(agent_name)")
op.execute("UPDATE agent_artifacts SET content_type = LOWER(content_type)")
# Update messages
op.execute("UPDATE messages SET user_type = LOWER(user_type)")
# Update message_feedbacks
op.execute("UPDATE message_feedbacks SET type = LOWER(type)")
# Update message_flow_steps
op.execute("UPDATE message_flow_steps SET step_type = LOWER(step_type)")
# Update message_meta
op.execute("UPDATE message_meta SET step_type = LOWER(step_type)")
# The workspacevectorization status is already lowercase, no update needed
# Finally, drop all the enum types
op.execute("DROP TYPE IF EXISTS agentstype CASCADE")
op.execute("DROP TYPE IF EXISTS agentartifactcontenttype CASCADE")
op.execute("DROP TYPE IF EXISTS usertypechoices CASCADE")
op.execute("DROP TYPE IF EXISTS messagefeedbacktypechoices CASCADE")
op.execute("DROP TYPE IF EXISTS flowsteptype CASCADE")
op.execute("DROP TYPE IF EXISTS messagemetasteptype CASCADE")
op.execute("DROP TYPE IF EXISTS vectorizationstatus CASCADE")
def downgrade() -> None:
"""Convert VARCHAR columns back to enum columns."""
# Recreate the enum types
agentstype = postgresql.ENUM("prd_writer", name="agentstype", create_type=False)
agentstype.create(op.get_bind(), checkfirst=True)
agentartifactcontenttype = postgresql.ENUM("markdown", name="agentartifactcontenttype", create_type=False)
agentartifactcontenttype.create(op.get_bind(), checkfirst=True)
usertypechoices = postgresql.ENUM("user", "assistant", "system", name="usertypechoices", create_type=False)
usertypechoices.create(op.get_bind(), checkfirst=True)
messagefeedbacktypechoices = postgresql.ENUM("feedback", "reaction", name="messagefeedbacktypechoices", create_type=False)
messagefeedbacktypechoices.create(op.get_bind(), checkfirst=True)
flowsteptype = postgresql.ENUM("rewrite", "routing", "tool", "combination", name="flowsteptype", create_type=False)
flowsteptype.create(op.get_bind(), checkfirst=True)
messagemetasteptype = postgresql.ENUM(
"input",
"rewrite",
"router",
"combination",
"sql_table_selection",
"sql_generation",
"title_generation",
"tool_orchestration",
name="messagemetasteptype",
create_type=False,
)
messagemetasteptype.create(op.get_bind(), checkfirst=True)
vectorizationstatus = postgresql.ENUM("queued", "running", "success", "failed", name="vectorizationstatus", create_type=False)
vectorizationstatus.create(op.get_bind(), checkfirst=True)
# Convert columns back to enum types
op.alter_column(
"agent_artifacts", "agent_name", type_=agentstype, existing_type=sa.String(255), nullable=False, postgresql_using="agent_name::agentstype"
)
op.alter_column(
"agent_artifacts",
"content_type",
type_=agentartifactcontenttype,
existing_type=sa.String(50),
nullable=False,
postgresql_using="content_type::agentartifactcontenttype",
)
op.alter_column(
"messages", "user_type", type_=usertypechoices, existing_type=sa.String(50), nullable=False, postgresql_using="user_type::usertypechoices"
)
op.alter_column(
"message_feedbacks",
"type",
type_=messagefeedbacktypechoices,
existing_type=sa.String(50),
nullable=False,
postgresql_using="type::messagefeedbacktypechoices",
)
op.alter_column(
"message_flow_steps", "step_type", type_=flowsteptype, existing_type=sa.String(50), nullable=False, postgresql_using="step_type::flowsteptype"
)
op.alter_column(
"message_meta",
"step_type",
type_=messagemetasteptype,
existing_type=sa.String(50),
nullable=False,
postgresql_using="step_type::messagemetasteptype",
)
op.alter_column(
"workspacevectorization",
"status",
type_=vectorizationstatus,
existing_type=sa.String(50),
nullable=False,
postgresql_using="status::vectorizationstatus",
)
@@ -0,0 +1,50 @@
from typing import Sequence
from typing import Union
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d6b5bf289cd4"
down_revision: Union[str, None] = "2a2367c71b4d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"workspacevectorization",
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_by_id", sa.Uuid(), nullable=True),
sa.Column("updated_by_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("workspace_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("status", sa.Enum("queued", "running", "success", "failed", name="vectorizationstatus"), nullable=False),
sa.Column("feed_issues", sa.Boolean(), nullable=False),
sa.Column("feed_pages", sa.Boolean(), nullable=False),
sa.Column("feed_slices", sa.Integer(), nullable=False),
sa.Column("batch_size", sa.Integer(), nullable=False),
sa.Column("live_sync_enabled", sa.Boolean(), nullable=False),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.Column("progress_pct", sa.Integer(), nullable=False),
sa.Column("last_error", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_workspacevectorization_id"), "workspacevectorization", ["id"], unique=False)
op.create_index(op.f("ix_workspacevectorization_status"), "workspacevectorization", ["status"], unique=False)
op.create_index(op.f("ix_workspacevectorization_workspace_id"), "workspacevectorization", ["workspace_id"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_workspacevectorization_workspace_id"), table_name="workspacevectorization")
op.drop_index(op.f("ix_workspacevectorization_status"), table_name="workspacevectorization")
op.drop_index(op.f("ix_workspacevectorization_id"), table_name="workspacevectorization")
op.drop_table("workspacevectorization")
# ### end Alembic commands ###
@@ -0,0 +1,224 @@
from typing import Sequence
from typing import Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "e9c1d7f3a9ab"
down_revision: Union[str, None] = "b18f9fa7c377"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create analytics schema
op.execute("CREATE SCHEMA IF NOT EXISTS analytics;")
# View: messages_enriched
op.execute(
r"""
CREATE OR REPLACE VIEW analytics.messages_enriched AS
SELECT
m.id AS message_id,
m.created_at AS message_created_at,
m.chat_id,
c.user_id,
c.workspace_id,
m.sequence,
m.user_type,
m.llm_model_id,
m.llm_model AS llm_model_key,
m.parsed_content
FROM public.messages m
LEFT JOIN public.chats c ON c.id = m.chat_id
WHERE m.deleted_at IS NULL;
"""
)
# View: message_costs_per_message
op.execute(
r"""
CREATE OR REPLACE VIEW analytics.message_costs_per_message AS
SELECT
mm.message_id,
MIN(m.created_at) AS message_created_at,
SUM(COALESCE(mm.input_text_tokens, 0)) AS input_tokens,
SUM(COALESCE(mm.cached_input_text_tokens, 0)) AS cached_input_tokens,
SUM(COALESCE(mm.output_text_tokens, 0)) AS output_tokens,
SUM(COALESCE(mm.input_text_price, 0)
+ COALESCE(mm.cached_input_text_price, 0)
+ COALESCE(mm.output_text_price, 0)) AS total_usd,
COUNT(*) AS meta_steps
FROM public.message_meta mm
JOIN public.messages m ON m.id = mm.message_id
WHERE mm.deleted_at IS NULL
GROUP BY mm.message_id;
"""
)
# View: daily_llm_spend
op.execute(
r"""
CREATE OR REPLACE VIEW analytics.daily_llm_spend AS
SELECT
DATE(me.message_created_at) AS day,
COALESCE(me.llm_model_key, lm.model_key) AS model_key,
me.llm_model_id,
me.workspace_id,
COUNT(DISTINCT me.message_id) AS messages,
SUM(m.total_usd) AS usd,
SUM(m.input_tokens) AS input_tokens,
SUM(m.cached_input_tokens) AS cached_input_tokens,
SUM(m.output_tokens) AS output_tokens
FROM analytics.message_costs_per_message m
JOIN analytics.messages_enriched me ON me.message_id = m.message_id
LEFT JOIN public.llm_models lm ON lm.id = me.llm_model_id
GROUP BY 1,2,3,4;
"""
)
# View: message_steps_enriched
op.execute(
r"""
CREATE OR REPLACE VIEW analytics.message_steps_enriched AS
SELECT
s.id AS step_id,
s.created_at AS step_created_at,
s.message_id,
s.chat_id,
s.step_type,
s.tool_name,
s.execution_data,
c.user_id,
c.workspace_id,
m.user_type
FROM public.message_flow_steps s
LEFT JOIN public.messages m ON m.id = s.message_id
LEFT JOIN public.chats c ON c.id = s.chat_id
WHERE s.deleted_at IS NULL;
"""
)
# View: message_feedbacks_enriched
op.execute(
r"""
CREATE OR REPLACE VIEW analytics.message_feedbacks_enriched AS
SELECT
f.id AS feedback_id,
f.created_at AS feedback_created_at,
f.type,
f.reaction,
f.feedback,
f.user_id AS feedback_user_id,
f.message_id,
me.chat_id,
c.workspace_id
FROM public.message_feedbacks f
JOIN public.messages me ON me.id = f.message_id
LEFT JOIN public.chats c ON c.id = me.chat_id
WHERE f.deleted_at IS NULL;
"""
)
# View: chats_enriched
op.execute(
r"""
CREATE OR REPLACE VIEW analytics.chats_enriched AS
SELECT
c.id AS chat_id,
c.created_at AS chat_created_at,
c.user_id,
c.workspace_id,
c.is_favorite,
c.is_project_chat,
COUNT(m.id) AS message_count,
MAX(m.created_at) AS last_activity_at
FROM public.chats c
LEFT JOIN public.messages m ON m.chat_id = c.id AND m.deleted_at IS NULL
WHERE c.deleted_at IS NULL
GROUP BY c.id;
"""
)
# View: daily_user_activity
op.execute(
r"""
CREATE OR REPLACE VIEW analytics.daily_user_activity AS
SELECT
DATE(m.created_at) AS day,
c.user_id,
c.workspace_id,
COUNT(m.id) AS messages,
COUNT(DISTINCT m.chat_id) AS active_chats
FROM public.messages m
JOIN public.chats c ON c.id = m.chat_id
WHERE m.deleted_at IS NULL
GROUP BY 1,2,3;
"""
)
# Materialized View: mv_daily_usage
op.execute(
r"""
CREATE MATERIALIZED VIEW IF NOT EXISTS analytics.mv_daily_usage AS
SELECT
d.day,
d.workspace_id,
COUNT(DISTINCT d.user_id) AS dau,
SUM(d.messages) AS messages,
SUM(d.active_chats) AS active_chats
FROM analytics.daily_user_activity d
GROUP BY 1,2;
"""
)
# Materialized View: mv_daily_spend
op.execute(
r"""
CREATE MATERIALIZED VIEW IF NOT EXISTS analytics.mv_daily_spend AS
SELECT
day,
workspace_id,
model_key,
SUM(usd) AS usd,
SUM(input_tokens) AS input_tokens,
SUM(cached_input_tokens) AS cached_input_tokens,
SUM(output_tokens) AS output_tokens
FROM analytics.daily_llm_spend
GROUP BY 1,2,3;
"""
)
# Materialized View: mv_user_first_activity
op.execute(
r"""
CREATE MATERIALIZED VIEW IF NOT EXISTS analytics.mv_user_first_activity AS
SELECT
c.user_id,
c.workspace_id,
MIN(m.created_at)::date AS first_message_day
FROM public.messages m
JOIN public.chats c ON c.id = m.chat_id
WHERE m.deleted_at IS NULL
GROUP BY c.user_id, c.workspace_id;
"""
)
def downgrade() -> None:
# Drop materialized views first (in dependency order)
op.execute("DROP MATERIALIZED VIEW IF EXISTS analytics.mv_user_first_activity;")
op.execute("DROP MATERIALIZED VIEW IF EXISTS analytics.mv_daily_spend;")
op.execute("DROP MATERIALIZED VIEW IF EXISTS analytics.mv_daily_usage;")
# Drop regular views
op.execute("DROP VIEW IF EXISTS analytics.daily_user_activity;")
op.execute("DROP VIEW IF EXISTS analytics.chats_enriched;")
op.execute("DROP VIEW IF EXISTS analytics.message_feedbacks_enriched;")
op.execute("DROP VIEW IF EXISTS analytics.message_steps_enriched;")
op.execute("DROP VIEW IF EXISTS analytics.daily_llm_spend;")
op.execute("DROP VIEW IF EXISTS analytics.message_costs_per_message;")
op.execute("DROP VIEW IF EXISTS analytics.messages_enriched;")
# Drop schema
op.execute("DROP SCHEMA IF EXISTS analytics CASCADE;")
@@ -0,0 +1,87 @@
from typing import Sequence
from typing import Union
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f395eac7eb26"
down_revision: Union[str, None] = "6de9a46f74be"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"planeoauthstate",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("state", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("workspace_id", sa.Uuid(), nullable=True),
sa.Column("redirect_uri", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("chat_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("message_token", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("return_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("is_project_chat", sa.Boolean(), nullable=True),
sa.Column("project_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("pi_sidebar_open", sa.Boolean(), nullable=True),
sa.Column("sidebar_open_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("is_used", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_planeoauthstate_state"), "planeoauthstate", ["state"], unique=True)
op.create_index(op.f("ix_planeoauthstate_user_id"), "planeoauthstate", ["user_id"], unique=False)
op.create_table(
"planeoauthtoken",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("workspace_id", sa.Uuid(), nullable=False),
sa.Column("workspace_slug", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("access_token", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("refresh_token", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("token_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("expires_in", sa.Integer(), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("app_installation_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("app_bot_user_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_planeoauthtoken_user_id"), "planeoauthtoken", ["user_id"], unique=False)
op.create_index(op.f("ix_planeoauthtoken_workspace_id"), "planeoauthtoken", ["workspace_id"], unique=False)
op.create_index(op.f("ix_planeoauthtoken_workspace_slug"), "planeoauthtoken", ["workspace_slug"], unique=False)
op.add_column("message_flow_steps", sa.Column("is_executed", sa.Boolean(), server_default=sa.text("false"), nullable=True))
op.add_column("message_flow_steps", sa.Column("is_planned", sa.Boolean(), server_default=sa.text("false"), nullable=True))
op.add_column("message_flow_steps", sa.Column("execution_success", sa.String(length=50), nullable=True))
op.add_column("message_flow_steps", sa.Column("execution_error", sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column("message_flow_steps", sa.Column("oauth_required", sa.Boolean(), server_default=sa.text("false"), nullable=True))
op.add_column("message_flow_steps", sa.Column("oauth_completed", sa.Boolean(), server_default=sa.text("false"), nullable=True))
op.add_column("message_flow_steps", sa.Column("oauth_completed_at", sa.DateTime(), nullable=True))
op.create_index(op.f("ix_message_flow_steps_execution_success"), "message_flow_steps", ["execution_success"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_message_flow_steps_execution_success"), table_name="message_flow_steps")
op.drop_column("message_flow_steps", "oauth_completed_at")
op.drop_column("message_flow_steps", "oauth_completed")
op.drop_column("message_flow_steps", "oauth_required")
op.drop_column("message_flow_steps", "execution_error")
op.drop_column("message_flow_steps", "execution_success")
op.drop_column("message_flow_steps", "is_planned")
op.drop_column("message_flow_steps", "is_executed")
op.drop_index(op.f("ix_planeoauthtoken_workspace_slug"), table_name="planeoauthtoken")
op.drop_index(op.f("ix_planeoauthtoken_workspace_id"), table_name="planeoauthtoken")
op.drop_index(op.f("ix_planeoauthtoken_user_id"), table_name="planeoauthtoken")
op.drop_table("planeoauthtoken")
op.drop_index(op.f("ix_planeoauthstate_user_id"), table_name="planeoauthstate")
op.drop_index(op.f("ix_planeoauthstate_state"), table_name="planeoauthstate")
op.drop_table("planeoauthstate")
# ### end Alembic commands ###
@@ -0,0 +1,221 @@
from typing import Sequence
from typing import Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f4c8d9e1b2a7"
down_revision: Union[str, None] = "e9c1d7f3a9ab"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add workspace_slug column to chats table
op.add_column("chats", sa.Column("workspace_slug", sa.String(length=255), nullable=True))
# Add workspace_slug column to messages table
op.add_column("messages", sa.Column("workspace_slug", sa.String(length=255), nullable=True))
# Add workspace_slug column to message_meta table
op.add_column("message_meta", sa.Column("workspace_slug", sa.String(length=255), nullable=True))
# Add workspace_slug column to message_flow_steps table
op.add_column("message_flow_steps", sa.Column("workspace_slug", sa.String(length=255), nullable=True))
# Add workspace_slug column to message_feedbacks table
op.add_column("message_feedbacks", sa.Column("workspace_slug", sa.String(length=255), nullable=True))
# Update analytics views to use workspace_slug instead of workspace_id
op.execute("DROP VIEW IF EXISTS analytics.messages_enriched CASCADE;")
op.execute("""
CREATE OR REPLACE VIEW analytics.messages_enriched AS
SELECT
m.id AS message_id,
m.created_at AS message_created_at,
m.chat_id,
c.user_id,
COALESCE(c.workspace_slug, c.workspace_id::text) AS workspace_slug,
m.sequence,
m.user_type,
m.llm_model_id,
m.llm_model AS llm_model_key,
m.parsed_content
FROM public.messages m
LEFT JOIN public.chats c ON c.id = m.chat_id
WHERE m.deleted_at IS NULL;
""")
op.execute("DROP VIEW IF EXISTS analytics.daily_llm_spend CASCADE;")
op.execute("""
CREATE OR REPLACE VIEW analytics.daily_llm_spend AS
SELECT
DATE(me.message_created_at) AS day,
COALESCE(me.llm_model_key, lm.model_key) AS model_key,
me.llm_model_id,
me.workspace_slug,
COUNT(DISTINCT me.message_id) AS messages,
SUM(m.total_usd) AS usd,
SUM(m.input_tokens) AS input_tokens,
SUM(m.cached_input_tokens) AS cached_input_tokens,
SUM(m.output_tokens) AS output_tokens
FROM analytics.message_costs_per_message m
JOIN analytics.messages_enriched me ON me.message_id = m.message_id
LEFT JOIN public.llm_models lm ON lm.id = me.llm_model_id
GROUP BY 1,2,3,4;
""")
op.execute("DROP VIEW IF EXISTS analytics.message_steps_enriched CASCADE;")
op.execute("""
CREATE OR REPLACE VIEW analytics.message_steps_enriched AS
SELECT
s.id AS step_id,
s.created_at AS step_created_at,
s.message_id,
s.chat_id,
s.step_type,
s.tool_name,
s.execution_data,
c.user_id,
COALESCE(c.workspace_slug, c.workspace_id::text) AS workspace_slug,
m.user_type
FROM public.message_flow_steps s
LEFT JOIN public.messages m ON m.id = s.message_id
LEFT JOIN public.chats c ON c.id = s.chat_id
WHERE s.deleted_at IS NULL;
""")
op.execute("DROP VIEW IF EXISTS analytics.message_feedbacks_enriched CASCADE;")
op.execute("""
CREATE OR REPLACE VIEW analytics.message_feedbacks_enriched AS
SELECT
f.id AS feedback_id,
f.created_at AS feedback_created_at,
f.type,
f.reaction,
f.feedback,
f.user_id AS feedback_user_id,
f.message_id,
me.chat_id,
COALESCE(c.workspace_slug, c.workspace_id::text) AS workspace_slug
FROM public.message_feedbacks f
JOIN public.messages me ON me.id = f.message_id
LEFT JOIN public.chats c ON c.id = me.chat_id
WHERE f.deleted_at IS NULL;
""")
op.execute("DROP VIEW IF EXISTS analytics.chats_enriched CASCADE;")
op.execute("""
CREATE OR REPLACE VIEW analytics.chats_enriched AS
SELECT
c.id AS chat_id,
c.created_at AS chat_created_at,
c.user_id,
COALESCE(c.workspace_slug, c.workspace_id::text) AS workspace_slug,
c.is_favorite,
c.is_project_chat,
COUNT(m.id) AS message_count,
MAX(m.created_at) AS last_activity_at
FROM public.chats c
LEFT JOIN public.messages m ON m.chat_id = c.id AND m.deleted_at IS NULL
WHERE c.deleted_at IS NULL
GROUP BY c.id;
""")
op.execute("DROP VIEW IF EXISTS analytics.daily_user_activity CASCADE;")
op.execute("""
CREATE OR REPLACE VIEW analytics.daily_user_activity AS
SELECT
DATE(m.created_at) AS day,
c.user_id,
COALESCE(c.workspace_slug, c.workspace_id::text) AS workspace_slug,
COUNT(m.id) AS messages,
COUNT(DISTINCT m.chat_id) AS active_chats
FROM public.messages m
JOIN public.chats c ON c.id = m.chat_id
WHERE m.deleted_at IS NULL
GROUP BY 1,2,3;
""")
# Update materialized views
op.execute("DROP MATERIALIZED VIEW IF EXISTS analytics.mv_daily_usage;")
op.execute("""
CREATE MATERIALIZED VIEW analytics.mv_daily_usage AS
SELECT
d.day,
d.workspace_slug,
COUNT(DISTINCT d.user_id) AS dau,
SUM(d.messages) AS messages,
SUM(d.active_chats) AS active_chats
FROM analytics.daily_user_activity d
GROUP BY 1,2;
""")
op.execute("DROP MATERIALIZED VIEW IF EXISTS analytics.mv_daily_spend;")
op.execute("""
CREATE MATERIALIZED VIEW analytics.mv_daily_spend AS
SELECT
day,
workspace_slug,
model_key,
SUM(usd) AS usd,
SUM(input_tokens) AS input_tokens,
SUM(cached_input_tokens) AS cached_input_tokens,
SUM(output_tokens) AS output_tokens
FROM analytics.daily_llm_spend
GROUP BY 1,2,3;
""")
op.execute("DROP MATERIALIZED VIEW IF EXISTS analytics.mv_user_first_activity;")
op.execute("""
CREATE MATERIALIZED VIEW analytics.mv_user_first_activity AS
SELECT
c.user_id,
COALESCE(c.workspace_slug, c.workspace_id::text) AS workspace_slug,
MIN(m.created_at)::date AS first_message_day
FROM public.messages m
JOIN public.chats c ON c.id = m.chat_id
WHERE m.deleted_at IS NULL
GROUP BY c.user_id, COALESCE(c.workspace_slug, c.workspace_id::text);
""")
def downgrade() -> None:
# Recreate original views with workspace_id
op.execute("DROP MATERIALIZED VIEW IF EXISTS analytics.mv_user_first_activity;")
op.execute("DROP MATERIALIZED VIEW IF EXISTS analytics.mv_daily_spend;")
op.execute("DROP MATERIALIZED VIEW IF EXISTS analytics.mv_daily_usage;")
op.execute("DROP VIEW IF EXISTS analytics.daily_user_activity CASCADE;")
op.execute("DROP VIEW IF EXISTS analytics.chats_enriched CASCADE;")
op.execute("DROP VIEW IF EXISTS analytics.message_feedbacks_enriched CASCADE;")
op.execute("DROP VIEW IF EXISTS analytics.message_steps_enriched CASCADE;")
op.execute("DROP VIEW IF EXISTS analytics.daily_llm_spend CASCADE;")
op.execute("DROP VIEW IF EXISTS analytics.messages_enriched CASCADE;")
# Remove workspace_slug columns
op.drop_column("message_feedbacks", "workspace_slug")
op.drop_column("message_flow_steps", "workspace_slug")
op.drop_column("message_meta", "workspace_slug")
op.drop_column("messages", "workspace_slug")
op.drop_column("chats", "workspace_slug")
# Recreate original views (from previous migration)
op.execute("""
CREATE OR REPLACE VIEW analytics.messages_enriched AS
SELECT
m.id AS message_id,
m.created_at AS message_created_at,
m.chat_id,
c.user_id,
c.workspace_id,
m.sequence,
m.user_type,
m.llm_model_id,
m.llm_model AS llm_model_key,
m.parsed_content
FROM public.messages m
LEFT JOIN public.chats c ON c.id = m.chat_id
WHERE m.deleted_at IS NULL;
""")
# Add other view recreations as needed...
+9
View File
@@ -0,0 +1,9 @@
# NOTE: PRODUCTION SERVER. TRY AVOIDING EXPERIMENTAL CHANGES.
# Pi API Server
Pi API Server serving Pi to Plane's World (Pi ~ Plane Intelligence)
```
python main.py
```
View File
View File
+183
View File
@@ -0,0 +1,183 @@
import asyncio
from typing import Annotated
import httpx
from fastapi import Header
from fastapi import HTTPException
from fastapi.security import APIKeyCookie
from fastapi.security import HTTPAuthorizationCredentials
from fastapi.security import HTTPBearer
from pydantic import ValidationError
from starlette.status import HTTP_400_BAD_REQUEST
from starlette.status import HTTP_401_UNAUTHORIZED
from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
from pi import logger
from pi import settings
from pi.app.schemas.auth import AuthResponse
log = logger.getChild("dependencies")
session_check_url = settings.plane_api.SESSION_CHECK
cookie_schema = APIKeyCookie(name=settings.plane_api.SESSION_COOKIE_NAME)
jwt_schema = HTTPBearer()
async def is_valid_session(session: str) -> AuthResponse:
if not session:
log.error("Missing or empty session cookie")
raise HTTPException(status_code=400, detail="Missing or invalid session cookie")
max_retries = 3
delay = 0.2 # seconds
for attempt in range(1, max_retries + 1):
try:
async with httpx.AsyncClient() as client:
response = await client.get(session_check_url, cookies={settings.plane_api.SESSION_COOKIE_NAME: session}, timeout=5.0)
response.raise_for_status()
data = response.json()
try:
auth = AuthResponse(**data)
except ValidationError as e:
log.error(f"Invalid response structure: {e}")
raise HTTPException(status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid response structure from session service")
if auth.is_authenticated:
return auth
log.error("User is not authenticated")
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="User is not authenticated")
except httpx.RequestError as e:
log.error(f"Request failed (attempt {attempt}): {e}")
if attempt < max_retries:
await asyncio.sleep(delay)
continue
raise HTTPException(status_code=HTTP_503_SERVICE_UNAVAILABLE, detail="Session validation service unavailable")
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
log.error(f"Unauthorized session: {e}")
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid or expired session")
log.error(f"HTTP error checking session: {e}")
raise HTTPException(status_code=e.response.status_code, detail="Error checking session")
except Exception as e:
log.error(f"Unexpected error checking session: {e!s}")
raise HTTPException(status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error")
raise HTTPException(status_code=HTTP_503_SERVICE_UNAVAILABLE, detail="Session validation service unavailable")
async def verify_internal_secret_key(x_internal_api_secret: Annotated[str | None, Header(description="Api Secret for internal endpoints")] = None):
"""Verify the Internal Api Secret for protected operations."""
expected_key = settings.server.PLANE_PI_INTERNAL_API_SECRET
if not expected_key:
log.error("Internal Api Secret not configured on server")
raise HTTPException(
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Api Secret not configured on server",
)
if not x_internal_api_secret:
log.error("Missing Internal Api Secret header")
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Missing X-Internal-Secret-Key header",
)
if x_internal_api_secret != expected_key:
log.error("Invalid Internal Api Secret")
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Invalid Internal Api Secret",
)
return True
async def is_jwt_valid(token: str) -> AuthResponse:
if not token:
log.error("Missing or empty JWT token")
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail="Missing or invalid JWT token",
)
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(
session_check_url,
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
data = resp.json()
try:
auth = AuthResponse(**data)
except ValidationError as e:
log.error(f"Invalid response structure: {e}")
raise HTTPException(
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
detail="Invalid response structure from JWT validation service",
)
if auth.is_authenticated:
return auth
log.error("User is not authenticated (JWT check)")
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="User is not authenticated",
)
# Network / service-level failures
except httpx.RequestError as e:
log.error(f"JWT validation request failed: {e}")
raise HTTPException(
status_code=HTTP_503_SERVICE_UNAVAILABLE,
detail="JWT validation service unavailable",
)
# 4xx/5xx returned by the remote service
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
log.error("Invalid or expired JWT")
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Invalid or expired JWT",
)
log.error(f"HTTP error checking JWT: {e}")
raise HTTPException(
status_code=e.response.status_code,
detail="Error checking JWT",
)
except HTTPException as e:
raise e
# Anything truly unexpected
except Exception as e:
log.error(f"Unexpected error checking JWT: {e!s}")
raise HTTPException(
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error",
)
async def validate_jwt_token(credentials: HTTPAuthorizationCredentials | None) -> AuthResponse:
"""
Validate JWT token from HTTPAuthorizationCredentials.
Handles null checks and proper error handling.
Raises HTTPException for compatibility with endpoint error handling.
"""
if not credentials:
log.error("Missing JWT token credentials")
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Missing Authorization header")
if not credentials.credentials:
log.error("Empty JWT token in credentials")
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Empty JWT token")
return await is_jwt_valid(credentials.credentials)
@@ -0,0 +1,174 @@
"""Action artifacts endpoint."""
import uuid
from typing import Any
from typing import List
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from pydantic import UUID4
from pi import logger
from pi.app.api.v1.dependencies import cookie_schema
from pi.app.api.v1.dependencies import is_valid_session
from pi.core.db.plane_pi.lifecycle import get_async_session
from pi.services.actions.artifacts.utils import prepare_artifact_data
from pi.services.retrievers.pg_store.action_artifact import get_action_artifacts_by_chat_id
from pi.services.retrievers.pg_store.action_artifact import get_action_artifacts_by_ids
log = logger.getChild(__name__)
router = APIRouter()
def serialize_for_json(obj: Any) -> Any:
"""Convert objects to JSON-serializable format."""
if isinstance(obj, uuid.UUID):
return str(obj)
elif isinstance(obj, dict):
return {key: serialize_for_json(value) for key, value in obj.items()}
elif isinstance(obj, list):
return [serialize_for_json(item) for item in obj]
else:
return obj
@router.get("/")
async def get_action_artifacts(artifact_ids: List[UUID4], session: str = Depends(cookie_schema), db=Depends(get_async_session)):
"""
Retrieve action artifacts by their IDs.
"""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"success": False, "detail": "Invalid User"})
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"success": False, "detail": "Invalid Session"})
try:
# Get the action artifacts
artifacts = await get_action_artifacts_by_ids(db, artifact_ids)
# Return enhanced artifact data
artifacts_data = []
for artifact in artifacts:
# Prepare enhanced data for UI display
try:
from pi.services.actions.artifacts.utils import prepare_artifact_data
enhanced_data = await prepare_artifact_data(artifact.entity, artifact.data)
except Exception as e:
log.warning(f"Error preparing artifact data for {artifact.id}: {e}")
enhanced_data = artifact.data
artifact_dict = {
"artifact_id": str(artifact.id), # Renamed from 'id' to match stream format
"sequence": artifact.sequence,
"artifact_type": artifact.entity, # Renamed from 'entity' to match stream format
"entity_id": str(artifact.entity_id) if artifact.entity_id else None,
"action": artifact.action,
"parameters": serialize_for_json(enhanced_data), # Renamed from 'data' to match stream format
"success": artifact.success, # Individual artifact success from database
"is_executed": artifact.is_executed,
"created_at": artifact.created_at.isoformat() if artifact.created_at else None,
"updated_at": artifact.updated_at.isoformat() if artifact.updated_at else None,
}
artifacts_data.append(artifact_dict)
return JSONResponse(status_code=200, content={"success": True, "artifacts": artifacts_data})
except HTTPException:
raise
except Exception as e:
log.error(f"Get action artifacts failed: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/chat/{chat_id}/")
async def get_chat_artifacts(chat_id: UUID4, session: str = Depends(cookie_schema), db=Depends(get_async_session)):
"""
Retrieve all action artifacts for a specific chat.
"""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"success": False, "detail": "Invalid User"})
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"success": False, "detail": "Invalid Session"})
try:
# Get the action artifacts for the chat
artifacts = await get_action_artifacts_by_chat_id(db, chat_id)
# Return enhanced artifact data
artifacts_data = []
for artifact in artifacts:
# Prepare enhanced data for UI display
try:
enhanced_data = await prepare_artifact_data(artifact.entity, artifact.data)
except Exception as e:
log.warning(f"Error preparing artifact data for {artifact.id}: {e}")
enhanced_data = artifact.data
# Extract entity_url and identifier if available from persisted entity_info
entity_url = None
issue_identifier = None
try:
if isinstance(artifact.data, dict):
entity_info = artifact.data.get("entity_info")
if isinstance(entity_info, dict):
entity_url = entity_info.get("entity_url")
issue_identifier = entity_info.get("issue_identifier")
except Exception:
entity_url = None
issue_identifier = None
# Extract project_identifier from planning_data/tool_args or entity_info if persisted
project_identifier = None
try:
if isinstance(artifact.data, dict):
# Some flows may persist project info under planning_data.parameters.project.identifier
planning_data = artifact.data.get("planning_data") or {}
params = planning_data.get("parameters") or {}
project_block = params.get("project")
if isinstance(project_block, dict):
project_identifier = project_block.get("identifier")
# Fallback 1: check entity_info for a project identifier
if not project_identifier:
entity_info = artifact.data.get("entity_info") or {}
if isinstance(entity_info, dict):
# For work-items, we already expose issue_identifier; for projects, support a generic identifier
if entity_info.get("project_identifier"):
project_identifier = entity_info.get("project_identifier")
elif entity_info.get("entity_type") == "project" and entity_info.get("entity_identifier"):
project_identifier = entity_info.get("entity_identifier")
# Fallback: if entity_info has identifier-like info in URL (but we avoid parsing here)
except Exception:
project_identifier = None
artifact_dict = {
"artifact_id": str(artifact.id), # Renamed from 'id' to match stream format
"sequence": artifact.sequence,
"artifact_type": artifact.entity, # Renamed from 'entity' to match stream format
"entity_id": str(artifact.entity_id) if artifact.entity_id else None,
"action": artifact.action,
"parameters": serialize_for_json(enhanced_data), # Renamed from 'data' to match stream format
"success": artifact.success, # Individual artifact success from database
"is_executed": artifact.is_executed,
"entity_url": entity_url,
"project_identifier": project_identifier,
"issue_identifier": issue_identifier,
"created_at": artifact.created_at.isoformat() if artifact.created_at else None,
"updated_at": artifact.updated_at.isoformat() if artifact.updated_at else None,
}
artifacts_data.append(artifact_dict)
return JSONResponse(status_code=200, content={"success": True, "artifacts": artifacts_data})
except HTTPException:
raise
except Exception as e:
log.error(f"Get chat artifacts failed: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,352 @@
import uuid
from fastapi import APIRouter
from fastapi import Depends
from fastapi.responses import JSONResponse
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from pi import logger
from pi.app.api.v1.dependencies import cookie_schema
from pi.app.api.v1.dependencies import is_valid_session
from pi.app.models.message_attachment import MessageAttachment
from pi.app.schemas.attachment import AttachmentCompleteRequest
from pi.app.schemas.attachment import AttachmentDetailResponse
from pi.app.schemas.attachment import AttachmentResponse
from pi.app.schemas.attachment import AttachmentUploadRequest
from pi.app.schemas.attachment import AttachmentUploadResponse
from pi.app.schemas.attachment import S3UploadData
from pi.app.utils.attachments import allowed_attachment_types
from pi.app.utils.attachments import get_presigned_url_download
from pi.app.utils.attachments import get_presigned_url_preview
from pi.app.utils.attachments import get_s3_client
from pi.app.utils.attachments import sanitize_filename
from pi.config import settings
from pi.core.db.plane_pi.lifecycle import get_async_session
router = APIRouter()
log = logger.getChild("v1")
# S3 Configuration
S3_BUCKET = settings.AWS_S3_BUCKET
@router.post("/upload-attachment/")
async def create_attachment_upload(
data: AttachmentUploadRequest,
db: AsyncSession = Depends(get_async_session),
session: str = Depends(cookie_schema),
):
"""Step 1: Create attachment record and generate S3 pre-signed upload URL"""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
try:
# Validate file size (10MB for images, 50MB for PDFs)
max_size = settings.FILE_SIZE_LIMIT
if data.file_size > max_size:
return JSONResponse(status_code=413, content={"detail": "File size exceeds limit"})
# Validate file type
if data.content_type not in allowed_attachment_types:
return JSONResponse(status_code=415, content={"detail": "Unsupported file type"})
sanitized_filename = sanitize_filename(data.filename)
# Create attachment record
attachment = MessageAttachment(
original_filename=sanitized_filename,
content_type=data.content_type,
file_size=data.file_size,
file_type=MessageAttachment.get_file_type_from_mime(data.content_type),
status="pending",
file_path="", # Will be set after generating
workspace_id=data.workspace_id,
chat_id=data.chat_id,
message_id=None, # Will be set later when message is created
user_id=user_id,
)
db.add(attachment)
await db.commit()
await db.refresh(attachment)
# Generate S3 file path
file_path = attachment.generate_file_path(data.workspace_id, data.chat_id)
attachment.file_path = file_path
# Generate pre-signed POST data for S3 upload
s3_client = get_s3_client()
presigned_post = s3_client.generate_presigned_post(
Bucket=S3_BUCKET,
Key=file_path,
Fields={"Content-Type": data.content_type},
Conditions=[
{"Content-Type": data.content_type},
["content-length-range", 1, settings.FILE_SIZE_LIMIT],
],
ExpiresIn=600, # 5 minutes
)
await db.commit()
# Prepare response
attachment_response = AttachmentResponse(
id=str(attachment.id),
filename=attachment.original_filename,
content_type=attachment.content_type,
file_size=attachment.file_size,
file_type=attachment.file_type,
status=attachment.status,
)
return JSONResponse(
content=AttachmentUploadResponse(
upload_data=S3UploadData(
url=presigned_post["url"],
fields=presigned_post["fields"],
),
attachment_id=str(attachment.id),
attachment=attachment_response,
).model_dump()
)
except Exception as e:
log.error(f"Error creating attachment upload: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@router.patch("/complete-upload/")
async def complete_attachment_upload(
data: AttachmentCompleteRequest,
db: AsyncSession = Depends(get_async_session),
session: str = Depends(cookie_schema),
):
"""Step 3: Complete attachment upload and optionally link to message"""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
try:
# Get attachment
stmt = select(MessageAttachment).where(
MessageAttachment.id == data.attachment_id,
MessageAttachment.chat_id == data.chat_id,
MessageAttachment.user_id == user_id,
)
result = await db.execute(stmt)
attachment = result.scalar_one_or_none()
if not attachment:
return JSONResponse(status_code=404, content={"detail": "Attachment not found"})
if attachment.status != "pending":
return JSONResponse(status_code=409, content={"detail": "Attachment already processed"})
# Verify file exists in S3 before marking as uploaded
try:
s3_client = get_s3_client()
s3_client.head_object(Bucket=S3_BUCKET, Key=attachment.file_path)
log.info(f"Verified S3 file exists: {attachment.file_path}")
except Exception as s3_error:
log.error(f"S3 file verification failed for {attachment.file_path}: {s3_error}")
return JSONResponse(status_code=400, content={"detail": "File not found in S3. Please upload the file first."})
# Update attachment status only after S3 verification
attachment.status = "uploaded"
download_url = get_presigned_url_download(attachment)
await db.commit()
await db.refresh(attachment)
# Prepare response
return JSONResponse(
content=AttachmentDetailResponse(
id=str(attachment.id),
filename=attachment.original_filename,
content_type=attachment.content_type,
file_size=attachment.file_size,
file_type=attachment.file_type,
status=attachment.status,
attachment_url=download_url,
).model_dump()
)
except Exception as e:
log.error(f"Error completing attachment upload: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@router.get("/get-url/")
async def get_attachment_url(
attachment_id: str,
chat_id: str,
db: AsyncSession = Depends(get_async_session),
session: str = Depends(cookie_schema),
):
"""Get pre-signed URLs (download & preview) for an attachment"""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
try:
# Convert string IDs to UUIDs
attachment_uuid = uuid.UUID(attachment_id)
chat_uuid = uuid.UUID(chat_id)
# Get attachment owned by this user
stmt = select(MessageAttachment).where(
MessageAttachment.id == attachment_uuid,
MessageAttachment.chat_id == chat_uuid,
MessageAttachment.user_id == user_id,
MessageAttachment.status == "uploaded",
)
result = await db.execute(stmt)
attachment = result.scalar_one_or_none()
if not attachment:
return JSONResponse(status_code=404, content={"detail": "Attachment not found"})
# Verify file exists in S3
try:
s3_client = get_s3_client()
s3_client.head_object(Bucket=S3_BUCKET, Key=attachment.file_path)
except Exception as s3_error:
log.error(f"S3 file verification failed for {attachment.file_path}: {s3_error}")
return JSONResponse(status_code=404, content={"detail": "File not found in S3"})
# Use utility functions for URL generation
download_url = get_presigned_url_download(attachment)
preview_url = get_presigned_url_preview(attachment)
return JSONResponse(
content={
"download_url": download_url,
"preview_url": preview_url,
"filename": attachment.original_filename,
"content_type": attachment.content_type,
"file_size": attachment.file_size,
}
)
except ValueError as e:
log.error(f"Invalid UUID format: {e}")
return JSONResponse(status_code=400, content={"detail": "Invalid ID format"})
except Exception as e:
log.error(f"Error generating attachment URLs: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@router.get("/chat/")
async def get_attachments_by_chat(
chat_id: str,
db: AsyncSession = Depends(get_async_session),
session: str = Depends(cookie_schema),
):
"""Get all attachment objects for a chat"""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
try:
# Convert string ID to UUID
chat_uuid = uuid.UUID(chat_id)
# Get all attachments for this chat owned by this user
stmt = select(MessageAttachment).where(
MessageAttachment.chat_id == chat_uuid,
MessageAttachment.user_id == user_id,
MessageAttachment.status == "uploaded",
MessageAttachment.deleted_at.is_(None), # type: ignore[union-attr]
)
result = await db.execute(stmt)
attachments = result.scalars().all()
# Build response with attachment details and URLs
attachment_list = []
for attachment in attachments:
# Generate download and preview URLs
download_url = get_presigned_url_download(attachment)
attachment_data = {
"id": str(attachment.id),
"filename": attachment.original_filename,
"content_type": attachment.content_type,
"file_size": attachment.file_size,
"file_type": attachment.file_type,
"status": attachment.status,
"message_id": str(attachment.message_id) if attachment.message_id else None,
"attachment_url": download_url,
"created_at": attachment.created_at.isoformat() if attachment.created_at else None,
}
attachment_list.append(attachment_data)
return JSONResponse(content={"attachments": attachment_list})
except ValueError as e:
log.error(f"Invalid UUID format: {e}")
return JSONResponse(status_code=400, content={"detail": "Invalid chat ID format"})
except Exception as e:
log.error(f"Error retrieving attachments for chat: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
# @router.delete("/delete-attachment/")
# async def delete_attachment(
# data: AttachmentDeleteRequest,
# db: AsyncSession = Depends(get_async_session),
# session: str = Depends(cookie_schema),
# ):
# """Delete an attachment (soft delete)"""
# try:
# auth = await is_valid_session(session)
# if not auth.user:
# return JSONResponse(status_code=401, content={"detail": "Invalid User"})
# user_id = auth.user.id
# except Exception as e:
# log.error(f"Error validating session: {e!s}")
# return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
# try:
# # Get attachment
# stmt = select(MessageAttachment).where(
# MessageAttachment.id == data.attachment_id,
# MessageAttachment.chat_id == data.chat_id,
# MessageAttachment.user_id == user_id,
# )
# result = await db.execute(stmt)
# attachment = result.scalar_one_or_none()
# if not attachment:
# return JSONResponse(status_code=404, content={"detail": "Attachment not found"})
# # Soft delete
# attachment.soft_delete()
# await db.commit()
# return JSONResponse(content={"detail": "Attachment deleted successfully"})
# except Exception as e:
# log.error(f"Error deleting attachment: {e!s}")
# return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
+934
View File
@@ -0,0 +1,934 @@
import asyncio
import json
from typing import Any
from typing import AsyncGenerator
from typing import Optional
from uuid import UUID
from uuid import uuid4
from fastapi import APIRouter
from fastapi import Depends
from fastapi import Query
from fastapi.responses import JSONResponse
from fastapi.responses import StreamingResponse
from pydantic import UUID4
from sqlalchemy import select
from sqlmodel.ext.asyncio.session import AsyncSession
from pi import logger
from pi.app.api.v1.dependencies import cookie_schema
from pi.app.api.v1.dependencies import is_valid_session
from pi.app.api.v1.helpers.batch_execution_helpers import execute_batch_actions
from pi.app.api.v1.helpers.batch_execution_helpers import format_execution_response
from pi.app.api.v1.helpers.batch_execution_helpers import prepare_execution_data
from pi.app.api.v1.helpers.batch_execution_helpers import update_assistant_message_with_execution_results
from pi.app.api.v1.helpers.batch_execution_helpers import validate_session_and_get_user
from pi.app.api.v1.helpers.plane_sql_queries import resolve_workspace_id_from_project_id
from pi.app.models.enums import FlowStepType
from pi.app.models.enums import UserTypeChoices
from pi.app.models.message import MessageFlowStep
from pi.app.schemas.chat import ActionBatchExecutionRequest
from pi.app.schemas.chat import ChatFeedback
from pi.app.schemas.chat import ChatInitializationRequest
from pi.app.schemas.chat import ChatRequest
from pi.app.schemas.chat import ChatSearchResponse
from pi.app.schemas.chat import ChatSuggestionTemplate
from pi.app.schemas.chat import DeleteChatRequest
from pi.app.schemas.chat import FavoriteChatRequest
from pi.app.schemas.chat import GetThreadsPaginatedResponse
from pi.app.schemas.chat import ModelsResponse
from pi.app.schemas.chat import RenameChatRequest
from pi.app.schemas.chat import TitleRequest
from pi.app.schemas.chat import UnfavoriteChatRequest
from pi.app.utils import validate_chat_initialization
from pi.app.utils import validate_chat_request
from pi.app.utils.background_tasks import schedule_chat_deletion
from pi.app.utils.background_tasks import schedule_chat_rename
from pi.app.utils.background_tasks import schedule_chat_search_upsert
from pi.app.utils.exceptions import SQLGenerationError
from pi.core.db.plane_pi.lifecycle import get_async_session
from pi.core.db.plane_pi.lifecycle import get_streaming_db_session
from pi.services.chat.chat import PlaneChatBot
from pi.services.chat.helpers.batch_execution_helpers import get_original_user_query
from pi.services.chat.helpers.batch_execution_helpers import get_planned_actions_for_execution
from pi.services.chat.helpers.tool_utils import format_clarification_as_text
from pi.services.chat.search import ChatSearchService
from pi.services.chat.templates import tiles_factory
from pi.services.chat.utils import initialize_new_chat
from pi.services.chat.utils import resolve_workspace_slug
from pi.services.retrievers.pg_store import favorite_chat
from pi.services.retrievers.pg_store import get_active_models
from pi.services.retrievers.pg_store import get_chat_messages
from pi.services.retrievers.pg_store import get_favorite_chats
from pi.services.retrievers.pg_store import get_user_chat_threads
from pi.services.retrievers.pg_store import get_user_chat_threads_paginated
from pi.services.retrievers.pg_store import rename_chat_title
from pi.services.retrievers.pg_store import retrieve_chat_history
from pi.services.retrievers.pg_store import soft_delete_chat
from pi.services.retrievers.pg_store import unfavorite_chat
from pi.services.retrievers.pg_store import update_message_feedback
from pi.services.retrievers.pg_store.message import upsert_message
from pi.services.retrievers.pg_store.message import upsert_message_flow_steps
log = logger.getChild("v1/chat")
router = APIRouter()
# Constants for batch execution
BATCH_EXECUTION_ERRORS = {
"NO_PLANNED_ACTIONS": "No planned actions found for this message",
"NO_ORIGINAL_QUERY": "Original user query not found",
"OAUTH_REQUIRED": "No valid OAuth token found. Please complete OAuth authentication for this workspace first.",
"WORKSPACE_NOT_FOUND": "Workspace not found",
"INVALID_SESSION": "Invalid Session",
"INTERNAL_ERROR": "Internal server error",
}
@router.post("/get-answer/")
async def get_answer(data: ChatRequest, session: str = Depends(cookie_schema)):
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
data.user_id = user_id
# Validate request data
validation_error = validate_chat_request(data)
if validation_error:
return JSONResponse(status_code=validation_error["status_code"], content={"detail": validation_error["detail"]})
chatbot = PlaneChatBot(llm=data.llm)
async def stream_response() -> AsyncGenerator[str, None]:
token_id = None
try:
pending_backticks = ""
# Open a short-lived session for the duration of the streaming work
async with get_streaming_db_session() as stream_db:
async for chunk in chatbot.process_query_stream(data, db=stream_db):
if chunk.startswith("πspecial reasoning blockπ: "):
reasoning_content = chunk.replace("πspecial reasoning blockπ: ", "")
# JSON-encode reasoning payload
payload = {"reasoning": reasoning_content}
yield f"event: reasoning\ndata: {json.dumps(payload)}\n\n"
elif chunk.startswith("πspecial actions blockπ: "):
# Extract the actions data and send as separate actions event
actions_content = chunk.replace("πspecial actions blockπ: ", "")
try:
# Parse the JSON content to validate it
actions_data = json.loads(actions_content)
yield f"event: actions\ndata: {json.dumps(actions_data)}\n\n"
except json.JSONDecodeError:
# If JSON parsing fails, fall back to delta event
log.warning(f"Failed to parse actions JSON: {actions_content}")
payload = {"chunk": chunk}
yield f"event: delta\ndata: {json.dumps(payload)}\n\n"
elif chunk.startswith("πspecial clarification blockπ: "):
# Dedicated event to ask user for clarifications
clarification_content = chunk.replace("πspecial clarification blockπ: ", "")
try:
clarification_data = json.loads(clarification_content)
# Format clarification as natural language text for frontend display
formatted_text = format_clarification_as_text(clarification_data)
payload = {"chunk": formatted_text}
yield f"event: delta\ndata: {json.dumps(payload)}\n\n"
except json.JSONDecodeError:
log.warning(f"Failed to parse clarification JSON: {clarification_content}")
# Fallback to raw content if JSON parsing fails
payload = {"chunk": "I'm sorry, I can't understand your request in your workspace context. Can you be more specific?"}
yield f"event: delta\ndata: {json.dumps(payload)}\n\n"
else:
# Handle code block formatting
if chunk.startswith("```"):
# Triple backticks indicate code block - send as-is
# First, yield any accumulated backticks if they exist
if pending_backticks:
payload = {"chunk": pending_backticks}
yield f"event: delta\ndata: {json.dumps(payload)}\n\n"
pending_backticks = ""
payload = {"chunk": chunk}
yield f"event: delta\ndata: {json.dumps(payload)}\n\n"
elif chunk in ["`", "``"]: # Only pure backticks, no other content
# Accumulate backticks - don't overwrite, append
pending_backticks += chunk
continue
else:
# Regular chunk - prepend any pending backticks
if pending_backticks:
chunk = pending_backticks + chunk
pending_backticks = ""
# JSON-encode delta payload
payload = {"chunk": chunk}
yield f"event: delta\ndata: {json.dumps(payload)}\n\n"
# Handle any remaining pending backticks at the end of stream
if pending_backticks:
payload = {"chunk": pending_backticks}
yield f"event: delta\ndata: {json.dumps(payload)}\n\n"
# Extract token_id from data.context if available for background task
if hasattr(data, "context") and isinstance(data.context, dict):
token_id = data.context.get("token_id")
# Explicitly signal completion so EventSource clients don't interpret
# the socket close as an error. Use a custom "done" event.
# The [DONE] sentinel can be JSON-wrapped or left raw; we use message event
payload = {"done": "true"}
yield f"event: done\ndata: {json.dumps(payload)}\n\n"
yield 'event: cta_available\ndata: {"type": "create_page"}\n\n'
except asyncio.CancelledError:
# This is expected if the client disconnects, so log as info and let it propagate
log.info("Stream cancelled by client disconnect.")
except Exception as e:
log.error(f"Error streaming response: {e!s}")
yield "error: 'An unexpected error occurred. Please try again'"
finally:
# Schedule Celery task to upsert chat search index after streaming completes
if token_id:
schedule_chat_search_upsert(token_id)
try:
return StreamingResponse(stream_response(), media_type="text/event-stream")
except SQLGenerationError as e:
log.error(f"SQL generation error: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
except Exception as e:
log.error(f"Unexpected error: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@router.delete("/delete-chat/")
async def delete_chat(data: DeleteChatRequest, db: AsyncSession = Depends(get_async_session), session: str = Depends(cookie_schema)):
try:
await is_valid_session(session)
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
result = await soft_delete_chat(chat_id=data.chat_id, db=db)
# Schedule Celery task to mark chat as deleted in search index
schedule_chat_deletion(str(data.chat_id))
# The soft_delete_chat function always returns a tuple of (status_code, content)
status_code, content = result
return JSONResponse(status_code=status_code, content=content)
@router.post("/feedback/")
async def handle_feedback(
feedback_data: ChatFeedback,
db: AsyncSession = Depends(get_async_session),
session: str = Depends(cookie_schema),
):
try:
# Get user_id from session
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
result = await update_message_feedback(
chat_id=feedback_data.chat_id,
message_index=feedback_data.message_index,
feedback_value=feedback_data.feedback.value,
user_id=user_id,
db=db,
feedback_message=feedback_data.feedback_message,
)
# The update_message_feedback function always returns a tuple of (status_code, content)
status_code, content = result
return JSONResponse(status_code=status_code, content=content)
@router.post("/generate-title/")
async def get_title(data: TitleRequest, db: AsyncSession = Depends(get_async_session), session: str = Depends(cookie_schema)):
try:
await is_valid_session(session)
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
if not data.chat_id:
log.warning("Request missing chat_id")
return JSONResponse(status_code=400, content={"detail": "chat_id is required"})
try:
# Get messages for this chat using the utility function
messages = await get_chat_messages(chat_id=data.chat_id, db=db)
# Check if messages is a tuple (error case)
if isinstance(messages, tuple) and len(messages) == 2:
status_code, content = messages
return JSONResponse(status_code=status_code, content=content)
# Generate or get existing title using PlaneChatBot
chatbot = PlaneChatBot()
title = await chatbot.get_title(chat_id=data.chat_id, messages=messages, db=db)
return JSONResponse(content={"title": title})
except Exception as e:
log.error(f"An unexpected error occurred: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@router.get("/get-recent-user-threads/")
async def get_recent_user_threads(
workspace_id: Optional[UUID4] = None,
workspace_slug: Optional[str] = None,
is_project_chat: Optional[bool] = False,
session: str = Depends(cookie_schema),
db: AsyncSession = Depends(get_async_session),
):
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
results = await get_user_chat_threads(user_id=user_id, db=db, workspace_id=workspace_id, is_project_chat=is_project_chat, is_latest=True)
# Check if results is a tuple (error case)
if isinstance(results, tuple) and len(results) == 2:
status_code_val, content = results # type: ignore[assignment]
status_code_int: int = int(status_code_val) if isinstance(status_code_val, int) else 500
return JSONResponse(status_code=status_code_int, content=content)
# Success case
return JSONResponse(content={"results": results})
@router.get("/get-chat-history/") # deprecated. To be removed
async def get_chat_history(
chat_id: UUID4,
workspace_id: Optional[UUID4] = None,
workspace_slug: Optional[str] = None,
session: str = Depends(cookie_schema),
db: AsyncSession = Depends(get_async_session),
):
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
try:
log.info(f"chat history retrieve request received for chat_id: {chat_id}")
results: dict[str, Any] = await retrieve_chat_history(chat_id=chat_id, db=db, user_id=user_id)
error_type = results.get("error")
if error_type == "not_found":
return JSONResponse(
status_code=404,
content={
"detail": results["detail"],
"results": {
"title": results.get("title", ""),
"dialogue": results.get("dialogue", []),
"llm": results.get("llm", ""),
"feedback": results.get("feedback", ""),
"reasoning": results.get("reasoning", ""),
"is_focus_enabled": results.get("is_focus_enabled", False),
"focus_project_id": results.get("focus_project_id", None),
"focus_workspace_id": results.get("focus_workspace_id", None),
},
},
)
elif error_type == "unauthorized":
return JSONResponse(
status_code=403,
content={
"detail": results["detail"],
"results": {
"title": results.get("title", ""),
"dialogue": results.get("dialogue", []),
"llm": results.get("llm", ""),
"feedback": results.get("feedback", ""),
"reasoning": results.get("reasoning", ""),
"is_focus_enabled": results.get("is_focus_enabled", False),
"focus_project_id": results.get("focus_project_id", None),
"focus_workspace_id": results.get("focus_workspace_id", None),
},
},
)
return JSONResponse(
content={
"results": {
"title": results["title"],
"dialogue": results["dialogue"],
"llm": results["llm"],
"feedback": results["feedback"],
"reasoning": results.get("reasoning", ""),
"is_focus_enabled": results.get("is_focus_enabled", False),
"focus_project_id": results.get("focus_project_id", None),
"focus_workspace_id": results.get("focus_workspace_id", None),
}
}
)
except ValueError as ve:
log.error(f"An error occurred during retrieval: {ve!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
except Exception as e:
log.error(f"An error occurred during retrieval: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@router.get("/get-chat-history-object/")
async def get_chat_history_object(
chat_id: UUID4,
workspace_id: Optional[UUID4] = None,
workspace_slug: Optional[str] = None,
session: str = Depends(cookie_schema),
db: AsyncSession = Depends(get_async_session),
):
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
try:
log.info(f"chat history retrieve request received for chat_id: {chat_id}")
results: dict[str, Any] = await retrieve_chat_history(chat_id=chat_id, dialogue_object=True, db=db, user_id=user_id)
error_type = results.get("error")
if error_type == "not_found":
return JSONResponse(status_code=404, content={"detail": results["detail"]})
elif error_type == "unauthorized":
return JSONResponse(status_code=403, content={"detail": results["detail"]})
results["dialogue"]
return JSONResponse(
content={
"results": {
"title": results["title"],
"dialogue": results["dialogue"],
"llm": results["llm"],
"feedback": results["feedback"],
"reasoning": results.get("reasoning", ""),
"is_focus_enabled": results.get("is_focus_enabled", False),
"focus_project_id": results.get("focus_project_id", None),
"focus_workspace_id": results.get("focus_workspace_id", None),
}
}
)
except ValueError as ve:
log.error(f"An error occurred during retrieval: {ve!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
except Exception as e:
log.error(f"An error occurred during retrieval: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@router.get("/get-models/", response_model=ModelsResponse)
async def get_model_list(
workspace_id: Optional[UUID4] = None,
workspace_slug: Optional[str] = None,
db: AsyncSession = Depends(get_async_session),
session: str = Depends(cookie_schema),
):
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
if not workspace_slug:
if workspace_id:
resolved_workspace_slug = await resolve_workspace_slug(workspace_id, workspace_slug)
else:
log.warning("get-models: No workspace_id to resolve workspace_slug from")
resolved_workspace_slug = None
else:
resolved_workspace_slug = workspace_slug
# Convert user_id to string and provide default workspace_slug if None
models_list = await get_active_models(db=db, user_id=str(user_id), workspace_slug=resolved_workspace_slug or "")
# Check if models_list is a tuple (error case)
if isinstance(models_list, tuple) and len(models_list) == 2:
status_code, content = models_list
return JSONResponse(status_code=status_code, content=content)
# Success case
model_dict = {"models": models_list}
return JSONResponse(content=model_dict)
@router.get("/get-templates/", response_model=ChatSuggestionTemplate)
async def get_chat_template_suggestion(
workspace_id: Optional[UUID4] = None, workspace_slug: Optional[str] = None, session: str = Depends(cookie_schema)
):
try:
await is_valid_session(session)
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
try:
suggestions = tiles_factory()
return ChatSuggestionTemplate(templates=suggestions)
except Exception as e:
log.error(f"An unexpected error occurred: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@router.post("/initialize-chat/")
async def initialize_chat(data: ChatInitializationRequest, db: AsyncSession = Depends(get_async_session), session: str = Depends(cookie_schema)):
"""Initialize a new chat and return the chat_id immediately."""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
# Validate request data
validation_error = validate_chat_initialization(data)
if validation_error:
return JSONResponse(status_code=validation_error["status_code"], content={"detail": validation_error["detail"]})
# Initialize chat using standalone function (workspace details backfilled later in queue_answer)
result = await initialize_new_chat(
user_id=user_id,
db=db,
chat_id=data.chat_id,
is_project_chat=data.is_project_chat,
workspace_in_context=data.workspace_in_context,
workspace_id=data.workspace_id,
)
# Handle result from service layer
if result["success"]:
return JSONResponse(content={"chat_id": result["chat_id"]})
else:
# Map service error codes to HTTP status codes
status_code = 500 # default
if result.get("error_code") == "CHAT_EXISTS":
status_code = 409
elif result.get("error_code") == "CHAT_CREATION_FAILED":
status_code = 500
elif result.get("error_code") == "UNEXPECTED_ERROR":
status_code = 500
return JSONResponse(status_code=status_code, content={"detail": result["message"]})
@router.post("/favorite-chat/")
async def favorite_user_chat(data: FavoriteChatRequest, db: AsyncSession = Depends(get_async_session), session: str = Depends(cookie_schema)):
try:
await is_valid_session(session)
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
result = await favorite_chat(chat_id=data.chat_id, db=db)
status_code, content = result
return JSONResponse(status_code=status_code, content=content)
@router.post("/unfavorite-chat/")
async def unfavorite_user_chat(data: UnfavoriteChatRequest, db: AsyncSession = Depends(get_async_session), session: str = Depends(cookie_schema)):
try:
await is_valid_session(session)
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
result = await unfavorite_chat(chat_id=data.chat_id, db=db)
# The unfavorite_chat function always returns a tuple of (status_code, content)
status_code, content = result
return JSONResponse(status_code=status_code, content=content)
@router.get("/get-favorite-chats/")
async def get_user_favorite_chats(
workspace_id: Optional[UUID4] = None,
workspace_slug: Optional[str] = None,
db: AsyncSession = Depends(get_async_session),
session: str = Depends(cookie_schema),
):
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
result = await get_favorite_chats(user_id=user_id, db=db, workspace_id=workspace_id)
# The get_favorite_chats function always returns a tuple of (status_code, content)
status_code, content = result
return JSONResponse(status_code=status_code, content=content)
@router.post("/rename-chat/")
async def rename_chat(data: RenameChatRequest, db: AsyncSession = Depends(get_async_session), session: str = Depends(cookie_schema)):
try:
await is_valid_session(session)
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
result = await rename_chat_title(chat_id=data.chat_id, new_title=data.title, db=db)
# Schedule Celery task to update chat title in search index
schedule_chat_rename(str(data.chat_id), data.title)
status_code, content = result
return JSONResponse(status_code=status_code, content=content)
# New paginated endpoints for web
@router.get("/get-user-threads/", response_model=GetThreadsPaginatedResponse)
async def get_user_threads(
workspace_id: Optional[UUID4] = None,
workspace_slug: Optional[str] = None,
is_project_chat: Optional[bool] = False,
cursor: Optional[str] = None,
per_page: int = Query(default=30, ge=1, le=100),
session: str = Depends(cookie_schema),
db: AsyncSession = Depends(get_async_session),
):
"""Get user chat threads with cursor-based pagination for web interface."""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
results = await get_user_chat_threads_paginated(
user_id=user_id, db=db, workspace_id=workspace_id, is_project_chat=is_project_chat, cursor=cursor, per_page=per_page
)
# Check if results is a tuple (error case)
if isinstance(results, tuple) and len(results) == 2:
# Check if it's an error tuple (status_code, error_dict) or success tuple (results, pagination)
if isinstance(results[0], int):
status_code_val, content = results
status_code: int = status_code_val # type: ignore[assignment]
return JSONResponse(status_code=status_code, content=content)
else:
# Success case - (results, pagination_response)
chat_results, pagination_response = results
response_data = pagination_response.model_dump() # type: ignore[attr-defined]
response_data["results"] = chat_results
return JSONResponse(content=response_data)
@router.post("/queue-answer/")
async def queue_answer(data: ChatRequest, db: AsyncSession = Depends(get_async_session), session: str = Depends(cookie_schema)):
"""First phase of two-step streaming flow.
Persists the ChatRequest payload and returns a one-time stream token.
The token is simply the UUID of a freshly created *user* Message row so we
don't need a new table. The rest of the ChatRequest fields are stored in
a MessageFlowStep (tool_name="QUEUE", step_order=0) until the client
later redeems the token via /stream-answer/{token}."""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
validation_error = validate_chat_request(data)
if validation_error:
return JSONResponse(status_code=validation_error["status_code"], content={"detail": validation_error["detail"]})
# 1. Create a new USER message that will serve as the token
if data.chat_id is None:
return JSONResponse(status_code=400, content={"detail": "chat_id is required. Call /initialize-chat/ first."})
## need to resolve ws id and slug from project id if it's project chat
if not data.workspace_id:
if data.is_project_chat and data.project_id:
log.info(f"Queue-answer: Resolving workspace_id from project_id: {data.project_id}")
resolved_workspace_id = await resolve_workspace_id_from_project_id(str(data.project_id))
# The DB may return an asyncpg UUID object. Convert safely to a standard uuid.UUID.
workspace_id_to_use = UUID(str(resolved_workspace_id)) if resolved_workspace_id else None
log.info(f"Queue-answer: Resolved workspace_id: {workspace_id_to_use}")
else:
workspace_id_to_use = data.workspace_id
if not data.workspace_slug:
if workspace_id_to_use:
resolved_workspace_slug = await resolve_workspace_slug(workspace_id_to_use, data.workspace_slug)
else:
log.warning("Queue-answer: No workspace_id to resolve workspace_slug from")
resolved_workspace_slug = None
else:
resolved_workspace_slug = data.workspace_slug
token_id = uuid4()
user_message_res = await upsert_message(
message_id=token_id,
chat_id=data.chat_id, # type: ignore[arg-type]
content=data.query,
parsed_content=None,
user_type=UserTypeChoices.USER.value,
llm_model=data.llm,
workspace_slug=resolved_workspace_slug,
db=db,
)
if user_message_res.get("message") != "success":
return JSONResponse(status_code=500, content={"detail": "Failed to create message"})
# 2. Stash the full ChatRequest inside a flow-step row
from fastapi.encoders import jsonable_encoder
flow_step_payload = {
"step_order": 0,
"step_type": FlowStepType.TOOL.value,
"tool_name": "QUEUE",
"content": "queued chat request",
# Use FastAPI's encoder to turn UUIDs/datetimes into JSON-serialisable primitives
"execution_data": jsonable_encoder(data),
"is_planned": False, # QUEUE is not a planned action, it's an internal tool
"is_executed": False, # QUEUE is not executed by user
}
flow_res = await upsert_message_flow_steps(message_id=token_id, chat_id=data.chat_id, db=db, flow_steps=[flow_step_payload])
if flow_res.get("message") != "success":
return JSONResponse(status_code=500, content={"detail": "Failed to queue request"})
# Backfill chat record with workspace details (after successful message creation)
try:
from sqlalchemy import select
from pi.app.models.chat import Chat
# Get the current chat record to preserve existing data
chat_stmt = select(Chat).where(Chat.id == data.chat_id) # type: ignore[arg-type]
chat_result = await db.execute(chat_stmt)
existing_chat = chat_result.scalar_one_or_none()
if existing_chat:
# Update the chat with workspace details
if workspace_id_to_use is not None:
existing_chat.workspace_id = workspace_id_to_use
if resolved_workspace_slug is not None:
existing_chat.workspace_slug = resolved_workspace_slug
# Always update workspace_in_context as it's a required field in ChatRequest
existing_chat.workspace_in_context = data.workspace_in_context
await db.commit()
else:
log.warning(f"Chat {data.chat_id} not found for backfill")
except Exception as e:
log.error(f"Error backfilling chat workspace details: {e}")
# Don't fail the request if backfill fails
return {"stream_token": str(token_id)}
@router.get("/stream-answer/{token}")
async def stream_answer(token: UUID4, db: AsyncSession = Depends(get_async_session), session: str = Depends(cookie_schema)):
"""Second phase of two-step flow.
Looks up the queued ChatRequest by token (message_id), deletes the queue
entry, and then re-uses the existing /get-answer/ logic to start the SSE
stream via a pure GET endpoint."""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception:
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
# Locate the queued flow step
stmt = (
select(MessageFlowStep)
.where(MessageFlowStep.message_id == token) # type: ignore[arg-type]
.where(MessageFlowStep.tool_name == "QUEUE") # type: ignore[arg-type]
)
res = await db.execute(stmt)
flow_step: MessageFlowStep | None = res.scalar_one_or_none()
if not flow_step:
return JSONResponse(status_code=404, content={"detail": "Unknown or expired stream token"})
# Parse the stored ChatRequest
try:
raw_data = flow_step.execution_data or {}
# Check if this is an OAuth message (has oauth_required field)
if flow_step.oauth_required:
# This is an OAuth message - check if OAuth is now complete
# by looking at the oauth_completed column
if flow_step.oauth_completed:
# OAuth is now complete! Process the request normally
log.info(f"OAuth completed for message {token}. Processing request.")
# Control continues to the normal processing code below
else:
# OAuth is still required
return JSONResponse(
status_code=401,
content={
"detail": "OAuth authorization still required. Please complete OAuth authentication first.",
"error_code": "OAUTH_REQUIRED",
},
)
# Convert empty-string UUIDs to None so pydantic validation passes
for field in ["project_id", "workspace_id", "chat_id"]:
if field in raw_data and raw_data[field] == "":
raw_data[field] = None
raw_data["user_id"] = user_id
queued_request = ChatRequest.parse_obj(raw_data)
except Exception as e:
log.error(f"Malformed execution_data for token {token}: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Corrupted queued request"})
# Consume the queue entry so the token is single-use
await db.delete(flow_step)
await db.commit()
# Pass the token/message_id forward so downstream processing reuses the same Message row
try:
queued_request.context["token_id"] = str(token)
except Exception as e:
log.warning(f"Failed to attach token_id to queued request context: {e!s}")
# Delegate to existing get_answer for streaming
return await get_answer(data=queued_request, session=session)
@router.post("/execute-action/")
async def execute_action(request: ActionBatchExecutionRequest, db: AsyncSession = Depends(get_async_session), session: str = Depends(cookie_schema)):
"""Execute all planned actions in a message as a batch using LLM orchestration."""
# EXECUTION STATUS TRACKING:
# The system tracks whether planned actions were executed by users:
# 1. When actions are planned, they are marked with is_executed=False in MessageFlowStep
# 2. When execute-action is called, actions are marked with is_executed=True
# 3. Assistant messages are updated with execution results and entity information
# 4. Conversation history includes explicit text about executed vs. not executed actions
# This ensures complete context for follow-up questions and LLM understanding.
try:
# Validate session and get user
user_id = await validate_session_and_get_user(session)
if not user_id:
return JSONResponse(status_code=401, content={"detail": BATCH_EXECUTION_ERRORS["INVALID_SESSION"]})
# Validate and prepare execution data
execution_data = await prepare_execution_data(request, user_id, db)
if not execution_data:
# Determine the specific error based on what failed
if not await get_planned_actions_for_execution(request.message_id, request.chat_id, db):
return JSONResponse(status_code=404, content={"detail": BATCH_EXECUTION_ERRORS["NO_PLANNED_ACTIONS"]})
elif not await get_original_user_query(request.message_id, db):
return JSONResponse(status_code=404, content={"detail": BATCH_EXECUTION_ERRORS["NO_ORIGINAL_QUERY"]})
else:
# OAuth or workspace issue
return JSONResponse(
status_code=401,
content={
"detail": BATCH_EXECUTION_ERRORS["OAUTH_REQUIRED"],
"error_code": "OAUTH_REQUIRED",
"workspace_id": str(request.workspace_id),
"user_id": str(user_id),
},
)
# Execute batch actions
context = await execute_batch_actions(execution_data, db)
# Update the assistant message with execution results
await update_assistant_message_with_execution_results(request.message_id, request.chat_id, context, db)
# Return appropriate response based on execution status
return JSONResponse(content=format_execution_response(context))
except Exception as e:
log.error(f"Error in execute_action: {str(e)}")
return JSONResponse(status_code=500, content={"detail": BATCH_EXECUTION_ERRORS["INTERNAL_ERROR"]})
@router.get("/search/", response_model=ChatSearchResponse)
async def search_chats(
query: str = Query(..., description="Search query text"),
workspace_id: UUID4 = Query(..., description="Workspace ID to filter by"),
is_project_chat: Optional[bool] = Query(False, description="Filter by project chat flag"),
cursor: Optional[str] = Query(None, description="Cursor for pagination"),
session: str = Depends(cookie_schema),
db: AsyncSession = Depends(get_async_session),
):
"""Search chats by title and message content with cursor-based pagination."""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
# Validate query parameters
if not query or not query.strip():
return JSONResponse(status_code=400, content={"detail": "Search query cannot be empty"})
try:
# Initialize search service
search_service = ChatSearchService()
try:
results, pagination = await search_service.search_chats(
query=query.strip(),
user_id=user_id,
workspace_id=workspace_id,
is_project_chat=is_project_chat,
cursor=cursor,
per_page=30, # Hardcoded for performance
)
# Prepare response - use model_dump with mode='json' to handle UUID serialization
response_data = pagination.model_dump(mode="json")
response_data["results"] = [result.model_dump(mode="json") for result in results]
return JSONResponse(content=response_data)
finally:
# Ensure search service is properly closed
await search_service.close()
except Exception as e:
log.error(f"Error searching chats: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@@ -0,0 +1,194 @@
"""
Chat CTAs (Call-to-Action) endpoints for post-chat actions.
Handles actions like saving answers as pages.
"""
from typing import Any
from typing import Optional
from uuid import UUID
from fastapi import APIRouter
from fastapi import Depends
from fastapi import status
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from pydantic import Field
from sqlmodel.ext.asyncio.session import AsyncSession
from pi import logger
from pi import settings
from pi.app.api.v1.dependencies import cookie_schema
from pi.app.api.v1.dependencies import is_valid_session
from pi.core.db.plane import PlaneDBPool
from pi.core.db.plane_pi.lifecycle import get_async_session
from pi.services.actions.method_executor import MethodExecutor
from pi.services.actions.oauth_service import PlaneOAuthService
from pi.services.actions.plane_actions_executor import PlaneActionsExecutor
log = logger.getChild("v1/chat_ctas")
router = APIRouter()
class SaveAsPageRequest(BaseModel):
name: str = Field(..., description="Page title/name")
description_html: str = Field(..., description="Page content in HTML format")
workspace_slug: str = Field(..., description="Workspace slug where the page will be created")
page_type: str = Field(..., description="Type of page: 'workspace' for wiki pages or 'project' for project pages")
project_id: Optional[UUID] = Field(None, description="Project ID (required if page_type is 'project')")
access: Optional[int] = Field(None, description="Access level - 0 for public, 1 for private")
color: Optional[str] = Field(None, description="Page color in hex format")
logo_props: Optional[dict] = Field(None, description="Logo properties dict")
class SaveAsPageResponse(BaseModel):
success: bool
message: str
page_id: Optional[str] = None
page_url: Optional[str] = None
data: Optional[dict] = None
@router.post("/save-as-page/", response_model=SaveAsPageResponse, status_code=status.HTTP_201_CREATED)
async def save_as_page(
data: SaveAsPageRequest,
session: str = Depends(cookie_schema),
db: AsyncSession = Depends(get_async_session),
):
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(
status_code=401, content={"success": False, "message": "Invalid User", "page_id": None, "page_url": None, "data": None}
)
user_id = auth.user.id
# Validate page_type and project_id combination
if data.page_type not in ["workspace", "project"]:
return JSONResponse(
status_code=400,
content={
"success": False,
"message": "page_type must be either 'workspace' or 'project'",
"page_id": None,
"page_url": None,
"data": None,
},
)
if data.page_type == "project" and not data.project_id:
return JSONResponse(
status_code=400,
content={
"success": False,
"message": "project_id is required when page_type is 'project'",
"page_id": None,
"page_url": None,
"data": None,
},
)
# Get workspace_id from workspace_slug
try:
# Query to get workspace_id from slug
query = "SELECT id FROM workspaces WHERE slug = $1 AND deleted_at IS NULL"
workspace_result = await PlaneDBPool.fetch(query, (data.workspace_slug,))
if not workspace_result:
return JSONResponse(
status_code=400,
content={
"success": False,
"message": f"Workspace '{data.workspace_slug}' not found",
"page_id": None,
"page_url": None,
"data": None,
},
)
workspace_id = UUID(str(workspace_result[0]["id"]))
except Exception as e:
log.error(f"Error resolving workspace_id for slug '{data.workspace_slug}': {e}")
return JSONResponse(
status_code=500,
content={"success": False, "message": f"Error resolving workspace: {str(e)}", "page_id": None, "page_url": None, "data": None},
)
# Get OAuth token
oauth_service = PlaneOAuthService()
oauth_token = await oauth_service.get_valid_token(db, user_id, workspace_id)
if not oauth_token:
return JSONResponse(
status_code=401,
content={
"success": False,
"message": "No valid OAuth token found. Please complete OAuth authentication for this workspace first.",
"page_id": None,
"page_url": None,
"data": None,
},
)
# Initialize executor
plane_executor = PlaneActionsExecutor(access_token=oauth_token, base_url=settings.plane_api.HOST)
method_executor = MethodExecutor(plane_executor)
# Create page based on explicit page_type
page_result: dict[str, Any]
if data.page_type == "project":
page_result = await method_executor.execute(
"pages",
"create_project_page",
project_id=str(data.project_id),
workspace_slug=data.workspace_slug,
name=data.name,
description_html=data.description_html,
access=data.access,
color=data.color,
logo_props=data.logo_props,
)
else: # workspace
page_result = await method_executor.execute(
"pages",
"create_workspace_page",
workspace_slug=data.workspace_slug,
name=data.name,
description_html=data.description_html,
access=data.access,
color=data.color,
logo_props=data.logo_props,
)
if page_result and page_result.get("success"):
page_data = page_result.get("data", {})
page_id = page_data.get("id")
# Construct page URL based on page_type
if data.page_type == "project":
page_url = f"{settings.plane_api.FRONTEND_URL}/{data.workspace_slug}/projects/{data.project_id}/pages/{page_id}"
else: # workspace
page_url = f"{settings.plane_api.FRONTEND_URL}/{data.workspace_slug}/wiki/{page_id}"
return SaveAsPageResponse(
success=True, message=f"Successfully saved as {data.page_type} page", page_id=page_id, page_url=page_url, data=page_data
)
else:
error_msg = page_result.get("error", "Unknown error occurred") if page_result else "No result returned"
return JSONResponse(
status_code=500,
content={
"success": False,
"message": f"Failed to create {data.page_type} page: {error_msg}",
"page_id": None,
"page_url": None,
"data": None,
},
)
except Exception as e:
log.error(f"Error saving as page: {str(e)}")
return JSONResponse(
status_code=500,
content={"success": False, "message": f"Internal server error: {str(e)}", "page_id": None, "page_url": None, "data": None},
)
+164
View File
@@ -0,0 +1,164 @@
import hashlib
import hmac
import logging
from typing import Dict
from typing import List
from typing import Union
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Request
from sqlmodel.ext.asyncio.session import AsyncSession
from pi import logger
from pi import settings
from pi.app.api.v1.helpers.github_api import get_net_file_changes
from pi.core.db.plane_pi.lifecycle import get_async_session
from pi.core.vectordb import VectorStore
from pi.services.retrievers.pg_store import create_webhook_record
from pi.services.retrievers.pg_store import get_last_processed_commit
from pi.services.retrievers.pg_store import get_webhook_by_commit
from pi.services.retrievers.pg_store import update_webhook_record
from pi.vectorizer.docs.initial_feed import get_all_file_paths
from pi.vectorizer.docs.initial_feed import process_file
router = APIRouter()
log = logger.getChild(__name__)
log.setLevel(logging.INFO)
def verify_signature(payload_body: bytes, secret_token: str, signature_header: str) -> bool:
"""Verify the webhook signature using HMAC-SHA256."""
if not signature_header:
return False
hash_object = hmac.new(secret_token.encode("utf-8"), msg=payload_body, digestmod=hashlib.sha256)
expected_signature = "sha256=" + hash_object.hexdigest()
return hmac.compare_digest(expected_signature, signature_header)
@router.post("/webhooks/")
async def handle_docs_webhook(request: Request, db: AsyncSession = Depends(get_async_session)):
if not settings.vector_db.DOCS_WEBHOOK_SECRET:
raise HTTPException(status_code=500, detail="Webhook secret not configured")
signature = request.headers.get("X-Hub-Signature-256")
payload_body = await request.body()
if not signature:
raise HTTPException(status_code=401, detail="No signature received")
if not verify_signature(payload_body, settings.vector_db.DOCS_WEBHOOK_SECRET, signature):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = await request.json()
current_commit_id = payload.get("current_commit_id")
repo_name = payload.get("repo_name")
branch_name = payload.get("branch_name")
if not current_commit_id or not repo_name:
raise HTTPException(status_code=400, detail="Missing current_commit_id or repo_name in payload")
log.info(f"Processing webhook for repository: {repo_name}, commit: {current_commit_id}")
existing_webhook = await get_webhook_by_commit(db, current_commit_id, repo_name, branch_name)
if existing_webhook and existing_webhook.processed:
log.info(f"Commit {current_commit_id} for {repo_name} already processed successfully")
return {
"status": "already_processed",
}
webhook_record = existing_webhook or await create_webhook_record(db, current_commit_id, repo_name, branch_name)
try:
last_processed_commit = await get_last_processed_commit(db, repo_name, branch_name)
file_changes: Dict[str, Union[List[str], str]]
if not last_processed_commit:
log.info(f"No previous processed commits found for {repo_name}. Starting initial feeding - processing all files as 'added'.")
all_files = get_all_file_paths(repo_name)
if isinstance(all_files, str):
await update_webhook_record(db, webhook_record, processed=False, error_message=all_files)
return {
"status": "Webhook processing failed",
}
file_changes = {"added": all_files, "modified": [], "removed": []}
else:
base_commit = last_processed_commit
file_changes = get_net_file_changes(repo_name, base_commit, current_commit_id)
added_files = file_changes["added"]
modified_files = file_changes["modified"]
removed_files = file_changes["removed"]
if not isinstance(added_files, list):
added_files = []
if not isinstance(modified_files, list):
modified_files = []
if not isinstance(removed_files, list):
removed_files = []
total_files_to_process = len(added_files) + len(modified_files) + len(removed_files)
if total_files_to_process == 0:
if file_changes.get("error"):
error_msg = file_changes["error"]
# Ensure error_msg is a string
error_message = str(error_msg) if not isinstance(error_msg, str) else error_msg
await update_webhook_record(db, webhook_record, processed=False, error_message=error_message)
return {
"status": "Webhook processing failed",
}
log.info(f"Files to process - Added: {len(added_files)}, Modified: {len(modified_files)}, Removed: {len(removed_files)}")
async with VectorStore() as vector_db:
success_count = 0
failed_files = []
docs_to_index = []
for file_path in added_files + modified_files:
try:
doc = process_file(repo_name, file_path)
if doc["content"].strip():
docs_to_index.append(doc)
except Exception as e:
log.error(f"Error processing file {file_path}: {e}")
failed_files.append(file_path)
if docs_to_index:
try:
success, failures = await vector_db.async_feed(index_name=settings.vector_db.DOCS_INDEX, docs=docs_to_index)
success_count += success
failed_files.extend([f.get("id", "unknown") for f in failures])
log.info(f"Successfully indexed {success} documents")
except Exception as e:
log.error(f"Error during bulk indexing: {e}")
failed_files.extend([d["id"] for d in docs_to_index])
if last_processed_commit:
for file_path in removed_files:
try:
unique_id = file_path.replace("/", "_").replace("-", "_").replace(".mdx", "").replace(".txt", "")
resp = await vector_db.async_delete_document(index_name=settings.vector_db.DOCS_INDEX, document_id=unique_id)
if resp.get("result") == "deleted":
success_count += 1
except Exception as e:
log.error(f"Error deleting file {file_path}: {e}")
failed_files.append(file_path)
processed_bool = len(failed_files) == 0
await update_webhook_record(
db,
webhook_record,
processed=processed_bool,
files_processed=success_count,
error_message=None if processed_bool else f"Failed files: {", ".join(failed_files)}",
)
return {
"status": "Webhook processed",
}
except Exception as e:
error_message = str(e)
await update_webhook_record(db, webhook_record, processed=False, error_message=error_message)
log.error(f"Error processing webhook for {repo_name}: {e}")
raise HTTPException(status_code=500, detail=f"Error processing webhook: {error_message}")
+48
View File
@@ -0,0 +1,48 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi.responses import JSONResponse
from pi import logger
from pi.app.api.v1.dependencies import cookie_schema
from pi.app.api.v1.dependencies import is_valid_session
from pi.app.schemas.dupes import DupeSearchRequest
from pi.app.schemas.dupes import NotDuplicateRequest
from pi.services.dupes import dupes
from pi.services.dupes.dupes import DuplicateNotFoundError
from pi.services.dupes.dupes import NotDuplicateUpdateError
from pi.services.dupes.dupes import VectorSearchError
router = APIRouter()
log = logger.getChild("v1/dupes")
@router.post("/issues/")
async def get_duplicate_issues(data: DupeSearchRequest, session: str = Depends(cookie_schema)):
try:
user = await is_valid_session(session)
if not user:
return JSONResponse(status_code=401, content={"detail": "Invalid session"})
result = await dupes.get_dupes(data)
return JSONResponse(content=result)
except DuplicateNotFoundError as e:
return JSONResponse(status_code=e.status_code, content={"detail": e.detail})
except VectorSearchError as e:
log.error(f"Exception in duplicate issues endpoint: {e!s}", exc_info=True)
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@router.post("/issues/feedback/")
async def set_not_duplicate_issues(data: NotDuplicateRequest, session: str = Depends(cookie_schema)):
try:
user = await is_valid_session(session)
if not user:
return JSONResponse(status_code=401, content={"detail": "Invalid session"})
result = await dupes.set_not_duplicate_issues(data)
return JSONResponse(content=result)
except NotDuplicateUpdateError as e:
return JSONResponse(status_code=e.status_code, content={"detail": e.detail})
except Exception as e:
log.error(f"Exception in not duplicate update endpoint: {e!s}", exc_info=True)
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@@ -0,0 +1,3 @@
from .vectorize import router as vectorize_router
__all__ = ["vectorize_router"]
@@ -0,0 +1,94 @@
from uuid import UUID
from fastapi import APIRouter
from fastapi import Depends
from fastapi import status
from fastapi.responses import JSONResponse
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from pi import logger
from pi.app.api.v1.dependencies import verify_internal_secret_key
from pi.app.models.llm import LlmModel
from pi.core.db.plane_pi.lifecycle import get_async_session
# Create logger for this module
log = logger.getChild(__name__)
# Create router with dependencies applied to all endpoints
router = APIRouter(
dependencies=[Depends(verify_internal_secret_key)],
include_in_schema=False,
)
@router.post("/llm/activate/", include_in_schema=False)
async def activate_llm(llm_id: UUID, db: AsyncSession = Depends(get_async_session)):
"""
Activate an LLM by setting is_active to True.
Requires internal API key for authentication.
"""
try:
statement = select(LlmModel).where(LlmModel.id == llm_id)
execution = await db.exec(statement)
llm = execution.first()
if not llm:
log.error(f"LLM with ID {llm_id} not found")
return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"detail": "LLM not found"})
if llm.is_active:
log.info(f"LLM {llm.name} is already active")
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={"detail": "LLM is already active"})
llm.is_active = True
db.add(llm)
await db.commit()
await db.refresh(llm)
log.info(f"LLM {llm.name} activated successfully")
return JSONResponse(
content={
"detail": f"{llm.name} activated successfully",
"llm": {"id": str(llm.id), "name": llm.name, "is_active": llm.is_active, "model_key": llm.model_key, "provider": llm.provider},
}
)
except Exception as e:
log.error(f"Error activating LLM {llm_id}: {e!s}")
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": "Internal Server Error"})
@router.post("/llm/deactivate/", include_in_schema=False)
async def deactivate_llm(llm_id: UUID, db: AsyncSession = Depends(get_async_session)):
"""
Deactivate an LLM by setting is_active to False.
Requires internal API key for authentication.
"""
try:
statement = select(LlmModel).where(LlmModel.id == llm_id)
execution = await db.exec(statement)
llm = execution.first()
if not llm:
log.error(f"LLM with ID {llm_id} not found")
return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"detail": "LLM not found"})
if not llm.is_active:
log.info(f"LLM {llm.name} is already inactive")
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={"detail": "LLM is already inactive"})
llm.is_active = False
db.add(llm)
await db.commit()
await db.refresh(llm)
log.info(f"LLM {llm.name} deactivated successfully")
return JSONResponse(
content={
"detail": f"{llm.name} deactivated successfully",
"llm": {"id": str(llm.id), "name": llm.name, "is_active": llm.is_active, "model_key": llm.model_key, "provider": llm.provider},
}
)
except Exception as e:
log.error(f"Error deactivating LLM {llm_id}: {e!s}")
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": "Internal Server Error"})
@@ -0,0 +1,470 @@
import uuid
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter
from fastapi import Depends
from fastapi import status
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlalchemy import desc
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from pi import logger
from pi.app.api.v1.dependencies import verify_internal_secret_key
from pi.app.models.workspace_vectorization import VectorizationStatus
from pi.app.models.workspace_vectorization import WorkspaceVectorization
from pi.celery_app import celery_app
from pi.config import Settings
from pi.core.db.plane_pi.lifecycle import get_async_session
from pi.core.vectordb import VectorStore
from pi.vectorizer.docs import process_repo_contents
from pi.vectorizer.vectorize import _batched_predict
log = logger.getChild(__name__)
router = APIRouter(
dependencies=[Depends(verify_internal_secret_key)],
include_in_schema=False,
)
class VectorizeRequest(BaseModel):
workspace_ids: list[str]
feed_issues: bool = True
feed_pages: bool = True
feed_slices: int = 4
batch_size: int = 32
class VectorizeQueryRequest(BaseModel):
query: str
retrieved_tables: list[str]
class JobStatusUpdate(BaseModel):
status: VectorizationStatus
progress_pct: int | None = None
last_error: str | None = None
class JobConfig(BaseModel):
workspace_id: str
job_id: str
feed_issues: bool = True
feed_pages: bool = True
feed_slices: int = 4
batch_size: int = 32
class VectorizeDocsRequest(BaseModel):
repo_names: list[str] | None = None # If None, uses settings.DOCS_REPO_NAME
class VectorizeDocsResponse(BaseModel):
total_ok: int
total_failed: int
results: list[dict[str, int | str]] # List of {repo: str, ok: int, failed: int}
class ChatSearchIndexRequest(BaseModel):
workspace_id: str | None = None # If None, processes all workspaces
batch_size: int = 100
class ChatSearchIndexResponse(BaseModel):
total_chats: int
total_messages: int
processed_chats: int
processed_messages: int
failed_chats: int
failed_messages: int
duration_seconds: float
@router.post("/vectorize/workspaces/", include_in_schema=False)
async def trigger_vectorization(
body: VectorizeRequest,
db: AsyncSession = Depends(get_async_session),
):
"""
Trigger vectorization for multiple workspaces.
Creates database records and queues Celery tasks for each workspace.
"""
accepted, skipped = [], []
for ws in body.workspace_ids:
# Check if workspace already has a running/queued job
stmt = select(WorkspaceVectorization).where(
WorkspaceVectorization.workspace_id == ws,
(WorkspaceVectorization.status == VectorizationStatus.queued) | (WorkspaceVectorization.status == VectorizationStatus.running),
)
existing_job = (await db.exec(stmt)).first()
if existing_job:
skipped.append(ws)
continue
# Create new job record
job = WorkspaceVectorization(
workspace_id=ws,
status=VectorizationStatus.queued,
feed_issues=body.feed_issues,
feed_pages=body.feed_pages,
feed_slices=body.feed_slices,
batch_size=body.batch_size,
)
db.add(job)
await db.commit()
await db.refresh(job)
# Queue Celery task with job config
job_config = JobConfig(
workspace_id=ws,
job_id=str(job.id),
feed_issues=body.feed_issues,
feed_pages=body.feed_pages,
feed_slices=body.feed_slices,
batch_size=body.batch_size,
)
celery_app.send_task("pi.celery_app.vectorize_workspace", args=[job_config.model_dump()])
accepted.append(ws)
return JSONResponse(
status_code=status.HTTP_202_ACCEPTED,
content={"accepted": accepted, "skipped": skipped},
)
@router.post("/vectorize/status/{job_id}/", include_in_schema=False)
async def update_job_status(
job_id: str,
status_update: JobStatusUpdate,
db: AsyncSession = Depends(get_async_session),
):
"""
Update the status of a vectorization job.
Note: This endpoint is primarily for external use or manual updates.
"""
try:
job_uuid = UUID(job_id)
stmt = select(WorkspaceVectorization).where(WorkspaceVectorization.id == job_uuid)
job = (await db.exec(stmt)).first()
if not job:
return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"detail": f"Job {job_id} not found"})
# Update job status
job.status = status_update.status
if status_update.progress_pct is not None:
job.progress_pct = status_update.progress_pct
if status_update.last_error is not None:
job.last_error = status_update.last_error
# Update timestamps based on status
now = datetime.utcnow()
if status_update.status == VectorizationStatus.running and not job.started_at:
job.started_at = now
elif status_update.status in [VectorizationStatus.success, VectorizationStatus.failed]:
job.finished_at = now
if status_update.status == VectorizationStatus.success:
job.progress_pct = 100
await db.commit()
return JSONResponse(status_code=status.HTTP_200_OK, content={"status": "updated", "job_id": job_id})
except ValueError:
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={"detail": "Invalid job ID format"})
except Exception as exc:
log.error("Failed to update job status for %s: %s", job_id, exc)
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": "Failed to update job status"})
@router.post("/vectorize/manual-status/{job_id}/", include_in_schema=False)
async def manual_update_job_status(
job_id: str,
status_update: JobStatusUpdate,
db: AsyncSession = Depends(get_async_session),
):
"""
Manually update the status of a vectorization job.
"""
try:
job_uuid = UUID(job_id)
stmt = select(WorkspaceVectorization).where(WorkspaceVectorization.id == job_uuid)
job = (await db.exec(stmt)).first()
if not job:
return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"detail": f"Job {job_id} not found"})
# Update job status
job.status = status_update.status
if status_update.progress_pct is not None:
job.progress_pct = status_update.progress_pct
if status_update.last_error is not None:
job.last_error = status_update.last_error
# Update timestamps based on status
now = datetime.utcnow()
if status_update.status == VectorizationStatus.running and not job.started_at:
job.started_at = now
elif status_update.status in [VectorizationStatus.success, VectorizationStatus.failed]:
job.finished_at = now
if status_update.status == VectorizationStatus.success:
job.progress_pct = 100
await db.commit()
return JSONResponse(status_code=status.HTTP_200_OK, content={"status": "updated", "job_id": job_id})
except ValueError:
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={"detail": "Invalid job ID format"})
except Exception as exc:
log.error("Failed to manually update job status for %s: %s", job_id, exc)
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": "Failed to update job status"})
@router.get("/vectorize/job/{job_id}/", include_in_schema=False)
async def get_job_details(
job_id: str,
db: AsyncSession = Depends(get_async_session),
):
"""
Get details of a vectorization job including progress.
Useful for monitoring and progress tracking.
"""
try:
job_uuid = UUID(job_id)
stmt = select(WorkspaceVectorization).where(WorkspaceVectorization.id == job_uuid)
job = (await db.exec(stmt)).first()
if not job:
return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"detail": f"Job {job_id} not found"})
status_value = job.status.value if hasattr(job.status, "value") else job.status
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
"job_id": str(job.id),
"workspace_id": job.workspace_id,
"status": status_value,
"progress_pct": job.progress_pct,
"feed_issues": job.feed_issues,
"feed_pages": job.feed_pages,
"feed_slices": job.feed_slices,
"batch_size": job.batch_size,
"live_sync_enabled": job.live_sync_enabled,
"created_at": job.created_at.isoformat() if job.created_at else None,
"started_at": job.started_at.isoformat() if job.started_at else None,
"finished_at": job.finished_at.isoformat() if job.finished_at else None,
"last_error": job.last_error,
},
)
except ValueError:
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={"detail": "Invalid job ID format"})
except Exception as exc:
log.error("Failed to get job details for %s: %s", job_id, exc)
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": "Failed to get job details"})
@router.get("/vectorize/workspace/{workspace_id}/progress/", include_in_schema=False)
async def get_workspace_progress(
workspace_id: str,
db: AsyncSession = Depends(get_async_session),
):
"""Return the most recent vectorization progress for a given workspace.
Looks up the latest WorkspaceVectorization record for the supplied ``workspace_id``
(ordered by the ``created_at`` timestamp) and returns its ``progress_pct`` along
with a few other helpful fields. If no job is found, a 404 response is returned.
"""
try:
stmt = (
select(WorkspaceVectorization)
.where(WorkspaceVectorization.workspace_id == workspace_id)
.order_by(desc(WorkspaceVectorization.created_at)) # type: ignore[arg-type]
)
job = (await db.exec(stmt)).first()
if not job:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"detail": f"No vectorization jobs found for workspace {workspace_id}"},
)
status_value = job.status.value if hasattr(job.status, "value") else job.status
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
"job_id": str(job.id),
"workspace_id": job.workspace_id,
"status": status_value,
"progress_pct": job.progress_pct,
"created_at": job.created_at.isoformat() if job.created_at else None,
"started_at": job.started_at.isoformat() if job.started_at else None,
"finished_at": job.finished_at.isoformat() if job.finished_at else None,
},
)
except Exception as exc:
log.error("Failed to get progress for workspace %s: %s", workspace_id, exc)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Failed to get workspace progress"},
)
@router.post("/vectorize/docs/", include_in_schema=False)
async def trigger_docs_vectorization(
body: VectorizeDocsRequest,
db: AsyncSession = Depends(get_async_session),
) -> JSONResponse:
"""
Trigger vectorization for documentation repositories.
Processes repository contents and feeds them to the docs index.
"""
settings = Settings()
try:
# Determine which repositories to process
if body.repo_names:
repos = [repo.strip() for repo in body.repo_names if repo.strip()]
else:
repos = [repo.strip() for repo in settings.vector_db.DOCS_REPO_NAME.split(",") if repo.strip()]
if not repos:
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={"detail": "No repositories specified"})
log.info("Processing %d documentation repositories: %s", len(repos), repos)
total_ok = 0
total_failed = 0
results = []
async with VectorStore() as vdb:
for i, repo in enumerate(repos, 1):
repo_result = {"repo": repo, "ok": 0, "failed": 0}
try:
log.info("Repo %d/%d: %s", i, len(repos), repo)
docs = process_repo_contents(repo) # read repo files
if docs:
ok, failed_docs = await vdb.async_feed(settings.vector_db.DOCS_INDEX, docs) # bulk-index docs
repo_result["ok"] = ok
repo_result["failed"] = len(failed_docs)
total_ok += ok
total_failed += len(failed_docs)
log.info("Repo %s: %d ok, %d failed", repo, ok, len(failed_docs))
else:
log.warning("No documents found in repository: %s", repo)
except Exception as exc:
log.error("Error processing repo %s: %s", repo, exc)
repo_result["failed"] = 1 # Mark the entire repo as failed
total_failed += 1
results.append(repo_result)
log.info("Docs vectorization complete — %d ok, %d failed", total_ok, total_failed)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={"total_ok": total_ok, "total_failed": total_failed, "results": results, "message": f"Processed {len(repos)} repositories"},
)
except Exception as exc:
log.error("Error in docs vectorization: %s", exc)
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": f"Failed to vectorize docs: {str(exc)}"})
@router.post("/vectorize/query/", include_in_schema=False)
async def vectorize_and_cache_query(
body: VectorizeQueryRequest,
db: AsyncSession = Depends(get_async_session),
):
"""
Create a vector embedding for the query and push it to cache_index.
"""
settings = Settings()
async with VectorStore() as vdb:
# Generate embedding for the query
vectors = await _batched_predict(vdb, [body.query])
if not vectors or not vectors[0]:
return JSONResponse(status_code=500, content={"detail": "Failed to generate embedding for query"})
query_vector = vectors[0]
doc = {
"id": str(uuid.uuid4()),
"query": body.query,
"retrieved_tables": body.retrieved_tables,
"query_vector": query_vector,
}
_, failed_docs = await vdb.async_feed(settings.vector_db.CACHE_INDEX, [doc])
if failed_docs:
return JSONResponse(status_code=500, content={"detail": "Failed to cache query"})
return JSONResponse(status_code=200, content={"detail": "Query cached successfully"})
@router.post("/vectorize/chat-search-index/", include_in_schema=False)
async def trigger_chat_search_index_population(
body: ChatSearchIndexRequest,
) -> JSONResponse:
"""
Trigger chat search index population as a Celery background task.
This endpoint queues a background job to populate the search index with existing
chats and messages. The job runs asynchronously to avoid HTTP timeouts.
"""
try:
# Queue Celery task
task = celery_app.send_task("pi.celery_app.populate_chat_search_index", args=[body.model_dump()])
log.info("Queued chat search index population task: %s", task.id)
return JSONResponse(
status_code=status.HTTP_202_ACCEPTED,
content={
"task_id": task.id,
"status": "queued",
"message": "Chat search index population started",
"workspace_id": body.workspace_id,
"batch_size": body.batch_size,
},
)
except Exception as exc:
log.error("Failed to queue chat search index population: %s", exc)
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": f"Failed to queue task: {str(exc)}"})
@router.get("/vectorize/chat-search-index/{task_id}/", include_in_schema=False)
async def get_chat_search_index_task_status(task_id: str) -> JSONResponse:
"""
Get the status and progress of a chat search index population task.
"""
try:
from celery.result import AsyncResult
task_result = AsyncResult(task_id, app=celery_app)
if task_result.state == "PENDING":
response = {"task_id": task_id, "status": "pending", "message": "Task is waiting to be processed"}
elif task_result.state == "PROGRESS":
response = {"task_id": task_id, "status": "running", "progress": task_result.info, "message": "Task is running"}
elif task_result.state == "SUCCESS":
response = {"task_id": task_id, "status": "completed", "result": task_result.info, "message": "Task completed successfully"}
elif task_result.state == "FAILURE":
response = {"task_id": task_id, "status": "failed", "error": str(task_result.info), "message": "Task failed"}
else:
response = {"task_id": task_id, "status": task_result.state.lower(), "message": f"Task state: {task_result.state}"}
return JSONResponse(status_code=status.HTTP_200_OK, content=response)
except Exception as exc:
log.error("Failed to get task status for %s: %s", task_id, exc)
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": f"Failed to get task status: {str(exc)}"})
@@ -0,0 +1,353 @@
import uuid
from fastapi import APIRouter
from fastapi import Depends
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from pi import logger
from pi.app.api.v1.dependencies import jwt_schema
from pi.app.api.v1.dependencies import validate_jwt_token
from pi.app.models.message_attachment import MessageAttachment
from pi.app.schemas.mobile.attachment import AttachmentCompleteRequestMobile
from pi.app.schemas.mobile.attachment import AttachmentDetailResponseMobile
from pi.app.schemas.mobile.attachment import AttachmentResponseMobile
from pi.app.schemas.mobile.attachment import AttachmentUploadRequestMobile
from pi.app.schemas.mobile.attachment import AttachmentUploadResponseMobile
from pi.app.schemas.mobile.attachment import S3UploadDataMobile
from pi.app.utils.attachments import allowed_attachment_types
from pi.app.utils.attachments import get_presigned_url_download
from pi.app.utils.attachments import get_presigned_url_preview
from pi.app.utils.attachments import get_s3_client
from pi.app.utils.attachments import sanitize_filename
from pi.config import settings
from pi.core.db.plane_pi.lifecycle import get_async_session
log = logger.getChild(__name__)
mobile_router = APIRouter()
# S3 Configuration
S3_BUCKET = settings.AWS_S3_BUCKET
@mobile_router.post("/upload-attachment/")
async def create_attachment_upload(
data: AttachmentUploadRequestMobile,
db: AsyncSession = Depends(get_async_session),
token: HTTPAuthorizationCredentials = Depends(jwt_schema),
):
"""Step 1: Create attachment record and generate S3 pre-signed upload URL"""
try:
auth = await validate_jwt_token(token)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
try:
# Validate file size (10MB for images, 50MB for PDFs)
max_size = settings.FILE_SIZE_LIMIT
if data.file_size > max_size:
return JSONResponse(status_code=413, content={"detail": "File size exceeds limit"})
# Validate file type
if data.content_type not in allowed_attachment_types:
return JSONResponse(status_code=415, content={"detail": "Unsupported file type"})
sanitized_filename = sanitize_filename(data.filename)
# Create attachment record
attachment = MessageAttachment(
original_filename=sanitized_filename,
content_type=data.content_type,
file_size=data.file_size,
file_type=MessageAttachment.get_file_type_from_mime(data.content_type),
status="pending",
file_path="", # Will be set after generating
workspace_id=data.workspace_id,
chat_id=data.chat_id,
message_id=None, # Will be set later when message is created
user_id=user_id,
)
db.add(attachment)
await db.commit()
await db.refresh(attachment)
# Generate S3 file path
file_path = attachment.generate_file_path(data.workspace_id, data.chat_id)
attachment.file_path = file_path
# Generate pre-signed POST data for S3 upload
s3_client = get_s3_client()
presigned_post = s3_client.generate_presigned_post(
Bucket=S3_BUCKET,
Key=file_path,
Fields={"Content-Type": data.content_type},
Conditions=[
{"Content-Type": data.content_type},
["content-length-range", 1, settings.FILE_SIZE_LIMIT],
],
ExpiresIn=600, # 5 minutes
)
await db.commit()
# Prepare response
attachment_response = AttachmentResponseMobile(
id=str(attachment.id),
filename=attachment.original_filename,
content_type=attachment.content_type,
file_size=attachment.file_size,
file_type=attachment.file_type,
status=attachment.status,
)
return JSONResponse(
content=AttachmentUploadResponseMobile(
upload_data=S3UploadDataMobile(
url=presigned_post["url"],
fields=presigned_post["fields"],
),
attachment_id=str(attachment.id),
attachment=attachment_response,
).model_dump()
)
except Exception as e:
log.error(f"Error creating attachment upload: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@mobile_router.patch("/complete-upload/")
async def complete_attachment_upload(
data: AttachmentCompleteRequestMobile,
db: AsyncSession = Depends(get_async_session),
token: HTTPAuthorizationCredentials = Depends(jwt_schema),
):
"""Step 3: Complete attachment upload and optionally link to message"""
try:
auth = await validate_jwt_token(token)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
try:
# Get attachment
stmt = select(MessageAttachment).where(
MessageAttachment.id == data.attachment_id,
MessageAttachment.chat_id == data.chat_id,
MessageAttachment.user_id == user_id,
)
result = await db.execute(stmt)
attachment = result.scalar_one_or_none()
if not attachment:
return JSONResponse(status_code=404, content={"detail": "Attachment not found"})
if attachment.status != "pending":
return JSONResponse(status_code=409, content={"detail": "Attachment already processed"})
# Verify file exists in S3 before marking as uploaded
try:
s3_client = get_s3_client()
s3_client.head_object(Bucket=S3_BUCKET, Key=attachment.file_path)
log.info(f"Verified S3 file exists: {attachment.file_path}")
except Exception as s3_error:
log.error(f"S3 file verification failed for {attachment.file_path}: {s3_error}")
return JSONResponse(status_code=400, content={"detail": "File not found in S3. Please upload the file first."})
# Update attachment status only after S3 verification
attachment.status = "uploaded"
download_url = get_presigned_url_download(attachment)
await db.commit()
await db.refresh(attachment)
# Prepare response
return JSONResponse(
content=AttachmentDetailResponseMobile(
id=str(attachment.id),
filename=attachment.original_filename,
content_type=attachment.content_type,
file_size=attachment.file_size,
file_type=attachment.file_type,
status=attachment.status,
attachment_url=download_url,
).model_dump()
)
except Exception as e:
log.error(f"Error completing attachment upload: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@mobile_router.get("/get-url/")
async def get_attachment_url(
attachment_id: str,
chat_id: str,
db: AsyncSession = Depends(get_async_session),
token: HTTPAuthorizationCredentials = Depends(jwt_schema),
):
"""Get pre-signed URLs (download & preview) for an attachment"""
try:
auth = await validate_jwt_token(token)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
try:
# Convert string IDs to UUIDs
attachment_uuid = uuid.UUID(attachment_id)
chat_uuid = uuid.UUID(chat_id)
# Get attachment owned by this user
stmt = select(MessageAttachment).where(
MessageAttachment.id == attachment_uuid,
MessageAttachment.chat_id == chat_uuid,
MessageAttachment.user_id == user_id,
MessageAttachment.status == "uploaded",
)
result = await db.execute(stmt)
attachment = result.scalar_one_or_none()
if not attachment:
return JSONResponse(status_code=404, content={"detail": "Attachment not found"})
# Verify file exists in S3
try:
s3_client = get_s3_client()
s3_client.head_object(Bucket=S3_BUCKET, Key=attachment.file_path)
except Exception as s3_error:
log.error(f"S3 file verification failed for {attachment.file_path}: {s3_error}")
return JSONResponse(status_code=404, content={"detail": "File not found in S3"})
# Use utility functions for URL generation
download_url = get_presigned_url_download(attachment)
preview_url = get_presigned_url_preview(attachment)
return JSONResponse(
content={
"download_url": download_url,
"preview_url": preview_url,
"filename": attachment.original_filename,
"content_type": attachment.content_type,
"file_size": attachment.file_size,
}
)
except ValueError as e:
log.error(f"Invalid UUID format: {e}")
return JSONResponse(status_code=400, content={"detail": "Invalid ID format"})
except Exception as e:
log.error(f"Error generating attachment URLs: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@mobile_router.get("/chat/")
async def get_attachments_by_chat(
chat_id: str,
db: AsyncSession = Depends(get_async_session),
token: HTTPAuthorizationCredentials = Depends(jwt_schema),
):
"""Get all attachment objects for a chat"""
try:
auth = await validate_jwt_token(token)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
try:
# Convert string ID to UUID
chat_uuid = uuid.UUID(chat_id)
# Get all attachments for this chat owned by this user
stmt = select(MessageAttachment).where(
MessageAttachment.chat_id == chat_uuid,
MessageAttachment.user_id == user_id,
MessageAttachment.status == "uploaded",
MessageAttachment.deleted_at.is_(None), # type: ignore[union-attr]
)
result = await db.execute(stmt)
attachments = result.scalars().all()
# Build response with attachment details and URLs
attachment_list = []
for attachment in attachments:
# Generate download and preview URLs
download_url = get_presigned_url_download(attachment)
attachment_data = {
"id": str(attachment.id),
"filename": attachment.original_filename,
"content_type": attachment.content_type,
"file_size": attachment.file_size,
"file_type": attachment.file_type,
"status": attachment.status,
"message_id": str(attachment.message_id) if attachment.message_id else None,
"attachment_url": download_url,
"created_at": attachment.created_at.isoformat() if attachment.created_at else None,
}
attachment_list.append(attachment_data)
return JSONResponse(content={"attachments": attachment_list})
except ValueError as e:
log.error(f"Invalid UUID format: {e}")
return JSONResponse(status_code=400, content={"detail": "Invalid chat ID format"})
except Exception as e:
log.error(f"Error retrieving attachments for chat: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
# @mobile_router.delete("/delete-attachment/")
# async def delete_attachment(
# data: AttachmentDeleteRequest,
# db: AsyncSession = Depends(get_async_session),
# token: HTTPAuthorizationCredentials = Depends(jwt_schema),
# ):
# """Delete an attachment (soft delete)"""
# try:
# auth = await validate_jwt_token(token)
# if not auth.user:
# return JSONResponse(status_code=401, content={"detail": "Invalid User"})
# user_id = auth.user.id
# except Exception as e:
# log.error(f"Error validating JWT: {e!s}")
# return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
# try:
# # Get attachment
# stmt = select(MessageAttachment).where(
# MessageAttachment.id == data.attachment_id,
# MessageAttachment.chat_id == data.chat_id,
# MessageAttachment.user_id == user_id,
# )
# result = await db.execute(stmt)
# attachment = result.scalar_one_or_none()
# if not attachment:
# return JSONResponse(status_code=404, content={"detail": "Attachment not found"})
# # Soft delete
# attachment.soft_delete()
# await db.commit()
# return JSONResponse(content={"detail": "Attachment deleted successfully"})
# except Exception as e:
# log.error(f"Error deleting attachment: {e!s}")
# return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@@ -0,0 +1,525 @@
# pi/app/api/v1/endpoints/mobile/chat.py
import uuid
from typing import Optional
from fastapi import APIRouter
from fastapi import Depends
from fastapi.responses import JSONResponse
from fastapi.responses import StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials
from pydantic import UUID4
from sqlmodel.ext.asyncio.session import AsyncSession
from pi import logger
from pi.app.api.v1.dependencies import jwt_schema
from pi.app.api.v1.dependencies import validate_jwt_token
from pi.app.schemas.mobile.chat import ChatFeedbackMobile
from pi.app.schemas.mobile.chat import ChatRequestMobile
from pi.app.schemas.mobile.chat import ChatSearchResponseMobile
from pi.app.schemas.mobile.chat import ChatStartResponseMobile
from pi.app.schemas.mobile.chat import ChatSuggestionMobile
from pi.app.schemas.mobile.chat import ChatSuggestionTemplateMobile
from pi.app.schemas.mobile.chat import DeleteChatRequestMobile
from pi.app.schemas.mobile.chat import FavoriteChatRequestMobile
from pi.app.schemas.mobile.chat import GetThreadsMobile
from pi.app.schemas.mobile.chat import ModelsResponseMobile
from pi.app.schemas.mobile.chat import RenameChatRequestMobile
from pi.app.schemas.mobile.chat import TitleRequestMobile
from pi.app.schemas.mobile.chat import UnfavoriteChatRequestMobile
from pi.app.utils.background_tasks import schedule_chat_deletion
from pi.app.utils.background_tasks import schedule_chat_rename
from pi.app.utils.background_tasks import schedule_chat_search_upsert
from pi.app.utils.exceptions import SQLGenerationError
from pi.core.db.plane_pi.lifecycle import get_async_session
from pi.core.db.plane_pi.lifecycle import get_streaming_db_session
from pi.services.chat.chat import PlaneChatBot
from pi.services.chat.search import ChatSearchService
from pi.services.chat.templates import tiles_factory
from pi.services.retrievers.pg_store import favorite_chat
from pi.services.retrievers.pg_store import get_active_models
from pi.services.retrievers.pg_store import get_chat_messages
from pi.services.retrievers.pg_store import get_favorite_chats
from pi.services.retrievers.pg_store import get_user_chat_threads
from pi.services.retrievers.pg_store import rename_chat_title
from pi.services.retrievers.pg_store import retrieve_chat_history
from pi.services.retrievers.pg_store import soft_delete_chat
from pi.services.retrievers.pg_store import unfavorite_chat
from pi.services.retrievers.pg_store import update_message_feedback
log = logger.getChild("v1/mobile/chat")
mobile_router = APIRouter()
@mobile_router.post("/get-answer/")
async def get_answer(
data: ChatRequestMobile,
token: HTTPAuthorizationCredentials = Depends(jwt_schema),
db: AsyncSession = Depends(get_async_session),
):
try:
await validate_jwt_token(token)
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
# Pre-validate required fields
if data.workspace_in_context and not (data.workspace_id or data.project_id):
# currently mobile not providing focus. so, set project_id as None
data.project_id = None
return JSONResponse(status_code=400, content={"detail": "Either project_id or workspace_id must be provided"})
# Constructing query_id to insert into index, can be removed after shiting to web flow (queue_answer, stream_answer)
query_id = uuid.uuid4()
data.context["token_id"] = str(query_id)
llm = data.llm
log.info(f"Processing mobile chat request for chat_id={data.chat_id}, llm={llm}")
chatbot = PlaneChatBot(llm=llm)
async def stream_response():
token_id = None
try:
async with get_streaming_db_session() as stream_db:
async for chunk in chatbot.process_query_stream(data, stream_db):
if chunk.startswith("πspecial reasoning blockπ: "):
# Format reasoning as proper SSE
reasoning_content = chunk.replace("πspecial reasoning blockπ: ", "")
yield f"event: reasoning\ndata: {reasoning_content}\n\n"
elif chunk.startswith("πspecial actions blockπ: "):
# Extract the actions data and send as separate actions event
actions_content = chunk.replace("πspecial actions blockπ: ", "")
try:
# Parse the JSON content to validate it
import json
actions_data = json.loads(actions_content)
yield f"event: actions\ndata: {json.dumps(actions_data)}\n\n"
except json.JSONDecodeError:
# If JSON parsing fails, fall back to delta event
log.warning(f"Failed to parse actions JSON: {actions_content}")
yield f"event: delta\ndata: {chunk}\n\n"
else:
yield f"event: delta\ndata: {chunk}\n\n"
# Extract token_id from data.context if available for background task
if hasattr(data, "context") and isinstance(data.context, dict):
token_id = data.context.get("token_id")
# Extract token_id from data.context if available for background task
if hasattr(data, "context") and isinstance(data.context, dict):
token_id = data.context.get("token_id")
except Exception as e:
log.error(f"Error processing chat request: {e!s}")
yield "event: error\ndata: An unexpected error occurred. Please try again"
finally:
# Schedule Celery task to upsert chat search index after streaming completes
if token_id:
schedule_chat_search_upsert(token_id)
try:
return StreamingResponse(stream_response(), media_type="text/event-stream")
except SQLGenerationError:
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
except Exception:
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@mobile_router.delete("/delete-chat/")
async def delete_chat(
data: DeleteChatRequestMobile,
db: AsyncSession = Depends(get_async_session),
token: HTTPAuthorizationCredentials = Depends(jwt_schema),
):
try:
await validate_jwt_token(token)
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
result = await soft_delete_chat(chat_id=data.chat_id, db=db)
# Schedule Celery task to mark chat as deleted in search index
schedule_chat_deletion(str(data.chat_id))
# The soft_delete_chat function always returns a tuple of (status_code, content)
status_code, _ = result
if status_code == 200:
return JSONResponse(status_code=200, content={"detail": True})
else:
return JSONResponse(status_code=status_code, content={"detail": False})
@mobile_router.post("/feedback/")
async def handle_feedback(
feedback_data: ChatFeedbackMobile,
db: AsyncSession = Depends(get_async_session),
token: HTTPAuthorizationCredentials = Depends(jwt_schema),
):
try:
# Get user_id from JWT
auth = await validate_jwt_token(token)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
result = await update_message_feedback(
chat_id=feedback_data.chat_id,
message_index=feedback_data.message_index,
feedback_value=feedback_data.feedback.value,
user_id=user_id,
db=db,
feedback_message=feedback_data.feedback_message,
)
# The update_message_feedback function always returns a tuple of (status_code, content)
status_code, _ = result
if status_code == 200:
return JSONResponse(status_code=200, content={"detail": True})
else:
return JSONResponse(status_code=status_code, content={"detail": False})
@mobile_router.post("/generate-title/")
async def get_title(
data: TitleRequestMobile, token: HTTPAuthorizationCredentials = Depends(jwt_schema), db: AsyncSession = Depends(get_async_session)
):
try:
await validate_jwt_token(token)
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
if not data.chat_id:
return JSONResponse(status_code=400, content={"detail": "chat_id is required"})
try:
# Get messages for this chat using the utility function
messages = await get_chat_messages(chat_id=data.chat_id, db=db)
# Check if messages is a tuple (error case)
if isinstance(messages, tuple) and len(messages) == 2:
status_code, content = messages
return JSONResponse(status_code=status_code, content=content)
chatbot = PlaneChatBot()
title = await chatbot.get_title(chat_id=data.chat_id, messages=messages, db=db)
return JSONResponse(content={"title": title})
except Exception as e:
log.error(f"Error generating title: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@mobile_router.post("/get-user-threads/")
async def get_user_threads(
data: GetThreadsMobile, token: HTTPAuthorizationCredentials = Depends(jwt_schema), db: AsyncSession = Depends(get_async_session)
):
try:
auth = await validate_jwt_token(token)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
results = await get_user_chat_threads(user_id=user_id, workspace_id=data.workspace_id, db=db, is_project_chat=data.is_project_chat)
# Check if results is a tuple (error case)
if isinstance(results, tuple) and len(results) == 2:
status_code, content = results
return JSONResponse(status_code=status_code, content=content)
# Success case
return JSONResponse(content={"results": results})
@mobile_router.post("/get-chat-history/")
async def get_chat_history(
data: TitleRequestMobile, token: HTTPAuthorizationCredentials = Depends(jwt_schema), db: AsyncSession = Depends(get_async_session)
):
try:
auth = await validate_jwt_token(token)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
try:
results = await retrieve_chat_history(chat_id=data.chat_id, db=db, user_id=user_id)
error_type = results.get("error")
if error_type == "not_found":
return JSONResponse(status_code=404, content={"detail": results["detail"]})
elif error_type == "unauthorized":
return JSONResponse(status_code=403, content={"detail": results["detail"]})
return JSONResponse(
content={
"results": {
"title": results["title"],
"dialogue": results["dialogue"],
"llm": results["llm"],
"feedback": results["feedback"],
"reasoning": results.get("reasoning", ""),
"is_focus_enabled": results.get("is_focus_enabled", False),
"focus_project_id": results.get("focus_project_id", None),
"focus_workspace_id": results.get("focus_workspace_id", None),
}
}
)
except Exception as e:
log.error(f"Error retrieving chat history: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@mobile_router.post("/get-chat-history-object/")
async def get_chat_history_object(
data: TitleRequestMobile, token: HTTPAuthorizationCredentials = Depends(jwt_schema), db: AsyncSession = Depends(get_async_session)
):
try:
auth = await validate_jwt_token(token)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
try:
chat_id = data.chat_id
log.info(f"Mobile chat history object request received for chat_id: {chat_id}")
results = await retrieve_chat_history(chat_id=chat_id, dialogue_object=True, db=db, user_id=user_id)
error_type = results.get("error")
if error_type == "not_found":
return JSONResponse(status_code=404, content={"detail": results["detail"]})
elif error_type == "unauthorized":
return JSONResponse(status_code=403, content={"detail": results["detail"]})
return JSONResponse(
content={
"results": {
"title": results["title"],
"dialogue": results["dialogue"],
"llm": results["llm"],
"feedback": results["feedback"],
"reasoning": results.get("reasoning", ""),
"is_focus_enabled": results.get("is_focus_enabled", False),
"focus_project_id": results.get("focus_project_id", None),
"focus_workspace_id": results.get("focus_workspace_id", None),
}
}
)
except Exception as e:
log.error(f"Error retrieving chat history object: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@mobile_router.get("/get-models/", response_model=ModelsResponseMobile)
async def get_model_list(
workspace_id: Optional[UUID4] = None,
workspace_slug: Optional[str] = None,
db: AsyncSession = Depends(get_async_session),
token: HTTPAuthorizationCredentials = Depends(jwt_schema),
):
try:
auth = await validate_jwt_token(token)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
# Convert user_id to string and provide default workspace_slug if None
models_list = await get_active_models(db=db, user_id=str(user_id), workspace_slug=workspace_slug or "")
# Check if models_list is a tuple (error case)
if isinstance(models_list, tuple) and len(models_list) == 2:
status_code, content = models_list
return JSONResponse(status_code=status_code, content=content)
# Success case
model_dict = {"models": models_list}
return JSONResponse(content=model_dict)
@mobile_router.get("/get-templates/", response_model=ChatSuggestionTemplateMobile)
async def get_chat_template_suggestion(
workspace_id: Optional[UUID4] = None, workspace_slug: Optional[str] = None, token: HTTPAuthorizationCredentials = Depends(jwt_schema)
):
try:
await validate_jwt_token(token)
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
try:
suggestions = tiles_factory()
return ChatSuggestionTemplateMobile(templates=suggestions)
except Exception as e:
log.error(f"Error getting templates: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@mobile_router.post("/get-placeholder/", response_model=ChatStartResponseMobile)
async def start_chat(data: ChatSuggestionMobile, token: HTTPAuthorizationCredentials = Depends(jwt_schema)):
try:
await validate_jwt_token(token)
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
try:
# Just return the template text as the placeholder - no database queries needed
placeholder = data.text or "How can I assist you with your work today?"
return ChatStartResponseMobile(placeholder=placeholder)
except Exception as e:
log.error(f"An unexpected error occurred: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@mobile_router.post("/favorite-chat/")
async def favorite_user_chat(
data: FavoriteChatRequestMobile, db: AsyncSession = Depends(get_async_session), token: HTTPAuthorizationCredentials = Depends(jwt_schema)
):
try:
await validate_jwt_token(token)
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
result = await favorite_chat(chat_id=data.chat_id, db=db)
status_code, _ = result
if status_code == 200:
return JSONResponse(status_code=200, content={"detail": True})
else:
return JSONResponse(status_code=status_code, content={"detail": False})
@mobile_router.post("/unfavorite-chat/")
async def unfavorite_user_chat(
data: UnfavoriteChatRequestMobile, db: AsyncSession = Depends(get_async_session), token: HTTPAuthorizationCredentials = Depends(jwt_schema)
):
try:
await validate_jwt_token(token)
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
result = await unfavorite_chat(chat_id=data.chat_id, db=db)
# The unfavorite_chat function always returns a tuple of (status_code, content)
status_code, _ = result
if status_code == 200:
return JSONResponse(status_code=200, content={"detail": True})
else:
return JSONResponse(status_code=status_code, content={"detail": False})
@mobile_router.get("/get-favorite-chats/")
async def get_user_favorite_chats(
workspace_id: Optional[UUID4] = None,
workspace_slug: Optional[str] = None,
db: AsyncSession = Depends(get_async_session),
token: HTTPAuthorizationCredentials = Depends(jwt_schema),
):
try:
# Get user_id from JWT
auth = await validate_jwt_token(token)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
result = await get_favorite_chats(user_id=user_id, db=db, workspace_id=workspace_id)
# The get_favorite_chats function always returns a tuple of (status_code, content)
status_code, content = result
return JSONResponse(status_code=status_code, content=content)
@mobile_router.post("/rename-chat/")
async def rename_chat(
data: RenameChatRequestMobile,
db: AsyncSession = Depends(get_async_session),
token: HTTPAuthorizationCredentials = Depends(jwt_schema),
):
try:
await validate_jwt_token(token)
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
result = await rename_chat_title(chat_id=data.chat_id, new_title=data.title, db=db)
# Schedule Celery task to update chat title in search index
schedule_chat_rename(str(data.chat_id), data.title)
status_code, _ = result
if status_code == 200:
return JSONResponse(status_code=200, content={"detail": True})
else:
return JSONResponse(status_code=status_code, content={"detail": False})
from fastapi import Query
@mobile_router.get("/search/", response_model=ChatSearchResponseMobile)
async def search_chats(
query: str = Query(..., description="Search query text"),
workspace_id: UUID4 = Query(..., description="Workspace ID to filter by"),
is_project_chat: Optional[bool] = Query(False, description="Filter by project chat flag"),
cursor: Optional[str] = Query(None, description="Cursor for pagination"),
token: HTTPAuthorizationCredentials = Depends(jwt_schema),
db: AsyncSession = Depends(get_async_session),
):
"""Search chats by title and message content with cursor-based pagination."""
try:
# Get user_id from JWT
auth = await validate_jwt_token(token)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
# Validate query parameters
if not query or not query.strip():
return JSONResponse(status_code=400, content={"detail": "Search query cannot be empty"})
try:
# Initialize search service
search_service = ChatSearchService()
try:
results, pagination = await search_service.search_chats(
query=query.strip(),
user_id=user_id,
workspace_id=workspace_id,
is_project_chat=is_project_chat,
cursor=cursor,
per_page=30, # Hardcoded for performance
)
# Prepare response - use model_dump with mode='json' to handle UUID serialization
response_data = pagination.model_dump(mode="json")
response_data["results"] = [result.model_dump(mode="json") for result in results]
return JSONResponse(content=response_data)
finally:
# Ensure search service is properly closed
await search_service.close()
except Exception as e:
log.error(f"Error searching chats: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
@@ -0,0 +1,51 @@
"""Simple transcription endpoint."""
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import UploadFile
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials
from pydantic import UUID4
from sqlmodel.ext.asyncio.session import AsyncSession
from pi import logger
from pi.app.api.v1.dependencies import jwt_schema
from pi.app.api.v1.dependencies import validate_jwt_token
from pi.core.db.plane_pi.lifecycle import get_async_session
from pi.services.transcription.transcribe import process_transcription
log = logger.getChild(__name__)
mobile_router = APIRouter()
@mobile_router.post("/transcribe")
async def transcribe_file(
workspace_id: UUID4,
chat_id: UUID4,
file: UploadFile,
db: AsyncSession = Depends(get_async_session),
token: HTTPAuthorizationCredentials = Depends(jwt_schema),
):
"""Transcribe audio file."""
try:
auth = await validate_jwt_token(token)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating JWT: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid JWT"})
try:
success, message = await process_transcription(workspace_id, chat_id, file, user_id, db)
if success:
return JSONResponse(status_code=200, content={"detail": message})
else:
return JSONResponse(status_code=500, content={"detail": message})
except HTTPException:
raise
except Exception as e:
log.error(f"Transcription failed: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
+491
View File
@@ -0,0 +1,491 @@
"""
OAuth endpoints for Plane app integration
Handles authorization flow, callback processing, and token management
"""
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter
from fastapi import Depends
from fastapi import Query
from fastapi.responses import JSONResponse
from fastapi.responses import RedirectResponse
from sqlmodel.ext.asyncio.session import AsyncSession
from pi import logger
from pi import settings
from pi.app.api.v1.dependencies import cookie_schema
from pi.app.api.v1.dependencies import is_valid_session
from pi.app.api.v1.helpers.plane_sql_queries import get_workspace_slug
from pi.app.schemas.oauth import OAuthRevokeRequest
from pi.app.schemas.oauth import OAuthRevokeResponse
from pi.app.schemas.oauth import OAuthStatusRequest
from pi.app.schemas.oauth import OAuthStatusResponse
from pi.core.db.plane_pi.lifecycle import get_async_session
from pi.services.actions.oauth_service import PlaneOAuthService
log = logger.getChild("v1/oauth")
router = APIRouter()
@router.get("/init/", tags=["oauth"])
async def browser_initiate_oauth(
workspace_id: UUID | None = Query(None),
db: AsyncSession = Depends(get_async_session),
session: str = Depends(cookie_schema),
):
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
oauth_service = PlaneOAuthService()
# Clean up expired states periodically
try:
await oauth_service.cleanup_expired_states(db)
except Exception as e:
log.warning(f"Failed to cleanup expired states: {e}")
# Get workspace slug if workspace_id is provided
workspace_slug = None
if workspace_id:
workspace_slug = await get_workspace_slug(str(workspace_id))
# same logic as POST /initiate/
auth_url, state = oauth_service.generate_authorization_url(
user_id=user_id,
workspace_id=workspace_id,
workspace_slug=workspace_slug,
)
await oauth_service.save_oauth_state(db, state, user_id, workspace_id)
# just redirect the browser
return RedirectResponse(url=auth_url, status_code=302)
@router.get("/public-init/", tags=["oauth"])
async def public_initiate_oauth(
user_id: UUID = Query(..., description="User ID requesting authorization"),
workspace_id: UUID = Query(..., description="Workspace ID to authorize"),
force_reset: bool = Query(False, description="Force reset of existing OAuth states"),
db: AsyncSession = Depends(get_async_session),
):
"""
Public OAuth initiation endpoint that doesn't require session authentication.
Used when session cookies can't be shared across domains.
"""
oauth_service = PlaneOAuthService()
# Clean up expired states periodically
try:
await oauth_service.cleanup_expired_states(db)
except Exception as e:
log.warning(f"Failed to cleanup expired states: {e}")
# If force_reset is requested, clear existing states for this user/workspace
if force_reset:
try:
reset_count = await oauth_service.reset_user_oauth_states(db, user_id, workspace_id)
log.info(f"Force reset: cleared {reset_count} OAuth states for user {user_id}, workspace {workspace_id}")
except Exception as e:
log.warning(f"Failed to reset OAuth states: {e}")
# Get workspace slug for the workspace
workspace_slug = None
if workspace_id:
workspace_slug = await get_workspace_slug(str(workspace_id))
# Generate authorization URL with state
auth_url, state = oauth_service.generate_authorization_url(
user_id=user_id,
workspace_id=workspace_id,
workspace_slug=workspace_slug,
)
# Save state for verification
await oauth_service.save_oauth_state(db, state, user_id, workspace_id)
log.debug(f"Generated new OAuth state for user {user_id}")
# Redirect to Plane for authorization
return RedirectResponse(url=auth_url, status_code=302)
@router.get("/callback/")
async def oauth_callback(
code: str = Query(..., description="Authorization code from Plane"),
state: str | None = Query(None, description="State parameter for verification - optional for marketplace install"),
app_installation_id: str = Query(None, description="App installation ID from Plane"),
db: AsyncSession = Depends(get_async_session),
):
"""
Handle OAuth callback from Plane after user authorization.
Exchanges authorization code for access tokens and stores them.
"""
try:
oauth_service = PlaneOAuthService()
log.debug(f"OAuth callback received - code: {code[:10]}..., state: {state}, app_installation_id: {app_installation_id}")
oauth_state = None
if state:
# Flow initiated via /oauth/initiate/ or /oauth/init/
oauth_state = await oauth_service.verify_state(db, state)
if not oauth_state:
log.error(f"Invalid or expired OAuth state: {state}")
# Clean up expired states to help with future requests
cleanup_count = await oauth_service.cleanup_expired_states(db)
log.info(f"Cleaned up {cleanup_count} expired states")
return JSONResponse(status_code=400, content={"detail": "Invalid or expired authorization state. Please try initiating OAuth again."})
else:
log.debug(f"OAuth state verified successfully: {state}")
else:
log.info("Processing OAuth callback without state parameter")
# Exchange code for tokens
token_data = await oauth_service.exchange_code_for_tokens(code=code, app_installation_id=app_installation_id)
# Get app installation details to fetch workspace info
installation_details = None
workspace_id = oauth_state.workspace_id if oauth_state else None
workspace_slug = "unknown"
if app_installation_id:
installation_details = await oauth_service.get_app_installation_details(
access_token=token_data["access_token"], app_installation_id=app_installation_id
)
if installation_details:
workspace_info = installation_details["workspace_detail"]
workspace_id = UUID(workspace_info["id"])
workspace_slug = workspace_info["slug"]
workspace_info["name"]
# Ensure we have a valid workspace_id
if not workspace_id:
log.error("No workspace_id available from state or installation details")
raise Exception("Unable to determine workspace for token storage")
# Determine user ID to associate with token
user_id_for_token = None
if oauth_state:
user_id_for_token = oauth_state.user_id
elif installation_details and installation_details.get("installed_by"):
# Use installer user id from Plane response
user_id_for_token = UUID(installation_details["installed_by"])
if not user_id_for_token:
log.error("Unable to determine user_id for token storage")
raise Exception("user_id required for token storage")
# Save tokens to database
await oauth_service.save_tokens(
db=db,
user_id=user_id_for_token,
workspace_id=workspace_id,
workspace_slug=workspace_slug,
token_data=token_data,
app_installation_id=app_installation_id,
app_bot_user_id=installation_details.get("app_bot") if installation_details else None,
)
# Mark state as used only after successful completion
if oauth_state:
await oauth_service.mark_state_as_used(db, oauth_state)
log.info(
"OAuth completed successfully for user %s, workspace %s",
str(user_id_for_token),
workspace_slug,
)
# Determine redirect URL based on stored context
if oauth_state and oauth_state.chat_id:
# Include message_token in redirect URL for frontend to use with stream-answer
message_token_param = ""
if oauth_state.message_token:
message_token_param = f"&message_token={oauth_state.message_token}"
# Update the MessageFlowStep to mark OAuth as complete
try:
from sqlalchemy import text
# Update the oauth_completed column to mark OAuth as complete
update_stmt = text("""
UPDATE message_flow_steps
SET oauth_completed = true, oauth_completed_at = :timestamp
WHERE message_id = :message_id AND tool_name = 'QUEUE'
""")
await db.execute(update_stmt, {"message_id": str(oauth_state.message_token), "timestamp": datetime.utcnow()})
await db.commit()
log.info(f"Updated MessageFlowStep {oauth_state.message_token} to mark OAuth as complete")
except Exception as e:
log.warning(f"Failed to update MessageFlowStep OAuth status: {e}")
# Don't fail the OAuth flow if this update fails
# Sidebar open redirect
if getattr(oauth_state, "pi_sidebar_open", False) and oauth_state.sidebar_open_url:
# Build sidebar redirect URL
sidebar_url = oauth_state.sidebar_open_url
chat_id = oauth_state.chat_id
redirect_url = (
f"{settings.plane_api.FRONTEND_URL}/{sidebar_url}/?chat_id={chat_id}{message_token_param}&pi_sidebar_open=true&oauth_success=true" # noqa: E501
)
# Project chat redirect
elif getattr(oauth_state, "is_project_chat", False):
redirect_url = f"{settings.plane_api.FRONTEND_URL}/{workspace_slug}/projects/pi-chat/{oauth_state.chat_id}/?oauth_success=true{message_token_param}" # noqa: E501
# Default chat redirect
else:
redirect_url = (
f"{settings.plane_api.FRONTEND_URL}/{workspace_slug}/pi-chat/{oauth_state.chat_id}/?oauth_success=true{message_token_param}"
)
else:
# Fallback to default success page
redirect_url = f"{settings.plane_api.FRONTEND_URL}?oauth_success=true&workspace={workspace_slug}"
return RedirectResponse(url=redirect_url, status_code=302)
except Exception as e:
log.error(f"Error processing OAuth callback: {e!s}")
# Redirect to frontend with error
frontend_url = settings.plane_api.FRONTEND_URL
redirect_url = f"{frontend_url}?oauth_error=true&message=authorization_failed"
return RedirectResponse(url=redirect_url, status_code=302)
@router.post("/status/", response_model=OAuthStatusResponse)
async def check_oauth_status(
data: OAuthStatusRequest,
db: AsyncSession = Depends(get_async_session),
session: str = Depends(cookie_schema),
):
"""
Check OAuth authorization status for a specific workspace.
Returns whether user has valid authorization and token details.
"""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
try:
oauth_service = PlaneOAuthService()
# Check if user has valid token for workspace
access_token = await oauth_service.get_valid_token(db=db, user_id=user_id, workspace_id=data.workspace_id)
if access_token:
# Get token details for expiry info
from sqlmodel import select
from pi.app.models.oauth import PlaneOAuthToken
result = await db.execute(
select(PlaneOAuthToken).where(
PlaneOAuthToken.user_id == user_id, PlaneOAuthToken.workspace_id == data.workspace_id, PlaneOAuthToken.is_active is True
)
)
oauth_token = result.scalar_one_or_none()
return OAuthStatusResponse(
is_authorized=True,
workspace_slug=oauth_token.workspace_slug if oauth_token else None,
expires_at=oauth_token.expires_at.isoformat() if oauth_token else None,
)
else:
return OAuthStatusResponse(is_authorized=False, workspace_slug=None, expires_at=None)
except Exception as e:
log.error(f"Error checking OAuth status: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Failed to check authorization status"})
@router.post("/revoke/", response_model=OAuthRevokeResponse)
async def revoke_oauth_authorization(
data: OAuthRevokeRequest,
db: AsyncSession = Depends(get_async_session),
session: str = Depends(cookie_schema),
):
"""
Revoke OAuth authorization for a specific workspace.
Deactivates stored tokens and prevents further API access.
"""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
try:
oauth_service = PlaneOAuthService()
# Revoke token
success = await oauth_service.revoke_token(db=db, user_id=user_id, workspace_id=data.workspace_id)
if success:
log.info(f"OAuth authorization revoked for user {user_id}, workspace {data.workspace_id}")
return OAuthRevokeResponse(success=True, message="Authorization revoked successfully")
else:
return OAuthRevokeResponse(success=False, message="No active authorization found for this workspace")
except Exception as e:
log.error(f"Error revoking OAuth authorization: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Failed to revoke authorization"})
@router.get("/workspaces/")
async def list_authorized_workspaces(
db: AsyncSession = Depends(get_async_session),
session: str = Depends(cookie_schema),
):
"""
List all workspaces the user has authorized access to.
Returns workspace details and token status.
"""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
try:
from sqlmodel import select
from pi.app.models.oauth import PlaneOAuthToken
# Get all active tokens for user
result = await db.execute(select(PlaneOAuthToken).where(PlaneOAuthToken.user_id == user_id, PlaneOAuthToken.is_active is True))
tokens = result.scalars().all()
workspaces = []
for token in tokens:
workspaces.append({
"workspace_id": str(token.workspace_id),
"workspace_slug": token.workspace_slug,
"expires_at": token.expires_at.isoformat(),
"needs_refresh": token.needs_refresh(),
"is_expired": token.is_expired(),
})
return JSONResponse(content={"workspaces": workspaces})
except Exception as e:
log.error(f"Error listing authorized workspaces: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Failed to list authorized workspaces"})
@router.post("/reset-states/")
async def reset_oauth_states(
user_id: UUID = Query(..., description="User ID to reset states for"),
workspace_id: UUID = Query(None, description="Workspace ID to reset (optional - if not provided, resets all user states)"),
db: AsyncSession = Depends(get_async_session),
):
"""
Reset OAuth states for a user to allow fresh authorization attempts.
This endpoint can be used when users are stuck with invalid/expired state errors.
"""
try:
oauth_service = PlaneOAuthService()
# Reset states for the user
reset_count = await oauth_service.reset_user_oauth_states(db, user_id, workspace_id)
# Also cleanup expired states
cleanup_count = await oauth_service.cleanup_expired_states(db)
message = f"Reset {reset_count} OAuth states for user {user_id}"
if workspace_id:
message += f" and workspace {workspace_id}"
message += f". Cleaned up {cleanup_count} expired states."
log.info(message)
return JSONResponse(content={"success": True, "message": message, "reset_count": reset_count, "cleanup_count": cleanup_count})
except Exception as e:
log.error(f"Error resetting OAuth states: {e!s}")
return JSONResponse(status_code=500, content={"detail": "Failed to reset OAuth states"})
@router.get("/authorize/{encoded_params}", tags=["oauth"])
async def clean_oauth_init(
encoded_params: str,
db: AsyncSession = Depends(get_async_session),
):
"""
Clean OAuth initiation endpoint with encrypted parameters.
This replaces the ugly URL with a clean, encrypted one.
"""
try:
from pi.services.actions.oauth_url_encoder import OAuthUrlEncoder
# Decode the parameters
oauth_encoder = OAuthUrlEncoder()
params = oauth_encoder.decode_oauth_params(encoded_params)
# Extract required parameters
user_id = UUID(params["user_id"])
workspace_id = UUID(params["workspace_id"])
chat_id = params.get("chat_id")
message_token = params.get("message_token")
is_project_chat = params.get("is_project_chat", "false").lower() == "true"
project_id = params.get("project_id")
pi_sidebar_open = params.get("pi_sidebar_open", "false").lower() == "true"
sidebar_open_url = params.get("sidebar_open_url")
oauth_service = PlaneOAuthService()
# Clean up expired states periodically
try:
await oauth_service.cleanup_expired_states(db)
except Exception as e:
log.warning(f"Failed to cleanup expired states: {e}")
# Get workspace slug for the workspace
workspace_slug = None
if workspace_id:
workspace_slug = await get_workspace_slug(str(workspace_id))
# Generate authorization URL with state
auth_url, state = oauth_service.generate_authorization_url(
user_id=user_id,
workspace_id=workspace_id,
workspace_slug=workspace_slug,
)
# Save state for verification with additional context
await oauth_service.save_oauth_state(
db,
state,
user_id,
workspace_id,
chat_id=chat_id,
message_token=message_token,
is_project_chat=is_project_chat,
project_id=project_id,
pi_sidebar_open=pi_sidebar_open,
sidebar_open_url=sidebar_open_url,
)
log.debug(f"Generated new OAuth state for user {user_id}")
# Redirect to Plane for authorization
return RedirectResponse(url=auth_url, status_code=302)
except Exception as e:
log.error(f"Error processing clean OAuth init: {e}")
# Redirect to frontend with error
frontend_url = settings.plane_api.FRONTEND_URL
redirect_url = f"{frontend_url}?oauth_error=true&message=invalid_parameters"
return RedirectResponse(url=redirect_url, status_code=302)
@@ -0,0 +1,50 @@
"""Simple transcription endpoint."""
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import UploadFile
from fastapi.responses import JSONResponse
from pydantic import UUID4
from sqlmodel.ext.asyncio.session import AsyncSession
from pi import logger
from pi.app.api.v1.dependencies import cookie_schema
from pi.app.api.v1.dependencies import is_valid_session
from pi.core.db.plane_pi.lifecycle import get_async_session
from pi.services.transcription.transcribe import process_transcription
log = logger.getChild(__name__)
router = APIRouter()
@router.post("/transcribe")
async def transcribe_file(
workspace_id: UUID4,
chat_id: UUID4,
file: UploadFile,
db: AsyncSession = Depends(get_async_session),
session: str = Depends(cookie_schema),
):
"""Transcribe audio file."""
try:
auth = await is_valid_session(session)
if not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
user_id = auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
try:
success, message = await process_transcription(workspace_id, chat_id, file, user_id, db)
if success:
return JSONResponse(status_code=200, content={"detail": message})
else:
return JSONResponse(status_code=500, content={"detail": message})
except HTTPException:
raise
except Exception as e:
log.error(f"Transcription failed: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,4 @@
from .chat import _get_chat
from .message import _get_message
__all__ = ["_get_chat", "_get_message"]
@@ -0,0 +1,301 @@
"""
Helper functions for batch action execution in chat endpoints.
"""
import datetime
import logging
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from uuid import UUID
from sqlalchemy import select
from sqlmodel.ext.asyncio.session import AsyncSession
from pi.app.models.enums import UserTypeChoices
from pi.app.models.message import Message
from pi.app.schemas.chat import ActionBatchExecutionRequest
from pi.services.chat.chat import PlaneChatBot
from pi.services.chat.helpers.batch_action_orchestrator import BatchActionOrchestrator
from pi.services.chat.helpers.batch_execution_context import BatchExecutionContext
log = logging.getLogger(__name__)
async def validate_session_and_get_user(session: str) -> Optional[UUID]:
"""Validate session and return user ID if valid."""
try:
from pi.app.api.v1.dependencies import is_valid_session
auth = await is_valid_session(session)
if not auth.user:
return None
return auth.user.id
except Exception as e:
log.error(f"Error validating session: {e!s}")
return None
async def prepare_execution_data(request: ActionBatchExecutionRequest, user_id: UUID, db: AsyncSession) -> Optional[dict]:
"""Prepare execution data and return error response if validation fails."""
try:
from pi.services.chat.helpers.batch_execution_helpers import get_original_user_query
from pi.services.chat.helpers.batch_execution_helpers import get_planned_actions_for_execution
# Get planned actions for this message
planned_actions = await get_planned_actions_for_execution(request.message_id, request.chat_id, db)
# Filter out any retrieval steps accidentally marked as planned (shared heuristic)
from pi.services.chat.helpers.tool_utils import is_retrieval_tool
planned_actions = [a for a in planned_actions if not is_retrieval_tool(a.get("tool_name"))]
if not planned_actions:
return None
# Get original user query
original_query = await get_original_user_query(request.message_id, db)
if not original_query:
return None
# Get OAuth token for the user
chatbot = PlaneChatBot()
access_token = await chatbot._get_oauth_token_for_user(db, str(user_id), str(request.workspace_id))
if not access_token:
log.error(f"No valid OAuth token found for user {user_id} and workspace {request.workspace_id}")
return None
# Get workspace context
from pi.app.api.v1.helpers.plane_sql_queries import get_workspace_slug
workspace_slug = await get_workspace_slug(str(request.workspace_id))
if not workspace_slug:
return None
# Get is_project_chat from chat record (with explicit commit/refresh to avoid race condition)
from sqlalchemy import select
from pi.app.models.chat import Chat
# Refresh the session to ensure we see any committed chat records
await db.commit()
stmt = select(Chat).where(Chat.id == request.chat_id) # type: ignore[arg-type]
result = await db.execute(stmt)
chat = result.scalar_one_or_none()
is_project_chat = chat.is_project_chat if chat else False
log.info(f"🔧 EXECUTION DATA PREP: chat_id={request.chat_id}, is_project_chat from DB (after commit)={is_project_chat}")
# Return successful execution data
return {
"planned_actions": planned_actions,
"original_query": original_query,
"access_token": access_token,
"workspace_slug": workspace_slug,
"user_id": user_id,
"workspace_id": request.workspace_id,
"message_id": request.message_id,
"chat_id": request.chat_id,
"is_project_chat": is_project_chat,
}
except Exception as e:
log.error(f"Error preparing execution data: {e}")
return None
async def execute_batch_actions(execution_data: dict, db: AsyncSession) -> BatchExecutionContext:
"""Execute batch actions using the orchestrator."""
try:
# Create execution context
context = BatchExecutionContext(
message_id=execution_data["message_id"],
chat_id=execution_data["chat_id"],
user_id=execution_data["user_id"],
workspace_id=execution_data["workspace_id"],
is_project_chat=execution_data.get("is_project_chat", False),
)
# Create orchestrator and execute batch
chatbot = PlaneChatBot()
orchestrator = BatchActionOrchestrator(chatbot, db)
context = await orchestrator.execute_planned_actions(
context,
execution_data["original_query"],
execution_data["planned_actions"],
execution_data["access_token"],
execution_data["workspace_slug"],
)
return context
except Exception as e:
log.error(f"Error executing batch actions: {e}")
# Create a failed context to return
context = BatchExecutionContext(
message_id=execution_data["message_id"],
chat_id=execution_data["chat_id"],
user_id=execution_data["user_id"],
workspace_id=execution_data["workspace_id"],
is_project_chat=execution_data.get("is_project_chat", False),
)
context.add_execution_failure("system", "execution_error", str(e))
return context
async def update_assistant_message_with_execution_results(message_id: UUID, chat_id: UUID, context: BatchExecutionContext, db: AsyncSession) -> None:
"""Update the assistant message with execution results."""
try:
# Get the assistant message that relates to this user message
# The assistant message should have the user message as its parent_id
filters = [Message.parent_id == message_id, Message.chat_id == chat_id, Message.user_type == UserTypeChoices.ASSISTANT.value]
stmt = select(Message).where(*filters) # type: ignore[arg-type]
result = await db.execute(stmt)
message = result.scalar_one_or_none()
if not message:
log.error(f"Assistant message for user message ID {message_id} not found")
return
# Frontend now gets execution details via structured data in chat history
# No longer appending execution results to assistant message content
except Exception as e:
log.error(f"Error updating assistant message with execution results: {e}")
if db:
await db.rollback()
def format_execution_response(context: BatchExecutionContext) -> Dict[str, Any]:
"""Format the execution response with clean, non-redundant structure."""
try:
# Base response structure
response: Dict[str, Any] = {
"action_summary": {
"total_planned": context.total_planned,
"completed": context.completed_count,
"failed": context.failed_count,
"duration_seconds": round((datetime.datetime.utcnow() - context.start_time).total_seconds(), 2),
},
}
# Add actions with consolidated entity information
if context.executed_actions:
response["actions"] = create_clean_actions_response(context.executed_actions)
return response
except Exception as e:
log.error(f"Error formatting execution response: {e}")
return {"action_summary": {"total_planned": 0, "completed": 0, "failed": 0, "duration_seconds": 0.0}, "actions": []}
def create_clean_actions_response(executed_actions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Create clean action results without duplicate entity information."""
clean_actions = []
for action in executed_actions:
action_data = {
"tool": action.get("tool_name"),
"success": action.get("success"),
"executed_at": action.get("executed_at"),
"artifact_id": action.get("artifact_id"), # NEW: Include artifact ID
"sequence": action.get("sequence"), # NEW: Include planned step order
}
if action.get("success"):
# For successful actions, include essential entity info
entity_info = action.get("entity_info")
if entity_info and isinstance(entity_info, dict):
# Only include the most important entity fields
essential_entity = {}
for field in ["entity_url", "entity_name", "entity_type", "entity_id"]:
if field in entity_info and entity_info[field]:
essential_entity[field] = entity_info[field]
# Include issue_identifier when available (for work-items)
if entity_info.get("issue_identifier"):
essential_entity["issue_identifier"] = entity_info["issue_identifier"]
if essential_entity:
action_data["entity"] = essential_entity
# Add project_identifier at the action root when derivable
project_identifier = None
try:
# Prefer deriving from identifiers present in entity_info
if entity_info.get("entity_type") == "project":
project_identifier = entity_info.get("entity_identifier") or entity_info.get("project_identifier")
elif entity_info.get("issue_identifier") and isinstance(entity_info.get("issue_identifier"), str):
ident = entity_info.get("issue_identifier")
if "-" in ident:
project_identifier = ident.split("-", 1)[0]
elif entity_info.get("entity_url") and isinstance(entity_info.get("entity_url"), str):
url = entity_info.get("entity_url")
# Attempt to parse /browse/PROJECT-SEQ/ pattern
if "/browse/" in url:
after = url.split("/browse/", 1)[1]
ident = after.split("/", 1)[0]
if "-" in ident:
project_identifier = ident.split("-", 1)[0]
except Exception:
project_identifier = None
if project_identifier:
action_data["project_identifier"] = project_identifier
# Extract the nice success message from the result
result = action.get("result", "")
if result:
# Extract the nice message that comes after "✅ " and before "\n\n"
nice_message = extract_success_message(result)
if nice_message:
action_data["message"] = nice_message
else:
# Fallback to generic messages
if "created" in result.lower():
action_data["message"] = "Created successfully"
elif "updated" in result.lower():
action_data["message"] = "Updated successfully"
else:
action_data["message"] = "Action completed successfully"
else:
# For failed actions, include error message
error = action.get("error", "")
if error:
action_data["error"] = error[:200] + "..." if len(error) > 200 else error
clean_actions.append(action_data)
return clean_actions
def extract_success_message(result: str) -> str:
"""Extract the nice success message from the tool result."""
if not result or not isinstance(result, str):
return ""
# Look for the pattern: "✅ [message]\n\n"
if "" in result:
lines = result.split("\n")
for line in lines:
line = line.strip()
if line.startswith(""):
# Remove the "✅ " prefix and return the message
message = line[2:].strip() # Remove "✅ " (2 characters)
return message
return ""
def get_status_message(context: BatchExecutionContext) -> str:
"""Generate a user-friendly status message."""
if context.status == "success":
return f"Successfully executed {context.completed_count} actions"
elif context.status == "partial":
return f"Partial success: {context.completed_count} actions completed, {context.failed_count} failed"
else:
return f"Batch execution failed: {context.failed_count} actions failed"
+33
View File
@@ -0,0 +1,33 @@
# Python imports
from typing import Any
from typing import List
from typing import Optional
from typing import Union
# External imports
from pydantic import UUID4
from sqlalchemy import and_
from sqlmodel.ext.asyncio.session import AsyncSession
# Module imports
from pi import logger
from pi.app.models import Chat
log = logger.getChild(__name__)
async def _get_chat(chat_id: UUID4, db: AsyncSession, user_id: Optional[UUID4] = None, filters: Optional[Union[List, Any]] = None) -> Optional[Chat]:
conditions = [Chat.id == chat_id]
if user_id:
conditions.append(Chat.user_id == user_id)
if filters:
if isinstance(filters, list):
conditions.extend(filters)
else:
conditions.append(filters)
statement = Chat.objects().where(and_(*conditions)) # type: ignore[arg-type]
execution = await db.exec(statement)
return execution.first()
@@ -0,0 +1,96 @@
from typing import Dict
from typing import List
from typing import Optional
from typing import Union
import requests
from pi import logger
from pi import settings
log = logger.getChild(__name__)
def get_commit_comparison(repo_name: str, base_commit: str, head_commit: str) -> Union[Dict, str]:
"""
Get the comparison between two commits using GitHub API.
Returns the net changes between base and head commits.
"""
url = f"https://api.github.com/repos/{settings.vector_db.DOCS_REPO_OWNER}/{repo_name}/compare/{base_commit}...{head_commit}"
headers = {"Authorization": f"token {settings.vector_db.DOCS_GITHUB_API_TOKEN}"}
try:
response = requests.get(url, headers=headers)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
error_message = f"Failed to get commit comparison for {repo_name} from {base_commit} to {head_commit}: {e}"
log.error(error_message)
return error_message
def get_net_file_changes(repo_name: str, base_commit: str, head_commit: str) -> Dict[str, Union[List[str], str]]:
"""
Get net file changes between two commits, filtering for documentation files only.
Returns a dictionary with 'added', 'modified', and 'removed' file lists.
"""
if base_commit == head_commit:
return {"added": [], "modified": [], "removed": []}
comparison = get_commit_comparison(repo_name, base_commit, head_commit)
if isinstance(comparison, str):
return {"added": [], "modified": [], "removed": [], "error": comparison}
# At this point, comparison is guaranteed to be a Dict
files = comparison.get("files", [])
# Filter for documentation files only (.mdx and .txt)
def is_doc_file(filename: str) -> bool:
return filename.endswith(".mdx") or filename.endswith(".txt")
added = []
modified = []
removed = []
for file in files:
filename = file.get("filename", "")
if not is_doc_file(filename):
continue
status = file.get("status", "")
if status == "added":
added.append(filename)
elif status == "modified":
modified.append(filename)
elif status == "removed":
removed.append(filename)
elif status == "renamed":
# Handle renamed files
previous_filename = file.get("previous_filename", "")
if is_doc_file(previous_filename):
removed.append(previous_filename)
added.append(filename)
log.info(
f"Net changes for {repo_name} ({base_commit}..{head_commit}): " f"Added: {len(added)}, Modified: {len(modified)}, Removed: {len(removed)}"
)
return {"added": added, "modified": modified, "removed": removed}
def get_latest_commit(repo_name: str, branch: str = "main") -> Optional[str]:
"""
Get the latest commit SHA for a given branch.
"""
url = f"https://api.github.com/repos/{settings.vector_db.DOCS_REPO_OWNER}/{repo_name}/commits/{branch}"
headers = {"Authorization": f"token {settings.vector_db.DOCS_GITHUB_API_TOKEN}"}
try:
response = requests.get(url, headers=headers)
response.raise_for_status()
commit_data = response.json()
return commit_data.get("sha")
except requests.exceptions.RequestException as e:
log.error(f"Failed to get latest commit for {repo_name}/{branch}: {e}")
return None
+51
View File
@@ -0,0 +1,51 @@
# Python imports
from typing import Any
from typing import List
from typing import Optional
from typing import Union
# External imports
from pydantic import UUID4
from sqlalchemy import and_
from sqlmodel.ext.asyncio.session import AsyncSession
# Module imports
from pi.app.models import Message
async def _get_last_message(db: AsyncSession, user_id: Optional[UUID4] = None, filters: Optional[Union[List, Any]] = None) -> Optional[Message]:
conditions = []
if user_id:
conditions.extend([Message.user_id == user_id]) # type: ignore[attr-defined]
if filters:
if isinstance(filters, list):
conditions.extend(filters)
else:
conditions.append(filters)
statement = Message.objects().where(and_(*conditions)).order_by(Message.sequence.desc()) # type: ignore[attr-defined]
execution = await db.exec(statement)
return execution.first()
async def _get_message(
db: AsyncSession, message_id: Optional[UUID4] = None, user_id: Optional[UUID4] = None, filters: Optional[Union[List, Any]] = None
) -> Optional[Message]:
conditions = []
if message_id:
conditions.append(Message.id == message_id)
if user_id:
conditions.append(Message.user_id == user_id) # type: ignore[attr-defined]
if filters:
if isinstance(filters, list):
conditions.extend(filters)
else:
conditions.append(filters)
statement = Message.objects().where(and_(*conditions)) # type: ignore[arg-type]
execution = await db.exec(statement)
return execution.first()
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
from fastapi import APIRouter
from pi.app.api.v1.endpoints import artifacts
from pi.app.api.v1.endpoints import attachments
from pi.app.api.v1.endpoints import chat
from pi.app.api.v1.endpoints import chat_ctas
from pi.app.api.v1.endpoints import docs
from pi.app.api.v1.endpoints import dupes
from pi.app.api.v1.endpoints import oauth
from pi.app.api.v1.endpoints import transcription
from pi.app.api.v1.endpoints.internal import llm
from pi.app.api.v1.endpoints.internal import vectorize
from pi.app.api.v1.endpoints.mobile import attachments as mobile_attachments
from pi.app.api.v1.endpoints.mobile import chat as mobile_chat
from pi.app.api.v1.endpoints.mobile import transcription as mobile_transcription
# Router for endpoints
plane_pi_router = APIRouter()
plane_pi_router.include_router(dupes.router, prefix="/dupes", tags=["dupes"])
plane_pi_router.include_router(chat.router, prefix="/chat", tags=["chat"])
plane_pi_router.include_router(chat_ctas.router, prefix="/chat-ctas", tags=["chat-ctas"])
plane_pi_router.include_router(oauth.router, prefix="/oauth", tags=["oauth"])
plane_pi_router.include_router(attachments.router, prefix="/attachments", tags=["attachments"])
plane_pi_router.include_router(llm.router, prefix="/internal", tags=["internal"])
plane_pi_router.include_router(vectorize.router, prefix="/internal", tags=["internal-vectorize"])
plane_pi_router.include_router(docs.router, prefix="/docs", tags=["docs-webhook"])
plane_pi_router.include_router(transcription.router, prefix="/transcription", tags=["transcription"])
plane_pi_router.include_router(artifacts.router, prefix="/artifacts", tags=["artifacts"])
# Mobile endpoints
plane_pi_router.include_router(mobile_chat.mobile_router, prefix="/mobile/chat", tags=["mobile/chat"])
plane_pi_router.include_router(mobile_transcription.mobile_router, prefix="/mobile/transcription", tags=["mobile/transcription"])
plane_pi_router.include_router(mobile_attachments.mobile_router, prefix="/mobile/attachments", tags=["mobile/attachments"])
+192
View File
@@ -0,0 +1,192 @@
import logging
from contextlib import asynccontextmanager
import sentry_sdk
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from pi import logger
from pi import settings
from pi.app.api.v1.router import plane_pi_router
from pi.app.middleware.feature_flag import FeatureFlagMiddleware
# LLM fixtures sync moved to explicit startup scripts
from pi.core.db.plane_pi.lifecycle import close_async_db
from pi.core.db.plane_pi.lifecycle import init_async_db
log = logger.getChild("FastAPI")
cors_origins = settings.CORS_ALLOWED_ORIGINS
def before_send(event, hint):
"""
Filter events before sending them to Sentry.
"""
exception = hint.get("exc_info")
# Only allow events with actual exceptions
if exception is None:
return None # Drop all non-exception events
return event # Allow other events to pass through
def configure_sentry():
"""Configure Sentry error tracking if enabled"""
if not settings.DEBUG and settings.SENTRY_DSN and settings.SENTRY_DSN.startswith("https://"):
sentry_sdk.init(
dsn=settings.SENTRY_DSN,
integrations=[
FastApiIntegration(),
LoggingIntegration(level=logging.WARNING, event_level=logging.ERROR),
],
before_send=before_send,
traces_sample_rate=1.0,
send_default_pii=True,
environment=settings.SENTRY_ENVIRONMENT,
)
def configure_datadog():
"""Configure DataDog tracing if enabled"""
from ddtrace import config
from ddtrace import patch
if not settings.DD_ENABLED:
return
config.logs_injection = True
config.version = settings.PROJECT_VERSION
config.service = settings.DD_SERVICE
config.env = settings.DD_ENV
patch(
logging=True,
requests=True,
fastapi=True,
openai=True,
psycopg=True,
langchain=True,
elasticsearch=True,
sqlalchemy=True,
aiohttp=True,
)
def create_app() -> FastAPI:
"""Create and configure the FastAPI application"""
configure_datadog()
configure_sentry()
base_path = settings.plane_api.BASE_PATH or ""
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.PROJECT_VERSION,
lifespan=lifespan,
docs_url=f"{base_path}/docs",
redoc_url=f"{base_path}/redoc",
openapi_url=f"{base_path}/openapi.json",
)
# Add routes
app.include_router(plane_pi_router, prefix=f"{base_path}/api/v1")
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Add feature flag middleware with endpoint configuration
# Only protect get-answer endpoints for now
endpoint_feature_map = {
# Web endpoints
"/api/v1/chat/get-answer/": settings.feature_flags.PI_CHAT,
"/api/v1/chat/initialize-chat/": settings.feature_flags.PI_CHAT,
"/api/v1/chat/queue-answer/": settings.feature_flags.PI_CHAT,
# "/api/v1/transcription/transcribe": settings.feature_flags.PI_CONVERSE,
# Mobile endpoints
"/api/v1/mobile/chat/get-answer/": settings.feature_flags.PI_CHAT,
# "/api/v1/mobile/transcription/transcribe": settings.feature_flags.PI_CONVERSE,
}
app.add_middleware(FeatureFlagMiddleware, endpoint_feature_map=endpoint_feature_map)
base_root = f"{base_path}/" if base_path else "/"
@app.get(base_root)
async def root():
return HTMLResponse("Welcome to Plane Intelligence API")
return app
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage startup and shutdown actions."""
try:
await init_async_db(app)
# LLM sync is now handled explicitly in start-application command
# Vector sync is now handled by Celery workers instead of background task
log.info("FastAPI application startup complete - Vector sync handled externally")
yield
finally:
await close_async_db(app)
log.info("Application shutdown complete")
def run_server():
"""Run the uvicorn server with configured settings"""
host = str(settings.server.FASTAPI_APP_HOST)
port = int(settings.server.FASTAPI_APP_PORT)
workers = int(settings.server.FASTAPI_APP_WORKERS)
# Configure Uvicorn logging to use JSON format
uvicorn_log_config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"fmt": "%(asctime)s %(name)s %(levelname)s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"handlers": {
"default": {
"formatter": "json",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
"access": {
"formatter": "json",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
},
"loggers": {
"uvicorn": {"handlers": ["default"], "level": "INFO", "propagate": False},
"uvicorn.error": {"handlers": ["default"], "level": "INFO", "propagate": False},
"uvicorn.access": {"handlers": ["access"], "level": "INFO", "propagate": False},
},
}
uvicorn.run(
"pi.app.main:app",
host=host,
port=port,
workers=workers,
reload=settings.DEBUG,
log_config=uvicorn_log_config,
)
# Initialize application
app = create_app()
+3
View File
@@ -0,0 +1,3 @@
# from .auth import session_validation_middleware
# __all__ = ["session_validation_middleware"]
+193
View File
@@ -0,0 +1,193 @@
import json
import logging
from typing import Dict
from typing import Optional
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from pi import settings
from pi.app.api.v1.dependencies import is_jwt_valid
from pi.app.api.v1.dependencies import is_valid_session
from pi.app.api.v1.helpers.plane_sql_queries import get_workspace_slug
from pi.services.feature_flags import FeatureFlagContext
from pi.services.feature_flags import feature_flag_service
logger = logging.getLogger(__name__)
FLAGS = settings.feature_flags
SESSION_ID_NAME = settings.plane_api.SESSION_COOKIE_NAME
class FeatureFlagMiddleware(BaseHTTPMiddleware):
"""
Feature flag middleware that can selectively protect endpoints based on feature flags.
This middleware can be configured to:
1. Protect specific endpoints with specific feature flags
2. Extract workspace information from different sources
3. Provide graceful fallbacks when feature checks fail
"""
def __init__(self, app, endpoint_feature_map: Optional[Dict[str, str]] = None):
"""
Initialize the middleware.
Args:
app: The FastAPI application
endpoint_feature_map: Mapping of endpoint patterns to required feature flags
e.g., {"/api/v1/chat/": "PI_CHAT", "/api/v1/dupes/": "PI_DEDUPE"}
"""
super().__init__(app)
self.endpoint_feature_map = endpoint_feature_map or {}
def _get_required_feature_flag(self, path: str) -> Optional[str]:
"""
Determine the required feature flag for a given path.
Args:
path: Request path
Returns:
Feature flag key if path requires feature flag check, None otherwise
"""
for endpoint_pattern, feature_flag in self.endpoint_feature_map.items():
if path.startswith(endpoint_pattern):
return feature_flag
return None
async def _extract_workspace_slug(self, request: Request) -> Optional[str]:
if request.method == "POST":
try:
body_bytes = await request.body()
if body_bytes:
body_json = json.loads(body_bytes.decode("utf-8"))
# Try workspace_slug first (direct from body)
workspace_slug = body_json.get("workspace_slug")
if workspace_slug:
return workspace_slug
# Try workspace_id and convert to slug
workspace_id = body_json.get("workspace_id")
if workspace_id and workspace_id != "": # Handle empty string case
workspace_slug = await get_workspace_slug(str(workspace_id))
if workspace_slug:
return workspace_slug
except Exception as e:
logger.debug(f"Could not parse POST request body for workspace slug: {e}")
elif request.method in ["DELETE", "PUT", "PATCH"]:
try:
body_bytes = await request.body()
if body_bytes:
body_json = json.loads(body_bytes.decode("utf-8"))
# Try workspace_slug first
workspace_slug = body_json.get("workspace_slug")
if workspace_slug:
return workspace_slug
# Try workspace_id and convert to slug
workspace_id = body_json.get("workspace_id")
if workspace_id and workspace_id != "": # Handle empty string case
workspace_slug = await get_workspace_slug(str(workspace_id))
if workspace_slug:
return workspace_slug
except Exception as e:
logger.debug(f"Could not parse {request.method} request body for workspace slug: {e}")
elif request.method == "GET":
# Try workspace_slug from query params first
workspace_slug = request.query_params.get("workspace_slug")
if workspace_slug:
return workspace_slug
# Try workspace_id from query params and convert to slug
workspace_id = request.query_params.get("workspace_id")
if workspace_id:
workspace_slug = await get_workspace_slug(str(workspace_id))
if workspace_slug:
return workspace_slug
logger.debug(f"No workspace slug found in {request.method} {request.url.path}")
return None
async def dispatch(self, request: Request, call_next):
# Skip feature flag checks for CORS preflight OPTIONS requests
if request.method == "OPTIONS":
return await call_next(request)
# Skip feature flag checks if server is not configured
if not settings.FEATURE_FLAG_SERVER_AUTH_TOKEN or not settings.FEATURE_FLAG_SERVER_BASE_URL:
logger.warning("Feature flag server not configured - skipping feature flag checks")
return await call_next(request)
# Check if this endpoint requires feature flag validation
feature_flag = self._get_required_feature_flag(request.url.path)
if not feature_flag:
# No feature flag required for this endpoint
return await call_next(request)
# Determine authentication method based on request path
is_mobile_endpoint = "/mobile/" in request.url.path
try:
if is_mobile_endpoint:
# Mobile endpoints use JWT authentication
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
logger.error("Missing or invalid Authorization header for mobile endpoint")
return JSONResponse(status_code=401, content={"detail": "Missing Authorization header"})
token = auth_header[7:] # Remove "Bearer " prefix
auth = await is_jwt_valid(token)
if not auth or not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid JWT token"})
user_id = str(auth.user.id)
# logger.debug(f"Mobile endpoint authenticated user: {user_id}")
else:
# Web endpoints use session cookie authentication
session = request.cookies.get(SESSION_ID_NAME)
if not session:
logger.error("Missing session cookie for web endpoint")
return JSONResponse(status_code=401, content={"detail": "Missing session cookie"})
auth = await is_valid_session(session)
if not auth or not auth.user:
return JSONResponse(status_code=401, content={"detail": "Invalid session"})
user_id = str(auth.user.id)
# logger.debug(f"Web endpoint authenticated user: {user_id}")
except Exception as e:
endpoint_type = "mobile" if is_mobile_endpoint else "web"
logger.error(f"Authentication failed for {endpoint_type} endpoint: {e}")
return JSONResponse(status_code=401, content={"detail": "Authentication failed"})
# Extract workspace information
workspace_slug = await self._extract_workspace_slug(request)
if not workspace_slug:
logger.warning(f"No workspace information found for feature flag check: {feature_flag}")
return JSONResponse(status_code=400, content={"detail": "Workspace information is required"})
# Check feature flag
try:
feature_context = FeatureFlagContext(user_id=user_id, workspace_slug=workspace_slug)
is_enabled = await feature_flag_service.is_enabled(feature_flag, feature_context)
if not is_enabled:
logger.info(f"Feature {feature_flag} not enabled for workspace {workspace_slug}, user {user_id}")
return JSONResponse(status_code=402, content={"detail": f"Feature {feature_flag} is not enabled for this workspace"})
except Exception as e:
logger.error(f"Error checking feature flag {feature_flag}: {e}")
return JSONResponse(status_code=503, content={"detail": "Feature flag service unavailable"})
return await call_next(request)
+31
View File
@@ -0,0 +1,31 @@
from pi.app.models.action_artifact import ActionArtifact # noqa: F401
from pi.app.models.agent_artifact import AgentArtifact
from pi.app.models.chat import Chat
from pi.app.models.chat import UserChatPreference
from pi.app.models.github_webhook import GitHubWebhook
from pi.app.models.llm import LlmModel
from pi.app.models.llm import LlmModelPricing
from pi.app.models.message import Message
from pi.app.models.message import MessageFeedback
from pi.app.models.message import MessageFlowStep
from pi.app.models.message import MessageMeta
from pi.app.models.message_attachment import MessageAttachment
from pi.app.models.transcription import Transcription # noqa: F401
from pi.app.models.workspace_vectorization import WorkspaceVectorization # noqa: F401
__all__ = [
"AgentArtifact",
"Chat",
"GitHubWebhook",
"LlmModel",
"LlmModelPricing",
"Message",
"MessageAttachment",
"MessageFeedback",
"MessageFlowStep",
"MessageMeta",
"Transcription",
"UserChatPreference",
"WorkspaceVectorization",
"ActionArtifact",
] # noqa: F401
+46
View File
@@ -0,0 +1,46 @@
import uuid
from typing import Any
from typing import Optional
from sqlalchemy import Column
from sqlalchemy import ForeignKey
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlmodel import Field
from pi.app.models.base import BaseModel
class ActionArtifact(BaseModel, table=True):
__tablename__ = "action_artifacts"
# Card metadata
sequence: int = Field(default=1, nullable=False) # order within the message
entity: str = Field(nullable=False, max_length=64) # "issue", "project", "cycle", ...
entity_id: Optional[uuid.UUID] = Field(nullable=True)
action: str = Field(nullable=False, max_length=32) # "create", "update", "delete", "link", ...
data: dict[str, Any] = Field(sa_column=Column(JSONB, nullable=False))
is_executed: bool = Field(default=False, nullable=False)
success: bool = Field(default=False, nullable=False) # Whether the action succeeded
# Foreign keys
message_id: Optional[uuid.UUID] = Field(
default=None,
sa_column=Column(
PG_UUID(as_uuid=True),
ForeignKey("messages.id", name="fk_action_artifacts_message_id"),
nullable=True,
index=True,
),
)
chat_id: uuid.UUID = Field(
sa_column=Column(
PG_UUID(as_uuid=True),
ForeignKey("chats.id", name="fk_action_artifacts_chat_id"),
nullable=False,
index=True,
),
)
+24
View File
@@ -0,0 +1,24 @@
# python imports
import uuid
from typing import Optional
# Third-party imports
from sqlalchemy import Column
from sqlalchemy import String
from sqlmodel import Field
from pi.app.models.base import BaseModel
from pi.app.models.enums import AgentArtifactContentType
from pi.app.models.enums import AgentsType
class AgentArtifact(BaseModel, table=True):
__tablename__ = "agent_artifacts"
agent_name: AgentsType = Field(sa_column=Column(String(255), nullable=False))
workspace_id: Optional[uuid.UUID] = Field(nullable=True)
project_id: Optional[uuid.UUID] = Field(nullable=True)
issue_id: Optional[uuid.UUID] = Field(nullable=True)
content: str = Field(nullable=False)
content_type: AgentArtifactContentType = Field(sa_column=Column(String(50), nullable=False, default=AgentArtifactContentType.MARKDOWN.value))
version: Optional[int] = Field(nullable=True, default=1)
+77
View File
@@ -0,0 +1,77 @@
# models/base.py
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import TIMESTAMP
from sqlalchemy import event
from sqlmodel import Field
from sqlmodel import Session
from sqlmodel import SQLModel
from sqlmodel import select
def get_current_time() -> datetime:
return datetime.utcnow()
def utc_now() -> datetime:
return get_current_time()
class UUIDModel(SQLModel):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True)
class TimeAuditModel(SQLModel):
created_at: datetime = Field(
default_factory=lambda: get_current_time(),
sa_type=TIMESTAMP(timezone=True),
sa_column_kwargs={"nullable": False, "default": utc_now, "name": "created_at"},
)
updated_at: datetime = Field(
default_factory=lambda: get_current_time(),
sa_type=TIMESTAMP(timezone=True),
sa_column_kwargs={"nullable": False, "default": utc_now, "onupdate": utc_now, "name": "updated_at"},
)
class UserAuditModel(SQLModel):
created_by_id: Optional[uuid.UUID] = Field(default=None, nullable=True)
updated_by_id: Optional[uuid.UUID] = Field(default=None, nullable=True)
class SoftDeleteModel(SQLModel):
deleted_at: Optional[datetime] = Field(
default=None,
sa_type=TIMESTAMP(timezone=True),
sa_column_kwargs={"nullable": True, "default": None, "name": "deleted_at"},
)
def soft_delete(self) -> None:
self.deleted_at = get_current_time()
@classmethod
def objects(cls, *sub_queries):
base_query = select(cls).where(cls.deleted_at.is_(None))
if sub_queries:
base_query = select(cls, *sub_queries).where(cls.deleted_at.is_(None))
return base_query
class BaseModel(UUIDModel, TimeAuditModel, UserAuditModel, SoftDeleteModel):
pass
@event.listens_for(Session, "before_flush")
def set_user_audit_fields(session, flush_context, instances):
current_user = session.info.get("current_user")
if not current_user:
return
for instance in session.new:
if isinstance(instance, UserAuditModel):
instance.created_by_id = current_user.id
instance.updated_by_id = current_user.id
for instance in session.dirty:
if isinstance(instance, UserAuditModel):
instance.updated_by_id = current_user.id
+52
View File
@@ -0,0 +1,52 @@
# models/chat.py
import uuid
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from sqlalchemy import JSON
from sqlalchemy import Column
from sqlalchemy import ForeignKey
from sqlalchemy import text
from sqlalchemy.dialects.postgresql import UUID
from sqlmodel import Field
from sqlmodel import Relationship
from pi.app.models.base import BaseModel
from pi.app.models.message import Message
class Chat(BaseModel, table=True):
__tablename__ = "chats"
# Fields
title: Optional[str] = Field(default=None, nullable=True, max_length=255)
description: Optional[str] = Field(default=None, nullable=True)
icon: Optional[Dict[str, Any]] = Field(sa_type=JSON, default_factory=dict)
user_id: uuid.UUID = Field(default=None, nullable=False, index=True)
workspace_id: uuid.UUID = Field(default=None, nullable=True)
workspace_slug: Optional[str] = Field(default=None, nullable=True, max_length=255)
is_favorite: bool = Field(default=False, nullable=False, sa_column_kwargs={"server_default": text("false")})
is_project_chat: bool = Field(default=False, nullable=False, sa_column_kwargs={"server_default": text("false")})
workspace_in_context: Optional[bool] = Field(default=None, nullable=True)
# Relationships
messages: List[Message] = Relationship(back_populates="chat", sa_relationship_kwargs={"lazy": "selectin"})
user_chat_preferences: List["UserChatPreference"] = Relationship(back_populates="chat", sa_relationship_kwargs={"lazy": "selectin"})
class UserChatPreference(BaseModel, table=True):
__tablename__ = "user_chat_preferences"
# Fields
is_focus_enabled: bool = Field(default=True, nullable=False)
focus_project_id: Optional[uuid.UUID] = Field(default=None, nullable=True)
focus_workspace_id: Optional[uuid.UUID] = Field(default=None, nullable=True)
user_id: uuid.UUID = Field(default=None, nullable=False)
# Foreign keys
chat_id: uuid.UUID = Field(sa_column=Column(UUID(as_uuid=True), ForeignKey("chats.id", name="fk_user_chat_preferences_chat_id"), nullable=False))
# Relationships
chat: Chat = Relationship(back_populates="user_chat_preferences", sa_relationship_kwargs={"lazy": "selectin"})
+73
View File
@@ -0,0 +1,73 @@
# models/enums.py
from enum import Enum
# WARNING: Enum values MUST match exactly what's in the database migrations!
# Current migrations use UPPERCASE values in PostgreSQL, but these enums use lowercase.
# This mismatch can cause data insertion failures.
class UserTypeChoices(str, Enum):
# Database has: USER, ASSISTANT, SYSTEM
# Python has: user, assistant, system
USER = "user"
ASSISTANT = "assistant"
SYSTEM = "system"
def __str__(self):
return self.value
class MessageFeedbackTypeChoices(str, Enum):
FEEDBACK = "feedback"
REACTION = "reaction"
def __str__(self):
return self.value
class FlowStepType(str, Enum):
REWRITE = "rewrite"
ROUTING = "routing"
TOOL = "tool"
COMBINATION = "combination"
def __str__(self):
return self.value
class AgentsType(str, Enum):
PRD_WRITER = "prd_writer"
def __str__(self):
return self.value
class AgentArtifactContentType(str, Enum):
MARKDOWN = "markdown"
def __str__(self):
return self.value
class MessageMetaStepType(str, Enum):
INPUT = "input"
REWRITE = "rewrite"
ROUTER = "router"
COMBINATION = "combination"
SQL_TABLE_SELECTION = "sql_table_selection"
SQL_GENERATION = "sql_generation"
TITLE_GENERATION = "title_generation"
TOOL_ORCHESTRATION = "tool_orchestration"
ATTACHMENT_CONTEXT_EXTRACTION = "attachment_context_extraction"
def __str__(self):
return self.value
class ExecutionStatus(str, Enum):
PENDING = "pending" # Not yet attempted
SUCCESS = "success" # Successfully executed
FAILED = "failed" # Attempted but failed
def __str__(self):
return self.value
+18
View File
@@ -0,0 +1,18 @@
# python imports
from typing import Optional
# Third-party imports
from sqlmodel import Field
from pi.app.models.base import BaseModel
class GitHubWebhook(BaseModel, table=True):
__tablename__ = "github_webhooks"
commit_id: str = Field(nullable=False, max_length=255)
source: str = Field(nullable=False, max_length=255) # repo_name
branch_name: str = Field(nullable=False, max_length=255)
processed: bool = Field(nullable=False, default=False)
files_processed: Optional[int] = Field(nullable=True, default=0)
error_message: Optional[str] = Field(nullable=True)
+47
View File
@@ -0,0 +1,47 @@
# python imports
import uuid
from typing import List
from typing import Optional
# Third-party imports
from sqlalchemy import Column
from sqlalchemy import ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlmodel import Field
from sqlmodel import Relationship
# Module imports
from pi.app.models.base import BaseModel
class LlmModel(BaseModel, table=True):
__tablename__ = "llm_models"
model_config = {"protected_namespaces": ()}
# Fields
name: str = Field(nullable=False, max_length=255)
description: Optional[str] = Field(default=None, nullable=True)
provider: str = Field(max_length=255)
model_key: str = Field(nullable=False, max_length=255, unique=True)
max_tokens: int = Field(nullable=False)
is_active: bool = Field(default=True)
# Relationships
llm_model_pricing: List["LlmModelPricing"] = Relationship(
back_populates="llm_model",
sa_relationship_kwargs={"lazy": "selectin"},
)
class LlmModelPricing(BaseModel, table=True):
__tablename__ = "llm_model_pricing"
# Fields
llm_model_id: uuid.UUID = Field(sa_column=Column(UUID(as_uuid=True), ForeignKey("llm_models.id", name="fk_llm_model_pricing_llm_model_id")))
text_input_price: Optional[float] = Field(nullable=True, default=None, description="In USD per 1M tokens")
text_output_price: Optional[float] = Field(nullable=True, default=None, description="In USD per 1M tokens")
cached_text_input_price: Optional[float] = Field(nullable=True, default=None, description="In USD per 1M tokens")
# Relationships
llm_model: "LlmModel" = Relationship(back_populates="llm_model_pricing", sa_relationship_kwargs={"lazy": "selectin"})
+171
View File
@@ -0,0 +1,171 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from typing import List
from typing import Optional
from sqlalchemy import JSON
from sqlalchemy import Column
from sqlalchemy import ForeignKey
from sqlalchemy import String
from sqlalchemy import text
from sqlalchemy.dialects.postgresql import UUID
from sqlmodel import Field
from sqlmodel import Relationship
from pi.app.models.base import BaseModel
from pi.app.models.enums import ExecutionStatus
from pi.app.models.enums import MessageMetaStepType
from pi.app.models.enums import UserTypeChoices
if TYPE_CHECKING:
from pi.app.models.chat import Chat
from pi.app.models.message_attachment import MessageAttachment
# Message table
class Message(BaseModel, table=True):
__tablename__ = "messages" # type: ignore[assignment]
# Fields
sequence: int = Field(nullable=False, index=True)
content: Optional[str] = Field(default=None)
parsed_content: Optional[str] = Field(default=None, nullable=True)
reasoning: Optional[str] = Field(default=None, nullable=True)
user_type: str = Field(sa_column=Column(String(50), nullable=False, default=UserTypeChoices.USER.value))
workspace_slug: Optional[str] = Field(default=None, nullable=True, max_length=255)
# Foreign keys
parent_id: Optional[uuid.UUID] = Field(
sa_column=Column(UUID(as_uuid=True), ForeignKey("messages.id", name="fk_messages_parent_id"), nullable=True)
)
relates_to: Optional[uuid.UUID] = Field(
sa_column=Column(UUID(as_uuid=True), ForeignKey("messages.id", name="fk_messages_relates_to"), nullable=True)
)
llm_model_id: Optional[uuid.UUID] = Field(
sa_column=Column(UUID(as_uuid=True), ForeignKey("llm_models.id", name="fk_messages_llm_model_id"), nullable=True)
)
chat_id: Optional[uuid.UUID] = Field(
sa_column=Column(
UUID(as_uuid=True),
ForeignKey("chats.id", name="fk_messages_chat_id"),
nullable=True,
default=None,
index=True,
)
)
llm_model: Optional[str] = Field(
sa_column=Column(ForeignKey("llm_models.model_key", name="fk_messages_llm_model"), nullable=True),
default=None,
)
# Relationships
message_feedbacks: List["MessageFeedback"] = Relationship(back_populates="message", sa_relationship_kwargs={"lazy": "selectin"})
message_flowsteps: List["MessageFlowStep"] = Relationship(back_populates="message", sa_relationship_kwargs={"lazy": "selectin"})
message_attachments: List["MessageAttachment"] = Relationship(
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "MessageAttachment.message_id"}
)
chat: "Chat" = Relationship(back_populates="messages", sa_relationship_kwargs={"lazy": "selectin"})
# Message flow steps for tracking the internal flow of the message
class MessageFlowStep(BaseModel, table=True):
__tablename__ = "message_flow_steps" # type: ignore[assignment]
# Fields
step_order: int = Field(default=1, nullable=False)
step_type: str = Field(sa_column=Column(String(50), nullable=False))
tool_name: Optional[str] = Field(default=None, nullable=True)
content: Optional[str] = Field(default=None)
execution_data: Optional[dict] = Field(sa_type=JSON, default_factory=None)
is_executed: bool = Field(
default=False,
nullable=True,
description="Whether this planned action was executed by the user",
sa_column_kwargs={"server_default": text("false")},
)
is_planned: bool = Field(
default=False,
nullable=True,
description="Whether this is a planned action that requires user approval (vs automatically executed retrieval tools)",
sa_column_kwargs={"server_default": text("false")},
)
execution_success: Optional[ExecutionStatus] = Field(
sa_column=Column(String(50), nullable=True, default=ExecutionStatus.PENDING.value, index=True),
description="Status of execution: pending (not attempted), success (completed successfully), failed (attempted but failed)",
)
execution_error: Optional[str] = Field(
default=None,
nullable=True,
description="Error message if execution failed",
)
# OAuth-related fields
oauth_required: bool = Field(
default=False,
nullable=True,
description="Whether this step requires OAuth authorization",
sa_column_kwargs={"server_default": text("false")},
)
oauth_completed: bool = Field(
default=False,
nullable=True,
description="Whether OAuth authorization has been completed",
sa_column_kwargs={"server_default": text("false")},
)
oauth_completed_at: Optional[datetime] = Field(default=None, nullable=True, description="When OAuth authorization was completed")
workspace_slug: Optional[str] = Field(default=None, nullable=True, max_length=255)
# Foreign keys
message_id: uuid.UUID = Field(
sa_column=Column(UUID(as_uuid=True), ForeignKey("messages.id", name="fk_message_flow_steps_message_id"), nullable=False)
)
chat_id: uuid.UUID = Field(sa_column=Column(UUID(as_uuid=True), ForeignKey("chats.id", name="fk_message_flow_steps_chat_id"), nullable=False))
# Relationships
message: "Message" = Relationship(back_populates="message_flowsteps", sa_relationship_kwargs={"lazy": "selectin"})
# Message meta for token/cost trackingclass MessageMeta(BaseModel, table=True):
class MessageMeta(BaseModel, table=True):
__tablename__ = "message_meta" # type: ignore[assignment]
# Fields
step_type: MessageMetaStepType = Field(sa_column=Column(String(50), nullable=False))
input_text_tokens: Optional[int] = Field(nullable=True, default=None)
input_text_price: Optional[float] = Field(nullable=True, default=None, description="In USD")
output_text_tokens: Optional[int] = Field(nullable=True, default=None)
output_text_price: Optional[float] = Field(nullable=True, default=None, description="In USD")
cached_input_text_tokens: Optional[int] = Field(nullable=True, default=None)
cached_input_text_price: Optional[float] = Field(nullable=True, default=None, description="In USD")
workspace_slug: Optional[str] = Field(default=None, nullable=True, max_length=255)
# Foreign keys
message_id: uuid.UUID = Field(
sa_column=Column(UUID(as_uuid=True), ForeignKey("messages.id", name="fk_message_meta_message_id"))
) # user_message_id
llm_model_id: uuid.UUID = Field(
sa_column=Column(UUID(as_uuid=True), ForeignKey("llm_models.id", name="fk_message_meta_llm_model_id"), nullable=True)
)
llm_model_pricing_id: Optional[uuid.UUID] = Field(
sa_column=Column(UUID(as_uuid=True), ForeignKey("llm_model_pricing.id", name="fk_message_meta_llm_model_pricing_id"), nullable=True)
)
# Feedback table
class MessageFeedback(BaseModel, table=True):
__tablename__ = "message_feedbacks" # type: ignore[assignment]
# Fields
type: Optional[str] = Field(sa_column=Column(String(50), nullable=True, default=None))
feedback: Optional[str] = Field(default=None, nullable=True)
reaction: Optional[str] = Field(default=None, nullable=True)
feedback_message: Optional[str] = Field(default=None, nullable=True)
user_id: uuid.UUID = Field(nullable=False, index=True)
workspace_slug: Optional[str] = Field(default=None, nullable=True, max_length=255)
# Foreign keys
message_id: uuid.UUID = Field(
sa_column=Column(UUID(as_uuid=True), ForeignKey("messages.id", name="fk_message_feedbacks_message_id"), nullable=False)
)
# Relationships
message: "Message" = Relationship(back_populates="message_feedbacks", sa_relationship_kwargs={"lazy": "selectin"})
@@ -0,0 +1,77 @@
# python imports
import uuid
from typing import Optional
# Third-party imports
from sqlalchemy import Column
from sqlalchemy import ForeignKey
from sqlalchemy import String
from sqlalchemy.dialects.postgresql import UUID
from sqlmodel import Field
from pi.app.models.base import BaseModel
from pi.config import settings
class MessageAttachment(BaseModel, table=True):
__tablename__ = "message_attachments"
# File information
original_filename: str = Field(nullable=False, max_length=500)
content_type: str = Field(nullable=False, max_length=100) # MIME type (e.g., image/png, application/pdf)
file_size: int = Field(nullable=False) # Size in bytes
file_type: str = Field(sa_column=Column(String(50), nullable=False)) # image, pdf, document, other
# Upload status
status: str = Field(sa_column=Column(String(50), nullable=False, default="pending")) # pending, uploaded, failed
# S3 file path (just the key/path, not full URL)
file_path: str = Field(nullable=False, max_length=1000) # e.g., "uploads/ws-id/chat-id/file-id-filename.pdf"
# Context information
workspace_id: Optional[uuid.UUID] = Field(nullable=True, index=True)
chat_id: Optional[uuid.UUID] = Field(nullable=True, index=True)
message_id: Optional[uuid.UUID] = Field(
sa_column=Column(UUID(as_uuid=True), ForeignKey("messages.id", name="fk_message_attachments_message_id"), nullable=True, index=True)
) # Can be null initially, set later
# User who uploaded
user_id: uuid.UUID = Field(nullable=False, index=True)
@property
def s3_url(self) -> str:
"""Generate the S3 URL for this attachment"""
return f"https://{settings.AWS_S3_BUCKET}.s3.{settings.AWS_S3_REGION}.amazonaws.com/{self.file_path}"
@property
def is_image(self) -> bool:
"""Check if the attachment is an image"""
return self.file_type == "image"
@property
def is_pdf(self) -> bool:
"""Check if the attachment is a PDF"""
return self.file_type == "pdf"
@classmethod
def get_file_type_from_mime(cls, content_type: str) -> str:
"""Determine file type from MIME type"""
if content_type.startswith("image/"):
return "image"
elif content_type == "application/pdf":
return "pdf"
else:
return "other"
def generate_file_path(self, workspace_id: Optional[uuid.UUID], chat_id: uuid.UUID) -> str:
# path is like: uploads/ws-id/chat-id/attachment-id-filename
env_prefix = settings.AWS_S3_ENV.strip()
# Base path
base_path = f"uploads/{workspace_id}/{chat_id}/{self.id}-{self.original_filename}"
if env_prefix:
# Add env as top-level folder
return f"{env_prefix}/{base_path}"
else:
# Prod case — no prefix
return base_path
@@ -0,0 +1,33 @@
import uuid
from datetime import datetime
from typing import Optional
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field
from pi.app.models.base import BaseModel
class MessageClarification(BaseModel, table=True):
__tablename__ = "message_clarifications"
chat_id: uuid.UUID = Field(nullable=False, foreign_key="chats.id")
message_id: uuid.UUID = Field(nullable=False, unique=True, foreign_key="messages.id")
pending: bool = Field(default=True, nullable=False)
# Store enum as VARCHAR (not DB enum) per project convention
kind: str = Field(sa_column=sa.Column(sa.String(16), nullable=False), description="action|retrieval")
original_query: str = Field(nullable=False)
# JSON fields
payload: dict = Field(sa_column=sa.Column(JSONB, nullable=False))
categories: list[str] = Field(sa_column=sa.Column(JSONB, nullable=False))
method_tool_names: list[str] = Field(sa_column=sa.Column(JSONB, nullable=False))
bound_tool_names: list[str] = Field(sa_column=sa.Column(JSONB, nullable=False))
# Resolution
answer_text: Optional[str] = Field(default=None, nullable=True)
resolved_by_message_id: Optional[uuid.UUID] = Field(default=None, nullable=True, foreign_key="messages.id")
resolved_at: Optional[datetime] = Field(default=None, nullable=True)
+92
View File
@@ -0,0 +1,92 @@
"""
OAuth models for storing Plane app authentication tokens
"""
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from typing import Optional
from uuid import UUID
from uuid import uuid4
from sqlmodel import Field
from sqlmodel import SQLModel
class PlaneOAuthToken(SQLModel, table=True):
"""Store OAuth tokens for Plane app integration"""
__table_args__ = {"extend_existing": True}
id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(index=True)
workspace_id: UUID = Field(index=True)
workspace_slug: str = Field(index=True)
# OAuth tokens
access_token: str = Field()
refresh_token: Optional[str] = Field(default=None)
# Token metadata
token_type: str = Field(default="Bearer")
expires_in: int = Field() # seconds until expiry
expires_at: datetime = Field() # calculated expiry time
# App installation details
app_installation_id: Optional[str] = Field(default=None)
app_bot_user_id: Optional[str] = Field(default=None)
# Timestamps
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None))
# Status
is_active: bool = Field(default=True)
def is_expired(self) -> bool:
"""Check if the access token is expired"""
# Ensure both times have no timezone for comparison
current_time = datetime.now(timezone.utc).replace(tzinfo=None)
expires_at = self.expires_at.replace(tzinfo=None) if self.expires_at.tzinfo else self.expires_at
return current_time >= expires_at
def needs_refresh(self) -> bool:
"""Check if token should be refreshed (5 minutes before expiry)"""
buffer_time = 300 # 5 minutes
# Ensure both times have no timezone for comparison
current_time = datetime.now(timezone.utc).replace(tzinfo=None)
expires_at = self.expires_at.replace(tzinfo=None) if self.expires_at.tzinfo else self.expires_at
return current_time >= (expires_at - timedelta(seconds=buffer_time))
class PlaneOAuthState(SQLModel, table=True):
"""Track OAuth flow state for security"""
__table_args__ = {"extend_existing": True}
id: UUID = Field(default_factory=uuid4, primary_key=True)
state: str = Field(unique=True, index=True) # Random state parameter
user_id: UUID = Field(index=True)
workspace_id: Optional[UUID] = Field(default=None)
# OAuth flow details
redirect_uri: str = Field()
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None))
expires_at: datetime = Field() # State expires after 10 minutes
# Additional context for redirect
chat_id: Optional[str] = Field(default=None) # Store chat ID
message_token: Optional[str] = Field(default=None) # Store message token
return_url: Optional[str] = Field(default=None) # Store where to redirect after OAuth
is_project_chat: Optional[bool] = Field(default=False) # Store if this is a project chat
project_id: Optional[str] = Field(default=None) # Store project ID if project chat
pi_sidebar_open: Optional[bool] = Field(default=False) # Store if sidebar is open
sidebar_open_url: Optional[str] = Field(default=None) # Store sidebar open URL
# Status
is_used: bool = Field(default=False)
def is_expired(self) -> bool:
"""Check if the state is expired"""
current_time = datetime.now(timezone.utc).replace(tzinfo=None)
return current_time >= self.expires_at
+25
View File
@@ -0,0 +1,25 @@
import uuid
from typing import Optional
from sqlmodel import Field
from .base import SoftDeleteModel
from .base import TimeAuditModel
from .base import UUIDModel
class Transcription(TimeAuditModel, SoftDeleteModel, UUIDModel, table=True):
__tablename__ = "transcriptions"
# Fields
transcription_text: str = Field(nullable=False)
transcription_id: str = Field(nullable=False)
audio_duration: int = Field(nullable=False) # Seconds
speech_model: str = Field(nullable=False)
processing_time: float = Field(nullable=False)
user_id: uuid.UUID = Field(nullable=False)
workspace_id: uuid.UUID = Field(nullable=False)
chat_id: Optional[uuid.UUID] = Field(nullable=True)
# Add pricing field
transcription_cost_usd: Optional[float] = Field(nullable=True, description="Cost in USD")
@@ -0,0 +1,37 @@
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlalchemy import Column
from sqlalchemy import String
from sqlmodel import Field
from .base import BaseModel
class VectorizationStatus(str, Enum):
queued = "queued"
running = "running"
success = "success"
failed = "failed"
class WorkspaceVectorization(BaseModel, table=True):
__tablename__ = "workspace_vectorizations"
workspace_id: str = Field(index=True, nullable=False)
status: VectorizationStatus = Field(sa_column=Column(String(50), nullable=False, default=VectorizationStatus.queued.value, index=True))
# caller-supplied knobs
feed_issues: bool = Field(default=True)
feed_pages: bool = Field(default=True)
feed_slices: int = Field(default=4)
batch_size: int = Field(default=32)
live_sync_enabled: bool = Field(default=True)
# audit / progress
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
progress_pct: int = 0
last_error: Optional[str] = None
View File
+80
View File
@@ -0,0 +1,80 @@
# python imports
import uuid
from typing import Any
from typing import Dict
from typing import Optional
# Third-party imports
from pydantic import BaseModel
from pydantic import Field
class AttachmentUploadRequest(BaseModel):
"""Request schema for initiating file upload"""
filename: str = Field(..., description="Original filename of the attachment", max_length=500)
content_type: str = Field(..., description="MIME type of the file (e.g., image/png, application/pdf)", max_length=100)
file_size: int = Field(..., description="Size of the file in bytes", gt=0)
workspace_id: Optional[uuid.UUID] = Field(None, description="Workspace ID for the attachment")
chat_id: uuid.UUID = Field(..., description="Chat ID for the attachment")
class S3UploadData(BaseModel):
"""S3 pre-signed POST data"""
url: str = Field(..., description="S3 upload URL")
fields: Dict[str, Any] = Field(..., description="Form fields for S3 POST request")
class AttachmentResponse(BaseModel):
"""Minimal attachment information response"""
id: str
filename: str
content_type: str
file_size: int
file_type: str
status: str
class AttachmentUploadResponse(BaseModel):
"""Response schema for upload initiation"""
upload_data: S3UploadData = Field(..., description="S3 pre-signed POST data for direct upload")
attachment_id: str = Field(..., description="Unique identifier for the attachment")
attachment: AttachmentResponse = Field(..., description="Attachment metadata")
class AttachmentCompleteRequest(BaseModel):
"""Request schema for completing upload"""
attachment_id: uuid.UUID = Field(..., description="Attachment ID to complete")
chat_id: uuid.UUID = Field(..., description="Chat ID for the attachment")
class AttachmentDetailResponse(BaseModel):
"""Minimal detailed attachment response"""
id: str
filename: str
content_type: str
file_size: int
file_type: str
status: str
attachment_url: Optional[str] = Field(None, description="Attachment download URL")
class AttachmentDeleteRequest(BaseModel):
"""Request schema for deleting attachment"""
attachment_id: uuid.UUID = Field(..., description="Attachment ID to delete")
chat_id: uuid.UUID = Field(..., description="Chat ID for the attachment")
# Error responses
class AttachmentError(BaseModel):
"""Error response for attachment operations"""
error: str
message: str
details: Optional[Dict[str, Any]] = None
+11
View File
@@ -0,0 +1,11 @@
from pydantic import UUID4
from pydantic import BaseModel
class User(BaseModel):
id: UUID4
class AuthResponse(BaseModel):
is_authenticated: bool
user: User | None = None
+213
View File
@@ -0,0 +1,213 @@
import uuid
from enum import Enum
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from pydantic import UUID4
from pydantic import BaseModel
from pydantic import Field
# Pagination base classes (defined here to avoid circular imports)
class PaginationRequest(BaseModel):
"""Request schema for cursor-based pagination."""
cursor: Optional[str] = Field(None, description="String cursor for pagination")
per_page: int = Field(30, ge=1, le=100, description="Number of items per page")
class PaginationResponse(BaseModel):
"""Response schema for cursor-based pagination."""
next_cursor: Optional[str] = Field(None, description="Cursor for next page")
prev_cursor: Optional[str] = Field(None, description="Cursor for previous page")
next_page_results: bool = Field(False, description="Whether there are more results in next page")
prev_page_results: bool = Field(False, description="Whether there are more results in previous page")
count: int = Field(description="Number of items in current page")
total_pages: Optional[int] = Field(None, description="Total number of pages (if calculable)")
total_results: Optional[int] = Field(None, description="Total number of results (if calculable)")
class ChatRequest(BaseModel):
query: str
llm: str = "gpt-4o"
is_new: bool
user_id: Optional[UUID4] = None
chat_id: Optional[UUID4] = None
is_temp: bool
workspace_in_context: bool
# is_reasoning: bool = False
workspace_id: UUID4 | None = "" # type: ignore
project_id: UUID4 | None = "" # type: ignore
context: dict[str, Any]
is_project_chat: Optional[bool] = False
pi_sidebar_open: Optional[bool] = False
workspace_slug: Optional[str] = None
attachment_ids: Optional[List[uuid.UUID]] = Field(default=[], description="List of attachment IDs to link to this message")
sidebar_open_url: Optional[str] = Field(default="", description="The URL where the sidebar was opened from")
source: Optional[str] = Field(default="", description="The source of the chat request")
class ChatInitializationRequest(BaseModel):
"""Schema for chat initialization - only includes required fields."""
user_id: Optional[UUID4] = None
chat_id: Optional[UUID4] = None
workspace_in_context: bool = False
workspace_id: UUID4 | None = None
project_id: UUID4 | None = None
is_project_chat: Optional[bool] = False
workspace_slug: Optional[str] = None
class TitleRequest(BaseModel):
chat_id: UUID4
workspace_id: Optional[UUID4] = None
workspace_slug: Optional[str] = None
class DeleteChatRequest(BaseModel):
chat_id: UUID4
workspace_id: Optional[UUID4] = None
workspace_slug: Optional[str] = None
class GetThreads(BaseModel):
user_id: Optional[UUID4] = None
workspace_id: Optional[UUID4] = None
workspace_slug: Optional[str] = None
is_project_chat: Optional[bool] = False
class ChatType(Enum):
THREADS = "threads"
ISSUES = "issues"
PROJECTS = "projects"
PAGES = "pages"
MODULES = "modules"
CYCLES = "cycles"
class ChatSuggestion(BaseModel):
text: str | None = None
type: ChatType
id: list[UUID4]
class ChatSuggestionTemplate(BaseModel):
templates: list[ChatSuggestion]
class ChatStartResponse(BaseModel):
placeholder: str
class FeedbackType(Enum):
POSITIVE = "positive"
NEGATIVE = "negative"
class ChatFeedback(BaseModel):
chat_id: UUID4
message_index: int
feedback: FeedbackType
feedback_message: Optional[str] = None
workspace_id: Optional[UUID4] = None
workspace_slug: Optional[str] = None
class ModelInfo(BaseModel):
id: str
name: str
description: str
type: str = "language_model"
is_default: bool
class ModelsResponse(BaseModel):
models: list[ModelInfo]
class FavoriteChatRequest(BaseModel):
chat_id: UUID4
workspace_id: Optional[UUID4] = None
workspace_slug: Optional[str] = None
class UnfavoriteChatRequest(BaseModel):
chat_id: UUID4
workspace_id: Optional[UUID4] = None
workspace_slug: Optional[str] = None
class RenameChatRequest(BaseModel):
chat_id: UUID4
title: str
workspace_id: Optional[UUID4] = None
workspace_slug: Optional[str] = None
class ActionExecutionRequest(BaseModel):
"""Request schema for executing planned actions."""
workspace_id: UUID4
chat_id: UUID4
message_id: UUID4
action_data: Dict[str, Any]
class ActionBatchExecutionRequest(BaseModel):
"""Request schema for executing all planned actions in a message as a batch."""
workspace_id: UUID4
chat_id: UUID4
message_id: UUID4
action_data: Optional[Dict[str, Any]] = None
execution_strategy: Optional[str] = "sequential" # sequential, parallel (future)
rollback_on_failure: Optional[bool] = False
# Search schemas
class ChatSearchRequest(BaseModel):
"""Request schema for chat search."""
query: str = Field(..., description="Search query text")
workspace_id: UUID4 = Field(..., description="Workspace ID to filter by")
is_project_chat: Optional[bool] = Field(False, description="Filter by project chat flag")
cursor: Optional[str] = Field(None, description="Cursor for pagination")
per_page: int = Field(30, ge=1, le=100, description="Number of results per page")
class ChatSearchPagination(BaseModel):
"""Clean pagination response for chat search."""
next_cursor: Optional[str] = Field(None, description="Cursor for next page - null if no more pages")
count: int = Field(description="Number of items in current page")
class ChatSearchResult(BaseModel):
"""Individual chat search result."""
id: UUID4 = Field(..., description="Chat ID")
title: Optional[str] = Field(None, description="Chat title")
snippet: str = Field(..., description="Text snippet with highlighted query terms")
match_type: str = Field(..., description="Type of match: 'title' or 'message'")
message_id: Optional[UUID4] = Field(None, description="Message ID if match is from message content")
created_at: Optional[str] = Field(None, description="Creation timestamp")
updated_at: Optional[str] = Field(None, description="Last update timestamp")
workspace_id: Optional[UUID4] = Field(None, description="Workspace ID")
class ChatSearchResponse(ChatSearchPagination):
"""Clean paginated response for chat search."""
results: List[ChatSearchResult] = Field(default_factory=list, description="Search results")
# Paginated response schemas
class GetThreadsPaginatedResponse(PaginationResponse):
"""Paginated response for user chat threads."""
results: list[dict[str, Any]]
+29
View File
@@ -0,0 +1,29 @@
from typing import List
from pydantic import UUID4
from pydantic import BaseModel
from pydantic import Field
class DupeSearchRequest(BaseModel):
title: str
description_stripped: str | None = None
issue_id: UUID4 | None = None
user_id: UUID4 | None = None
project_id: UUID4 | None = None
workspace_id: UUID4
workspace_slug: str | None = None
class NotDuplicateRequest(BaseModel):
issue_id: UUID4
not_duplicates_with: list[UUID4]
class DuplicateIdentificationResponse(BaseModel):
"""Structured output for LLM duplicate identification"""
duplicates: List[int] = Field(
description="List of serial numbers (1, 2, 3, etc.) of candidate issues that are duplicates of the query issue. Only include numbers of issues that are clearly duplicates.", # noqa: E501
default_factory=list,
)

Some files were not shown because too many files have changed in this diff Show More