[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:
co-authored by
akshat5302
sriramveeraghanta
parent
6d955ef29e
commit
74fe1af85f
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.db
|
||||
.env
|
||||
.git
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Plane Intelligence
|
||||
Executable
+15
@@ -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
|
||||
Executable
+17
@@ -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
|
||||
Executable
+13
@@ -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
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Starting Plane PI Migrator..."
|
||||
|
||||
python -m pi.manage bootstrap-db
|
||||
Executable
+14
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
from .config import logger
|
||||
from .config import settings
|
||||
|
||||
__all__ = ["logger", "settings"]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .base import text2sql
|
||||
|
||||
__all__ = ["text2sql"]
|
||||
@@ -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})
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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...
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"})
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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}")
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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"])
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
# from .auth import session_validation_middleware
|
||||
|
||||
# __all__ = ["session_validation_middleware"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"})
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]]
|
||||
@@ -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
Reference in New Issue
Block a user