├── .coveragerc ├── .editorconfig ├── .env.example ├── .env.production.example ├── .flake8 ├── .github └── workflows │ ├── pr-ui.yml │ └── pr.yml ├── .gitignore ├── .python-version ├── BUILD ├── LICENSE ├── Makefile ├── README.md ├── alembic.ini ├── assets ├── PersonaFlowIcon-512.png └── screenshot.png ├── constraints.txt ├── contrib ├── postgresql │ ├── BUILD │ ├── Dockerfile │ └── setup-internal.sql └── qdrant │ ├── config.yaml │ ├── development.yaml │ └── production.yaml ├── docker-compose.dev.yaml ├── docker-compose.yaml ├── docs ├── assistants.md ├── auth_guide.md ├── rag.md └── troubleshooting.md ├── migrations ├── BUILD ├── README ├── env.py ├── script.py.mako └── versions │ ├── 1091347b4ab9_alter_checkpoints_table.py │ ├── 42cb7889f092_init.py │ ├── BUILD │ ├── af0a5f3e2816_auth.py │ ├── dc27d3e10363_file_tracking.py │ ├── e1338e81c83d_add_relationships.py │ ├── fa2c17415189_add_checkpoints_table.py │ └── fd264e716c5d_remove_checkpoints_table.py ├── mypy.ini ├── pants.toml ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── scripts ├── db_management.sh └── setup_default_user.py ├── stack ├── .dockerignore ├── .flake8 ├── BUILD ├── Dockerfile ├── __init__.py ├── app │ ├── BUILD │ ├── __init__.py │ ├── agents │ │ ├── BUILD │ │ ├── __init__.py │ │ ├── chatbot_executor.py │ │ ├── configurable_agent.py │ │ ├── configurable_chatbot.py │ │ ├── configurable_crag.py │ │ ├── configurable_retrieval.py │ │ ├── llm.py │ │ ├── prompts.py │ │ ├── retrieval_executor.py │ │ ├── tools.py │ │ ├── tools_agent_executor.py │ │ └── xml_agent.py │ ├── api │ │ ├── BUILD │ │ ├── __init__.py │ │ └── v1 │ │ │ ├── BUILD │ │ │ ├── __init__.py │ │ │ ├── admin_users.py │ │ │ ├── assistants.py │ │ │ ├── auth.py │ │ │ ├── files.py │ │ │ ├── rag.py │ │ │ ├── runs.py │ │ │ ├── threads.py │ │ │ └── users.py │ ├── app_factory.py │ ├── cache │ │ ├── BUILD │ │ ├── __init__.py │ │ └── cache.py │ ├── chains │ │ ├── BUILD │ │ ├── __init__.py │ │ └── universal_retrieval_chain.py │ ├── core │ │ ├── BUILD │ │ ├── __init__.py │ │ ├── auth │ │ │ ├── BUILD │ │ │ ├── __init__.py │ │ │ ├── auth_config.py │ │ │ ├── jwt.py │ │ │ ├── request_validators.py │ │ │ ├── strategies │ │ │ │ ├── BUILD │ │ │ │ ├── __init__.py │ │ │ │ ├── base.py │ │ │ │ ├── basic.py │ │ │ │ ├── google_oauth.py │ │ │ │ ├── oidc.py │ │ │ │ └── settings.py │ │ │ └── utils.py │ │ ├── configuration.py │ │ ├── datastore.py │ │ ├── exception.py │ │ ├── logger.py │ │ ├── redis.py │ │ ├── retriever.py │ │ └── struct_logger.py │ ├── main.py │ ├── middlewares │ │ ├── BUILD │ │ ├── __init__.py │ │ ├── request_logger.py │ │ └── system_logger.py │ ├── model │ │ ├── BUILD │ │ ├── __init__.py │ │ ├── assistant.py │ │ ├── base.py │ │ ├── blacklist.py │ │ ├── file.py │ │ ├── message.py │ │ ├── request_log.py │ │ ├── thread.py │ │ ├── user.py │ │ └── util.py │ ├── rag │ │ ├── BUILD │ │ ├── __init__.py │ │ ├── custom_retriever.py │ │ ├── embedding_service.py │ │ ├── encoders │ │ │ ├── BUILD │ │ │ ├── __init__.py │ │ │ └── ollama_encoder.py │ │ ├── ingest.py │ │ ├── query.py │ │ ├── splitter.py │ │ ├── summarizer.py │ │ ├── table_parser.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ └── unit │ │ │ │ ├── BUILD │ │ │ │ └── test_util.py │ │ └── util.py │ ├── repositories │ │ ├── BUILD │ │ ├── __init__.py │ │ ├── assistant.py │ │ ├── base.py │ │ ├── blacklist.py │ │ ├── file.py │ │ ├── message.py │ │ ├── thread.py │ │ └── user.py │ ├── schema │ │ ├── BUILD │ │ ├── __init__.py │ │ ├── assistant.py │ │ ├── auth.py │ │ ├── feedback.py │ │ ├── file.py │ │ ├── message.py │ │ ├── message_types.py │ │ ├── rag.py │ │ ├── thread.py │ │ ├── title.py │ │ └── user.py │ ├── start.py │ ├── utils │ │ ├── BUILD │ │ ├── __init__.py │ │ ├── exceptions.py │ │ ├── file_helpers.py │ │ ├── format_docs.py │ │ ├── group_threads.py │ │ ├── helpers.py │ │ ├── stream.py │ │ └── tests │ │ │ ├── __init__.py │ │ │ └── unit │ │ │ ├── BUILD │ │ │ └── test_format_docs.py │ └── vectordbs │ │ ├── BUILD │ │ ├── __init__.py │ │ ├── base.py │ │ └── qdrant.py ├── dev.sh └── tests │ ├── __init__.py │ ├── integration │ ├── PersonaFlow.postman_collection.json │ └── openapi.json │ └── unit │ ├── BUILD │ ├── __init__.py │ ├── conftest.py │ ├── test_app.py │ ├── test_app_admin_users.py │ ├── test_app_assistants.py │ ├── test_app_auth.py │ ├── test_app_files.py │ ├── test_app_runs.py │ ├── test_app_threads.py │ ├── test_app_users.py │ ├── test_authorization_header.py │ ├── test_basic_auth.py │ └── test_format_docs.py └── ui ├── .env.local.example ├── .eslintrc.json ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── assets └── PersonaFlowIcon-512.png ├── components.json ├── jest.config.ts ├── jest.setup.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── src ├── app │ ├── [[...slug]] │ │ └── page.tsx │ ├── icon.ico │ ├── layout.tsx │ └── not-found.tsx ├── components │ ├── features │ │ ├── build-panel │ │ │ └── components │ │ │ │ ├── __tests__ │ │ │ │ ├── assistant-builder.spec.tsx │ │ │ │ └── assistant-form.spec.tsx │ │ │ │ ├── assistant-builder.tsx │ │ │ │ ├── assistant-form.tsx │ │ │ │ ├── assistant-selector.tsx │ │ │ │ ├── build-panel.tsx │ │ │ │ ├── create-assistant.tsx │ │ │ │ ├── edit-assistant.tsx │ │ │ │ ├── file-builder.tsx │ │ │ │ ├── files-dialog.tsx │ │ │ │ ├── form-select.tsx │ │ │ │ ├── public-switch.tsx │ │ │ │ ├── retrieval-description.tsx │ │ │ │ ├── select-actions.tsx │ │ │ │ ├── select-capabilities.tsx │ │ │ │ ├── select-files.tsx │ │ │ │ ├── select-options.tsx │ │ │ │ ├── select-tools.tsx │ │ │ │ ├── system-prompt.tsx │ │ │ │ └── tool-dialog.tsx │ │ ├── chat-panel │ │ │ ├── chat-panel.tsx │ │ │ └── components │ │ │ │ ├── composer.tsx │ │ │ │ ├── message-item.tsx │ │ │ │ └── messages-container.tsx │ │ ├── header │ │ │ └── components │ │ │ │ └── header.tsx │ │ ├── markdown │ │ │ └── markdown.tsx │ │ ├── navbar │ │ │ └── components │ │ │ │ ├── navbar.tsx │ │ │ │ ├── new-thread-btn.tsx │ │ │ │ └── thread-item.tsx │ │ └── tools │ │ │ ├── tool-container.tsx │ │ │ ├── tool-query.tsx │ │ │ └── tool-result.tsx │ └── ui │ │ ├── accordion.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── multiselect.tsx │ │ ├── select.tsx │ │ ├── spinner.tsx │ │ ├── switch.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts ├── data-provider │ ├── data-service.ts │ ├── endpoints.ts │ ├── query-provider.ts │ ├── query-service.ts │ ├── requests.ts │ └── types.ts ├── hooks │ ├── useAvailableTools.ts │ ├── useChat.ts │ ├── useConfig.ts │ ├── useFileStream.ts │ ├── useSlugParams.ts │ └── useStream.ts ├── mockFiles.ts ├── providers │ └── Providers.tsx ├── store.ts ├── styles │ └── main.css └── utils │ ├── routeUtils.ts │ └── utils.ts ├── tailwind.config.ts └── tsconfig.json /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = */tests/* 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | 11 | [*.{py,pyi}] 12 | indent_size = 4 13 | 14 | [Makefile] 15 | indent_style = tab 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore: 3 | # whitespace before ':' (conflicts with Black) 4 | E203, 5 | # Bad trailing comma (conflicts with Black) 6 | E231, 7 | # line too long (conflicts with Black) 8 | E501, 9 | 10 | max-complexity = 10 11 | statistics = True 12 | -------------------------------------------------------------------------------- /.github/workflows/pr-ui.yml: -------------------------------------------------------------------------------- 1 | name: UI PR Check 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | paths: 7 | - "ui/**" 8 | - ".github/workflows/pr-ui.yml" 9 | pull_request: 10 | branches: ["main"] 11 | paths: 12 | - "ui/**" 13 | - ".github/workflows/pr-ui.yml" 14 | jobs: 15 | build: 16 | name: Build and Test UI 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | node-version: [20.x] 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | with: 25 | sparse-checkout: | 26 | .github 27 | ui 28 | - name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v3 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | cache: "npm" 33 | cache-dependency-path: ui/package-lock.json 34 | 35 | - name: Cache build 36 | uses: actions/cache@v4 37 | with: 38 | path: | 39 | ~/.npm 40 | ${{ github.workspace }}/.next/cache 41 | # Generate a new cache whenever packages or source files change. 42 | key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} 43 | # If source files changed but packages didn't, rebuild from a prior cache. 44 | restore-keys: | 45 | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- 46 | 47 | - name: Build and Test UI 48 | working-directory: ui 49 | run: | 50 | pwd 51 | npm ci 52 | npm run format:check 53 | npm run build 54 | npm run test:ci 55 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR Check 2 | 3 | #on: workflow_dispatch 4 | on: 5 | pull_request: 6 | push: 7 | branches: 8 | - main # or whatever your default branch is named 9 | paths-ignore: 10 | - 'ui/**' 11 | - '.github/workflows/pr-ui.yml' 12 | 13 | jobs: 14 | pr-check: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ["3.11"] 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Pants init 26 | uses: pantsbuild/actions/init-pants@main 27 | with: 28 | # cache0 makes it easy to bust the cache if needed 29 | gha-cache-key: cache0-py-${{ github.head_ref || github.ref_name }}-${{ matrix.python_version }} 30 | named-caches-hash: ${{ hashFiles('constraints.txt', 'poetry.lock') }} 31 | - name: Pants lint 32 | if: '!cancelled()' 33 | run: | 34 | pants lint :: 35 | # - name: Pants check 36 | # if: '!cancelled()' 37 | # run: | 38 | # pants check :: 39 | - name: Pants test 40 | if: '!cancelled()' 41 | run: | 42 | pants test :: -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11.9 2 | -------------------------------------------------------------------------------- /BUILD: -------------------------------------------------------------------------------- 1 | poetry_requirements(name="reqs") 2 | 3 | python_tests( 4 | name="tests", 5 | dependencies=["//:test-reqs"] 6 | ) 7 | 8 | target( 9 | name="test-reqs", 10 | dependencies=[ 11 | "//:reqs#asyncpg", 12 | "//:reqs#python-multipart", 13 | "//:reqs#pytest-asyncio", 14 | "//:reqs#itsdangerous", 15 | "//:reqs#langgraph-checkpoint-postgres" 16 | ] 17 | ) 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 PersonaFlow 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile 2 | 3 | # Define variables 4 | PYTHON := python 5 | ALEMBIC := alembic 6 | SERVER_DIR := stack 7 | 8 | # Target: migrate 9 | migrate: 10 | @echo "Initializing the project..." 11 | $(ALEMBIC) upgrade head 12 | PYTHONPATH=$(SERVER_DIR) $(PYTHON) scripts/setup_default_user.py 13 | @echo "Initialization complete." 14 | 15 | # Target: stack-dev 16 | stack-dev: 17 | @echo "Starting the development server..." 18 | uvicorn stack.app.main:app --reload --port 9000 19 | 20 | # Target create_db_snapshot 21 | create_db_snapshot: 22 | @echo "Creating a clone of the database..." 23 | bash ./scripts/db_management.sh create_db_snapshot internal personaflow 24 | 25 | restore_db_snapshot: 26 | @echo "Restoring the database from the snapshot..." 27 | bash ./scripts/db_management.sh restore_db_snapshot internal backup.sql 28 | 29 | # Target: help 30 | help: 31 | @echo "Available commands:" 32 | @echo " make migrate - Initialize the project (run migrations and insert default user)" 33 | @echo " make help - Show this help message" 34 | @echo " make stack-dev - Start the server with auto-reload" 35 | @echo " make create_db_snapshot - Create a snapshot of the database schema" 36 | @echo " make restore_db_snapshot - Restore the database from the snapshot" 37 | 38 | .PHONY: migrate help stack-dev 39 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # alembic.ini 2 | # ------------------ 3 | # 4 | # Alembic configuration file to manage database schema migrations. 5 | # This file contains settings related to the location of migration scripts, 6 | # database URL, and logging configuration. 7 | # 8 | # ----------------------------------------------------------------------------------- 9 | 10 | # Core alembic configurations. 11 | [alembic] 12 | # Directory where migration scripts are located. 13 | script_location = migrations 14 | # A list of Python paths to prepend to sys.path. 15 | prepend_sys_path = . 16 | # Database URL for Alembic to connect to. 17 | # WARNING: The provided URL lacks authentication details, consider updating it. 18 | sqlalchemy.url = postgresql://:@:/ 19 | 20 | # Logging configurations. 21 | [loggers] 22 | # Define the names of loggers, handlers, and formatters. 23 | keys = root,sqlalchemy,alembic 24 | 25 | [handlers] 26 | keys = console 27 | 28 | [formatters] 29 | keys = generic 30 | 31 | # Logger configurations. 32 | # 'root' logger configuration. 33 | [logger_root] 34 | level = WARN 35 | handlers = console 36 | qualname = 37 | 38 | # 'sqlalchemy' logger configuration for logging SQL queries. 39 | [logger_sqlalchemy] 40 | level = WARN 41 | handlers = 42 | qualname = sqlalchemy.engine 43 | 44 | # 'alembic' logger configuration specific to Alembic operations. 45 | [logger_alembic] 46 | level = INFO 47 | handlers = 48 | qualname = alembic 49 | 50 | # Console handler: Defines how logs are handled on the console. 51 | [handler_console] 52 | class = StreamHandler 53 | args = (sys.stderr,) 54 | level = NOTSET 55 | formatter = generic 56 | 57 | # Formatter: Specifies the format for displaying logs. 58 | [formatter_generic] 59 | format = %(levelname)-5.5s [%(name)s] %(message)s 60 | datefmt = %H:%M:%S 61 | -------------------------------------------------------------------------------- /assets/PersonaFlowIcon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/assets/PersonaFlowIcon-512.png -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/assets/screenshot.png -------------------------------------------------------------------------------- /contrib/postgresql/BUILD: -------------------------------------------------------------------------------- 1 | docker_image( 2 | name="docker", 3 | ) 4 | -------------------------------------------------------------------------------- /contrib/postgresql/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:15.5 2 | 3 | COPY setup-internal.sql /docker-entrypoint-initdb.d/001-setup-internal.sql 4 | -------------------------------------------------------------------------------- /contrib/postgresql/setup-internal.sql: -------------------------------------------------------------------------------- 1 | /* 2 | contrib/setup-internal.sql 3 | -------------------------- 4 | 5 | This SQL script is responsible for the initial setup of the `internal` user and the related `internal` database in a PostgreSQL environment. 6 | */ 7 | 8 | \c postgres 9 | CREATE USER internal WITH PASSWORD 'internal'; 10 | CREATE DATABASE internal; 11 | -- GRANT ALL PRIVILEGES ON DATABASE internal TO internal; 12 | ALTER USER internal WITH SUPERUSER; 13 | 14 | \c internal; 15 | CREATE SCHEMA personaflow; 16 | ALTER SCHEMA personaflow OWNER TO internal; 17 | 18 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 19 | -------------------------------------------------------------------------------- /contrib/qdrant/development.yaml: -------------------------------------------------------------------------------- 1 | log_level: DEBUG 2 | 3 | service: 4 | host: 127.0.0.1 5 | http_port: 6333 6 | # Uncomment to enable gRPC: 7 | #grpc_port: 6334 8 | #api_key: your_secret_api_key_here 9 | 10 | 11 | storage: 12 | performance: 13 | # Number of parallel threads used for search operations. If 0 - auto selection. 14 | max_search_threads: 4 15 | 16 | optimizers: 17 | # Minimum interval between forced flushes. 18 | flush_interval_sec: 5 19 | 20 | # Do not create too much segments in dev 21 | default_segment_number: 2 22 | 23 | handle_collection_load_errors: true 24 | -------------------------------------------------------------------------------- /contrib/qdrant/production.yaml: -------------------------------------------------------------------------------- 1 | log_level: INFO 2 | 3 | service: 4 | host: 0.0.0.0 5 | http_port: 6333 6 | # Uncomment to enable gRPC: 7 | #grpc_port: 6334 8 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting (WIP) 2 | 3 | **Problem** 4 | When I try to run `docker-compose up` I get the error: "configs.qdrant_config Additional property content is not allowed" 5 | 6 | **Solution** 7 | Proving the inline content in the configs top-level element requires Docker Compose v2.23.1 or above. This functionality is supported starting Docker Engine v25.0.0 and Docker Desktop v4.26.0 onwards. 8 | 9 |
10 | 11 | **Problem** 12 | `black` fails the CI pipeline on the `pants lint` step of my PR. 13 | 14 | **Solution** 15 | Run `pants fmt ::` and commit the formatting changes and it should pass. 16 | 17 |
18 | -------------------------------------------------------------------------------- /migrations/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """ 2 | migrations/script.py.mako 3 | ---------------------------- 4 | 5 | 6 | Alembic Migration Script Template 7 | 8 | This script serves as a template for Alembic migrations. When Alembic auto-generates migration scripts, it uses 9 | this template to structure the content. The generated script then gets saved in the 'versions' directory under 'migrations'. 10 | Each script defines actions to be taken for both 'upgrade' (applying the migration) and 'downgrade' (reverting the migration). 11 | 12 | """ 13 | 14 | """${message} 15 | 16 | Revision ID: ${up_revision} 17 | Revises: ${down_revision | comma,n} 18 | Create Date: ${create_date} 19 | 20 | """ 21 | from alembic import op 22 | import sqlalchemy as sa 23 | ${imports if imports else ""} # Import additional modules dynamically based on the migration's needs. 24 | 25 | # Revision identifiers, used by Alembic to identify the migration script. 26 | revision = ${repr(up_revision)} # The ID of this revision (migration). 27 | down_revision = ${repr(down_revision)} # The ID of the previous revision. Specifies ordering of migrations. 28 | branch_labels = ${repr(branch_labels)} # Labels that can group revisions together. 29 | depends_on = ${repr(depends_on)} # If this revision depends on another one, it is referenced here. 30 | 31 | # Function to handle the 'upgrade' operation. 32 | # This contains the operations to be performed when moving to this version from the previous version. 33 | def upgrade() -> None: 34 | ${upgrades if upgrades else "pass"} # Operations to upgrade the database schema. 35 | 36 | # Function to handle the 'downgrade' operation. 37 | # This contains the operations to revert the changes introduced in the 'upgrade' function. 38 | def downgrade() -> None: 39 | ${downgrades if downgrades else "pass"} # Operations to downgrade the database schema. 40 | -------------------------------------------------------------------------------- /migrations/versions/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /migrations/versions/fa2c17415189_add_checkpoints_table.py: -------------------------------------------------------------------------------- 1 | """ 2 | migrations/script.py.mako 3 | ---------------------------- 4 | 5 | 6 | Alembic Migration Script Template 7 | 8 | This script serves as a template for Alembic migrations. When Alembic auto-generates migration scripts, it uses 9 | this template to structure the content. The generated script then gets saved in the 'versions' directory under 'migrations'. 10 | Each script defines actions to be taken for both 'upgrade' (applying the migration) and 'downgrade' (reverting the migration). 11 | 12 | """ 13 | 14 | """add checkpoints table 15 | 16 | Revision ID: fa2c17415189 17 | Revises: 42cb7889f092 18 | Create Date: 2024-03-09 16:14:13.358092 19 | 20 | """ 21 | from alembic import op 22 | import sqlalchemy as sa 23 | from sqlalchemy.dialects import ( 24 | postgresql, 25 | ) # Import additional modules dynamically based on the migration's needs. 26 | 27 | # Revision identifiers, used by Alembic to identify the migration script. 28 | revision = "fa2c17415189" # The ID of this revision (migration). 29 | down_revision = ( 30 | "42cb7889f092" # The ID of the previous revision. Specifies ordering of migrations. 31 | ) 32 | branch_labels = None # Labels that can group revisions together. 33 | depends_on = None # If this revision depends on another one, it is referenced here. 34 | 35 | 36 | # Function to handle the 'upgrade' operation. 37 | # This contains the operations to be performed when moving to this version from the previous version. 38 | def upgrade() -> None: 39 | # ### commands auto generated by Alembic - please adjust! ### 40 | op.create_table( 41 | "checkpoints", 42 | sa.Column( 43 | "id", 44 | sa.UUID(), 45 | server_default=sa.text("gen_random_uuid()"), 46 | nullable=False, 47 | comment="A unique identifier for the checkpoint. It's a UUID type and is automatically generated by the database.", 48 | ), 49 | sa.Column( 50 | "user_id", 51 | sa.String(), 52 | nullable=False, 53 | comment="The ID of the user to whom the checkpoint belongs.", 54 | ), 55 | sa.Column( 56 | "thread_id", 57 | sa.String(), 58 | nullable=False, 59 | comment="The ID of the thread to which the checkpoint belongs.", 60 | ), 61 | sa.Column( 62 | "checkpoint", 63 | sa.LargeBinary(), 64 | nullable=False, 65 | comment="The serialized checkpoint data.", 66 | ), 67 | sa.Column( 68 | "created_at", 69 | sa.DateTime(timezone=True), 70 | nullable=False, 71 | comment="The timestamp when the checkpoint was created.", 72 | ), 73 | sa.Column( 74 | "updated_at", 75 | sa.DateTime(timezone=True), 76 | nullable=False, 77 | comment="The timestamp when the checkpoint was last updated.", 78 | ), 79 | sa.PrimaryKeyConstraint("id"), 80 | schema="personaflow", 81 | ) 82 | # ### end Alembic commands ### # Operations to upgrade the database schema. 83 | 84 | 85 | # Function to handle the 'downgrade' operation. 86 | # This contains the operations to revert the changes introduced in the 'upgrade' function. 87 | def downgrade() -> None: 88 | # ### commands auto generated by Alembic - please adjust! ### 89 | op.drop_table("checkpoints", schema="personaflow") 90 | # ### end Alembic commands ### # Operations to downgrade the database schema. 91 | -------------------------------------------------------------------------------- /migrations/versions/fd264e716c5d_remove_checkpoints_table.py: -------------------------------------------------------------------------------- 1 | """ 2 | migrations/script.py.mako 3 | ---------------------------- 4 | 5 | 6 | Alembic Migration Script Template 7 | 8 | This script serves as a template for Alembic migrations. When Alembic auto-generates migration scripts, it uses 9 | this template to structure the content. The generated script then gets saved in the 'versions' directory under 'migrations'. 10 | Each script defines actions to be taken for both 'upgrade' (applying the migration) and 'downgrade' (reverting the migration). 11 | 12 | """ 13 | 14 | """remove_checkpoints_table 15 | 16 | Revision ID: fd264e716c5d 17 | Revises: af0a5f3e2816 18 | Create Date: 2024-10-28 16:11:58.088012 19 | 20 | """ 21 | from alembic import op 22 | import sqlalchemy as sa 23 | from sqlalchemy.dialects import ( 24 | postgresql, 25 | ) # Import additional modules dynamically based on the migration's needs. 26 | 27 | # Revision identifiers, used by Alembic to identify the migration script. 28 | revision = "fd264e716c5d" # The ID of this revision (migration). 29 | down_revision = ( 30 | "af0a5f3e2816" # The ID of the previous revision. Specifies ordering of migrations. 31 | ) 32 | branch_labels = None # Labels that can group revisions together. 33 | depends_on = None # If this revision depends on another one, it is referenced here. 34 | 35 | 36 | # Function to handle the 'upgrade' operation. 37 | # This contains the operations to be performed when moving to this version from the previous version. 38 | def upgrade() -> None: 39 | op.execute('DROP TABLE IF EXISTS "personaflow".checkpoints') 40 | 41 | 42 | # Function to handle the 'downgrade' operation. 43 | # This contains the operations to revert the changes introduced in the 'upgrade' function. 44 | def downgrade() -> None: 45 | pass 46 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | warn_return_any = True 3 | warn_unused_configs = True 4 | -------------------------------------------------------------------------------- /pants.toml: -------------------------------------------------------------------------------- 1 | [GLOBAL] 2 | pants_version = "2.20.0" 3 | backend_packages = [ 4 | "pants.backend.shell", 5 | "pants.backend.shell.lint.shfmt", 6 | "pants.backend.shell.lint.shellcheck", 7 | 8 | "pants.backend.docker", 9 | # "pants.backend.docker.lint.hadolint", 10 | 11 | "pants.backend.python", 12 | "pants.backend.python.lint.docformatter", 13 | "pants.backend.python.lint.black", 14 | # "pants.backend.python.lint.flake8", 15 | # "pants.backend.python.lint.autoflake", 16 | "pants.backend.python.typecheck.mypy", 17 | ] 18 | 19 | [source] 20 | root_patterns = ["/"] 21 | 22 | [test] 23 | extra_env_vars = [ 24 | 'OPENAI_API_KEY=asdf' 25 | ] 26 | 27 | [pytest] 28 | args = ["--disable-warnings", "-vv"] 29 | 30 | [python] 31 | # The default interpreter constraints for code in this repo. Individual targets can override 32 | # this with the `interpreter_constraints` field. See 33 | # https://www.pantsbuild.org/docs/python-interpreter-compatibility. 34 | 35 | # Modify this if you don't have Python 3.9 on your machine. 36 | # This can be a range, such as [">=3.8,<3.11"], but it's usually recommended to restrict 37 | # to a single minor version. 38 | interpreter_constraints = ["CPython>=3.11,<3.12"] 39 | 40 | # Enable the "resolves" mechanism, which turns on lockfiles for user code. See 41 | # https://www.pantsbuild.org/docs/python-third-party-dependencies. This also adds the 42 | # `generate-lockfiles` goal for Pants to generate the lockfile for you. 43 | enable_resolves = true 44 | 45 | resolves = { python-default = "constraints.txt"} 46 | 47 | [python-bootstrap] 48 | # We search for interpreters both on the $PATH and in the `$(pyenv root)/versions` folder. 49 | # If you're using macOS, you may want to leave off the entry to avoid using the 50 | # problematic system Pythons. See 51 | # https://www.pantsbuild.org/docs/python-interpreter-compatibility#changing-the-interpreter-search-path. 52 | search_path = ["", ""] 53 | 54 | [docker] 55 | build_verbose = true 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "personaflow" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["PersonaFlow"] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = ">=3.11.0,<3.12" 10 | openai = "^1.7.2" 11 | fastapi = "^0.103.2" 12 | pydantic = "^2.0.1" 13 | uvicorn = "^0.23.2" 14 | asyncio = "^3.4.3" 15 | python-dotenv = "^1.0.0" 16 | jq = "^1.6.0" 17 | loguru = "^0.7.2" 18 | pyjwt = "^2.8.0" 19 | pypdf = "^4.0.1" 20 | redis = "^5.0.1" 21 | langsmith = "^0.1.31" 22 | langchain-core = "0.2.38" 23 | langchain-community = "0.2.16" 24 | langchain = "0.2.16" 25 | orjson = "^3.9.13" 26 | langgraph = "0.2.19" 27 | sse-starlette = "^2.0.0" 28 | langchain-anthropic = "0.1.23" 29 | langchain-google-vertexai = "1.0.10" 30 | fireworks-ai = "^0.11.2" 31 | cohere = ">=4.32,<5.0" 32 | urllib3 = "1.26.18" 33 | boto3 = "^1.34.105" 34 | 35 | semantic-router = { extras = ["processing"], version = ">=0.0.34" } 36 | tiktoken = "0.7" 37 | 38 | alembic = "^1.11.1" 39 | sqlalchemy = { extras = ["asyncio"], version = "^2.0.27" } 40 | asyncpg = "^0.28.0" 41 | structlog = "^23.1.0" 42 | rich = "^13.5.2" 43 | colorama = "^0.4.6" 44 | pydantic-settings = "^2.0.2" 45 | asgi-correlation-id = "^4.2.0" 46 | python-multipart = "^0.0.9" 47 | 48 | duckduckgo-search = "^4.4" 49 | wikipedia = "^1.4.0" 50 | arxiv = "^2.1.0" 51 | xmltodict = "^0.13.0" 52 | python-magic = "^0.4.27" 53 | beautifulsoup4 = "^4.12.3" 54 | pdfminer-six = "^20221105" 55 | psycopg2-binary = "^2.9.9" 56 | qdrant-client = "^1.8.0" 57 | azure-monitor-opentelemetry = "^1.3.0" 58 | dataclasses-json = "^0.6.4" 59 | unstructured-client = "^0.22.0" 60 | langchain-robocorp = "0.0.10" 61 | langchain-openai = "0.1.23" 62 | pytest = "^8.2.2" 63 | factory-boy = "^3.3.0" 64 | httpx = "0.27.0" 65 | pytest-asyncio = "^0.23.7" 66 | pytest-mock = "^3.14.0" 67 | pytest-socket = "^0.6.0" 68 | pytest-cov = "^5.0.0" 69 | langfuse = "^2.36.1" 70 | arize-phoenix = {extras = ["evals"], version = "^4.4.3"} 71 | bcrypt = "^4.1.3" 72 | itsdangerous = "^2.2.0" 73 | freezegun = "^1.5.1" 74 | Authlib = "^1.3.1" 75 | langgraph-checkpoint-postgres = "1.0.7" 76 | 77 | [build-system] 78 | requires = ["poetry-core"] 79 | build-backend = "poetry.core.masonry.api" 80 | 81 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = auto 3 | -------------------------------------------------------------------------------- /scripts/db_management.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Create a development database clone 4 | create_db_clone() { 5 | local SOURCE_DB="internal" 6 | local DEV_DB="pf_internal_dev" 7 | 8 | # Stop connections to source database 9 | psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$SOURCE_DB';" 10 | 11 | # Create new database as a clone 12 | createdb -T "$SOURCE_DB" "$DEV_DB" 13 | 14 | echo "Created development database: $DEV_DB" 15 | } 16 | 17 | # Create a database schema snapshot 18 | create_db_snapshot() { 19 | local DB_NAME=$1 20 | local SCHEMA_NAME=$2 21 | local TIMESTAMP=$(date +%Y%m%d_%H%M%S) 22 | local BACKUP_FILE="personaflow_db_snapshot_${TIMESTAMP}.sql" 23 | 24 | # Dump only the specified schema 25 | pg_dump -d "$DB_NAME" -n "$SCHEMA_NAME" -f "$BACKUP_FILE" 26 | 27 | echo "Created database snapshot: $BACKUP_FILE" 28 | } 29 | 30 | # Restore database from snapshot 31 | restore_db_snapshot() { 32 | local DB_NAME=$1 33 | local BACKUP_FILE=$2 34 | 35 | # Restore the database from snapshot 36 | psql -d "$DB_NAME" -f "$BACKUP_FILE" 37 | 38 | echo "Restored database from snapshot: $BACKUP_FILE" 39 | } 40 | 41 | # Usage examples: 42 | # ./db_management.sh create_clone 43 | # ./db_management.sh create_snapshot internal personaflow 44 | # ./db_management.sh restore_snapshot internal backup_file.sql -------------------------------------------------------------------------------- /scripts/setup_default_user.py: -------------------------------------------------------------------------------- 1 | # scripts/setup_default_user.py 2 | 3 | import asyncio 4 | import os 5 | import sys 6 | 7 | # Add the 'stack' directory to the Python path 8 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 9 | 10 | # Import all models to ensure they're registered with SQLAlchemy 11 | from stack.app.model.user import User 12 | from stack.app.model.thread import Thread 13 | from stack.app.model.assistant import Assistant 14 | from stack.app.model.file import File 15 | from stack.app.model.message import Message 16 | 17 | from stack.app.core.configuration import settings 18 | from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession 19 | from sqlalchemy.orm import sessionmaker 20 | from sqlalchemy import select 21 | 22 | async def insert_default_user(): 23 | engine = create_async_engine(settings.INTERNAL_DATABASE_URI) 24 | async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) 25 | 26 | default_user_id = settings.DEFAULT_USER_ID 27 | 28 | async with async_session() as session: 29 | # Check if the default user already exists 30 | query = select(User).where(User.user_id == default_user_id) 31 | result = await session.execute(query) 32 | default_user = result.scalar_one_or_none() 33 | 34 | if default_user is None: 35 | # Create the default user 36 | default_user = User(user_id=default_user_id, username=f"Default User ({default_user_id})") 37 | session.add(default_user) 38 | await session.commit() 39 | print(f"Default user with ID '{default_user_id}' inserted successfully.") 40 | else: 41 | print(f"Default user with ID '{default_user_id}' already exists.") 42 | 43 | 44 | if __name__ == "__main__": 45 | asyncio.run(insert_default_user()) 46 | -------------------------------------------------------------------------------- /stack/.dockerignore: -------------------------------------------------------------------------------- 1 | .flake8 2 | __pycache__ -------------------------------------------------------------------------------- /stack/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = W291 -------------------------------------------------------------------------------- /stack/BUILD: -------------------------------------------------------------------------------- 1 | python_sources(name="lib") 2 | 3 | pex_binary( 4 | name="local", 5 | entry_point="app/main.py", 6 | dependencies=["//:reqs#asyncpg", "//:reqs#python-multipart", "//:reqs#itsdangerous", "//:reqs#langgraph-checkpoint-postgres"], 7 | execution_mode="venv", 8 | ) 9 | 10 | docker_image( 11 | name="docker", 12 | dependencies=[":local"], 13 | repository="personaflow/stack" 14 | ) 15 | 16 | shell_sources() 17 | -------------------------------------------------------------------------------- /stack/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim as base 2 | ENV ENVIRONMENT='' \ 3 | OPENAI_API_KEY='' \ 4 | LANGCHAIN_API_KEY='' \ 5 | LANGCHAIN_ENDPOINT='' \ 6 | LANGCHAIN_TRACING_V2='' \ 7 | APPLICATIONINSIGHTS_CONNECTION_STRING='' \ 8 | ENABLE_LANGSMITH_TRACING='' \ 9 | LANGSMITH_PROJECT_NAME='' \ 10 | LANGGRAPH_RECURSION_LIMIT=5 \ 11 | INTERNAL_DATABASE_USER='' \ 12 | INTERNAL_DATABASE_PASSWORD='' \ 13 | INTERNAL_DATABASE_HOST='' \ 14 | INTERNAL_DATABASE_PORT=5432 \ 15 | INTERNAL_DATABASE_DATABASE='' \ 16 | INTERNAL_DATABASE_SCHEMA='' \ 17 | VECTOR_DB_HOST='' \ 18 | VECTOR_DB_PORT=6333 \ 19 | VECTOR_DB_NAME='' \ 20 | VECTOR_DB_API_KEY='' \ 21 | VECTOR_DB_ENCODER_DIMENSIONS=1536 \ 22 | VECTOR_DB_ENCODER_MODEL='' \ 23 | VECTOR_DB_DEFAULT_NAMESPACE='' \ 24 | DEFAULT_PARTITION_STRATEGY='' \ 25 | DEFAULT_HI_RES_MODEL_NAME='' \ 26 | PROCESS_UNSTRUCTURED_TABLES=true \ 27 | CREATE_SUMMARY_COLLECTION=true \ 28 | DEFAULT_CHUNKING_STRATEGY='' \ 29 | DEFAULT_SEMANTIC_CHUNK_MIN_TOKENS=100 \ 30 | DEFAULT_SEMANTIC_CHUNK_MAX_TOKENS=1000 \ 31 | SEMANTIC_ROLLING_WINDOW_SIZE=1 \ 32 | PREFIX_TITLES=true \ 33 | PREFIX_SUMMARY=true \ 34 | ENABLE_RERANK_BY_DEFAULT=false \ 35 | MAX_QUERY_TOP_K=5 \ 36 | PERSONAFLOW_API_KEY='' \ 37 | COHERE_API_KEY='' \ 38 | UNSTRUCTURED_API_KEY='' \ 39 | UNSTRUCTURED_BASE_URL='' \ 40 | MAX_FILE_UPLOAD_SIZE=25000000 \ 41 | ENVIRONMENT='' \ 42 | TAVILY_API_KEY='' \ 43 | AZURE_OPENAI_DEPLOYMENT_NAME='' \ 44 | AZURE_OPENAI_API_BASE='' \ 45 | AZURE_OPENAI_API_KEY='' \ 46 | AZURE_OPENAI_API_VERSION='' 47 | 48 | COPY stack/local.pex /bin/app 49 | 50 | ENTRYPOINT ["/usr/local/bin/python3.11", "/bin/app"] 51 | -------------------------------------------------------------------------------- /stack/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/__init__.py -------------------------------------------------------------------------------- /stack/app/BUILD: -------------------------------------------------------------------------------- 1 | python_sources( 2 | 3 | ) 4 | -------------------------------------------------------------------------------- /stack/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/__init__.py -------------------------------------------------------------------------------- /stack/app/agents/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/agents/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/agents/__init__.py -------------------------------------------------------------------------------- /stack/app/agents/chatbot_executor.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, List 2 | from langchain_core.language_models.base import LanguageModelLike 3 | from langchain_core.messages import BaseMessage, SystemMessage 4 | from langgraph.graph import END 5 | from langgraph.graph.state import StateGraph 6 | from stack.app.schema.message_types import add_messages_liberal 7 | from stack.app.core.datastore import get_checkpointer 8 | 9 | 10 | def get_chatbot_executor( 11 | llm: LanguageModelLike, 12 | system_message: str, 13 | ): 14 | checkpointer = get_checkpointer() 15 | 16 | def _get_messages(messages): 17 | return [SystemMessage(content=system_message)] + messages 18 | 19 | chatbot = _get_messages | llm 20 | 21 | workflow = StateGraph(Annotated[List[BaseMessage], add_messages_liberal]) 22 | workflow.add_node("chatbot", chatbot) 23 | workflow.set_entry_point("chatbot") 24 | workflow.add_edge("chatbot", END) 25 | app = workflow.compile(checkpointer=checkpointer) 26 | return app 27 | -------------------------------------------------------------------------------- /stack/app/agents/configurable_chatbot.py: -------------------------------------------------------------------------------- 1 | # from enum import Enum 2 | # from typing import Any, Dict, Mapping, Optional, Sequence, Union 3 | 4 | # from langchain_core.messages import AnyMessage 5 | # from langchain_core.runnables import ( 6 | # ConfigurableField, 7 | # RunnableBinding, 8 | # RunnableSerializable, 9 | # ) 10 | # from langgraph.graph.message import Messages 11 | 12 | # from stack.app.agents.xml_agent import get_xml_agent_executor 13 | # from stack.app.agents.chatbot import get_chatbot_executor 14 | # from stack.app.core.configuration import get_settings 15 | # from stack.app.schema.assistant import AgentType, LLMType 16 | 17 | # from stack.app.agents.retrieval import get_retrieval_executor 18 | # from stack.app.core.datastore import get_checkpointer 19 | # from stack.app.agents.tools import ( 20 | # RETRIEVAL_DESCRIPTION, 21 | # TOOLS, 22 | # ActionServer, 23 | # Arxiv, 24 | # AvailableTools, 25 | # Connery, 26 | # DallE, 27 | # DDGSearch, 28 | # PubMed, 29 | # Retrieval, 30 | # Tavily, 31 | # TavilyAnswer, 32 | # Wikipedia, 33 | # YouSearch, 34 | # SecFilings, 35 | # PressReleases, 36 | # get_retrieval_tool, 37 | # get_retriever, 38 | # ) 39 | 40 | # Tool = Union[ 41 | # ActionServer, 42 | # Connery, 43 | # DDGSearch, 44 | # Arxiv, 45 | # YouSearch, 46 | # PubMed, 47 | # Wikipedia, 48 | # Tavily, 49 | # TavilyAnswer, 50 | # Retrieval, 51 | # DallE, 52 | # SecFilings, 53 | # PressReleases, 54 | # ] 55 | 56 | # DEFAULT_SYSTEM_MESSAGE = "You are a helpful assistant." 57 | 58 | 59 | # def get_chatbot( 60 | # llm_type: LLMType, 61 | # system_message: str, 62 | # ): 63 | # llm = get_llm(llm_type) 64 | # return get_chatbot_executor(llm, system_message) 65 | 66 | 67 | # class ConfigurableChatBot(RunnableBinding): 68 | # llm: LLMType 69 | # system_message: str = DEFAULT_SYSTEM_MESSAGE 70 | # user_id: Optional[str] = None 71 | 72 | # def __init__( 73 | # self, 74 | # *, 75 | # llm: LLMType = LLMType.GPT_4O_MINI, 76 | # system_message: str = DEFAULT_SYSTEM_MESSAGE, 77 | # kwargs: Optional[Mapping[str, Any]] = None, 78 | # config: Optional[Mapping[str, Any]] = None, 79 | # **others: Any, 80 | # ) -> None: 81 | # others.pop("bound", None) 82 | 83 | # chatbot = get_chatbot(llm, system_message) 84 | # super().__init__( 85 | # llm=llm, 86 | # system_message=system_message, 87 | # bound=chatbot, 88 | # kwargs=kwargs or {}, 89 | # config=config or {}, 90 | # ) 91 | 92 | 93 | # chatbot = ( 94 | # ConfigurableChatBot(llm=LLMType.GPT_4O_MINI) 95 | # .configurable_fields( 96 | # llm=ConfigurableField(id="llm_type", name="LLM Type"), 97 | # system_message=ConfigurableField(id="system_message", name="Instructions"), 98 | # ) 99 | # .with_types( 100 | # input_type=Messages, 101 | # output_type=Sequence[AnyMessage], 102 | # ) 103 | # ) 104 | -------------------------------------------------------------------------------- /stack/app/agents/configurable_retrieval.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Mapping, Optional 2 | 3 | from langchain_core.runnables import ( 4 | ConfigurableField, 5 | RunnableBinding, 6 | Runnable, 7 | ) 8 | 9 | from stack.app.agents.llm import get_llm, LLMType 10 | 11 | from stack.app.agents.retrieval_executor import get_retrieval_executor 12 | from stack.app.agents.tools import get_retriever 13 | 14 | 15 | DEFAULT_SYSTEM_MESSAGE = "You are a helpful assistant." 16 | 17 | 18 | class ConfigurableRetrieval(RunnableBinding): 19 | llm_type: LLMType 20 | system_message: str = DEFAULT_SYSTEM_MESSAGE 21 | assistant_id: Optional[str] = None 22 | thread_id: str = "" 23 | user_id: Optional[str] = None 24 | 25 | def __init__( 26 | self, 27 | *, 28 | llm_type: LLMType = LLMType.GPT_4O_MINI, 29 | system_message: str = DEFAULT_SYSTEM_MESSAGE, 30 | assistant_id: Optional[str] = None, 31 | thread_id: str = "", 32 | kwargs: Optional[Mapping[str, Any]] = None, 33 | config: Optional[Mapping[str, Any]] = None, 34 | **others: Any, 35 | ) -> None: 36 | others.pop("bound", None) 37 | retriever = get_retriever(assistant_id, thread_id) 38 | llm = get_llm(llm_type) 39 | chatbot = get_retrieval_executor(llm, retriever, system_message) 40 | super().__init__( 41 | llm_type=llm_type, 42 | system_message=system_message, 43 | bound=chatbot, 44 | kwargs=kwargs or {}, 45 | config=config or {}, 46 | ) # type: ignore 47 | 48 | 49 | def get_configured_chat_retrieval() -> Runnable: 50 | initial = ConfigurableRetrieval( 51 | llm_type=LLMType.GPT_4O_MINI, system_message=DEFAULT_SYSTEM_MESSAGE 52 | ) 53 | return initial.configurable_fields( 54 | llm_type=ConfigurableField(id="llm_type", name="LLM Type"), 55 | system_message=ConfigurableField(id="system_message", name="Instructions"), 56 | assistant_id=ConfigurableField( 57 | id="assistant_id", name="Assistant ID", is_shared=True 58 | ), 59 | thread_id=ConfigurableField(id="thread_id", name="Thread ID", is_shared=True), 60 | ).with_types( 61 | input_type=Dict[str, Any], 62 | output_type=Dict[str, Any], 63 | ) 64 | -------------------------------------------------------------------------------- /stack/app/agents/prompts.py: -------------------------------------------------------------------------------- 1 | xml_template = """{system_message} 2 | 3 | You have access to the following tools: 4 | 5 | {tools} 6 | 7 | In order to use a tool, you can use and tags. You will then get back a response in the form 8 | For example, if you have a tool called 'search' that could run a google search, in order to search for the weather in SF you would respond: 9 | 10 | searchweather in SF 11 | 64 degrees 12 | 13 | When you are done, you can respond as normal to the user. 14 | 15 | Example 1: 16 | 17 | Human: Hi! 18 | 19 | Assistant: Hi! How are you? 20 | 21 | Human: What is the weather in SF? 22 | Assistant: searchweather in SF 23 | 64 degrees 24 | It is 64 degrees in SF 25 | 26 | 27 | Begin!""" 28 | -------------------------------------------------------------------------------- /stack/app/api/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/api/__init__.py -------------------------------------------------------------------------------- /stack/app/api/v1/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/api/v1/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Response 2 | 3 | from stack.app.api.v1 import runs 4 | from stack.app.api.v1 import users 5 | from stack.app.api.v1 import threads 6 | from stack.app.api.v1 import assistants 7 | from stack.app.api.v1 import rag 8 | from stack.app.api.v1 import files 9 | from stack.app.api.v1 import auth 10 | from stack.app.api.v1 import admin_users 11 | 12 | api_router = APIRouter() 13 | api_router.include_router(runs.router, prefix="/runs") 14 | api_router.include_router(users.router, prefix="/users") 15 | api_router.include_router(threads.router, prefix="/threads") 16 | api_router.include_router(assistants.router, prefix="/assistants") 17 | api_router.include_router(rag.router, prefix="/rag") 18 | api_router.include_router(files.router, prefix="/files") 19 | api_router.include_router(auth.router, prefix="/auth") 20 | 21 | api_router.include_router(admin_users.router, prefix="/admin/users") 22 | 23 | 24 | @api_router.get("/health_check", tags=["Health Check"]) 25 | def health_check(): 26 | return Response(content="OK", status_code=200) 27 | -------------------------------------------------------------------------------- /stack/app/cache/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/cache/__init__.py: -------------------------------------------------------------------------------- 1 | import redis 2 | 3 | from .cache import create_redis 4 | 5 | pool = create_redis() 6 | 7 | 8 | def get_redis(): 9 | """Get instance of redis reusing the connection pool to avoid constantly 10 | opening/closing connections, yet get a fresh instance of redis each 11 | time.""" 12 | return redis.Redis(connection_pool=pool) 13 | -------------------------------------------------------------------------------- /stack/app/cache/cache.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import redis 4 | 5 | """ 6 | GLOBALS 7 | """ 8 | REDIS_SSL = os.environ.get("REDIS_SSL") == "1" 9 | REDIS_HOST = os.environ.get("REDIS_HOST") 10 | REDIS_PORT = os.environ.get("REDIS_PORT") 11 | REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD") 12 | 13 | 14 | def create_redis(): 15 | """Create Redis connection pool to use through the lifetime of the 16 | service.""" 17 | return redis.ConnectionPool( 18 | host=REDIS_HOST, 19 | port=REDIS_PORT, 20 | password=REDIS_PASSWORD, 21 | connection_class=redis.SSLConnection if REDIS_SSL else redis.Connection, 22 | decode_responses=True, 23 | ) 24 | -------------------------------------------------------------------------------- /stack/app/chains/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/chains/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/chains/__init__.py -------------------------------------------------------------------------------- /stack/app/chains/universal_retrieval_chain.py: -------------------------------------------------------------------------------- 1 | from operator import itemgetter 2 | from langchain.schema.retriever import BaseRetriever 3 | from langchain.schema.language_model import BaseLanguageModel 4 | from langchain.schema.runnable import Runnable, RunnableMap 5 | from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder, PromptTemplate 6 | from langchain.schema.output_parser import StrOutputParser 7 | from stack.app.utils import format_docs 8 | 9 | 10 | def create_retriever_chain( 11 | llm: BaseLanguageModel, 12 | retriever: BaseRetriever, 13 | rephrase_template: str, 14 | use_chat_history: bool, 15 | ) -> Runnable: 16 | CONDENSE_QUESTION_PROMPT = PromptTemplate.from_template(rephrase_template) 17 | if not use_chat_history: 18 | initial_chain = (itemgetter("question")) | retriever 19 | return initial_chain 20 | else: 21 | condense_question_chain = ( 22 | { 23 | "question": itemgetter("question"), 24 | "chat_history": itemgetter("chat_history"), 25 | } 26 | | CONDENSE_QUESTION_PROMPT 27 | | llm 28 | | StrOutputParser() 29 | ).with_config( 30 | run_name="CondenseQuestion", 31 | ) 32 | conversation_chain = condense_question_chain | retriever 33 | return conversation_chain 34 | 35 | 36 | def create_universal_retrieval_chain( 37 | llm: BaseLanguageModel, 38 | retriever: BaseRetriever, 39 | response_template: str, 40 | rephrase_template: str, 41 | use_chat_history: bool = False, 42 | ) -> Runnable: 43 | retriever_chain = create_retriever_chain( 44 | llm, retriever, rephrase_template, use_chat_history 45 | ).with_config(run_name="ChatPipeline") 46 | 47 | _context = RunnableMap( 48 | { 49 | "context": retriever_chain | format_docs, 50 | "question": itemgetter("question"), 51 | "chat_history": itemgetter("chat_history"), 52 | } 53 | ).with_config(run_name="RetrieveDocs") 54 | 55 | prompt = ChatPromptTemplate.from_messages( 56 | [ 57 | ("system", response_template), 58 | MessagesPlaceholder(variable_name="chat_history"), 59 | ("human", "{question}"), 60 | ] 61 | ) 62 | 63 | response_synthesizer = (prompt | llm | StrOutputParser()).with_config( 64 | run_name="GenerateResponse", 65 | ) 66 | 67 | return _context | response_synthesizer 68 | -------------------------------------------------------------------------------- /stack/app/core/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/core/__init__.py -------------------------------------------------------------------------------- /stack/app/core/auth/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/core/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/core/auth/__init__.py -------------------------------------------------------------------------------- /stack/app/core/auth/auth_config.py: -------------------------------------------------------------------------------- 1 | from stack.app.core.auth.strategies.base import ( 2 | BaseAuthenticationStrategy, 3 | BaseOAuthStrategy, 4 | ) 5 | from stack.app.core.auth.strategies.basic import BasicAuthentication 6 | from stack.app.core.auth.strategies.google_oauth import GoogleOAuth 7 | from stack.app.core.auth.strategies.oidc import OpenIDConnect 8 | 9 | # Add Auth strategy classes here to enable them 10 | # Ex: [BasicAuthentication] 11 | ENABLED_AUTH_STRATEGIES = [ 12 | # BasicAuthentication, 13 | # GoogleOAuth, 14 | # OpenIDConnect, 15 | ] 16 | 17 | # Define the mapping from Auth strategy name to class obj - does not need to be manually modified. 18 | # During runtime, this will create an instance of each enabled strategy class. 19 | # Ex: {"Basic": BasicAuthentication()} 20 | ENABLED_AUTH_STRATEGY_MAPPING = {cls.NAME: cls() for cls in ENABLED_AUTH_STRATEGIES} 21 | 22 | 23 | def is_authentication_enabled() -> bool: 24 | """Check whether any form of authentication was enabled. 25 | 26 | Returns: 27 | bool: Whether authentication is enabled. 28 | """ 29 | return bool(ENABLED_AUTH_STRATEGY_MAPPING) 30 | 31 | 32 | def get_auth_strategy( 33 | strategy_name: str, 34 | ) -> BaseAuthenticationStrategy | BaseOAuthStrategy | None: 35 | return ENABLED_AUTH_STRATEGY_MAPPING.get(strategy_name) 36 | 37 | 38 | async def get_auth_strategy_endpoints() -> None: 39 | """Fetches the endpoints for each enabled strategy.""" 40 | for strategy in ENABLED_AUTH_STRATEGY_MAPPING.values(): 41 | if hasattr(strategy, "get_endpoints"): 42 | await strategy.get_endpoints() 43 | -------------------------------------------------------------------------------- /stack/app/core/auth/jwt.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | import structlog 3 | import uuid 4 | import jwt 5 | from jwt.exceptions import DecodeError, ExpiredSignatureError, InvalidTokenError 6 | 7 | from stack.app.core.configuration import Settings, get_settings 8 | 9 | logger = structlog.get_logger() 10 | 11 | settings = get_settings() 12 | 13 | 14 | class JWTService: 15 | def __init__(self, settings: Settings = None): 16 | self.settings = settings or get_settings() 17 | self.secret_key = self.settings.AUTH_SECRET_KEY 18 | self.algorithm = self.settings.JWT_ALGORITHM 19 | self.expiry = self.settings.TOKEN_EXPIRY_HOURS 20 | self.issuer = self.settings.JWT_ISSUER 21 | 22 | if not self.secret_key: 23 | raise ValueError("Secret auth key is not set.") 24 | 25 | def create_and_encode_jwt(self, user: dict, strategy_name: str) -> str: 26 | now = datetime.now(timezone.utc) 27 | payload = { 28 | "iss": self.issuer, 29 | "iat": now, 30 | "exp": now + timedelta(hours=self.expiry), 31 | "jti": str(uuid.uuid4()), 32 | "strategy": strategy_name, 33 | "context": user, 34 | } 35 | 36 | token = jwt.encode(payload, self.secret_key, self.algorithm) 37 | 38 | return token 39 | 40 | def decode_jwt(self, token: str) -> dict: 41 | try: 42 | decoded_payload = jwt.decode( 43 | token, self.secret_key, algorithms=[self.algorithm] 44 | ) 45 | logger.info(f"Successfully decoded token: {decoded_payload}") 46 | return decoded_payload 47 | except ExpiredSignatureError: 48 | logger.warning("JWT Token has expired.") 49 | raise ValueError("JWT Token has expired") 50 | except DecodeError as e: 51 | logger.exception(f"JWT Token is malformed: {str(e)}") 52 | raise ValueError("JWT Token is malformed") 53 | except InvalidTokenError as e: 54 | logger.exception(f"JWT Token is invalid: {str(e)}") 55 | raise ValueError(f"JWT Token is invalid: {str(e)}") 56 | -------------------------------------------------------------------------------- /stack/app/core/auth/strategies/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/core/auth/strategies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/core/auth/strategies/__init__.py -------------------------------------------------------------------------------- /stack/app/core/auth/strategies/basic.py: -------------------------------------------------------------------------------- 1 | import bcrypt 2 | from typing import List 3 | 4 | from stack.app.model.user import User 5 | from stack.app.core.auth.strategies.base import BaseAuthenticationStrategy 6 | from stack.app.repositories.user import UserRepository 7 | 8 | 9 | class BasicAuthentication(BaseAuthenticationStrategy): 10 | """Basic email/password strategy.""" 11 | 12 | NAME = "Basic" 13 | 14 | @staticmethod 15 | def get_required_payload() -> List[str]: 16 | """Retrieves the required /login payload for the Auth strategy. 17 | 18 | Returns: 19 | List[str]: List of required variables. 20 | """ 21 | return ["email", "password"] 22 | 23 | def check_password(self, plain_text_password: str, hashed_password: bytes) -> bool: 24 | """Checks that the input plain text password corresponds to a hashed 25 | password. 26 | 27 | Args: 28 | plain_text_password (str): Password to check. 29 | hashed_password (bytes): Password to check against. 30 | 31 | Returns: 32 | bool: Whether the plain-text password matches the given hashed password. 33 | """ 34 | return bcrypt.checkpw(plain_text_password.encode("utf-8"), hashed_password) 35 | 36 | async def login( 37 | self, user_repository: UserRepository, payload: dict[str, str] 38 | ) -> dict | None: 39 | """Logs user in, checking if the hashed input password corresponds to 40 | the one stored in the DB. 41 | 42 | Args: 43 | user_repository (UserRepository): UserRepository 44 | payload (dict[str, str]): Login payload 45 | 46 | Returns: 47 | dict | None: Returns the user as dict to set the app session, or None. 48 | """ 49 | 50 | payload_email = payload.get("email", "") 51 | payload_password = payload.get("password", "") 52 | user: User = await user_repository.retrieve_user_by_email(payload_email) 53 | 54 | if not user: 55 | return None 56 | 57 | if self.check_password(payload_password, user.hashed_password): 58 | return { 59 | "user_id": user.user_id, 60 | "email": user.email, 61 | } 62 | 63 | return None 64 | -------------------------------------------------------------------------------- /stack/app/core/auth/strategies/google_oauth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import requests 4 | from authlib.integrations.requests_client import OAuth2Session 5 | from starlette.requests import Request 6 | 7 | from stack.app.core.auth.strategies.base import BaseOAuthStrategy 8 | from stack.app.core.auth.strategies.settings import AuthStrategySettings 9 | 10 | 11 | class GoogleOAuthSettings(AuthStrategySettings): 12 | google_client_id: str 13 | google_client_secret: str 14 | frontend_hostname: str 15 | 16 | 17 | class GoogleOAuth(BaseOAuthStrategy): 18 | """Google OAuth2.0 strategy.""" 19 | 20 | NAME = "Google" 21 | WELL_KNOWN_ENDPOINT = "https://accounts.google.com/.well-known/openid-configuration" 22 | 23 | def __init__(self): 24 | try: 25 | self.settings = GoogleOAuthSettings() 26 | self.REDIRECT_URI = ( 27 | f"{self.settings.frontend_hostname}/auth/{self.NAME.lower()}" 28 | ) 29 | self.client = OAuth2Session( 30 | client_id=self.settings.google_client_id, 31 | client_secret=self.settings.google_client_secret, 32 | ) 33 | except Exception as e: 34 | logging.error(f"Error during initializing of GoogleOAuth class: {str(e)}") 35 | raise 36 | 37 | def get_client_id(self): 38 | return self.settings.google_client_id 39 | 40 | def get_pkce_enabled(self): 41 | return self.PKCE_ENABLED if hasattr(self, "PKCE_ENABLED") else False 42 | 43 | def get_authorization_endpoint(self): 44 | return ( 45 | self.AUTHORIZATION_ENDPOINT 46 | if hasattr(self, "AUTHORIZATION_ENDPOINT") 47 | else None 48 | ) 49 | -------------------------------------------------------------------------------- /stack/app/core/auth/strategies/oidc.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from authlib.integrations.requests_client import OAuth2Session 3 | 4 | from stack.app.core.auth.strategies.base import BaseOAuthStrategy 5 | from stack.app.core.auth.strategies.settings import AuthStrategySettings 6 | 7 | 8 | class OIDCSettings(AuthStrategySettings): 9 | oidc_client_id: str 10 | oidc_client_secret: str 11 | oidc_well_known_endpoint: str 12 | frontend_hostname: str 13 | 14 | 15 | class OpenIDConnect(BaseOAuthStrategy): 16 | """OpenID Connect strategy.""" 17 | 18 | NAME = "OIDC" 19 | PKCE_ENABLED = True 20 | 21 | def __init__(self): 22 | try: 23 | self.settings = OIDCSettings() 24 | self.REDIRECT_URI = ( 25 | f"{self.settings.frontend_hostname}/auth/{self.NAME.lower()}" 26 | ) 27 | self.WELL_KNOWN_ENDPOINT = self.settings.oidc_well_known_endpoint 28 | self.client = OAuth2Session( 29 | client_id=self.settings.oidc_client_id, 30 | client_secret=self.settings.oidc_client_secret, 31 | ) 32 | except Exception as e: 33 | logging.error(f"Error during initializing of OpenIDConnect class: {str(e)}") 34 | raise 35 | 36 | def get_client_id(self): 37 | return self.settings.oidc_client_id 38 | 39 | def get_pkce_enabled(self): 40 | return self.PKCE_ENABLED if hasattr(self, "PKCE_ENABLED") else False 41 | 42 | def get_authorization_endpoint(self): 43 | return ( 44 | self.AUTHORIZATION_ENDPOINT 45 | if hasattr(self, "AUTHORIZATION_ENDPOINT") 46 | else None 47 | ) 48 | -------------------------------------------------------------------------------- /stack/app/core/auth/strategies/settings.py: -------------------------------------------------------------------------------- 1 | from pydantic import root_validator 2 | from pydantic_settings import BaseSettings 3 | 4 | 5 | class AuthStrategySettings(BaseSettings): 6 | """Settings class used to grab environment variables directly from .env 7 | file. 8 | 9 | Uppercase env variables converted to class parameters. 10 | """ 11 | 12 | class Config: 13 | env_file = ".env" 14 | extra = "ignore" 15 | 16 | @root_validator(pre=True) 17 | def check_required_fields(cls, values): 18 | # Retrieves list of class properties 19 | required_fields = list(cls.__annotations__.keys()) 20 | 21 | missing_fields = [ 22 | field.upper() 23 | for field in required_fields 24 | if field not in values or values[field] is None 25 | ] 26 | 27 | if missing_fields: 28 | errors = ", ".join(missing_fields) 29 | raise ValueError( 30 | f"Missing required environment variables: {errors}. Please set them in the .env file." 31 | ) 32 | 33 | return values 34 | -------------------------------------------------------------------------------- /stack/app/core/auth/utils.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request 2 | from stack.app.core.auth.auth_config import ( 3 | is_authentication_enabled, 4 | ENABLED_AUTH_STRATEGY_MAPPING, 5 | ) 6 | from stack.app.core.auth.jwt import JWTService 7 | from stack.app.core.configuration import settings 8 | 9 | 10 | def is_enabled_authentication_strategy(strategy_name: str) -> bool: 11 | """Check whether a given authentication strategy is enabled in 12 | auth_config.py. 13 | 14 | Args: 15 | strategy_name (str): Name the of auth strategy. 16 | 17 | Returns: 18 | bool: Whether that strategy is currently enabled 19 | """ 20 | # Check the strategy is valid and enabled 21 | return strategy_name in ENABLED_AUTH_STRATEGY_MAPPING.keys() 22 | 23 | 24 | def get_header_user_id(request: Request) -> str: 25 | """Retrieves the user_id from request headers, will work whether 26 | authentication is enabled or not. 27 | 28 | (Auth disabled): retrieves the User-Id header value 29 | (Auth enabled): retrieves the Authorization header, and decodes the value 30 | 31 | Args: 32 | request (Request): current Request 33 | 34 | 35 | Returns: 36 | str: User ID 37 | """ 38 | default_user_id = settings.DEFAULT_USER_ID 39 | # Check if Auth enabled 40 | if is_authentication_enabled(): 41 | # Validation already performed, so just retrieve value 42 | authorization = request.headers.get("Authorization") 43 | _, token = authorization.split(" ") 44 | decoded = JWTService().decode_jwt(token) 45 | 46 | return decoded["context"]["user_id"] 47 | # Auth disabled 48 | else: 49 | user_id = request.headers.get("User-Id") or default_user_id 50 | return user_id 51 | -------------------------------------------------------------------------------- /stack/app/core/exception.py: -------------------------------------------------------------------------------- 1 | """ 2 | exception.py 3 | ---------- 4 | This module defines custom exception classes extending FastAPI's HTTPException. 5 | 6 | These exceptions are tailored to cater to common error scenarios in the application, 7 | enabling consistent error responses and ensuring that the response codes align with 8 | the HTTP standard. 9 | 10 | """ 11 | 12 | from fastapi import HTTPException, status 13 | 14 | 15 | class NotFoundException(HTTPException): 16 | def __init__(self, message: str = None): 17 | super().__init__( 18 | status_code=status.HTTP_404_NOT_FOUND, detail=message or "Record Not Found" 19 | ) 20 | 21 | 22 | class UnauthorizedException(HTTPException): 23 | def __init__(self, message: str = None): 24 | super().__init__( 25 | status_code=status.HTTP_401_UNAUTHORIZED, 26 | detail=message or "Can not validate credentials", 27 | ) 28 | -------------------------------------------------------------------------------- /stack/app/core/redis.py: -------------------------------------------------------------------------------- 1 | from redis.asyncio import Redis 2 | import structlog 3 | from typing import Optional 4 | 5 | logger = structlog.get_logger() 6 | 7 | 8 | class RedisService: 9 | def __init__(self, redis: Redis): 10 | self.redis = redis 11 | 12 | async def get_progress_messages( 13 | self, task_id: str, start_index: int = 0 14 | ) -> list[str]: 15 | messages = await self.redis.lrange( 16 | f"ingestion:{task_id}:progress", start_index, -1 17 | ) 18 | return [message.decode("utf-8") for message in messages] 19 | 20 | async def get_ingestion_status(self, task_id: str) -> Optional[str]: 21 | status = await self.redis.get(f"ingestion:{task_id}:status") 22 | if status: 23 | return status.decode("utf-8") 24 | return None 25 | 26 | async def push_progress_message(self, task_id: str, message: str): 27 | await self.redis.rpush(f"ingestion:{task_id}:progress", message) 28 | 29 | async def set_ingestion_status(self, task_id: str, status: str): 30 | await self.redis.set(f"ingestion:{task_id}:status", status) 31 | 32 | 33 | async def get_redis_service() -> RedisService: 34 | from stack.app.core.datastore import get_redis_connection 35 | 36 | redis = await get_redis_connection() 37 | return RedisService(redis) 38 | -------------------------------------------------------------------------------- /stack/app/core/retriever.py: -------------------------------------------------------------------------------- 1 | from qdrant_client import QdrantClient 2 | from stack.app.core.configuration import get_settings 3 | from langchain_openai import OpenAIEmbeddings 4 | from langchain_community.vectorstores.qdrant import Qdrant 5 | 6 | settings = get_settings() 7 | 8 | qdrant_client = QdrantClient(host=settings.VECTOR_DB_HOST, port=settings.VECTOR_DB_PORT) 9 | 10 | qdrant_vstore = Qdrant( 11 | client=qdrant_client, 12 | collection_name=settings.VECTOR_DB_COLLECTION_NAME, 13 | embeddings=OpenAIEmbeddings(), 14 | vector_name="page_content", 15 | ) 16 | -------------------------------------------------------------------------------- /stack/app/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from dotenv import load_dotenv 4 | 5 | 6 | # If we have the `ENVIRONMENT`variable already, we are running in Docker or Kubernetes 7 | # and do not need to load the .env file 8 | environment = os.getenv("ENVIRONMENT") 9 | if not environment: 10 | env_path = ".env" 11 | load_dotenv(env_path) 12 | 13 | from stack.app.app_factory import create_app 14 | from stack.app.core.configuration import settings 15 | 16 | 17 | if settings.ENABLE_LANGSMITH_TRACING: 18 | from langsmith import Client 19 | 20 | client = Client() 21 | 22 | app = create_app(settings) 23 | 24 | """ 25 | Application Insights configuration 26 | (Disabled if APPLICATIONINSIGHTS_CONNECTION_STRING is not set) 27 | """ 28 | logging.getLogger("azure").setLevel(logging.WARN) 29 | logging.getLogger("httpcore").setLevel(logging.WARN) 30 | logging.getLogger("urllib3").setLevel(logging.WARN) 31 | logging.getLogger("opentelemetry").setLevel(logging.WARN) 32 | AI_CONN_STR_ENV_NAME = "APPLICATIONINSIGHTS_CONNECTION_STRING" 33 | AI_CONN_STR = os.getenv(AI_CONN_STR_ENV_NAME) 34 | if AI_CONN_STR and AI_CONN_STR != "": 35 | from azure.monitor.opentelemetry import configure_azure_monitor 36 | from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor 37 | 38 | configure_azure_monitor( 39 | connection_string=os.getenv(AI_CONN_STR_ENV_NAME), 40 | logger_name="personaflow", 41 | logging_level=os.getenv("INFO"), 42 | ) 43 | FastAPIInstrumentor.instrument_app(app) 44 | 45 | 46 | if __name__ == "__main__": 47 | import uvicorn 48 | 49 | uvicorn.run(app, host="0.0.0.0", port=9000) 50 | -------------------------------------------------------------------------------- /stack/app/middlewares/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | # from .request_logger import RequestLoggerMiddleware 2 | -------------------------------------------------------------------------------- /stack/app/model/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/model/__init__.py -------------------------------------------------------------------------------- /stack/app/model/assistant.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import uuid 3 | from sqlalchemy import String, Boolean, text, DateTime, ForeignKey 4 | from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY 5 | from sqlalchemy.orm import Mapped, mapped_column, relationship 6 | from sqlalchemy.sql import func 7 | from stack.app.core.configuration import settings 8 | from stack.app.model.base import Base 9 | 10 | 11 | class Assistant(Base): 12 | __tablename__ = "assistants" 13 | __table_args__ = {"schema": settings.INTERNAL_DATABASE_SCHEMA} 14 | 15 | id: Mapped[uuid.UUID] = mapped_column( 16 | UUID(as_uuid=True), 17 | primary_key=True, 18 | server_default=text("gen_random_uuid()"), 19 | comment="A unique identifier for the assistant. It's a UUID type and is automatically generated by the database.", 20 | ) 21 | user_id: Mapped[str] = mapped_column( 22 | String(), 23 | ForeignKey(f"{settings.INTERNAL_DATABASE_SCHEMA}.users.user_id"), 24 | nullable=False, 25 | index=True, 26 | comment="The ID of the user who created the assistant.", 27 | ) 28 | name: Mapped[str] = mapped_column( 29 | String(), nullable=False, comment="The name of the assistant." 30 | ) 31 | config: Mapped[JSONB] = mapped_column( 32 | JSONB(), 33 | nullable=False, 34 | comment="The assistant config, containing specific configuration parameters.", 35 | ) 36 | public: Mapped[bool] = mapped_column( 37 | Boolean(), default=False, comment="Whether or not the assistant is public." 38 | ) 39 | file_ids: Mapped[ARRAY] = mapped_column( 40 | ARRAY(String()), 41 | nullable=True, 42 | comment="A list of files to be associated with this assistant for use with Retrieval.", 43 | ) 44 | kwargs: Mapped[JSONB] = mapped_column( 45 | JSONB(), 46 | nullable=True, 47 | comment="The assistant metadata, containing any additional information about the assistant.", 48 | ) 49 | created_at: Mapped[datetime] = mapped_column( 50 | DateTime(timezone=True), 51 | default=func.now(), 52 | nullable=False, 53 | comment="Created date", 54 | ) 55 | updated_at: Mapped[datetime] = mapped_column( 56 | DateTime(timezone=True), 57 | default=func.now(), 58 | onupdate=func.now(), 59 | nullable=False, 60 | comment="Last updated date", 61 | ) 62 | 63 | user = relationship("User", back_populates="assistant") 64 | 65 | thread = relationship("Thread", back_populates="assistant") 66 | -------------------------------------------------------------------------------- /stack/app/model/base.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from sqlalchemy import MetaData 4 | from sqlalchemy.orm import declarative_base, declared_attr 5 | 6 | from stack.app.core.configuration import settings 7 | 8 | 9 | def camel_to_snake_case(name: str): 10 | name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) 11 | return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower() 12 | 13 | 14 | class _Base: 15 | @declared_attr 16 | def __tablename__(cls): 17 | return camel_to_snake_case(cls.__name__) 18 | 19 | @declared_attr 20 | def alias(cls): 21 | return camel_to_snake_case(cls.__name__) 22 | 23 | 24 | Base = declarative_base( 25 | cls=_Base, metadata=MetaData(schema=settings.INTERNAL_DATABASE_SCHEMA) 26 | ) 27 | -------------------------------------------------------------------------------- /stack/app/model/blacklist.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | 4 | from sqlalchemy.dialects.postgresql import UUID 5 | from sqlalchemy import String, func 6 | from sqlalchemy.orm import Mapped, mapped_column 7 | from stack.app.core.configuration import settings 8 | from stack.app.model.base import Base 9 | from sqlalchemy import DateTime, LargeBinary, String, text, ForeignKey 10 | 11 | 12 | class Blacklist(Base): 13 | """Table that contains the list of JWT access tokens that are blacklisted 14 | during logout.""" 15 | 16 | __tablename__ = "blacklist" 17 | __table_args__ = {"schema": settings.INTERNAL_DATABASE_SCHEMA} 18 | 19 | id: Mapped[uuid.UUID] = mapped_column( 20 | UUID(as_uuid=True), 21 | primary_key=True, 22 | server_default=text("gen_random_uuid()"), 23 | comment="A unique identifier for the blacklist entry. It's a UUID type and is automatically generated by the database.", 24 | ) 25 | token_id: Mapped[str] = mapped_column( 26 | String(), 27 | index=True, 28 | comment="The ID of the token to be blacklisted.", 29 | ) 30 | created_at: Mapped[datetime] = mapped_column( 31 | DateTime(timezone=True), 32 | default=func.now(), 33 | nullable=False, 34 | comment="The timestamp when the token was blacklisted.", 35 | ) 36 | updated_at: Mapped[datetime] = mapped_column( 37 | DateTime(timezone=True), 38 | default=func.now(), 39 | onupdate=func.now(), 40 | nullable=False, 41 | comment="The timestamp when the token was last updated.", 42 | ) 43 | -------------------------------------------------------------------------------- /stack/app/model/file.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import uuid 3 | from sqlalchemy import String, text, DateTime, Integer, ForeignKey 4 | from sqlalchemy.dialects.postgresql import UUID, JSONB 5 | from sqlalchemy.orm import Mapped, mapped_column, relationship 6 | from sqlalchemy.sql import func 7 | from stack.app.core.configuration import settings 8 | from stack.app.model.base import Base 9 | 10 | 11 | class File(Base): 12 | __tablename__ = "files" 13 | __table_args__ = {"schema": settings.INTERNAL_DATABASE_SCHEMA} 14 | 15 | id: Mapped[uuid.UUID] = mapped_column( 16 | UUID(as_uuid=True), 17 | primary_key=True, 18 | server_default=text("gen_random_uuid()"), 19 | comment="A unique identifier for the file. It's a UUID type and is automatically generated by the database.", 20 | ) 21 | user_id: Mapped[str] = mapped_column( 22 | String(), 23 | ForeignKey(f"{settings.INTERNAL_DATABASE_SCHEMA}.users.user_id"), 24 | nullable=False, 25 | index=True, 26 | comment="The ID of the user who created the file.", 27 | ) 28 | filename: Mapped[str] = mapped_column( 29 | String(), nullable=False, comment="The name of the file." 30 | ) 31 | purpose: Mapped[str] = mapped_column( 32 | String(), 33 | nullable=False, 34 | comment="The purpose of the file - either thread, assistants, or personas.", 35 | ) 36 | mime_type: Mapped[str] = mapped_column( 37 | String(), nullable=False, comment="The mime type of the file." 38 | ) 39 | source: Mapped[str] = mapped_column( 40 | String(), 41 | nullable=True, 42 | comment="The source of the file. For local files, this will be the fully qualified file path plus filename and extension.", 43 | ) 44 | bytes: Mapped[str] = mapped_column( 45 | Integer(), 46 | nullable=False, 47 | comment="The bytes of the file. (Added automatically when file is uploaded)", 48 | ) 49 | kwargs: Mapped[JSONB] = mapped_column( 50 | JSONB(), 51 | nullable=True, 52 | comment="Any additional information to be included for the file.", 53 | ) 54 | created_at: Mapped[datetime] = mapped_column( 55 | DateTime(timezone=True), 56 | default=func.now(), 57 | nullable=False, 58 | index=True, 59 | comment="Created date", 60 | ) 61 | updated_at: Mapped[datetime] = mapped_column( 62 | DateTime(timezone=True), 63 | default=func.now(), 64 | onupdate=func.now(), 65 | nullable=False, 66 | comment="Last updated date", 67 | ) 68 | 69 | user = relationship("User", back_populates="file") 70 | -------------------------------------------------------------------------------- /stack/app/model/message.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import uuid 3 | from sqlalchemy import String, text, DateTime, func, Boolean, ForeignKey 4 | from sqlalchemy.orm import Mapped, mapped_column, relationship 5 | from sqlalchemy.dialects.postgresql import UUID 6 | from stack.app.core.configuration import settings 7 | from stack.app.model.base import Base 8 | 9 | 10 | class Message(Base): 11 | __tablename__ = "messages" 12 | __table_args__ = {"schema": settings.INTERNAL_DATABASE_SCHEMA} 13 | 14 | id: Mapped[uuid.UUID] = mapped_column( 15 | UUID(as_uuid=True), 16 | primary_key=True, 17 | server_default=text("gen_random_uuid()"), 18 | comment="A unique identifier for the message. It's a UUID type and is automatically generated by the database.", 19 | ) 20 | thread_id: Mapped[str] = mapped_column( 21 | UUID(as_uuid=True), 22 | ForeignKey(f"{settings.INTERNAL_DATABASE_SCHEMA}.threads.id"), 23 | nullable=False, 24 | index=True, 25 | comment="The ID of the thread to which this message belongs.", 26 | ) 27 | user_id: Mapped[str] = mapped_column( 28 | String(), 29 | ForeignKey(f"{settings.INTERNAL_DATABASE_SCHEMA}.users.user_id"), 30 | comment="The ID of the user who sent the message.", 31 | ) 32 | assistant_id: Mapped[str] = mapped_column( 33 | UUID(as_uuid=True), 34 | ForeignKey(f"{settings.INTERNAL_DATABASE_SCHEMA}.assistants.id"), 35 | comment="The ID of the assistant that processed the message.", 36 | ) 37 | content: Mapped[str] = mapped_column( 38 | String(), comment="The content of the message." 39 | ) 40 | role: Mapped[str] = mapped_column( 41 | String(), comment="The type of message (e.g., text, image, etc.)." 42 | ) 43 | example: Mapped[Boolean] = mapped_column( 44 | Boolean(), 45 | nullable=True, 46 | comment="Indicates whether the message is an example message for training or demonstration purposes.", 47 | ) 48 | kwargs: Mapped[str] = mapped_column( 49 | String(), 50 | nullable=True, 51 | comment="Additional arguments associated with the message.", 52 | ) 53 | created_at: Mapped[datetime] = mapped_column( 54 | DateTime(timezone=True), 55 | default=func.now(), 56 | nullable=False, 57 | comment="Created date", 58 | ) 59 | updated_at: Mapped[datetime] = mapped_column( 60 | DateTime(timezone=True), 61 | default=func.now(), 62 | onupdate=func.now(), 63 | nullable=False, 64 | comment="Last updated date", 65 | ) 66 | 67 | thread = relationship("Thread", back_populates="message") 68 | 69 | user = relationship("User", back_populates="message") 70 | -------------------------------------------------------------------------------- /stack/app/model/request_log.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | 4 | from sqlalchemy import Integer, String, text 5 | from sqlalchemy.dialects.postgresql import JSONB 6 | from sqlalchemy.orm import Mapped, mapped_column 7 | 8 | from stack.app.core.configuration import settings 9 | from stack.app.model.base import Base 10 | 11 | 12 | from datetime import datetime 13 | import uuid 14 | from sqlalchemy import String, text, DateTime, Integer, Float 15 | from sqlalchemy.orm import Mapped, mapped_column 16 | from sqlalchemy.dialects.postgresql import UUID, JSONB 17 | from stack.app.model.base import Base 18 | 19 | 20 | class RequestLog(Base): 21 | __tablename__ = "request_logs" 22 | __table_args__ = {"schema": settings.INTERNAL_DATABASE_SCHEMA} 23 | 24 | id: Mapped[uuid.UUID] = mapped_column( 25 | UUID(as_uuid=True), 26 | primary_key=True, 27 | server_default=text("gen_random_uuid()"), 28 | comment="A unique identifier for the log entry. It's a UUID type and is automatically generated by the database.", 29 | ) 30 | timestamp: Mapped[datetime] = mapped_column( 31 | DateTime(timezone=True), 32 | default=datetime.utcnow, 33 | nullable=False, 34 | comment="The time when the log entry was created. Defaults to the current time.", 35 | ) 36 | host: Mapped[str] = mapped_column( 37 | String(), 38 | nullable=True, 39 | comment="The host (i.e., IP address) from which the request originated.", 40 | ) 41 | request_id: Mapped[str] = mapped_column( 42 | String(), 43 | nullable=True, 44 | comment="An identifier for the request, used for correlating logs.", 45 | ) 46 | endpoint: Mapped[str] = mapped_column( 47 | String(), nullable=True, comment="The API endpoint that was accessed." 48 | ) 49 | method: Mapped[str] = mapped_column( 50 | String(), 51 | nullable=True, 52 | comment="The HTTP method (e.g., GET, POST) used for the request.", 53 | ) 54 | headers: Mapped[dict] = mapped_column( 55 | JSONB(), nullable=True, comment="The HTTP headers associated with the request." 56 | ) 57 | query_parameters: Mapped[dict] = mapped_column( 58 | JSONB(), nullable=True, comment="The query parameters from the request URL." 59 | ) 60 | request_body: Mapped[dict] = mapped_column( 61 | JSONB(), nullable=True, comment="The body of the request, stored as JSON." 62 | ) 63 | response_body: Mapped[dict] = mapped_column( 64 | JSONB(), nullable=True, comment="The body of the response, stored as JSON." 65 | ) 66 | status_code: Mapped[int] = mapped_column( 67 | Integer, 68 | nullable=True, 69 | comment="The HTTP status code returned by the API for the request.", 70 | ) 71 | response_time: Mapped[float] = mapped_column( 72 | Float, 73 | nullable=True, 74 | comment="The time it took for the API to process the request.", 75 | ) 76 | -------------------------------------------------------------------------------- /stack/app/model/thread.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import uuid 3 | from sqlalchemy import String, text, DateTime, func, ForeignKey 4 | from sqlalchemy.orm import Mapped, mapped_column, relationship 5 | from sqlalchemy.dialects.postgresql import UUID, JSONB 6 | from stack.app.core.configuration import settings 7 | from stack.app.model.base import Base 8 | 9 | 10 | class Thread(Base): 11 | __tablename__ = "threads" 12 | __table_args__ = {"schema": settings.INTERNAL_DATABASE_SCHEMA} 13 | 14 | id: Mapped[uuid.UUID] = mapped_column( 15 | UUID(as_uuid=True), 16 | primary_key=True, 17 | server_default=text("gen_random_uuid()"), 18 | comment="A unique identifier for the thread. It's a UUID type and is automatically generated by the database.", 19 | ) 20 | user_id: Mapped[str] = mapped_column( 21 | String(), 22 | ForeignKey(f"{settings.INTERNAL_DATABASE_SCHEMA}.users.user_id"), 23 | index=True, 24 | comment="The ID of the user who initiated the thread.", 25 | ) 26 | assistant_id: Mapped[str] = mapped_column( 27 | UUID(as_uuid=True), 28 | ForeignKey(f"{settings.INTERNAL_DATABASE_SCHEMA}.assistants.id"), 29 | nullable=True, 30 | comment="The ID of the assistant that is associated with the thread.", 31 | ) 32 | name: Mapped[str] = mapped_column( 33 | String(), nullable=True, comment="The title of the thread." 34 | ) 35 | kwargs: Mapped[JSONB] = mapped_column( 36 | JSONB(), 37 | nullable=True, 38 | comment="Additional arguments associated with the thread.", 39 | ) 40 | created_at: Mapped[datetime] = mapped_column( 41 | DateTime(timezone=True), 42 | default=func.now(), 43 | nullable=False, 44 | comment="Created date", 45 | ) 46 | updated_at: Mapped[datetime] = mapped_column( 47 | DateTime(timezone=True), 48 | default=func.now(), 49 | onupdate=func.now(), 50 | nullable=False, 51 | index=True, 52 | comment="Last updated date", 53 | ) 54 | # TODO: this is no longer in use, need to remove message table and relationship 55 | message = relationship( 56 | "Message", 57 | back_populates="thread", 58 | cascade="all, delete-orphan", 59 | ) 60 | 61 | user = relationship("User", back_populates="thread") 62 | 63 | assistant = relationship("Assistant", back_populates="thread") 64 | -------------------------------------------------------------------------------- /stack/app/model/util.py: -------------------------------------------------------------------------------- 1 | import string 2 | import random 3 | 4 | 5 | def generate_unique_identifier(length=16): 6 | """Generate a random string of fixed length.""" 7 | return "".join(random.choices(string.ascii_letters + string.digits, k=length)) 8 | -------------------------------------------------------------------------------- /stack/app/rag/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/rag/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/rag/__init__.py -------------------------------------------------------------------------------- /stack/app/rag/custom_retriever.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Any, Optional 2 | 3 | from langchain_core.retrievers import BaseRetriever 4 | from langchain_core.callbacks import ( 5 | CallbackManagerForRetrieverRun, 6 | AsyncCallbackManagerForRetrieverRun, 7 | ) 8 | from langchain_core.documents import Document 9 | from stack.app.schema.rag import QueryRequestPayload, BaseDocumentChunk 10 | from stack.app.rag.query import query_documents 11 | from stack.app.core.configuration import get_settings 12 | 13 | 14 | settings = get_settings() 15 | 16 | 17 | class Retriever(BaseRetriever): 18 | def __init__( 19 | self, 20 | tags: Optional[List[str]] = None, 21 | namespace: Optional[str] = None, 22 | metadata: Optional[Dict[str, Any]] = None, 23 | ): 24 | super().__init__(tags=tags, metadata=metadata) 25 | metadata = metadata or {} 26 | if namespace is not None: 27 | metadata["namespace"] = namespace 28 | 29 | def _get_relevant_documents( 30 | self, query: str, *, run_manager: CallbackManagerForRetrieverRun 31 | ) -> list[Document]: 32 | """Sync implementations for retriever.""" 33 | raise NotImplementedError 34 | 35 | def _chunk_to_document(self, chunk: BaseDocumentChunk) -> Document: 36 | """Convert a BaseDocumentChunk to a langchain Document.""" 37 | metadata = {"namespace": chunk.namespace, **chunk.metadata} 38 | return Document(page_content=chunk.page_content, metadata=metadata) 39 | 40 | async def _aget_relevant_documents( 41 | self, query: str, *, run_manager: AsyncCallbackManagerForRetrieverRun 42 | ) -> List[Document]: 43 | """Async implementations for retriever.""" 44 | payload = QueryRequestPayload( 45 | input=query, 46 | namespace=self.metadata.get("namespace"), 47 | index_name=self.metadata.get( 48 | "index_name", settings.VECTOR_DB_COLLECTION_NAME 49 | ), 50 | vector_database=self.metadata.get("vector_database"), 51 | encoder=self.metadata.get("encoder"), 52 | enable_rerank=self.metadata.get("enable_rerank"), 53 | exclude_fields=self.metadata.get("exclude_fields"), 54 | ) 55 | chunks = await query_documents(payload) 56 | return [self._chunk_to_document(chunk) for chunk in chunks] 57 | -------------------------------------------------------------------------------- /stack/app/rag/encoders/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/rag/encoders/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/rag/encoders/__init__.py -------------------------------------------------------------------------------- /stack/app/rag/encoders/ollama_encoder.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pydantic import Field 3 | from semantic_router.encoders import BaseEncoder 4 | from langchain_community.embeddings import OllamaEmbeddings 5 | from stack.app.core.configuration import get_settings 6 | 7 | settings = get_settings() 8 | 9 | 10 | class OllamaEncoder(BaseEncoder): 11 | name: str = Field(default="all-minilm") 12 | score_threshold: float = Field(default=0.5) 13 | type: str = Field(default="ollama") 14 | dimensions: int = Field(default=384) 15 | embeddings: OllamaEmbeddings = Field(default=None) 16 | 17 | class Config: 18 | arbitrary_types_allowed = True 19 | 20 | def __init__(self, **data): 21 | super().__init__(**data) 22 | if "name" in data: 23 | self.embeddings = OllamaEmbeddings( 24 | model=self.name, base_url=settings.OLLAMA_BASE_URL 25 | ) 26 | 27 | def __call__(self, docs: List[str]) -> List[List[float]]: 28 | return self.embeddings.embed_documents(docs) 29 | -------------------------------------------------------------------------------- /stack/app/rag/ingest.py: -------------------------------------------------------------------------------- 1 | from stack.app.schema.rag import IngestRequestPayload 2 | from stack.app.rag.embedding_service import EmbeddingService 3 | from stack.app.rag.summarizer import SUMMARY_SUFFIX 4 | from stack.app.core.configuration import get_settings 5 | from stack.app.schema.file import FileSchema 6 | import structlog 7 | 8 | 9 | logger = structlog.get_logger() 10 | settings = get_settings() 11 | 12 | 13 | async def get_ingest_tasks_from_config( 14 | files_to_ingest: list[tuple[FileSchema, bytes]], 15 | config: IngestRequestPayload, 16 | ) -> list: 17 | vector_db_creds = config.vector_database 18 | document_processor_config = config.document_processor 19 | encoder = document_processor_config.encoder.get_encoder() 20 | collection_name = config.index_name 21 | namespace = ( 22 | config.namespace if config.namespace else settings.VECTOR_DB_DEFAULT_NAMESPACE 23 | ) 24 | 25 | embedding_service = EmbeddingService( 26 | encoder=encoder, 27 | index_name=collection_name, 28 | vector_credentials=vector_db_creds, 29 | dimensions=document_processor_config.encoder.dimensions, 30 | files=files_to_ingest, 31 | namespace=namespace, 32 | purpose=config.purpose, 33 | parser_config=document_processor_config.parser_config, 34 | ) 35 | 36 | chunks = await embedding_service.generate_chunks(config=document_processor_config) 37 | 38 | summary_documents = None 39 | if document_processor_config.summarize: 40 | summary_documents = await embedding_service.generate_summary_documents( 41 | documents=chunks 42 | ) 43 | 44 | tasks = [ 45 | embedding_service.embed_and_upsert( 46 | chunks=chunks, encoder=encoder, index_name=collection_name 47 | ), 48 | ] 49 | 50 | if summary_documents and all(item is not None for item in summary_documents): 51 | tasks.append( 52 | embedding_service.embed_and_upsert( 53 | chunks=summary_documents, 54 | encoder=encoder, 55 | index_name=f"{collection_name}_{SUMMARY_SUFFIX}", 56 | ) 57 | ) 58 | 59 | return tasks 60 | -------------------------------------------------------------------------------- /stack/app/rag/query.py: -------------------------------------------------------------------------------- 1 | import structlog 2 | from semantic_router.layer import RouteLayer 3 | from semantic_router.route import Route 4 | from semantic_router.encoders import BaseEncoder 5 | 6 | from stack.app.schema.rag import BaseDocumentChunk, QueryRequestPayload 7 | from .summarizer import SUMMARY_SUFFIX 8 | from stack.app.vectordbs import BaseVectorDatabase, get_vector_service 9 | 10 | from stack.app.core.configuration import get_settings 11 | 12 | logger = structlog.get_logger() 13 | settings = get_settings() 14 | 15 | 16 | def create_route_layer(encoder: BaseEncoder) -> RouteLayer: 17 | routes = [ 18 | Route( 19 | name="summarize", 20 | utterances=[ 21 | "Summmarize the following", 22 | "Could you summarize the", 23 | "Summarize", 24 | "Provide a summary of", 25 | ], 26 | score_threshold=0.5, 27 | ) 28 | ] 29 | return RouteLayer(encoder=encoder, routes=routes) 30 | 31 | 32 | async def get_documents( 33 | *, vector_service: BaseVectorDatabase, payload: QueryRequestPayload 34 | ) -> list[BaseDocumentChunk]: 35 | chunks = await vector_service.query(input=payload.input, top_k=5) 36 | if not len(chunks): 37 | logger.info(f"No documents found for query: {payload.input}") 38 | return [] 39 | 40 | if not payload.enable_rerank: 41 | return chunks 42 | 43 | reranked_chunks = [] 44 | reranked_chunks.extend( 45 | await vector_service.rerank(query=payload.input, documents=chunks) 46 | ) 47 | return reranked_chunks 48 | 49 | 50 | async def query_documents(payload: QueryRequestPayload) -> list[BaseDocumentChunk]: 51 | encoder = payload.encoder.get_encoder() 52 | rl = create_route_layer(encoder) 53 | decision = rl(payload.input).name 54 | 55 | vector_service: BaseVectorDatabase = get_vector_service( 56 | index_name=f"{payload.index_name}_{SUMMARY_SUFFIX}" 57 | if decision == "summarize" 58 | else payload.index_name, 59 | credentials=payload.vector_database, 60 | encoder=encoder, 61 | namespace=payload.namespace, 62 | ) 63 | return await get_documents(vector_service=vector_service, payload=payload) 64 | -------------------------------------------------------------------------------- /stack/app/rag/summarizer.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from stack.app.core.configuration import get_settings 3 | from stack.app.schema.rag import BaseDocumentChunk 4 | from stack.app.agents.llm import ( 5 | get_openai_llm, 6 | get_anthropic_llm, 7 | get_google_llm, 8 | get_mixtral_fireworks, 9 | get_ollama_llm, 10 | ) 11 | from langchain_core.language_models import BaseChatModel 12 | from langchain_core.output_parsers import StrOutputParser 13 | from langchain_core.prompts import ChatPromptTemplate 14 | 15 | 16 | SUMMARY_SUFFIX = "summary" 17 | settings = get_settings() 18 | 19 | 20 | class Summarizer: 21 | def __init__(self): 22 | self.llm = self._get_llm() 23 | self.prompt = ChatPromptTemplate.from_template( 24 | "Make an in-depth summary of the block of text below:\n\n" 25 | "Text:\n" 26 | "------------------------------------------\n" 27 | "{text}\n" 28 | "------------------------------------------\n\n" 29 | "Your summary:" 30 | ) 31 | self.chain = self.prompt | self.llm | StrOutputParser() 32 | 33 | def _get_llm(self) -> BaseChatModel: 34 | provider = settings.SUMMARIZATION_MODEL_PROVIDER.lower() 35 | model_name = settings.SUMMARIZATION_MODEL_NAME 36 | 37 | if provider == "openai": 38 | return get_openai_llm(model=model_name) 39 | elif provider == "anthropic": 40 | return get_anthropic_llm() 41 | elif provider == "google": 42 | return get_google_llm() 43 | elif provider == "mixtral": 44 | return get_mixtral_fireworks() 45 | elif provider == "ollama": 46 | return get_ollama_llm() 47 | else: 48 | raise ValueError(f"Unsupported LLM provider: {provider}") 49 | 50 | async def summarize(self, document: BaseDocumentChunk) -> str: 51 | return await self.chain.ainvoke({"text": document.page_content}) 52 | 53 | 54 | async def completion(*, document: BaseDocumentChunk) -> str: 55 | summarizer = Summarizer() 56 | return await summarizer.summarize(document) 57 | -------------------------------------------------------------------------------- /stack/app/rag/table_parser.py: -------------------------------------------------------------------------------- 1 | from html.parser import HTMLParser 2 | 3 | 4 | class TableParser(HTMLParser): 5 | def __init__(self): 6 | super().__init__() 7 | self.in_table = False 8 | self.in_thead = False 9 | self.in_tbody = False 10 | self.in_row = False 11 | self.in_cell = False 12 | self.title_row = "" 13 | self.current_row = "" 14 | self.rows = [] 15 | self.capture_next_row_as_title = True 16 | 17 | def handle_starttag(self, tag, _attrs): 18 | if tag == "table": 19 | self.in_table = True 20 | elif tag == "thead": 21 | self.in_thead = True 22 | elif tag == "tbody": 23 | self.in_tbody = True 24 | elif tag == "tr": 25 | self.in_row = True 26 | self.current_row = "" 27 | elif tag in ["td", "th"]: 28 | self.in_cell = True 29 | self.current_row += "<" + tag + ">" 30 | 31 | def handle_endtag(self, tag): 32 | if tag == "table": 33 | self.in_table = False 34 | elif tag == "thead": 35 | self.in_thead = False 36 | elif tag == "tbody": 37 | self.in_tbody = False 38 | elif tag == "tr": 39 | self.in_row = False 40 | self.current_row += "" 41 | if self.capture_next_row_as_title: 42 | self.title_row = self.current_row 43 | self.capture_next_row_as_title = False 44 | else: 45 | self.rows.append(self.current_row) 46 | elif tag in ["td", "th"]: 47 | self.in_cell = False 48 | self.current_row += "" 49 | 50 | def handle_data(self, data): 51 | if self.in_cell: 52 | self.current_row += data 53 | -------------------------------------------------------------------------------- /stack/app/rag/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/rag/tests/__init__.py -------------------------------------------------------------------------------- /stack/app/rag/tests/unit/BUILD: -------------------------------------------------------------------------------- 1 | python_tests( 2 | name="tests", 3 | dependencies=["//:test-reqs"] 4 | ) 5 | 6 | python_test_utils( 7 | name="test_utils", 8 | ) 9 | -------------------------------------------------------------------------------- /stack/app/rag/util.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from typing import Tuple 3 | 4 | import tiktoken 5 | 6 | 7 | def get_tiktoken_length(text: str) -> int: 8 | tokenizer = tiktoken.get_encoding("cl100k_base") 9 | tokens = tokenizer.encode(text, disallowed_special=()) 10 | return len(tokens) 11 | 12 | 13 | def check_content_is_useful( 14 | document_content: str, 15 | min_word_count: int = 10, 16 | max_number_ratio: float = 0.3, 17 | information_density_ratio: float = 0.5, 18 | max_density_word_count: int = 200, 19 | ) -> Tuple[bool, str]: 20 | words = document_content.split(" ") 21 | print(f"WORDS={len(words)}") 22 | if document_content == "" or not words or not len(words): 23 | return False, "No words in content" 24 | 25 | # Check min word length 26 | word_count = len(words) 27 | if word_count < min_word_count: 28 | return False, f"word_count={word_count} < threshold={min_word_count}" 29 | 30 | # Check information density 31 | density_ratio = len(set(words)) / len(words) 32 | if ( 33 | word_count < max_density_word_count 34 | and density_ratio < information_density_ratio 35 | ): 36 | return ( 37 | False, 38 | f"density_ratio={density_ratio} < threshold={information_density_ratio}", 39 | ) 40 | 41 | # Check that this chunk is not full of useless numbers 42 | number_count = sum(word.replace(".", "").isdigit() for word in words) 43 | number_ratio = number_count / word_count if word_count > 0 else 0 44 | if number_ratio > max_number_ratio: 45 | return False, f"number_ratio={number_ratio} > threshold={max_number_ratio}" 46 | 47 | return True, "Document content passes checks." 48 | 49 | 50 | def sentence_hash(sentence): 51 | """Create a hash for a sentence, ignoring whitespace and capitalization.""" 52 | return hashlib.md5(sentence.strip().lower().encode()).hexdigest() 53 | 54 | 55 | def deduplicate_chunk(chunk: str) -> str: 56 | # TODO: employ more advanced strategy here, perhaps with NLTK or Spacy 57 | if not chunk: 58 | return chunk 59 | 60 | final_chunk = "" 61 | memory = {} 62 | for sentence in chunk.split("."): 63 | # end of chunk 64 | if not sentence.strip(): 65 | final_chunk += "." 66 | continue 67 | hash_value = sentence_hash(sentence.strip()) 68 | if hash_value in memory: 69 | continue 70 | final_chunk += f".{sentence}" if final_chunk else sentence 71 | memory[hash_value] = 1 72 | 73 | return final_chunk 74 | -------------------------------------------------------------------------------- /stack/app/repositories/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/repositories/__init__.py -------------------------------------------------------------------------------- /stack/app/repositories/blacklist.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException, Depends 2 | from sqlalchemy import select 3 | from sqlalchemy.exc import SQLAlchemyError 4 | from stack.app.repositories.base import BaseRepository 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | from stack.app.core.datastore import get_postgresql_session_provider 7 | from stack.app.model.blacklist import Blacklist 8 | import structlog 9 | 10 | logger = structlog.get_logger() 11 | 12 | 13 | async def get_blacklist_repository( 14 | session: AsyncSession = Depends(get_postgresql_session_provider), 15 | ): 16 | return BlacklistRepository(postgresql_session=session) 17 | 18 | 19 | class BlacklistRepository(BaseRepository): 20 | def __init__(self, postgresql_session): 21 | self.postgresql_session = postgresql_session 22 | 23 | @staticmethod 24 | def _get_retrieve_query() -> select: 25 | """A private method to construct the default query for retrieval.""" 26 | return select( 27 | Blacklist.id, 28 | Blacklist.token_id, 29 | Blacklist.created_at, 30 | Blacklist.updated_at, 31 | ) 32 | 33 | async def create_blacklist(self, data: Blacklist) -> Blacklist: 34 | """Creates a new blacklist in the database.""" 35 | try: 36 | blacklist = await self.create(model=Blacklist, values=data) 37 | await self.postgresql_session.commit() 38 | return blacklist 39 | except SQLAlchemyError as e: 40 | await self.postgresql_session.rollback() 41 | logger.exception( 42 | f"Failed to create blacklist due to a database error.", exc_info=True 43 | ) 44 | raise HTTPException( 45 | status_code=400, detail=f"Failed to create blacklist." 46 | ) from e 47 | 48 | async def retrieve_blacklist(self, token_id: str) -> Blacklist: 49 | """Retrieves a blacklist from the database by token_id.""" 50 | try: 51 | query = self._get_retrieve_query() 52 | blacklist = await self.retrieve_one(query=query, object_id=token_id) 53 | # return db.query(Blacklist).filter(Blacklist.token_id == token_id).first() 54 | return blacklist 55 | except SQLAlchemyError as e: 56 | logger.exception( 57 | f"Failed to retrieve blacklist due to a database error.", exc_info=True 58 | ) 59 | raise HTTPException( 60 | status_code=400, detail=f"Failed to retrieve blacklist." 61 | ) from e 62 | -------------------------------------------------------------------------------- /stack/app/schema/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/schema/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/schema/__init__.py -------------------------------------------------------------------------------- /stack/app/schema/auth.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Auth(BaseModel): 7 | strategy: str 8 | 9 | 10 | class Login(Auth): 11 | payload: Optional[dict[str, str]] = None 12 | 13 | 14 | class Logout(BaseModel): 15 | pass 16 | 17 | 18 | class ListAuthStrategy(BaseModel): 19 | strategy: str 20 | client_id: str | None 21 | authorization_endpoint: str | None 22 | pkce_enabled: bool 23 | 24 | 25 | class JWTResponse(BaseModel): 26 | token: str 27 | -------------------------------------------------------------------------------- /stack/app/schema/feedback.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Dict, Optional, Union 3 | from uuid import UUID 4 | 5 | from pydantic import BaseModel 6 | 7 | 8 | class BaseFeedback(BaseModel): 9 | """Shared information between create requests of feedback and feedback 10 | objects.""" 11 | 12 | run_id: UUID 13 | """The associated run ID this feedback is logged for.""" 14 | 15 | key: str 16 | """The metric name, tag, or aspect to provide feedback on.""" 17 | 18 | score: Optional[Union[float, int, bool]] = None 19 | """Value or score to assign the run.""" 20 | 21 | value: Optional[Union[float, int, bool, str, Dict]] = None 22 | """The display value for the feedback if not a metric.""" 23 | 24 | comment: Optional[str] = None 25 | """Comment or explanation for the feedback.""" 26 | 27 | 28 | class FeedbackCreateRequest(BaseFeedback): 29 | """Represents a request that creates feedback for an individual run.""" 30 | 31 | 32 | class Feedback(BaseFeedback): 33 | """Represents feedback given on an individual run.""" 34 | 35 | id: UUID 36 | """The unique ID of the feedback that was created.""" 37 | 38 | created_at: datetime 39 | """The time the feedback was created.""" 40 | 41 | modified_at: datetime 42 | """The time the feedback was last modified.""" 43 | 44 | correction: Optional[Dict] = None 45 | """Correction for the run.""" 46 | -------------------------------------------------------------------------------- /stack/app/schema/message_types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, get_args 2 | 3 | from langchain_core.messages import ( 4 | AnyMessage, 5 | FunctionMessage, 6 | MessageLikeRepresentation, 7 | ToolMessage, 8 | ) 9 | from langgraph.graph.message import add_messages, Messages 10 | 11 | 12 | class LiberalFunctionMessage(FunctionMessage): 13 | content: Any 14 | 15 | 16 | class LiberalToolMessage(ToolMessage): 17 | content: Any 18 | 19 | 20 | def _convert_pydantic_dict_to_message( 21 | data: MessageLikeRepresentation, 22 | ) -> MessageLikeRepresentation: 23 | if ( 24 | isinstance(data, dict) 25 | and "content" in data 26 | and isinstance(data.get("type"), str) 27 | ): 28 | for cls in get_args(AnyMessage): 29 | if data["type"] == cls(content="").type: 30 | return cls(**data) 31 | return data 32 | 33 | 34 | def add_messages_liberal(left: Messages, right: Messages): 35 | # coerce to list 36 | if not isinstance(left, list): 37 | left = [left] 38 | if not isinstance(right, list): 39 | right = [right] 40 | return add_messages( 41 | [_convert_pydantic_dict_to_message(m) for m in left], 42 | [_convert_pydantic_dict_to_message(m) for m in right], 43 | ) 44 | -------------------------------------------------------------------------------- /stack/app/schema/thread.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field, validator 2 | import uuid 3 | from datetime import datetime 4 | from langchain.schema.messages import AnyMessage 5 | from typing import Any, Dict, List, Optional, Sequence, Union 6 | 7 | 8 | class Thread(BaseModel): 9 | id: uuid.UUID = Field( 10 | ..., 11 | description="A unique identifier for the thread. It's a UUID type and is automatically generated by the database.", 12 | ) 13 | user_id: str = Field(..., description="The user id associated with the thread.") 14 | assistant_id: Optional[uuid.UUID] = Field( 15 | None, description="(Optional) The assistant id associated with the thread." 16 | ) 17 | name: Optional[str] = Field( 18 | None, description="(Optional) The conversation title of the thread." 19 | ) 20 | kwargs: Optional[dict] = Field( 21 | None, description="(Optional) Additional kwargs associated with the thread." 22 | ) 23 | created_at: datetime = Field(..., description="Created date") 24 | updated_at: datetime = Field(..., description="Last updated date") 25 | 26 | class Config: 27 | from_attributes = True 28 | json_encoders = { 29 | datetime: lambda v: v.replace(microsecond=0).isoformat(), 30 | } 31 | 32 | 33 | class CreateThreadSchema(BaseModel): 34 | assistant_id: uuid.UUID = Field( 35 | ..., description="(Optional) The assistant id associated with the thread." 36 | ) 37 | name: Optional[str] = Field( 38 | None, description="(Optional) The conversation title of the thread." 39 | ) 40 | kwargs: Optional[dict] = Field( 41 | None, description="(Optional) Additional kwargs associated with the thread." 42 | ) 43 | 44 | 45 | class UpdateThreadSchema(BaseModel): 46 | assistant_id: Optional[uuid.UUID] = Field( 47 | None, description="(Optional) The assistant id associated with the thread." 48 | ) 49 | name: Optional[str] = Field( 50 | None, description="(Optional) The conversation title of the thread." 51 | ) 52 | kwargs: Optional[dict] = Field( 53 | None, description="(Optional) Additional kwargs associated with the thread." 54 | ) 55 | 56 | 57 | class GroupedThreads(BaseModel): 58 | """Grouped threads by time period.""" 59 | 60 | Today: Optional[List[Thread]] = Field(None, alias="Today") 61 | Yesterday: Optional[List[Thread]] = Field(None, alias="Yesterday") 62 | Past_7_Days: Optional[List[Thread]] = Field(None, alias="Past 7 Days") 63 | Past_30_Days: Optional[List[Thread]] = Field(None, alias="Past 30 Days") 64 | This_Year: Optional[List[Thread]] = Field(None, alias="This Year") 65 | Previous_Years: Optional[List[Thread]] = Field(None, alias="Previous Years") 66 | 67 | class Config: 68 | populate_by_name = True 69 | json_encoders = { 70 | datetime: lambda v: v.replace(microsecond=0).isoformat(), 71 | } 72 | 73 | 74 | class ThreadPostRequest(BaseModel): 75 | """Payload for adding state to a thread.""" 76 | 77 | values: Union[Sequence[AnyMessage], Dict[str, Any]] = Field( 78 | ..., 79 | description="The state values to add to the thread. It can be a list of messages or a dictionary.", 80 | ) 81 | config: Optional[Dict[str, Any]] = Field( 82 | None, 83 | description="The configuration values to add to the thread. It can be a dictionary.", 84 | ) 85 | -------------------------------------------------------------------------------- /stack/app/schema/title.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | from typing import List, Optional 3 | 4 | 5 | class Source(BaseModel): 6 | url: str 7 | title: Optional[str] 8 | 9 | 10 | class Metadata(BaseModel): 11 | run_id: Optional[str] 12 | sources: Optional[List[Source]] 13 | 14 | 15 | class ChatMessage(BaseModel): 16 | content: str 17 | type: str 18 | 19 | 20 | class TitleRequest(BaseModel): 21 | thread_id: str = Field( 22 | ..., description="The id of the thread to generate the title for." 23 | ) 24 | history: Optional[List[ChatMessage]] = Field( 25 | None, 26 | description="(Optional) The conversation history of the thread. This is used as context for the model when generating the title.", 27 | ) 28 | -------------------------------------------------------------------------------- /stack/app/schema/user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from pydantic import BaseModel, Field 3 | from typing import Optional 4 | 5 | 6 | class UserBase(BaseModel): 7 | """Base model for user data.""" 8 | 9 | username: Optional[str] = Field( 10 | None, 11 | description="(Optional) The username chosen by the user. It's a string type and does not need to be unique across the userbase.", 12 | ) 13 | email: Optional[str] = Field( 14 | None, 15 | description="(Optional) The email address associated with the user's account. It's a string type and is expected to be unique across the userbase", 16 | ) 17 | first_name: Optional[str] = Field( 18 | None, description="(Optional) The first name of the user." 19 | ) 20 | last_name: Optional[str] = Field( 21 | None, description="(Optional) The last name of the user." 22 | ) 23 | kwargs: Optional[dict] = Field( 24 | None, description="(Optional) Additional kwargs associated with the user." 25 | ) 26 | 27 | class Config: 28 | from_attributes = True 29 | 30 | 31 | class User(UserBase): 32 | """Model representing a registered user in the application.""" 33 | 34 | user_id: str = Field( 35 | ..., 36 | description="Unique identifier for the user to be used across the application. Set once when the user is created and cannot be updated thereafter. Can be used for correlating local user with external systems. Autogenerates if none is provided.", 37 | ) 38 | created_at: datetime = Field(..., description="Created date") 39 | updated_at: datetime = Field(..., description="Last updated date") 40 | 41 | class Config: 42 | from_attributes = True 43 | json_encoders = { 44 | datetime: lambda v: v.replace(microsecond=0).isoformat(), 45 | } 46 | 47 | 48 | class CreateUpdateUserSchema(UserBase): 49 | password: Optional[str] = Field( 50 | None, description="(Optional) Password for the new user account." 51 | ) 52 | user_id: Optional[str] = Field( 53 | None, 54 | description="Identifier for the user to be used across the application. Can be used for correlating local user with external systems. Autogenerates if none is provided.", 55 | ) 56 | -------------------------------------------------------------------------------- /stack/app/start.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Start the server 4 | os.system("uvicorn main:app --reload --port 9000") 5 | -------------------------------------------------------------------------------- /stack/app/utils/BUILD: -------------------------------------------------------------------------------- 1 | python_sources(name="lib", dependencies=["//:reqs#beautifulsoup4"]) 2 | -------------------------------------------------------------------------------- /stack/app/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/utils/__init__.py -------------------------------------------------------------------------------- /stack/app/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | 3 | 4 | class UniqueConstraintViolationError(HTTPException): 5 | def __init__(self, field: str): 6 | super().__init__(status_code=400, detail=f"{field} already exists") 7 | self.field = field 8 | -------------------------------------------------------------------------------- /stack/app/utils/format_docs.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | from langchain.schema import Document 3 | 4 | 5 | def format_docs(docs: Sequence[Document]) -> str: 6 | formatted_docs = [] 7 | for i, doc in enumerate(docs): 8 | doc_string = f"{doc.page_content}" 9 | formatted_docs.append(doc_string) 10 | return "\n".join(formatted_docs) 11 | -------------------------------------------------------------------------------- /stack/app/utils/group_threads.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from stack.app.schema.thread import Thread, GroupedThreads 3 | from datetime import datetime, timedelta, timezone 4 | 5 | 6 | def group_threads(threads: List[Thread], client_tz_offset: int = 0) -> GroupedThreads: 7 | grouped = { 8 | "Today": [], 9 | "Yesterday": [], 10 | "Past 7 Days": [], 11 | "Past 30 Days": [], 12 | "This Year": [], 13 | "Previous Years": [], 14 | } 15 | 16 | # Adjusting for the client's timezone offset 17 | client_tz = timezone(timedelta(minutes=client_tz_offset)) 18 | now_utc = datetime.now(timezone.utc) 19 | now_in_user_tz = now_utc.astimezone(client_tz) 20 | 21 | def to_user_tz(dt: datetime) -> datetime: 22 | if dt.tzinfo is None: 23 | dt = dt.replace(tzinfo=timezone.utc) 24 | return dt.astimezone(client_tz) 25 | 26 | for thread in threads: 27 | updated_at_user_tz = to_user_tz(thread.updated_at) 28 | updated_at_date = updated_at_user_tz.date() 29 | 30 | if updated_at_date == now_in_user_tz.date(): 31 | grouped["Today"].append(thread) 32 | elif updated_at_date == (now_in_user_tz - timedelta(days=1)).date(): 33 | grouped["Yesterday"].append(thread) 34 | elif ( 35 | (now_in_user_tz - timedelta(days=7)).date() 36 | <= updated_at_date 37 | < (now_in_user_tz - timedelta(days=1)).date() 38 | ): 39 | grouped["Past 7 Days"].append(thread) 40 | elif ( 41 | (now_in_user_tz - timedelta(days=30)).date() 42 | <= updated_at_date 43 | < (now_in_user_tz - timedelta(days=7)).date() 44 | ): 45 | grouped["Past 30 Days"].append(thread) 46 | elif updated_at_user_tz.year == now_in_user_tz.year: 47 | grouped["This Year"].append(thread) 48 | else: 49 | grouped["Previous Years"].append(thread) 50 | 51 | # Sorting each category by most recent first 52 | for category in grouped: 53 | grouped[category] = sorted( 54 | grouped[category], key=lambda x: x.updated_at, reverse=True 55 | ) 56 | 57 | return grouped 58 | -------------------------------------------------------------------------------- /stack/app/utils/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/utils/tests/__init__.py -------------------------------------------------------------------------------- /stack/app/utils/tests/unit/BUILD: -------------------------------------------------------------------------------- 1 | python_tests( 2 | name="tests", 3 | dependencies=["//:test-reqs"] 4 | ) 5 | -------------------------------------------------------------------------------- /stack/app/utils/tests/unit/test_format_docs.py: -------------------------------------------------------------------------------- 1 | def test__format_docs__handles_no_documents(): 2 | assert True 3 | -------------------------------------------------------------------------------- /stack/app/vectordbs/BUILD: -------------------------------------------------------------------------------- 1 | python_sources() 2 | -------------------------------------------------------------------------------- /stack/app/vectordbs/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from dotenv import load_dotenv 3 | from semantic_router.encoders import BaseEncoder 4 | from stack.app.schema.rag import ( 5 | VectorDatabase, 6 | VectorDatabaseType, 7 | EncoderConfig, 8 | EncoderProvider, 9 | ) 10 | from stack.app.vectordbs.base import BaseVectorDatabase 11 | from stack.app.vectordbs.qdrant import QdrantService 12 | from stack.app.core.configuration import get_settings 13 | 14 | load_dotenv() 15 | settings = get_settings() 16 | 17 | 18 | def get_vector_service( 19 | *, 20 | index_name: str = settings.VECTOR_DB_COLLECTION_NAME, 21 | namespace: Optional[str] = settings.VECTOR_DB_DEFAULT_NAMESPACE, 22 | credentials: Optional[VectorDatabase] = VectorDatabase(), 23 | encoder_provider: Optional[EncoderProvider] = EncoderProvider( 24 | settings.VECTOR_DB_ENCODER_NAME 25 | ), 26 | encoder: Optional[BaseEncoder] = None, 27 | dimensions: Optional[int] = settings.VECTOR_DB_ENCODER_DIMENSIONS, 28 | enable_rerank: Optional[bool] = settings.ENABLE_RERANK_BY_DEFAULT, 29 | ) -> BaseVectorDatabase: 30 | services = { 31 | VectorDatabaseType.qdrant: QdrantService, 32 | # Add other providers here 33 | } 34 | 35 | vector_db = credentials or VectorDatabase() 36 | # Convert string to enum if necessary 37 | db_type = ( 38 | VectorDatabaseType(vector_db.type) 39 | if isinstance(vector_db.type, str) 40 | else vector_db.type 41 | ) 42 | 43 | service = services.get(db_type) 44 | if service is None: 45 | raise ValueError(f"Unsupported provider: {db_type}") 46 | 47 | encoder_config = EncoderConfig.get_encoder_config(encoder_provider) 48 | if encoder_config is None: 49 | raise ValueError(f"Unsupported encoder provider: {encoder_provider}") 50 | 51 | if encoder is None: 52 | encoder_class = encoder_config["class"] 53 | if not issubclass(encoder_class, BaseEncoder): 54 | raise ValueError( 55 | f"Encoder class {encoder_class} is not a subclass of BaseEncoder" 56 | ) 57 | encoder: BaseEncoder = encoder_class() 58 | 59 | return service( 60 | index_name=index_name, 61 | dimension=dimensions, 62 | credentials=dict(credentials.config), 63 | encoder=encoder, 64 | enable_rerank=enable_rerank, 65 | namespace=namespace, 66 | ) 67 | -------------------------------------------------------------------------------- /stack/app/vectordbs/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from semantic_router.encoders import BaseEncoder 4 | from tqdm import tqdm 5 | 6 | from stack.app.schema.rag import DeleteDocumentsResponse, BaseDocumentChunk 7 | import structlog 8 | from stack.app.core.configuration import get_settings 9 | 10 | logger = structlog.get_logger() 11 | 12 | settings = get_settings() 13 | 14 | 15 | class BaseVectorDatabase(ABC): 16 | def __init__( 17 | self, 18 | index_name: str, 19 | dimension: int, 20 | credentials: dict, 21 | encoder: BaseEncoder, 22 | enable_rerank: bool, 23 | namespace: str, 24 | ): 25 | self.index_name = index_name 26 | self.dimension = dimension 27 | self.credentials = credentials 28 | self.encoder = encoder 29 | self.enable_rerank = enable_rerank 30 | self.namespace = namespace 31 | 32 | @abstractmethod 33 | async def upsert(self, chunks: list[BaseDocumentChunk]): 34 | pass 35 | 36 | @abstractmethod 37 | async def query(self, input: str, top_k: int = 25) -> list[BaseDocumentChunk]: 38 | pass 39 | 40 | @abstractmethod 41 | async def delete( 42 | self, file_id: str, assistant_id: str = None 43 | ) -> DeleteDocumentsResponse: 44 | pass 45 | 46 | async def _generate_vectors(self, input: str) -> list[list[float]]: 47 | return self.encoder([input]) 48 | 49 | async def rerank( 50 | self, query: str, documents: list[BaseDocumentChunk], top_n: int = 5 51 | ) -> list[BaseDocumentChunk]: 52 | if not self.enable_rerank: 53 | return documents 54 | 55 | from cohere import Client 56 | 57 | api_key = settings.COHERE_API_KEY 58 | if not api_key: 59 | raise ValueError("API key for Cohere is not present.") 60 | cohere_client = Client(api_key=api_key) 61 | 62 | # Avoid duplications, TODO: fix ingestion for duplications 63 | # Deduplicate documents based on content while preserving order 64 | seen = set() 65 | deduplicated_documents = [ 66 | doc 67 | for doc in documents 68 | if doc.page_content not in seen and not seen.add(doc.page_content) 69 | ] 70 | docs_text = list( 71 | doc.page_content 72 | for doc in tqdm( 73 | deduplicated_documents, 74 | desc=f"Reranking {len(deduplicated_documents)} documents", 75 | ) 76 | ) 77 | try: 78 | re_ranked = cohere_client.rerank( 79 | model="rerank-multilingual-v2.0", 80 | query=query, 81 | documents=docs_text, 82 | top_n=top_n, 83 | ).results 84 | results = [] 85 | for r in tqdm(re_ranked, desc="Processing reranked results "): 86 | doc = deduplicated_documents[r.index] 87 | results.append(doc) 88 | return results 89 | except Exception as e: 90 | logger.error(f"Error while reranking: {e}") 91 | raise Exception(f"Error while reranking: {e}") 92 | -------------------------------------------------------------------------------- /stack/dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo Starting server... 4 | uvicorn app.main:app --reload --port 9000 5 | -------------------------------------------------------------------------------- /stack/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/tests/__init__.py -------------------------------------------------------------------------------- /stack/tests/unit/BUILD: -------------------------------------------------------------------------------- 1 | python_tests( 2 | name="tests", 3 | dependencies=["//:test-reqs"] 4 | ) 5 | 6 | python_test_utils( 7 | name="test_utils", 8 | ) 9 | -------------------------------------------------------------------------------- /stack/tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/tests/unit/__init__.py -------------------------------------------------------------------------------- /stack/tests/unit/test_app.py: -------------------------------------------------------------------------------- 1 | from starlette.testclient import TestClient 2 | 3 | from stack.app.app_factory import create_app 4 | from stack.app.core.configuration import Settings 5 | 6 | app = create_app(Settings()) 7 | client = TestClient(app) 8 | 9 | 10 | async def test__app__healthcheck(): 11 | response = client.get("/api/v1/health_check") 12 | assert response.status_code == 200 13 | -------------------------------------------------------------------------------- /stack/tests/unit/test_app_auth.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, MagicMock 2 | 3 | from starlette.testclient import TestClient 4 | 5 | from stack.app.app_factory import create_app 6 | from stack.app.core.configuration import Settings 7 | from stack.app.core.auth.auth_config import ENABLED_AUTH_STRATEGY_MAPPING 8 | from stack.tests.unit.conftest import passthrough 9 | 10 | app = create_app(Settings()) 11 | client = TestClient(app) 12 | 13 | 14 | async def test__list_auth_strategies__responds_correctly(): 15 | # Arrange 16 | mock_strategies = { 17 | "Basic": MagicMock( 18 | get_client_id=lambda: None, 19 | get_authorization_endpoint=lambda: None, 20 | get_pkce_enabled=lambda: False, 21 | ), 22 | "Google": MagicMock( 23 | get_client_id=lambda: "google_client_id", 24 | get_authorization_endpoint=lambda: "https://accounts.google.com/o/oauth2/v2/auth", 25 | get_pkce_enabled=lambda: True, 26 | ), 27 | "OIDC": MagicMock( 28 | get_client_id=lambda: "oidc_client_id", 29 | get_authorization_endpoint=lambda: "https://example.com/auth", 30 | get_pkce_enabled=lambda: True, 31 | ), 32 | } 33 | 34 | with patch.dict(ENABLED_AUTH_STRATEGY_MAPPING, mock_strategies): 35 | # Act 36 | response = client.get("/api/v1/auth/auth_strategies") 37 | 38 | # Assert 39 | assert response.status_code == 200 40 | data = response.json() 41 | assert len(data) == 3 42 | 43 | expected_strategies = ["Basic", "Google", "OIDC"] 44 | for strategy in data: 45 | assert strategy["strategy"] in expected_strategies 46 | assert "client_id" in strategy 47 | assert "authorization_endpoint" in strategy 48 | assert "pkce_enabled" in strategy 49 | 50 | basic_strategy = next(s for s in data if s["strategy"] == "Basic") 51 | assert basic_strategy["client_id"] is None 52 | assert basic_strategy["authorization_endpoint"] is None 53 | assert basic_strategy["pkce_enabled"] is False 54 | 55 | google_strategy = next(s for s in data if s["strategy"] == "Google") 56 | assert google_strategy["client_id"] == "google_client_id" 57 | assert ( 58 | google_strategy["authorization_endpoint"] 59 | == "https://accounts.google.com/o/oauth2/v2/auth" 60 | ) 61 | assert google_strategy["pkce_enabled"] is True 62 | 63 | oidc_strategy = next(s for s in data if s["strategy"] == "OIDC") 64 | assert oidc_strategy["client_id"] == "oidc_client_id" 65 | assert oidc_strategy["authorization_endpoint"] == "https://example.com/auth" 66 | assert oidc_strategy["pkce_enabled"] is True 67 | 68 | 69 | async def test__list_auth_strategies__no_strategies(): 70 | # Arrange 71 | with patch.dict(ENABLED_AUTH_STRATEGY_MAPPING, {}, clear=True): 72 | # Act 73 | response = client.get("/api/v1/auth/auth_strategies") 74 | 75 | # Assert 76 | assert response.status_code == 200 77 | data = response.json() 78 | assert len(data) == 0 79 | -------------------------------------------------------------------------------- /stack/tests/unit/test_format_docs.py: -------------------------------------------------------------------------------- 1 | def test__example(): 2 | assert True 3 | -------------------------------------------------------------------------------- /ui/.env.local.example: -------------------------------------------------------------------------------- 1 | # NEXT_PUBLIC_PERSONAFLOW_API_KEY= 2 | # NEXT_PUBLIC_BASE_API_URL=http://localhost:9000 -------------------------------------------------------------------------------- /ui/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /ui/.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /ui/.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | -------------------------------------------------------------------------------- /ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "printWidth": 110, 5 | "useTabs": false 6 | } 7 | -------------------------------------------------------------------------------- /ui/assets/PersonaFlowIcon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/ui/assets/PersonaFlowIcon-512.png -------------------------------------------------------------------------------- /ui/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/styles/main.css", 9 | "baseColor": "stone", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ui/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | -------------------------------------------------------------------------------- /ui/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | export default nextConfig 5 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev -p 4000", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "jest", 11 | "test:ci": "jest --ci --coverage", 12 | "prepare": "husky", 13 | "format:check": "prettier --check ." 14 | }, 15 | "dependencies": { 16 | "@hookform/resolvers": "^3.4.2", 17 | "@microsoft/fetch-event-source": "^2.0.1", 18 | "@radix-ui/react-accordion": "^1.1.2", 19 | "@radix-ui/react-checkbox": "^1.0.4", 20 | "@radix-ui/react-dialog": "^1.0.5", 21 | "@radix-ui/react-icons": "^1.3.0", 22 | "@radix-ui/react-label": "^2.0.2", 23 | "@radix-ui/react-select": "^2.0.0", 24 | "@radix-ui/react-slot": "^1.0.2", 25 | "@radix-ui/react-switch": "^1.0.3", 26 | "@radix-ui/react-tabs": "^1.0.4", 27 | "@radix-ui/react-toast": "^1.1.5", 28 | "@radix-ui/react-tooltip": "^1.1.2", 29 | "@tanstack/react-query": "^5.36.2", 30 | "@tanstack/react-query-devtools": "^5.45.1", 31 | "axios": "^1.7.4", 32 | "class-variance-authority": "^0.7.0", 33 | "clsx": "^2.1.1", 34 | "cmdk": "^1.0.0", 35 | "jotai": "^2.9.1", 36 | "lucide-react": "^0.381.0", 37 | "next": "14.2.3", 38 | "react": "^18.2.0", 39 | "react-dom": "^18.2.0", 40 | "react-hook-form": "^7.51.5", 41 | "react-markdown": "^9.0.1", 42 | "remark": "^15.0.1", 43 | "remark-gfm": "^4.0.0", 44 | "remark-math": "^6.0.0", 45 | "tailwind-merge": "^2.3.0", 46 | "tailwindcss-animate": "^1.0.7", 47 | "zod": "^3.23.8" 48 | }, 49 | "devDependencies": { 50 | "@tailwindcss/typography": "^0.5.13", 51 | "@tanstack/eslint-plugin-query": "^5.35.6", 52 | "@testing-library/jest-dom": "^6.4.6", 53 | "@testing-library/react": "^16.0.0", 54 | "@types/jest": "^29.5.12", 55 | "@types/node": "^20", 56 | "@types/react": "^18.3.3", 57 | "@types/react-dom": "^18.3.0", 58 | "eslint": "^8", 59 | "eslint-config-next": "14.2.3", 60 | "eslint-config-prettier": "^9.1.0", 61 | "husky": "^9.1.6", 62 | "jest": "^29.7.0", 63 | "jest-environment-jsdom": "^29.7.0", 64 | "lint-staged": "^15.2.10", 65 | "postcss": "^8", 66 | "prettier": "3.2.5", 67 | "tailwindcss": "^3.4.1", 68 | "ts-node": "^10.9.2", 69 | "typescript": "^5" 70 | }, 71 | "lint-staged": { 72 | "**/*": "prettier --write --ignore-unknown" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /ui/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | } 7 | 8 | export default config 9 | -------------------------------------------------------------------------------- /ui/src/app/[[...slug]]/page.tsx: -------------------------------------------------------------------------------- 1 | import ChatPanel from '@/components/features/chat-panel/chat-panel' 2 | import { isValidParam } from '@/utils/routeUtils' 3 | import { notFound } from 'next/navigation' 4 | 5 | export default function ConversationPage({ params }: { params: { slug?: string[] } }) { 6 | if (!isValidParam(params.slug)) return notFound() 7 | 8 | return ( 9 |
10 | 11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/app/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/ui/src/app/icon.ico -------------------------------------------------------------------------------- /ui/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Inter } from 'next/font/google' 3 | import '@/styles/main.css' 4 | import Providers from '@/providers/Providers' 5 | import { Toaster } from '@/components/ui/toaster' 6 | import BuildPanel from '@/components/features/build-panel/components/build-panel' 7 | import Navbar from '@/components/features/navbar/components/navbar' 8 | import Header from '@/components/features/header/components/header' 9 | 10 | const inter = Inter({ subsets: ['latin'] }) 11 | 12 | export const metadata: Metadata = { 13 | title: 'PersonaFlow', 14 | description: 'Where AI meets individuality for unmatched personalization', 15 | icons: { 16 | icon: '/icon.ico', 17 | }, 18 | } 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode 24 | }>) { 25 | return ( 26 | 27 | 28 |
31 | 32 |
33 |
34 | 35 | {children} 36 | 37 |
38 | 39 | 40 |
41 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /ui/src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | const Page = () => { 2 | return ( 3 |
4 |

Page not found.

5 |
6 | ) 7 | } 8 | 9 | export default Page 10 | -------------------------------------------------------------------------------- /ui/src/components/features/build-panel/components/__tests__/assistant-builder.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import { AssistantBuilder } from '../assistant-builder' 3 | import { useRouter } from 'next/navigation' 4 | import { useAssistants } from '@/data-provider/query-service' 5 | import { useSlugRoutes } from '@/hooks/useSlugParams' 6 | 7 | jest.mock('../edit-assistant', () => { 8 | return { 9 | EditAssistant: jest.fn(() =>
EditAssistant
), 10 | } 11 | }) 12 | jest.mock('../create-assistant', () => { 13 | return { 14 | CreateAssistant: jest.fn(() =>
CreateAssistant
), 15 | } 16 | }) 17 | 18 | jest.mock('next/navigation', () => { 19 | return { 20 | useRouter: jest.fn(), 21 | } 22 | }) 23 | 24 | jest.mock('@/data-provider/query-service', () => { 25 | return { 26 | useAssistants: jest.fn(), 27 | } 28 | }) 29 | 30 | jest.mock('@/hooks/useSlugParams', () => { 31 | return { 32 | useSlugRoutes: jest.fn(), 33 | } 34 | }) 35 | 36 | test('should render', () => { 37 | ;(useAssistants as jest.Mock).mockReturnValue({ data: [{ id: '1' }] }) 38 | ;(useRouter as jest.Mock).mockReturnValue([]) 39 | ;(useSlugRoutes as jest.Mock).mockReturnValue({ assistantId: '1' }) 40 | 41 | render() 42 | 43 | expect(screen.getByText('EditAssistant')).toBeInTheDocument() 44 | expect(screen.queryByText('CreateAssistant')).toBeNull() 45 | }) 46 | -------------------------------------------------------------------------------- /ui/src/components/features/build-panel/components/__tests__/assistant-form.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | 3 | test.todo('implement') 4 | -------------------------------------------------------------------------------- /ui/src/components/features/build-panel/components/assistant-builder.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { EditAssistant } from './edit-assistant' 4 | import { CreateAssistant } from './create-assistant' 5 | import { useSlugRoutes } from '@/hooks/useSlugParams' 6 | 7 | export function AssistantBuilder() { 8 | const { assistantId } = useSlugRoutes() 9 | 10 | return <>{assistantId ? : } 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/components/features/build-panel/components/assistant-selector.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' 4 | import Spinner from '@/components/ui/spinner' 5 | import { useAssistants } from '@/data-provider/query-service' 6 | import { useSlugRoutes } from '@/hooks/useSlugParams' 7 | import { useRouter } from 'next/navigation' 8 | 9 | export function AssistantSelector() { 10 | const { assistantId } = useSlugRoutes() 11 | const { data: assistantsData, isLoading } = useAssistants() 12 | const router = useRouter() 13 | 14 | if (isLoading || !assistantsData) return 15 | 16 | const handleValueChange = (selectedAssistantId: string) => { 17 | router.push(`/a/${selectedAssistantId}`) 18 | } 19 | const selectedAssistant = assistantsData.find((assistant) => assistant.id === assistantId) 20 | 21 | return ( 22 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /ui/src/components/features/build-panel/components/build-panel.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { useState } from 'react' 3 | import { ChevronLeft, ChevronRight } from 'lucide-react' 4 | import { AssistantBuilder } from './assistant-builder' 5 | 6 | export default function BuildPanel() { 7 | const [isOpen, setIsOpen] = useState(true) 8 | 9 | const drawerStyles = { 10 | open: 'p-4 h-full flex flex-col gap-4 overflow-x-hidden sm:min-w-[520px]', 11 | closed: 'hidden', 12 | } 13 | 14 | return ( 15 |
16 |
17 | {isOpen ? ( 18 | setIsOpen((prev) => !prev)} /> 19 | ) : ( 20 | setIsOpen((prev) => !prev)} /> 21 | )} 22 |
23 |
24 | 25 |
26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /ui/src/components/features/build-panel/components/file-builder.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useFileStream } from '@/hooks/useFileStream' 4 | import FilesDialog from './files-dialog' 5 | import SelectFiles from './select-files' 6 | import Spinner from '@/components/ui/spinner' 7 | 8 | export default function FileBuilder() { 9 | const { startProgressStream, progressStream } = useFileStream() 10 | return ( 11 |
12 | 13 | 14 |
15 | {progressStream?.status === 'error' &&

Something went wrong when ingesting files.

} 16 | {progressStream?.status === 'inflight' && } 17 |
18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /ui/src/components/features/build-panel/components/form-select.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form' 4 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' 5 | import { UseFormReturn } from 'react-hook-form' 6 | 7 | type TFormSelectProps = { 8 | form: UseFormReturn 9 | title: string 10 | formName: string 11 | options: string[] 12 | placeholder: string 13 | } 14 | 15 | export function FormSelect({ form, title, options, formName, placeholder }: TFormSelectProps) { 16 | return ( 17 | ( 21 | 22 | {title} 23 | 24 | 36 | 37 | 38 | )} 39 | /> 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /ui/src/components/features/build-panel/components/public-switch.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form' 2 | import { Switch } from '@/components/ui/switch' 3 | import { UseFormReturn } from 'react-hook-form' 4 | 5 | type TPublicSwitchProps = { 6 | form: UseFormReturn 7 | } 8 | 9 | export default function PublicSwitch({ form }: TPublicSwitchProps) { 10 | return ( 11 | { 15 | return ( 16 | 17 | Public 18 | 19 | field.onChange(checked)} /> 20 | 21 | 22 | ) 23 | }} 24 | /> 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /ui/src/components/features/build-panel/components/retrieval-description.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { FormField, FormItem, FormLabel } from '@/components/ui/form' 4 | import { Textarea } from '@/components/ui/textarea' 5 | import { UseFormReturn } from 'react-hook-form' 6 | 7 | type TRetrievalInstructionsProps = { 8 | form: UseFormReturn 9 | } 10 | 11 | export function RetrievalInstructions({ form }: TRetrievalInstructionsProps) { 12 | return ( 13 | { 17 | return ( 18 | 19 | Retrieval Instructions 20 |