mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-11 18:02:42 +00:00
Compare commits
1 Commits
argus
...
jamison/sh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b32e2fd304 |
@@ -1,6 +1,7 @@
|
||||
FROM ubuntu:26.04@sha256:cc925e589b7543b910fea57a240468940003fbfc0515245a495dd0ad8fe7cef1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
acl \
|
||||
curl \
|
||||
fd-find \
|
||||
fzf \
|
||||
|
||||
@@ -14,6 +14,12 @@ A containerized development environment for working on Onyx.
|
||||
|
||||
## Usage
|
||||
|
||||
### VS Code
|
||||
|
||||
1. Install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
|
||||
2. Open this repo in VS Code
|
||||
3. "Reopen in Container" when prompted
|
||||
|
||||
### CLI (`ods dev`)
|
||||
|
||||
The [`ods` devtools CLI](../tools/ods/README.md) provides workspace-aware wrappers
|
||||
@@ -33,8 +39,25 @@ ods dev exec npm test
|
||||
ods dev stop
|
||||
```
|
||||
|
||||
If you don't have `ods` installed, use the `devcontainer` CLI directly:
|
||||
|
||||
```bash
|
||||
npm install -g @devcontainers/cli
|
||||
|
||||
devcontainer up --workspace-folder .
|
||||
devcontainer exec --workspace-folder . zsh
|
||||
```
|
||||
|
||||
## Restarting the container
|
||||
|
||||
### VS Code
|
||||
|
||||
Open the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and run:
|
||||
|
||||
- **Dev Containers: Reopen in Container** — restarts the container without rebuilding
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Restart the container
|
||||
ods dev restart
|
||||
@@ -43,6 +66,12 @@ ods dev restart
|
||||
ods dev rebuild
|
||||
```
|
||||
|
||||
Or without `ods`:
|
||||
|
||||
```bash
|
||||
devcontainer up --workspace-folder . --remove-existing-container
|
||||
```
|
||||
|
||||
## Image
|
||||
|
||||
The devcontainer uses a prebuilt image published to `onyxdotapp/onyx-devcontainer`.
|
||||
@@ -59,19 +88,15 @@ The `devcontainer` target is defined in `docker-bake.hcl` at the repo root.
|
||||
## User & permissions
|
||||
|
||||
The container runs as the `dev` user by default (`remoteUser` in devcontainer.json).
|
||||
An init script (`init-dev-user.sh`) runs at container start to ensure the active
|
||||
user has read/write access to the bind-mounted workspace:
|
||||
An init script (`init-dev-user.sh`) runs at container start to ensure `dev` has
|
||||
read/write access to the bind-mounted workspace:
|
||||
|
||||
- **Standard Docker** — `dev`'s UID/GID is remapped to match the workspace owner,
|
||||
so file permissions work seamlessly.
|
||||
- **Rootless Docker** — The workspace appears as root-owned (UID 0) inside the
|
||||
container due to user-namespace mapping. `ods dev up` auto-detects rootless Docker
|
||||
and sets `DEVCONTAINER_REMOTE_USER=root` so the container runs as root — which
|
||||
maps back to your host user via the user namespace. New files are owned by your
|
||||
host UID and no ACL workarounds are needed.
|
||||
|
||||
To override the auto-detection, set `DEVCONTAINER_REMOTE_USER` before running
|
||||
`ods dev up`.
|
||||
container due to user-namespace mapping. The init script grants `dev` access via
|
||||
POSIX ACLs (`setfacl`), which adds a few seconds to the first container start on
|
||||
large repos.
|
||||
|
||||
## Docker socket
|
||||
|
||||
@@ -84,7 +109,9 @@ from inside. `ods dev` auto-detects the socket path and sets `DOCKER_SOCK`:
|
||||
| macOS (Docker Desktop) | `~/.docker/run/docker.sock` |
|
||||
| Linux (standard Docker) | `/var/run/docker.sock` |
|
||||
|
||||
To override, set `DOCKER_SOCK` before running `ods dev up`.
|
||||
To override, set `DOCKER_SOCK` before running `ods dev up`. When using the
|
||||
VS Code extension or `devcontainer` CLI directly (without `ods`), you must set
|
||||
`DOCKER_SOCK` yourself.
|
||||
|
||||
## Firewall
|
||||
|
||||
|
||||
@@ -7,15 +7,13 @@
|
||||
"source=${localEnv:HOME}/.claude,target=/home/dev/.claude,type=bind",
|
||||
"source=${localEnv:HOME}/.claude.json,target=/home/dev/.claude.json,type=bind",
|
||||
"source=${localEnv:HOME}/.zshrc,target=/home/dev/.zshrc.host,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.gitconfig,target=/home/dev/.gitconfig,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.config/nvim,target=/home/dev/.config/nvim,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.gitconfig,target=/home/dev/.gitconfig.host,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.ssh,target=/home/dev/.ssh.host,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.config/nvim,target=/home/dev/.config/nvim.host,type=bind,readonly",
|
||||
"source=onyx-devcontainer-cache,target=/home/dev/.cache,type=volume",
|
||||
"source=onyx-devcontainer-local,target=/home/dev/.local,type=volume"
|
||||
],
|
||||
"containerEnv": {
|
||||
"SSH_AUTH_SOCK": "/tmp/ssh-agent.sock"
|
||||
},
|
||||
"remoteUser": "${localEnv:DEVCONTAINER_REMOTE_USER:dev}",
|
||||
"remoteUser": "dev",
|
||||
"updateRemoteUserUID": false,
|
||||
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
|
||||
"workspaceFolder": "/workspace",
|
||||
|
||||
@@ -8,68 +8,38 @@ set -euo pipefail
|
||||
# We remap dev to that UID -- fast and seamless.
|
||||
#
|
||||
# Rootless Docker: Workspace appears as root-owned (UID 0) inside the
|
||||
# container due to user-namespace mapping. Requires
|
||||
# DEVCONTAINER_REMOTE_USER=root (set automatically by
|
||||
# ods dev up). Container root IS the host user, so
|
||||
# bind-mounts and named volumes are symlinked into /root.
|
||||
# container due to user-namespace mapping. We can't remap
|
||||
# dev to UID 0 (that's root), so we grant access with
|
||||
# POSIX ACLs instead.
|
||||
|
||||
WORKSPACE=/workspace
|
||||
TARGET_USER=dev
|
||||
REMOTE_USER="${SUDO_USER:-$TARGET_USER}"
|
||||
|
||||
WS_UID=$(stat -c '%u' "$WORKSPACE")
|
||||
WS_GID=$(stat -c '%g' "$WORKSPACE")
|
||||
DEV_UID=$(id -u "$TARGET_USER")
|
||||
DEV_GID=$(id -g "$TARGET_USER")
|
||||
|
||||
# devcontainer.json bind-mounts and named volumes target /home/dev regardless
|
||||
# of remoteUser. When running as root ($HOME=/root), Phase 1 bridges the gap
|
||||
# with symlinks from ACTIVE_HOME → MOUNT_HOME.
|
||||
MOUNT_HOME=/home/"$TARGET_USER"
|
||||
DEV_HOME=/home/"$TARGET_USER"
|
||||
|
||||
if [ "$REMOTE_USER" = "root" ]; then
|
||||
ACTIVE_HOME="/root"
|
||||
else
|
||||
ACTIVE_HOME="$MOUNT_HOME"
|
||||
# Ensure directories that tools expect exist under ~dev.
|
||||
# ~/.local and ~/.cache are named Docker volumes -- ensure they are owned by dev.
|
||||
mkdir -p "$DEV_HOME"/.local/state "$DEV_HOME"/.local/share
|
||||
chown -R "$TARGET_USER":"$TARGET_USER" "$DEV_HOME"/.local
|
||||
chown -R "$TARGET_USER":"$TARGET_USER" "$DEV_HOME"/.cache
|
||||
|
||||
# Copy host configs mounted as *.host into their real locations.
|
||||
# This gives the dev user owned copies without touching host originals.
|
||||
if [ -d "$DEV_HOME/.ssh.host" ]; then
|
||||
cp -a "$DEV_HOME/.ssh.host" "$DEV_HOME/.ssh"
|
||||
chmod 700 "$DEV_HOME/.ssh"
|
||||
chmod 600 "$DEV_HOME"/.ssh/id_* 2>/dev/null || true
|
||||
chown -R "$TARGET_USER":"$TARGET_USER" "$DEV_HOME/.ssh"
|
||||
fi
|
||||
|
||||
# ── Phase 1: home directory setup ───────────────────────────────────
|
||||
|
||||
# ~/.local and ~/.cache are named Docker volumes mounted under MOUNT_HOME.
|
||||
mkdir -p "$MOUNT_HOME"/.local/state "$MOUNT_HOME"/.local/share
|
||||
|
||||
# When running as root, symlink bind-mounts and named volumes into /root
|
||||
# so that $HOME-relative tools (Claude Code, git, etc.) find them.
|
||||
if [ "$ACTIVE_HOME" != "$MOUNT_HOME" ]; then
|
||||
for item in .claude .cache .local; do
|
||||
[ -d "$MOUNT_HOME/$item" ] || continue
|
||||
if [ -e "$ACTIVE_HOME/$item" ] && [ ! -L "$ACTIVE_HOME/$item" ]; then
|
||||
echo "warning: replacing $ACTIVE_HOME/$item with symlink to $MOUNT_HOME/$item" >&2
|
||||
rm -rf "$ACTIVE_HOME/$item"
|
||||
fi
|
||||
ln -sfn "$MOUNT_HOME/$item" "$ACTIVE_HOME/$item"
|
||||
done
|
||||
# Symlink files (not directories).
|
||||
for file in .claude.json .gitconfig .zshrc.host; do
|
||||
[ -f "$MOUNT_HOME/$file" ] && ln -sf "$MOUNT_HOME/$file" "$ACTIVE_HOME/$file"
|
||||
done
|
||||
|
||||
# Nested mount: .config/nvim
|
||||
if [ -d "$MOUNT_HOME/.config/nvim" ]; then
|
||||
mkdir -p "$ACTIVE_HOME/.config"
|
||||
if [ -e "$ACTIVE_HOME/.config/nvim" ] && [ ! -L "$ACTIVE_HOME/.config/nvim" ]; then
|
||||
echo "warning: replacing $ACTIVE_HOME/.config/nvim with symlink" >&2
|
||||
rm -rf "$ACTIVE_HOME/.config/nvim"
|
||||
fi
|
||||
ln -sfn "$MOUNT_HOME/.config/nvim" "$ACTIVE_HOME/.config/nvim"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 2: workspace access ───────────────────────────────────────
|
||||
|
||||
# Root always has workspace access; Phase 1 handled home setup.
|
||||
if [ "$REMOTE_USER" = "root" ]; then
|
||||
exit 0
|
||||
if [ -d "$DEV_HOME/.config/nvim.host" ]; then
|
||||
mkdir -p "$DEV_HOME/.config"
|
||||
cp -a "$DEV_HOME/.config/nvim.host" "$DEV_HOME/.config/nvim"
|
||||
chown -R "$TARGET_USER":"$TARGET_USER" "$DEV_HOME/.config/nvim"
|
||||
fi
|
||||
|
||||
# Already matching -- nothing to do.
|
||||
@@ -91,17 +61,46 @@ if [ "$WS_UID" != "0" ]; then
|
||||
echo "warning: failed to remap $TARGET_USER UID to $WS_UID" >&2
|
||||
fi
|
||||
fi
|
||||
if ! chown -R "$TARGET_USER":"$TARGET_USER" "$MOUNT_HOME" 2>&1; then
|
||||
echo "warning: failed to chown $MOUNT_HOME" >&2
|
||||
if ! chown -R "$TARGET_USER":"$TARGET_USER" /home/"$TARGET_USER" 2>&1; then
|
||||
echo "warning: failed to chown /home/$TARGET_USER" >&2
|
||||
fi
|
||||
else
|
||||
# ── Rootless Docker ──────────────────────────────────────────────
|
||||
# Workspace is root-owned (UID 0) due to user-namespace mapping.
|
||||
# The supported path is remoteUser=root (set DEVCONTAINER_REMOTE_USER=root),
|
||||
# which is handled above. If we reach here, the user is running as dev
|
||||
# under rootless Docker without the override.
|
||||
echo "error: rootless Docker detected but remoteUser is not root." >&2
|
||||
echo " Set DEVCONTAINER_REMOTE_USER=root before starting the container," >&2
|
||||
echo " or use 'ods dev up' which sets it automatically." >&2
|
||||
exit 1
|
||||
# Workspace is root-owned inside the container. Grant dev access
|
||||
# via POSIX ACLs (preserves ownership, works across the namespace
|
||||
# boundary).
|
||||
if command -v setfacl &>/dev/null; then
|
||||
setfacl -Rm "u:${TARGET_USER}:rwX" "$WORKSPACE"
|
||||
setfacl -Rdm "u:${TARGET_USER}:rwX" "$WORKSPACE" # default ACL for new files
|
||||
|
||||
# Git refuses to operate in repos owned by a different UID.
|
||||
# Host gitconfig is mounted readonly as ~/.gitconfig.host.
|
||||
# Create a real ~/.gitconfig that includes it plus container overrides.
|
||||
printf '[include]\n\tpath = %s/.gitconfig.host\n[safe]\n\tdirectory = %s\n' \
|
||||
"$DEV_HOME" "$WORKSPACE" > "$DEV_HOME/.gitconfig"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$DEV_HOME/.gitconfig"
|
||||
|
||||
# If this is a worktree, the main .git dir is bind-mounted at its
|
||||
# host absolute path. Grant dev access so git operations work.
|
||||
GIT_COMMON_DIR=$(git -C "$WORKSPACE" rev-parse --git-common-dir 2>/dev/null || true)
|
||||
if [ -n "$GIT_COMMON_DIR" ] && [ "$GIT_COMMON_DIR" != "$WORKSPACE/.git" ]; then
|
||||
[ ! -d "$GIT_COMMON_DIR" ] && GIT_COMMON_DIR="$WORKSPACE/$GIT_COMMON_DIR"
|
||||
if [ -d "$GIT_COMMON_DIR" ]; then
|
||||
setfacl -Rm "u:${TARGET_USER}:rwX" "$GIT_COMMON_DIR"
|
||||
setfacl -Rdm "u:${TARGET_USER}:rwX" "$GIT_COMMON_DIR"
|
||||
git config -f "$DEV_HOME/.gitconfig" --add safe.directory "$(dirname "$GIT_COMMON_DIR")"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Also fix bind-mounted dirs under ~dev that appear root-owned.
|
||||
dir="/home/${TARGET_USER}/.claude"
|
||||
if [ -d "$dir" ]; then
|
||||
setfacl -Rm "u:${TARGET_USER}:rwX" "$dir" && setfacl -Rdm "u:${TARGET_USER}:rwX" "$dir"
|
||||
fi
|
||||
[ -f /home/"$TARGET_USER"/.claude.json ] && \
|
||||
setfacl -m "u:${TARGET_USER}:rw" /home/"$TARGET_USER"/.claude.json
|
||||
else
|
||||
echo "warning: setfacl not found; dev user may not have write access to workspace" >&2
|
||||
echo " install the 'acl' package or set remoteUser to root" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -86,6 +86,17 @@ repos:
|
||||
hooks:
|
||||
- id: actionlint
|
||||
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
rev: 745eface02aef23e168a8afb6b5737818efbea95 # frozen: v0.11.0.1
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
exclude: >-
|
||||
(?x)^(
|
||||
backend/scripts/setup_craft_templates\.sh|
|
||||
deployment/docker_compose/init-letsencrypt\.sh|
|
||||
deployment/docker_compose/install\.sh
|
||||
)$
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 8a737e727ac5ab2f1d4cf5876720ed276dc8dc4b # frozen: 25.1.0
|
||||
hooks:
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
"""add proposal review tables
|
||||
|
||||
Revision ID: 61ea78857c97
|
||||
Revises: c7bf5721733e
|
||||
Create Date: 2026-04-09 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
import fastapi_users_db_sqlalchemy
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "61ea78857c97"
|
||||
down_revision = "c7bf5721733e"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# -- proposal_review_ruleset --
|
||||
op.create_table(
|
||||
"proposal_review_ruleset",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("tenant_id", sa.Text(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"is_default",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("false"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("true"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_by",
|
||||
fastapi_users_db_sqlalchemy.generics.GUID(),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["user.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_proposal_review_ruleset_tenant_id",
|
||||
"proposal_review_ruleset",
|
||||
["tenant_id"],
|
||||
)
|
||||
|
||||
# -- proposal_review_rule --
|
||||
op.create_table(
|
||||
"proposal_review_rule",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"ruleset_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("category", sa.Text(), nullable=True),
|
||||
sa.Column("rule_type", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"rule_intent",
|
||||
sa.Text(),
|
||||
server_default=sa.text("'CHECK'"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("prompt_template", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"source",
|
||||
sa.Text(),
|
||||
server_default=sa.text("'MANUAL'"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("authority", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"is_hard_stop",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("false"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"priority",
|
||||
sa.Integer(),
|
||||
server_default=sa.text("0"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("true"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["ruleset_id"],
|
||||
["proposal_review_ruleset.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_proposal_review_rule_ruleset_id",
|
||||
"proposal_review_rule",
|
||||
["ruleset_id"],
|
||||
)
|
||||
|
||||
# -- proposal_review_proposal --
|
||||
op.create_table(
|
||||
"proposal_review_proposal",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("document_id", sa.Text(), nullable=False),
|
||||
sa.Column("tenant_id", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Text(),
|
||||
server_default=sa.text("'PENDING'"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("document_id", "tenant_id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_proposal_review_proposal_tenant_id",
|
||||
"proposal_review_proposal",
|
||||
["tenant_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_proposal_review_proposal_document_id",
|
||||
"proposal_review_proposal",
|
||||
["document_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_proposal_review_proposal_status",
|
||||
"proposal_review_proposal",
|
||||
["status"],
|
||||
)
|
||||
|
||||
# -- proposal_review_run --
|
||||
op.create_table(
|
||||
"proposal_review_run",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"proposal_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"ruleset_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"triggered_by",
|
||||
fastapi_users_db_sqlalchemy.generics.GUID(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Text(),
|
||||
server_default=sa.text("'PENDING'"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("total_rules", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"completed_rules",
|
||||
sa.Integer(),
|
||||
server_default=sa.text("0"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["proposal_id"],
|
||||
["proposal_review_proposal.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["ruleset_id"],
|
||||
["proposal_review_ruleset.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(["triggered_by"], ["user.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_proposal_review_run_proposal_id",
|
||||
"proposal_review_run",
|
||||
["proposal_id"],
|
||||
)
|
||||
|
||||
# -- proposal_review_finding --
|
||||
op.create_table(
|
||||
"proposal_review_finding",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"proposal_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"rule_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"review_run_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("verdict", sa.Text(), nullable=False),
|
||||
sa.Column("confidence", sa.Text(), nullable=True),
|
||||
sa.Column("evidence", sa.Text(), nullable=True),
|
||||
sa.Column("explanation", sa.Text(), nullable=True),
|
||||
sa.Column("suggested_action", sa.Text(), nullable=True),
|
||||
sa.Column("llm_model", sa.Text(), nullable=True),
|
||||
sa.Column("llm_tokens_used", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["proposal_id"],
|
||||
["proposal_review_proposal.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["rule_id"],
|
||||
["proposal_review_rule.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["review_run_id"],
|
||||
["proposal_review_run.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_proposal_review_finding_proposal_id",
|
||||
"proposal_review_finding",
|
||||
["proposal_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_proposal_review_finding_review_run_id",
|
||||
"proposal_review_finding",
|
||||
["review_run_id"],
|
||||
)
|
||||
|
||||
# -- proposal_review_decision (per-finding) --
|
||||
op.create_table(
|
||||
"proposal_review_decision",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"finding_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"officer_id",
|
||||
fastapi_users_db_sqlalchemy.generics.GUID(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("action", sa.Text(), nullable=False),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["finding_id"],
|
||||
["proposal_review_finding.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["officer_id"], ["user.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("finding_id"),
|
||||
)
|
||||
|
||||
# -- proposal_review_proposal_decision --
|
||||
op.create_table(
|
||||
"proposal_review_proposal_decision",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"proposal_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"officer_id",
|
||||
fastapi_users_db_sqlalchemy.generics.GUID(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("decision", sa.Text(), nullable=False),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"jira_synced",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("false"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("jira_synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["proposal_id"],
|
||||
["proposal_review_proposal.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["officer_id"], ["user.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_proposal_review_proposal_decision_proposal_id",
|
||||
"proposal_review_proposal_decision",
|
||||
["proposal_id"],
|
||||
)
|
||||
|
||||
# -- proposal_review_document --
|
||||
op.create_table(
|
||||
"proposal_review_document",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"proposal_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("file_name", sa.Text(), nullable=False),
|
||||
sa.Column("file_type", sa.Text(), nullable=True),
|
||||
sa.Column("file_store_id", sa.Text(), nullable=True),
|
||||
sa.Column("extracted_text", sa.Text(), nullable=True),
|
||||
sa.Column("document_role", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"uploaded_by",
|
||||
fastapi_users_db_sqlalchemy.generics.GUID(),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["proposal_id"],
|
||||
["proposal_review_proposal.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["uploaded_by"], ["user.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_proposal_review_document_proposal_id",
|
||||
"proposal_review_document",
|
||||
["proposal_id"],
|
||||
)
|
||||
|
||||
# -- proposal_review_audit_log --
|
||||
op.create_table(
|
||||
"proposal_review_audit_log",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"proposal_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
fastapi_users_db_sqlalchemy.generics.GUID(),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("action", sa.Text(), nullable=False),
|
||||
sa.Column("details", postgresql.JSONB(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["proposal_id"],
|
||||
["proposal_review_proposal.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_proposal_review_audit_log_proposal_id",
|
||||
"proposal_review_audit_log",
|
||||
["proposal_id"],
|
||||
)
|
||||
|
||||
# -- proposal_review_config --
|
||||
op.create_table(
|
||||
"proposal_review_config",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("tenant_id", sa.Text(), nullable=False, unique=True),
|
||||
sa.Column("jira_connector_id", sa.Integer(), nullable=True),
|
||||
sa.Column("jira_project_key", sa.Text(), nullable=True),
|
||||
sa.Column("field_mapping", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("jira_writeback", postgresql.JSONB(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("proposal_review_config")
|
||||
op.drop_table("proposal_review_audit_log")
|
||||
op.drop_table("proposal_review_document")
|
||||
op.drop_table("proposal_review_proposal_decision")
|
||||
op.drop_table("proposal_review_decision")
|
||||
op.drop_table("proposal_review_finding")
|
||||
op.drop_table("proposal_review_run")
|
||||
op.drop_table("proposal_review_proposal")
|
||||
op.drop_table("proposal_review_rule")
|
||||
op.drop_table("proposal_review_ruleset")
|
||||
@@ -13,7 +13,6 @@ from ee.onyx.server.license.models import LicenseSource
|
||||
from onyx.auth.schemas import UserRole
|
||||
from onyx.cache.factory import get_cache_backend
|
||||
from onyx.configs.constants import ANONYMOUS_USER_EMAIL
|
||||
from onyx.db.enums import AccountType
|
||||
from onyx.db.models import License
|
||||
from onyx.db.models import User
|
||||
from onyx.utils.logger import setup_logger
|
||||
@@ -108,13 +107,12 @@ def get_used_seats(tenant_id: str | None = None) -> int:
|
||||
Get current seat usage directly from database.
|
||||
|
||||
For multi-tenant: counts users in UserTenantMapping for this tenant.
|
||||
For self-hosted: counts all active users.
|
||||
For self-hosted: counts all active users (excludes EXT_PERM_USER role
|
||||
and the anonymous system user).
|
||||
|
||||
Only human accounts count toward seat limits.
|
||||
SERVICE_ACCOUNT (API key dummy users), EXT_PERM_USER, and the
|
||||
anonymous system user are excluded. BOT (Slack users) ARE counted
|
||||
because they represent real humans and get upgraded to STANDARD
|
||||
when they log in via web.
|
||||
TODO: Exclude API key dummy users from seat counting. API keys create
|
||||
users with emails like `__DANSWER_API_KEY_*` that should not count toward
|
||||
seat limits. See: https://linear.app/onyx-app/issue/ENG-3518
|
||||
"""
|
||||
if MULTI_TENANT:
|
||||
from ee.onyx.server.tenants.user_mapping import get_tenant_count
|
||||
@@ -131,7 +129,6 @@ def get_used_seats(tenant_id: str | None = None) -> int:
|
||||
User.is_active == True, # type: ignore # noqa: E712
|
||||
User.role != UserRole.EXT_PERM_USER,
|
||||
User.email != ANONYMOUS_USER_EMAIL, # type: ignore
|
||||
User.account_type != AccountType.SERVICE_ACCOUNT,
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
@@ -322,7 +322,6 @@ celery_app.autodiscover_tasks(
|
||||
"onyx.background.celery.tasks.vespa",
|
||||
"onyx.background.celery.tasks.llm_model_update",
|
||||
"onyx.background.celery.tasks.user_file_processing",
|
||||
"onyx.server.features.proposal_review.engine",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
@@ -42,9 +42,6 @@ from onyx.connectors.google_drive.file_retrieval import (
|
||||
get_all_files_in_my_drive_and_shared,
|
||||
)
|
||||
from onyx.connectors.google_drive.file_retrieval import get_external_access_for_folder
|
||||
from onyx.connectors.google_drive.file_retrieval import (
|
||||
get_files_by_web_view_links_batch,
|
||||
)
|
||||
from onyx.connectors.google_drive.file_retrieval import get_files_in_shared_drive
|
||||
from onyx.connectors.google_drive.file_retrieval import get_folder_metadata
|
||||
from onyx.connectors.google_drive.file_retrieval import get_root_folder_id
|
||||
@@ -73,13 +70,11 @@ from onyx.connectors.interfaces import CheckpointedConnectorWithPermSync
|
||||
from onyx.connectors.interfaces import CheckpointOutput
|
||||
from onyx.connectors.interfaces import GenerateSlimDocumentOutput
|
||||
from onyx.connectors.interfaces import NormalizationResult
|
||||
from onyx.connectors.interfaces import Resolver
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
from onyx.connectors.interfaces import SlimConnectorWithPermSync
|
||||
from onyx.connectors.models import ConnectorFailure
|
||||
from onyx.connectors.models import ConnectorMissingCredentialError
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import DocumentFailure
|
||||
from onyx.connectors.models import EntityFailure
|
||||
from onyx.connectors.models import HierarchyNode
|
||||
from onyx.connectors.models import SlimDocument
|
||||
@@ -207,9 +202,7 @@ class DriveIdStatus(Enum):
|
||||
|
||||
|
||||
class GoogleDriveConnector(
|
||||
SlimConnectorWithPermSync,
|
||||
CheckpointedConnectorWithPermSync[GoogleDriveCheckpoint],
|
||||
Resolver,
|
||||
SlimConnectorWithPermSync, CheckpointedConnectorWithPermSync[GoogleDriveCheckpoint]
|
||||
):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1672,82 +1665,6 @@ class GoogleDriveConnector(
|
||||
start, end, checkpoint, include_permissions=True
|
||||
)
|
||||
|
||||
@override
|
||||
def resolve_errors(
|
||||
self,
|
||||
errors: list[ConnectorFailure],
|
||||
include_permissions: bool = False,
|
||||
) -> Generator[Document | ConnectorFailure | HierarchyNode, None, None]:
|
||||
if self._creds is None or self._primary_admin_email is None:
|
||||
raise RuntimeError(
|
||||
"Credentials missing, should not call this method before calling load_credentials"
|
||||
)
|
||||
|
||||
logger.info(f"Resolving {len(errors)} errors")
|
||||
doc_ids = [
|
||||
failure.failed_document.document_id
|
||||
for failure in errors
|
||||
if failure.failed_document
|
||||
]
|
||||
service = get_drive_service(self.creds, self.primary_admin_email)
|
||||
field_type = (
|
||||
DriveFileFieldType.WITH_PERMISSIONS
|
||||
if include_permissions or self.exclude_domain_link_only
|
||||
else DriveFileFieldType.STANDARD
|
||||
)
|
||||
batch_result = get_files_by_web_view_links_batch(service, doc_ids, field_type)
|
||||
|
||||
for doc_id, error in batch_result.errors.items():
|
||||
yield ConnectorFailure(
|
||||
failed_document=DocumentFailure(
|
||||
document_id=doc_id,
|
||||
document_link=doc_id,
|
||||
),
|
||||
failure_message=f"Failed to retrieve file during error resolution: {error}",
|
||||
exception=error,
|
||||
)
|
||||
|
||||
permission_sync_context = (
|
||||
PermissionSyncContext(
|
||||
primary_admin_email=self.primary_admin_email,
|
||||
google_domain=self.google_domain,
|
||||
)
|
||||
if include_permissions
|
||||
else None
|
||||
)
|
||||
|
||||
retrieved_files = [
|
||||
RetrievedDriveFile(
|
||||
drive_file=file,
|
||||
user_email=self.primary_admin_email,
|
||||
completion_stage=DriveRetrievalStage.DONE,
|
||||
)
|
||||
for file in batch_result.files.values()
|
||||
]
|
||||
|
||||
yield from self._get_new_ancestors_for_files(
|
||||
files=retrieved_files,
|
||||
seen_hierarchy_node_raw_ids=ThreadSafeSet(),
|
||||
fully_walked_hierarchy_node_raw_ids=ThreadSafeSet(),
|
||||
permission_sync_context=permission_sync_context,
|
||||
add_prefix=True,
|
||||
)
|
||||
|
||||
func_with_args = [
|
||||
(
|
||||
self._convert_retrieved_file_to_document,
|
||||
(rf, permission_sync_context),
|
||||
)
|
||||
for rf in retrieved_files
|
||||
]
|
||||
results = cast(
|
||||
list[Document | ConnectorFailure | None],
|
||||
run_functions_tuples_in_parallel(func_with_args, max_workers=8),
|
||||
)
|
||||
for result in results:
|
||||
if result is not None:
|
||||
yield result
|
||||
|
||||
def _extract_slim_docs_from_google_drive(
|
||||
self,
|
||||
checkpoint: GoogleDriveCheckpoint,
|
||||
|
||||
@@ -9,7 +9,6 @@ from urllib.parse import urlparse
|
||||
|
||||
from googleapiclient.discovery import Resource # type: ignore
|
||||
from googleapiclient.errors import HttpError # type: ignore
|
||||
from googleapiclient.http import BatchHttpRequest # type: ignore
|
||||
|
||||
from onyx.access.models import ExternalAccess
|
||||
from onyx.connectors.google_drive.constants import DRIVE_FOLDER_TYPE
|
||||
@@ -61,8 +60,6 @@ SLIM_FILE_FIELDS = (
|
||||
)
|
||||
FOLDER_FIELDS = "nextPageToken, files(id, name, permissions, modifiedTime, webViewLink, shortcutDetails)"
|
||||
|
||||
MAX_BATCH_SIZE = 100
|
||||
|
||||
HIERARCHY_FIELDS = "id, name, parents, webViewLink, mimeType, driveId"
|
||||
|
||||
HIERARCHY_FIELDS_WITH_PERMISSIONS = (
|
||||
@@ -219,7 +216,7 @@ def get_external_access_for_folder(
|
||||
|
||||
|
||||
def _get_fields_for_file_type(field_type: DriveFileFieldType) -> str:
|
||||
"""Get the appropriate fields string for files().list() based on the field type enum."""
|
||||
"""Get the appropriate fields string based on the field type enum"""
|
||||
if field_type == DriveFileFieldType.SLIM:
|
||||
return SLIM_FILE_FIELDS
|
||||
elif field_type == DriveFileFieldType.WITH_PERMISSIONS:
|
||||
@@ -228,25 +225,6 @@ def _get_fields_for_file_type(field_type: DriveFileFieldType) -> str:
|
||||
return FILE_FIELDS
|
||||
|
||||
|
||||
def _extract_single_file_fields(list_fields: str) -> str:
|
||||
"""Convert a files().list() fields string to one suitable for files().get().
|
||||
|
||||
List fields look like "nextPageToken, files(field1, field2, ...)"
|
||||
Single-file fields should be just "field1, field2, ..."
|
||||
"""
|
||||
start = list_fields.find("files(")
|
||||
if start == -1:
|
||||
return list_fields
|
||||
inner_start = start + len("files(")
|
||||
inner_end = list_fields.rfind(")")
|
||||
return list_fields[inner_start:inner_end]
|
||||
|
||||
|
||||
def _get_single_file_fields(field_type: DriveFileFieldType) -> str:
|
||||
"""Get the appropriate fields string for files().get() based on the field type enum."""
|
||||
return _extract_single_file_fields(_get_fields_for_file_type(field_type))
|
||||
|
||||
|
||||
def _get_files_in_parent(
|
||||
service: Resource,
|
||||
parent_id: str,
|
||||
@@ -558,74 +536,3 @@ def get_file_by_web_view_link(
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
|
||||
class BatchRetrievalResult:
|
||||
"""Result of a batch file retrieval, separating successes from errors."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.files: dict[str, GoogleDriveFileType] = {}
|
||||
self.errors: dict[str, Exception] = {}
|
||||
|
||||
|
||||
def get_files_by_web_view_links_batch(
|
||||
service: GoogleDriveService,
|
||||
web_view_links: list[str],
|
||||
field_type: DriveFileFieldType,
|
||||
) -> BatchRetrievalResult:
|
||||
"""Retrieve multiple Google Drive files by webViewLink using the batch API.
|
||||
|
||||
Returns a BatchRetrievalResult containing successful file retrievals
|
||||
and errors for any files that could not be fetched.
|
||||
Automatically splits into chunks of MAX_BATCH_SIZE.
|
||||
"""
|
||||
fields = _get_single_file_fields(field_type)
|
||||
if len(web_view_links) <= MAX_BATCH_SIZE:
|
||||
return _get_files_by_web_view_links_batch(service, web_view_links, fields)
|
||||
|
||||
combined = BatchRetrievalResult()
|
||||
for i in range(0, len(web_view_links), MAX_BATCH_SIZE):
|
||||
chunk = web_view_links[i : i + MAX_BATCH_SIZE]
|
||||
chunk_result = _get_files_by_web_view_links_batch(service, chunk, fields)
|
||||
combined.files.update(chunk_result.files)
|
||||
combined.errors.update(chunk_result.errors)
|
||||
return combined
|
||||
|
||||
|
||||
def _get_files_by_web_view_links_batch(
|
||||
service: GoogleDriveService,
|
||||
web_view_links: list[str],
|
||||
fields: str,
|
||||
) -> BatchRetrievalResult:
|
||||
"""Single-batch implementation."""
|
||||
|
||||
result = BatchRetrievalResult()
|
||||
|
||||
def callback(
|
||||
request_id: str,
|
||||
response: GoogleDriveFileType,
|
||||
exception: Exception | None,
|
||||
) -> None:
|
||||
if exception:
|
||||
logger.warning(f"Error retrieving file {request_id}: {exception}")
|
||||
result.errors[request_id] = exception
|
||||
else:
|
||||
result.files[request_id] = response
|
||||
|
||||
batch = cast(BatchHttpRequest, service.new_batch_http_request(callback=callback))
|
||||
|
||||
for web_view_link in web_view_links:
|
||||
try:
|
||||
file_id = _extract_file_id_from_web_view_link(web_view_link)
|
||||
request = service.files().get(
|
||||
fileId=file_id,
|
||||
supportsAllDrives=True,
|
||||
fields=fields,
|
||||
)
|
||||
batch.add(request, request_id=web_view_link)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to extract file ID from {web_view_link}: {e}")
|
||||
result.errors[web_view_link] = e
|
||||
|
||||
batch.execute()
|
||||
return result
|
||||
|
||||
@@ -298,22 +298,6 @@ class CheckpointedConnectorWithPermSync(CheckpointedConnector[CT]):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Resolver(BaseConnector):
|
||||
@abc.abstractmethod
|
||||
def resolve_errors(
|
||||
self,
|
||||
errors: list[ConnectorFailure],
|
||||
include_permissions: bool = False,
|
||||
) -> Generator[Document | ConnectorFailure | HierarchyNode, None, None]:
|
||||
"""Attempts to yield back ALL the documents described by the errors, no checkpointing.
|
||||
|
||||
Caller's responsibility is to delete the old ConnectorFailures and replace with the new ones.
|
||||
If include_permissions is True, the documents will have permissions synced.
|
||||
May also yield HierarchyNode objects for ancestor folders of resolved documents.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class HierarchyConnector(BaseConnector):
|
||||
@abc.abstractmethod
|
||||
def load_hierarchy(
|
||||
|
||||
@@ -8,7 +8,6 @@ from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from datetime import timezone
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
@@ -41,7 +40,6 @@ from onyx.connectors.jira.utils import best_effort_basic_expert_info
|
||||
from onyx.connectors.jira.utils import best_effort_get_field_from_issue
|
||||
from onyx.connectors.jira.utils import build_jira_client
|
||||
from onyx.connectors.jira.utils import build_jira_url
|
||||
from onyx.connectors.jira.utils import CustomFieldExtractor
|
||||
from onyx.connectors.jira.utils import extract_text_from_adf
|
||||
from onyx.connectors.jira.utils import get_comment_strs
|
||||
from onyx.connectors.jira.utils import JIRA_CLOUD_API_VERSION
|
||||
@@ -54,7 +52,6 @@ from onyx.connectors.models import HierarchyNode
|
||||
from onyx.connectors.models import SlimDocument
|
||||
from onyx.connectors.models import TextSection
|
||||
from onyx.db.enums import HierarchyNodeType
|
||||
from onyx.file_processing.extract_file_text import extract_file_text
|
||||
from onyx.indexing.indexing_heartbeat import IndexingHeartbeatInterface
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
@@ -63,11 +60,8 @@ logger = setup_logger()
|
||||
|
||||
ONE_HOUR = 3600
|
||||
|
||||
_MAX_RESULTS_FETCH_IDS = 5000
|
||||
_MAX_RESULTS_FETCH_IDS = 5000 # 5000
|
||||
_JIRA_FULL_PAGE_SIZE = 50
|
||||
# https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/
|
||||
_JIRA_BULK_FETCH_LIMIT = 100
|
||||
_MAX_ATTACHMENT_SIZE_BYTES = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
# Constants for Jira field names
|
||||
_FIELD_REPORTER = "reporter"
|
||||
@@ -261,13 +255,15 @@ def _bulk_fetch_request(
|
||||
return resp.json()["issues"]
|
||||
|
||||
|
||||
def _bulk_fetch_batch(
|
||||
jira_client: JIRA, issue_ids: list[str], fields: str | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch a single batch (must be <= _JIRA_BULK_FETCH_LIMIT).
|
||||
On JSONDecodeError, recursively bisects until it succeeds or reaches size 1."""
|
||||
def bulk_fetch_issues(
|
||||
jira_client: JIRA, issue_ids: list[str], fields: str | None = None
|
||||
) -> list[Issue]:
|
||||
# TODO(evan): move away from this jira library if they continue to not support
|
||||
# the endpoints we need. Using private fields is not ideal, but
|
||||
# is likely fine for now since we pin the library version
|
||||
|
||||
try:
|
||||
return _bulk_fetch_request(jira_client, issue_ids, fields)
|
||||
raw_issues = _bulk_fetch_request(jira_client, issue_ids, fields)
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
if len(issue_ids) <= 1:
|
||||
logger.exception(
|
||||
@@ -281,25 +277,12 @@ def _bulk_fetch_batch(
|
||||
f"Jira bulk-fetch JSON decode failed for batch of {len(issue_ids)} issues. "
|
||||
f"Splitting into sub-batches of {mid} and {len(issue_ids) - mid}."
|
||||
)
|
||||
left = _bulk_fetch_batch(jira_client, issue_ids[:mid], fields)
|
||||
right = _bulk_fetch_batch(jira_client, issue_ids[mid:], fields)
|
||||
left = bulk_fetch_issues(jira_client, issue_ids[:mid], fields)
|
||||
right = bulk_fetch_issues(jira_client, issue_ids[mid:], fields)
|
||||
return left + right
|
||||
|
||||
|
||||
def bulk_fetch_issues(
|
||||
jira_client: JIRA, issue_ids: list[str], fields: str | None = None
|
||||
) -> list[Issue]:
|
||||
# TODO(evan): move away from this jira library if they continue to not support
|
||||
# the endpoints we need. Using private fields is not ideal, but
|
||||
# is likely fine for now since we pin the library version
|
||||
|
||||
raw_issues: list[dict[str, Any]] = []
|
||||
for batch in chunked(issue_ids, _JIRA_BULK_FETCH_LIMIT):
|
||||
try:
|
||||
raw_issues.extend(_bulk_fetch_batch(jira_client, list(batch), fields))
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching issues: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching issues: {e}")
|
||||
raise
|
||||
|
||||
return [
|
||||
Issue(jira_client._options, jira_client._session, raw=issue)
|
||||
@@ -381,7 +364,6 @@ def process_jira_issue(
|
||||
comment_email_blacklist: tuple[str, ...] = (),
|
||||
labels_to_skip: set[str] | None = None,
|
||||
parent_hierarchy_raw_node_id: str | None = None,
|
||||
custom_fields_mapping: dict[str, str] | None = None,
|
||||
) -> Document | None:
|
||||
if labels_to_skip:
|
||||
if any(label in issue.fields.labels for label in labels_to_skip):
|
||||
@@ -467,24 +449,6 @@ def process_jira_issue(
|
||||
else:
|
||||
logger.error(f"Project should exist but does not for {issue.key}")
|
||||
|
||||
# Merge custom fields into metadata if a mapping was provided
|
||||
if custom_fields_mapping:
|
||||
try:
|
||||
custom_fields = CustomFieldExtractor.get_issue_custom_fields(
|
||||
issue, custom_fields_mapping
|
||||
)
|
||||
# Filter out custom fields that collide with existing metadata keys
|
||||
for key in list(custom_fields.keys()):
|
||||
if key in metadata_dict:
|
||||
logger.warning(
|
||||
f"Custom field '{key}' on {issue.key} collides with "
|
||||
f"standard metadata key; skipping custom field value"
|
||||
)
|
||||
del custom_fields[key]
|
||||
metadata_dict.update(custom_fields)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract custom fields for {issue.key}: {e}")
|
||||
|
||||
return Document(
|
||||
id=page_url,
|
||||
sections=[TextSection(link=page_url, text=ticket_content)],
|
||||
@@ -527,12 +491,6 @@ class JiraConnector(
|
||||
# Custom JQL query to filter Jira issues
|
||||
jql_query: str | None = None,
|
||||
scoped_token: bool = False,
|
||||
# When True, extract custom fields from Jira issues and include them
|
||||
# in document metadata with human-readable field names.
|
||||
extract_custom_fields: bool = False,
|
||||
# When True, download attachments from Jira issues and yield them
|
||||
# as separate Documents linked to the parent ticket.
|
||||
fetch_attachments: bool = False,
|
||||
) -> None:
|
||||
self.batch_size = batch_size
|
||||
|
||||
@@ -546,11 +504,7 @@ class JiraConnector(
|
||||
self.labels_to_skip = set(labels_to_skip)
|
||||
self.jql_query = jql_query
|
||||
self.scoped_token = scoped_token
|
||||
self.extract_custom_fields = extract_custom_fields
|
||||
self.fetch_attachments = fetch_attachments
|
||||
self._jira_client: JIRA | None = None
|
||||
# Mapping of custom field IDs to human-readable names (populated on load_credentials)
|
||||
self._custom_fields_mapping: dict[str, str] = {}
|
||||
# Cache project permissions to avoid fetching them repeatedly across runs
|
||||
self._project_permissions_cache: dict[str, Any] = {}
|
||||
|
||||
@@ -711,134 +665,12 @@ class JiraConnector(
|
||||
# the document belongs directly under the project in the hierarchy
|
||||
return project_key
|
||||
|
||||
def _process_attachments(
|
||||
self,
|
||||
issue: Issue,
|
||||
parent_hierarchy_raw_node_id: str | None,
|
||||
include_permissions: bool = False,
|
||||
project_key: str | None = None,
|
||||
) -> Generator[Document | ConnectorFailure, None, None]:
|
||||
"""Download and yield Documents for each attachment on a Jira issue.
|
||||
|
||||
Each attachment becomes a separate Document whose text is extracted
|
||||
from the downloaded file content. Failures on individual attachments
|
||||
are logged and yielded as ConnectorFailure so they never break the
|
||||
overall indexing run.
|
||||
"""
|
||||
attachments = best_effort_get_field_from_issue(issue, "attachment")
|
||||
if not attachments:
|
||||
return
|
||||
|
||||
issue_url = build_jira_url(self.jira_base, issue.key)
|
||||
|
||||
for attachment in attachments:
|
||||
try:
|
||||
filename = getattr(attachment, "filename", "unknown")
|
||||
try:
|
||||
size = int(getattr(attachment, "size", 0) or 0)
|
||||
except (ValueError, TypeError):
|
||||
size = 0
|
||||
content_url = getattr(attachment, "content", None)
|
||||
attachment_id = getattr(attachment, "id", filename)
|
||||
mime_type = getattr(attachment, "mimeType", "application/octet-stream")
|
||||
created = getattr(attachment, "created", None)
|
||||
|
||||
if size > _MAX_ATTACHMENT_SIZE_BYTES:
|
||||
logger.warning(
|
||||
f"Skipping attachment '{filename}' on {issue.key}: "
|
||||
f"size {size} bytes exceeds {_MAX_ATTACHMENT_SIZE_BYTES} byte limit"
|
||||
)
|
||||
continue
|
||||
|
||||
if not content_url:
|
||||
logger.warning(
|
||||
f"Skipping attachment '{filename}' on {issue.key}: "
|
||||
f"no content URL available"
|
||||
)
|
||||
continue
|
||||
|
||||
# Download the attachment using the public API on the
|
||||
# python-jira Attachment resource (avoids private _session access
|
||||
# and the double-copy from response.content + BytesIO wrapping).
|
||||
file_content = attachment.get()
|
||||
|
||||
# Extract text from the downloaded file
|
||||
try:
|
||||
text = extract_file_text(
|
||||
file=BytesIO(file_content),
|
||||
file_name=filename,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not extract text from attachment '{filename}' "
|
||||
f"on {issue.key}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
if not text or not text.strip():
|
||||
logger.info(
|
||||
f"Skipping attachment '{filename}' on {issue.key}: "
|
||||
f"no text content could be extracted"
|
||||
)
|
||||
continue
|
||||
|
||||
doc_id = f"{issue_url}/attachments/{attachment_id}"
|
||||
attachment_doc = Document(
|
||||
id=doc_id,
|
||||
sections=[TextSection(link=issue_url, text=text)],
|
||||
source=DocumentSource.JIRA,
|
||||
semantic_identifier=f"{issue.key}: {filename}",
|
||||
title=filename,
|
||||
doc_updated_at=(time_str_to_utc(created) if created else None),
|
||||
parent_hierarchy_raw_node_id=parent_hierarchy_raw_node_id,
|
||||
metadata={
|
||||
"parent_ticket": issue.key,
|
||||
"attachment_filename": filename,
|
||||
"attachment_mime_type": mime_type,
|
||||
"attachment_size": str(size),
|
||||
},
|
||||
)
|
||||
if include_permissions and project_key:
|
||||
attachment_doc.external_access = self._get_project_permissions(
|
||||
project_key,
|
||||
add_prefix=True,
|
||||
)
|
||||
yield attachment_doc
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process attachment on {issue.key}: {e}")
|
||||
yield ConnectorFailure(
|
||||
failed_document=DocumentFailure(
|
||||
document_id=f"{issue_url}/attachments/{getattr(attachment, 'id', 'unknown')}",
|
||||
document_link=issue_url,
|
||||
),
|
||||
failure_message=f"Failed to process attachment '{getattr(attachment, 'filename', 'unknown')}': {str(e)}",
|
||||
exception=e,
|
||||
)
|
||||
|
||||
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
|
||||
self._jira_client = build_jira_client(
|
||||
credentials=credentials,
|
||||
jira_base=self.jira_base,
|
||||
scoped_token=self.scoped_token,
|
||||
)
|
||||
|
||||
# Fetch the custom field ID-to-name mapping once at credential load time.
|
||||
# This avoids repeated API calls during issue processing.
|
||||
if self.extract_custom_fields:
|
||||
try:
|
||||
self._custom_fields_mapping = (
|
||||
CustomFieldExtractor.get_all_custom_fields(self._jira_client)
|
||||
)
|
||||
logger.info(
|
||||
f"Loaded {len(self._custom_fields_mapping)} custom field definitions"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to fetch custom field definitions; "
|
||||
f"custom field extraction will be skipped: {e}"
|
||||
)
|
||||
self._custom_fields_mapping = {}
|
||||
|
||||
return None
|
||||
|
||||
def _get_jql_query(
|
||||
@@ -969,11 +801,6 @@ class JiraConnector(
|
||||
comment_email_blacklist=self.comment_email_blacklist,
|
||||
labels_to_skip=self.labels_to_skip,
|
||||
parent_hierarchy_raw_node_id=parent_hierarchy_raw_node_id,
|
||||
custom_fields_mapping=(
|
||||
self._custom_fields_mapping
|
||||
if self._custom_fields_mapping
|
||||
else None
|
||||
),
|
||||
):
|
||||
# Add permission information to the document if requested
|
||||
if include_permissions:
|
||||
@@ -983,15 +810,6 @@ class JiraConnector(
|
||||
)
|
||||
yield document
|
||||
|
||||
# Yield attachment documents if enabled
|
||||
if self.fetch_attachments:
|
||||
yield from self._process_attachments(
|
||||
issue=issue,
|
||||
parent_hierarchy_raw_node_id=parent_hierarchy_raw_node_id,
|
||||
include_permissions=include_permissions,
|
||||
project_key=project_key,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
yield ConnectorFailure(
|
||||
failed_document=DocumentFailure(
|
||||
@@ -1099,41 +917,20 @@ class JiraConnector(
|
||||
issue_key = best_effort_get_field_from_issue(issue, _FIELD_KEY)
|
||||
doc_id = build_jira_url(self.jira_base, issue_key)
|
||||
|
||||
parent_hierarchy_raw_node_id = (
|
||||
self._get_parent_hierarchy_raw_node_id(issue, project_key)
|
||||
if project_key
|
||||
else None
|
||||
)
|
||||
project_perms = self._get_project_permissions(
|
||||
project_key, add_prefix=False
|
||||
)
|
||||
|
||||
slim_doc_batch.append(
|
||||
SlimDocument(
|
||||
id=doc_id,
|
||||
# Permission sync path - don't prefix, upsert_document_external_perms handles it
|
||||
external_access=project_perms,
|
||||
parent_hierarchy_raw_node_id=parent_hierarchy_raw_node_id,
|
||||
external_access=self._get_project_permissions(
|
||||
project_key, add_prefix=False
|
||||
),
|
||||
parent_hierarchy_raw_node_id=(
|
||||
self._get_parent_hierarchy_raw_node_id(issue, project_key)
|
||||
if project_key
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Also emit SlimDocument entries for each attachment
|
||||
if self.fetch_attachments:
|
||||
attachments = best_effort_get_field_from_issue(issue, "attachment")
|
||||
if attachments:
|
||||
for attachment in attachments:
|
||||
attachment_id = getattr(
|
||||
attachment,
|
||||
"id",
|
||||
getattr(attachment, "filename", "unknown"),
|
||||
)
|
||||
slim_doc_batch.append(
|
||||
SlimDocument(
|
||||
id=f"{doc_id}/attachments/{attachment_id}",
|
||||
external_access=project_perms,
|
||||
parent_hierarchy_raw_node_id=parent_hierarchy_raw_node_id,
|
||||
)
|
||||
)
|
||||
current_offset += 1
|
||||
if len(slim_doc_batch) >= JIRA_SLIM_PAGE_SIZE:
|
||||
yield slim_doc_batch
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TypedDict
|
||||
|
||||
@@ -7,14 +6,6 @@ from pydantic import BaseModel
|
||||
from onyx.onyxbot.slack.models import ChannelType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DirectThreadFetch:
|
||||
"""Request to fetch a Slack thread directly by channel and timestamp."""
|
||||
|
||||
channel_id: str
|
||||
thread_ts: str
|
||||
|
||||
|
||||
class ChannelMetadata(TypedDict):
|
||||
"""Type definition for cached channel metadata."""
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from onyx.configs.chat_configs import DOC_TIME_DECAY
|
||||
from onyx.connectors.models import IndexingDocument
|
||||
from onyx.connectors.models import TextSection
|
||||
from onyx.context.search.federated.models import ChannelMetadata
|
||||
from onyx.context.search.federated.models import DirectThreadFetch
|
||||
from onyx.context.search.federated.models import SlackMessage
|
||||
from onyx.context.search.federated.slack_search_utils import ALL_CHANNEL_TYPES
|
||||
from onyx.context.search.federated.slack_search_utils import build_channel_query_filter
|
||||
@@ -50,6 +49,7 @@ from onyx.server.federated.models import FederatedConnectorDetail
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.threadpool_concurrency import run_functions_tuples_in_parallel
|
||||
from onyx.utils.timing import log_function_time
|
||||
from shared_configs.configs import DOC_EMBEDDING_CONTEXT_SIZE
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
@@ -58,6 +58,7 @@ HIGHLIGHT_END_CHAR = "\ue001"
|
||||
|
||||
CHANNEL_METADATA_CACHE_TTL = 60 * 60 * 24 # 24 hours
|
||||
USER_PROFILE_CACHE_TTL = 60 * 60 * 24 # 24 hours
|
||||
SLACK_THREAD_CONTEXT_WINDOW = 3 # Number of messages before matched message to include
|
||||
CHANNEL_METADATA_MAX_RETRIES = 3 # Maximum retry attempts for channel metadata fetching
|
||||
CHANNEL_METADATA_RETRY_DELAY = 1 # Initial retry delay in seconds (exponential backoff)
|
||||
|
||||
@@ -420,94 +421,6 @@ class SlackQueryResult(BaseModel):
|
||||
filtered_channels: list[str] # Channels filtered out during this query
|
||||
|
||||
|
||||
def _fetch_thread_from_url(
|
||||
thread_fetch: DirectThreadFetch,
|
||||
access_token: str,
|
||||
channel_metadata_dict: dict[str, ChannelMetadata] | None = None,
|
||||
) -> SlackQueryResult:
|
||||
"""Fetch a thread directly from a Slack URL via conversations.replies."""
|
||||
channel_id = thread_fetch.channel_id
|
||||
thread_ts = thread_fetch.thread_ts
|
||||
|
||||
slack_client = WebClient(token=access_token)
|
||||
try:
|
||||
response = slack_client.conversations_replies(
|
||||
channel=channel_id,
|
||||
ts=thread_ts,
|
||||
)
|
||||
response.validate()
|
||||
messages: list[dict[str, Any]] = response.get("messages", [])
|
||||
except SlackApiError as e:
|
||||
logger.warning(
|
||||
f"Failed to fetch thread from URL (channel={channel_id}, ts={thread_ts}): {e}"
|
||||
)
|
||||
return SlackQueryResult(messages=[], filtered_channels=[])
|
||||
|
||||
if not messages:
|
||||
logger.warning(
|
||||
f"No messages found for URL override (channel={channel_id}, ts={thread_ts})"
|
||||
)
|
||||
return SlackQueryResult(messages=[], filtered_channels=[])
|
||||
|
||||
# Build thread text from all messages
|
||||
thread_text = _build_thread_text(messages, access_token, None, slack_client)
|
||||
|
||||
# Get channel name from metadata cache or API
|
||||
channel_name = "unknown"
|
||||
if channel_metadata_dict and channel_id in channel_metadata_dict:
|
||||
channel_name = channel_metadata_dict[channel_id].get("name", "unknown")
|
||||
else:
|
||||
try:
|
||||
ch_response = slack_client.conversations_info(channel=channel_id)
|
||||
ch_response.validate()
|
||||
channel_info: dict[str, Any] = ch_response.get("channel", {})
|
||||
channel_name = channel_info.get("name", "unknown")
|
||||
except SlackApiError:
|
||||
pass
|
||||
|
||||
# Build the SlackMessage
|
||||
parent_msg = messages[0]
|
||||
message_ts = parent_msg.get("ts", thread_ts)
|
||||
username = parent_msg.get("user", "unknown_user")
|
||||
parent_text = parent_msg.get("text", "")
|
||||
snippet = (
|
||||
parent_text[:50].rstrip() + "..." if len(parent_text) > 50 else parent_text
|
||||
).replace("\n", " ")
|
||||
|
||||
doc_time = datetime.fromtimestamp(float(message_ts))
|
||||
decay_factor = DOC_TIME_DECAY
|
||||
doc_age_years = (datetime.now() - doc_time).total_seconds() / (365 * 24 * 60 * 60)
|
||||
recency_bias = max(1 / (1 + decay_factor * doc_age_years), 0.75)
|
||||
|
||||
permalink = (
|
||||
f"https://slack.com/archives/{channel_id}/p{message_ts.replace('.', '')}"
|
||||
)
|
||||
|
||||
slack_message = SlackMessage(
|
||||
document_id=f"{channel_id}_{message_ts}",
|
||||
channel_id=channel_id,
|
||||
message_id=message_ts,
|
||||
thread_id=None, # Prevent double-enrichment in thread context fetch
|
||||
link=permalink,
|
||||
metadata={
|
||||
"channel": channel_name,
|
||||
"time": doc_time.isoformat(),
|
||||
},
|
||||
timestamp=doc_time,
|
||||
recency_bias=recency_bias,
|
||||
semantic_identifier=f"{username} in #{channel_name}: {snippet}",
|
||||
text=thread_text,
|
||||
highlighted_texts=set(),
|
||||
slack_score=100000.0, # High priority — user explicitly asked for this thread
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"URL override: fetched thread from channel={channel_id}, ts={thread_ts}, {len(messages)} messages"
|
||||
)
|
||||
|
||||
return SlackQueryResult(messages=[slack_message], filtered_channels=[])
|
||||
|
||||
|
||||
def query_slack(
|
||||
query_string: str,
|
||||
access_token: str,
|
||||
@@ -519,6 +432,7 @@ def query_slack(
|
||||
available_channels: list[str] | None = None,
|
||||
channel_metadata_dict: dict[str, ChannelMetadata] | None = None,
|
||||
) -> SlackQueryResult:
|
||||
|
||||
# Check if query has channel override (user specified channels in query)
|
||||
has_channel_override = query_string.startswith("__CHANNEL_OVERRIDE__")
|
||||
|
||||
@@ -748,6 +662,7 @@ def _fetch_thread_context(
|
||||
"""
|
||||
channel_id = message.channel_id
|
||||
thread_id = message.thread_id
|
||||
message_id = message.message_id
|
||||
|
||||
# If not a thread, return original text as success
|
||||
if thread_id is None:
|
||||
@@ -780,37 +695,62 @@ def _fetch_thread_context(
|
||||
if len(messages) <= 1:
|
||||
return ThreadContextResult.success(message.text)
|
||||
|
||||
# Build thread text from thread starter + all replies
|
||||
thread_text = _build_thread_text(messages, access_token, team_id, slack_client)
|
||||
# Build thread text from thread starter + context window around matched message
|
||||
thread_text = _build_thread_text(
|
||||
messages, message_id, thread_id, access_token, team_id, slack_client
|
||||
)
|
||||
return ThreadContextResult.success(thread_text)
|
||||
|
||||
|
||||
def _build_thread_text(
|
||||
messages: list[dict[str, Any]],
|
||||
message_id: str,
|
||||
thread_id: str,
|
||||
access_token: str,
|
||||
team_id: str | None,
|
||||
slack_client: WebClient,
|
||||
) -> str:
|
||||
"""Build thread text including all replies.
|
||||
|
||||
Includes the thread parent message followed by all replies in order.
|
||||
"""
|
||||
"""Build the thread text from messages."""
|
||||
msg_text = messages[0].get("text", "")
|
||||
msg_sender = messages[0].get("user", "")
|
||||
thread_text = f"<@{msg_sender}>: {msg_text}"
|
||||
|
||||
# All messages after index 0 are replies
|
||||
replies = messages[1:]
|
||||
if not replies:
|
||||
return thread_text
|
||||
|
||||
logger.debug(f"Thread {messages[0].get('ts')}: {len(replies)} replies included")
|
||||
thread_text += "\n\nReplies:"
|
||||
if thread_id == message_id:
|
||||
message_id_idx = 0
|
||||
else:
|
||||
message_id_idx = next(
|
||||
(i for i, msg in enumerate(messages) if msg.get("ts") == message_id), 0
|
||||
)
|
||||
if not message_id_idx:
|
||||
return thread_text
|
||||
|
||||
for msg in replies:
|
||||
start_idx = max(1, message_id_idx - SLACK_THREAD_CONTEXT_WINDOW)
|
||||
|
||||
if start_idx > 1:
|
||||
thread_text += "\n..."
|
||||
|
||||
for i in range(start_idx, message_id_idx):
|
||||
msg_text = messages[i].get("text", "")
|
||||
msg_sender = messages[i].get("user", "")
|
||||
thread_text += f"\n\n<@{msg_sender}>: {msg_text}"
|
||||
|
||||
msg_text = messages[message_id_idx].get("text", "")
|
||||
msg_sender = messages[message_id_idx].get("user", "")
|
||||
thread_text += f"\n\n<@{msg_sender}>: {msg_text}"
|
||||
|
||||
# Add following replies
|
||||
len_replies = 0
|
||||
for msg in messages[message_id_idx + 1 :]:
|
||||
msg_text = msg.get("text", "")
|
||||
msg_sender = msg.get("user", "")
|
||||
thread_text += f"\n\n<@{msg_sender}>: {msg_text}"
|
||||
reply = f"\n\n<@{msg_sender}>: {msg_text}"
|
||||
thread_text += reply
|
||||
|
||||
len_replies += len(reply)
|
||||
if len_replies >= DOC_EMBEDDING_CONTEXT_SIZE * 4:
|
||||
thread_text += "\n..."
|
||||
break
|
||||
|
||||
# Replace user IDs with names using cached lookups
|
||||
userids: set[str] = set(re.findall(r"<@([A-Z0-9]+)>", thread_text))
|
||||
@@ -1036,16 +976,7 @@ def slack_retrieval(
|
||||
|
||||
# Query slack with entity filtering
|
||||
llm = get_default_llm()
|
||||
query_items = build_slack_queries(query, llm, entities, available_channels)
|
||||
|
||||
# Partition into direct thread fetches and search query strings
|
||||
direct_fetches: list[DirectThreadFetch] = []
|
||||
query_strings: list[str] = []
|
||||
for item in query_items:
|
||||
if isinstance(item, DirectThreadFetch):
|
||||
direct_fetches.append(item)
|
||||
else:
|
||||
query_strings.append(item)
|
||||
query_strings = build_slack_queries(query, llm, entities, available_channels)
|
||||
|
||||
# Determine filtering based on entities OR context (bot)
|
||||
include_dm = False
|
||||
@@ -1062,16 +993,8 @@ def slack_retrieval(
|
||||
f"Private channel context: will only allow messages from {allowed_private_channel} + public channels"
|
||||
)
|
||||
|
||||
# Build search tasks — direct thread fetches + keyword searches
|
||||
search_tasks: list[tuple] = [
|
||||
(
|
||||
_fetch_thread_from_url,
|
||||
(fetch, access_token, channel_metadata_dict),
|
||||
)
|
||||
for fetch in direct_fetches
|
||||
]
|
||||
|
||||
search_tasks.extend(
|
||||
# Build search tasks
|
||||
search_tasks = [
|
||||
(
|
||||
query_slack,
|
||||
(
|
||||
@@ -1087,7 +1010,7 @@ def slack_retrieval(
|
||||
),
|
||||
)
|
||||
for query_string in query_strings
|
||||
)
|
||||
]
|
||||
|
||||
# If include_dm is True AND we're not already searching all channels,
|
||||
# add additional searches without channel filters.
|
||||
|
||||
@@ -10,7 +10,6 @@ from pydantic import ValidationError
|
||||
|
||||
from onyx.configs.app_configs import MAX_SLACK_QUERY_EXPANSIONS
|
||||
from onyx.context.search.federated.models import ChannelMetadata
|
||||
from onyx.context.search.federated.models import DirectThreadFetch
|
||||
from onyx.context.search.models import ChunkIndexRequest
|
||||
from onyx.federated_connectors.slack.models import SlackEntities
|
||||
from onyx.llm.interfaces import LLM
|
||||
@@ -639,38 +638,12 @@ def expand_query_with_llm(query_text: str, llm: LLM) -> list[str]:
|
||||
return [query_text]
|
||||
|
||||
|
||||
SLACK_URL_PATTERN = re.compile(
|
||||
r"https?://[a-z0-9-]+\.slack\.com/archives/([A-Z0-9]+)/p(\d{16})"
|
||||
)
|
||||
|
||||
|
||||
def extract_slack_message_urls(
|
||||
query_text: str,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Extract Slack message URLs from query text.
|
||||
|
||||
Parses URLs like:
|
||||
https://onyx-company.slack.com/archives/C097NBWMY8Y/p1775491616524769
|
||||
|
||||
Returns list of (channel_id, thread_ts) tuples.
|
||||
The 16-digit timestamp is converted to Slack ts format (with dot).
|
||||
"""
|
||||
results = []
|
||||
for match in SLACK_URL_PATTERN.finditer(query_text):
|
||||
channel_id = match.group(1)
|
||||
raw_ts = match.group(2)
|
||||
# Convert p1775491616524769 -> 1775491616.524769
|
||||
thread_ts = f"{raw_ts[:10]}.{raw_ts[10:]}"
|
||||
results.append((channel_id, thread_ts))
|
||||
return results
|
||||
|
||||
|
||||
def build_slack_queries(
|
||||
query: ChunkIndexRequest,
|
||||
llm: LLM,
|
||||
entities: dict[str, Any] | None = None,
|
||||
available_channels: list[str] | None = None,
|
||||
) -> list[str | DirectThreadFetch]:
|
||||
) -> list[str]:
|
||||
"""Build Slack query strings with date filtering and query expansion."""
|
||||
default_search_days = 30
|
||||
if entities:
|
||||
@@ -695,15 +668,6 @@ def build_slack_queries(
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_back)
|
||||
time_filter = f" after:{cutoff_date.strftime('%Y-%m-%d')}"
|
||||
|
||||
# Check for Slack message URLs — if found, add direct fetch requests
|
||||
url_fetches: list[DirectThreadFetch] = []
|
||||
slack_urls = extract_slack_message_urls(query.query)
|
||||
for channel_id, thread_ts in slack_urls:
|
||||
url_fetches.append(
|
||||
DirectThreadFetch(channel_id=channel_id, thread_ts=thread_ts)
|
||||
)
|
||||
logger.info(f"Detected Slack URL: channel={channel_id}, ts={thread_ts}")
|
||||
|
||||
# ALWAYS extract channel references from the query (not just for recency queries)
|
||||
channel_references = extract_channel_references_from_query(query.query)
|
||||
|
||||
@@ -720,9 +684,7 @@ def build_slack_queries(
|
||||
|
||||
# If valid channels detected, use ONLY those channels with NO keywords
|
||||
# Return query with ONLY time filter + channel filter (no keywords)
|
||||
return url_fetches + [
|
||||
build_channel_override_query(channel_references, time_filter)
|
||||
]
|
||||
return [build_channel_override_query(channel_references, time_filter)]
|
||||
except ValueError as e:
|
||||
# If validation fails, log the error and continue with normal flow
|
||||
logger.warning(f"Channel reference validation failed: {e}")
|
||||
@@ -740,8 +702,7 @@ def build_slack_queries(
|
||||
rephrased_queries = expand_query_with_llm(query.query, llm)
|
||||
|
||||
# Build final query strings with time filters
|
||||
search_queries = [
|
||||
return [
|
||||
rephrased_query.strip() + time_filter
|
||||
for rephrased_query in rephrased_queries[:MAX_SLACK_QUERY_EXPANSIONS]
|
||||
]
|
||||
return url_fetches + search_queries
|
||||
|
||||
@@ -96,9 +96,6 @@ from onyx.server.features.persona.api import admin_router as admin_persona_route
|
||||
from onyx.server.features.persona.api import agents_router
|
||||
from onyx.server.features.persona.api import basic_router as persona_router
|
||||
from onyx.server.features.projects.api import router as projects_router
|
||||
from onyx.server.features.proposal_review.api.api import (
|
||||
router as proposal_review_router,
|
||||
)
|
||||
from onyx.server.features.tool.api import admin_router as admin_tool_router
|
||||
from onyx.server.features.tool.api import router as tool_router
|
||||
from onyx.server.features.user_oauth_token.api import router as user_oauth_token_router
|
||||
@@ -472,7 +469,6 @@ def get_application(lifespan_override: Lifespan | None = None) -> FastAPI:
|
||||
include_router_with_global_prefix_prepended(application, projects_router)
|
||||
include_router_with_global_prefix_prepended(application, public_build_router)
|
||||
include_router_with_global_prefix_prepended(application, build_router)
|
||||
include_router_with_global_prefix_prepended(application, proposal_review_router)
|
||||
include_router_with_global_prefix_prepended(application, document_set_router)
|
||||
include_router_with_global_prefix_prepended(application, hierarchy_router)
|
||||
include_router_with_global_prefix_prepended(application, search_settings_router)
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Main router for Proposal Review (Argus).
|
||||
|
||||
Mounts all sub-routers under /proposal-review prefix.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
|
||||
from onyx.auth.permissions import require_permission
|
||||
from onyx.db.enums import Permission
|
||||
from onyx.server.features.proposal_review.configs import ENABLE_PROPOSAL_REVIEW
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/proposal-review",
|
||||
dependencies=[Depends(require_permission(Permission.BASIC_ACCESS))],
|
||||
)
|
||||
|
||||
if ENABLE_PROPOSAL_REVIEW:
|
||||
from onyx.server.features.proposal_review.api.config_api import (
|
||||
router as config_router,
|
||||
)
|
||||
from onyx.server.features.proposal_review.api.decisions_api import (
|
||||
router as decisions_router,
|
||||
)
|
||||
from onyx.server.features.proposal_review.api.proposals_api import (
|
||||
router as proposals_router,
|
||||
)
|
||||
from onyx.server.features.proposal_review.api.review_api import (
|
||||
router as review_router,
|
||||
)
|
||||
from onyx.server.features.proposal_review.api.rulesets_api import (
|
||||
router as rulesets_router,
|
||||
)
|
||||
|
||||
router.include_router(rulesets_router, tags=["proposal-review"])
|
||||
router.include_router(proposals_router, tags=["proposal-review"])
|
||||
router.include_router(review_router, tags=["proposal-review"])
|
||||
router.include_router(decisions_router, tags=["proposal-review"])
|
||||
router.include_router(config_router, tags=["proposal-review"])
|
||||
@@ -1,87 +0,0 @@
|
||||
"""API endpoints for tenant configuration."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.permissions import require_permission
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.db.connector import fetch_connectors
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.enums import Permission
|
||||
from onyx.db.models import User
|
||||
from onyx.server.features.proposal_review.api.models import ConfigResponse
|
||||
from onyx.server.features.proposal_review.api.models import ConfigUpdate
|
||||
from onyx.server.features.proposal_review.api.models import JiraConnectorInfo
|
||||
from onyx.server.features.proposal_review.db import config as config_db
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
def get_config(
|
||||
_user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ConfigResponse:
|
||||
"""Get the tenant's proposal review configuration."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
config = config_db.get_config(tenant_id, db_session)
|
||||
if not config:
|
||||
# Return a default empty config rather than 404
|
||||
config = config_db.upsert_config(tenant_id, db_session)
|
||||
db_session.commit()
|
||||
return ConfigResponse.from_model(config)
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
def update_config(
|
||||
request: ConfigUpdate,
|
||||
_user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ConfigResponse:
|
||||
"""Update the tenant's proposal review configuration."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
config = config_db.upsert_config(
|
||||
tenant_id=tenant_id,
|
||||
jira_connector_id=request.jira_connector_id,
|
||||
jira_project_key=request.jira_project_key,
|
||||
field_mapping=request.field_mapping,
|
||||
jira_writeback=request.jira_writeback,
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
return ConfigResponse.from_model(config)
|
||||
|
||||
|
||||
@router.get("/jira-connectors")
|
||||
def list_jira_connectors(
|
||||
_user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[JiraConnectorInfo]:
|
||||
"""List all Jira connectors available to this tenant."""
|
||||
connectors = fetch_connectors(db_session, sources=[DocumentSource.JIRA])
|
||||
results: list[JiraConnectorInfo] = []
|
||||
for c in connectors:
|
||||
cfg = c.connector_specific_config or {}
|
||||
project_key = cfg.get("project_key", "")
|
||||
base_url = cfg.get("jira_base_url", "")
|
||||
results.append(
|
||||
JiraConnectorInfo(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
project_key=project_key,
|
||||
project_url=base_url,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/jira-connectors/{connector_id}/metadata-keys")
|
||||
def get_connector_metadata_keys(
|
||||
connector_id: int,
|
||||
_user: User = Depends(require_permission(Permission.BASIC_ACCESS)), # noqa: ARG001
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[str]:
|
||||
"""Return the distinct doc_metadata keys across all documents for a connector."""
|
||||
return config_db.get_connector_metadata_keys(connector_id, db_session)
|
||||
@@ -1,187 +0,0 @@
|
||||
"""API endpoints for per-finding decisions, proposal decisions, and Jira sync."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.permissions import require_permission
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.enums import Permission
|
||||
from onyx.db.models import User
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.features.proposal_review.api.models import FindingDecisionCreate
|
||||
from onyx.server.features.proposal_review.api.models import FindingDecisionResponse
|
||||
from onyx.server.features.proposal_review.api.models import JiraSyncResponse
|
||||
from onyx.server.features.proposal_review.api.models import ProposalDecisionCreate
|
||||
from onyx.server.features.proposal_review.api.models import ProposalDecisionResponse
|
||||
from onyx.server.features.proposal_review.db import decisions as decisions_db
|
||||
from onyx.server.features.proposal_review.db import findings as findings_db
|
||||
from onyx.server.features.proposal_review.db import proposals as proposals_db
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/findings/{finding_id}/decision",
|
||||
)
|
||||
def record_finding_decision(
|
||||
finding_id: UUID,
|
||||
request: FindingDecisionCreate,
|
||||
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> FindingDecisionResponse:
|
||||
"""Record or update a decision on a finding (upsert)."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
# Verify finding exists
|
||||
finding = findings_db.get_finding(finding_id, db_session)
|
||||
if not finding:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Finding not found")
|
||||
|
||||
# Verify the finding's proposal belongs to the current tenant
|
||||
proposal = proposals_db.get_proposal(finding.proposal_id, tenant_id, db_session)
|
||||
if not proposal:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Finding not found")
|
||||
|
||||
decision = decisions_db.upsert_finding_decision(
|
||||
finding_id=finding_id,
|
||||
officer_id=user.id,
|
||||
action=request.action,
|
||||
notes=request.notes,
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
# Audit log
|
||||
decisions_db.create_audit_log(
|
||||
proposal_id=finding.proposal_id,
|
||||
action="finding_decided",
|
||||
user_id=user.id,
|
||||
details={
|
||||
"finding_id": str(finding_id),
|
||||
"action": request.action,
|
||||
},
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
db_session.commit()
|
||||
return FindingDecisionResponse.from_model(decision)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/proposals/{proposal_id}/decision",
|
||||
status_code=201,
|
||||
)
|
||||
def record_proposal_decision(
|
||||
proposal_id: UUID,
|
||||
request: ProposalDecisionCreate,
|
||||
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ProposalDecisionResponse:
|
||||
"""Record a final decision on a proposal."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
proposal = proposals_db.get_proposal(proposal_id, tenant_id, db_session)
|
||||
if not proposal:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Proposal not found")
|
||||
|
||||
# Map decision to proposal status
|
||||
status_map = {
|
||||
"APPROVED": "APPROVED",
|
||||
"CHANGES_REQUESTED": "CHANGES_REQUESTED",
|
||||
"REJECTED": "REJECTED",
|
||||
}
|
||||
new_status = status_map.get(request.decision)
|
||||
if not new_status:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
"decision must be APPROVED, CHANGES_REQUESTED, or REJECTED",
|
||||
)
|
||||
|
||||
# Update proposal status
|
||||
proposals_db.update_proposal_status(proposal_id, tenant_id, new_status, db_session)
|
||||
|
||||
# Create the decision record
|
||||
decision = decisions_db.create_proposal_decision(
|
||||
proposal_id=proposal_id,
|
||||
officer_id=user.id,
|
||||
decision=request.decision,
|
||||
notes=request.notes,
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
# Audit log
|
||||
decisions_db.create_audit_log(
|
||||
proposal_id=proposal_id,
|
||||
action="decision_submitted",
|
||||
user_id=user.id,
|
||||
details={
|
||||
"decision_id": str(decision.id),
|
||||
"decision": request.decision,
|
||||
},
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
db_session.commit()
|
||||
return ProposalDecisionResponse.from_model(decision)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/proposals/{proposal_id}/sync-jira",
|
||||
)
|
||||
def sync_jira(
|
||||
proposal_id: UUID,
|
||||
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> JiraSyncResponse:
|
||||
"""Sync the latest proposal decision to Jira.
|
||||
|
||||
Dispatches a Celery task that performs 3 Jira API operations:
|
||||
1. Update custom fields (decision, completion %)
|
||||
2. Transition the issue to the appropriate column
|
||||
3. Post a structured review summary comment
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
proposal = proposals_db.get_proposal(proposal_id, tenant_id, db_session)
|
||||
if not proposal:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Proposal not found")
|
||||
|
||||
latest_decision = decisions_db.get_latest_proposal_decision(proposal_id, db_session)
|
||||
if not latest_decision:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
"No decision to sync -- record a proposal decision first",
|
||||
)
|
||||
|
||||
if latest_decision.jira_synced:
|
||||
return JiraSyncResponse(
|
||||
success=True,
|
||||
message="Decision already synced to Jira",
|
||||
)
|
||||
|
||||
# Dispatch Celery task for Jira sync
|
||||
from onyx.server.features.proposal_review.engine.review_engine import (
|
||||
sync_decision_to_jira,
|
||||
)
|
||||
|
||||
sync_decision_to_jira.apply_async(args=[str(proposal_id), tenant_id], expires=300)
|
||||
|
||||
# Audit log
|
||||
decisions_db.create_audit_log(
|
||||
proposal_id=proposal_id,
|
||||
action="jira_sync_dispatched",
|
||||
user_id=user.id,
|
||||
details={"decision_id": str(latest_decision.id)},
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
db_session.commit()
|
||||
return JiraSyncResponse(
|
||||
success=True,
|
||||
message="Jira sync task dispatched",
|
||||
)
|
||||
@@ -1,463 +0,0 @@
|
||||
"""Pydantic request/response models for Proposal Review (Argus)."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ruleset Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class RulesetCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class RulesetUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
is_default: bool | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class RulesetResponse(BaseModel):
|
||||
id: UUID
|
||||
tenant_id: str
|
||||
name: str
|
||||
description: str | None
|
||||
is_default: bool
|
||||
is_active: bool
|
||||
created_by: UUID | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
rules: list["RuleResponse"] = []
|
||||
|
||||
@classmethod
|
||||
def from_model(
|
||||
cls,
|
||||
ruleset: Any,
|
||||
include_rules: bool = True,
|
||||
) -> "RulesetResponse":
|
||||
return cls(
|
||||
id=ruleset.id,
|
||||
tenant_id=ruleset.tenant_id,
|
||||
name=ruleset.name,
|
||||
description=ruleset.description,
|
||||
is_default=ruleset.is_default,
|
||||
is_active=ruleset.is_active,
|
||||
created_by=ruleset.created_by,
|
||||
created_at=ruleset.created_at,
|
||||
updated_at=ruleset.updated_at,
|
||||
rules=(
|
||||
[RuleResponse.from_model(r) for r in ruleset.rules]
|
||||
if include_rules
|
||||
else []
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Rule Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class RuleCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
category: str | None = None
|
||||
rule_type: Literal[
|
||||
"DOCUMENT_CHECK", "METADATA_CHECK", "CROSS_REFERENCE", "CUSTOM_NL"
|
||||
]
|
||||
rule_intent: Literal["CHECK", "HIGHLIGHT"] = "CHECK"
|
||||
prompt_template: str
|
||||
source: Literal["IMPORTED", "MANUAL"] = "MANUAL"
|
||||
authority: Literal["OVERRIDE", "RETURN"] | None = None
|
||||
is_hard_stop: bool = False
|
||||
priority: int = 0
|
||||
|
||||
|
||||
class RuleUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
category: str | None = None
|
||||
rule_type: str | None = None
|
||||
rule_intent: str | None = None
|
||||
prompt_template: str | None = None
|
||||
authority: str | None = None
|
||||
is_hard_stop: bool | None = None
|
||||
priority: int | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class RuleResponse(BaseModel):
|
||||
id: UUID
|
||||
ruleset_id: UUID
|
||||
name: str
|
||||
description: str | None
|
||||
category: str | None
|
||||
rule_type: str
|
||||
rule_intent: str
|
||||
prompt_template: str
|
||||
source: str
|
||||
authority: str | None
|
||||
is_hard_stop: bool
|
||||
priority: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, rule: Any) -> "RuleResponse":
|
||||
return cls(
|
||||
id=rule.id,
|
||||
ruleset_id=rule.ruleset_id,
|
||||
name=rule.name,
|
||||
description=rule.description,
|
||||
category=rule.category,
|
||||
rule_type=rule.rule_type,
|
||||
rule_intent=rule.rule_intent,
|
||||
prompt_template=rule.prompt_template,
|
||||
source=rule.source,
|
||||
authority=rule.authority,
|
||||
is_hard_stop=rule.is_hard_stop,
|
||||
priority=rule.priority,
|
||||
is_active=rule.is_active,
|
||||
created_at=rule.created_at,
|
||||
updated_at=rule.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class BulkRuleUpdateRequest(BaseModel):
|
||||
"""Batch activate/deactivate/delete rules."""
|
||||
|
||||
action: Literal["activate", "deactivate", "delete"]
|
||||
rule_ids: list[UUID]
|
||||
|
||||
|
||||
class BulkRuleUpdateResponse(BaseModel):
|
||||
updated_count: int
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Proposal Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ProposalResponse(BaseModel):
|
||||
"""Thin response -- status + document_id. Metadata comes from Document."""
|
||||
|
||||
id: UUID
|
||||
document_id: str
|
||||
tenant_id: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
# Resolved metadata from Document table via field_mapping
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
@classmethod
|
||||
def from_model(
|
||||
cls,
|
||||
proposal: Any,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> "ProposalResponse":
|
||||
return cls(
|
||||
id=proposal.id,
|
||||
document_id=proposal.document_id,
|
||||
tenant_id=proposal.tenant_id,
|
||||
status=proposal.status,
|
||||
created_at=proposal.created_at,
|
||||
updated_at=proposal.updated_at,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
class ProposalListResponse(BaseModel):
|
||||
proposals: list[ProposalResponse]
|
||||
total_count: int
|
||||
config_missing: bool = False # True when no Argus config exists
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Review Run Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ReviewRunTriggerRequest(BaseModel):
|
||||
ruleset_id: UUID
|
||||
|
||||
|
||||
class ReviewRunResponse(BaseModel):
|
||||
id: UUID
|
||||
proposal_id: UUID
|
||||
ruleset_id: UUID
|
||||
triggered_by: UUID
|
||||
status: str
|
||||
total_rules: int
|
||||
completed_rules: int
|
||||
started_at: datetime | None
|
||||
completed_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, run: Any) -> "ReviewRunResponse":
|
||||
return cls(
|
||||
id=run.id,
|
||||
proposal_id=run.proposal_id,
|
||||
ruleset_id=run.ruleset_id,
|
||||
triggered_by=run.triggered_by,
|
||||
status=run.status,
|
||||
total_rules=run.total_rules,
|
||||
completed_rules=run.completed_rules,
|
||||
started_at=run.started_at,
|
||||
completed_at=run.completed_at,
|
||||
created_at=run.created_at,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Finding Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class FindingDecisionResponse(BaseModel):
|
||||
id: UUID
|
||||
finding_id: UUID
|
||||
officer_id: UUID
|
||||
action: str
|
||||
notes: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, decision: Any) -> "FindingDecisionResponse":
|
||||
return cls(
|
||||
id=decision.id,
|
||||
finding_id=decision.finding_id,
|
||||
officer_id=decision.officer_id,
|
||||
action=decision.action,
|
||||
notes=decision.notes,
|
||||
created_at=decision.created_at,
|
||||
updated_at=decision.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class FindingResponse(BaseModel):
|
||||
id: UUID
|
||||
proposal_id: UUID
|
||||
rule_id: UUID
|
||||
review_run_id: UUID
|
||||
verdict: str
|
||||
confidence: str | None
|
||||
evidence: str | None
|
||||
explanation: str | None
|
||||
suggested_action: str | None
|
||||
llm_model: str | None
|
||||
llm_tokens_used: int | None
|
||||
created_at: datetime
|
||||
# Nested rule info for display
|
||||
rule_name: str | None = None
|
||||
rule_category: str | None = None
|
||||
rule_is_hard_stop: bool | None = None
|
||||
# Nested decision if exists
|
||||
decision: FindingDecisionResponse | None = None
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, finding: Any) -> "FindingResponse":
|
||||
decision = None
|
||||
if finding.decision is not None:
|
||||
decision = FindingDecisionResponse.from_model(finding.decision)
|
||||
|
||||
rule_name = None
|
||||
rule_category = None
|
||||
rule_is_hard_stop = None
|
||||
if finding.rule is not None:
|
||||
rule_name = finding.rule.name
|
||||
rule_category = finding.rule.category
|
||||
rule_is_hard_stop = finding.rule.is_hard_stop
|
||||
|
||||
return cls(
|
||||
id=finding.id,
|
||||
proposal_id=finding.proposal_id,
|
||||
rule_id=finding.rule_id,
|
||||
review_run_id=finding.review_run_id,
|
||||
verdict=finding.verdict,
|
||||
confidence=finding.confidence,
|
||||
evidence=finding.evidence,
|
||||
explanation=finding.explanation,
|
||||
suggested_action=finding.suggested_action,
|
||||
llm_model=finding.llm_model,
|
||||
llm_tokens_used=finding.llm_tokens_used,
|
||||
created_at=finding.created_at,
|
||||
rule_name=rule_name,
|
||||
rule_category=rule_category,
|
||||
rule_is_hard_stop=rule_is_hard_stop,
|
||||
decision=decision,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Decision Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class FindingDecisionCreate(BaseModel):
|
||||
action: Literal["VERIFIED", "ISSUE", "NOT_APPLICABLE", "OVERRIDDEN"]
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ProposalDecisionCreate(BaseModel):
|
||||
decision: Literal["APPROVED", "CHANGES_REQUESTED", "REJECTED"]
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ProposalDecisionResponse(BaseModel):
|
||||
id: UUID
|
||||
proposal_id: UUID
|
||||
officer_id: UUID
|
||||
decision: str
|
||||
notes: str | None
|
||||
jira_synced: bool
|
||||
jira_synced_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, decision: Any) -> "ProposalDecisionResponse":
|
||||
return cls(
|
||||
id=decision.id,
|
||||
proposal_id=decision.proposal_id,
|
||||
officer_id=decision.officer_id,
|
||||
decision=decision.decision,
|
||||
notes=decision.notes,
|
||||
jira_synced=decision.jira_synced,
|
||||
jira_synced_at=decision.jira_synced_at,
|
||||
created_at=decision.created_at,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Config Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ConfigUpdate(BaseModel):
|
||||
jira_connector_id: int | None = None
|
||||
jira_project_key: str | None = None
|
||||
field_mapping: list[str] | None = None # List of visible metadata keys
|
||||
jira_writeback: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ConfigResponse(BaseModel):
|
||||
id: UUID
|
||||
tenant_id: str
|
||||
jira_connector_id: int | None
|
||||
jira_project_key: str | None
|
||||
field_mapping: list[str] | None
|
||||
jira_writeback: dict[str, Any] | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, config: Any) -> "ConfigResponse":
|
||||
return cls(
|
||||
id=config.id,
|
||||
tenant_id=config.tenant_id,
|
||||
jira_connector_id=config.jira_connector_id,
|
||||
jira_project_key=config.jira_project_key,
|
||||
field_mapping=config.field_mapping,
|
||||
jira_writeback=config.jira_writeback,
|
||||
created_at=config.created_at,
|
||||
updated_at=config.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Import Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ImportResponse(BaseModel):
|
||||
rules_created: int
|
||||
rules: list[RuleResponse]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Document Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ProposalDocumentResponse(BaseModel):
|
||||
id: UUID
|
||||
proposal_id: UUID
|
||||
file_name: str
|
||||
file_type: str | None
|
||||
document_role: str
|
||||
uploaded_by: UUID | None
|
||||
extracted_text: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, doc: Any) -> "ProposalDocumentResponse":
|
||||
return cls(
|
||||
id=doc.id,
|
||||
proposal_id=doc.proposal_id,
|
||||
file_name=doc.file_name,
|
||||
file_type=doc.file_type,
|
||||
document_role=doc.document_role,
|
||||
uploaded_by=doc.uploaded_by,
|
||||
extracted_text=getattr(doc, "extracted_text", None),
|
||||
created_at=doc.created_at,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Audit Log Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AuditLogEntry(BaseModel):
|
||||
id: UUID
|
||||
proposal_id: UUID
|
||||
user_id: UUID | None
|
||||
action: str
|
||||
details: dict[str, Any] | None
|
||||
created_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, entry: Any) -> "AuditLogEntry":
|
||||
return cls(
|
||||
id=entry.id,
|
||||
proposal_id=entry.proposal_id,
|
||||
user_id=entry.user_id,
|
||||
action=entry.action,
|
||||
details=entry.details,
|
||||
created_at=entry.created_at,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Jira Sync Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class JiraSyncResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Jira Connector Discovery Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class JiraConnectorInfo(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
project_key: str
|
||||
project_url: str
|
||||
@@ -1,370 +0,0 @@
|
||||
"""API endpoints for proposals and proposal documents."""
|
||||
|
||||
import io
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import Form
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.permissions import require_permission
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.enums import Permission
|
||||
from onyx.db.models import Connector
|
||||
from onyx.db.models import Document
|
||||
from onyx.db.models import DocumentByConnectorCredentialPair
|
||||
from onyx.db.models import User
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.file_processing.extract_file_text import extract_file_text
|
||||
from onyx.server.features.proposal_review.api.models import ProposalDocumentResponse
|
||||
from onyx.server.features.proposal_review.api.models import ProposalListResponse
|
||||
from onyx.server.features.proposal_review.api.models import ProposalResponse
|
||||
from onyx.server.features.proposal_review.configs import (
|
||||
DOCUMENT_UPLOAD_MAX_FILE_SIZE_BYTES,
|
||||
)
|
||||
from onyx.server.features.proposal_review.db import config as config_db
|
||||
from onyx.server.features.proposal_review.db import proposals as proposals_db
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewDocument
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewProposal
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_document_metadata(
|
||||
document: Document,
|
||||
visible_fields: list[str] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve metadata from a Document's tags, filtered to visible fields.
|
||||
|
||||
Jira custom fields are stored as Tag rows (tag_key / tag_value)
|
||||
linked to the document via document__tag. visible_fields selects
|
||||
which tag keys to include. If None/empty, returns all tags.
|
||||
"""
|
||||
# Build metadata from the document's tags
|
||||
raw_metadata: dict[str, Any] = {}
|
||||
for tag in document.tags:
|
||||
key = tag.tag_key
|
||||
value = tag.tag_value
|
||||
# Tags with is_list=True can have multiple values for the same key
|
||||
if tag.is_list:
|
||||
raw_metadata.setdefault(key, [])
|
||||
raw_metadata[key].append(value)
|
||||
else:
|
||||
raw_metadata[key] = value
|
||||
|
||||
# Extract jira_key from tags and clean title from semantic_id.
|
||||
# Jira semantic_id is "KEY-123: Summary Text" — split to isolate each.
|
||||
jira_key = raw_metadata.get("key", "")
|
||||
title = document.semantic_id or ""
|
||||
if title and ": " in title:
|
||||
title = title.split(": ", 1)[1]
|
||||
|
||||
raw_metadata["jira_key"] = jira_key
|
||||
raw_metadata["title"] = title
|
||||
raw_metadata["link"] = document.link
|
||||
|
||||
if not visible_fields:
|
||||
return raw_metadata
|
||||
|
||||
# Filter to only the selected fields, plus always include core fields
|
||||
resolved: dict[str, Any] = {
|
||||
"jira_key": raw_metadata.get("jira_key"),
|
||||
"title": raw_metadata.get("title"),
|
||||
"link": raw_metadata.get("link"),
|
||||
}
|
||||
for key in visible_fields:
|
||||
if key in raw_metadata:
|
||||
resolved[key] = raw_metadata[key]
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
@router.get("/proposals")
|
||||
def list_proposals(
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
user: User = Depends(require_permission(Permission.BASIC_ACCESS)), # noqa: ARG001
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ProposalListResponse:
|
||||
"""List proposals.
|
||||
|
||||
This queries the Document table filtered by the configured Jira project,
|
||||
LEFT JOINs proposal_review_proposal for review state, and resolves
|
||||
metadata field names via the field_mapping config.
|
||||
|
||||
Documents without a proposal record are returned with status PENDING
|
||||
without persisting any new rows (read-only endpoint).
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
# Get config for field mapping and Jira project filtering
|
||||
config = config_db.get_config(tenant_id, db_session)
|
||||
|
||||
# When no Argus config exists, return an empty list with a hint for the frontend.
|
||||
# The frontend can show "Configure a Jira connector in Settings to see proposals."
|
||||
if config is None:
|
||||
return ProposalListResponse(
|
||||
proposals=[],
|
||||
total_count=0,
|
||||
config_missing=True,
|
||||
)
|
||||
|
||||
visible_fields = config.field_mapping
|
||||
|
||||
# Query documents from the configured Jira connector only,
|
||||
# LEFT JOIN proposal state for review tracking.
|
||||
# NOTE: Tenant isolation is handled at the schema level (schema-per-tenant).
|
||||
# The DB session is already scoped to the current tenant's schema, so
|
||||
# cross-tenant data leakage is prevented by the connection itself.
|
||||
query = (
|
||||
db_session.query(Document, ProposalReviewProposal)
|
||||
.outerjoin(
|
||||
ProposalReviewProposal,
|
||||
Document.id == ProposalReviewProposal.document_id,
|
||||
)
|
||||
.options(selectinload(Document.tags))
|
||||
)
|
||||
|
||||
# Filter to only documents from the configured Jira connector
|
||||
if config and config.jira_connector_id:
|
||||
# Join through DocumentByConnectorCredentialPair to filter by connector
|
||||
query = query.join(
|
||||
DocumentByConnectorCredentialPair,
|
||||
Document.id == DocumentByConnectorCredentialPair.id,
|
||||
).filter(
|
||||
DocumentByConnectorCredentialPair.connector_id == config.jira_connector_id,
|
||||
)
|
||||
else:
|
||||
# No connector configured — filter to Jira source connectors only
|
||||
# to avoid showing Slack/GitHub/etc documents
|
||||
query = (
|
||||
query.join(
|
||||
DocumentByConnectorCredentialPair,
|
||||
Document.id == DocumentByConnectorCredentialPair.id,
|
||||
)
|
||||
.join(
|
||||
Connector,
|
||||
DocumentByConnectorCredentialPair.connector_id == Connector.id,
|
||||
)
|
||||
.filter(
|
||||
Connector.source == DocumentSource.JIRA,
|
||||
)
|
||||
)
|
||||
|
||||
# Exclude attachment documents — they are children of issue documents
|
||||
# and have "/attachments/" in their document ID.
|
||||
query = query.filter(~Document.id.contains("/attachments/"))
|
||||
|
||||
# If status filter is specified, only show documents with matching proposal status.
|
||||
# PENDING is special: documents without a proposal record are implicitly pending.
|
||||
if status:
|
||||
if status == "PENDING":
|
||||
query = query.filter(
|
||||
or_(
|
||||
ProposalReviewProposal.status == status,
|
||||
ProposalReviewProposal.id.is_(None),
|
||||
),
|
||||
)
|
||||
else:
|
||||
query = query.filter(ProposalReviewProposal.status == status)
|
||||
|
||||
# Count before adding DISTINCT ON — count(distinct(...)) handles
|
||||
# deduplication on its own and conflicts with DISTINCT ON.
|
||||
total_count = (
|
||||
query.with_entities(func.count(func.distinct(Document.id))).scalar() or 0
|
||||
)
|
||||
|
||||
# Deduplicate rows that can arise from multiple connector-credential pairs.
|
||||
# Applied after counting to avoid the DISTINCT ON + aggregate conflict.
|
||||
# ORDER BY Document.id is required for DISTINCT ON to be deterministic.
|
||||
query = query.distinct(Document.id).order_by(Document.id)
|
||||
results = query.offset(offset).limit(limit).all()
|
||||
|
||||
proposals: list[ProposalResponse] = []
|
||||
for document, proposal in results:
|
||||
if proposal is None:
|
||||
# Don't create DB records during GET — treat as pending
|
||||
metadata = _resolve_document_metadata(document, visible_fields)
|
||||
proposals.append(
|
||||
ProposalResponse(
|
||||
id=uuid4(), # temporary, not persisted
|
||||
document_id=document.id,
|
||||
tenant_id=tenant_id,
|
||||
status="PENDING",
|
||||
created_at=document.doc_updated_at or datetime.now(timezone.utc),
|
||||
updated_at=document.doc_updated_at or datetime.now(timezone.utc),
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
continue
|
||||
metadata = _resolve_document_metadata(document, visible_fields)
|
||||
proposals.append(ProposalResponse.from_model(proposal, metadata=metadata))
|
||||
|
||||
return ProposalListResponse(proposals=proposals, total_count=total_count)
|
||||
|
||||
|
||||
@router.get("/proposals/{proposal_id}")
|
||||
def get_proposal(
|
||||
proposal_id: UUID,
|
||||
user: User = Depends(require_permission(Permission.BASIC_ACCESS)), # noqa: ARG001
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ProposalResponse:
|
||||
"""Get a single proposal with its metadata from the Document table."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
proposal = proposals_db.get_proposal(proposal_id, tenant_id, db_session)
|
||||
if not proposal:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Proposal not found")
|
||||
|
||||
# Load the linked Document for metadata
|
||||
document = (
|
||||
db_session.query(Document)
|
||||
.options(selectinload(Document.tags))
|
||||
.filter(Document.id == proposal.document_id)
|
||||
.one_or_none()
|
||||
)
|
||||
config = config_db.get_config(tenant_id, db_session)
|
||||
visible_fields = config.field_mapping if config else None
|
||||
|
||||
metadata: dict[str, Any] = {}
|
||||
if document:
|
||||
metadata = _resolve_document_metadata(document, visible_fields)
|
||||
|
||||
return ProposalResponse.from_model(proposal, metadata=metadata)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Proposal Documents (manual uploads)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post(
|
||||
"/proposals/{proposal_id}/documents",
|
||||
status_code=201,
|
||||
)
|
||||
def upload_document(
|
||||
proposal_id: UUID,
|
||||
file: UploadFile,
|
||||
document_role: str = Form("OTHER"),
|
||||
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ProposalDocumentResponse:
|
||||
"""Upload a document to a proposal."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
proposal = proposals_db.get_proposal(proposal_id, tenant_id, db_session)
|
||||
if not proposal:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Proposal not found")
|
||||
|
||||
# Read file content
|
||||
try:
|
||||
file_bytes = file.file.read()
|
||||
except Exception as e:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
f"Failed to read uploaded file: {str(e)}",
|
||||
)
|
||||
|
||||
# Validate file size
|
||||
if len(file_bytes) > DOCUMENT_UPLOAD_MAX_FILE_SIZE_BYTES:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.PAYLOAD_TOO_LARGE,
|
||||
f"File size {len(file_bytes)} bytes exceeds maximum "
|
||||
f"allowed size of {DOCUMENT_UPLOAD_MAX_FILE_SIZE_BYTES} bytes",
|
||||
)
|
||||
|
||||
# Determine file type from filename
|
||||
filename = file.filename or "untitled"
|
||||
file_type = None
|
||||
if filename:
|
||||
parts = filename.rsplit(".", 1)
|
||||
if len(parts) > 1:
|
||||
file_type = parts[1].upper()
|
||||
|
||||
# Extract text from the uploaded file
|
||||
extracted_text = None
|
||||
if file_bytes:
|
||||
try:
|
||||
extracted_text = extract_file_text(
|
||||
file=io.BytesIO(file_bytes),
|
||||
file_name=filename,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to extract text from uploaded file '{filename}': {e}"
|
||||
)
|
||||
|
||||
doc = ProposalReviewDocument(
|
||||
proposal_id=proposal_id,
|
||||
file_name=filename,
|
||||
file_type=file_type,
|
||||
document_role=document_role,
|
||||
uploaded_by=user.id,
|
||||
extracted_text=extracted_text,
|
||||
)
|
||||
db_session.add(doc)
|
||||
db_session.commit()
|
||||
return ProposalDocumentResponse.from_model(doc)
|
||||
|
||||
|
||||
@router.get("/proposals/{proposal_id}/documents")
|
||||
def list_documents(
|
||||
proposal_id: UUID,
|
||||
user: User = Depends(require_permission(Permission.BASIC_ACCESS)), # noqa: ARG001
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[ProposalDocumentResponse]:
|
||||
"""List documents for a proposal."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
proposal = proposals_db.get_proposal(proposal_id, tenant_id, db_session)
|
||||
if not proposal:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Proposal not found")
|
||||
|
||||
docs = (
|
||||
db_session.query(ProposalReviewDocument)
|
||||
.filter(ProposalReviewDocument.proposal_id == proposal_id)
|
||||
.order_by(ProposalReviewDocument.created_at)
|
||||
.all()
|
||||
)
|
||||
return [ProposalDocumentResponse.from_model(d) for d in docs]
|
||||
|
||||
|
||||
@router.delete("/proposals/{proposal_id}/documents/{doc_id}", status_code=204)
|
||||
def delete_document(
|
||||
proposal_id: UUID,
|
||||
doc_id: UUID,
|
||||
user: User = Depends(require_permission(Permission.BASIC_ACCESS)), # noqa: ARG001
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
"""Delete a manually uploaded document."""
|
||||
# Verify the proposal belongs to the current tenant
|
||||
tenant_id = get_current_tenant_id()
|
||||
proposal = proposals_db.get_proposal(proposal_id, tenant_id, db_session)
|
||||
if not proposal:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Proposal not found")
|
||||
|
||||
doc = (
|
||||
db_session.query(ProposalReviewDocument)
|
||||
.filter(
|
||||
ProposalReviewDocument.id == doc_id,
|
||||
ProposalReviewDocument.proposal_id == proposal_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if not doc:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Document not found")
|
||||
db_session.delete(doc)
|
||||
db_session.commit()
|
||||
@@ -1,171 +0,0 @@
|
||||
"""API endpoints for review triggers, status, and findings."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.permissions import require_permission
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.enums import Permission
|
||||
from onyx.db.models import User
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.features.proposal_review.api.models import AuditLogEntry
|
||||
from onyx.server.features.proposal_review.api.models import FindingResponse
|
||||
from onyx.server.features.proposal_review.api.models import ReviewRunResponse
|
||||
from onyx.server.features.proposal_review.api.models import ReviewRunTriggerRequest
|
||||
from onyx.server.features.proposal_review.db import decisions as decisions_db
|
||||
from onyx.server.features.proposal_review.db import findings as findings_db
|
||||
from onyx.server.features.proposal_review.db import proposals as proposals_db
|
||||
from onyx.server.features.proposal_review.db import rulesets as rulesets_db
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/proposals/{proposal_id}/review",
|
||||
status_code=201,
|
||||
)
|
||||
def trigger_review(
|
||||
proposal_id: UUID,
|
||||
request: ReviewRunTriggerRequest,
|
||||
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ReviewRunResponse:
|
||||
"""Trigger a new review run for a proposal.
|
||||
|
||||
Creates a ReviewRun record and returns it. No Celery dispatch yet --
|
||||
the engine will be wired in Workstream 3.
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
# Verify proposal exists
|
||||
proposal = proposals_db.get_proposal(proposal_id, tenant_id, db_session)
|
||||
if not proposal:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Proposal not found")
|
||||
|
||||
# Verify ruleset exists and count active rules
|
||||
ruleset = rulesets_db.get_ruleset(request.ruleset_id, tenant_id, db_session)
|
||||
if not ruleset:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Ruleset not found")
|
||||
|
||||
active_rule_count = rulesets_db.count_active_rules(request.ruleset_id, db_session)
|
||||
if active_rule_count == 0:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
"Ruleset has no active rules",
|
||||
)
|
||||
|
||||
# Update proposal status to IN_REVIEW
|
||||
proposals_db.update_proposal_status(proposal_id, tenant_id, "IN_REVIEW", db_session)
|
||||
|
||||
# Create the review run record
|
||||
run = findings_db.create_review_run(
|
||||
proposal_id=proposal_id,
|
||||
ruleset_id=request.ruleset_id,
|
||||
triggered_by=user.id,
|
||||
total_rules=active_rule_count,
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
# Create audit log entry
|
||||
decisions_db.create_audit_log(
|
||||
proposal_id=proposal_id,
|
||||
action="review_triggered",
|
||||
user_id=user.id,
|
||||
details={
|
||||
"review_run_id": str(run.id),
|
||||
"ruleset_id": str(request.ruleset_id),
|
||||
"total_rules": active_rule_count,
|
||||
},
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
db_session.commit()
|
||||
logger.info(
|
||||
f"Review triggered for proposal {proposal_id} "
|
||||
f"with ruleset {request.ruleset_id} ({active_rule_count} rules)"
|
||||
)
|
||||
|
||||
# Dispatch Celery task to run the review asynchronously
|
||||
from onyx.server.features.proposal_review.engine.review_engine import (
|
||||
run_proposal_review,
|
||||
)
|
||||
|
||||
run_proposal_review.apply_async(args=[str(run.id), tenant_id], expires=3600)
|
||||
|
||||
return ReviewRunResponse.from_model(run)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/proposals/{proposal_id}/review-status",
|
||||
)
|
||||
def get_review_status(
|
||||
proposal_id: UUID,
|
||||
user: User = Depends(require_permission(Permission.BASIC_ACCESS)), # noqa: ARG001
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ReviewRunResponse:
|
||||
"""Get the status of the latest review run for a proposal."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
proposal = proposals_db.get_proposal(proposal_id, tenant_id, db_session)
|
||||
if not proposal:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Proposal not found")
|
||||
|
||||
run = findings_db.get_latest_review_run(proposal_id, db_session)
|
||||
if not run:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "No review runs found")
|
||||
|
||||
return ReviewRunResponse.from_model(run)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/proposals/{proposal_id}/findings",
|
||||
)
|
||||
def get_findings(
|
||||
proposal_id: UUID,
|
||||
review_run_id: UUID | None = None,
|
||||
user: User = Depends(require_permission(Permission.BASIC_ACCESS)), # noqa: ARG001
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[FindingResponse]:
|
||||
"""Get findings for a proposal.
|
||||
|
||||
If review_run_id is not specified, returns findings from the latest run.
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
proposal = proposals_db.get_proposal(proposal_id, tenant_id, db_session)
|
||||
if not proposal:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Proposal not found")
|
||||
|
||||
# If no run specified, get the latest
|
||||
if review_run_id is None:
|
||||
run = findings_db.get_latest_review_run(proposal_id, db_session)
|
||||
if not run:
|
||||
return []
|
||||
review_run_id = run.id
|
||||
|
||||
results = findings_db.list_findings_by_run(review_run_id, db_session)
|
||||
return [FindingResponse.from_model(f) for f in results]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/proposals/{proposal_id}/audit-log",
|
||||
)
|
||||
def get_audit_log(
|
||||
proposal_id: UUID,
|
||||
user: User = Depends(require_permission(Permission.BASIC_ACCESS)), # noqa: ARG001
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[AuditLogEntry]:
|
||||
"""Get the audit trail for a proposal."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
proposal = proposals_db.get_proposal(proposal_id, tenant_id, db_session)
|
||||
if not proposal:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Proposal not found")
|
||||
|
||||
entries = decisions_db.list_audit_log(proposal_id, db_session)
|
||||
return [AuditLogEntry.from_model(e) for e in entries]
|
||||
@@ -1,421 +0,0 @@
|
||||
"""API endpoints for rulesets and rules."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.permissions import require_permission
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.enums import Permission
|
||||
from onyx.db.models import User
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.features.proposal_review.api.models import BulkRuleUpdateRequest
|
||||
from onyx.server.features.proposal_review.api.models import BulkRuleUpdateResponse
|
||||
from onyx.server.features.proposal_review.api.models import ImportResponse
|
||||
from onyx.server.features.proposal_review.api.models import RuleCreate
|
||||
from onyx.server.features.proposal_review.api.models import RuleResponse
|
||||
from onyx.server.features.proposal_review.api.models import RulesetCreate
|
||||
from onyx.server.features.proposal_review.api.models import RulesetResponse
|
||||
from onyx.server.features.proposal_review.api.models import RulesetUpdate
|
||||
from onyx.server.features.proposal_review.api.models import RuleUpdate
|
||||
from onyx.server.features.proposal_review.configs import IMPORT_MAX_FILE_SIZE_BYTES
|
||||
from onyx.server.features.proposal_review.db import rulesets as rulesets_db
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Rulesets
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/rulesets")
|
||||
def list_rulesets(
|
||||
user: User = Depends(require_permission(Permission.BASIC_ACCESS)), # noqa: ARG001
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[RulesetResponse]:
|
||||
"""List all rulesets for the current tenant."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
rulesets = rulesets_db.list_rulesets(tenant_id, db_session)
|
||||
return [RulesetResponse.from_model(rs) for rs in rulesets]
|
||||
|
||||
|
||||
@router.post("/rulesets", status_code=201)
|
||||
def create_ruleset(
|
||||
request: RulesetCreate,
|
||||
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> RulesetResponse:
|
||||
"""Create a new ruleset."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
ruleset = rulesets_db.create_ruleset(
|
||||
tenant_id=tenant_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
is_default=request.is_default,
|
||||
created_by=user.id,
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
return RulesetResponse.from_model(ruleset, include_rules=False)
|
||||
|
||||
|
||||
@router.get("/rulesets/{ruleset_id}")
|
||||
def get_ruleset(
|
||||
ruleset_id: UUID,
|
||||
user: User = Depends(require_permission(Permission.BASIC_ACCESS)), # noqa: ARG001
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> RulesetResponse:
|
||||
"""Get a ruleset with all its rules."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
ruleset = rulesets_db.get_ruleset(ruleset_id, tenant_id, db_session)
|
||||
if not ruleset:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Ruleset not found")
|
||||
return RulesetResponse.from_model(ruleset)
|
||||
|
||||
|
||||
@router.put("/rulesets/{ruleset_id}")
|
||||
def update_ruleset(
|
||||
ruleset_id: UUID,
|
||||
request: RulesetUpdate,
|
||||
user: User = Depends( # noqa: ARG001
|
||||
require_permission(Permission.MANAGE_CONNECTORS)
|
||||
),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> RulesetResponse:
|
||||
"""Update a ruleset."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
ruleset = rulesets_db.update_ruleset(
|
||||
ruleset_id=ruleset_id,
|
||||
tenant_id=tenant_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
is_default=request.is_default,
|
||||
is_active=request.is_active,
|
||||
db_session=db_session,
|
||||
)
|
||||
if not ruleset:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Ruleset not found")
|
||||
db_session.commit()
|
||||
return RulesetResponse.from_model(ruleset)
|
||||
|
||||
|
||||
@router.delete("/rulesets/{ruleset_id}", status_code=204)
|
||||
def delete_ruleset(
|
||||
ruleset_id: UUID,
|
||||
user: User = Depends( # noqa: ARG001
|
||||
require_permission(Permission.MANAGE_CONNECTORS)
|
||||
),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
"""Delete a ruleset and all its rules."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
deleted = rulesets_db.delete_ruleset(ruleset_id, tenant_id, db_session)
|
||||
if not deleted:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Ruleset not found")
|
||||
db_session.commit()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Rules
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rulesets/{ruleset_id}/rules",
|
||||
status_code=201,
|
||||
)
|
||||
def create_rule(
|
||||
ruleset_id: UUID,
|
||||
request: RuleCreate,
|
||||
user: User = Depends( # noqa: ARG001
|
||||
require_permission(Permission.MANAGE_CONNECTORS)
|
||||
),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> RuleResponse:
|
||||
"""Create a new rule within a ruleset."""
|
||||
# Verify ruleset exists and belongs to tenant
|
||||
tenant_id = get_current_tenant_id()
|
||||
ruleset = rulesets_db.get_ruleset(ruleset_id, tenant_id, db_session)
|
||||
if not ruleset:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Ruleset not found")
|
||||
|
||||
rule = rulesets_db.create_rule(
|
||||
ruleset_id=ruleset_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
category=request.category,
|
||||
rule_type=request.rule_type,
|
||||
rule_intent=request.rule_intent,
|
||||
prompt_template=request.prompt_template,
|
||||
source=request.source,
|
||||
authority=request.authority,
|
||||
is_hard_stop=request.is_hard_stop,
|
||||
priority=request.priority,
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
return RuleResponse.from_model(rule)
|
||||
|
||||
|
||||
@router.put("/rules/{rule_id}")
|
||||
def update_rule(
|
||||
rule_id: UUID,
|
||||
request: RuleUpdate,
|
||||
user: User = Depends( # noqa: ARG001
|
||||
require_permission(Permission.MANAGE_CONNECTORS)
|
||||
),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> RuleResponse:
|
||||
"""Update a rule."""
|
||||
# Verify the rule belongs to a ruleset owned by the current tenant
|
||||
tenant_id = get_current_tenant_id()
|
||||
rule = rulesets_db.get_rule(rule_id, db_session)
|
||||
if not rule:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Rule not found")
|
||||
ruleset = rulesets_db.get_ruleset(rule.ruleset_id, tenant_id, db_session)
|
||||
if not ruleset:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Rule not found")
|
||||
|
||||
updated_rule = rulesets_db.update_rule(
|
||||
rule_id=rule_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
category=request.category,
|
||||
rule_type=request.rule_type,
|
||||
rule_intent=request.rule_intent,
|
||||
prompt_template=request.prompt_template,
|
||||
authority=request.authority,
|
||||
is_hard_stop=request.is_hard_stop,
|
||||
priority=request.priority,
|
||||
is_active=request.is_active,
|
||||
db_session=db_session,
|
||||
)
|
||||
if not updated_rule:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Rule not found")
|
||||
db_session.commit()
|
||||
return RuleResponse.from_model(updated_rule)
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}", status_code=204)
|
||||
def delete_rule(
|
||||
rule_id: UUID,
|
||||
user: User = Depends( # noqa: ARG001
|
||||
require_permission(Permission.MANAGE_CONNECTORS)
|
||||
),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
"""Delete a rule."""
|
||||
# Verify the rule belongs to a ruleset owned by the current tenant
|
||||
tenant_id = get_current_tenant_id()
|
||||
rule = rulesets_db.get_rule(rule_id, db_session)
|
||||
if not rule:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Rule not found")
|
||||
ruleset = rulesets_db.get_ruleset(rule.ruleset_id, tenant_id, db_session)
|
||||
if not ruleset:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Rule not found")
|
||||
|
||||
deleted = rulesets_db.delete_rule(rule_id, db_session)
|
||||
if not deleted:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Rule not found")
|
||||
db_session.commit()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rulesets/{ruleset_id}/rules/bulk-update",
|
||||
)
|
||||
def bulk_update_rules(
|
||||
ruleset_id: UUID,
|
||||
request: BulkRuleUpdateRequest,
|
||||
user: User = Depends( # noqa: ARG001
|
||||
require_permission(Permission.MANAGE_CONNECTORS)
|
||||
),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> BulkRuleUpdateResponse:
|
||||
"""Batch activate/deactivate/delete rules."""
|
||||
# Verify the ruleset belongs to the current tenant
|
||||
tenant_id = get_current_tenant_id()
|
||||
ruleset = rulesets_db.get_ruleset(ruleset_id, tenant_id, db_session)
|
||||
if not ruleset:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Ruleset not found")
|
||||
|
||||
if request.action not in ("activate", "deactivate", "delete"):
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
"action must be 'activate', 'deactivate', or 'delete'",
|
||||
)
|
||||
# Only operate on rules that belong to this ruleset (tenant-scoped)
|
||||
count = rulesets_db.bulk_update_rules(
|
||||
request.rule_ids, request.action, ruleset_id, db_session
|
||||
)
|
||||
db_session.commit()
|
||||
return BulkRuleUpdateResponse(updated_count=count)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rulesets/{ruleset_id}/import",
|
||||
)
|
||||
def import_checklist(
|
||||
ruleset_id: UUID,
|
||||
file: UploadFile,
|
||||
user: User = Depends( # noqa: ARG001
|
||||
require_permission(Permission.MANAGE_CONNECTORS)
|
||||
),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ImportResponse:
|
||||
"""Upload a checklist document and parse it into atomic review rules via LLM.
|
||||
|
||||
Accepts a checklist file (.pdf, .docx, .xlsx, .txt), extracts its text,
|
||||
and uses LLM to decompose it into atomic, self-contained rules.
|
||||
Rules are saved to the ruleset as inactive drafts (is_active=false).
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
ruleset = rulesets_db.get_ruleset(ruleset_id, tenant_id, db_session)
|
||||
if not ruleset:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Ruleset not found")
|
||||
|
||||
# Read the uploaded file content
|
||||
try:
|
||||
file_content = file.file.read()
|
||||
except Exception as e:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
f"Failed to read uploaded file: {str(e)}",
|
||||
)
|
||||
|
||||
if not file_content:
|
||||
raise OnyxError(OnyxErrorCode.INVALID_INPUT, "Uploaded file is empty")
|
||||
|
||||
# Validate file size
|
||||
if len(file_content) > IMPORT_MAX_FILE_SIZE_BYTES:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.PAYLOAD_TOO_LARGE,
|
||||
f"File size {len(file_content)} bytes exceeds maximum "
|
||||
f"allowed size of {IMPORT_MAX_FILE_SIZE_BYTES} bytes",
|
||||
)
|
||||
|
||||
# Extract text from the file
|
||||
# For text files, decode directly; for other formats, use extract_file_text
|
||||
extracted_text = ""
|
||||
filename = file.filename or "untitled"
|
||||
file_ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
|
||||
if file_ext in ("txt", "text", "md"):
|
||||
extracted_text = file_content.decode("utf-8", errors="replace")
|
||||
else:
|
||||
try:
|
||||
import io
|
||||
|
||||
from onyx.file_processing.extract_file_text import extract_file_text
|
||||
|
||||
extracted_text = extract_file_text(
|
||||
file=io.BytesIO(file_content),
|
||||
file_name=filename,
|
||||
)
|
||||
except Exception as e:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
f"Failed to extract text from file: {str(e)}",
|
||||
)
|
||||
|
||||
if not extracted_text or not extracted_text.strip():
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
"No text could be extracted from the uploaded file",
|
||||
)
|
||||
|
||||
# Call the LLM-based checklist importer
|
||||
from onyx.server.features.proposal_review.engine.checklist_importer import (
|
||||
import_checklist,
|
||||
)
|
||||
|
||||
try:
|
||||
rule_dicts = import_checklist(extracted_text)
|
||||
except RuntimeError as e:
|
||||
raise OnyxError(OnyxErrorCode.INTERNAL_ERROR, str(e))
|
||||
|
||||
# Save parsed rules to the ruleset as inactive drafts
|
||||
created_rules = []
|
||||
for rd in rule_dicts:
|
||||
rule = rulesets_db.create_rule(
|
||||
ruleset_id=ruleset_id,
|
||||
name=rd["name"],
|
||||
description=rd.get("description"),
|
||||
category=rd.get("category"),
|
||||
rule_type=rd.get("rule_type", "CUSTOM_NL"),
|
||||
rule_intent=rd.get("rule_intent", "CHECK"),
|
||||
prompt_template=rd["prompt_template"],
|
||||
source="IMPORTED",
|
||||
is_hard_stop=False,
|
||||
priority=0,
|
||||
db_session=db_session,
|
||||
)
|
||||
# Rules start as inactive drafts — admin reviews and activates
|
||||
rule.is_active = False
|
||||
db_session.flush()
|
||||
created_rules.append(rule)
|
||||
|
||||
db_session.commit()
|
||||
return ImportResponse(
|
||||
rules_created=len(created_rules),
|
||||
rules=[RuleResponse.from_model(r) for r in created_rules],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/rules/{rule_id}/test")
|
||||
def test_rule(
|
||||
rule_id: UUID,
|
||||
user: User = Depends( # noqa: ARG001
|
||||
require_permission(Permission.MANAGE_CONNECTORS)
|
||||
),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Test a rule against sample text.
|
||||
|
||||
Evaluates the rule against an empty/minimal proposal context to verify
|
||||
the prompt template is well-formed and the LLM can produce a valid response.
|
||||
"""
|
||||
# Verify the rule belongs to a ruleset owned by the current tenant
|
||||
tenant_id = get_current_tenant_id()
|
||||
rule = rulesets_db.get_rule(rule_id, db_session)
|
||||
if not rule:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Rule not found")
|
||||
ruleset = rulesets_db.get_ruleset(rule.ruleset_id, tenant_id, db_session)
|
||||
if not ruleset:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Rule not found")
|
||||
|
||||
from onyx.server.features.proposal_review.engine.context_assembler import (
|
||||
ProposalContext,
|
||||
)
|
||||
from onyx.server.features.proposal_review.engine.rule_evaluator import (
|
||||
evaluate_rule,
|
||||
)
|
||||
|
||||
# Build a minimal test context
|
||||
test_context = ProposalContext(
|
||||
proposal_text="[Sample proposal text for testing. No real proposal loaded.]",
|
||||
budget_text="[No budget text available for test.]",
|
||||
foa_text="[No FOA text available for test.]",
|
||||
metadata={"test_mode": True},
|
||||
jira_key="TEST-000",
|
||||
)
|
||||
|
||||
try:
|
||||
result = evaluate_rule(rule, test_context, db_session)
|
||||
except Exception as e:
|
||||
return {
|
||||
"rule_id": str(rule_id),
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
return {
|
||||
"rule_id": str(rule_id),
|
||||
"success": True,
|
||||
"result": result,
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import os
|
||||
|
||||
# Feature flag for enabling proposal review
|
||||
ENABLE_PROPOSAL_REVIEW = (
|
||||
os.environ.get("ENABLE_PROPOSAL_REVIEW", "true").lower() == "true"
|
||||
)
|
||||
|
||||
# Maximum file size for checklist imports (in MB)
|
||||
IMPORT_MAX_FILE_SIZE_MB = int(
|
||||
os.environ.get("PROPOSAL_REVIEW_IMPORT_MAX_FILE_SIZE_MB", "50")
|
||||
)
|
||||
IMPORT_MAX_FILE_SIZE_BYTES = IMPORT_MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
|
||||
# Maximum file size for document uploads (in MB)
|
||||
DOCUMENT_UPLOAD_MAX_FILE_SIZE_MB = int(
|
||||
os.environ.get("PROPOSAL_REVIEW_DOCUMENT_UPLOAD_MAX_FILE_SIZE_MB", "100")
|
||||
)
|
||||
DOCUMENT_UPLOAD_MAX_FILE_SIZE_BYTES = DOCUMENT_UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
@@ -1,93 +0,0 @@
|
||||
"""DB operations for tenant configuration."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.db.models import Document__Tag
|
||||
from onyx.db.models import DocumentByConnectorCredentialPair
|
||||
from onyx.db.models import Tag
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewConfig
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def get_config(
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
) -> ProposalReviewConfig | None:
|
||||
"""Get the config row for a tenant (there is at most one)."""
|
||||
return (
|
||||
db_session.query(ProposalReviewConfig)
|
||||
.filter(ProposalReviewConfig.tenant_id == tenant_id)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def upsert_config(
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
jira_connector_id: int | None = None,
|
||||
jira_project_key: str | None = None,
|
||||
field_mapping: list[str] | None = None,
|
||||
jira_writeback: dict[str, Any] | None = None,
|
||||
) -> ProposalReviewConfig:
|
||||
"""Create or update the tenant config."""
|
||||
config = get_config(tenant_id, db_session)
|
||||
|
||||
if config:
|
||||
if jira_connector_id is not None:
|
||||
config.jira_connector_id = jira_connector_id
|
||||
if jira_project_key is not None:
|
||||
config.jira_project_key = jira_project_key
|
||||
if field_mapping is not None:
|
||||
config.field_mapping = field_mapping
|
||||
if jira_writeback is not None:
|
||||
config.jira_writeback = jira_writeback
|
||||
config.updated_at = datetime.now(timezone.utc)
|
||||
db_session.flush()
|
||||
logger.info(f"Updated proposal review config for tenant {tenant_id}")
|
||||
return config
|
||||
|
||||
config = ProposalReviewConfig(
|
||||
tenant_id=tenant_id,
|
||||
jira_connector_id=jira_connector_id,
|
||||
jira_project_key=jira_project_key,
|
||||
field_mapping=field_mapping,
|
||||
jira_writeback=jira_writeback,
|
||||
)
|
||||
db_session.add(config)
|
||||
db_session.flush()
|
||||
logger.info(f"Created proposal review config for tenant {tenant_id}")
|
||||
return config
|
||||
|
||||
|
||||
def get_connector_metadata_keys(
|
||||
connector_id: int,
|
||||
db_session: Session,
|
||||
) -> list[str]:
|
||||
"""Return distinct metadata tag keys for documents from a connector.
|
||||
|
||||
Jira custom fields are stored as tags (tag_key / tag_value) linked
|
||||
to documents via the document__tag join table.
|
||||
"""
|
||||
stmt = (
|
||||
select(Tag.tag_key)
|
||||
.select_from(Tag)
|
||||
.join(Document__Tag, Tag.id == Document__Tag.tag_id)
|
||||
.join(
|
||||
DocumentByConnectorCredentialPair,
|
||||
Document__Tag.document_id == DocumentByConnectorCredentialPair.id,
|
||||
)
|
||||
.where(
|
||||
DocumentByConnectorCredentialPair.connector_id == connector_id,
|
||||
)
|
||||
.distinct()
|
||||
.limit(500)
|
||||
)
|
||||
rows = db_session.execute(stmt).all()
|
||||
return sorted(row[0] for row in rows)
|
||||
@@ -1,168 +0,0 @@
|
||||
"""DB operations for finding decisions and proposal decisions."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewAuditLog
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewDecision
|
||||
from onyx.server.features.proposal_review.db.models import (
|
||||
ProposalReviewProposalDecision,
|
||||
)
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Per-Finding Decisions (upsert — one decision per finding)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def upsert_finding_decision(
|
||||
finding_id: UUID,
|
||||
officer_id: UUID,
|
||||
action: str,
|
||||
db_session: Session,
|
||||
notes: str | None = None,
|
||||
) -> ProposalReviewDecision:
|
||||
"""Create or update a decision on a finding.
|
||||
|
||||
There is a UNIQUE constraint on finding_id, so this is an upsert.
|
||||
"""
|
||||
existing = (
|
||||
db_session.query(ProposalReviewDecision)
|
||||
.filter(ProposalReviewDecision.finding_id == finding_id)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.officer_id = officer_id
|
||||
existing.action = action
|
||||
existing.notes = notes
|
||||
existing.updated_at = datetime.now(timezone.utc)
|
||||
db_session.flush()
|
||||
logger.info(f"Updated decision on finding {finding_id} to {action}")
|
||||
return existing
|
||||
|
||||
decision = ProposalReviewDecision(
|
||||
finding_id=finding_id,
|
||||
officer_id=officer_id,
|
||||
action=action,
|
||||
notes=notes,
|
||||
)
|
||||
db_session.add(decision)
|
||||
db_session.flush()
|
||||
logger.info(f"Created decision {decision.id} on finding {finding_id}")
|
||||
return decision
|
||||
|
||||
|
||||
def get_finding_decision(
|
||||
finding_id: UUID,
|
||||
db_session: Session,
|
||||
) -> ProposalReviewDecision | None:
|
||||
"""Get the decision for a finding."""
|
||||
return (
|
||||
db_session.query(ProposalReviewDecision)
|
||||
.filter(ProposalReviewDecision.finding_id == finding_id)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Proposal-Level Decisions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def create_proposal_decision(
|
||||
proposal_id: UUID,
|
||||
officer_id: UUID,
|
||||
decision: str,
|
||||
db_session: Session,
|
||||
notes: str | None = None,
|
||||
) -> ProposalReviewProposalDecision:
|
||||
"""Create a final decision on a proposal."""
|
||||
pd = ProposalReviewProposalDecision(
|
||||
proposal_id=proposal_id,
|
||||
officer_id=officer_id,
|
||||
decision=decision,
|
||||
notes=notes,
|
||||
)
|
||||
db_session.add(pd)
|
||||
db_session.flush()
|
||||
logger.info(
|
||||
f"Created proposal decision {pd.id} ({decision}) for proposal {proposal_id}"
|
||||
)
|
||||
return pd
|
||||
|
||||
|
||||
def get_latest_proposal_decision(
|
||||
proposal_id: UUID,
|
||||
db_session: Session,
|
||||
) -> ProposalReviewProposalDecision | None:
|
||||
"""Get the most recent decision for a proposal."""
|
||||
return (
|
||||
db_session.query(ProposalReviewProposalDecision)
|
||||
.filter(ProposalReviewProposalDecision.proposal_id == proposal_id)
|
||||
.order_by(desc(ProposalReviewProposalDecision.created_at))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def mark_decision_jira_synced(
|
||||
decision_id: UUID,
|
||||
db_session: Session,
|
||||
) -> ProposalReviewProposalDecision | None:
|
||||
"""Mark a proposal decision as synced to Jira."""
|
||||
pd = (
|
||||
db_session.query(ProposalReviewProposalDecision)
|
||||
.filter(ProposalReviewProposalDecision.id == decision_id)
|
||||
.one_or_none()
|
||||
)
|
||||
if not pd:
|
||||
return None
|
||||
pd.jira_synced = True
|
||||
pd.jira_synced_at = datetime.now(timezone.utc)
|
||||
db_session.flush()
|
||||
logger.info(f"Marked proposal decision {decision_id} as jira_synced")
|
||||
return pd
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Audit Log
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def create_audit_log(
|
||||
proposal_id: UUID,
|
||||
action: str,
|
||||
db_session: Session,
|
||||
user_id: UUID | None = None,
|
||||
details: dict | None = None,
|
||||
) -> ProposalReviewAuditLog:
|
||||
"""Create an audit log entry."""
|
||||
entry = ProposalReviewAuditLog(
|
||||
proposal_id=proposal_id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
details=details,
|
||||
)
|
||||
db_session.add(entry)
|
||||
db_session.flush()
|
||||
return entry
|
||||
|
||||
|
||||
def list_audit_log(
|
||||
proposal_id: UUID,
|
||||
db_session: Session,
|
||||
) -> list[ProposalReviewAuditLog]:
|
||||
"""List audit log entries for a proposal, newest first."""
|
||||
return (
|
||||
db_session.query(ProposalReviewAuditLog)
|
||||
.filter(ProposalReviewAuditLog.proposal_id == proposal_id)
|
||||
.order_by(desc(ProposalReviewAuditLog.created_at))
|
||||
.all()
|
||||
)
|
||||
@@ -1,158 +0,0 @@
|
||||
"""DB operations for review runs and findings."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewFinding
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewRun
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Review Runs
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def create_review_run(
|
||||
proposal_id: UUID,
|
||||
ruleset_id: UUID,
|
||||
triggered_by: UUID,
|
||||
total_rules: int,
|
||||
db_session: Session,
|
||||
) -> ProposalReviewRun:
|
||||
"""Create a new review run record."""
|
||||
run = ProposalReviewRun(
|
||||
proposal_id=proposal_id,
|
||||
ruleset_id=ruleset_id,
|
||||
triggered_by=triggered_by,
|
||||
total_rules=total_rules,
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.flush()
|
||||
logger.info(
|
||||
f"Created review run {run.id} for proposal {proposal_id} "
|
||||
f"with {total_rules} rules"
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
def get_review_run(
|
||||
run_id: UUID,
|
||||
db_session: Session,
|
||||
) -> ProposalReviewRun | None:
|
||||
"""Get a review run by ID."""
|
||||
return (
|
||||
db_session.query(ProposalReviewRun)
|
||||
.filter(ProposalReviewRun.id == run_id)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def get_latest_review_run(
|
||||
proposal_id: UUID,
|
||||
db_session: Session,
|
||||
) -> ProposalReviewRun | None:
|
||||
"""Get the most recent review run for a proposal."""
|
||||
return (
|
||||
db_session.query(ProposalReviewRun)
|
||||
.filter(ProposalReviewRun.proposal_id == proposal_id)
|
||||
.order_by(desc(ProposalReviewRun.created_at))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Findings
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def create_finding(
|
||||
proposal_id: UUID,
|
||||
rule_id: UUID,
|
||||
review_run_id: UUID,
|
||||
verdict: str,
|
||||
db_session: Session,
|
||||
confidence: str | None = None,
|
||||
evidence: str | None = None,
|
||||
explanation: str | None = None,
|
||||
suggested_action: str | None = None,
|
||||
llm_model: str | None = None,
|
||||
llm_tokens_used: int | None = None,
|
||||
) -> ProposalReviewFinding:
|
||||
"""Create a new finding."""
|
||||
finding = ProposalReviewFinding(
|
||||
proposal_id=proposal_id,
|
||||
rule_id=rule_id,
|
||||
review_run_id=review_run_id,
|
||||
verdict=verdict,
|
||||
confidence=confidence,
|
||||
evidence=evidence,
|
||||
explanation=explanation,
|
||||
suggested_action=suggested_action,
|
||||
llm_model=llm_model,
|
||||
llm_tokens_used=llm_tokens_used,
|
||||
)
|
||||
db_session.add(finding)
|
||||
db_session.flush()
|
||||
logger.info(
|
||||
f"Created finding {finding.id} verdict={verdict} for proposal {proposal_id}"
|
||||
)
|
||||
return finding
|
||||
|
||||
|
||||
def get_finding(
|
||||
finding_id: UUID,
|
||||
db_session: Session,
|
||||
) -> ProposalReviewFinding | None:
|
||||
"""Get a finding by ID with its decision and rule eagerly loaded."""
|
||||
return (
|
||||
db_session.query(ProposalReviewFinding)
|
||||
.filter(ProposalReviewFinding.id == finding_id)
|
||||
.options(
|
||||
selectinload(ProposalReviewFinding.decision),
|
||||
selectinload(ProposalReviewFinding.rule),
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def list_findings_by_proposal(
|
||||
proposal_id: UUID,
|
||||
db_session: Session,
|
||||
review_run_id: UUID | None = None,
|
||||
) -> list[ProposalReviewFinding]:
|
||||
"""List findings for a proposal, optionally filtered to a specific run."""
|
||||
query = (
|
||||
db_session.query(ProposalReviewFinding)
|
||||
.filter(ProposalReviewFinding.proposal_id == proposal_id)
|
||||
.options(
|
||||
selectinload(ProposalReviewFinding.decision),
|
||||
selectinload(ProposalReviewFinding.rule),
|
||||
)
|
||||
.order_by(ProposalReviewFinding.created_at)
|
||||
)
|
||||
if review_run_id:
|
||||
query = query.filter(ProposalReviewFinding.review_run_id == review_run_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
def list_findings_by_run(
|
||||
review_run_id: UUID,
|
||||
db_session: Session,
|
||||
) -> list[ProposalReviewFinding]:
|
||||
"""List all findings for a specific review run."""
|
||||
return (
|
||||
db_session.query(ProposalReviewFinding)
|
||||
.filter(ProposalReviewFinding.review_run_id == review_run_id)
|
||||
.options(
|
||||
selectinload(ProposalReviewFinding.decision),
|
||||
selectinload(ProposalReviewFinding.rule),
|
||||
)
|
||||
.order_by(ProposalReviewFinding.created_at)
|
||||
.all()
|
||||
)
|
||||
@@ -1,412 +0,0 @@
|
||||
"""SQLAlchemy models for Proposal Review (Argus)."""
|
||||
|
||||
import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Boolean
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import Index
|
||||
from sqlalchemy import Integer
|
||||
from sqlalchemy import Text
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB as PGJSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped
|
||||
from sqlalchemy.orm import mapped_column
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from onyx.db.models import Base
|
||||
|
||||
|
||||
class ProposalReviewRuleset(Base):
|
||||
__tablename__ = "proposal_review_ruleset"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(Text, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_default: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, server_default=text("false")
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, server_default=text("true")
|
||||
)
|
||||
created_by: Mapped[UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("user.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
rules: Mapped[list["ProposalReviewRule"]] = relationship(
|
||||
"ProposalReviewRule",
|
||||
back_populates="ruleset",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ProposalReviewRule.priority",
|
||||
)
|
||||
|
||||
|
||||
class ProposalReviewRule(Base):
|
||||
__tablename__ = "proposal_review_rule"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
ruleset_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("proposal_review_ruleset.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
rule_type: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
rule_intent: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, server_default=text("'CHECK'")
|
||||
)
|
||||
prompt_template: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, server_default=text("'MANUAL'")
|
||||
)
|
||||
authority: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_hard_stop: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, server_default=text("false")
|
||||
)
|
||||
priority: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default=text("0")
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, server_default=text("true")
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
ruleset: Mapped["ProposalReviewRuleset"] = relationship(
|
||||
"ProposalReviewRuleset", back_populates="rules"
|
||||
)
|
||||
|
||||
|
||||
class ProposalReviewProposal(Base):
|
||||
__tablename__ = "proposal_review_proposal"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("document_id", "tenant_id"),
|
||||
Index("ix_proposal_review_proposal_tenant_id", "tenant_id"),
|
||||
Index("ix_proposal_review_proposal_document_id", "document_id"),
|
||||
Index("ix_proposal_review_proposal_status", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
document_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
tenant_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, server_default=text("'PENDING'")
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
review_runs: Mapped[list["ProposalReviewRun"]] = relationship(
|
||||
"ProposalReviewRun",
|
||||
back_populates="proposal",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
findings: Mapped[list["ProposalReviewFinding"]] = relationship(
|
||||
"ProposalReviewFinding",
|
||||
back_populates="proposal",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
proposal_decisions: Mapped[list["ProposalReviewProposalDecision"]] = relationship(
|
||||
"ProposalReviewProposalDecision",
|
||||
back_populates="proposal",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
documents: Mapped[list["ProposalReviewDocument"]] = relationship(
|
||||
"ProposalReviewDocument",
|
||||
back_populates="proposal",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
audit_logs: Mapped[list["ProposalReviewAuditLog"]] = relationship(
|
||||
"ProposalReviewAuditLog",
|
||||
back_populates="proposal",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class ProposalReviewRun(Base):
|
||||
__tablename__ = "proposal_review_run"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
proposal_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("proposal_review_proposal.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
ruleset_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("proposal_review_ruleset.id"),
|
||||
nullable=False,
|
||||
)
|
||||
triggered_by: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("user.id"), nullable=False
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, server_default=text("'PENDING'")
|
||||
)
|
||||
total_rules: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
completed_rules: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default=text("0")
|
||||
)
|
||||
started_at: Mapped[datetime.datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
completed_at: Mapped[datetime.datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
proposal: Mapped["ProposalReviewProposal"] = relationship(
|
||||
"ProposalReviewProposal", back_populates="review_runs"
|
||||
)
|
||||
findings: Mapped[list["ProposalReviewFinding"]] = relationship(
|
||||
"ProposalReviewFinding",
|
||||
back_populates="review_run",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class ProposalReviewFinding(Base):
|
||||
__tablename__ = "proposal_review_finding"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
proposal_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("proposal_review_proposal.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
rule_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("proposal_review_rule.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
review_run_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("proposal_review_run.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
verdict: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
confidence: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
evidence: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
explanation: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
suggested_action: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
llm_model: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
llm_tokens_used: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
proposal: Mapped["ProposalReviewProposal"] = relationship(
|
||||
"ProposalReviewProposal", back_populates="findings"
|
||||
)
|
||||
review_run: Mapped["ProposalReviewRun"] = relationship(
|
||||
"ProposalReviewRun", back_populates="findings"
|
||||
)
|
||||
rule: Mapped["ProposalReviewRule"] = relationship("ProposalReviewRule")
|
||||
decision: Mapped["ProposalReviewDecision | None"] = relationship(
|
||||
"ProposalReviewDecision",
|
||||
back_populates="finding",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class ProposalReviewDecision(Base):
|
||||
"""Officer's decision on a single finding."""
|
||||
|
||||
__tablename__ = "proposal_review_decision"
|
||||
__table_args__ = (UniqueConstraint("finding_id"),)
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
finding_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("proposal_review_finding.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
officer_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("user.id"), nullable=False
|
||||
)
|
||||
action: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
finding: Mapped["ProposalReviewFinding"] = relationship(
|
||||
"ProposalReviewFinding", back_populates="decision"
|
||||
)
|
||||
|
||||
|
||||
class ProposalReviewProposalDecision(Base):
|
||||
"""Officer's final decision on the entire proposal."""
|
||||
|
||||
__tablename__ = "proposal_review_proposal_decision"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
proposal_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("proposal_review_proposal.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
officer_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("user.id"), nullable=False
|
||||
)
|
||||
decision: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
jira_synced: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, server_default=text("false")
|
||||
)
|
||||
jira_synced_at: Mapped[datetime.datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
proposal: Mapped["ProposalReviewProposal"] = relationship(
|
||||
"ProposalReviewProposal", back_populates="proposal_decisions"
|
||||
)
|
||||
|
||||
|
||||
class ProposalReviewDocument(Base):
|
||||
"""Manually uploaded documents or auto-fetched FOAs."""
|
||||
|
||||
__tablename__ = "proposal_review_document"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
proposal_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("proposal_review_proposal.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
file_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
file_type: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
file_store_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
extracted_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
document_role: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
uploaded_by: Mapped[UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("user.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
proposal: Mapped["ProposalReviewProposal"] = relationship(
|
||||
"ProposalReviewProposal", back_populates="documents"
|
||||
)
|
||||
|
||||
|
||||
class ProposalReviewAuditLog(Base):
|
||||
"""Audit trail for all proposal review actions."""
|
||||
|
||||
__tablename__ = "proposal_review_audit_log"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
proposal_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("proposal_review_proposal.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("user.id"), nullable=True
|
||||
)
|
||||
action: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
details: Mapped[dict | None] = mapped_column(PGJSONB(), nullable=True)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
proposal: Mapped["ProposalReviewProposal"] = relationship(
|
||||
"ProposalReviewProposal", back_populates="audit_logs"
|
||||
)
|
||||
|
||||
|
||||
class ProposalReviewConfig(Base):
|
||||
"""Admin configuration (one row per tenant)."""
|
||||
|
||||
__tablename__ = "proposal_review_config"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
jira_connector_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
jira_project_key: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
field_mapping: Mapped[list | None] = mapped_column(PGJSONB(), nullable=True)
|
||||
jira_writeback: Mapped[dict | None] = mapped_column(PGJSONB(), nullable=True)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -1,133 +0,0 @@
|
||||
"""DB operations for proposal state records."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewProposal
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def get_proposal(
|
||||
proposal_id: UUID,
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
) -> ProposalReviewProposal | None:
|
||||
"""Get a proposal by its ID."""
|
||||
return (
|
||||
db_session.query(ProposalReviewProposal)
|
||||
.filter(
|
||||
ProposalReviewProposal.id == proposal_id,
|
||||
ProposalReviewProposal.tenant_id == tenant_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def get_proposal_by_document_id(
|
||||
document_id: str,
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
) -> ProposalReviewProposal | None:
|
||||
"""Get a proposal by its linked document ID."""
|
||||
return (
|
||||
db_session.query(ProposalReviewProposal)
|
||||
.filter(
|
||||
ProposalReviewProposal.document_id == document_id,
|
||||
ProposalReviewProposal.tenant_id == tenant_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def get_or_create_proposal(
|
||||
document_id: str,
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
) -> ProposalReviewProposal:
|
||||
"""Get or lazily create a proposal state record for a document.
|
||||
|
||||
This is the primary entry point — the proposal record is created on first
|
||||
interaction, not when the Jira ticket is ingested.
|
||||
"""
|
||||
proposal = get_proposal_by_document_id(document_id, tenant_id, db_session)
|
||||
if proposal:
|
||||
return proposal
|
||||
|
||||
proposal = ProposalReviewProposal(
|
||||
document_id=document_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
db_session.add(proposal)
|
||||
try:
|
||||
db_session.flush()
|
||||
except IntegrityError:
|
||||
db_session.rollback()
|
||||
proposal = get_proposal_by_document_id(document_id, tenant_id, db_session)
|
||||
if proposal is None:
|
||||
raise
|
||||
return proposal
|
||||
logger.info(f"Lazily created proposal {proposal.id} for document {document_id}")
|
||||
return proposal
|
||||
|
||||
|
||||
def list_proposals(
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[ProposalReviewProposal]:
|
||||
"""List proposals for a tenant with optional status filter."""
|
||||
query = (
|
||||
db_session.query(ProposalReviewProposal)
|
||||
.filter(ProposalReviewProposal.tenant_id == tenant_id)
|
||||
.order_by(desc(ProposalReviewProposal.updated_at))
|
||||
)
|
||||
if status:
|
||||
query = query.filter(ProposalReviewProposal.status == status)
|
||||
return query.offset(offset).limit(limit).all()
|
||||
|
||||
|
||||
def count_proposals(
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
status: str | None = None,
|
||||
) -> int:
|
||||
"""Count proposals for a tenant."""
|
||||
query = db_session.query(ProposalReviewProposal).filter(
|
||||
ProposalReviewProposal.tenant_id == tenant_id
|
||||
)
|
||||
if status:
|
||||
query = query.filter(ProposalReviewProposal.status == status)
|
||||
return query.count()
|
||||
|
||||
|
||||
def update_proposal_status(
|
||||
proposal_id: UUID,
|
||||
tenant_id: str,
|
||||
status: str,
|
||||
db_session: Session,
|
||||
) -> ProposalReviewProposal | None:
|
||||
"""Update a proposal's status."""
|
||||
proposal = (
|
||||
db_session.query(ProposalReviewProposal)
|
||||
.filter(
|
||||
ProposalReviewProposal.id == proposal_id,
|
||||
ProposalReviewProposal.tenant_id == tenant_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if not proposal:
|
||||
return None
|
||||
proposal.status = status
|
||||
proposal.updated_at = datetime.now(timezone.utc)
|
||||
db_session.flush()
|
||||
logger.info(f"Updated proposal {proposal_id} status to {status}")
|
||||
return proposal
|
||||
@@ -1,338 +0,0 @@
|
||||
"""DB operations for rulesets and rules."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewRule
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewRuleset
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ruleset CRUD
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def list_rulesets(
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
active_only: bool = False,
|
||||
) -> list[ProposalReviewRuleset]:
|
||||
"""List all rulesets for a tenant."""
|
||||
query = (
|
||||
db_session.query(ProposalReviewRuleset)
|
||||
.filter(ProposalReviewRuleset.tenant_id == tenant_id)
|
||||
.options(selectinload(ProposalReviewRuleset.rules))
|
||||
.order_by(desc(ProposalReviewRuleset.created_at))
|
||||
)
|
||||
if active_only:
|
||||
query = query.filter(ProposalReviewRuleset.is_active.is_(True))
|
||||
return query.all()
|
||||
|
||||
|
||||
def get_ruleset(
|
||||
ruleset_id: UUID,
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
) -> ProposalReviewRuleset | None:
|
||||
"""Get a single ruleset by ID with all its rules."""
|
||||
return (
|
||||
db_session.query(ProposalReviewRuleset)
|
||||
.filter(
|
||||
ProposalReviewRuleset.id == ruleset_id,
|
||||
ProposalReviewRuleset.tenant_id == tenant_id,
|
||||
)
|
||||
.options(selectinload(ProposalReviewRuleset.rules))
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def create_ruleset(
|
||||
tenant_id: str,
|
||||
name: str,
|
||||
db_session: Session,
|
||||
description: str | None = None,
|
||||
is_default: bool = False,
|
||||
created_by: UUID | None = None,
|
||||
) -> ProposalReviewRuleset:
|
||||
"""Create a new ruleset."""
|
||||
# If this ruleset is default, un-default any existing default
|
||||
if is_default:
|
||||
_clear_default_ruleset(tenant_id, db_session)
|
||||
|
||||
ruleset = ProposalReviewRuleset(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
description=description,
|
||||
is_default=is_default,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session.add(ruleset)
|
||||
db_session.flush()
|
||||
logger.info(f"Created ruleset {ruleset.id} '{name}' for tenant {tenant_id}")
|
||||
return ruleset
|
||||
|
||||
|
||||
def update_ruleset(
|
||||
ruleset_id: UUID,
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
is_default: bool | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> ProposalReviewRuleset | None:
|
||||
"""Update a ruleset. Returns None if not found."""
|
||||
ruleset = get_ruleset(ruleset_id, tenant_id, db_session)
|
||||
if not ruleset:
|
||||
return None
|
||||
|
||||
if name is not None:
|
||||
ruleset.name = name
|
||||
if description is not None:
|
||||
ruleset.description = description
|
||||
if is_default is not None:
|
||||
if is_default:
|
||||
_clear_default_ruleset(tenant_id, db_session)
|
||||
ruleset.is_default = is_default
|
||||
if is_active is not None:
|
||||
ruleset.is_active = is_active
|
||||
|
||||
ruleset.updated_at = datetime.now(timezone.utc)
|
||||
db_session.flush()
|
||||
return ruleset
|
||||
|
||||
|
||||
def delete_ruleset(
|
||||
ruleset_id: UUID,
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
) -> bool:
|
||||
"""Delete a ruleset. Returns False if not found."""
|
||||
ruleset = get_ruleset(ruleset_id, tenant_id, db_session)
|
||||
if not ruleset:
|
||||
return False
|
||||
db_session.delete(ruleset)
|
||||
db_session.flush()
|
||||
logger.info(f"Deleted ruleset {ruleset_id}")
|
||||
return True
|
||||
|
||||
|
||||
def _clear_default_ruleset(tenant_id: str, db_session: Session) -> None:
|
||||
"""Un-default any existing default ruleset for a tenant."""
|
||||
db_session.query(ProposalReviewRuleset).filter(
|
||||
ProposalReviewRuleset.tenant_id == tenant_id,
|
||||
ProposalReviewRuleset.is_default.is_(True),
|
||||
).update({ProposalReviewRuleset.is_default: False})
|
||||
db_session.flush()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Rule CRUD
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def list_rules_by_ruleset(
|
||||
ruleset_id: UUID,
|
||||
db_session: Session,
|
||||
active_only: bool = False,
|
||||
) -> list[ProposalReviewRule]:
|
||||
"""List all rules in a ruleset."""
|
||||
query = (
|
||||
db_session.query(ProposalReviewRule)
|
||||
.filter(ProposalReviewRule.ruleset_id == ruleset_id)
|
||||
.order_by(ProposalReviewRule.priority)
|
||||
)
|
||||
if active_only:
|
||||
query = query.filter(ProposalReviewRule.is_active.is_(True))
|
||||
return query.all()
|
||||
|
||||
|
||||
def get_rule(
|
||||
rule_id: UUID,
|
||||
db_session: Session,
|
||||
) -> ProposalReviewRule | None:
|
||||
"""Get a single rule by ID."""
|
||||
return (
|
||||
db_session.query(ProposalReviewRule)
|
||||
.filter(ProposalReviewRule.id == rule_id)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def create_rule(
|
||||
ruleset_id: UUID,
|
||||
name: str,
|
||||
rule_type: str,
|
||||
prompt_template: str,
|
||||
db_session: Session,
|
||||
description: str | None = None,
|
||||
category: str | None = None,
|
||||
rule_intent: str = "CHECK",
|
||||
source: str = "MANUAL",
|
||||
authority: str | None = None,
|
||||
is_hard_stop: bool = False,
|
||||
priority: int = 0,
|
||||
) -> ProposalReviewRule:
|
||||
"""Create a new rule within a ruleset."""
|
||||
rule = ProposalReviewRule(
|
||||
ruleset_id=ruleset_id,
|
||||
name=name,
|
||||
description=description,
|
||||
category=category,
|
||||
rule_type=rule_type,
|
||||
rule_intent=rule_intent,
|
||||
prompt_template=prompt_template,
|
||||
source=source,
|
||||
authority=authority,
|
||||
is_hard_stop=is_hard_stop,
|
||||
priority=priority,
|
||||
)
|
||||
db_session.add(rule)
|
||||
db_session.flush()
|
||||
logger.info(f"Created rule {rule.id} '{name}' in ruleset {ruleset_id}")
|
||||
return rule
|
||||
|
||||
|
||||
def update_rule(
|
||||
rule_id: UUID,
|
||||
db_session: Session,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
category: str | None = None,
|
||||
rule_type: str | None = None,
|
||||
rule_intent: str | None = None,
|
||||
prompt_template: str | None = None,
|
||||
authority: str | None = None,
|
||||
is_hard_stop: bool | None = None,
|
||||
priority: int | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> ProposalReviewRule | None:
|
||||
"""Update a rule. Returns None if not found."""
|
||||
rule = get_rule(rule_id, db_session)
|
||||
if not rule:
|
||||
return None
|
||||
|
||||
if name is not None:
|
||||
rule.name = name
|
||||
if description is not None:
|
||||
rule.description = description
|
||||
if category is not None:
|
||||
rule.category = category
|
||||
if rule_type is not None:
|
||||
rule.rule_type = rule_type
|
||||
if rule_intent is not None:
|
||||
rule.rule_intent = rule_intent
|
||||
if prompt_template is not None:
|
||||
rule.prompt_template = prompt_template
|
||||
if authority is not None:
|
||||
rule.authority = authority
|
||||
if is_hard_stop is not None:
|
||||
rule.is_hard_stop = is_hard_stop
|
||||
if priority is not None:
|
||||
rule.priority = priority
|
||||
if is_active is not None:
|
||||
rule.is_active = is_active
|
||||
|
||||
rule.updated_at = datetime.now(timezone.utc)
|
||||
db_session.flush()
|
||||
return rule
|
||||
|
||||
|
||||
def delete_rule(
|
||||
rule_id: UUID,
|
||||
db_session: Session,
|
||||
) -> bool:
|
||||
"""Delete a rule. Returns False if not found."""
|
||||
rule = get_rule(rule_id, db_session)
|
||||
if not rule:
|
||||
return False
|
||||
db_session.delete(rule)
|
||||
db_session.flush()
|
||||
logger.info(f"Deleted rule {rule_id}")
|
||||
return True
|
||||
|
||||
|
||||
def bulk_update_rules(
|
||||
rule_ids: list[UUID],
|
||||
action: str,
|
||||
ruleset_id: UUID,
|
||||
db_session: Session,
|
||||
) -> int:
|
||||
"""Batch activate/deactivate/delete rules.
|
||||
|
||||
Args:
|
||||
rule_ids: list of rule IDs
|
||||
action: "activate" | "deactivate" | "delete"
|
||||
ruleset_id: scope operations to rules within this ruleset
|
||||
|
||||
Returns:
|
||||
number of rules affected
|
||||
"""
|
||||
if action == "delete":
|
||||
count = (
|
||||
db_session.query(ProposalReviewRule)
|
||||
.filter(
|
||||
ProposalReviewRule.id.in_(rule_ids),
|
||||
ProposalReviewRule.ruleset_id == ruleset_id,
|
||||
)
|
||||
.delete(synchronize_session="fetch")
|
||||
)
|
||||
elif action == "activate":
|
||||
count = (
|
||||
db_session.query(ProposalReviewRule)
|
||||
.filter(
|
||||
ProposalReviewRule.id.in_(rule_ids),
|
||||
ProposalReviewRule.ruleset_id == ruleset_id,
|
||||
)
|
||||
.update(
|
||||
{
|
||||
ProposalReviewRule.is_active: True,
|
||||
ProposalReviewRule.updated_at: datetime.now(timezone.utc),
|
||||
},
|
||||
synchronize_session="fetch",
|
||||
)
|
||||
)
|
||||
elif action == "deactivate":
|
||||
count = (
|
||||
db_session.query(ProposalReviewRule)
|
||||
.filter(
|
||||
ProposalReviewRule.id.in_(rule_ids),
|
||||
ProposalReviewRule.ruleset_id == ruleset_id,
|
||||
)
|
||||
.update(
|
||||
{
|
||||
ProposalReviewRule.is_active: False,
|
||||
ProposalReviewRule.updated_at: datetime.now(timezone.utc),
|
||||
},
|
||||
synchronize_session="fetch",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown bulk action: {action}")
|
||||
|
||||
db_session.flush()
|
||||
logger.info(f"Bulk {action} on {count} rules")
|
||||
return count
|
||||
|
||||
|
||||
def count_active_rules(
|
||||
ruleset_id: UUID,
|
||||
db_session: Session,
|
||||
) -> int:
|
||||
"""Count active rules in a ruleset."""
|
||||
return (
|
||||
db_session.query(ProposalReviewRule)
|
||||
.filter(
|
||||
ProposalReviewRule.ruleset_id == ruleset_id,
|
||||
ProposalReviewRule.is_active.is_(True),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
"""Argus Review Engine — AI-powered proposal evaluation."""
|
||||
@@ -1,204 +0,0 @@
|
||||
"""Parses uploaded checklist documents into atomic review rules via LLM."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from onyx.llm.factory import get_default_llm
|
||||
from onyx.llm.models import SystemMessage
|
||||
from onyx.llm.models import UserMessage
|
||||
from onyx.llm.utils import llm_response_to_string
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
_IMPORT_SYSTEM_PROMPT = """\
|
||||
You are an expert at analyzing institutional review checklists for university grant \
|
||||
offices. Your task is to decompose a checklist document into atomic, self-contained \
|
||||
review rules that can each be independently evaluated by an AI against a grant proposal.
|
||||
|
||||
Key principles:
|
||||
1. ATOMIC DECOMPOSITION: Each checklist item may contain multiple distinct requirements. \
|
||||
You MUST split compound items into separate atomic rules. Each rule should test exactly \
|
||||
ONE pass/fail criterion.
|
||||
|
||||
2. CATEGORY PRESERVATION: All rules decomposed from the same source checklist item \
|
||||
should share the same category value. Use the checklist item's identifier and title \
|
||||
(e.g., "IR-2: Regulatory Compliance") as the category.
|
||||
|
||||
3. SELF-CONTAINED PROMPTS: Each rule's prompt_template must be fully self-contained. \
|
||||
It should include all context needed to evaluate the criterion. Use {{variable}} \
|
||||
placeholders for dynamic content:
|
||||
- {{proposal_text}} - full text of the proposal and supporting documents
|
||||
- {{budget_text}} - budget/financial sections
|
||||
- {{foa_text}} - funding opportunity announcement
|
||||
- {{metadata}} - structured metadata (PI, sponsor, deadlines, etc.)
|
||||
- {{metadata.FIELD_NAME}} - specific metadata field
|
||||
|
||||
4. REFINEMENT DETECTION: If a rule requires institution-specific information that is \
|
||||
NOT present in the source checklist (such as IDC rates, cost categories, institutional \
|
||||
policies, specific thresholds, or local procedures), mark it with:
|
||||
- refinement_needed: true
|
||||
- refinement_question: a clear question asking for the missing information
|
||||
- Use a placeholder like {{INSTITUTION_IDC_RATES}} in the prompt_template
|
||||
|
||||
5. RULE TYPES:
|
||||
- DOCUMENT_CHECK: Verifies presence/content of specific documents or sections
|
||||
- METADATA_CHECK: Validates structured metadata fields
|
||||
- CROSS_REFERENCE: Compares information across multiple documents (e.g., budget vs narrative)
|
||||
- CUSTOM_NL: Natural language evaluation of content quality or compliance
|
||||
|
||||
6. RULE INTENT:
|
||||
- CHECK: Pass/fail criterion that must be satisfied
|
||||
- HIGHLIGHT: Informational flag for officer attention (no pass/fail)"""
|
||||
|
||||
_IMPORT_USER_PROMPT = """\
|
||||
Analyze the following checklist document and decompose it into atomic review rules.
|
||||
|
||||
CHECKLIST CONTENT:
|
||||
---
|
||||
{checklist_text}
|
||||
---
|
||||
|
||||
Respond with ONLY a valid JSON array of rule objects. Each rule must have:
|
||||
{{
|
||||
"name": "Short descriptive name for the rule (max 100 chars)",
|
||||
"description": "Detailed description of what this rule checks",
|
||||
"category": "Source checklist item identifier and title (e.g., 'IR-2: Regulatory Compliance')",
|
||||
"rule_type": "DOCUMENT_CHECK | METADATA_CHECK | CROSS_REFERENCE | CUSTOM_NL",
|
||||
"rule_intent": "CHECK | HIGHLIGHT",
|
||||
"prompt_template": "Self-contained prompt with {{{{variable}}}} placeholders. Must clearly state the criterion and ask for evaluation.",
|
||||
"refinement_needed": false,
|
||||
"refinement_question": null
|
||||
}}
|
||||
|
||||
For rules that need institution-specific info:
|
||||
{{
|
||||
"name": "...",
|
||||
"description": "...",
|
||||
"category": "...",
|
||||
"rule_type": "...",
|
||||
"rule_intent": "CHECK",
|
||||
"prompt_template": "... {{{{INSTITUTION_IDC_RATES}}}} ...",
|
||||
"refinement_needed": true,
|
||||
"refinement_question": "Please provide your institution's IDC rate schedule."
|
||||
}}
|
||||
|
||||
Important:
|
||||
- Decompose compound checklist items into multiple atomic rules
|
||||
- Each rule tests exactly ONE criterion
|
||||
- Prompt templates must be specific and actionable
|
||||
- Include all relevant context placeholders in templates
|
||||
- Flag any rule requiring institution-specific knowledge"""
|
||||
|
||||
|
||||
def import_checklist(
|
||||
extracted_text: str,
|
||||
) -> list[dict]:
|
||||
"""Parse a checklist document into atomic review rules via LLM.
|
||||
|
||||
Args:
|
||||
extracted_text: The full text content extracted from the uploaded checklist file.
|
||||
|
||||
Returns:
|
||||
List of rule dicts, each with: name, description, category, rule_type,
|
||||
rule_intent, prompt_template, refinement_needed, refinement_question.
|
||||
"""
|
||||
if not extracted_text or not extracted_text.strip():
|
||||
logger.warning("Empty checklist text provided for import")
|
||||
return []
|
||||
|
||||
# Build the prompt
|
||||
user_content = _IMPORT_USER_PROMPT.format(checklist_text=extracted_text)
|
||||
|
||||
prompt_messages = [
|
||||
SystemMessage(content=_IMPORT_SYSTEM_PROMPT),
|
||||
UserMessage(content=user_content),
|
||||
]
|
||||
|
||||
# Call LLM synchronously (this runs in the API request, not Celery)
|
||||
try:
|
||||
llm = get_default_llm()
|
||||
response = llm.invoke(prompt_messages)
|
||||
raw_text = llm_response_to_string(response)
|
||||
except Exception as e:
|
||||
logger.error(f"LLM call failed during checklist import: {e}")
|
||||
raise RuntimeError(f"Failed to parse checklist via LLM: {str(e)}") from e
|
||||
|
||||
# Parse JSON response
|
||||
rules = _parse_import_response(raw_text)
|
||||
logger.info(f"Checklist import produced {len(rules)} atomic rules")
|
||||
|
||||
return rules
|
||||
|
||||
|
||||
def _parse_import_response(raw_text: str) -> list[dict]:
|
||||
"""Parse the LLM response text as a JSON array of rule dicts."""
|
||||
text = raw_text.strip()
|
||||
|
||||
# Strip markdown code fences if present
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?\s*\n?", "", text)
|
||||
text = re.sub(r"\n?```\s*$", "", text)
|
||||
text = text.strip()
|
||||
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse import response as JSON: {e}")
|
||||
logger.debug(f"Raw LLM response: {text[:500]}...")
|
||||
raise RuntimeError(
|
||||
"LLM returned invalid JSON. Please try the import again."
|
||||
) from e
|
||||
|
||||
if not isinstance(parsed, list):
|
||||
raise RuntimeError(
|
||||
"LLM returned a non-array JSON. Expected a list of rule objects."
|
||||
)
|
||||
|
||||
# Validate and normalize each rule
|
||||
validated_rules: list[dict] = []
|
||||
for i, raw_rule in enumerate(parsed):
|
||||
if not isinstance(raw_rule, dict):
|
||||
logger.warning(f"Skipping non-dict entry at index {i}")
|
||||
continue
|
||||
|
||||
rule = _validate_rule(raw_rule, i)
|
||||
if rule:
|
||||
validated_rules.append(rule)
|
||||
|
||||
return validated_rules
|
||||
|
||||
|
||||
def _validate_rule(raw_rule: dict, index: int) -> dict | None:
|
||||
"""Validate and normalize a single parsed rule dict."""
|
||||
valid_types = {"DOCUMENT_CHECK", "METADATA_CHECK", "CROSS_REFERENCE", "CUSTOM_NL"}
|
||||
valid_intents = {"CHECK", "HIGHLIGHT"}
|
||||
|
||||
name = raw_rule.get("name")
|
||||
if not name:
|
||||
logger.warning(f"Rule at index {index} missing 'name', skipping")
|
||||
return None
|
||||
|
||||
prompt_template = raw_rule.get("prompt_template")
|
||||
if not prompt_template:
|
||||
logger.warning(f"Rule '{name}' missing 'prompt_template', skipping")
|
||||
return None
|
||||
|
||||
rule_type = str(raw_rule.get("rule_type", "CUSTOM_NL")).upper()
|
||||
if rule_type not in valid_types:
|
||||
rule_type = "CUSTOM_NL"
|
||||
|
||||
rule_intent = str(raw_rule.get("rule_intent", "CHECK")).upper()
|
||||
if rule_intent not in valid_intents:
|
||||
rule_intent = "CHECK"
|
||||
|
||||
return {
|
||||
"name": str(name)[:200], # Cap length
|
||||
"description": raw_rule.get("description"),
|
||||
"category": raw_rule.get("category"),
|
||||
"rule_type": rule_type,
|
||||
"rule_intent": rule_intent,
|
||||
"prompt_template": str(prompt_template),
|
||||
"refinement_needed": bool(raw_rule.get("refinement_needed", False)),
|
||||
"refinement_question": raw_rule.get("refinement_question"),
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
"""Assembles all available text content for a proposal to pass to rule evaluation.
|
||||
|
||||
V1 LIMITATION: Document body text (the main text content extracted by connectors)
|
||||
is stored in Vespa, not in the PostgreSQL Document table. The DB row only stores
|
||||
metadata (semantic_id, link, doc_metadata, primary_owners, etc.). For Jira tickets,
|
||||
the Description and Comments text are indexed into Vespa during connector runs and
|
||||
are NOT accessible here without a Vespa query.
|
||||
|
||||
As a result, the primary source of rich text for rule evaluation in V1 is:
|
||||
- Manually uploaded documents (proposal_review_document.extracted_text)
|
||||
- Structured metadata from the Document row's doc_metadata JSONB column
|
||||
- For Jira tickets: the connector populates doc_metadata with field values,
|
||||
which often includes Description, Status, Priority, Assignee, etc.
|
||||
|
||||
Future improvement: add a Vespa retrieval step to fetch indexed text chunks for
|
||||
the parent document and its attachments.
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.db.models import Document
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewDocument
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewProposal
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
# Metadata keys from Jira connector that commonly carry useful text content.
|
||||
# These are extracted from doc_metadata and presented as labeled sections to
|
||||
# give the LLM more signal when evaluating rules.
|
||||
_JIRA_TEXT_METADATA_KEYS = [
|
||||
"description",
|
||||
"summary",
|
||||
"comment",
|
||||
"comments",
|
||||
"acceptance_criteria",
|
||||
"story_points",
|
||||
"priority",
|
||||
"status",
|
||||
"resolution",
|
||||
"issue_type",
|
||||
"labels",
|
||||
"components",
|
||||
"fix_versions",
|
||||
"affects_versions",
|
||||
"environment",
|
||||
"assignee",
|
||||
"reporter",
|
||||
"creator",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProposalContext:
|
||||
"""All text and metadata context assembled for rule evaluation."""
|
||||
|
||||
proposal_text: str # concatenated text from all documents
|
||||
budget_text: str # best-effort budget section extraction
|
||||
foa_text: str # FOA content (auto-fetched or uploaded)
|
||||
metadata: dict # structured metadata from Document.doc_metadata
|
||||
jira_key: str # for display/reference
|
||||
metadata_raw: dict = field(default_factory=dict) # full unresolved metadata
|
||||
|
||||
|
||||
def get_proposal_context(
|
||||
proposal_id: UUID,
|
||||
db_session: Session,
|
||||
) -> ProposalContext:
|
||||
"""Assemble context for rule evaluation.
|
||||
|
||||
Gathers text from three sources:
|
||||
1. Jira ticket content (from Document.semantic_id + doc_metadata)
|
||||
2. Jira attachments (child Documents linked by ID prefix convention)
|
||||
3. Manually uploaded documents (from proposal_review_document.extracted_text)
|
||||
|
||||
For MVP, returns full text of everything. Future: smart section selection.
|
||||
"""
|
||||
# 1. Get the proposal record to find the linked document_id
|
||||
proposal = (
|
||||
db_session.query(ProposalReviewProposal)
|
||||
.filter(ProposalReviewProposal.id == proposal_id)
|
||||
.one_or_none()
|
||||
)
|
||||
if not proposal:
|
||||
logger.warning(f"Proposal {proposal_id} not found during context assembly")
|
||||
return ProposalContext(
|
||||
proposal_text="",
|
||||
budget_text="",
|
||||
foa_text="",
|
||||
metadata={},
|
||||
jira_key="",
|
||||
metadata_raw={},
|
||||
)
|
||||
|
||||
# 2. Fetch the parent Document (Jira ticket)
|
||||
parent_doc = (
|
||||
db_session.query(Document)
|
||||
.filter(Document.id == proposal.document_id)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
jira_key = ""
|
||||
metadata: dict = {}
|
||||
all_text_parts: list[str] = []
|
||||
budget_parts: list[str] = []
|
||||
foa_parts: list[str] = []
|
||||
|
||||
if parent_doc:
|
||||
jira_key = parent_doc.semantic_id or ""
|
||||
metadata = parent_doc.doc_metadata or {}
|
||||
|
||||
# Build text from DB-available fields. The actual ticket body text lives
|
||||
# in Vespa and is not accessible here. The doc_metadata JSONB column
|
||||
# often contains structured Jira fields that the connector extracted.
|
||||
parent_text = _build_parent_document_text(parent_doc)
|
||||
if parent_text:
|
||||
all_text_parts.append(parent_text)
|
||||
|
||||
# 3. Look for child Documents (Jira attachments).
|
||||
# Jira attachment Documents have IDs of the form:
|
||||
# "{parent_jira_url}/attachments/{attachment_id}"
|
||||
# We find them via ID prefix match.
|
||||
#
|
||||
# V1 LIMITATION: child document text content is in Vespa, not in the
|
||||
# DB. We can only extract metadata (filename, mime type, etc.) from
|
||||
# the Document row. The actual attachment text is not available here
|
||||
# without a Vespa query. See module docstring for details.
|
||||
child_docs = _find_child_documents(parent_doc, db_session)
|
||||
if child_docs:
|
||||
logger.info(
|
||||
f"Found {len(child_docs)} child documents for {jira_key}. "
|
||||
f"Note: their text content is in Vespa and only metadata is "
|
||||
f"available for rule evaluation."
|
||||
)
|
||||
for child_doc in child_docs:
|
||||
child_text = _build_child_document_text(child_doc)
|
||||
if child_text:
|
||||
all_text_parts.append(child_text)
|
||||
_classify_child_text(child_doc, child_text, budget_parts, foa_parts)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Parent Document not found for proposal {proposal_id} "
|
||||
f"(document_id={proposal.document_id}). "
|
||||
f"Context will rely on manually uploaded documents only."
|
||||
)
|
||||
|
||||
# 4. Fetch manually uploaded documents from proposal_review_document.
|
||||
# This is the PRIMARY source of rich text content for V1 since the
|
||||
# extracted_text column holds the full document content.
|
||||
manual_docs = (
|
||||
db_session.query(ProposalReviewDocument)
|
||||
.filter(ProposalReviewDocument.proposal_id == proposal_id)
|
||||
.order_by(ProposalReviewDocument.created_at)
|
||||
.all()
|
||||
)
|
||||
for doc in manual_docs:
|
||||
if doc.extracted_text:
|
||||
all_text_parts.append(
|
||||
f"--- Document: {doc.file_name} (role: {doc.document_role}) ---\n"
|
||||
f"{doc.extracted_text}"
|
||||
)
|
||||
# Classify by role
|
||||
role_upper = (doc.document_role or "").upper()
|
||||
if role_upper == "BUDGET" or _is_budget_filename(doc.file_name):
|
||||
budget_parts.append(doc.extracted_text)
|
||||
elif role_upper == "FOA":
|
||||
foa_parts.append(doc.extracted_text)
|
||||
|
||||
return ProposalContext(
|
||||
proposal_text="\n\n".join(all_text_parts) if all_text_parts else "",
|
||||
budget_text="\n\n".join(budget_parts) if budget_parts else "",
|
||||
foa_text="\n\n".join(foa_parts) if foa_parts else "",
|
||||
metadata=metadata,
|
||||
jira_key=jira_key,
|
||||
metadata_raw=metadata,
|
||||
)
|
||||
|
||||
|
||||
def _build_parent_document_text(doc: Document) -> str:
|
||||
"""Build text representation from a parent Document row (Jira ticket).
|
||||
|
||||
The Document table does NOT store the ticket body text -- that lives in Vespa.
|
||||
What we DO have access to:
|
||||
- semantic_id: typically "{ISSUE_KEY}: {summary}"
|
||||
- link: URL to the Jira ticket
|
||||
- doc_metadata: JSONB with structured fields from the connector (may include
|
||||
description, status, priority, assignee, custom fields, etc.)
|
||||
- primary_owners / secondary_owners: people associated with the document
|
||||
|
||||
We extract all available metadata and present it as labeled sections to
|
||||
maximize the signal available to the LLM for rule evaluation.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
if doc.semantic_id:
|
||||
parts.append(f"Document: {doc.semantic_id}")
|
||||
if doc.link:
|
||||
parts.append(f"Link: {doc.link}")
|
||||
|
||||
# Include owner information which may be useful for compliance checks
|
||||
if doc.primary_owners:
|
||||
parts.append(f"Primary Owners: {', '.join(doc.primary_owners)}")
|
||||
if doc.secondary_owners:
|
||||
parts.append(f"Secondary Owners: {', '.join(doc.secondary_owners)}")
|
||||
|
||||
# doc_metadata contains structured data from the Jira connector.
|
||||
# Extract well-known text-bearing fields first, then include the rest.
|
||||
if doc.doc_metadata:
|
||||
metadata = doc.doc_metadata
|
||||
|
||||
# Extract well-known Jira fields as labeled sections
|
||||
for key in _JIRA_TEXT_METADATA_KEYS:
|
||||
value = metadata.get(key)
|
||||
if value is not None and value != "" and value != []:
|
||||
label = key.replace("_", " ").title()
|
||||
if isinstance(value, list):
|
||||
parts.append(f"{label}: {', '.join(str(v) for v in value)}")
|
||||
elif isinstance(value, dict):
|
||||
parts.append(
|
||||
f"{label}:\n{json.dumps(value, indent=2, default=str)}"
|
||||
)
|
||||
else:
|
||||
parts.append(f"{label}: {value}")
|
||||
|
||||
# Include any remaining metadata keys not in the well-known set,
|
||||
# so custom fields and connector-specific data are not lost.
|
||||
remaining = {
|
||||
k: v
|
||||
for k, v in metadata.items()
|
||||
if k.lower() not in _JIRA_TEXT_METADATA_KEYS
|
||||
and v is not None
|
||||
and v != ""
|
||||
and v != []
|
||||
}
|
||||
if remaining:
|
||||
parts.append(
|
||||
f"Additional Metadata:\n"
|
||||
f"{json.dumps(remaining, indent=2, default=str)}"
|
||||
)
|
||||
|
||||
return "\n".join(parts) if parts else ""
|
||||
|
||||
|
||||
def _build_child_document_text(doc: Document) -> str:
|
||||
"""Build text representation from a child Document row (Jira attachment).
|
||||
|
||||
V1 LIMITATION: The actual extracted text of the attachment lives in Vespa,
|
||||
not in the Document table. We can only present the metadata that the
|
||||
connector stored in doc_metadata (filename, mime type, size, parent ticket).
|
||||
|
||||
This means the LLM knows an attachment EXISTS and its metadata, but cannot
|
||||
read its contents. Future versions should add a Vespa retrieval step.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
if doc.semantic_id:
|
||||
parts.append(f"Attachment: {doc.semantic_id}")
|
||||
if doc.link:
|
||||
parts.append(f"Link: {doc.link}")
|
||||
|
||||
# Child document metadata typically includes:
|
||||
# parent_ticket, attachment_filename, attachment_mime_type, attachment_size
|
||||
if doc.doc_metadata:
|
||||
for key, value in doc.doc_metadata.items():
|
||||
if value is not None and value != "":
|
||||
label = key.replace("_", " ").title()
|
||||
parts.append(f"{label}: {value}")
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
# Note the limitation inline for the LLM context
|
||||
parts.append(
|
||||
"[Note: Full attachment text is indexed in Vespa and not available "
|
||||
"in this context. Upload the document manually for full text analysis.]"
|
||||
)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _find_child_documents(parent_doc: Document, db_session: Session) -> list[Document]:
|
||||
"""Find child Documents linked to the parent (e.g. Jira attachments).
|
||||
|
||||
Jira attachments are indexed as separate Document rows whose ID follows
|
||||
the convention: "{parent_document_id}/attachments/{attachment_id}".
|
||||
The parent_document_id for Jira is the full URL to the issue, e.g.
|
||||
"https://jira.example.com/browse/PROJ-123".
|
||||
|
||||
V1 LIMITATION: These child Document rows only contain metadata in the DB.
|
||||
Their actual extracted text content is stored in Vespa. To read the
|
||||
attachment text, a Vespa query would be required. This is not implemented
|
||||
in V1 -- officers should upload key documents manually for full text
|
||||
analysis.
|
||||
"""
|
||||
if not parent_doc.id:
|
||||
return []
|
||||
|
||||
# Child documents have IDs that start with the parent document's ID
|
||||
# followed by a path segment (e.g., /attachments/12345)
|
||||
# Escape LIKE wildcards in the document ID
|
||||
escaped_id = parent_doc.id.replace("%", r"\%").replace("_", r"\_")
|
||||
child_docs = (
|
||||
db_session.query(Document)
|
||||
.filter(
|
||||
Document.id.like(f"{escaped_id}/%"),
|
||||
Document.id != parent_doc.id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return child_docs
|
||||
|
||||
|
||||
def _classify_child_text(
|
||||
doc: Document,
|
||||
text: str,
|
||||
budget_parts: list[str],
|
||||
foa_parts: list[str],
|
||||
) -> None:
|
||||
"""Best-effort classification of child document text into budget or FOA."""
|
||||
semantic_id = (doc.semantic_id or "").lower()
|
||||
|
||||
if _is_budget_filename(semantic_id):
|
||||
budget_parts.append(text)
|
||||
elif any(
|
||||
term in semantic_id
|
||||
for term in ["foa", "funding opportunity", "rfa", "solicitation", "nofo"]
|
||||
):
|
||||
foa_parts.append(text)
|
||||
|
||||
|
||||
def _is_budget_filename(filename: str) -> bool:
|
||||
"""Check if a filename suggests budget content."""
|
||||
lower = (filename or "").lower()
|
||||
return any(term in lower for term in ["budget", "cost", "financial", "expenditure"])
|
||||
@@ -1,168 +0,0 @@
|
||||
"""Auto-fetches Funding Opportunity Announcements using Onyx web search infrastructure."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewDocument
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
# Map known opportunity ID prefixes to federal agency domains
|
||||
_AGENCY_DOMAINS: dict[str, str] = {
|
||||
"RFA": "grants.nih.gov",
|
||||
"PA": "grants.nih.gov",
|
||||
"PAR": "grants.nih.gov",
|
||||
"R01": "grants.nih.gov",
|
||||
"R21": "grants.nih.gov",
|
||||
"U01": "grants.nih.gov",
|
||||
"NOT": "grants.nih.gov",
|
||||
"NSF": "nsf.gov",
|
||||
"DE-FOA": "energy.gov",
|
||||
"HRSA": "hrsa.gov",
|
||||
"W911": "grants.gov", # DoD
|
||||
"FA": "grants.gov", # Air Force
|
||||
"N00": "grants.gov", # Navy
|
||||
"NOFO": "grants.gov",
|
||||
}
|
||||
|
||||
|
||||
def fetch_foa(
|
||||
opportunity_id: str,
|
||||
proposal_id: UUID,
|
||||
db_session: Session,
|
||||
) -> str | None:
|
||||
"""Fetch FOA content given an opportunity ID.
|
||||
|
||||
1. Determine domain from ID prefix (RFA/PA -> nih.gov, NSF -> nsf.gov, etc.)
|
||||
2. Build search query
|
||||
3. Call Onyx web search provider
|
||||
4. Fetch full content from best URL
|
||||
5. Save as proposal_review_document with role=FOA
|
||||
6. Return extracted text or None
|
||||
|
||||
If the web search provider is not configured, logs a warning and returns None.
|
||||
"""
|
||||
if not opportunity_id or not opportunity_id.strip():
|
||||
logger.debug("No opportunity_id provided, skipping FOA fetch")
|
||||
return None
|
||||
|
||||
opportunity_id = opportunity_id.strip()
|
||||
|
||||
# Check if we already have an FOA document for this proposal
|
||||
existing_foa = (
|
||||
db_session.query(ProposalReviewDocument)
|
||||
.filter(
|
||||
ProposalReviewDocument.proposal_id == proposal_id,
|
||||
ProposalReviewDocument.document_role == "FOA",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing_foa and existing_foa.extracted_text:
|
||||
logger.info(
|
||||
f"FOA document already exists for proposal {proposal_id}, skipping fetch"
|
||||
)
|
||||
return existing_foa.extracted_text
|
||||
|
||||
# Determine search domain from opportunity ID prefix
|
||||
site_domain = _determine_domain(opportunity_id)
|
||||
|
||||
# Build search query
|
||||
search_query = f"{opportunity_id} funding opportunity announcement"
|
||||
if site_domain:
|
||||
search_query = f"site:{site_domain} {opportunity_id}"
|
||||
|
||||
# Try to get the web search provider
|
||||
try:
|
||||
from onyx.tools.tool_implementations.web_search.providers import (
|
||||
get_default_provider,
|
||||
)
|
||||
|
||||
provider = get_default_provider()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load web search provider: {e}")
|
||||
provider = None
|
||||
|
||||
if provider is None:
|
||||
logger.warning(
|
||||
"No web search provider configured. Cannot auto-fetch FOA. "
|
||||
"Configure a web search provider in Admin settings to enable this feature."
|
||||
)
|
||||
return None
|
||||
|
||||
# Search for the FOA
|
||||
try:
|
||||
results = provider.search(search_query)
|
||||
except Exception as e:
|
||||
logger.error(f"Web search failed for FOA '{opportunity_id}': {e}")
|
||||
return None
|
||||
|
||||
if not results:
|
||||
logger.info(f"No search results found for FOA '{opportunity_id}'")
|
||||
return None
|
||||
|
||||
# Pick the best result URL
|
||||
best_url = str(results[0].link)
|
||||
logger.info(f"Fetching FOA content from: {best_url}")
|
||||
|
||||
# Fetch full content from the URL
|
||||
try:
|
||||
from onyx.tools.tool_implementations.open_url.onyx_web_crawler import (
|
||||
OnyxWebCrawler,
|
||||
)
|
||||
|
||||
crawler = OnyxWebCrawler()
|
||||
contents = crawler.contents([best_url])
|
||||
|
||||
if (
|
||||
not contents
|
||||
or not contents[0].scrape_successful
|
||||
or not contents[0].full_content
|
||||
):
|
||||
logger.warning(f"No content extracted from FOA URL: {best_url}")
|
||||
return None
|
||||
|
||||
foa_text = contents[0].full_content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch FOA content from {best_url}: {e}")
|
||||
return None
|
||||
|
||||
# Save as a proposal_review_document with role=FOA
|
||||
try:
|
||||
foa_doc = ProposalReviewDocument(
|
||||
proposal_id=proposal_id,
|
||||
file_name=f"FOA_{opportunity_id}.html",
|
||||
file_type="HTML",
|
||||
document_role="FOA",
|
||||
extracted_text=foa_text,
|
||||
# uploaded_by is None for auto-fetched documents
|
||||
)
|
||||
db_session.add(foa_doc)
|
||||
db_session.flush()
|
||||
logger.info(
|
||||
f"Saved FOA document for proposal {proposal_id} "
|
||||
f"(opportunity_id={opportunity_id}, {len(foa_text)} chars)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save FOA document: {e}")
|
||||
# Still return the text even if save fails
|
||||
return foa_text
|
||||
|
||||
return foa_text
|
||||
|
||||
|
||||
def _determine_domain(opportunity_id: str) -> str | None:
|
||||
"""Determine the likely agency domain from the opportunity ID prefix."""
|
||||
upper_id = opportunity_id.upper()
|
||||
|
||||
for prefix, domain in _AGENCY_DOMAINS.items():
|
||||
if upper_id.startswith(prefix):
|
||||
return domain
|
||||
|
||||
# If it looks like a grants.gov number (numeric), try grants.gov
|
||||
if opportunity_id.replace("-", "").isdigit():
|
||||
return "grants.gov"
|
||||
|
||||
return None
|
||||
@@ -1,394 +0,0 @@
|
||||
"""Writes officer decisions back to Jira."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from uuid import UUID
|
||||
|
||||
import requests
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.db.connector import fetch_connector_by_id
|
||||
from onyx.db.connector_credential_pair import (
|
||||
fetch_connector_credential_pair_for_connector,
|
||||
)
|
||||
from onyx.db.models import Document
|
||||
from onyx.server.features.proposal_review.db import config as config_db
|
||||
from onyx.server.features.proposal_review.db import decisions as decisions_db
|
||||
from onyx.server.features.proposal_review.db import findings as findings_db
|
||||
from onyx.server.features.proposal_review.db import proposals as proposals_db
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewFinding
|
||||
from onyx.server.features.proposal_review.db.models import (
|
||||
ProposalReviewProposalDecision,
|
||||
)
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def sync_to_jira(
|
||||
proposal_id: UUID,
|
||||
db_session: Session,
|
||||
) -> None:
|
||||
"""Write the officer's final decision back to Jira.
|
||||
|
||||
Performs up to 3 Jira API operations:
|
||||
1. PUT custom fields (decision, completion %)
|
||||
2. POST transition (move to configured column)
|
||||
3. POST comment (structured review summary)
|
||||
|
||||
Then marks the decision as jira_synced.
|
||||
|
||||
Raises:
|
||||
ValueError: If required config/data is missing.
|
||||
RuntimeError: If Jira API calls fail.
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
# Load proposal and decision
|
||||
proposal = proposals_db.get_proposal(proposal_id, tenant_id, db_session)
|
||||
if not proposal:
|
||||
raise ValueError(f"Proposal {proposal_id} not found")
|
||||
|
||||
latest_decision = decisions_db.get_latest_proposal_decision(proposal_id, db_session)
|
||||
if not latest_decision:
|
||||
raise ValueError(f"No decision found for proposal {proposal_id}")
|
||||
|
||||
if latest_decision.jira_synced:
|
||||
logger.info(f"Decision for proposal {proposal_id} already synced to Jira")
|
||||
return
|
||||
|
||||
# Load tenant config for Jira settings
|
||||
config = config_db.get_config(tenant_id, db_session)
|
||||
if not config:
|
||||
raise ValueError("Proposal review config not found for this tenant")
|
||||
|
||||
if not config.jira_connector_id:
|
||||
raise ValueError(
|
||||
"No Jira connector configured. Set jira_connector_id in proposal review settings."
|
||||
)
|
||||
|
||||
writeback_config = config.jira_writeback or {}
|
||||
|
||||
# Get the Jira issue key from the linked Document
|
||||
parent_doc = (
|
||||
db_session.query(Document)
|
||||
.filter(Document.id == proposal.document_id)
|
||||
.one_or_none()
|
||||
)
|
||||
if not parent_doc:
|
||||
raise ValueError(f"Linked document {proposal.document_id} not found")
|
||||
|
||||
# semantic_id is formatted as "KEY-123: Summary text" by the Jira connector.
|
||||
# Extract just the issue key (everything before the first colon).
|
||||
raw_id = parent_doc.semantic_id
|
||||
if not raw_id:
|
||||
raise ValueError(
|
||||
f"Document {proposal.document_id} has no semantic_id (Jira issue key)"
|
||||
)
|
||||
issue_key = raw_id.split(":")[0].strip()
|
||||
|
||||
# Get Jira credentials from the connector
|
||||
jira_base_url, auth_headers = _get_jira_credentials(
|
||||
config.jira_connector_id, db_session
|
||||
)
|
||||
|
||||
# Get findings for the summary
|
||||
latest_run = findings_db.get_latest_review_run(proposal_id, db_session)
|
||||
all_findings: list[ProposalReviewFinding] = []
|
||||
if latest_run:
|
||||
all_findings = findings_db.list_findings_by_run(latest_run.id, db_session)
|
||||
|
||||
# Calculate summary counts
|
||||
verdict_counts = _count_verdicts(all_findings)
|
||||
|
||||
# Operation 1: Update custom fields
|
||||
_update_custom_fields(
|
||||
jira_base_url=jira_base_url,
|
||||
auth_headers=auth_headers,
|
||||
issue_key=issue_key,
|
||||
decision=latest_decision.decision,
|
||||
verdict_counts=verdict_counts,
|
||||
writeback_config=writeback_config,
|
||||
)
|
||||
|
||||
# Operation 2: Transition the issue
|
||||
_transition_issue(
|
||||
jira_base_url=jira_base_url,
|
||||
auth_headers=auth_headers,
|
||||
issue_key=issue_key,
|
||||
decision=latest_decision.decision,
|
||||
writeback_config=writeback_config,
|
||||
)
|
||||
|
||||
# Operation 3: Post review summary comment
|
||||
_post_comment(
|
||||
jira_base_url=jira_base_url,
|
||||
auth_headers=auth_headers,
|
||||
issue_key=issue_key,
|
||||
decision=latest_decision,
|
||||
verdict_counts=verdict_counts,
|
||||
findings=all_findings,
|
||||
)
|
||||
|
||||
# Mark the decision as synced
|
||||
decisions_db.mark_decision_jira_synced(latest_decision.id, db_session)
|
||||
db_session.flush()
|
||||
|
||||
logger.info(
|
||||
f"Successfully synced decision for proposal {proposal_id} to Jira issue {issue_key}"
|
||||
)
|
||||
|
||||
|
||||
def _get_jira_credentials(
|
||||
connector_id: int,
|
||||
db_session: Session,
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
"""Extract Jira base URL and auth headers from the connector's credentials.
|
||||
|
||||
Returns:
|
||||
Tuple of (jira_base_url, auth_headers_dict).
|
||||
"""
|
||||
connector = fetch_connector_by_id(connector_id, db_session)
|
||||
if not connector:
|
||||
raise ValueError(f"Jira connector {connector_id} not found")
|
||||
|
||||
# Get the connector's credential pair
|
||||
cc_pair = fetch_connector_credential_pair_for_connector(db_session, connector_id)
|
||||
if not cc_pair:
|
||||
raise ValueError(f"No credential pair found for connector {connector_id}")
|
||||
|
||||
# Extract credentials — guard against missing credential_json
|
||||
cred_json = cc_pair.credential.credential_json
|
||||
if cred_json is None:
|
||||
raise ValueError(f"No credential_json for connector {connector_id}")
|
||||
credentials = cred_json.get_value(apply_mask=False)
|
||||
if not credentials:
|
||||
raise ValueError(f"Empty credentials for connector {connector_id}")
|
||||
|
||||
# Extract Jira base URL from connector config
|
||||
connector_config = connector.connector_specific_config or {}
|
||||
jira_base_url = connector_config.get("jira_base_url", "")
|
||||
|
||||
if not jira_base_url:
|
||||
raise ValueError("Could not determine Jira base URL from connector config")
|
||||
|
||||
# Build auth headers
|
||||
api_token = credentials.get("jira_api_token", "")
|
||||
email = credentials.get("jira_user_email")
|
||||
|
||||
if email:
|
||||
# Cloud auth: Basic auth with email:token
|
||||
import base64
|
||||
|
||||
auth_string = base64.b64encode(f"{email}:{api_token}".encode()).decode()
|
||||
auth_headers = {
|
||||
"Authorization": f"Basic {auth_string}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
else:
|
||||
# Server auth: Bearer token
|
||||
auth_headers = {
|
||||
"Authorization": f"Bearer {api_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
return jira_base_url, auth_headers
|
||||
|
||||
|
||||
def _count_verdicts(findings: list[ProposalReviewFinding]) -> dict[str, int]:
|
||||
"""Count findings by verdict."""
|
||||
counts: dict[str, int] = {
|
||||
"PASS": 0,
|
||||
"FAIL": 0,
|
||||
"FLAG": 0,
|
||||
"NEEDS_REVIEW": 0,
|
||||
"NOT_APPLICABLE": 0,
|
||||
}
|
||||
for f in findings:
|
||||
verdict = f.verdict.upper() if f.verdict else "NEEDS_REVIEW"
|
||||
counts[verdict] = counts.get(verdict, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def _update_custom_fields(
|
||||
jira_base_url: str,
|
||||
auth_headers: dict[str, str],
|
||||
issue_key: str,
|
||||
decision: str,
|
||||
verdict_counts: dict[str, int],
|
||||
writeback_config: dict,
|
||||
) -> None:
|
||||
"""PUT custom fields on the Jira issue (decision, completion %)."""
|
||||
decision_field = writeback_config.get("decision_field_id")
|
||||
completion_field = writeback_config.get("completion_field_id")
|
||||
|
||||
if not decision_field and not completion_field:
|
||||
logger.debug("No custom field IDs configured for Jira writeback, skipping")
|
||||
return
|
||||
|
||||
fields: dict = {}
|
||||
if decision_field:
|
||||
fields[decision_field] = decision
|
||||
if completion_field:
|
||||
total = sum(verdict_counts.values())
|
||||
completed = total - verdict_counts.get("NEEDS_REVIEW", 0)
|
||||
pct = (completed / total * 100) if total > 0 else 0
|
||||
fields[completion_field] = round(pct, 1)
|
||||
|
||||
url = f"{jira_base_url}/rest/api/3/issue/{issue_key}"
|
||||
payload = {"fields": fields}
|
||||
|
||||
try:
|
||||
resp = requests.put(url, headers=auth_headers, json=payload, timeout=30)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"Updated custom fields on {issue_key}")
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to update custom fields on {issue_key}: {e}")
|
||||
raise RuntimeError(f"Jira field update failed: {e}") from e
|
||||
|
||||
|
||||
def _transition_issue(
|
||||
jira_base_url: str,
|
||||
auth_headers: dict[str, str],
|
||||
issue_key: str,
|
||||
decision: str,
|
||||
writeback_config: dict,
|
||||
) -> None:
|
||||
"""POST a transition to move the issue to the appropriate column."""
|
||||
transition_map = writeback_config.get("transitions", {})
|
||||
transition_name = transition_map.get(decision)
|
||||
|
||||
if not transition_name:
|
||||
logger.debug(f"No transition configured for decision '{decision}', skipping")
|
||||
return
|
||||
|
||||
# First, get available transitions
|
||||
transitions_url = f"{jira_base_url}/rest/api/3/issue/{issue_key}/transitions"
|
||||
try:
|
||||
resp = requests.get(transitions_url, headers=auth_headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
available = resp.json().get("transitions", [])
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch transitions for {issue_key}: {e}")
|
||||
raise RuntimeError(f"Jira transition fetch failed: {e}") from e
|
||||
|
||||
# Find the matching transition by name (case-insensitive)
|
||||
target_transition = None
|
||||
for t in available:
|
||||
if t.get("name", "").lower() == transition_name.lower():
|
||||
target_transition = t
|
||||
break
|
||||
|
||||
if not target_transition:
|
||||
available_names = [t.get("name", "") for t in available]
|
||||
logger.warning(
|
||||
f"Transition '{transition_name}' not found for {issue_key}. "
|
||||
f"Available: {available_names}"
|
||||
)
|
||||
return
|
||||
|
||||
# Perform the transition
|
||||
payload = {"transition": {"id": target_transition["id"]}}
|
||||
try:
|
||||
resp = requests.post(
|
||||
transitions_url, headers=auth_headers, json=payload, timeout=30
|
||||
)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"Transitioned {issue_key} to '{transition_name}'")
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to transition {issue_key}: {e}")
|
||||
raise RuntimeError(f"Jira transition failed: {e}") from e
|
||||
|
||||
|
||||
def _post_comment(
|
||||
jira_base_url: str,
|
||||
auth_headers: dict[str, str],
|
||||
issue_key: str,
|
||||
decision: ProposalReviewProposalDecision | None,
|
||||
verdict_counts: dict[str, int],
|
||||
findings: list[ProposalReviewFinding],
|
||||
) -> None:
|
||||
"""POST a structured review summary as a Jira comment."""
|
||||
comment_text = _build_comment_text(decision, verdict_counts, findings)
|
||||
|
||||
url = f"{jira_base_url}/rest/api/3/issue/{issue_key}/comment"
|
||||
# Jira Cloud uses ADF (Atlassian Document Format) for comments
|
||||
payload = {
|
||||
"body": {
|
||||
"version": 1,
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": comment_text,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, headers=auth_headers, json=payload, timeout=30)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"Posted review summary comment on {issue_key}")
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to post comment on {issue_key}: {e}")
|
||||
raise RuntimeError(f"Jira comment post failed: {e}") from e
|
||||
|
||||
|
||||
def _build_comment_text(
|
||||
decision: ProposalReviewProposalDecision | None,
|
||||
verdict_counts: dict[str, int],
|
||||
findings: list[ProposalReviewFinding],
|
||||
) -> str:
|
||||
"""Build a structured review summary text for the Jira comment."""
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append("=== Argus Proposal Review Summary ===")
|
||||
lines.append("")
|
||||
|
||||
# Decision
|
||||
decision_text = getattr(decision, "decision", "N/A")
|
||||
decision_notes = getattr(decision, "notes", None)
|
||||
lines.append(f"Final Decision: {decision_text}")
|
||||
if decision_notes:
|
||||
lines.append(f"Notes: {decision_notes}")
|
||||
lines.append("")
|
||||
|
||||
# Summary counts
|
||||
total = sum(verdict_counts.values())
|
||||
lines.append(f"Review Results ({total} rules evaluated):")
|
||||
lines.append(f" Pass: {verdict_counts.get('PASS', 0)}")
|
||||
lines.append(f" Fail: {verdict_counts.get('FAIL', 0)}")
|
||||
lines.append(f" Flag: {verdict_counts.get('FLAG', 0)}")
|
||||
lines.append(f" Needs Review: {verdict_counts.get('NEEDS_REVIEW', 0)}")
|
||||
lines.append(f" Not Applicable: {verdict_counts.get('NOT_APPLICABLE', 0)}")
|
||||
lines.append("")
|
||||
|
||||
# Individual findings (truncated for readability)
|
||||
if findings:
|
||||
lines.append("--- Detailed Findings ---")
|
||||
for f in findings:
|
||||
rule_name = f.rule.name if f.rule else "Unknown Rule"
|
||||
verdict = f.verdict or "N/A"
|
||||
officer_action = ""
|
||||
if f.decision:
|
||||
officer_action = f" | Officer: {f.decision.action}"
|
||||
lines.append(f" [{verdict}] {rule_name}{officer_action}")
|
||||
if f.explanation:
|
||||
# Truncate long explanations
|
||||
explanation = f.explanation[:200]
|
||||
if len(f.explanation) > 200:
|
||||
explanation += "..."
|
||||
lines.append(f" Reason: {explanation}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"Reviewed at: {datetime.now(timezone.utc).isoformat()}")
|
||||
lines.append("Generated by Argus (Onyx Proposal Review)")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -1,339 +0,0 @@
|
||||
"""Celery tasks that orchestrate proposal review — parallel rule evaluation."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy import update
|
||||
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
@shared_task(bind=True, ignore_result=True, soft_time_limit=3600, time_limit=3660)
|
||||
def run_proposal_review(_self: object, review_run_id: str, tenant_id: str) -> None:
|
||||
"""Parent task: orchestrates rule evaluation for a review run.
|
||||
|
||||
1. Set run status=RUNNING
|
||||
2. Call get_proposal_context() once
|
||||
3. Try to auto-fetch FOA if opportunity_id in metadata and no FOA doc
|
||||
4. Get all active rules for the run's ruleset
|
||||
5. Set total_rules on the run
|
||||
6. Evaluate each rule sequentially (V1 — no Celery subtasks)
|
||||
7. After all complete: set status=COMPLETED
|
||||
8. On error: set status=FAILED
|
||||
"""
|
||||
# Set tenant context for DB access
|
||||
CURRENT_TENANT_ID_CONTEXTVAR.set(tenant_id)
|
||||
|
||||
try:
|
||||
_execute_review(review_run_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Review run {review_run_id} failed: {e}", exc_info=True)
|
||||
_mark_run_failed(review_run_id)
|
||||
raise
|
||||
finally:
|
||||
CURRENT_TENANT_ID_CONTEXTVAR.set(None)
|
||||
|
||||
|
||||
def _execute_review(review_run_id: str) -> None:
|
||||
"""Core review logic, separated for testability."""
|
||||
from uuid import UUID
|
||||
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.server.features.proposal_review.db import findings as findings_db
|
||||
from onyx.server.features.proposal_review.db import rulesets as rulesets_db
|
||||
from onyx.server.features.proposal_review.engine.context_assembler import (
|
||||
get_proposal_context,
|
||||
)
|
||||
from onyx.server.features.proposal_review.engine.foa_fetcher import fetch_foa
|
||||
|
||||
run_uuid = UUID(review_run_id)
|
||||
|
||||
# Step 1: Set run status to RUNNING
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
run = findings_db.get_review_run(run_uuid, db_session)
|
||||
if not run:
|
||||
raise ValueError(f"Review run {review_run_id} not found")
|
||||
|
||||
run.status = "RUNNING"
|
||||
run.started_at = datetime.now(timezone.utc)
|
||||
db_session.commit()
|
||||
|
||||
proposal_id = run.proposal_id
|
||||
ruleset_id = run.ruleset_id
|
||||
|
||||
# Step 2: Assemble proposal context
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
context = get_proposal_context(proposal_id, db_session)
|
||||
|
||||
# Step 3: Try to auto-fetch FOA if opportunity_id is in metadata
|
||||
opportunity_id = context.metadata.get("opportunity_id") or context.metadata.get(
|
||||
"funding_opportunity_number"
|
||||
)
|
||||
if opportunity_id and not context.foa_text:
|
||||
logger.info(f"Attempting to auto-fetch FOA for opportunity_id={opportunity_id}")
|
||||
try:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
foa_text = fetch_foa(opportunity_id, proposal_id, db_session)
|
||||
db_session.commit()
|
||||
if foa_text:
|
||||
context.foa_text = foa_text
|
||||
logger.info(f"Auto-fetched FOA: {len(foa_text)} chars")
|
||||
except Exception as e:
|
||||
logger.warning(f"FOA auto-fetch failed (non-fatal): {e}")
|
||||
|
||||
# Step 4: Get all active rules for the ruleset
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
rules = rulesets_db.list_rules_by_ruleset(
|
||||
ruleset_id, db_session, active_only=True
|
||||
)
|
||||
# Detach rules from the session so we can use them outside
|
||||
rule_data = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"name": rule.name,
|
||||
"prompt_template": rule.prompt_template,
|
||||
"rule_type": rule.rule_type,
|
||||
"rule_intent": rule.rule_intent,
|
||||
"is_hard_stop": rule.is_hard_stop,
|
||||
"category": rule.category,
|
||||
}
|
||||
for rule in rules
|
||||
]
|
||||
|
||||
if not rule_data:
|
||||
logger.warning(f"No active rules found for ruleset {ruleset_id}")
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
run = findings_db.get_review_run(run_uuid, db_session)
|
||||
if run:
|
||||
run.status = "COMPLETED"
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
db_session.commit()
|
||||
return
|
||||
|
||||
# Step 5: Update total_rules on the run
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
run = findings_db.get_review_run(run_uuid, db_session)
|
||||
if run:
|
||||
run.total_rules = len(rule_data)
|
||||
db_session.commit()
|
||||
|
||||
# Step 6: Evaluate each rule sequentially
|
||||
completed = 0
|
||||
for rule_info in rule_data:
|
||||
try:
|
||||
_evaluate_and_save(
|
||||
review_run_id=review_run_id,
|
||||
rule_id=str(rule_info["id"]),
|
||||
proposal_id=proposal_id,
|
||||
context=context,
|
||||
)
|
||||
completed += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Rule '{rule_info['name']}' (id={rule_info['id']}) failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
# Save an error finding so the officer sees which rule failed
|
||||
_save_error_finding(
|
||||
review_run_id=review_run_id,
|
||||
rule_id=str(rule_info["id"]),
|
||||
proposal_id=proposal_id,
|
||||
error=str(e),
|
||||
)
|
||||
completed += 1
|
||||
|
||||
# Increment completed_rules counter
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
run = findings_db.get_review_run(run_uuid, db_session)
|
||||
if run:
|
||||
run.completed_rules = completed
|
||||
db_session.commit()
|
||||
|
||||
# Step 7: Mark run as completed
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
run = findings_db.get_review_run(run_uuid, db_session)
|
||||
if run:
|
||||
run.status = "COMPLETED"
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
run.completed_rules = completed
|
||||
db_session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Review run {review_run_id} completed: {completed}/{len(rule_data)} rules evaluated"
|
||||
)
|
||||
|
||||
|
||||
def _evaluate_and_save(
|
||||
review_run_id: str,
|
||||
rule_id: str,
|
||||
proposal_id: str,
|
||||
context: object, # ProposalContext — typed as object to avoid circular import at module level
|
||||
) -> None:
|
||||
"""Evaluate a single rule and save the finding to DB."""
|
||||
from uuid import UUID
|
||||
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.server.features.proposal_review.db import findings as findings_db
|
||||
from onyx.server.features.proposal_review.db import rulesets as rulesets_db
|
||||
from onyx.server.features.proposal_review.engine.rule_evaluator import (
|
||||
evaluate_rule,
|
||||
)
|
||||
|
||||
rule_uuid = UUID(rule_id)
|
||||
run_uuid = UUID(review_run_id)
|
||||
|
||||
# Load the rule from DB
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
rule = rulesets_db.get_rule(rule_uuid, db_session)
|
||||
if not rule:
|
||||
raise ValueError(f"Rule {rule_id} not found")
|
||||
|
||||
# Evaluate the rule
|
||||
result = evaluate_rule(rule, context, db_session)
|
||||
|
||||
# Save finding
|
||||
findings_db.create_finding(
|
||||
proposal_id=proposal_id,
|
||||
rule_id=rule_uuid,
|
||||
review_run_id=run_uuid,
|
||||
verdict=result["verdict"],
|
||||
confidence=result.get("confidence"),
|
||||
evidence=result.get("evidence"),
|
||||
explanation=result.get("explanation"),
|
||||
suggested_action=result.get("suggested_action"),
|
||||
llm_model=result.get("llm_model"),
|
||||
llm_tokens_used=result.get("llm_tokens_used"),
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
logger.debug(f"Rule {rule_id} evaluated: verdict={result['verdict']}")
|
||||
|
||||
|
||||
def _save_error_finding(
|
||||
review_run_id: str,
|
||||
rule_id: str,
|
||||
proposal_id: str,
|
||||
error: str,
|
||||
) -> None:
|
||||
"""Save an error finding when a rule evaluation fails."""
|
||||
from uuid import UUID
|
||||
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.server.features.proposal_review.db import findings as findings_db
|
||||
|
||||
try:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
findings_db.create_finding(
|
||||
proposal_id=proposal_id,
|
||||
rule_id=UUID(rule_id),
|
||||
review_run_id=UUID(review_run_id),
|
||||
verdict="NEEDS_REVIEW",
|
||||
confidence="LOW",
|
||||
evidence=None,
|
||||
explanation=f"Rule evaluation failed with error: {error}",
|
||||
suggested_action="Manual review required due to system error.",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save error finding for rule {rule_id}: {e}")
|
||||
|
||||
|
||||
def _mark_run_failed(review_run_id: str) -> None:
|
||||
"""Mark a review run as FAILED."""
|
||||
from uuid import UUID
|
||||
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.server.features.proposal_review.db import findings as findings_db
|
||||
|
||||
try:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
run = findings_db.get_review_run(UUID(review_run_id), db_session)
|
||||
if run:
|
||||
run.status = "FAILED"
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
db_session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to mark run {review_run_id} as FAILED: {e}")
|
||||
|
||||
|
||||
@shared_task(bind=True, ignore_result=True, soft_time_limit=300, time_limit=330)
|
||||
def evaluate_single_rule(
|
||||
_self: object, review_run_id: str, rule_id: str, tenant_id: str
|
||||
) -> None:
|
||||
"""Child task: evaluates one rule (for future parallel execution).
|
||||
|
||||
Currently not used in V1 — rules are evaluated sequentially in
|
||||
run_proposal_review. This task exists for future migration to
|
||||
parallel execution via Celery groups.
|
||||
"""
|
||||
CURRENT_TENANT_ID_CONTEXTVAR.set(tenant_id)
|
||||
try:
|
||||
from uuid import UUID
|
||||
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.server.features.proposal_review.db import findings as findings_db
|
||||
from onyx.server.features.proposal_review.engine.context_assembler import (
|
||||
get_proposal_context,
|
||||
)
|
||||
|
||||
run_uuid = UUID(review_run_id)
|
||||
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
run = findings_db.get_review_run(run_uuid, db_session)
|
||||
if not run:
|
||||
raise ValueError(f"Review run {review_run_id} not found")
|
||||
proposal_id = run.proposal_id
|
||||
|
||||
# Re-assemble context (each subtask is independent)
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
context = get_proposal_context(proposal_id, db_session)
|
||||
|
||||
_evaluate_and_save(review_run_id, rule_id, proposal_id, context)
|
||||
|
||||
# Increment completed_rules atomically to avoid race conditions
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
from onyx.server.features.proposal_review.db.models import (
|
||||
ProposalReviewRun,
|
||||
)
|
||||
|
||||
db_session.execute(
|
||||
update(ProposalReviewRun)
|
||||
.where(ProposalReviewRun.id == run_uuid)
|
||||
.values(completed_rules=ProposalReviewRun.completed_rules + 1)
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
finally:
|
||||
CURRENT_TENANT_ID_CONTEXTVAR.set(None)
|
||||
|
||||
|
||||
@shared_task(bind=True, ignore_result=True, soft_time_limit=60, time_limit=90)
|
||||
def sync_decision_to_jira(_self: object, proposal_id: str, tenant_id: str) -> None:
|
||||
"""Writes officer decision back to Jira.
|
||||
|
||||
Dispatched from the sync-jira API endpoint.
|
||||
"""
|
||||
CURRENT_TENANT_ID_CONTEXTVAR.set(tenant_id)
|
||||
try:
|
||||
from uuid import UUID
|
||||
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.server.features.proposal_review.engine.jira_sync import sync_to_jira
|
||||
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
sync_to_jira(UUID(proposal_id), db_session)
|
||||
db_session.commit()
|
||||
|
||||
logger.info(f"Jira sync completed for proposal {proposal_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Jira sync failed for proposal {proposal_id}: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
CURRENT_TENANT_ID_CONTEXTVAR.set(None)
|
||||
@@ -1,210 +0,0 @@
|
||||
"""Evaluates a single rule against a proposal context via LLM."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.llm.factory import get_default_llm
|
||||
from onyx.llm.models import SystemMessage
|
||||
from onyx.llm.models import UserMessage
|
||||
from onyx.llm.utils import llm_response_to_string
|
||||
from onyx.server.features.proposal_review.db.models import ProposalReviewRule
|
||||
from onyx.server.features.proposal_review.engine.context_assembler import (
|
||||
ProposalContext,
|
||||
)
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You are a meticulous grant proposal compliance reviewer for a university research office.
|
||||
Your role is to evaluate specific aspects of grant proposals against institutional
|
||||
and sponsor requirements.
|
||||
|
||||
You must evaluate each rule independently, focusing ONLY on the specific criterion
|
||||
described. Be precise in your assessment. When in doubt, mark for human review.
|
||||
|
||||
Always respond with a valid JSON object in the exact format specified."""
|
||||
|
||||
RESPONSE_FORMAT_INSTRUCTIONS = """
|
||||
Respond with ONLY a valid JSON object in the following format:
|
||||
{
|
||||
"verdict": "PASS | FAIL | FLAG | NEEDS_REVIEW | NOT_APPLICABLE",
|
||||
"confidence": "HIGH | MEDIUM | LOW",
|
||||
"evidence": "Direct quote or reference from the proposal documents that supports your verdict. If no relevant text found, state that clearly.",
|
||||
"explanation": "Concise reasoning for why this verdict was reached. Reference specific requirements and how the proposal does or does not meet them.",
|
||||
"suggested_action": "If verdict is FAIL or FLAG, describe what the officer or PI should do. Otherwise, null."
|
||||
}
|
||||
|
||||
Verdict meanings:
|
||||
- PASS: The proposal clearly meets this requirement.
|
||||
- FAIL: The proposal clearly does NOT meet this requirement.
|
||||
- FLAG: There is a potential issue that needs human attention.
|
||||
- NEEDS_REVIEW: Insufficient information to make a determination.
|
||||
- NOT_APPLICABLE: This rule does not apply to this proposal.
|
||||
"""
|
||||
|
||||
|
||||
def evaluate_rule(
|
||||
rule: ProposalReviewRule,
|
||||
context: ProposalContext,
|
||||
_db_session: Session | None = None,
|
||||
) -> dict:
|
||||
"""Evaluate one rule against proposal context via LLM.
|
||||
|
||||
1. Fills rule.prompt_template variables ({{proposal_text}}, {{metadata}}, etc.)
|
||||
2. Wraps in system prompt establishing reviewer role
|
||||
3. Calls llm.invoke() with structured output instructions
|
||||
4. Parses response into a findings dict
|
||||
|
||||
Args:
|
||||
rule: The rule to evaluate.
|
||||
context: Assembled proposal context.
|
||||
db_session: Optional DB session (not used for LLM call but kept for API compat).
|
||||
|
||||
Returns:
|
||||
Dict with verdict, confidence, evidence, explanation, suggested_action,
|
||||
plus llm_model and llm_tokens_used if available.
|
||||
"""
|
||||
# 1. Fill template variables
|
||||
filled_prompt = _fill_template(rule.prompt_template, context)
|
||||
|
||||
# 2. Build full prompt
|
||||
user_content = f"{filled_prompt}\n\n" f"{RESPONSE_FORMAT_INSTRUCTIONS}"
|
||||
|
||||
prompt_messages = [
|
||||
SystemMessage(content=SYSTEM_PROMPT),
|
||||
UserMessage(content=user_content),
|
||||
]
|
||||
|
||||
# 3. Call LLM
|
||||
try:
|
||||
llm = get_default_llm()
|
||||
response = llm.invoke(prompt_messages)
|
||||
raw_text = llm_response_to_string(response)
|
||||
|
||||
# Extract model info
|
||||
llm_model = llm.config.model_name if llm.config else None
|
||||
llm_tokens_used = _extract_token_usage(response)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LLM call failed for rule {rule.id} '{rule.name}': {e}")
|
||||
return {
|
||||
"verdict": "NEEDS_REVIEW",
|
||||
"confidence": "LOW",
|
||||
"evidence": None,
|
||||
"explanation": f"LLM evaluation failed: {str(e)}",
|
||||
"suggested_action": "Manual review required due to system error.",
|
||||
"llm_model": None,
|
||||
"llm_tokens_used": None,
|
||||
}
|
||||
|
||||
# 4. Parse JSON response
|
||||
result = _parse_llm_response(raw_text)
|
||||
result["llm_model"] = llm_model
|
||||
result["llm_tokens_used"] = llm_tokens_used
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _fill_template(template: str, context: ProposalContext) -> str:
|
||||
"""Replace {{variable}} placeholders in the prompt template.
|
||||
|
||||
Supported variables:
|
||||
- {{proposal_text}} -> context.proposal_text
|
||||
- {{budget_text}} -> context.budget_text
|
||||
- {{foa_text}} -> context.foa_text
|
||||
- {{metadata}} -> JSON dump of context.metadata
|
||||
- {{metadata.FIELD}} -> specific metadata field value
|
||||
- {{jira_key}} -> context.jira_key
|
||||
"""
|
||||
result = template
|
||||
|
||||
# Direct substitutions
|
||||
result = result.replace("{{proposal_text}}", context.proposal_text or "")
|
||||
result = result.replace("{{budget_text}}", context.budget_text or "")
|
||||
result = result.replace("{{foa_text}}", context.foa_text or "")
|
||||
result = result.replace("{{jira_key}}", context.jira_key or "")
|
||||
|
||||
# Metadata as JSON
|
||||
metadata_str = json.dumps(context.metadata, indent=2, default=str)
|
||||
result = result.replace("{{metadata}}", metadata_str)
|
||||
|
||||
# Specific metadata fields: {{metadata.FIELD}}
|
||||
metadata_field_pattern = re.compile(r"\{\{metadata\.([^}]+)\}\}")
|
||||
for match in metadata_field_pattern.finditer(result):
|
||||
field_name = match.group(1)
|
||||
field_value = context.metadata.get(field_name, "")
|
||||
if isinstance(field_value, (dict, list)):
|
||||
field_value = json.dumps(field_value, default=str)
|
||||
result = result.replace(match.group(0), str(field_value))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _parse_llm_response(raw_text: str) -> dict:
|
||||
"""Parse the LLM response text as JSON.
|
||||
|
||||
Handles cases where the LLM wraps JSON in markdown code fences.
|
||||
"""
|
||||
text = raw_text.strip()
|
||||
|
||||
# Strip markdown code fences if present
|
||||
if text.startswith("```"):
|
||||
# Remove opening fence (with optional language tag)
|
||||
text = re.sub(r"^```(?:json)?\s*\n?", "", text)
|
||||
# Remove closing fence
|
||||
text = re.sub(r"\n?```\s*$", "", text)
|
||||
text = text.strip()
|
||||
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse LLM response as JSON: {text[:200]}...")
|
||||
return {
|
||||
"verdict": "NEEDS_REVIEW",
|
||||
"confidence": "LOW",
|
||||
"evidence": None,
|
||||
"explanation": f"Failed to parse LLM response. Raw output: {text[:500]}",
|
||||
"suggested_action": "Manual review required due to unparseable AI response.",
|
||||
}
|
||||
|
||||
# Validate and normalize the parsed result
|
||||
valid_verdicts = {"PASS", "FAIL", "FLAG", "NEEDS_REVIEW", "NOT_APPLICABLE"}
|
||||
valid_confidences = {"HIGH", "MEDIUM", "LOW"}
|
||||
|
||||
verdict = str(parsed.get("verdict", "NEEDS_REVIEW")).upper()
|
||||
if verdict not in valid_verdicts:
|
||||
verdict = "NEEDS_REVIEW"
|
||||
|
||||
confidence = str(parsed.get("confidence", "LOW")).upper()
|
||||
if confidence not in valid_confidences:
|
||||
confidence = "LOW"
|
||||
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"confidence": confidence,
|
||||
"evidence": parsed.get("evidence"),
|
||||
"explanation": parsed.get("explanation"),
|
||||
"suggested_action": parsed.get("suggested_action"),
|
||||
}
|
||||
|
||||
|
||||
def _extract_token_usage(response: object) -> int | None:
|
||||
"""Best-effort extraction of token usage from the LLM response."""
|
||||
try:
|
||||
# litellm ModelResponse has a usage attribute
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
usage = response.usage
|
||||
total = getattr(usage, "total_tokens", None)
|
||||
if total is not None:
|
||||
return int(total)
|
||||
# Sum prompt + completion tokens if total not available
|
||||
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
|
||||
completion_tokens = getattr(usage, "completion_tokens", 0) or 0
|
||||
if prompt_tokens or completion_tokens:
|
||||
return prompt_tokens + completion_tokens
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -111,43 +111,6 @@ def _mask_string(value: str) -> str:
|
||||
return value[:4] + "****" + value[-4:]
|
||||
|
||||
|
||||
def _resolve_api_key(
|
||||
api_key: str | None,
|
||||
provider_name: str | None,
|
||||
api_base: str | None,
|
||||
db_session: Session,
|
||||
) -> str | None:
|
||||
"""Return the real API key for model-fetch endpoints.
|
||||
|
||||
When editing an existing provider the form value is masked (e.g.
|
||||
``sk-a****b1c2``). If *provider_name* is supplied we can look up
|
||||
the unmasked key from the database so the external request succeeds.
|
||||
|
||||
The stored key is only returned when the request's *api_base*
|
||||
matches the value stored in the database.
|
||||
"""
|
||||
if not provider_name:
|
||||
return api_key
|
||||
|
||||
existing_provider = fetch_existing_llm_provider(
|
||||
name=provider_name, db_session=db_session
|
||||
)
|
||||
if existing_provider and existing_provider.api_key:
|
||||
# Normalise both URLs before comparing so trailing-slash
|
||||
# differences don't cause a false mismatch.
|
||||
stored_base = (existing_provider.api_base or "").strip().rstrip("/")
|
||||
request_base = (api_base or "").strip().rstrip("/")
|
||||
if stored_base != request_base:
|
||||
return api_key
|
||||
|
||||
stored_key = existing_provider.api_key.get_value(apply_mask=False)
|
||||
# Only resolve when the incoming value is the masked form of the
|
||||
# stored key — i.e. the user hasn't typed a new key.
|
||||
if api_key and api_key == _mask_string(stored_key):
|
||||
return stored_key
|
||||
return api_key
|
||||
|
||||
|
||||
def _sync_fetched_models(
|
||||
db_session: Session,
|
||||
provider_name: str,
|
||||
@@ -1211,17 +1174,16 @@ def get_ollama_available_models(
|
||||
return sorted_results
|
||||
|
||||
|
||||
def _get_openrouter_models_response(api_base: str, api_key: str | None) -> dict:
|
||||
def _get_openrouter_models_response(api_base: str, api_key: str) -> dict:
|
||||
"""Perform GET to OpenRouter /models and return parsed JSON."""
|
||||
cleaned_api_base = api_base.strip().rstrip("/")
|
||||
url = f"{cleaned_api_base}/models"
|
||||
headers: dict[str, str] = {
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
# Optional headers recommended by OpenRouter for attribution
|
||||
"HTTP-Referer": "https://onyx.app",
|
||||
"X-Title": "Onyx",
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
try:
|
||||
response = httpx.get(url, headers=headers, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
@@ -1244,12 +1206,8 @@ def get_openrouter_available_models(
|
||||
Parses id, name (display), context_length, and architecture.input_modalities.
|
||||
"""
|
||||
|
||||
api_key = _resolve_api_key(
|
||||
request.api_key, request.provider_name, request.api_base, db_session
|
||||
)
|
||||
|
||||
response_json = _get_openrouter_models_response(
|
||||
api_base=request.api_base, api_key=api_key
|
||||
api_base=request.api_base, api_key=request.api_key
|
||||
)
|
||||
|
||||
data = response_json.get("data", [])
|
||||
@@ -1342,18 +1300,13 @@ def get_lm_studio_available_models(
|
||||
|
||||
# If provider_name is given and the api_key hasn't been changed by the user,
|
||||
# fall back to the stored API key from the database (the form value is masked).
|
||||
# Only do so when the api_base matches what is stored.
|
||||
api_key = request.api_key
|
||||
if request.provider_name and not request.api_key_changed:
|
||||
existing_provider = fetch_existing_llm_provider(
|
||||
name=request.provider_name, db_session=db_session
|
||||
)
|
||||
if existing_provider and existing_provider.custom_config:
|
||||
stored_base = (existing_provider.api_base or "").strip().rstrip("/")
|
||||
if stored_base == cleaned_api_base:
|
||||
api_key = existing_provider.custom_config.get(
|
||||
LM_STUDIO_API_KEY_CONFIG_KEY
|
||||
)
|
||||
api_key = existing_provider.custom_config.get(LM_STUDIO_API_KEY_CONFIG_KEY)
|
||||
|
||||
url = f"{cleaned_api_base}/api/v1/models"
|
||||
headers: dict[str, str] = {}
|
||||
@@ -1437,12 +1390,8 @@ def get_litellm_available_models(
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[LitellmFinalModelResponse]:
|
||||
"""Fetch available models from Litellm proxy /v1/models endpoint."""
|
||||
api_key = _resolve_api_key(
|
||||
request.api_key, request.provider_name, request.api_base, db_session
|
||||
)
|
||||
|
||||
response_json = _get_litellm_models_response(
|
||||
api_key=api_key, api_base=request.api_base
|
||||
api_key=request.api_key, api_base=request.api_base
|
||||
)
|
||||
|
||||
models = response_json.get("data", [])
|
||||
@@ -1499,7 +1448,7 @@ def get_litellm_available_models(
|
||||
return sorted_results
|
||||
|
||||
|
||||
def _get_litellm_models_response(api_key: str | None, api_base: str) -> dict:
|
||||
def _get_litellm_models_response(api_key: str, api_base: str) -> dict:
|
||||
"""Perform GET to Litellm proxy /api/v1/models and return parsed JSON."""
|
||||
cleaned_api_base = api_base.strip().rstrip("/")
|
||||
url = f"{cleaned_api_base}/v1/models"
|
||||
@@ -1574,12 +1523,8 @@ def get_bifrost_available_models(
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[BifrostFinalModelResponse]:
|
||||
"""Fetch available models from Bifrost gateway /v1/models endpoint."""
|
||||
api_key = _resolve_api_key(
|
||||
request.api_key, request.provider_name, request.api_base, db_session
|
||||
)
|
||||
|
||||
response_json = _get_bifrost_models_response(
|
||||
api_base=request.api_base, api_key=api_key
|
||||
api_base=request.api_base, api_key=request.api_key
|
||||
)
|
||||
|
||||
models = response_json.get("data", [])
|
||||
@@ -1668,12 +1613,8 @@ def get_openai_compatible_server_available_models(
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[OpenAICompatibleFinalModelResponse]:
|
||||
"""Fetch available models from a generic OpenAI-compatible /v1/models endpoint."""
|
||||
api_key = _resolve_api_key(
|
||||
request.api_key, request.provider_name, request.api_base, db_session
|
||||
)
|
||||
|
||||
response_json = _get_openai_compatible_server_response(
|
||||
api_base=request.api_base, api_key=api_key
|
||||
api_base=request.api_base, api_key=request.api_key
|
||||
)
|
||||
|
||||
models = response_json.get("data", [])
|
||||
|
||||
@@ -254,7 +254,7 @@ oauthlib==3.2.2
|
||||
# via
|
||||
# kubernetes
|
||||
# requests-oauthlib
|
||||
onyx-devtools==0.7.5
|
||||
onyx-devtools==0.7.4
|
||||
openai==2.14.0
|
||||
# via
|
||||
# litellm
|
||||
|
||||
@@ -46,7 +46,7 @@ stop_and_remove_containers
|
||||
# Start the PostgreSQL container with optional volume
|
||||
echo "Starting PostgreSQL container..."
|
||||
if [[ -n "$POSTGRES_VOLUME" ]]; then
|
||||
docker run -p 5432:5432 --name onyx_postgres -e POSTGRES_PASSWORD=password -d -v $POSTGRES_VOLUME:/var/lib/postgresql/data postgres -c max_connections=250
|
||||
docker run -p 5432:5432 --name onyx_postgres -e POSTGRES_PASSWORD=password -d -v "$POSTGRES_VOLUME":/var/lib/postgresql/data postgres -c max_connections=250
|
||||
else
|
||||
docker run -p 5432:5432 --name onyx_postgres -e POSTGRES_PASSWORD=password -d postgres -c max_connections=250
|
||||
fi
|
||||
@@ -54,7 +54,7 @@ fi
|
||||
# Start the Vespa container with optional volume
|
||||
echo "Starting Vespa container..."
|
||||
if [[ -n "$VESPA_VOLUME" ]]; then
|
||||
docker run --detach --name onyx_vespa --hostname vespa-container --publish 8081:8081 --publish 19071:19071 -v $VESPA_VOLUME:/opt/vespa/var vespaengine/vespa:8
|
||||
docker run --detach --name onyx_vespa --hostname vespa-container --publish 8081:8081 --publish 19071:19071 -v "$VESPA_VOLUME":/opt/vespa/var vespaengine/vespa:8
|
||||
else
|
||||
docker run --detach --name onyx_vespa --hostname vespa-container --publish 8081:8081 --publish 19071:19071 vespaengine/vespa:8
|
||||
fi
|
||||
@@ -85,7 +85,7 @@ docker compose -f "$COMPOSE_FILE" -f "$COMPOSE_DEV_FILE" --profile opensearch-en
|
||||
# Start the Redis container with optional volume
|
||||
echo "Starting Redis container..."
|
||||
if [[ -n "$REDIS_VOLUME" ]]; then
|
||||
docker run --detach --name onyx_redis --publish 6379:6379 -v $REDIS_VOLUME:/data redis
|
||||
docker run --detach --name onyx_redis --publish 6379:6379 -v "$REDIS_VOLUME":/data redis
|
||||
else
|
||||
docker run --detach --name onyx_redis --publish 6379:6379 redis
|
||||
fi
|
||||
@@ -93,7 +93,7 @@ fi
|
||||
# Start the MinIO container with optional volume
|
||||
echo "Starting MinIO container..."
|
||||
if [[ -n "$MINIO_VOLUME" ]]; then
|
||||
docker run --detach --name onyx_minio --publish 9004:9000 --publish 9005:9001 -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin -v $MINIO_VOLUME:/data minio/minio server /data --console-address ":9001"
|
||||
docker run --detach --name onyx_minio --publish 9004:9000 --publish 9005:9001 -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin -v "$MINIO_VOLUME":/data minio/minio server /data --console-address ":9001"
|
||||
else
|
||||
docker run --detach --name onyx_minio --publish 9004:9000 --publish 9005:9001 -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin minio/minio server /data --console-address ":9001"
|
||||
fi
|
||||
@@ -111,6 +111,7 @@ sleep 1
|
||||
|
||||
# Alembic should be configured in the virtualenv for this repo
|
||||
if [[ -f "../.venv/bin/activate" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source ../.venv/bin/activate
|
||||
else
|
||||
echo "Warning: Python virtual environment not found at .venv/bin/activate; alembic may not work."
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"""Tests for GoogleDriveConnector.resolve_errors against real Google Drive."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from unittest.mock import patch
|
||||
|
||||
from onyx.connectors.google_drive.connector import GoogleDriveConnector
|
||||
from onyx.connectors.models import ConnectorFailure
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import DocumentFailure
|
||||
from onyx.connectors.models import HierarchyNode
|
||||
from tests.daily.connectors.google_drive.consts_and_utils import ADMIN_EMAIL
|
||||
from tests.daily.connectors.google_drive.consts_and_utils import (
|
||||
ALL_EXPECTED_HIERARCHY_NODES,
|
||||
)
|
||||
from tests.daily.connectors.google_drive.consts_and_utils import FOLDER_1_ID
|
||||
from tests.daily.connectors.google_drive.consts_and_utils import SHARED_DRIVE_1_ID
|
||||
|
||||
_DRIVE_ID_MAPPING_PATH = os.path.join(
|
||||
os.path.dirname(__file__), "drive_id_mapping.json"
|
||||
)
|
||||
|
||||
|
||||
def _load_web_view_links(file_ids: list[int]) -> list[str]:
|
||||
with open(_DRIVE_ID_MAPPING_PATH) as f:
|
||||
mapping: dict[str, str] = json.load(f)
|
||||
return [mapping[str(fid)] for fid in file_ids]
|
||||
|
||||
|
||||
def _build_failures(web_view_links: list[str]) -> list[ConnectorFailure]:
|
||||
return [
|
||||
ConnectorFailure(
|
||||
failed_document=DocumentFailure(
|
||||
document_id=link,
|
||||
document_link=link,
|
||||
),
|
||||
failure_message=f"Synthetic failure for {link}",
|
||||
)
|
||||
for link in web_view_links
|
||||
]
|
||||
|
||||
|
||||
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
|
||||
def test_resolve_single_file(
|
||||
mock_api_key: None, # noqa: ARG001
|
||||
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
|
||||
) -> None:
|
||||
"""Resolve a single known file and verify we get back exactly one Document."""
|
||||
connector = google_drive_service_acct_connector_factory(
|
||||
primary_admin_email=ADMIN_EMAIL,
|
||||
include_shared_drives=True,
|
||||
shared_drive_urls=None,
|
||||
include_my_drives=True,
|
||||
my_drive_emails=None,
|
||||
shared_folder_urls=None,
|
||||
include_files_shared_with_me=False,
|
||||
)
|
||||
|
||||
web_view_links = _load_web_view_links([0])
|
||||
failures = _build_failures(web_view_links)
|
||||
|
||||
results = list(connector.resolve_errors(failures))
|
||||
|
||||
docs = [r for r in results if isinstance(r, Document)]
|
||||
new_failures = [r for r in results if isinstance(r, ConnectorFailure)]
|
||||
hierarchy_nodes = [r for r in results if isinstance(r, HierarchyNode)]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert len(new_failures) == 0
|
||||
assert docs[0].semantic_identifier == "file_0.txt"
|
||||
|
||||
# Should yield at least one hierarchy node (the file's parent folder chain)
|
||||
assert len(hierarchy_nodes) > 0
|
||||
|
||||
|
||||
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
|
||||
def test_resolve_multiple_files(
|
||||
mock_api_key: None, # noqa: ARG001
|
||||
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
|
||||
) -> None:
|
||||
"""Resolve multiple files across different folders via batch API."""
|
||||
connector = google_drive_service_acct_connector_factory(
|
||||
primary_admin_email=ADMIN_EMAIL,
|
||||
include_shared_drives=True,
|
||||
shared_drive_urls=None,
|
||||
include_my_drives=True,
|
||||
my_drive_emails=None,
|
||||
shared_folder_urls=None,
|
||||
include_files_shared_with_me=False,
|
||||
)
|
||||
|
||||
# Pick files from different folders: admin files (0-4), shared drive 1 (20-24), folder_2 (45-49)
|
||||
file_ids = [0, 1, 20, 21, 45]
|
||||
web_view_links = _load_web_view_links(file_ids)
|
||||
failures = _build_failures(web_view_links)
|
||||
|
||||
results = list(connector.resolve_errors(failures))
|
||||
|
||||
docs = [r for r in results if isinstance(r, Document)]
|
||||
new_failures = [r for r in results if isinstance(r, ConnectorFailure)]
|
||||
hierarchy_nodes = [r for r in results if isinstance(r, HierarchyNode)]
|
||||
|
||||
assert len(new_failures) == 0
|
||||
retrieved_names = {doc.semantic_identifier for doc in docs}
|
||||
expected_names = {f"file_{fid}.txt" for fid in file_ids}
|
||||
assert expected_names == retrieved_names
|
||||
|
||||
# Files span multiple folders, so we should get hierarchy nodes
|
||||
assert len(hierarchy_nodes) > 0
|
||||
|
||||
|
||||
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
|
||||
def test_resolve_hierarchy_nodes_are_valid(
|
||||
mock_api_key: None, # noqa: ARG001
|
||||
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
|
||||
) -> None:
|
||||
"""Verify that hierarchy nodes from resolve_errors match expected structure."""
|
||||
connector = google_drive_service_acct_connector_factory(
|
||||
primary_admin_email=ADMIN_EMAIL,
|
||||
include_shared_drives=True,
|
||||
shared_drive_urls=None,
|
||||
include_my_drives=True,
|
||||
my_drive_emails=None,
|
||||
shared_folder_urls=None,
|
||||
include_files_shared_with_me=False,
|
||||
)
|
||||
|
||||
# File in folder_1 (inside shared_drive_1) — should walk up to shared_drive_1 root
|
||||
web_view_links = _load_web_view_links([25])
|
||||
failures = _build_failures(web_view_links)
|
||||
|
||||
results = list(connector.resolve_errors(failures))
|
||||
|
||||
hierarchy_nodes = [r for r in results if isinstance(r, HierarchyNode)]
|
||||
node_ids = {node.raw_node_id for node in hierarchy_nodes}
|
||||
|
||||
# File 25 is in folder_1 which is inside shared_drive_1.
|
||||
# The parent walk must yield at least these two ancestors.
|
||||
assert (
|
||||
FOLDER_1_ID in node_ids
|
||||
), f"Expected folder_1 ({FOLDER_1_ID}) in hierarchy nodes, got: {node_ids}"
|
||||
assert (
|
||||
SHARED_DRIVE_1_ID in node_ids
|
||||
), f"Expected shared_drive_1 ({SHARED_DRIVE_1_ID}) in hierarchy nodes, got: {node_ids}"
|
||||
|
||||
for node in hierarchy_nodes:
|
||||
if node.raw_node_id not in ALL_EXPECTED_HIERARCHY_NODES:
|
||||
continue
|
||||
expected = ALL_EXPECTED_HIERARCHY_NODES[node.raw_node_id]
|
||||
assert node.display_name == expected.display_name, (
|
||||
f"Display name mismatch for {node.raw_node_id}: "
|
||||
f"expected '{expected.display_name}', got '{node.display_name}'"
|
||||
)
|
||||
assert node.node_type == expected.node_type, (
|
||||
f"Node type mismatch for {node.raw_node_id}: "
|
||||
f"expected '{expected.node_type}', got '{node.node_type}'"
|
||||
)
|
||||
|
||||
|
||||
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
|
||||
def test_resolve_with_invalid_link(
|
||||
mock_api_key: None, # noqa: ARG001
|
||||
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
|
||||
) -> None:
|
||||
"""Resolve with a mix of valid and invalid links — invalid ones yield ConnectorFailure."""
|
||||
connector = google_drive_service_acct_connector_factory(
|
||||
primary_admin_email=ADMIN_EMAIL,
|
||||
include_shared_drives=True,
|
||||
shared_drive_urls=None,
|
||||
include_my_drives=True,
|
||||
my_drive_emails=None,
|
||||
shared_folder_urls=None,
|
||||
include_files_shared_with_me=False,
|
||||
)
|
||||
|
||||
valid_links = _load_web_view_links([0])
|
||||
invalid_link = "https://drive.google.com/file/d/NONEXISTENT_FILE_ID_12345"
|
||||
failures = _build_failures(valid_links + [invalid_link])
|
||||
|
||||
results = list(connector.resolve_errors(failures))
|
||||
|
||||
docs = [r for r in results if isinstance(r, Document)]
|
||||
new_failures = [r for r in results if isinstance(r, ConnectorFailure)]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert docs[0].semantic_identifier == "file_0.txt"
|
||||
assert len(new_failures) == 1
|
||||
assert new_failures[0].failed_document is not None
|
||||
assert new_failures[0].failed_document.document_id == invalid_link
|
||||
|
||||
|
||||
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
|
||||
def test_resolve_empty_errors(
|
||||
mock_api_key: None, # noqa: ARG001
|
||||
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
|
||||
) -> None:
|
||||
"""Resolving an empty error list should yield nothing."""
|
||||
connector = google_drive_service_acct_connector_factory(
|
||||
primary_admin_email=ADMIN_EMAIL,
|
||||
include_shared_drives=True,
|
||||
shared_drive_urls=None,
|
||||
include_my_drives=True,
|
||||
my_drive_emails=None,
|
||||
shared_folder_urls=None,
|
||||
include_files_shared_with_me=False,
|
||||
)
|
||||
|
||||
results = list(connector.resolve_errors([]))
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
|
||||
def test_resolve_entity_failures_are_skipped(
|
||||
mock_api_key: None, # noqa: ARG001
|
||||
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
|
||||
) -> None:
|
||||
"""Entity failures (not document failures) should be skipped by resolve_errors."""
|
||||
from onyx.connectors.models import EntityFailure
|
||||
|
||||
connector = google_drive_service_acct_connector_factory(
|
||||
primary_admin_email=ADMIN_EMAIL,
|
||||
include_shared_drives=True,
|
||||
shared_drive_urls=None,
|
||||
include_my_drives=True,
|
||||
my_drive_emails=None,
|
||||
shared_folder_urls=None,
|
||||
include_files_shared_with_me=False,
|
||||
)
|
||||
|
||||
entity_failure = ConnectorFailure(
|
||||
failed_entity=EntityFailure(entity_id="some_stage"),
|
||||
failure_message="retrieval failure",
|
||||
)
|
||||
|
||||
results = list(connector.resolve_errors([entity_failure]))
|
||||
|
||||
assert len(results) == 0
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Shared fixtures for proposal review integration tests.
|
||||
|
||||
Uses the same real-PostgreSQL pattern as the parent external_dependency_unit
|
||||
conftest. Tables must already exist (via the 61ea78857c97 migration).
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi_users.password import PasswordHelper
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.db.engine.sql_engine import SqlEngine
|
||||
from onyx.db.enums import AccountType
|
||||
from onyx.db.models import User
|
||||
from onyx.db.models import UserRole
|
||||
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
|
||||
from tests.external_dependency_unit.constants import TEST_TENANT_ID
|
||||
|
||||
# Tables to clean up after each test, in dependency order (children first).
|
||||
_PROPOSAL_REVIEW_TABLES = [
|
||||
"proposal_review_audit_log",
|
||||
"proposal_review_decision",
|
||||
"proposal_review_proposal_decision",
|
||||
"proposal_review_finding",
|
||||
"proposal_review_run",
|
||||
"proposal_review_document",
|
||||
"proposal_review_proposal",
|
||||
"proposal_review_rule",
|
||||
"proposal_review_ruleset",
|
||||
"proposal_review_config",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def tenant_context() -> Generator[None, None, None]:
|
||||
token = CURRENT_TENANT_ID_CONTEXTVAR.set(TEST_TENANT_ID)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db_session(tenant_context: None) -> Generator[Session, None, None]: # noqa: ARG001
|
||||
"""Yield a DB session scoped to the current tenant.
|
||||
|
||||
After the test completes, all proposal_review rows are deleted so tests
|
||||
don't leave artifacts in the database.
|
||||
"""
|
||||
SqlEngine.init_engine(pool_size=10, max_overflow=5)
|
||||
with get_session_with_current_tenant() as session:
|
||||
yield session
|
||||
|
||||
# Clean up all proposal_review data created during this test
|
||||
try:
|
||||
for table in _PROPOSAL_REVIEW_TABLES:
|
||||
session.execute(text(f"DELETE FROM {table}")) # noqa: S608
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def test_user(db_session: Session) -> User:
|
||||
"""Create a throwaway user for FK references (triggered_by, officer_id, etc.)."""
|
||||
unique_email = f"pr_test_{uuid4().hex[:8]}@example.com"
|
||||
password_helper = PasswordHelper()
|
||||
hashed_password = password_helper.hash(password_helper.generate())
|
||||
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email=unique_email,
|
||||
hashed_password=hashed_password,
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
is_verified=True,
|
||||
role=UserRole.ADMIN,
|
||||
account_type=AccountType.STANDARD,
|
||||
)
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
db_session.refresh(user)
|
||||
return user
|
||||
@@ -1,425 +0,0 @@
|
||||
"""Integration tests for per-finding decisions, proposal decisions, config, and audit log."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.db.models import User
|
||||
from onyx.server.features.proposal_review.db.config import get_config
|
||||
from onyx.server.features.proposal_review.db.config import upsert_config
|
||||
from onyx.server.features.proposal_review.db.decisions import create_audit_log
|
||||
from onyx.server.features.proposal_review.db.decisions import (
|
||||
create_proposal_decision,
|
||||
)
|
||||
from onyx.server.features.proposal_review.db.decisions import get_finding_decision
|
||||
from onyx.server.features.proposal_review.db.decisions import (
|
||||
get_latest_proposal_decision,
|
||||
)
|
||||
from onyx.server.features.proposal_review.db.decisions import list_audit_log
|
||||
from onyx.server.features.proposal_review.db.decisions import (
|
||||
mark_decision_jira_synced,
|
||||
)
|
||||
from onyx.server.features.proposal_review.db.decisions import (
|
||||
upsert_finding_decision,
|
||||
)
|
||||
from onyx.server.features.proposal_review.db.findings import create_finding
|
||||
from onyx.server.features.proposal_review.db.findings import create_review_run
|
||||
from onyx.server.features.proposal_review.db.findings import get_finding
|
||||
from onyx.server.features.proposal_review.db.proposals import get_or_create_proposal
|
||||
from onyx.server.features.proposal_review.db.proposals import update_proposal_status
|
||||
from onyx.server.features.proposal_review.db.rulesets import create_rule
|
||||
from onyx.server.features.proposal_review.db.rulesets import create_ruleset
|
||||
from tests.external_dependency_unit.constants import TEST_TENANT_ID
|
||||
|
||||
TENANT = TEST_TENANT_ID
|
||||
|
||||
|
||||
def _make_finding(db_session: Session, test_user: User):
|
||||
"""Helper: create a full chain (ruleset -> rule -> proposal -> run -> finding)."""
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"RS-{uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
rule = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Test Rule",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="{{proposal_text}}",
|
||||
db_session=db_session,
|
||||
)
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
run = create_review_run(
|
||||
proposal_id=proposal.id,
|
||||
ruleset_id=rs.id,
|
||||
triggered_by=test_user.id,
|
||||
total_rules=1,
|
||||
db_session=db_session,
|
||||
)
|
||||
finding = create_finding(
|
||||
proposal_id=proposal.id,
|
||||
rule_id=rule.id,
|
||||
review_run_id=run.id,
|
||||
verdict="FAIL",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
return finding, proposal
|
||||
|
||||
|
||||
class TestFindingDecision:
|
||||
def test_create_finding_decision(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
finding, _ = _make_finding(db_session, test_user)
|
||||
|
||||
decision = upsert_finding_decision(
|
||||
finding_id=finding.id,
|
||||
officer_id=test_user.id,
|
||||
action="VERIFIED",
|
||||
db_session=db_session,
|
||||
notes="Looks good",
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
assert decision.id is not None
|
||||
assert decision.finding_id == finding.id
|
||||
assert decision.action == "VERIFIED"
|
||||
assert decision.notes == "Looks good"
|
||||
|
||||
def test_upsert_overwrites_previous_decision(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
finding, _ = _make_finding(db_session, test_user)
|
||||
|
||||
first = upsert_finding_decision(
|
||||
finding_id=finding.id,
|
||||
officer_id=test_user.id,
|
||||
action="VERIFIED",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
first_id = first.id
|
||||
|
||||
second = upsert_finding_decision(
|
||||
finding_id=finding.id,
|
||||
officer_id=test_user.id,
|
||||
action="ISSUE",
|
||||
db_session=db_session,
|
||||
notes="Actually, this is a problem",
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
# Same row was updated, not a new one created
|
||||
assert second.id == first_id
|
||||
assert second.action == "ISSUE"
|
||||
assert second.notes == "Actually, this is a problem"
|
||||
|
||||
def test_get_finding_decision(self, db_session: Session, test_user: User) -> None:
|
||||
finding, _ = _make_finding(db_session, test_user)
|
||||
|
||||
upsert_finding_decision(
|
||||
finding_id=finding.id,
|
||||
officer_id=test_user.id,
|
||||
action="NOT_APPLICABLE",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
fetched = get_finding_decision(finding.id, db_session)
|
||||
assert fetched is not None
|
||||
assert fetched.action == "NOT_APPLICABLE"
|
||||
|
||||
def test_get_finding_decision_returns_none_when_no_decision(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
finding, _ = _make_finding(db_session, test_user)
|
||||
assert get_finding_decision(finding.id, db_session) is None
|
||||
|
||||
def test_finding_decision_accessible_via_finding_relationship(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
finding, _ = _make_finding(db_session, test_user)
|
||||
|
||||
upsert_finding_decision(
|
||||
finding_id=finding.id,
|
||||
officer_id=test_user.id,
|
||||
action="OVERRIDDEN",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
fetched = get_finding(finding.id, db_session)
|
||||
assert fetched is not None
|
||||
assert fetched.decision is not None
|
||||
assert fetched.decision.action == "OVERRIDDEN"
|
||||
|
||||
|
||||
class TestProposalDecision:
|
||||
def test_create_proposal_decision(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
pd = create_proposal_decision(
|
||||
proposal_id=proposal.id,
|
||||
officer_id=test_user.id,
|
||||
decision="APPROVED",
|
||||
db_session=db_session,
|
||||
notes="All checks pass",
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
assert pd.id is not None
|
||||
assert pd.decision == "APPROVED"
|
||||
assert pd.notes == "All checks pass"
|
||||
assert pd.jira_synced is False
|
||||
|
||||
def test_get_latest_proposal_decision(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
create_proposal_decision(
|
||||
proposal_id=proposal.id,
|
||||
officer_id=test_user.id,
|
||||
decision="CHANGES_REQUESTED",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
create_proposal_decision(
|
||||
proposal_id=proposal.id,
|
||||
officer_id=test_user.id,
|
||||
decision="APPROVED",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
latest = get_latest_proposal_decision(proposal.id, db_session)
|
||||
assert latest is not None
|
||||
assert latest.decision == "APPROVED"
|
||||
|
||||
def test_get_latest_proposal_decision_returns_none_when_empty(
|
||||
self, db_session: Session
|
||||
) -> None:
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
assert get_latest_proposal_decision(proposal.id, db_session) is None
|
||||
|
||||
def test_proposal_decision_updates_proposal_status(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
"""Verify that recording a decision and updating status works together."""
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
create_proposal_decision(
|
||||
proposal_id=proposal.id,
|
||||
officer_id=test_user.id,
|
||||
decision="REJECTED",
|
||||
db_session=db_session,
|
||||
)
|
||||
update_proposal_status(proposal.id, TENANT, "REJECTED", db_session)
|
||||
db_session.commit()
|
||||
|
||||
db_session.refresh(proposal)
|
||||
assert proposal.status == "REJECTED"
|
||||
|
||||
def test_mark_decision_jira_synced(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
pd = create_proposal_decision(
|
||||
proposal_id=proposal.id,
|
||||
officer_id=test_user.id,
|
||||
decision="APPROVED",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
assert pd.jira_synced is False
|
||||
|
||||
synced = mark_decision_jira_synced(pd.id, db_session)
|
||||
db_session.commit()
|
||||
|
||||
assert synced is not None
|
||||
assert synced.jira_synced is True
|
||||
assert synced.jira_synced_at is not None
|
||||
|
||||
def test_mark_decision_jira_synced_returns_none_for_nonexistent(
|
||||
self, db_session: Session
|
||||
) -> None:
|
||||
assert mark_decision_jira_synced(uuid4(), db_session) is None
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_create_config(self, db_session: Session) -> None:
|
||||
# Use a unique tenant to avoid collision with other tests
|
||||
tenant = f"test-tenant-{uuid4().hex[:8]}"
|
||||
config = upsert_config(
|
||||
tenant_id=tenant,
|
||||
db_session=db_session,
|
||||
jira_project_key="PROJ",
|
||||
field_mapping={"title": "summary", "budget": "customfield_10001"},
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
assert config.id is not None
|
||||
assert config.tenant_id == tenant
|
||||
assert config.jira_project_key == "PROJ"
|
||||
assert config.field_mapping == {
|
||||
"title": "summary",
|
||||
"budget": "customfield_10001",
|
||||
}
|
||||
|
||||
def test_upsert_config_updates_existing(self, db_session: Session) -> None:
|
||||
tenant = f"test-tenant-{uuid4().hex[:8]}"
|
||||
first = upsert_config(
|
||||
tenant_id=tenant,
|
||||
db_session=db_session,
|
||||
jira_project_key="OLD",
|
||||
)
|
||||
db_session.commit()
|
||||
first_id = first.id
|
||||
|
||||
second = upsert_config(
|
||||
tenant_id=tenant,
|
||||
db_session=db_session,
|
||||
jira_project_key="NEW",
|
||||
field_mapping={"x": "y"},
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
assert second.id == first_id
|
||||
assert second.jira_project_key == "NEW"
|
||||
assert second.field_mapping == {"x": "y"}
|
||||
|
||||
def test_get_config_returns_correct_tenant(self, db_session: Session) -> None:
|
||||
tenant = f"test-tenant-{uuid4().hex[:8]}"
|
||||
upsert_config(
|
||||
tenant_id=tenant,
|
||||
db_session=db_session,
|
||||
jira_project_key="ABC",
|
||||
jira_writeback={"status_field": "customfield_20001"},
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
fetched = get_config(tenant, db_session)
|
||||
assert fetched is not None
|
||||
assert fetched.jira_project_key == "ABC"
|
||||
assert fetched.jira_writeback == {"status_field": "customfield_20001"}
|
||||
|
||||
def test_get_config_returns_none_for_unknown_tenant(
|
||||
self, db_session: Session
|
||||
) -> None:
|
||||
assert get_config(f"nonexistent-{uuid4().hex[:8]}", db_session) is None
|
||||
|
||||
def test_upsert_config_preserves_unset_fields(self, db_session: Session) -> None:
|
||||
tenant = f"test-tenant-{uuid4().hex[:8]}"
|
||||
upsert_config(
|
||||
tenant_id=tenant,
|
||||
db_session=db_session,
|
||||
jira_project_key="KEEP",
|
||||
jira_connector_id=42,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
# Update only field_mapping, leave jira_project_key alone
|
||||
upsert_config(
|
||||
tenant_id=tenant,
|
||||
db_session=db_session,
|
||||
field_mapping={"a": "b"},
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
fetched = get_config(tenant, db_session)
|
||||
assert fetched is not None
|
||||
assert fetched.jira_project_key == "KEEP"
|
||||
assert fetched.jira_connector_id == 42
|
||||
assert fetched.field_mapping == {"a": "b"}
|
||||
|
||||
|
||||
class TestAuditLog:
|
||||
def test_create_audit_log_entry(self, db_session: Session, test_user: User) -> None:
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
entry = create_audit_log(
|
||||
proposal_id=proposal.id,
|
||||
action="REVIEW_STARTED",
|
||||
db_session=db_session,
|
||||
user_id=test_user.id,
|
||||
details={"ruleset_id": str(uuid4())},
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
assert entry.id is not None
|
||||
assert entry.action == "REVIEW_STARTED"
|
||||
assert entry.user_id == test_user.id
|
||||
|
||||
def test_list_audit_log_ordered_by_created_at_desc(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
actions = ["REVIEW_STARTED", "FINDING_CREATED", "DECISION_MADE"]
|
||||
for action in actions:
|
||||
create_audit_log(
|
||||
proposal_id=proposal.id,
|
||||
action=action,
|
||||
db_session=db_session,
|
||||
user_id=test_user.id,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
entries = list_audit_log(proposal.id, db_session)
|
||||
assert len(entries) == 3
|
||||
# Newest first
|
||||
assert entries[0].action == "DECISION_MADE"
|
||||
assert entries[1].action == "FINDING_CREATED"
|
||||
assert entries[2].action == "REVIEW_STARTED"
|
||||
|
||||
def test_audit_log_entries_are_scoped_to_proposal(
|
||||
self, db_session: Session, test_user: User # noqa: ARG002
|
||||
) -> None:
|
||||
p1 = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
p2 = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
create_audit_log(
|
||||
proposal_id=p1.id,
|
||||
action="ACTION_A",
|
||||
db_session=db_session,
|
||||
)
|
||||
create_audit_log(
|
||||
proposal_id=p2.id,
|
||||
action="ACTION_B",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
p1_entries = list_audit_log(p1.id, db_session)
|
||||
p1_actions = {e.action for e in p1_entries}
|
||||
assert "ACTION_A" in p1_actions
|
||||
assert "ACTION_B" not in p1_actions
|
||||
|
||||
def test_audit_log_with_null_user_id(self, db_session: Session) -> None:
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
entry = create_audit_log(
|
||||
proposal_id=proposal.id,
|
||||
action="SYSTEM_ACTION",
|
||||
db_session=db_session,
|
||||
details={"source": "automated"},
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
assert entry.user_id is None
|
||||
assert entry.details == {"source": "automated"}
|
||||
@@ -1,159 +0,0 @@
|
||||
"""Integration tests for proposal state management DB operations."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.server.features.proposal_review.db.proposals import count_proposals
|
||||
from onyx.server.features.proposal_review.db.proposals import get_or_create_proposal
|
||||
from onyx.server.features.proposal_review.db.proposals import get_proposal
|
||||
from onyx.server.features.proposal_review.db.proposals import (
|
||||
get_proposal_by_document_id,
|
||||
)
|
||||
from onyx.server.features.proposal_review.db.proposals import list_proposals
|
||||
from onyx.server.features.proposal_review.db.proposals import update_proposal_status
|
||||
from tests.external_dependency_unit.constants import TEST_TENANT_ID
|
||||
|
||||
TENANT = TEST_TENANT_ID
|
||||
|
||||
|
||||
class TestGetOrCreateProposal:
|
||||
def test_creates_proposal_on_first_call(self, db_session: Session) -> None:
|
||||
doc_id = f"doc-{uuid4().hex[:8]}"
|
||||
proposal = get_or_create_proposal(doc_id, TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
assert proposal.id is not None
|
||||
assert proposal.document_id == doc_id
|
||||
assert proposal.tenant_id == TENANT
|
||||
assert proposal.status == "PENDING"
|
||||
|
||||
def test_returns_same_proposal_on_second_call(self, db_session: Session) -> None:
|
||||
doc_id = f"doc-{uuid4().hex[:8]}"
|
||||
first = get_or_create_proposal(doc_id, TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
second = get_or_create_proposal(doc_id, TENANT, db_session)
|
||||
assert second.id == first.id
|
||||
|
||||
def test_different_document_ids_create_different_proposals(
|
||||
self, db_session: Session
|
||||
) -> None:
|
||||
p1 = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
p2 = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
assert p1.id != p2.id
|
||||
|
||||
|
||||
class TestGetProposal:
|
||||
def test_returns_none_for_nonexistent_id(self, db_session: Session) -> None:
|
||||
result = get_proposal(uuid4(), TENANT, db_session)
|
||||
assert result is None
|
||||
|
||||
def test_returns_proposal_by_id(self, db_session: Session) -> None:
|
||||
doc_id = f"doc-{uuid4().hex[:8]}"
|
||||
created = get_or_create_proposal(doc_id, TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
fetched = get_proposal(created.id, TENANT, db_session)
|
||||
assert fetched is not None
|
||||
assert fetched.id == created.id
|
||||
assert fetched.document_id == doc_id
|
||||
|
||||
def test_returns_none_for_wrong_tenant(self, db_session: Session) -> None:
|
||||
doc_id = f"doc-{uuid4().hex[:8]}"
|
||||
created = get_or_create_proposal(doc_id, TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
result = get_proposal(created.id, "nonexistent_tenant", db_session)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetProposalByDocumentId:
|
||||
def test_returns_none_when_no_proposal_exists(self, db_session: Session) -> None:
|
||||
result = get_proposal_by_document_id("no-such-doc", TENANT, db_session)
|
||||
assert result is None
|
||||
|
||||
def test_finds_proposal_by_document_id(self, db_session: Session) -> None:
|
||||
doc_id = f"doc-{uuid4().hex[:8]}"
|
||||
created = get_or_create_proposal(doc_id, TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
fetched = get_proposal_by_document_id(doc_id, TENANT, db_session)
|
||||
assert fetched is not None
|
||||
assert fetched.id == created.id
|
||||
|
||||
|
||||
class TestUpdateProposalStatus:
|
||||
def test_changes_status_correctly(self, db_session: Session) -> None:
|
||||
doc_id = f"doc-{uuid4().hex[:8]}"
|
||||
proposal = get_or_create_proposal(doc_id, TENANT, db_session)
|
||||
db_session.commit()
|
||||
assert proposal.status == "PENDING"
|
||||
|
||||
updated = update_proposal_status(proposal.id, TENANT, "IN_REVIEW", db_session)
|
||||
db_session.commit()
|
||||
|
||||
assert updated is not None
|
||||
assert updated.status == "IN_REVIEW"
|
||||
|
||||
# Verify persisted
|
||||
refetched = get_proposal(proposal.id, TENANT, db_session)
|
||||
assert refetched is not None
|
||||
assert refetched.status == "IN_REVIEW"
|
||||
|
||||
def test_returns_none_for_nonexistent_proposal(self, db_session: Session) -> None:
|
||||
result = update_proposal_status(uuid4(), TENANT, "IN_REVIEW", db_session)
|
||||
assert result is None
|
||||
|
||||
def test_successive_status_updates(self, db_session: Session) -> None:
|
||||
doc_id = f"doc-{uuid4().hex[:8]}"
|
||||
proposal = get_or_create_proposal(doc_id, TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
update_proposal_status(proposal.id, TENANT, "IN_REVIEW", db_session)
|
||||
db_session.commit()
|
||||
update_proposal_status(proposal.id, TENANT, "APPROVED", db_session)
|
||||
db_session.commit()
|
||||
|
||||
refetched = get_proposal(proposal.id, TENANT, db_session)
|
||||
assert refetched is not None
|
||||
assert refetched.status == "APPROVED"
|
||||
|
||||
|
||||
class TestListAndCountProposals:
|
||||
def test_list_proposals_with_status_filter(self, db_session: Session) -> None:
|
||||
p1 = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
p2 = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
update_proposal_status(p1.id, TENANT, "IN_REVIEW", db_session)
|
||||
db_session.commit()
|
||||
|
||||
in_review = list_proposals(TENANT, db_session, status="IN_REVIEW")
|
||||
in_review_ids = {p.id for p in in_review}
|
||||
assert p1.id in in_review_ids
|
||||
assert p2.id not in in_review_ids
|
||||
|
||||
def test_count_proposals_with_status_filter(self, db_session: Session) -> None:
|
||||
p1 = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
update_proposal_status(p1.id, TENANT, "COMPLETED", db_session)
|
||||
db_session.commit()
|
||||
|
||||
total = count_proposals(TENANT, db_session)
|
||||
completed = count_proposals(TENANT, db_session, status="COMPLETED")
|
||||
|
||||
assert total >= 2
|
||||
assert completed >= 1
|
||||
|
||||
def test_list_proposals_pagination(self, db_session: Session) -> None:
|
||||
for _ in range(5):
|
||||
get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
page = list_proposals(TENANT, db_session, limit=2, offset=0)
|
||||
assert len(page) <= 2
|
||||
@@ -1,448 +0,0 @@
|
||||
"""Integration tests for review run + findings + progress tracking."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.db.models import User
|
||||
from onyx.server.features.proposal_review.db.findings import create_finding
|
||||
from onyx.server.features.proposal_review.db.findings import create_review_run
|
||||
from onyx.server.features.proposal_review.db.findings import get_finding
|
||||
from onyx.server.features.proposal_review.db.findings import get_latest_review_run
|
||||
from onyx.server.features.proposal_review.db.findings import get_review_run
|
||||
from onyx.server.features.proposal_review.db.findings import (
|
||||
list_findings_by_proposal,
|
||||
)
|
||||
from onyx.server.features.proposal_review.db.findings import list_findings_by_run
|
||||
from onyx.server.features.proposal_review.db.proposals import get_or_create_proposal
|
||||
from onyx.server.features.proposal_review.db.rulesets import create_rule
|
||||
from onyx.server.features.proposal_review.db.rulesets import create_ruleset
|
||||
from tests.external_dependency_unit.constants import TEST_TENANT_ID
|
||||
|
||||
TENANT = TEST_TENANT_ID
|
||||
|
||||
|
||||
class TestReviewRun:
|
||||
def test_create_review_run_and_verify_status(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"Review RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Rule 1",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="t1",
|
||||
db_session=db_session,
|
||||
)
|
||||
create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Rule 2",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="t2",
|
||||
db_session=db_session,
|
||||
)
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
db_session.commit()
|
||||
|
||||
run = create_review_run(
|
||||
proposal_id=proposal.id,
|
||||
ruleset_id=rs.id,
|
||||
triggered_by=test_user.id,
|
||||
total_rules=2,
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
assert run.id is not None
|
||||
assert run.proposal_id == proposal.id
|
||||
assert run.ruleset_id == rs.id
|
||||
assert run.triggered_by == test_user.id
|
||||
assert run.total_rules == 2
|
||||
assert run.completed_rules == 0
|
||||
assert run.status == "PENDING"
|
||||
|
||||
def test_get_review_run(self, db_session: Session, test_user: User) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
run = create_review_run(
|
||||
proposal_id=proposal.id,
|
||||
ruleset_id=rs.id,
|
||||
triggered_by=test_user.id,
|
||||
total_rules=1,
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
fetched = get_review_run(run.id, db_session)
|
||||
assert fetched is not None
|
||||
assert fetched.id == run.id
|
||||
|
||||
def test_get_review_run_returns_none_for_nonexistent(
|
||||
self, db_session: Session
|
||||
) -> None:
|
||||
assert get_review_run(uuid4(), db_session) is None
|
||||
|
||||
def test_get_latest_review_run(self, db_session: Session, test_user: User) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
|
||||
create_review_run(
|
||||
proposal_id=proposal.id,
|
||||
ruleset_id=rs.id,
|
||||
triggered_by=test_user.id,
|
||||
total_rules=1,
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
run2 = create_review_run(
|
||||
proposal_id=proposal.id,
|
||||
ruleset_id=rs.id,
|
||||
triggered_by=test_user.id,
|
||||
total_rules=2,
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
latest = get_latest_review_run(proposal.id, db_session)
|
||||
assert latest is not None
|
||||
assert latest.id == run2.id
|
||||
|
||||
def test_increment_completed_rules_tracks_progress(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"Progress RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
run = create_review_run(
|
||||
proposal_id=proposal.id,
|
||||
ruleset_id=rs.id,
|
||||
triggered_by=test_user.id,
|
||||
total_rules=3,
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
# Simulate progress by incrementing completed_rules directly
|
||||
run.completed_rules = 1
|
||||
db_session.flush()
|
||||
db_session.commit()
|
||||
|
||||
fetched = get_review_run(run.id, db_session)
|
||||
assert fetched is not None
|
||||
assert fetched.completed_rules == 1
|
||||
assert fetched.total_rules == 3
|
||||
|
||||
run.completed_rules = 3
|
||||
db_session.flush()
|
||||
db_session.commit()
|
||||
|
||||
fetched = get_review_run(run.id, db_session)
|
||||
assert fetched is not None
|
||||
assert fetched.completed_rules == 3
|
||||
|
||||
|
||||
class TestFindings:
|
||||
def test_create_finding_and_retrieve(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"Findings RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
rule = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Budget Rule",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="Check budget",
|
||||
db_session=db_session,
|
||||
)
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
run = create_review_run(
|
||||
proposal_id=proposal.id,
|
||||
ruleset_id=rs.id,
|
||||
triggered_by=test_user.id,
|
||||
total_rules=1,
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
finding = create_finding(
|
||||
proposal_id=proposal.id,
|
||||
rule_id=rule.id,
|
||||
review_run_id=run.id,
|
||||
verdict="PASS",
|
||||
db_session=db_session,
|
||||
confidence="HIGH",
|
||||
evidence="Budget is $500k",
|
||||
explanation="Under the $1M cap",
|
||||
llm_model="gpt-4",
|
||||
llm_tokens_used=1500,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
fetched = get_finding(finding.id, db_session)
|
||||
assert fetched is not None
|
||||
assert fetched.verdict == "PASS"
|
||||
assert fetched.confidence == "HIGH"
|
||||
assert fetched.evidence == "Budget is $500k"
|
||||
assert fetched.llm_model == "gpt-4"
|
||||
assert fetched.llm_tokens_used == 1500
|
||||
assert fetched.rule is not None
|
||||
assert fetched.rule.name == "Budget Rule"
|
||||
|
||||
def test_list_findings_by_proposal(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"List Findings RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
rule1 = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="R1",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="t1",
|
||||
db_session=db_session,
|
||||
)
|
||||
rule2 = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="R2",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="t2",
|
||||
db_session=db_session,
|
||||
)
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
run = create_review_run(
|
||||
proposal_id=proposal.id,
|
||||
ruleset_id=rs.id,
|
||||
triggered_by=test_user.id,
|
||||
total_rules=2,
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
create_finding(
|
||||
proposal_id=proposal.id,
|
||||
rule_id=rule1.id,
|
||||
review_run_id=run.id,
|
||||
verdict="PASS",
|
||||
db_session=db_session,
|
||||
)
|
||||
create_finding(
|
||||
proposal_id=proposal.id,
|
||||
rule_id=rule2.id,
|
||||
review_run_id=run.id,
|
||||
verdict="FAIL",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
findings = list_findings_by_proposal(proposal.id, db_session)
|
||||
assert len(findings) == 2
|
||||
verdicts = {f.verdict for f in findings}
|
||||
assert verdicts == {"PASS", "FAIL"}
|
||||
|
||||
def test_list_findings_by_run_filters_correctly(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"Run Filter RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
rule = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="R",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="t",
|
||||
db_session=db_session,
|
||||
)
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
run1 = create_review_run(
|
||||
proposal_id=proposal.id,
|
||||
ruleset_id=rs.id,
|
||||
triggered_by=test_user.id,
|
||||
total_rules=1,
|
||||
db_session=db_session,
|
||||
)
|
||||
run2 = create_review_run(
|
||||
proposal_id=proposal.id,
|
||||
ruleset_id=rs.id,
|
||||
triggered_by=test_user.id,
|
||||
total_rules=1,
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
create_finding(
|
||||
proposal_id=proposal.id,
|
||||
rule_id=rule.id,
|
||||
review_run_id=run1.id,
|
||||
verdict="PASS",
|
||||
db_session=db_session,
|
||||
)
|
||||
create_finding(
|
||||
proposal_id=proposal.id,
|
||||
rule_id=rule.id,
|
||||
review_run_id=run2.id,
|
||||
verdict="FAIL",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
run1_findings = list_findings_by_run(run1.id, db_session)
|
||||
assert len(run1_findings) == 1
|
||||
assert run1_findings[0].verdict == "PASS"
|
||||
|
||||
run2_findings = list_findings_by_run(run2.id, db_session)
|
||||
assert len(run2_findings) == 1
|
||||
assert run2_findings[0].verdict == "FAIL"
|
||||
|
||||
def test_list_findings_by_proposal_with_run_id_filter(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"Filter RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
rule = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="R",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="t",
|
||||
db_session=db_session,
|
||||
)
|
||||
proposal = get_or_create_proposal(f"doc-{uuid4().hex[:8]}", TENANT, db_session)
|
||||
run1 = create_review_run(
|
||||
proposal_id=proposal.id,
|
||||
ruleset_id=rs.id,
|
||||
triggered_by=test_user.id,
|
||||
total_rules=1,
|
||||
db_session=db_session,
|
||||
)
|
||||
run2 = create_review_run(
|
||||
proposal_id=proposal.id,
|
||||
ruleset_id=rs.id,
|
||||
triggered_by=test_user.id,
|
||||
total_rules=1,
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
create_finding(
|
||||
proposal_id=proposal.id,
|
||||
rule_id=rule.id,
|
||||
review_run_id=run1.id,
|
||||
verdict="PASS",
|
||||
db_session=db_session,
|
||||
)
|
||||
create_finding(
|
||||
proposal_id=proposal.id,
|
||||
rule_id=rule.id,
|
||||
review_run_id=run2.id,
|
||||
verdict="FAIL",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
# All findings for proposal
|
||||
all_findings = list_findings_by_proposal(proposal.id, db_session)
|
||||
assert len(all_findings) == 2
|
||||
|
||||
# Filtered by run
|
||||
filtered = list_findings_by_proposal(
|
||||
proposal.id, db_session, review_run_id=run1.id
|
||||
)
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].verdict == "PASS"
|
||||
|
||||
def test_get_finding_returns_none_for_nonexistent(
|
||||
self, db_session: Session
|
||||
) -> None:
|
||||
assert get_finding(uuid4(), db_session) is None
|
||||
|
||||
def test_full_review_flow_end_to_end(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
"""Create ruleset with rules -> proposal -> run -> findings -> verify."""
|
||||
# Setup
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"E2E RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
rules = []
|
||||
for i in range(3):
|
||||
r = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name=f"E2E Rule {i}",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template=f"Check {i}: {{{{proposal_text}}}}",
|
||||
db_session=db_session,
|
||||
)
|
||||
rules.append(r)
|
||||
|
||||
proposal = get_or_create_proposal(
|
||||
f"doc-e2e-{uuid4().hex[:8]}", TENANT, db_session
|
||||
)
|
||||
run = create_review_run(
|
||||
proposal_id=proposal.id,
|
||||
ruleset_id=rs.id,
|
||||
triggered_by=test_user.id,
|
||||
total_rules=3,
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
# Create findings for each rule
|
||||
verdicts = ["PASS", "FAIL", "PASS"]
|
||||
for rule, verdict in zip(rules, verdicts):
|
||||
create_finding(
|
||||
proposal_id=proposal.id,
|
||||
rule_id=rule.id,
|
||||
review_run_id=run.id,
|
||||
verdict=verdict,
|
||||
db_session=db_session,
|
||||
confidence="HIGH",
|
||||
)
|
||||
run.completed_rules += 1
|
||||
db_session.flush()
|
||||
db_session.commit()
|
||||
|
||||
# Verify
|
||||
fetched_run = get_review_run(run.id, db_session)
|
||||
assert fetched_run is not None
|
||||
assert fetched_run.completed_rules == 3
|
||||
assert fetched_run.total_rules == 3
|
||||
|
||||
findings = list_findings_by_run(run.id, db_session)
|
||||
assert len(findings) == 3
|
||||
assert {f.verdict for f in findings} == {"PASS", "FAIL"}
|
||||
@@ -1,424 +0,0 @@
|
||||
"""Integration tests for ruleset + rule CRUD DB operations."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.db.models import User
|
||||
from onyx.server.features.proposal_review.db.rulesets import bulk_update_rules
|
||||
from onyx.server.features.proposal_review.db.rulesets import count_active_rules
|
||||
from onyx.server.features.proposal_review.db.rulesets import create_rule
|
||||
from onyx.server.features.proposal_review.db.rulesets import create_ruleset
|
||||
from onyx.server.features.proposal_review.db.rulesets import delete_rule
|
||||
from onyx.server.features.proposal_review.db.rulesets import delete_ruleset
|
||||
from onyx.server.features.proposal_review.db.rulesets import get_rule
|
||||
from onyx.server.features.proposal_review.db.rulesets import get_ruleset
|
||||
from onyx.server.features.proposal_review.db.rulesets import list_rules_by_ruleset
|
||||
from onyx.server.features.proposal_review.db.rulesets import list_rulesets
|
||||
from onyx.server.features.proposal_review.db.rulesets import update_rule
|
||||
from onyx.server.features.proposal_review.db.rulesets import update_ruleset
|
||||
from tests.external_dependency_unit.constants import TEST_TENANT_ID
|
||||
|
||||
TENANT = TEST_TENANT_ID
|
||||
|
||||
|
||||
class TestRulesetCRUD:
|
||||
def test_create_ruleset_appears_in_list(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"Compliance v1 {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
description="First ruleset",
|
||||
created_by=test_user.id,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
rulesets = list_rulesets(TENANT, db_session)
|
||||
ids = [r.id for r in rulesets]
|
||||
assert rs.id in ids
|
||||
|
||||
def test_create_ruleset_with_rules_returned_together(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"RS with rules {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Rule A",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="Check A: {{proposal_text}}",
|
||||
db_session=db_session,
|
||||
)
|
||||
create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Rule B",
|
||||
rule_type="METADATA_CHECK",
|
||||
prompt_template="Check B: {{proposal_text}}",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
fetched = get_ruleset(rs.id, TENANT, db_session)
|
||||
assert fetched is not None
|
||||
assert len(fetched.rules) == 2
|
||||
rule_names = {r.name for r in fetched.rules}
|
||||
assert rule_names == {"Rule A", "Rule B"}
|
||||
|
||||
def test_list_rulesets_active_only_filter(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs_active = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"Active RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
rs_inactive = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"Inactive RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
update_ruleset(rs_inactive.id, TENANT, db_session, is_active=False)
|
||||
db_session.commit()
|
||||
|
||||
active_rulesets = list_rulesets(TENANT, db_session, active_only=True)
|
||||
active_ids = {r.id for r in active_rulesets}
|
||||
assert rs_active.id in active_ids
|
||||
assert rs_inactive.id not in active_ids
|
||||
|
||||
def test_update_ruleset_changes_persist(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"Original {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
updated = update_ruleset(
|
||||
rs.id,
|
||||
TENANT,
|
||||
db_session,
|
||||
name="Updated Name",
|
||||
description="New desc",
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
assert updated is not None
|
||||
assert updated.name == "Updated Name"
|
||||
assert updated.description == "New desc"
|
||||
|
||||
refetched = get_ruleset(rs.id, TENANT, db_session)
|
||||
assert refetched is not None
|
||||
assert refetched.name == "Updated Name"
|
||||
|
||||
def test_update_nonexistent_ruleset_returns_none(self, db_session: Session) -> None:
|
||||
result = update_ruleset(uuid4(), TENANT, db_session, name="nope")
|
||||
assert result is None
|
||||
|
||||
def test_delete_ruleset_returns_false_for_nonexistent(
|
||||
self, db_session: Session
|
||||
) -> None:
|
||||
assert delete_ruleset(uuid4(), TENANT, db_session) is False
|
||||
|
||||
def test_set_default_ruleset_clears_previous_default(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs1 = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"Default 1 {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
is_default=True,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
db_session.commit()
|
||||
assert rs1.is_default is True
|
||||
|
||||
rs2 = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"Default 2 {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
is_default=True,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
# rs1 should no longer be default
|
||||
db_session.refresh(rs1)
|
||||
assert rs1.is_default is False
|
||||
assert rs2.is_default is True
|
||||
|
||||
def test_delete_ruleset_cascade_deletes_rules(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"RS to delete {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
r1 = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Doomed Rule",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="{{proposal_text}}",
|
||||
db_session=db_session,
|
||||
)
|
||||
rule_id = r1.id
|
||||
db_session.commit()
|
||||
|
||||
assert delete_ruleset(rs.id, TENANT, db_session) is True
|
||||
db_session.commit()
|
||||
|
||||
assert get_ruleset(rs.id, TENANT, db_session) is None
|
||||
assert get_rule(rule_id, db_session) is None
|
||||
|
||||
|
||||
class TestRuleCRUD:
|
||||
def test_create_and_get_rule(self, db_session: Session, test_user: User) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"RS for rules {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
rule = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Budget Cap",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="Check budget cap: {{proposal_text}}",
|
||||
db_session=db_session,
|
||||
description="Verify budget < $1M",
|
||||
category="FINANCIAL",
|
||||
is_hard_stop=True,
|
||||
priority=10,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
fetched = get_rule(rule.id, db_session)
|
||||
assert fetched is not None
|
||||
assert fetched.name == "Budget Cap"
|
||||
assert fetched.rule_type == "DOCUMENT_CHECK"
|
||||
assert fetched.is_hard_stop is True
|
||||
assert fetched.priority == 10
|
||||
assert fetched.category == "FINANCIAL"
|
||||
|
||||
def test_update_rule_prompt_template_and_is_active(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
rule = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Rule X",
|
||||
rule_type="CUSTOM_NL",
|
||||
prompt_template="old template",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
assert rule.is_active is True
|
||||
|
||||
updated = update_rule(
|
||||
rule.id,
|
||||
db_session,
|
||||
prompt_template="new template: {{proposal_text}}",
|
||||
is_active=False,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
assert updated is not None
|
||||
assert updated.prompt_template == "new template: {{proposal_text}}"
|
||||
assert updated.is_active is False
|
||||
|
||||
refetched = get_rule(rule.id, db_session)
|
||||
assert refetched is not None
|
||||
assert refetched.prompt_template == "new template: {{proposal_text}}"
|
||||
assert refetched.is_active is False
|
||||
|
||||
def test_update_nonexistent_rule_returns_none(self, db_session: Session) -> None:
|
||||
result = update_rule(uuid4(), db_session, name="nope")
|
||||
assert result is None
|
||||
|
||||
def test_delete_rule(self, db_session: Session, test_user: User) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
rule = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Temp Rule",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="{{proposal_text}}",
|
||||
db_session=db_session,
|
||||
)
|
||||
rule_id = rule.id
|
||||
db_session.commit()
|
||||
|
||||
assert delete_rule(rule_id, db_session) is True
|
||||
db_session.commit()
|
||||
assert get_rule(rule_id, db_session) is None
|
||||
|
||||
def test_delete_nonexistent_rule_returns_false(self, db_session: Session) -> None:
|
||||
assert delete_rule(uuid4(), db_session) is False
|
||||
|
||||
def test_list_rules_by_ruleset_respects_active_only(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
r_active = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Active",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="{{proposal_text}}",
|
||||
db_session=db_session,
|
||||
)
|
||||
r_inactive = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Inactive",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="{{proposal_text}}",
|
||||
db_session=db_session,
|
||||
)
|
||||
update_rule(r_inactive.id, db_session, is_active=False)
|
||||
db_session.commit()
|
||||
|
||||
all_rules = list_rules_by_ruleset(rs.id, db_session)
|
||||
assert len(all_rules) == 2
|
||||
|
||||
active_rules = list_rules_by_ruleset(rs.id, db_session, active_only=True)
|
||||
assert len(active_rules) == 1
|
||||
assert active_rules[0].id == r_active.id
|
||||
|
||||
def test_bulk_activate_rules_only_affects_specified_rules(
|
||||
self, db_session: Session, test_user: User
|
||||
) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"Bulk test RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
|
||||
# Create 5 rules, all initially active
|
||||
rules = []
|
||||
for i in range(5):
|
||||
r = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name=f"Rule {i}",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template=f"Template {i}",
|
||||
db_session=db_session,
|
||||
)
|
||||
rules.append(r)
|
||||
db_session.commit()
|
||||
|
||||
# Deactivate all 5
|
||||
all_ids = [r.id for r in rules]
|
||||
bulk_update_rules(all_ids, "deactivate", rs.id, db_session)
|
||||
db_session.commit()
|
||||
|
||||
# Verify all are inactive
|
||||
assert count_active_rules(rs.id, db_session) == 0
|
||||
|
||||
# Bulk activate only the first 3
|
||||
activate_ids = [rules[0].id, rules[1].id, rules[2].id]
|
||||
count = bulk_update_rules(activate_ids, "activate", rs.id, db_session)
|
||||
db_session.commit()
|
||||
|
||||
assert count == 3
|
||||
assert count_active_rules(rs.id, db_session) == 3
|
||||
|
||||
# Verify exactly which are active
|
||||
active_rules = list_rules_by_ruleset(rs.id, db_session, active_only=True)
|
||||
active_ids_result = {r.id for r in active_rules}
|
||||
assert active_ids_result == set(activate_ids)
|
||||
|
||||
def test_bulk_delete_rules(self, db_session: Session, test_user: User) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"Bulk delete RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
r1 = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Keep",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="keep",
|
||||
db_session=db_session,
|
||||
)
|
||||
r2 = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Delete 1",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="del1",
|
||||
db_session=db_session,
|
||||
)
|
||||
r3 = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Delete 2",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="del2",
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
count = bulk_update_rules([r2.id, r3.id], "delete", rs.id, db_session)
|
||||
db_session.commit()
|
||||
|
||||
assert count == 2
|
||||
remaining = list_rules_by_ruleset(rs.id, db_session)
|
||||
assert len(remaining) == 1
|
||||
assert remaining[0].id == r1.id
|
||||
|
||||
def test_bulk_update_unknown_action_raises_error(self, db_session: Session) -> None:
|
||||
import pytest as _pytest
|
||||
|
||||
with _pytest.raises(ValueError, match="Unknown bulk action"):
|
||||
bulk_update_rules([uuid4()], "explode", uuid4(), db_session)
|
||||
|
||||
def test_count_active_rules(self, db_session: Session, test_user: User) -> None:
|
||||
rs = create_ruleset(
|
||||
tenant_id=TENANT,
|
||||
name=f"Count RS {uuid4().hex[:6]}",
|
||||
db_session=db_session,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Active1",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="t1",
|
||||
db_session=db_session,
|
||||
)
|
||||
r2 = create_rule(
|
||||
ruleset_id=rs.id,
|
||||
name="Inactive1",
|
||||
rule_type="DOCUMENT_CHECK",
|
||||
prompt_template="t2",
|
||||
db_session=db_session,
|
||||
)
|
||||
update_rule(r2.id, db_session, is_active=False)
|
||||
db_session.commit()
|
||||
|
||||
assert count_active_rules(rs.id, db_session) == 1
|
||||
@@ -9,7 +9,6 @@ from unittest.mock import patch
|
||||
from ee.onyx.db.license import check_seat_availability
|
||||
from ee.onyx.db.license import delete_license
|
||||
from ee.onyx.db.license import get_license
|
||||
from ee.onyx.db.license import get_used_seats
|
||||
from ee.onyx.db.license import upsert_license
|
||||
from ee.onyx.server.license.models import LicenseMetadata
|
||||
from ee.onyx.server.license.models import LicenseSource
|
||||
@@ -215,43 +214,3 @@ class TestCheckSeatAvailabilityMultiTenant:
|
||||
assert result.available is False
|
||||
assert result.error_message is not None
|
||||
mock_tenant_count.assert_called_once_with("tenant-abc")
|
||||
|
||||
|
||||
class TestGetUsedSeatsAccountTypeFiltering:
|
||||
"""Verify get_used_seats query excludes SERVICE_ACCOUNT but includes BOT."""
|
||||
|
||||
@patch("ee.onyx.db.license.MULTI_TENANT", False)
|
||||
@patch("onyx.db.engine.sql_engine.get_session_with_current_tenant")
|
||||
def test_excludes_service_accounts(self, mock_get_session: MagicMock) -> None:
|
||||
"""SERVICE_ACCOUNT users should not count toward seats."""
|
||||
mock_session = MagicMock()
|
||||
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||
mock_session.execute.return_value.scalar.return_value = 5
|
||||
|
||||
result = get_used_seats()
|
||||
|
||||
assert result == 5
|
||||
# Inspect the compiled query to verify account_type filter
|
||||
call_args = mock_session.execute.call_args
|
||||
query = call_args[0][0]
|
||||
compiled = str(query.compile(compile_kwargs={"literal_binds": True}))
|
||||
assert "SERVICE_ACCOUNT" in compiled
|
||||
# BOT should NOT be excluded
|
||||
assert "BOT" not in compiled
|
||||
|
||||
@patch("ee.onyx.db.license.MULTI_TENANT", False)
|
||||
@patch("onyx.db.engine.sql_engine.get_session_with_current_tenant")
|
||||
def test_still_excludes_ext_perm_user(self, mock_get_session: MagicMock) -> None:
|
||||
"""EXT_PERM_USER exclusion should still be present."""
|
||||
mock_session = MagicMock()
|
||||
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||
mock_session.execute.return_value.scalar.return_value = 3
|
||||
|
||||
get_used_seats()
|
||||
|
||||
call_args = mock_session.execute.call_args
|
||||
query = call_args[0][0]
|
||||
compiled = str(query.compile(compile_kwargs={"literal_binds": True}))
|
||||
assert "EXT_PERM_USER" in compiled
|
||||
|
||||
@@ -1,659 +0,0 @@
|
||||
"""Tests for Jira connector enhancements: custom field extraction and attachment fetching."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from jira import JIRA
|
||||
from jira.resources import CustomFieldOption
|
||||
from jira.resources import User
|
||||
|
||||
from onyx.connectors.jira.connector import _MAX_ATTACHMENT_SIZE_BYTES
|
||||
from onyx.connectors.jira.connector import JiraConnector
|
||||
from onyx.connectors.jira.connector import process_jira_issue
|
||||
from onyx.connectors.jira.utils import CustomFieldExtractor
|
||||
from onyx.connectors.models import ConnectorFailure
|
||||
from onyx.connectors.models import Document
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FieldsBag:
|
||||
"""A plain object whose __dict__ is exactly what we put in it.
|
||||
|
||||
MagicMock pollutes __dict__ with internal bookkeeping, which breaks
|
||||
CustomFieldExtractor.get_issue_custom_fields (it iterates __dict__).
|
||||
This class gives us full control over the attribute namespace.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
for k, v in kwargs.items():
|
||||
object.__setattr__(self, k, v)
|
||||
|
||||
|
||||
def _make_mock_issue(
|
||||
key: str = "TEST-1",
|
||||
summary: str = "Test Issue",
|
||||
description: str = "Test description",
|
||||
labels: list[str] | None = None,
|
||||
extra_fields: dict[str, Any] | None = None,
|
||||
attachments: list[Any] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Build a mock Issue with standard fields wired up.
|
||||
|
||||
Uses _FieldsBag for ``issue.fields`` so that ``fields.__dict__``
|
||||
contains only the Jira field attributes (no MagicMock internals).
|
||||
"""
|
||||
# Build sub-objects using SimpleNamespace so attribute access
|
||||
# returns real values instead of auto-generated MagicMock objects.
|
||||
reporter = SimpleNamespace(
|
||||
displayName="Reporter Name",
|
||||
emailAddress="reporter@example.com",
|
||||
)
|
||||
assignee = SimpleNamespace(
|
||||
displayName="Assignee Name",
|
||||
emailAddress="assignee@example.com",
|
||||
)
|
||||
priority = SimpleNamespace(name="High")
|
||||
status = SimpleNamespace(name="Open")
|
||||
project = SimpleNamespace(key="TEST", name="Test Project")
|
||||
issuetype = SimpleNamespace(name="Bug")
|
||||
comment = SimpleNamespace(comments=[])
|
||||
|
||||
field_kwargs: dict[str, Any] = {
|
||||
"description": description,
|
||||
"summary": summary,
|
||||
"labels": labels or [],
|
||||
"updated": "2024-01-01T00:00:00+0000",
|
||||
"reporter": reporter,
|
||||
"assignee": assignee,
|
||||
"priority": priority,
|
||||
"status": status,
|
||||
"resolution": None,
|
||||
"project": project,
|
||||
"issuetype": issuetype,
|
||||
"parent": None,
|
||||
"created": "2024-01-01T00:00:00+0000",
|
||||
"duedate": None,
|
||||
"resolutiondate": None,
|
||||
"comment": comment,
|
||||
"attachment": attachments if attachments is not None else [],
|
||||
}
|
||||
if extra_fields:
|
||||
field_kwargs.update(extra_fields)
|
||||
|
||||
fields = _FieldsBag(**field_kwargs)
|
||||
|
||||
# Use _FieldsBag for the issue itself too, then add the attributes
|
||||
# that process_jira_issue needs. This prevents MagicMock from
|
||||
# auto-creating attributes for field names like "reporter", which
|
||||
# would shadow the real values on issue.fields.
|
||||
issue = _FieldsBag(
|
||||
fields=fields,
|
||||
key=key,
|
||||
raw={"fields": {"description": description}},
|
||||
)
|
||||
return issue # type: ignore[return-value]
|
||||
|
||||
|
||||
def _make_attachment(
|
||||
attachment_id: str = "att-1",
|
||||
filename: str = "report.pdf",
|
||||
size: int = 1024,
|
||||
content_url: str | None = "https://jira.example.com/attachment/att-1",
|
||||
mime_type: str = "application/pdf",
|
||||
created: str | None = "2026-01-15T10:00:00.000+0000",
|
||||
download_content: bytes = b"binary content",
|
||||
download_raises: Exception | None = None,
|
||||
) -> MagicMock:
|
||||
"""Build a mock Jira attachment resource."""
|
||||
att = MagicMock()
|
||||
att.id = attachment_id
|
||||
att.filename = filename
|
||||
att.size = size
|
||||
att.content = content_url
|
||||
att.mimeType = mime_type
|
||||
att.created = created
|
||||
if download_raises:
|
||||
att.get.side_effect = download_raises
|
||||
else:
|
||||
att.get.return_value = download_content
|
||||
return att
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Test 1: Custom Field Extraction
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestCustomFieldExtractorGetAllCustomFields:
|
||||
def test_returns_only_custom_fields(self) -> None:
|
||||
"""Given a mix of standard and custom fields, only custom fields are returned."""
|
||||
mock_client = MagicMock(spec=JIRA)
|
||||
mock_client.fields.return_value = [
|
||||
{"id": "summary", "name": "Summary", "custom": False},
|
||||
{"id": "customfield_10001", "name": "Sprint", "custom": True},
|
||||
{"id": "status", "name": "Status", "custom": False},
|
||||
{"id": "customfield_10002", "name": "Story Points", "custom": True},
|
||||
]
|
||||
|
||||
result = CustomFieldExtractor.get_all_custom_fields(mock_client)
|
||||
|
||||
assert result == {
|
||||
"customfield_10001": "Sprint",
|
||||
"customfield_10002": "Story Points",
|
||||
}
|
||||
assert "summary" not in result
|
||||
assert "status" not in result
|
||||
|
||||
def test_returns_empty_dict_when_no_custom_fields(self) -> None:
|
||||
"""When no custom fields exist, an empty dict is returned."""
|
||||
mock_client = MagicMock(spec=JIRA)
|
||||
mock_client.fields.return_value = [
|
||||
{"id": "summary", "name": "Summary", "custom": False},
|
||||
]
|
||||
|
||||
result = CustomFieldExtractor.get_all_custom_fields(mock_client)
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestCustomFieldExtractorGetIssueCustomFields:
|
||||
def test_string_value_extracted(self) -> None:
|
||||
"""String custom field values pass through as-is."""
|
||||
issue = _make_mock_issue(extra_fields={"customfield_10001": "v2024.1"})
|
||||
mapping = {"customfield_10001": "Release Version"}
|
||||
|
||||
result = CustomFieldExtractor.get_issue_custom_fields(issue, mapping)
|
||||
assert result == {"Release Version": "v2024.1"}
|
||||
|
||||
def test_custom_field_option_value_extracted_as_string(self) -> None:
|
||||
"""CustomFieldOption objects are converted via .value."""
|
||||
option = MagicMock(spec=CustomFieldOption)
|
||||
option.value = "Critical Path"
|
||||
|
||||
issue = _make_mock_issue(extra_fields={"customfield_10002": option})
|
||||
mapping = {"customfield_10002": "Category"}
|
||||
|
||||
result = CustomFieldExtractor.get_issue_custom_fields(issue, mapping)
|
||||
assert result == {"Category": "Critical Path"}
|
||||
|
||||
def test_user_value_extracted_as_display_name(self) -> None:
|
||||
"""User objects are converted via .displayName."""
|
||||
user = MagicMock(spec=User)
|
||||
user.displayName = "Alice Johnson"
|
||||
|
||||
issue = _make_mock_issue(extra_fields={"customfield_10003": user})
|
||||
mapping = {"customfield_10003": "Reviewer"}
|
||||
|
||||
result = CustomFieldExtractor.get_issue_custom_fields(issue, mapping)
|
||||
assert result == {"Reviewer": "Alice Johnson"}
|
||||
|
||||
def test_list_value_extracted_as_space_joined_string(self) -> None:
|
||||
"""Lists of values are space-joined after individual processing."""
|
||||
opt1 = MagicMock(spec=CustomFieldOption)
|
||||
opt1.value = "Backend"
|
||||
opt2 = MagicMock(spec=CustomFieldOption)
|
||||
opt2.value = "Frontend"
|
||||
|
||||
issue = _make_mock_issue(extra_fields={"customfield_10004": [opt1, opt2]})
|
||||
mapping = {"customfield_10004": "Components"}
|
||||
|
||||
result = CustomFieldExtractor.get_issue_custom_fields(issue, mapping)
|
||||
assert result == {"Components": "Backend Frontend"}
|
||||
|
||||
def test_none_value_excluded(self) -> None:
|
||||
"""None custom field values are excluded from the result."""
|
||||
issue = _make_mock_issue(extra_fields={"customfield_10005": None})
|
||||
mapping = {"customfield_10005": "Optional Field"}
|
||||
|
||||
result = CustomFieldExtractor.get_issue_custom_fields(issue, mapping)
|
||||
assert "Optional Field" not in result
|
||||
|
||||
def test_value_exceeding_max_length_excluded(self) -> None:
|
||||
"""Values longer than max_value_length are excluded."""
|
||||
long_value = "x" * 300 # exceeds the default 250 limit
|
||||
issue = _make_mock_issue(extra_fields={"customfield_10006": long_value})
|
||||
mapping = {"customfield_10006": "Long Description"}
|
||||
|
||||
result = CustomFieldExtractor.get_issue_custom_fields(issue, mapping)
|
||||
assert "Long Description" not in result
|
||||
|
||||
def test_value_at_exact_max_length_excluded(self) -> None:
|
||||
"""Values at exactly max_value_length are excluded (< not <=)."""
|
||||
exact_value = "x" * 250 # exactly 250, not < 250
|
||||
issue = _make_mock_issue(extra_fields={"customfield_10007": exact_value})
|
||||
mapping = {"customfield_10007": "Edge Case"}
|
||||
|
||||
result = CustomFieldExtractor.get_issue_custom_fields(issue, mapping)
|
||||
assert "Edge Case" not in result
|
||||
|
||||
def test_value_just_under_max_length_included(self) -> None:
|
||||
"""Values just under max_value_length are included."""
|
||||
under_value = "x" * 249
|
||||
issue = _make_mock_issue(extra_fields={"customfield_10008": under_value})
|
||||
mapping = {"customfield_10008": "Just Under"}
|
||||
|
||||
result = CustomFieldExtractor.get_issue_custom_fields(issue, mapping)
|
||||
assert result == {"Just Under": under_value}
|
||||
|
||||
def test_unmapped_custom_fields_ignored(self) -> None:
|
||||
"""Custom fields not in the mapping dict are not included."""
|
||||
issue = _make_mock_issue(
|
||||
extra_fields={
|
||||
"customfield_10001": "mapped_value",
|
||||
"customfield_99999": "unmapped_value",
|
||||
}
|
||||
)
|
||||
mapping = {"customfield_10001": "Mapped Field"}
|
||||
|
||||
result = CustomFieldExtractor.get_issue_custom_fields(issue, mapping)
|
||||
assert "Mapped Field" in result
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestProcessJiraIssueWithCustomFields:
|
||||
def test_custom_fields_added_to_metadata(self) -> None:
|
||||
"""When custom_fields_mapping is provided, custom fields appear in metadata."""
|
||||
option = MagicMock(spec=CustomFieldOption)
|
||||
option.value = "High Impact"
|
||||
|
||||
issue = _make_mock_issue(
|
||||
extra_fields={
|
||||
"customfield_10001": "Sprint 42",
|
||||
"customfield_10002": option,
|
||||
}
|
||||
)
|
||||
mapping = {
|
||||
"customfield_10001": "Sprint",
|
||||
"customfield_10002": "Impact Level",
|
||||
}
|
||||
|
||||
doc = process_jira_issue(
|
||||
jira_base_url="https://jira.example.com",
|
||||
issue=issue,
|
||||
custom_fields_mapping=mapping,
|
||||
)
|
||||
|
||||
assert doc is not None
|
||||
assert doc.metadata["Sprint"] == "Sprint 42"
|
||||
assert doc.metadata["Impact Level"] == "High Impact"
|
||||
# Standard fields should still be present
|
||||
assert doc.metadata["key"] == "TEST-1"
|
||||
|
||||
def test_no_custom_fields_when_mapping_is_none(self) -> None:
|
||||
"""When custom_fields_mapping is None, no custom fields in metadata."""
|
||||
issue = _make_mock_issue(
|
||||
extra_fields={"customfield_10001": "should_not_appear"}
|
||||
)
|
||||
|
||||
doc = process_jira_issue(
|
||||
jira_base_url="https://jira.example.com",
|
||||
issue=issue,
|
||||
custom_fields_mapping=None,
|
||||
)
|
||||
|
||||
assert doc is not None
|
||||
# The custom field name should not appear since we didn't provide a mapping
|
||||
assert "customfield_10001" not in doc.metadata
|
||||
|
||||
def test_custom_field_extraction_failure_does_not_break_processing(self) -> None:
|
||||
"""If custom field extraction raises, the document is still returned."""
|
||||
issue = _make_mock_issue()
|
||||
mapping = {"customfield_10001": "Broken Field"}
|
||||
|
||||
with patch.object(
|
||||
CustomFieldExtractor,
|
||||
"get_issue_custom_fields",
|
||||
side_effect=RuntimeError("extraction failed"),
|
||||
):
|
||||
doc = process_jira_issue(
|
||||
jira_base_url="https://jira.example.com",
|
||||
issue=issue,
|
||||
custom_fields_mapping=mapping,
|
||||
)
|
||||
|
||||
assert doc is not None
|
||||
# The document should still have standard metadata
|
||||
assert doc.metadata["key"] == "TEST-1"
|
||||
# The broken custom field should not have leaked into metadata
|
||||
assert "Broken Field" not in doc.metadata
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Test 2: Attachment Fetching
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestProcessAttachments:
|
||||
"""Tests for JiraConnector._process_attachments."""
|
||||
|
||||
def _make_connector(self, fetch_attachments: bool = True) -> JiraConnector:
|
||||
"""Create a JiraConnector wired with a mock client."""
|
||||
connector = JiraConnector(
|
||||
jira_base_url="https://jira.example.com",
|
||||
project_key="TEST",
|
||||
fetch_attachments=fetch_attachments,
|
||||
)
|
||||
# Don't use spec=JIRA because _process_attachments accesses
|
||||
# the private _session attribute, which spec blocks.
|
||||
mock_client = MagicMock()
|
||||
mock_client._options = {"rest_api_version": "2"}
|
||||
mock_client.client_info.return_value = "https://jira.example.com"
|
||||
connector._jira_client = mock_client
|
||||
return connector
|
||||
|
||||
@patch("onyx.connectors.jira.connector.extract_file_text")
|
||||
def test_happy_path_two_attachments(self, mock_extract: MagicMock) -> None:
|
||||
"""Two normal attachments yield two Documents with correct structure."""
|
||||
mock_extract.side_effect = ["Text from report", "Text from spec"]
|
||||
|
||||
att1 = _make_attachment(
|
||||
attachment_id="att-1",
|
||||
filename="report.pdf",
|
||||
size=1024,
|
||||
download_content=b"report bytes",
|
||||
)
|
||||
att2 = _make_attachment(
|
||||
attachment_id="att-2",
|
||||
filename="spec.docx",
|
||||
size=2048,
|
||||
content_url="https://jira.example.com/attachment/att-2",
|
||||
mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
download_content=b"spec bytes",
|
||||
)
|
||||
issue = _make_mock_issue(key="TEST-42", attachments=[att1, att2])
|
||||
|
||||
connector = self._make_connector()
|
||||
|
||||
results = list(
|
||||
connector._process_attachments(issue, parent_hierarchy_raw_node_id="TEST")
|
||||
)
|
||||
|
||||
docs = [r for r in results if isinstance(r, Document)]
|
||||
assert len(docs) == 2
|
||||
|
||||
# First attachment
|
||||
assert docs[0].id == "https://jira.example.com/browse/TEST-42/attachments/att-1"
|
||||
assert docs[0].title == "report.pdf"
|
||||
assert docs[0].metadata["parent_ticket"] == "TEST-42"
|
||||
assert docs[0].metadata["attachment_filename"] == "report.pdf"
|
||||
assert docs[0].metadata["attachment_size"] == "1024"
|
||||
assert docs[0].parent_hierarchy_raw_node_id == "TEST"
|
||||
assert docs[0].sections[0].text == "Text from report"
|
||||
|
||||
# Second attachment
|
||||
assert docs[1].id == "https://jira.example.com/browse/TEST-42/attachments/att-2"
|
||||
assert docs[1].title == "spec.docx"
|
||||
|
||||
@patch("onyx.connectors.jira.connector.extract_file_text")
|
||||
def test_large_attachment_skipped(self, mock_extract: MagicMock) -> None:
|
||||
"""Attachments exceeding 50 MB are skipped silently (only warning logged)."""
|
||||
large_att = _make_attachment(
|
||||
size=_MAX_ATTACHMENT_SIZE_BYTES + 1,
|
||||
filename="huge.zip",
|
||||
)
|
||||
issue = _make_mock_issue(attachments=[large_att])
|
||||
connector = self._make_connector()
|
||||
|
||||
results = list(connector._process_attachments(issue, None))
|
||||
assert len(results) == 0
|
||||
mock_extract.assert_not_called()
|
||||
|
||||
@patch("onyx.connectors.jira.connector.extract_file_text")
|
||||
def test_no_content_url_skipped(self, mock_extract: MagicMock) -> None:
|
||||
"""Attachments with no content URL are skipped gracefully."""
|
||||
att = _make_attachment(content_url=None, filename="orphan.txt")
|
||||
issue = _make_mock_issue(attachments=[att])
|
||||
connector = self._make_connector()
|
||||
|
||||
results = list(connector._process_attachments(issue, None))
|
||||
assert len(results) == 0
|
||||
mock_extract.assert_not_called()
|
||||
|
||||
@patch("onyx.connectors.jira.connector.extract_file_text")
|
||||
def test_download_failure_yields_connector_failure(
|
||||
self, mock_extract: MagicMock
|
||||
) -> None:
|
||||
"""If the download raises, a ConnectorFailure is yielded; other attachments continue."""
|
||||
att_bad = _make_attachment(
|
||||
attachment_id="att-bad",
|
||||
filename="broken.pdf",
|
||||
content_url="https://jira.example.com/attachment/att-bad",
|
||||
download_raises=ConnectionError("download failed"),
|
||||
)
|
||||
att_good = _make_attachment(
|
||||
attachment_id="att-good",
|
||||
filename="good.pdf",
|
||||
content_url="https://jira.example.com/attachment/att-good",
|
||||
download_content=b"good content",
|
||||
)
|
||||
issue = _make_mock_issue(attachments=[att_bad, att_good])
|
||||
|
||||
connector = self._make_connector()
|
||||
mock_extract.return_value = "extracted good text"
|
||||
|
||||
results = list(connector._process_attachments(issue, None))
|
||||
|
||||
failures = [r for r in results if isinstance(r, ConnectorFailure)]
|
||||
docs = [r for r in results if isinstance(r, Document)]
|
||||
|
||||
assert len(failures) == 1
|
||||
assert "broken.pdf" in failures[0].failure_message
|
||||
assert len(docs) == 1
|
||||
assert docs[0].title == "good.pdf"
|
||||
|
||||
@patch("onyx.connectors.jira.connector.extract_file_text")
|
||||
def test_text_extraction_failure_skips_attachment(
|
||||
self, mock_extract: MagicMock
|
||||
) -> None:
|
||||
"""If extract_file_text raises, the attachment is skipped (not a ConnectorFailure)."""
|
||||
att = _make_attachment(
|
||||
filename="bad_format.xyz", download_content=b"some bytes"
|
||||
)
|
||||
issue = _make_mock_issue(attachments=[att])
|
||||
connector = self._make_connector()
|
||||
|
||||
mock_extract.side_effect = ValueError("Unsupported format")
|
||||
|
||||
results = list(connector._process_attachments(issue, None))
|
||||
assert len(results) == 0
|
||||
|
||||
@patch("onyx.connectors.jira.connector.extract_file_text")
|
||||
def test_empty_text_extraction_skips_attachment(
|
||||
self, mock_extract: MagicMock
|
||||
) -> None:
|
||||
"""Attachments yielding empty text are skipped."""
|
||||
att = _make_attachment(filename="empty.pdf", download_content=b"some bytes")
|
||||
issue = _make_mock_issue(attachments=[att])
|
||||
connector = self._make_connector()
|
||||
|
||||
mock_extract.return_value = ""
|
||||
|
||||
results = list(connector._process_attachments(issue, None))
|
||||
assert len(results) == 0
|
||||
|
||||
@patch("onyx.connectors.jira.connector.extract_file_text")
|
||||
def test_whitespace_only_text_skips_attachment(
|
||||
self, mock_extract: MagicMock
|
||||
) -> None:
|
||||
"""Attachments yielding only whitespace are skipped."""
|
||||
att = _make_attachment(filename="whitespace.txt", download_content=b" ")
|
||||
issue = _make_mock_issue(attachments=[att])
|
||||
connector = self._make_connector()
|
||||
|
||||
mock_extract.return_value = " \n\t "
|
||||
|
||||
results = list(connector._process_attachments(issue, None))
|
||||
assert len(results) == 0
|
||||
|
||||
@patch("onyx.connectors.jira.connector.extract_file_text")
|
||||
def test_no_attachments_on_issue(self, mock_extract: MagicMock) -> None:
|
||||
"""When an issue has no attachments, nothing is yielded."""
|
||||
issue = _make_mock_issue(attachments=[])
|
||||
connector = self._make_connector()
|
||||
|
||||
results = list(connector._process_attachments(issue, None))
|
||||
assert len(results) == 0
|
||||
mock_extract.assert_not_called()
|
||||
|
||||
@patch("onyx.connectors.jira.connector.extract_file_text")
|
||||
def test_attachment_field_is_none(self, mock_extract: MagicMock) -> None:
|
||||
"""When the attachment field is None (not set), nothing is yielded."""
|
||||
issue = _make_mock_issue()
|
||||
# Override attachment to be explicitly falsy (best_effort_get_field returns None)
|
||||
issue.fields.attachment = None
|
||||
issue.fields.__dict__["attachment"] = None
|
||||
connector = self._make_connector()
|
||||
|
||||
results = list(connector._process_attachments(issue, None))
|
||||
assert len(results) == 0
|
||||
mock_extract.assert_not_called()
|
||||
|
||||
|
||||
class TestFetchAttachmentsFlag:
|
||||
"""Verify _process_attachments is only called when fetch_attachments=True."""
|
||||
|
||||
def test_fetch_attachments_false_skips_processing(self) -> None:
|
||||
"""With fetch_attachments=False, _process_attachments should not be invoked
|
||||
during the load_from_checkpoint flow."""
|
||||
connector = JiraConnector(
|
||||
jira_base_url="https://jira.example.com",
|
||||
project_key="TEST",
|
||||
fetch_attachments=False,
|
||||
)
|
||||
assert connector.fetch_attachments is False
|
||||
|
||||
with patch.object(connector, "_process_attachments") as mock_process:
|
||||
# Simulate what _load_from_checkpoint does: only call
|
||||
# _process_attachments when self.fetch_attachments is True.
|
||||
if connector.fetch_attachments:
|
||||
connector._process_attachments(MagicMock(), None)
|
||||
mock_process.assert_not_called()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Test 3: Backwards Compatibility
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestBackwardsCompatibility:
|
||||
def test_default_config_has_flags_off(self) -> None:
|
||||
"""JiraConnector defaults have both new feature flags disabled."""
|
||||
connector = JiraConnector(
|
||||
jira_base_url="https://jira.example.com",
|
||||
project_key="TEST",
|
||||
)
|
||||
assert connector.extract_custom_fields is False
|
||||
assert connector.fetch_attachments is False
|
||||
|
||||
def test_default_config_has_empty_custom_fields_mapping(self) -> None:
|
||||
"""Before load_credentials, the custom fields mapping is empty."""
|
||||
connector = JiraConnector(
|
||||
jira_base_url="https://jira.example.com",
|
||||
project_key="TEST",
|
||||
)
|
||||
assert connector._custom_fields_mapping == {}
|
||||
|
||||
def test_process_jira_issue_without_mapping_has_no_custom_fields(self) -> None:
|
||||
"""Calling process_jira_issue without custom_fields_mapping produces
|
||||
the same metadata as the pre-enhancement code."""
|
||||
issue = _make_mock_issue(
|
||||
key="COMPAT-1",
|
||||
extra_fields={"customfield_10001": "should_be_ignored"},
|
||||
)
|
||||
|
||||
doc = process_jira_issue(
|
||||
jira_base_url="https://jira.example.com",
|
||||
issue=issue,
|
||||
)
|
||||
|
||||
assert doc is not None
|
||||
# Standard fields present
|
||||
assert doc.metadata["key"] == "COMPAT-1"
|
||||
assert doc.metadata["priority"] == "High"
|
||||
assert doc.metadata["status"] == "Open"
|
||||
# No custom field should leak through
|
||||
for key in doc.metadata:
|
||||
assert not key.startswith(
|
||||
"customfield_"
|
||||
), f"Custom field {key} leaked into metadata without mapping"
|
||||
|
||||
def test_process_jira_issue_default_params_match_old_signature(self) -> None:
|
||||
"""process_jira_issue with only the required params works identically
|
||||
to the pre-enhancement signature (jira_base_url + issue)."""
|
||||
issue = _make_mock_issue()
|
||||
|
||||
doc_new = process_jira_issue(
|
||||
jira_base_url="https://jira.example.com",
|
||||
issue=issue,
|
||||
)
|
||||
doc_explicit_none = process_jira_issue(
|
||||
jira_base_url="https://jira.example.com",
|
||||
issue=issue,
|
||||
custom_fields_mapping=None,
|
||||
)
|
||||
|
||||
assert doc_new is not None
|
||||
assert doc_explicit_none is not None
|
||||
assert doc_new.metadata == doc_explicit_none.metadata
|
||||
assert doc_new.id == doc_explicit_none.id
|
||||
|
||||
def test_load_credentials_does_not_fetch_custom_fields_when_flag_off(self) -> None:
|
||||
"""When extract_custom_fields=False, load_credentials does not call
|
||||
get_all_custom_fields."""
|
||||
connector = JiraConnector(
|
||||
jira_base_url="https://jira.example.com",
|
||||
project_key="TEST",
|
||||
extract_custom_fields=False,
|
||||
)
|
||||
|
||||
with patch("onyx.connectors.jira.connector.build_jira_client") as mock_build:
|
||||
mock_client = MagicMock(spec=JIRA)
|
||||
mock_build.return_value = mock_client
|
||||
|
||||
connector.load_credentials({"jira_api_token": "tok"})
|
||||
|
||||
mock_client.fields.assert_not_called()
|
||||
assert connector._custom_fields_mapping == {}
|
||||
|
||||
def test_load_credentials_fetches_custom_fields_when_flag_on(self) -> None:
|
||||
"""When extract_custom_fields=True, load_credentials populates the mapping."""
|
||||
connector = JiraConnector(
|
||||
jira_base_url="https://jira.example.com",
|
||||
project_key="TEST",
|
||||
extract_custom_fields=True,
|
||||
)
|
||||
|
||||
with patch("onyx.connectors.jira.connector.build_jira_client") as mock_build:
|
||||
mock_client = MagicMock(spec=JIRA)
|
||||
mock_client.fields.return_value = [
|
||||
{"id": "summary", "name": "Summary", "custom": False},
|
||||
{"id": "customfield_10001", "name": "Sprint", "custom": True},
|
||||
]
|
||||
mock_build.return_value = mock_client
|
||||
|
||||
connector.load_credentials({"jira_api_token": "tok"})
|
||||
|
||||
assert connector._custom_fields_mapping == {"customfield_10001": "Sprint"}
|
||||
|
||||
def test_load_credentials_handles_custom_fields_fetch_failure(self) -> None:
|
||||
"""If get_all_custom_fields raises, the mapping stays empty and no exception propagates."""
|
||||
connector = JiraConnector(
|
||||
jira_base_url="https://jira.example.com",
|
||||
project_key="TEST",
|
||||
extract_custom_fields=True,
|
||||
)
|
||||
|
||||
with patch("onyx.connectors.jira.connector.build_jira_client") as mock_build:
|
||||
mock_client = MagicMock(spec=JIRA)
|
||||
mock_client.fields.side_effect = RuntimeError("API unavailable")
|
||||
mock_build.return_value = mock_client
|
||||
|
||||
# Should not raise
|
||||
connector.load_credentials({"jira_api_token": "tok"})
|
||||
|
||||
assert connector._custom_fields_mapping == {}
|
||||
@@ -6,7 +6,6 @@ import requests
|
||||
from jira import JIRA
|
||||
from jira.resources import Issue
|
||||
|
||||
from onyx.connectors.jira.connector import _JIRA_BULK_FETCH_LIMIT
|
||||
from onyx.connectors.jira.connector import bulk_fetch_issues
|
||||
|
||||
|
||||
@@ -146,29 +145,3 @@ def test_bulk_fetch_recursive_splitting_raises_on_bad_issue() -> None:
|
||||
|
||||
with pytest.raises(requests.exceptions.JSONDecodeError):
|
||||
bulk_fetch_issues(client, ["1", "2", bad_id, "3", "4", "5"])
|
||||
|
||||
|
||||
def test_bulk_fetch_respects_api_batch_limit() -> None:
|
||||
"""Requests to the bulkfetch endpoint never exceed _JIRA_BULK_FETCH_LIMIT IDs."""
|
||||
client = _mock_jira_client()
|
||||
total_issues = _JIRA_BULK_FETCH_LIMIT * 3 + 7
|
||||
all_ids = [str(i) for i in range(total_issues)]
|
||||
|
||||
batch_sizes: list[int] = []
|
||||
|
||||
def _post_side_effect(url: str, json: dict[str, Any]) -> MagicMock: # noqa: ARG001
|
||||
ids = json["issueIdsOrKeys"]
|
||||
batch_sizes.append(len(ids))
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {"issues": [_make_raw_issue(i) for i in ids]}
|
||||
return resp
|
||||
|
||||
client._session.post.side_effect = _post_side_effect
|
||||
|
||||
result = bulk_fetch_issues(client, all_ids)
|
||||
|
||||
assert len(result) == total_issues
|
||||
# keeping this hardcoded because it's the documented limit
|
||||
# https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/
|
||||
assert all(size <= 100 for size in batch_sizes)
|
||||
assert len(batch_sizes) == 4
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Tests for _build_thread_text function."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from onyx.context.search.federated.slack_search import _build_thread_text
|
||||
|
||||
|
||||
def _make_msg(user: str, text: str, ts: str) -> dict[str, str]:
|
||||
return {"user": user, "text": text, "ts": ts}
|
||||
|
||||
|
||||
class TestBuildThreadText:
|
||||
"""Verify _build_thread_text includes full thread replies up to cap."""
|
||||
|
||||
@patch("onyx.context.search.federated.slack_search.batch_get_user_profiles")
|
||||
def test_includes_all_replies(self, mock_profiles: MagicMock) -> None:
|
||||
"""All replies within cap are included in output."""
|
||||
mock_profiles.return_value = {}
|
||||
messages = [
|
||||
_make_msg("U1", "parent msg", "1000.0"),
|
||||
_make_msg("U2", "reply 1", "1001.0"),
|
||||
_make_msg("U3", "reply 2", "1002.0"),
|
||||
_make_msg("U4", "reply 3", "1003.0"),
|
||||
]
|
||||
result = _build_thread_text(messages, "token", "T123", MagicMock())
|
||||
assert "parent msg" in result
|
||||
assert "reply 1" in result
|
||||
assert "reply 2" in result
|
||||
assert "reply 3" in result
|
||||
assert "..." not in result
|
||||
|
||||
@patch("onyx.context.search.federated.slack_search.batch_get_user_profiles")
|
||||
def test_non_thread_returns_parent_only(self, mock_profiles: MagicMock) -> None:
|
||||
"""Single message (no replies) returns just the parent text."""
|
||||
mock_profiles.return_value = {}
|
||||
messages = [_make_msg("U1", "just a message", "1000.0")]
|
||||
result = _build_thread_text(messages, "token", "T123", MagicMock())
|
||||
assert "just a message" in result
|
||||
assert "Replies:" not in result
|
||||
|
||||
@patch("onyx.context.search.federated.slack_search.batch_get_user_profiles")
|
||||
def test_parent_always_first(self, mock_profiles: MagicMock) -> None:
|
||||
"""Thread parent message is always the first line of output."""
|
||||
mock_profiles.return_value = {}
|
||||
messages = [
|
||||
_make_msg("U1", "I am the parent", "1000.0"),
|
||||
_make_msg("U2", "I am a reply", "1001.0"),
|
||||
]
|
||||
result = _build_thread_text(messages, "token", "T123", MagicMock())
|
||||
parent_pos = result.index("I am the parent")
|
||||
reply_pos = result.index("I am a reply")
|
||||
assert parent_pos < reply_pos
|
||||
|
||||
@patch("onyx.context.search.federated.slack_search.batch_get_user_profiles")
|
||||
def test_user_profiles_resolved(self, mock_profiles: MagicMock) -> None:
|
||||
"""User IDs in thread text are replaced with display names."""
|
||||
mock_profiles.return_value = {"U1": "Alice", "U2": "Bob"}
|
||||
messages = [
|
||||
_make_msg("U1", "hello", "1000.0"),
|
||||
_make_msg("U2", "world", "1001.0"),
|
||||
]
|
||||
result = _build_thread_text(messages, "token", "T123", MagicMock())
|
||||
assert "Alice" in result
|
||||
assert "Bob" in result
|
||||
assert "<@U1>" not in result
|
||||
assert "<@U2>" not in result
|
||||
@@ -1,108 +0,0 @@
|
||||
"""Tests for Slack URL parsing and direct thread fetch via URL override."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from onyx.context.search.federated.models import DirectThreadFetch
|
||||
from onyx.context.search.federated.slack_search import _fetch_thread_from_url
|
||||
from onyx.context.search.federated.slack_search_utils import extract_slack_message_urls
|
||||
|
||||
|
||||
class TestExtractSlackMessageUrls:
|
||||
"""Verify URL parsing extracts channel_id and timestamp correctly."""
|
||||
|
||||
def test_standard_url(self) -> None:
|
||||
query = "summarize https://mycompany.slack.com/archives/C097NBWMY8Y/p1775491616524769"
|
||||
results = extract_slack_message_urls(query)
|
||||
assert len(results) == 1
|
||||
assert results[0] == ("C097NBWMY8Y", "1775491616.524769")
|
||||
|
||||
def test_multiple_urls(self) -> None:
|
||||
query = (
|
||||
"compare https://co.slack.com/archives/C111/p1234567890123456 "
|
||||
"and https://co.slack.com/archives/C222/p9876543210987654"
|
||||
)
|
||||
results = extract_slack_message_urls(query)
|
||||
assert len(results) == 2
|
||||
assert results[0] == ("C111", "1234567890.123456")
|
||||
assert results[1] == ("C222", "9876543210.987654")
|
||||
|
||||
def test_no_urls(self) -> None:
|
||||
query = "what happened in #general last week?"
|
||||
results = extract_slack_message_urls(query)
|
||||
assert len(results) == 0
|
||||
|
||||
def test_non_slack_url_ignored(self) -> None:
|
||||
query = "check https://google.com/archives/C111/p1234567890123456"
|
||||
results = extract_slack_message_urls(query)
|
||||
assert len(results) == 0
|
||||
|
||||
def test_timestamp_conversion(self) -> None:
|
||||
"""p prefix removed, dot inserted after 10th digit."""
|
||||
query = "https://x.slack.com/archives/CABC123/p1775491616524769"
|
||||
results = extract_slack_message_urls(query)
|
||||
channel_id, ts = results[0]
|
||||
assert channel_id == "CABC123"
|
||||
assert ts == "1775491616.524769"
|
||||
assert not ts.startswith("p")
|
||||
assert "." in ts
|
||||
|
||||
|
||||
class TestFetchThreadFromUrl:
|
||||
"""Verify _fetch_thread_from_url calls conversations.replies and returns SlackMessage."""
|
||||
|
||||
@patch("onyx.context.search.federated.slack_search._build_thread_text")
|
||||
@patch("onyx.context.search.federated.slack_search.WebClient")
|
||||
def test_successful_fetch(
|
||||
self, mock_webclient_cls: MagicMock, mock_build_thread: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_webclient_cls.return_value = mock_client
|
||||
|
||||
# Mock conversations_replies
|
||||
mock_response = MagicMock()
|
||||
mock_response.get.return_value = [
|
||||
{"user": "U1", "text": "parent", "ts": "1775491616.524769"},
|
||||
{"user": "U2", "text": "reply 1", "ts": "1775491617.000000"},
|
||||
{"user": "U3", "text": "reply 2", "ts": "1775491618.000000"},
|
||||
]
|
||||
mock_client.conversations_replies.return_value = mock_response
|
||||
|
||||
# Mock channel info
|
||||
mock_ch_response = MagicMock()
|
||||
mock_ch_response.get.return_value = {"name": "general"}
|
||||
mock_client.conversations_info.return_value = mock_ch_response
|
||||
|
||||
mock_build_thread.return_value = (
|
||||
"U1: parent\n\nReplies:\n\nU2: reply 1\n\nU3: reply 2"
|
||||
)
|
||||
|
||||
fetch = DirectThreadFetch(
|
||||
channel_id="C097NBWMY8Y", thread_ts="1775491616.524769"
|
||||
)
|
||||
result = _fetch_thread_from_url(fetch, "xoxp-token")
|
||||
|
||||
assert len(result.messages) == 1
|
||||
msg = result.messages[0]
|
||||
assert msg.channel_id == "C097NBWMY8Y"
|
||||
assert msg.thread_id is None # Prevents double-enrichment
|
||||
assert msg.slack_score == 100000.0
|
||||
assert "parent" in msg.text
|
||||
mock_client.conversations_replies.assert_called_once_with(
|
||||
channel="C097NBWMY8Y", ts="1775491616.524769"
|
||||
)
|
||||
|
||||
@patch("onyx.context.search.federated.slack_search.WebClient")
|
||||
def test_api_error_returns_empty(self, mock_webclient_cls: MagicMock) -> None:
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_webclient_cls.return_value = mock_client
|
||||
mock_client.conversations_replies.side_effect = SlackApiError(
|
||||
message="channel_not_found",
|
||||
response=MagicMock(status_code=404),
|
||||
)
|
||||
|
||||
fetch = DirectThreadFetch(channel_id="CBAD", thread_ts="1234567890.123456")
|
||||
result = _fetch_thread_from_url(fetch, "xoxp-token")
|
||||
assert len(result.messages) == 0
|
||||
@@ -505,7 +505,6 @@ class TestGetLMStudioAvailableModels:
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.api_base = "http://localhost:1234"
|
||||
mock_provider.custom_config = {"LM_STUDIO_API_KEY": "stored-secret"}
|
||||
|
||||
response = {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Shared fixtures for proposal review engine unit tests."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lightweight stand-in for ProposalContext (avoids importing the real one,
|
||||
# which pulls in SQLAlchemy models that are irrelevant to pure-logic tests).
|
||||
# The real dataclass lives in context_assembler.py; we import it directly
|
||||
# where needed but provide a builder here for convenience.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_proposal_context():
|
||||
"""Factory fixture that builds a ProposalContext with sensible defaults."""
|
||||
from onyx.server.features.proposal_review.engine.context_assembler import (
|
||||
ProposalContext,
|
||||
)
|
||||
|
||||
def _make(
|
||||
proposal_text: str = "Default proposal text.",
|
||||
budget_text: str = "",
|
||||
foa_text: str = "",
|
||||
metadata: dict | None = None,
|
||||
jira_key: str = "PROJ-100",
|
||||
) -> "ProposalContext":
|
||||
return ProposalContext(
|
||||
proposal_text=proposal_text,
|
||||
budget_text=budget_text,
|
||||
foa_text=foa_text,
|
||||
metadata=metadata or {},
|
||||
jira_key=jira_key,
|
||||
)
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_rule():
|
||||
"""Factory fixture that builds a minimal mock ProposalReviewRule."""
|
||||
|
||||
def _make(
|
||||
name: str = "Test Rule",
|
||||
prompt_template: str = "Evaluate: {{proposal_text}}",
|
||||
rule_id: UUID | None = None,
|
||||
) -> MagicMock:
|
||||
rule = MagicMock()
|
||||
rule.id = rule_id or uuid4()
|
||||
rule.name = name
|
||||
rule.prompt_template = prompt_template
|
||||
return rule
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def well_formed_llm_json() -> str:
|
||||
"""A valid JSON string matching the expected rule-evaluator response schema."""
|
||||
return json.dumps(
|
||||
{
|
||||
"verdict": "PASS",
|
||||
"confidence": "HIGH",
|
||||
"evidence": "Section 4.2 states the budget is $500k.",
|
||||
"explanation": "The proposal meets the budget cap requirement.",
|
||||
"suggested_action": None,
|
||||
}
|
||||
)
|
||||
@@ -1,294 +0,0 @@
|
||||
"""Unit tests for the checklist importer engine component.
|
||||
|
||||
Tests cover:
|
||||
- _parse_import_response: JSON array parsing and validation
|
||||
- _validate_rule: field validation, type normalization, missing fields
|
||||
- Compound decomposition (multiple rules sharing a category)
|
||||
- Refinement detection (refinement_needed / refinement_question)
|
||||
- Malformed response handling (invalid JSON, non-array)
|
||||
- import_checklist: empty-input guard and LLM error propagation
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.server.features.proposal_review.engine.checklist_importer import (
|
||||
_parse_import_response,
|
||||
)
|
||||
from onyx.server.features.proposal_review.engine.checklist_importer import (
|
||||
_validate_rule,
|
||||
)
|
||||
from onyx.server.features.proposal_review.engine.checklist_importer import (
|
||||
import_checklist,
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# _validate_rule -- single rule validation
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestValidateRule:
|
||||
"""Tests for _validate_rule (field validation and normalization)."""
|
||||
|
||||
def test_valid_rule_passes(self):
|
||||
raw = {
|
||||
"name": "Check budget cap",
|
||||
"description": "Ensures budget is under $500k",
|
||||
"category": "IR-2: Budget",
|
||||
"rule_type": "DOCUMENT_CHECK",
|
||||
"rule_intent": "CHECK",
|
||||
"prompt_template": "Review {{budget_text}} for compliance.",
|
||||
"refinement_needed": False,
|
||||
"refinement_question": None,
|
||||
}
|
||||
result = _validate_rule(raw, 0)
|
||||
assert result is not None
|
||||
assert result["name"] == "Check budget cap"
|
||||
assert result["rule_type"] == "DOCUMENT_CHECK"
|
||||
assert result["rule_intent"] == "CHECK"
|
||||
assert result["refinement_needed"] is False
|
||||
|
||||
def test_missing_name_returns_none(self):
|
||||
raw = {"prompt_template": "something"}
|
||||
assert _validate_rule(raw, 0) is None
|
||||
|
||||
def test_missing_prompt_template_returns_none(self):
|
||||
raw = {"name": "A rule"}
|
||||
assert _validate_rule(raw, 0) is None
|
||||
|
||||
def test_invalid_rule_type_defaults_to_custom_nl(self):
|
||||
raw = {
|
||||
"name": "Test",
|
||||
"prompt_template": "t",
|
||||
"rule_type": "INVALID_TYPE",
|
||||
}
|
||||
result = _validate_rule(raw, 0)
|
||||
assert result["rule_type"] == "CUSTOM_NL"
|
||||
|
||||
def test_invalid_rule_intent_defaults_to_check(self):
|
||||
raw = {
|
||||
"name": "Test",
|
||||
"prompt_template": "t",
|
||||
"rule_intent": "NOTIFY",
|
||||
}
|
||||
result = _validate_rule(raw, 0)
|
||||
assert result["rule_intent"] == "CHECK"
|
||||
|
||||
def test_missing_rule_type_defaults_to_custom_nl(self):
|
||||
raw = {"name": "Test", "prompt_template": "t"}
|
||||
result = _validate_rule(raw, 0)
|
||||
assert result["rule_type"] == "CUSTOM_NL"
|
||||
|
||||
def test_missing_rule_intent_defaults_to_check(self):
|
||||
raw = {"name": "Test", "prompt_template": "t"}
|
||||
result = _validate_rule(raw, 0)
|
||||
assert result["rule_intent"] == "CHECK"
|
||||
|
||||
def test_name_truncated_to_200_chars(self):
|
||||
raw = {"name": "x" * 300, "prompt_template": "t"}
|
||||
result = _validate_rule(raw, 0)
|
||||
assert len(result["name"]) == 200
|
||||
|
||||
def test_refinement_needed_truthy_values(self):
|
||||
raw = {
|
||||
"name": "Test",
|
||||
"prompt_template": "t",
|
||||
"refinement_needed": True,
|
||||
"refinement_question": "What is the IDC rate?",
|
||||
}
|
||||
result = _validate_rule(raw, 0)
|
||||
assert result["refinement_needed"] is True
|
||||
assert result["refinement_question"] == "What is the IDC rate?"
|
||||
|
||||
def test_refinement_needed_defaults_false(self):
|
||||
raw = {"name": "Test", "prompt_template": "t"}
|
||||
result = _validate_rule(raw, 0)
|
||||
assert result["refinement_needed"] is False
|
||||
assert result["refinement_question"] is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rule_type",
|
||||
["DOCUMENT_CHECK", "METADATA_CHECK", "CROSS_REFERENCE", "CUSTOM_NL"],
|
||||
)
|
||||
def test_all_valid_rule_types_accepted(self, rule_type):
|
||||
raw = {"name": "Test", "prompt_template": "t", "rule_type": rule_type}
|
||||
result = _validate_rule(raw, 0)
|
||||
assert result["rule_type"] == rule_type
|
||||
|
||||
@pytest.mark.parametrize("intent", ["CHECK", "HIGHLIGHT"])
|
||||
def test_all_valid_intents_accepted(self, intent):
|
||||
raw = {"name": "Test", "prompt_template": "t", "rule_intent": intent}
|
||||
result = _validate_rule(raw, 0)
|
||||
assert result["rule_intent"] == intent
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# _parse_import_response -- full array parsing
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestParseImportResponse:
|
||||
"""Tests for _parse_import_response (JSON array parsing + validation)."""
|
||||
|
||||
def test_parses_valid_array(self):
|
||||
rules_json = json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "Rule A",
|
||||
"description": "Checks A",
|
||||
"category": "Cat-1",
|
||||
"rule_type": "DOCUMENT_CHECK",
|
||||
"rule_intent": "CHECK",
|
||||
"prompt_template": "Check {{proposal_text}}",
|
||||
"refinement_needed": False,
|
||||
"refinement_question": None,
|
||||
},
|
||||
{
|
||||
"name": "Rule B",
|
||||
"description": "Checks B",
|
||||
"category": "Cat-1",
|
||||
"rule_type": "METADATA_CHECK",
|
||||
"rule_intent": "HIGHLIGHT",
|
||||
"prompt_template": "Check {{metadata.sponsor}}",
|
||||
"refinement_needed": False,
|
||||
"refinement_question": None,
|
||||
},
|
||||
]
|
||||
)
|
||||
result = _parse_import_response(rules_json)
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "Rule A"
|
||||
assert result[1]["name"] == "Rule B"
|
||||
|
||||
def test_strips_markdown_code_fences(self):
|
||||
inner = json.dumps([{"name": "R", "prompt_template": "p"}])
|
||||
raw = f"```json\n{inner}\n```"
|
||||
result = _parse_import_response(raw)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "R"
|
||||
|
||||
def test_invalid_json_raises_runtime_error(self):
|
||||
with pytest.raises(RuntimeError, match="invalid JSON"):
|
||||
_parse_import_response("not valid json [")
|
||||
|
||||
def test_non_array_json_raises_runtime_error(self):
|
||||
with pytest.raises(RuntimeError, match="non-array JSON"):
|
||||
_parse_import_response('{"name": "single rule"}')
|
||||
|
||||
def test_skips_non_dict_entries(self):
|
||||
raw = json.dumps(
|
||||
[
|
||||
{"name": "Valid", "prompt_template": "p"},
|
||||
"this is a string, not a dict",
|
||||
42,
|
||||
]
|
||||
)
|
||||
result = _parse_import_response(raw)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "Valid"
|
||||
|
||||
def test_skips_rules_missing_required_fields(self):
|
||||
raw = json.dumps(
|
||||
[
|
||||
{"name": "Valid", "prompt_template": "p"},
|
||||
{"description": "no name or template"},
|
||||
]
|
||||
)
|
||||
result = _parse_import_response(raw)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_compound_decomposition_shared_category(self):
|
||||
"""Multiple rules from the same checklist item share a category."""
|
||||
rules_json = json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "Budget under 500k",
|
||||
"category": "IR-3: Budget Compliance",
|
||||
"prompt_template": "Check if budget < 500k using {{budget_text}}",
|
||||
},
|
||||
{
|
||||
"name": "Budget justification present",
|
||||
"category": "IR-3: Budget Compliance",
|
||||
"prompt_template": "Check for budget justification in {{proposal_text}}",
|
||||
},
|
||||
{
|
||||
"name": "Indirect costs correct",
|
||||
"category": "IR-3: Budget Compliance",
|
||||
"prompt_template": "Verify IDC rates in {{budget_text}}",
|
||||
"refinement_needed": True,
|
||||
"refinement_question": "What is the negotiated IDC rate?",
|
||||
},
|
||||
]
|
||||
)
|
||||
result = _parse_import_response(rules_json)
|
||||
assert len(result) == 3
|
||||
# All share the same category
|
||||
categories = {r["category"] for r in result}
|
||||
assert categories == {"IR-3: Budget Compliance"}
|
||||
|
||||
def test_refinement_preserved_in_output(self):
|
||||
raw = json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "IDC Rate Check",
|
||||
"prompt_template": "Verify {{INSTITUTION_IDC_RATES}} against {{budget_text}}",
|
||||
"refinement_needed": True,
|
||||
"refinement_question": "What are your institution's IDC rates?",
|
||||
}
|
||||
]
|
||||
)
|
||||
result = _parse_import_response(raw)
|
||||
assert len(result) == 1
|
||||
assert result[0]["refinement_needed"] is True
|
||||
assert "IDC rates" in result[0]["refinement_question"]
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# import_checklist -- top-level function
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestImportChecklist:
|
||||
"""Tests for the top-level import_checklist function."""
|
||||
|
||||
def test_empty_text_returns_empty_list(self):
|
||||
assert import_checklist("") == []
|
||||
assert import_checklist(" ") == []
|
||||
|
||||
def test_none_text_returns_empty_list(self):
|
||||
# The function checks `not extracted_text`, which is True for None
|
||||
assert import_checklist(None) == [] # type: ignore[arg-type]
|
||||
|
||||
@patch(
|
||||
"onyx.server.features.proposal_review.engine.checklist_importer.get_default_llm"
|
||||
)
|
||||
@patch(
|
||||
"onyx.server.features.proposal_review.engine.checklist_importer.llm_response_to_string"
|
||||
)
|
||||
def test_successful_import(self, mock_to_string, mock_get_llm):
|
||||
rules_json = json.dumps(
|
||||
[
|
||||
{"name": "Rule 1", "prompt_template": "Check {{proposal_text}}"},
|
||||
]
|
||||
)
|
||||
mock_to_string.return_value = rules_json
|
||||
mock_llm = MagicMock()
|
||||
mock_get_llm.return_value = mock_llm
|
||||
|
||||
result = import_checklist("Some checklist content here.")
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "Rule 1"
|
||||
mock_llm.invoke.assert_called_once()
|
||||
|
||||
@patch(
|
||||
"onyx.server.features.proposal_review.engine.checklist_importer.get_default_llm"
|
||||
)
|
||||
def test_llm_failure_raises_runtime_error(self, mock_get_llm):
|
||||
mock_get_llm.side_effect = RuntimeError("No API key")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Failed to parse checklist"):
|
||||
import_checklist("Some checklist content.")
|
||||
@@ -1,462 +0,0 @@
|
||||
"""Unit tests for the context assembler engine component.
|
||||
|
||||
Tests cover:
|
||||
- get_proposal_context: full assembly with mocked DB queries
|
||||
- Budget detection by document role and filename
|
||||
- FOA detection by document role
|
||||
- Multiple document concatenation
|
||||
- Missing documents / missing proposal handling
|
||||
- _is_budget_filename helper
|
||||
- _build_parent_document_text helper
|
||||
- _classify_child_text helper
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.server.features.proposal_review.engine.context_assembler import (
|
||||
_build_parent_document_text,
|
||||
)
|
||||
from onyx.server.features.proposal_review.engine.context_assembler import (
|
||||
_classify_child_text,
|
||||
)
|
||||
from onyx.server.features.proposal_review.engine.context_assembler import (
|
||||
_is_budget_filename,
|
||||
)
|
||||
from onyx.server.features.proposal_review.engine.context_assembler import (
|
||||
get_proposal_context,
|
||||
)
|
||||
from onyx.server.features.proposal_review.engine.context_assembler import (
|
||||
ProposalContext,
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# _is_budget_filename
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestIsBudgetFilename:
|
||||
"""Tests for _is_budget_filename helper."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filename",
|
||||
[
|
||||
"budget.xlsx",
|
||||
"BUDGET_justification.pdf",
|
||||
"project_budget_v2.docx",
|
||||
"cost_estimate.xlsx",
|
||||
"financial_plan.pdf",
|
||||
"annual_expenditure.csv",
|
||||
],
|
||||
)
|
||||
def test_budget_filenames_detected(self, filename):
|
||||
assert _is_budget_filename(filename) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filename",
|
||||
[
|
||||
"narrative.pdf",
|
||||
"abstract.docx",
|
||||
"biosketch.pdf",
|
||||
"facilities.docx",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_non_budget_filenames_not_detected(self, filename):
|
||||
assert _is_budget_filename(filename) is False
|
||||
|
||||
def test_none_filename_returns_false(self):
|
||||
assert _is_budget_filename(None) is False # type: ignore[arg-type]
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# _build_parent_document_text
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestBuildParentDocumentText:
|
||||
"""Tests for _build_parent_document_text helper."""
|
||||
|
||||
def test_includes_semantic_id(self):
|
||||
doc = MagicMock()
|
||||
doc.semantic_id = "PROJ-42"
|
||||
doc.link = None
|
||||
doc.doc_metadata = None
|
||||
result = _build_parent_document_text(doc)
|
||||
assert "PROJ-42" in result
|
||||
|
||||
def test_includes_link(self):
|
||||
doc = MagicMock()
|
||||
doc.semantic_id = None
|
||||
doc.link = "https://jira.example.com/PROJ-42"
|
||||
doc.doc_metadata = None
|
||||
result = _build_parent_document_text(doc)
|
||||
assert "https://jira.example.com/PROJ-42" in result
|
||||
|
||||
def test_includes_metadata_as_json(self):
|
||||
doc = MagicMock()
|
||||
doc.semantic_id = "PROJ-42"
|
||||
doc.link = None
|
||||
doc.doc_metadata = {"sponsor": "NIH", "pi": "Dr. Smith"}
|
||||
result = _build_parent_document_text(doc)
|
||||
assert "NIH" in result
|
||||
assert "Dr. Smith" in result
|
||||
|
||||
def test_empty_document_returns_minimal_text(self):
|
||||
doc = MagicMock()
|
||||
doc.semantic_id = None
|
||||
doc.link = None
|
||||
doc.doc_metadata = None
|
||||
doc.primary_owners = None
|
||||
doc.secondary_owners = None
|
||||
result = _build_parent_document_text(doc)
|
||||
# With no content at all, the result should be empty or only contain
|
||||
# structural headers. Verify it doesn't contain any meaningful data.
|
||||
assert "NIH" not in result
|
||||
assert "Dr. Smith" not in result
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# _classify_child_text
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestClassifyChildText:
|
||||
"""Tests for _classify_child_text helper."""
|
||||
|
||||
def test_budget_filename_classified(self):
|
||||
doc = MagicMock()
|
||||
doc.semantic_id = "PROJ-42/attachments/budget_v2.xlsx"
|
||||
budget_parts: list[str] = []
|
||||
foa_parts: list[str] = []
|
||||
_classify_child_text(doc, "budget content", budget_parts, foa_parts)
|
||||
assert "budget content" in budget_parts
|
||||
assert foa_parts == []
|
||||
|
||||
def test_foa_filename_classified(self):
|
||||
doc = MagicMock()
|
||||
doc.semantic_id = "PROJ-42/attachments/foa_document.pdf"
|
||||
budget_parts: list[str] = []
|
||||
foa_parts: list[str] = []
|
||||
_classify_child_text(doc, "foa content", budget_parts, foa_parts)
|
||||
assert foa_parts == ["foa content"]
|
||||
assert budget_parts == []
|
||||
|
||||
def test_solicitation_keyword_classified_as_foa(self):
|
||||
doc = MagicMock()
|
||||
doc.semantic_id = "solicitation_2024.pdf"
|
||||
budget_parts: list[str] = []
|
||||
foa_parts: list[str] = []
|
||||
_classify_child_text(doc, "solicitation text", budget_parts, foa_parts)
|
||||
assert "solicitation text" in foa_parts
|
||||
|
||||
def test_rfa_keyword_classified_as_foa(self):
|
||||
doc = MagicMock()
|
||||
doc.semantic_id = "RFA-AI-24-001.pdf"
|
||||
budget_parts: list[str] = []
|
||||
foa_parts: list[str] = []
|
||||
_classify_child_text(doc, "rfa text", budget_parts, foa_parts)
|
||||
assert "rfa text" in foa_parts
|
||||
|
||||
def test_unrelated_filename_not_classified(self):
|
||||
doc = MagicMock()
|
||||
doc.semantic_id = "narrative_v3.docx"
|
||||
budget_parts: list[str] = []
|
||||
foa_parts: list[str] = []
|
||||
_classify_child_text(doc, "narrative text", budget_parts, foa_parts)
|
||||
assert budget_parts == []
|
||||
assert foa_parts == []
|
||||
|
||||
def test_none_semantic_id_not_classified(self):
|
||||
doc = MagicMock()
|
||||
doc.semantic_id = None
|
||||
budget_parts: list[str] = []
|
||||
foa_parts: list[str] = []
|
||||
_classify_child_text(doc, "some text", budget_parts, foa_parts)
|
||||
assert budget_parts == []
|
||||
assert foa_parts == []
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# get_proposal_context -- full assembly with mocked DB
|
||||
# =====================================================================
|
||||
|
||||
|
||||
def _make_mock_proposal(document_id="DOC-123"):
|
||||
"""Create a mock ProposalReviewProposal."""
|
||||
proposal = MagicMock()
|
||||
proposal.id = uuid4()
|
||||
proposal.document_id = document_id
|
||||
return proposal
|
||||
|
||||
|
||||
def _make_mock_document(
|
||||
doc_id="DOC-123",
|
||||
semantic_id="PROJ-42",
|
||||
link=None,
|
||||
doc_metadata=None,
|
||||
):
|
||||
"""Create a mock Document."""
|
||||
doc = MagicMock()
|
||||
doc.id = doc_id
|
||||
doc.semantic_id = semantic_id
|
||||
doc.link = link
|
||||
doc.doc_metadata = doc_metadata or {}
|
||||
return doc
|
||||
|
||||
|
||||
def _make_mock_review_doc(
|
||||
file_name="doc.pdf",
|
||||
document_role="SUPPORTING",
|
||||
extracted_text="Some text.",
|
||||
):
|
||||
"""Create a mock ProposalReviewDocument."""
|
||||
doc = MagicMock()
|
||||
doc.file_name = file_name
|
||||
doc.document_role = document_role
|
||||
doc.extracted_text = extracted_text
|
||||
return doc
|
||||
|
||||
|
||||
class TestGetProposalContext:
|
||||
"""Tests for get_proposal_context with mocked DB session."""
|
||||
|
||||
def _setup_db(
|
||||
self,
|
||||
proposal=None,
|
||||
parent_doc=None,
|
||||
child_docs=None,
|
||||
manual_docs=None,
|
||||
):
|
||||
"""Build a mock db_session with controlled query results.
|
||||
|
||||
The function under test does three queries:
|
||||
1. ProposalReviewProposal by id
|
||||
2. Document by id (parent doc)
|
||||
3. Document.id.like(...) (child docs)
|
||||
4. ProposalReviewDocument by proposal_id (manual docs)
|
||||
|
||||
We use side_effect on db_session.query() to differentiate them.
|
||||
"""
|
||||
db = MagicMock()
|
||||
|
||||
# We need to handle multiple .query() calls with different model classes.
|
||||
# The function calls:
|
||||
# db_session.query(ProposalReviewProposal).filter(...).one_or_none()
|
||||
# db_session.query(Document).filter(...).one_or_none()
|
||||
# db_session.query(Document).filter(..., ...).all()
|
||||
# db_session.query(ProposalReviewDocument).filter(...).order_by(...).all()
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def query_side_effect(model_cls):
|
||||
call_count["n"] += 1
|
||||
mock_query = MagicMock()
|
||||
|
||||
model_name = getattr(model_cls, "__name__", str(model_cls))
|
||||
|
||||
if model_name == "ProposalReviewProposal":
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.one_or_none.return_value = proposal
|
||||
return mock_query
|
||||
|
||||
if model_name == "Document":
|
||||
# First Document query is for parent (one_or_none),
|
||||
# second is for children (all).
|
||||
# We track via a sub-counter.
|
||||
if not hasattr(query_side_effect, "_doc_calls"):
|
||||
query_side_effect._doc_calls = 0
|
||||
query_side_effect._doc_calls += 1
|
||||
|
||||
if query_side_effect._doc_calls == 1:
|
||||
# Parent doc query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.one_or_none.return_value = parent_doc
|
||||
else:
|
||||
# Child docs query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.all.return_value = child_docs or []
|
||||
return mock_query
|
||||
|
||||
if model_name == "ProposalReviewDocument":
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.all.return_value = manual_docs or []
|
||||
return mock_query
|
||||
|
||||
return mock_query
|
||||
|
||||
# Reset the doc_calls counter if it exists from a previous test
|
||||
if hasattr(query_side_effect, "_doc_calls"):
|
||||
del query_side_effect._doc_calls
|
||||
|
||||
db.query.side_effect = query_side_effect
|
||||
return db
|
||||
|
||||
def test_basic_assembly_with_parent_doc(self):
|
||||
proposal = _make_mock_proposal()
|
||||
parent_doc = _make_mock_document(
|
||||
semantic_id="PROJ-42",
|
||||
doc_metadata={"sponsor": "NIH", "pi": "Dr. Smith"},
|
||||
)
|
||||
|
||||
db = self._setup_db(proposal=proposal, parent_doc=parent_doc)
|
||||
ctx = get_proposal_context(proposal.id, db)
|
||||
|
||||
assert isinstance(ctx, ProposalContext)
|
||||
assert ctx.jira_key == "PROJ-42"
|
||||
assert ctx.metadata["sponsor"] == "NIH"
|
||||
assert "PROJ-42" in ctx.proposal_text
|
||||
|
||||
def test_proposal_not_found_returns_empty_context(self):
|
||||
db = self._setup_db(proposal=None)
|
||||
|
||||
ctx = get_proposal_context(uuid4(), db)
|
||||
# When proposal is not found, returns a safe empty context
|
||||
assert isinstance(ctx, ProposalContext)
|
||||
assert ctx.proposal_text == ""
|
||||
assert ctx.metadata == {}
|
||||
|
||||
def test_budget_document_by_role(self):
|
||||
proposal = _make_mock_proposal()
|
||||
parent_doc = _make_mock_document()
|
||||
budget_doc = _make_mock_review_doc(
|
||||
file_name="project_budget.xlsx",
|
||||
document_role="BUDGET",
|
||||
extracted_text="Total: $500k direct costs.",
|
||||
)
|
||||
|
||||
db = self._setup_db(
|
||||
proposal=proposal,
|
||||
parent_doc=parent_doc,
|
||||
manual_docs=[budget_doc],
|
||||
)
|
||||
ctx = get_proposal_context(proposal.id, db)
|
||||
|
||||
assert "$500k" in ctx.budget_text
|
||||
# Budget text should also appear in proposal_text (all docs)
|
||||
assert "$500k" in ctx.proposal_text
|
||||
|
||||
def test_budget_document_by_filename(self):
|
||||
proposal = _make_mock_proposal()
|
||||
parent_doc = _make_mock_document()
|
||||
budget_doc = _make_mock_review_doc(
|
||||
file_name="budget_justification.pdf",
|
||||
document_role="SUPPORTING", # role is not BUDGET
|
||||
extracted_text="Budget justification: $200k.",
|
||||
)
|
||||
|
||||
db = self._setup_db(
|
||||
proposal=proposal,
|
||||
parent_doc=parent_doc,
|
||||
manual_docs=[budget_doc],
|
||||
)
|
||||
ctx = get_proposal_context(proposal.id, db)
|
||||
|
||||
assert "$200k" in ctx.budget_text
|
||||
|
||||
def test_foa_document_by_role(self):
|
||||
proposal = _make_mock_proposal()
|
||||
parent_doc = _make_mock_document()
|
||||
foa_doc = _make_mock_review_doc(
|
||||
file_name="rfa-ai-24-001.html",
|
||||
document_role="FOA",
|
||||
extracted_text="This is the funding opportunity announcement.",
|
||||
)
|
||||
|
||||
db = self._setup_db(
|
||||
proposal=proposal,
|
||||
parent_doc=parent_doc,
|
||||
manual_docs=[foa_doc],
|
||||
)
|
||||
ctx = get_proposal_context(proposal.id, db)
|
||||
|
||||
assert "funding opportunity announcement" in ctx.foa_text
|
||||
|
||||
def test_multiple_documents_concatenated(self):
|
||||
proposal = _make_mock_proposal()
|
||||
parent_doc = _make_mock_document(semantic_id="PROJ-42")
|
||||
doc_a = _make_mock_review_doc(
|
||||
file_name="narrative.pdf",
|
||||
document_role="SUPPORTING",
|
||||
extracted_text="Section A content.",
|
||||
)
|
||||
doc_b = _make_mock_review_doc(
|
||||
file_name="abstract.pdf",
|
||||
document_role="SUPPORTING",
|
||||
extracted_text="Section B content.",
|
||||
)
|
||||
|
||||
db = self._setup_db(
|
||||
proposal=proposal,
|
||||
parent_doc=parent_doc,
|
||||
manual_docs=[doc_a, doc_b],
|
||||
)
|
||||
ctx = get_proposal_context(proposal.id, db)
|
||||
|
||||
assert "Section A content" in ctx.proposal_text
|
||||
assert "Section B content" in ctx.proposal_text
|
||||
|
||||
def test_no_documents_returns_minimal_text(self):
|
||||
proposal = _make_mock_proposal()
|
||||
# Parent doc exists but has no meaningful content fields
|
||||
parent_doc = _make_mock_document(
|
||||
semantic_id=None,
|
||||
link=None,
|
||||
doc_metadata=None,
|
||||
)
|
||||
parent_doc.primary_owners = None
|
||||
parent_doc.secondary_owners = None
|
||||
|
||||
db = self._setup_db(
|
||||
proposal=proposal,
|
||||
parent_doc=parent_doc,
|
||||
manual_docs=[],
|
||||
)
|
||||
ctx = get_proposal_context(proposal.id, db)
|
||||
|
||||
# No meaningful content — may contain structural headers but no real data
|
||||
assert "NIH" not in ctx.proposal_text
|
||||
assert ctx.budget_text == ""
|
||||
assert ctx.foa_text == ""
|
||||
|
||||
def test_no_parent_doc_still_returns_context(self):
|
||||
proposal = _make_mock_proposal()
|
||||
manual_doc = _make_mock_review_doc(
|
||||
file_name="narrative.pdf",
|
||||
document_role="SUPPORTING",
|
||||
extracted_text="Manual upload content.",
|
||||
)
|
||||
|
||||
db = self._setup_db(
|
||||
proposal=proposal,
|
||||
parent_doc=None,
|
||||
manual_docs=[manual_doc],
|
||||
)
|
||||
ctx = get_proposal_context(proposal.id, db)
|
||||
|
||||
assert "Manual upload content" in ctx.proposal_text
|
||||
assert ctx.jira_key == ""
|
||||
assert ctx.metadata == {}
|
||||
|
||||
def test_manual_doc_with_no_text_is_skipped(self):
|
||||
proposal = _make_mock_proposal()
|
||||
parent_doc = _make_mock_document()
|
||||
empty_doc = _make_mock_review_doc(
|
||||
file_name="empty.pdf",
|
||||
document_role="SUPPORTING",
|
||||
extracted_text=None,
|
||||
)
|
||||
|
||||
db = self._setup_db(
|
||||
proposal=proposal,
|
||||
parent_doc=parent_doc,
|
||||
manual_docs=[empty_doc],
|
||||
)
|
||||
ctx = get_proposal_context(proposal.id, db)
|
||||
|
||||
# The empty doc should not contribute to proposal_text
|
||||
assert "empty.pdf" not in ctx.proposal_text
|
||||
@@ -1,227 +0,0 @@
|
||||
"""Unit tests for the FOA fetcher engine component.
|
||||
|
||||
Tests cover:
|
||||
- _determine_domain: opportunity ID prefix -> agency domain mapping
|
||||
- fetch_foa: search flow with mocked web search provider and crawler
|
||||
- Graceful failure when no web search provider is configured
|
||||
- Empty / missing opportunity ID handling
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.server.features.proposal_review.engine.foa_fetcher import _determine_domain
|
||||
from onyx.server.features.proposal_review.engine.foa_fetcher import fetch_foa
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# _determine_domain -- prefix -> domain mapping
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestDetermineDomain:
|
||||
"""Tests for _determine_domain (opportunity ID prefix detection)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"opp_id, expected_domain",
|
||||
[
|
||||
("RFA-AI-24-001", "grants.nih.gov"),
|
||||
("PA-24-123", "grants.nih.gov"),
|
||||
("PAR-24-100", "grants.nih.gov"),
|
||||
("R01-AI-12345", "grants.nih.gov"),
|
||||
("R21-GM-67890", "grants.nih.gov"),
|
||||
("U01-CA-11111", "grants.nih.gov"),
|
||||
("NOT-OD-24-100", "grants.nih.gov"),
|
||||
("NSF-24-567", "nsf.gov"),
|
||||
("DE-FOA-0003000", "energy.gov"),
|
||||
("HRSA-25-001", "hrsa.gov"),
|
||||
("W911NF-24-R-0001", "grants.gov"),
|
||||
("FA8750-24-S-0001", "grants.gov"),
|
||||
("N00014-24-S-0001", "grants.gov"),
|
||||
("NOFO-2024-001", "grants.gov"),
|
||||
],
|
||||
)
|
||||
def test_known_prefixes(self, opp_id, expected_domain):
|
||||
assert _determine_domain(opp_id) == expected_domain
|
||||
|
||||
def test_unknown_prefix_returns_none(self):
|
||||
assert _determine_domain("UNKNOWN-123") is None
|
||||
|
||||
def test_purely_numeric_id_returns_grants_gov(self):
|
||||
assert _determine_domain("12345-67890") == "grants.gov"
|
||||
|
||||
def test_case_insensitive_matching(self):
|
||||
assert _determine_domain("rfa-ai-24-001") == "grants.nih.gov"
|
||||
assert _determine_domain("nsf-24-567") == "nsf.gov"
|
||||
|
||||
def test_empty_string_returns_none(self):
|
||||
# Empty string is not purely numeric after dash removal, so returns None
|
||||
assert _determine_domain("") is None
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# fetch_foa -- search flow
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestFetchFoa:
|
||||
"""Tests for fetch_foa with mocked dependencies."""
|
||||
|
||||
def _mock_db_session(self, existing_foa=None):
|
||||
"""Build a mock db_session that returns existing_foa for the FOA query."""
|
||||
db_session = MagicMock()
|
||||
query_mock = MagicMock()
|
||||
db_session.query.return_value = query_mock
|
||||
query_mock.filter.return_value = query_mock
|
||||
query_mock.first.return_value = existing_foa
|
||||
return db_session
|
||||
|
||||
def test_empty_opportunity_id_returns_none(self):
|
||||
db = MagicMock()
|
||||
assert fetch_foa("", uuid4(), db) is None
|
||||
assert fetch_foa(" ", uuid4(), db) is None
|
||||
|
||||
def test_none_opportunity_id_returns_none(self):
|
||||
db = MagicMock()
|
||||
assert fetch_foa(None, uuid4(), db) is None # type: ignore[arg-type]
|
||||
|
||||
def test_existing_foa_is_returned_without_search(self):
|
||||
existing = MagicMock()
|
||||
existing.extracted_text = "Previously fetched FOA content."
|
||||
db = self._mock_db_session(existing_foa=existing)
|
||||
|
||||
result = fetch_foa("RFA-AI-24-001", uuid4(), db)
|
||||
assert result == "Previously fetched FOA content."
|
||||
|
||||
def test_search_flow_fetches_and_saves(self):
|
||||
"""Full happy-path: search returns a URL, crawler fetches content, doc is saved."""
|
||||
# Setup: no existing FOA
|
||||
db = self._mock_db_session(existing_foa=None)
|
||||
|
||||
# Mock the web search provider
|
||||
search_result = MagicMock()
|
||||
search_result.link = "https://grants.nih.gov/foa/RFA-AI-24-001"
|
||||
provider = MagicMock()
|
||||
provider.search.return_value = [search_result]
|
||||
|
||||
# Mock the crawler
|
||||
content = MagicMock()
|
||||
content.scrape_successful = True
|
||||
content.full_content = "Full FOA text from NIH."
|
||||
crawler_instance = MagicMock()
|
||||
crawler_instance.contents.return_value = [content]
|
||||
|
||||
# The function does lazy imports, so we patch at the module level
|
||||
# where the imports happen
|
||||
import_target_provider = (
|
||||
"onyx.tools.tool_implementations.web_search.providers.get_default_provider"
|
||||
)
|
||||
import_target_crawler = (
|
||||
"onyx.tools.tool_implementations.open_url.onyx_web_crawler.OnyxWebCrawler"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(import_target_provider, return_value=provider),
|
||||
patch(import_target_crawler, return_value=crawler_instance),
|
||||
):
|
||||
result = fetch_foa("RFA-AI-24-001", uuid4(), db)
|
||||
|
||||
assert result == "Full FOA text from NIH."
|
||||
db.add.assert_called_once()
|
||||
db.flush.assert_called_once()
|
||||
|
||||
def test_no_provider_configured_returns_none(self):
|
||||
"""If get_default_provider raises or returns None, fetch_foa returns None."""
|
||||
db = self._mock_db_session(existing_foa=None)
|
||||
|
||||
import_target = (
|
||||
"onyx.tools.tool_implementations.web_search.providers.get_default_provider"
|
||||
)
|
||||
with patch(import_target, return_value=None):
|
||||
result = fetch_foa("RFA-AI-24-001", uuid4(), db)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_provider_import_failure_returns_none(self):
|
||||
"""If the web search provider module can't be imported, returns None."""
|
||||
db = self._mock_db_session(existing_foa=None)
|
||||
|
||||
import_target = (
|
||||
"onyx.tools.tool_implementations.web_search.providers.get_default_provider"
|
||||
)
|
||||
with patch(import_target, side_effect=ImportError("module not found")):
|
||||
result = fetch_foa("RFA-AI-24-001", uuid4(), db)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_search_returns_no_results(self):
|
||||
"""If the search returns an empty list, fetch_foa returns None."""
|
||||
db = self._mock_db_session(existing_foa=None)
|
||||
|
||||
provider = MagicMock()
|
||||
provider.search.return_value = []
|
||||
|
||||
import_target = (
|
||||
"onyx.tools.tool_implementations.web_search.providers.get_default_provider"
|
||||
)
|
||||
with patch(import_target, return_value=provider):
|
||||
result = fetch_foa("NSF-24-567", uuid4(), db)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_crawler_failure_returns_none(self):
|
||||
"""If the crawler raises an exception, fetch_foa returns None."""
|
||||
db = self._mock_db_session(existing_foa=None)
|
||||
|
||||
search_result = MagicMock()
|
||||
search_result.link = "https://nsf.gov/foa/24-567"
|
||||
provider = MagicMock()
|
||||
provider.search.return_value = [search_result]
|
||||
|
||||
import_target_provider = (
|
||||
"onyx.tools.tool_implementations.web_search.providers.get_default_provider"
|
||||
)
|
||||
import_target_crawler = (
|
||||
"onyx.tools.tool_implementations.open_url.onyx_web_crawler.OnyxWebCrawler"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(import_target_provider, return_value=provider),
|
||||
patch(import_target_crawler, side_effect=Exception("crawl failed")),
|
||||
):
|
||||
result = fetch_foa("NSF-24-567", uuid4(), db)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_scrape_unsuccessful_returns_none(self):
|
||||
"""If the crawler succeeds but scrape_successful is False, returns None."""
|
||||
db = self._mock_db_session(existing_foa=None)
|
||||
|
||||
search_result = MagicMock()
|
||||
search_result.link = "https://nsf.gov/foa/24-567"
|
||||
provider = MagicMock()
|
||||
provider.search.return_value = [search_result]
|
||||
|
||||
content = MagicMock()
|
||||
content.scrape_successful = False
|
||||
content.full_content = None
|
||||
crawler_instance = MagicMock()
|
||||
crawler_instance.contents.return_value = [content]
|
||||
|
||||
import_target_provider = (
|
||||
"onyx.tools.tool_implementations.web_search.providers.get_default_provider"
|
||||
)
|
||||
import_target_crawler = (
|
||||
"onyx.tools.tool_implementations.open_url.onyx_web_crawler.OnyxWebCrawler"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(import_target_provider, return_value=provider),
|
||||
patch(import_target_crawler, return_value=crawler_instance),
|
||||
):
|
||||
result = fetch_foa("NSF-24-567", uuid4(), db)
|
||||
|
||||
assert result is None
|
||||
@@ -1,354 +0,0 @@
|
||||
"""Unit tests for the rule evaluator engine component.
|
||||
|
||||
Tests cover:
|
||||
- Template variable substitution (_fill_template)
|
||||
- LLM response parsing (_parse_llm_response)
|
||||
- Malformed / missing-field responses
|
||||
- Markdown code fence stripping
|
||||
- Verdict and confidence validation/normalization
|
||||
- Token usage extraction
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.server.features.proposal_review.engine.rule_evaluator import (
|
||||
_extract_token_usage,
|
||||
)
|
||||
from onyx.server.features.proposal_review.engine.rule_evaluator import _fill_template
|
||||
from onyx.server.features.proposal_review.engine.rule_evaluator import (
|
||||
_parse_llm_response,
|
||||
)
|
||||
from onyx.server.features.proposal_review.engine.rule_evaluator import evaluate_rule
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# _fill_template -- variable substitution
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestFillTemplate:
|
||||
"""Tests for _fill_template (prompt variable substitution)."""
|
||||
|
||||
def test_replaces_proposal_text(self, make_proposal_context):
|
||||
ctx = make_proposal_context(proposal_text="My great proposal.")
|
||||
result = _fill_template("Review: {{proposal_text}}", ctx)
|
||||
assert result == "Review: My great proposal."
|
||||
|
||||
def test_replaces_budget_text(self, make_proposal_context):
|
||||
ctx = make_proposal_context(budget_text="$100k total")
|
||||
result = _fill_template("Budget info: {{budget_text}}", ctx)
|
||||
assert result == "Budget info: $100k total"
|
||||
|
||||
def test_replaces_foa_text(self, make_proposal_context):
|
||||
ctx = make_proposal_context(foa_text="NSF solicitation 24-567")
|
||||
result = _fill_template("FOA: {{foa_text}}", ctx)
|
||||
assert result == "FOA: NSF solicitation 24-567"
|
||||
|
||||
def test_replaces_jira_key(self, make_proposal_context):
|
||||
ctx = make_proposal_context(jira_key="PROJ-42")
|
||||
result = _fill_template("Ticket: {{jira_key}}", ctx)
|
||||
assert result == "Ticket: PROJ-42"
|
||||
|
||||
def test_replaces_metadata_as_json(self, make_proposal_context):
|
||||
ctx = make_proposal_context(metadata={"sponsor": "NIH", "pi": "Dr. Smith"})
|
||||
result = _fill_template("Meta: {{metadata}}", ctx)
|
||||
# Should be valid JSON
|
||||
parsed = json.loads(result.replace("Meta: ", ""))
|
||||
assert parsed["sponsor"] == "NIH"
|
||||
assert parsed["pi"] == "Dr. Smith"
|
||||
|
||||
def test_replaces_metadata_dot_field(self, make_proposal_context):
|
||||
ctx = make_proposal_context(
|
||||
metadata={"sponsor": "NIH", "deadline": "2025-01-15"}
|
||||
)
|
||||
result = _fill_template(
|
||||
"Sponsor is {{metadata.sponsor}}, due {{metadata.deadline}}", ctx
|
||||
)
|
||||
assert result == "Sponsor is NIH, due 2025-01-15"
|
||||
|
||||
def test_metadata_dot_field_with_dict_value(self, make_proposal_context):
|
||||
ctx = make_proposal_context(
|
||||
metadata={"budget_detail": {"direct": 100, "indirect": 50}}
|
||||
)
|
||||
result = _fill_template("Details: {{metadata.budget_detail}}", ctx)
|
||||
parsed = json.loads(result.replace("Details: ", ""))
|
||||
assert parsed == {"direct": 100, "indirect": 50}
|
||||
|
||||
def test_metadata_dot_field_missing_returns_empty(self, make_proposal_context):
|
||||
ctx = make_proposal_context(metadata={"sponsor": "NIH"})
|
||||
result = _fill_template("Agency: {{metadata.agency}}", ctx)
|
||||
assert result == "Agency: "
|
||||
|
||||
def test_replaces_all_placeholders_in_one_template(self, make_proposal_context):
|
||||
ctx = make_proposal_context(
|
||||
proposal_text="proposal body",
|
||||
budget_text="budget body",
|
||||
foa_text="foa body",
|
||||
jira_key="PROJ-99",
|
||||
metadata={"sponsor": "NSF"},
|
||||
)
|
||||
template = (
|
||||
"{{jira_key}}: {{proposal_text}} | "
|
||||
"Budget: {{budget_text}} | FOA: {{foa_text}} | "
|
||||
"Sponsor: {{metadata.sponsor}} | All: {{metadata}}"
|
||||
)
|
||||
result = _fill_template(template, ctx)
|
||||
assert "PROJ-99" in result
|
||||
assert "proposal body" in result
|
||||
assert "budget body" in result
|
||||
assert "foa body" in result
|
||||
assert "NSF" in result
|
||||
|
||||
def test_none_values_replaced_with_empty_string(self, make_proposal_context):
|
||||
ctx = make_proposal_context(
|
||||
proposal_text=None, # type: ignore[arg-type]
|
||||
budget_text=None, # type: ignore[arg-type]
|
||||
foa_text=None, # type: ignore[arg-type]
|
||||
jira_key=None, # type: ignore[arg-type]
|
||||
)
|
||||
result = _fill_template(
|
||||
"{{proposal_text}}|{{budget_text}}|{{foa_text}}|{{jira_key}}", ctx
|
||||
)
|
||||
assert result == "|||"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# _parse_llm_response -- JSON parsing and validation
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestParseLLMResponse:
|
||||
"""Tests for _parse_llm_response (JSON parsing / verdict validation)."""
|
||||
|
||||
def test_parses_well_formed_json(self, well_formed_llm_json):
|
||||
result = _parse_llm_response(well_formed_llm_json)
|
||||
assert result["verdict"] == "PASS"
|
||||
assert result["confidence"] == "HIGH"
|
||||
assert result["evidence"] == "Section 4.2 states the budget is $500k."
|
||||
assert result["explanation"] == "The proposal meets the budget cap requirement."
|
||||
assert result["suggested_action"] is None
|
||||
|
||||
def test_strips_markdown_json_fence(self):
|
||||
inner = json.dumps(
|
||||
{
|
||||
"verdict": "FAIL",
|
||||
"confidence": "MEDIUM",
|
||||
"evidence": "x",
|
||||
"explanation": "y",
|
||||
"suggested_action": "Fix it.",
|
||||
}
|
||||
)
|
||||
raw = f"```json\n{inner}\n```"
|
||||
result = _parse_llm_response(raw)
|
||||
assert result["verdict"] == "FAIL"
|
||||
assert result["confidence"] == "MEDIUM"
|
||||
assert result["suggested_action"] == "Fix it."
|
||||
|
||||
def test_strips_bare_code_fence(self):
|
||||
inner = json.dumps(
|
||||
{
|
||||
"verdict": "FLAG",
|
||||
"confidence": "LOW",
|
||||
"evidence": "e",
|
||||
"explanation": "exp",
|
||||
"suggested_action": None,
|
||||
}
|
||||
)
|
||||
raw = f"```\n{inner}\n```"
|
||||
result = _parse_llm_response(raw)
|
||||
assert result["verdict"] == "FLAG"
|
||||
|
||||
def test_malformed_json_returns_needs_review(self):
|
||||
result = _parse_llm_response("this is not json at all")
|
||||
assert result["verdict"] == "NEEDS_REVIEW"
|
||||
assert result["confidence"] == "LOW"
|
||||
assert result["evidence"] is None
|
||||
assert "Failed to parse" in result["explanation"]
|
||||
assert result["suggested_action"] is not None
|
||||
|
||||
def test_invalid_verdict_normalised_to_needs_review(self):
|
||||
raw = json.dumps(
|
||||
{
|
||||
"verdict": "MAYBE",
|
||||
"confidence": "HIGH",
|
||||
"evidence": "e",
|
||||
"explanation": "x",
|
||||
"suggested_action": None,
|
||||
}
|
||||
)
|
||||
result = _parse_llm_response(raw)
|
||||
assert result["verdict"] == "NEEDS_REVIEW"
|
||||
|
||||
def test_invalid_confidence_normalised_to_low(self):
|
||||
raw = json.dumps(
|
||||
{
|
||||
"verdict": "PASS",
|
||||
"confidence": "VERY_HIGH",
|
||||
"evidence": "e",
|
||||
"explanation": "x",
|
||||
"suggested_action": None,
|
||||
}
|
||||
)
|
||||
result = _parse_llm_response(raw)
|
||||
assert result["confidence"] == "LOW"
|
||||
|
||||
def test_missing_verdict_defaults_to_needs_review(self):
|
||||
raw = json.dumps(
|
||||
{
|
||||
"confidence": "HIGH",
|
||||
"evidence": "e",
|
||||
"explanation": "x",
|
||||
"suggested_action": None,
|
||||
}
|
||||
)
|
||||
result = _parse_llm_response(raw)
|
||||
assert result["verdict"] == "NEEDS_REVIEW"
|
||||
|
||||
def test_missing_confidence_defaults_to_low(self):
|
||||
raw = json.dumps(
|
||||
{
|
||||
"verdict": "PASS",
|
||||
"evidence": "e",
|
||||
"explanation": "x",
|
||||
"suggested_action": None,
|
||||
}
|
||||
)
|
||||
result = _parse_llm_response(raw)
|
||||
assert result["confidence"] == "LOW"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"verdict", ["PASS", "FAIL", "FLAG", "NEEDS_REVIEW", "NOT_APPLICABLE"]
|
||||
)
|
||||
def test_all_valid_verdicts_accepted(self, verdict):
|
||||
raw = json.dumps(
|
||||
{
|
||||
"verdict": verdict,
|
||||
"confidence": "HIGH",
|
||||
"evidence": "e",
|
||||
"explanation": "x",
|
||||
"suggested_action": None,
|
||||
}
|
||||
)
|
||||
result = _parse_llm_response(raw)
|
||||
assert result["verdict"] == verdict
|
||||
|
||||
def test_verdict_case_insensitive(self):
|
||||
raw = json.dumps(
|
||||
{
|
||||
"verdict": "pass",
|
||||
"confidence": "high",
|
||||
"evidence": "e",
|
||||
"explanation": "x",
|
||||
"suggested_action": None,
|
||||
}
|
||||
)
|
||||
result = _parse_llm_response(raw)
|
||||
assert result["verdict"] == "PASS"
|
||||
assert result["confidence"] == "HIGH"
|
||||
|
||||
def test_whitespace_around_json_is_tolerated(self):
|
||||
inner = json.dumps(
|
||||
{
|
||||
"verdict": "PASS",
|
||||
"confidence": "HIGH",
|
||||
"evidence": "e",
|
||||
"explanation": "x",
|
||||
"suggested_action": None,
|
||||
}
|
||||
)
|
||||
raw = f" \n {inner} \n "
|
||||
result = _parse_llm_response(raw)
|
||||
assert result["verdict"] == "PASS"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# _extract_token_usage
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestExtractTokenUsage:
|
||||
"""Tests for _extract_token_usage (best-effort token extraction)."""
|
||||
|
||||
def test_extracts_total_tokens(self):
|
||||
response = MagicMock()
|
||||
response.usage.total_tokens = 1234
|
||||
assert _extract_token_usage(response) == 1234
|
||||
|
||||
def test_sums_prompt_and_completion_tokens_when_no_total(self):
|
||||
response = MagicMock()
|
||||
response.usage.total_tokens = None
|
||||
response.usage.prompt_tokens = 100
|
||||
response.usage.completion_tokens = 50
|
||||
assert _extract_token_usage(response) == 150
|
||||
|
||||
def test_returns_none_when_no_usage(self):
|
||||
response = MagicMock(spec=[]) # no usage attr
|
||||
assert _extract_token_usage(response) is None
|
||||
|
||||
def test_returns_none_when_usage_is_none(self):
|
||||
response = MagicMock()
|
||||
response.usage = None
|
||||
assert _extract_token_usage(response) is None
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# evaluate_rule -- integration of template + LLM call + parsing
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestEvaluateRule:
|
||||
"""Tests for the top-level evaluate_rule function with mocked LLM."""
|
||||
|
||||
@patch("onyx.server.features.proposal_review.engine.rule_evaluator.get_default_llm")
|
||||
@patch(
|
||||
"onyx.server.features.proposal_review.engine.rule_evaluator.llm_response_to_string"
|
||||
)
|
||||
def test_successful_evaluation(
|
||||
self, mock_to_string, mock_get_llm, make_rule, make_proposal_context
|
||||
):
|
||||
llm_response_json = json.dumps(
|
||||
{
|
||||
"verdict": "PASS",
|
||||
"confidence": "HIGH",
|
||||
"evidence": "Found in section 3.",
|
||||
"explanation": "Meets requirement.",
|
||||
"suggested_action": None,
|
||||
}
|
||||
)
|
||||
mock_to_string.return_value = llm_response_json
|
||||
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.config.model_name = "gpt-4o"
|
||||
mock_llm.invoke.return_value = MagicMock(usage=MagicMock(total_tokens=500))
|
||||
mock_get_llm.return_value = mock_llm
|
||||
|
||||
rule = make_rule(prompt_template="Check: {{proposal_text}}")
|
||||
ctx = make_proposal_context(proposal_text="Grant text here.")
|
||||
|
||||
result = evaluate_rule(rule, ctx)
|
||||
|
||||
assert result["verdict"] == "PASS"
|
||||
assert result["confidence"] == "HIGH"
|
||||
assert result["llm_model"] == "gpt-4o"
|
||||
assert result["llm_tokens_used"] == 500
|
||||
|
||||
@patch("onyx.server.features.proposal_review.engine.rule_evaluator.get_default_llm")
|
||||
def test_llm_failure_returns_needs_review(
|
||||
self, mock_get_llm, make_rule, make_proposal_context
|
||||
):
|
||||
mock_get_llm.side_effect = RuntimeError("API key expired")
|
||||
|
||||
rule = make_rule()
|
||||
ctx = make_proposal_context()
|
||||
|
||||
result = evaluate_rule(rule, ctx)
|
||||
|
||||
assert result["verdict"] == "NEEDS_REVIEW"
|
||||
assert result["confidence"] == "LOW"
|
||||
assert "LLM evaluation failed" in result["explanation"]
|
||||
assert result["llm_model"] is None
|
||||
assert result["llm_tokens_used"] is None
|
||||
@@ -58,8 +58,7 @@ SERVICE_ORDER=(
|
||||
validate_template() {
|
||||
local template_file=$1
|
||||
echo "Validating template: $template_file..."
|
||||
aws cloudformation validate-template --template-body file://"$template_file" --region "$AWS_REGION" > /dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
if ! aws cloudformation validate-template --template-body file://"$template_file" --region "$AWS_REGION" > /dev/null; then
|
||||
echo "Error: Validation failed for $template_file. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
@@ -108,13 +107,15 @@ deploy_stack() {
|
||||
fi
|
||||
|
||||
# Create temporary parameters file for this template
|
||||
local temp_params_file=$(create_parameters_from_json "$template_file")
|
||||
local temp_params_file
|
||||
temp_params_file=$(create_parameters_from_json "$template_file")
|
||||
|
||||
# Special handling for SubnetIDs parameter if needed
|
||||
if grep -q "SubnetIDs" "$template_file"; then
|
||||
echo "Template uses SubnetIDs parameter, ensuring it's properly formatted..."
|
||||
# Make sure we're passing SubnetIDs as a comma-separated list
|
||||
local subnet_ids=$(remove_comments "$CONFIG_FILE" | jq -r '.SubnetIDs // empty')
|
||||
local subnet_ids
|
||||
subnet_ids=$(remove_comments "$CONFIG_FILE" | jq -r '.SubnetIDs // empty')
|
||||
if [ -n "$subnet_ids" ]; then
|
||||
echo "Using SubnetIDs from config: $subnet_ids"
|
||||
else
|
||||
@@ -123,15 +124,13 @@ deploy_stack() {
|
||||
fi
|
||||
|
||||
echo "Deploying stack: $stack_name with template: $template_file and generated config from: $CONFIG_FILE..."
|
||||
aws cloudformation deploy \
|
||||
if ! aws cloudformation deploy \
|
||||
--stack-name "$stack_name" \
|
||||
--template-file "$template_file" \
|
||||
--parameter-overrides file://"$temp_params_file" \
|
||||
--capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND \
|
||||
--region "$AWS_REGION" \
|
||||
--no-cli-auto-prompt > /dev/null
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
--no-cli-auto-prompt > /dev/null; then
|
||||
echo "Error: Deployment failed for $stack_name. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -52,11 +52,9 @@ delete_stack() {
|
||||
--region "$AWS_REGION"
|
||||
|
||||
echo "Waiting for stack $stack_name to be deleted..."
|
||||
aws cloudformation wait stack-delete-complete \
|
||||
if aws cloudformation wait stack-delete-complete \
|
||||
--stack-name "$stack_name" \
|
||||
--region "$AWS_REGION"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
--region "$AWS_REGION"; then
|
||||
echo "Stack $stack_name deleted successfully."
|
||||
sleep 10
|
||||
else
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#!/bin/sh
|
||||
# fill in the template
|
||||
export ONYX_BACKEND_API_HOST="${ONYX_BACKEND_API_HOST:-api_server}"
|
||||
export ONYX_WEB_SERVER_HOST="${ONYX_WEB_SERVER_HOST:-web_server}"
|
||||
@@ -16,12 +17,15 @@ echo "Using web server host: $ONYX_WEB_SERVER_HOST"
|
||||
echo "Using MCP server host: $ONYX_MCP_SERVER_HOST"
|
||||
echo "Using nginx proxy timeouts - connect: ${NGINX_PROXY_CONNECT_TIMEOUT}s, send: ${NGINX_PROXY_SEND_TIMEOUT}s, read: ${NGINX_PROXY_READ_TIMEOUT}s"
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
envsubst '$DOMAIN $SSL_CERT_FILE_NAME $SSL_CERT_KEY_FILE_NAME $ONYX_BACKEND_API_HOST $ONYX_WEB_SERVER_HOST $ONYX_MCP_SERVER_HOST $NGINX_PROXY_CONNECT_TIMEOUT $NGINX_PROXY_SEND_TIMEOUT $NGINX_PROXY_READ_TIMEOUT' < "/etc/nginx/conf.d/$1" > /etc/nginx/conf.d/app.conf
|
||||
|
||||
# Conditionally create MCP server configuration
|
||||
if [ "${MCP_SERVER_ENABLED}" = "True" ] || [ "${MCP_SERVER_ENABLED}" = "true" ]; then
|
||||
echo "MCP server is enabled, creating MCP configuration..."
|
||||
# shellcheck disable=SC2016
|
||||
envsubst '$ONYX_MCP_SERVER_HOST' < "/etc/nginx/conf.d/mcp_upstream.conf.inc.template" > /etc/nginx/conf.d/mcp_upstream.conf.inc
|
||||
# shellcheck disable=SC2016
|
||||
envsubst '$ONYX_MCP_SERVER_HOST' < "/etc/nginx/conf.d/mcp.conf.inc.template" > /etc/nginx/conf.d/mcp.conf.inc
|
||||
else
|
||||
echo "MCP server is disabled, removing MCP configuration..."
|
||||
|
||||
@@ -5,7 +5,7 @@ home: https://www.onyx.app/
|
||||
sources:
|
||||
- "https://github.com/onyx-dot-app/onyx"
|
||||
type: application
|
||||
version: 0.4.42
|
||||
version: 0.4.41
|
||||
appVersion: latest
|
||||
annotations:
|
||||
category: Productivity
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": true,
|
||||
"panels": [
|
||||
{
|
||||
"title": "Client-Side Search Latency (P50 / P95 / P99)",
|
||||
"description": "End-to-end latency as measured by the Python client, including network round-trip and serialization overhead.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 10, "w": 12, "x": 0, "y": 0 },
|
||||
"id": 1,
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisLabel": "seconds",
|
||||
"axisPlacement": "auto",
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "dashed" }
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 0.5 },
|
||||
{ "color": "red", "value": 2.0 }
|
||||
]
|
||||
},
|
||||
"unit": "s",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "histogram_quantile(0.5, sum by (le) (rate(onyx_opensearch_search_client_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "P50",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "histogram_quantile(0.95, sum by (le) (rate(onyx_opensearch_search_client_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "P95",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "histogram_quantile(0.99, sum by (le) (rate(onyx_opensearch_search_client_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "P99",
|
||||
"refId": "C"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Server-Side Search Latency (P50 / P95 / P99)",
|
||||
"description": "OpenSearch server-side execution time from the 'took' field in the response. Does not include network or client-side overhead.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 10, "w": 12, "x": 12, "y": 0 },
|
||||
"id": 2,
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisLabel": "seconds",
|
||||
"axisPlacement": "auto",
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "dashed" }
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 0.5 },
|
||||
{ "color": "red", "value": 2.0 }
|
||||
]
|
||||
},
|
||||
"unit": "s",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "histogram_quantile(0.5, sum by (le) (rate(onyx_opensearch_search_server_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "P50",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "histogram_quantile(0.95, sum by (le) (rate(onyx_opensearch_search_server_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "P95",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "histogram_quantile(0.99, sum by (le) (rate(onyx_opensearch_search_server_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "P99",
|
||||
"refId": "C"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Client-Side Latency by Search Type (P95)",
|
||||
"description": "P95 client-side latency broken down by search type (hybrid, keyword, semantic, random, doc_id_retrieval).",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 10, "w": 12, "x": 0, "y": 10 },
|
||||
"id": 3,
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisLabel": "seconds",
|
||||
"axisPlacement": "auto",
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"unit": "s",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "histogram_quantile(0.95, sum by (search_type, le) (rate(onyx_opensearch_search_client_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "{{ search_type }}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Search Throughput by Type",
|
||||
"description": "Searches per second broken down by search type.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 10, "w": 12, "x": 12, "y": 10 },
|
||||
"id": 4,
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisLabel": "searches/s",
|
||||
"axisPlacement": "auto",
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "normal" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"unit": "ops",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "sum by (search_type) (rate(onyx_opensearch_search_total[5m]))",
|
||||
"legendFormat": "{{ search_type }}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Concurrent Searches In Progress",
|
||||
"description": "Number of OpenSearch searches currently in flight, broken down by search type. Summed across all instances.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 10, "w": 12, "x": 0, "y": 20 },
|
||||
"id": 5,
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisLabel": "searches",
|
||||
"axisPlacement": "auto",
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "normal" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "sum by (search_type) (onyx_opensearch_searches_in_progress)",
|
||||
"legendFormat": "{{ search_type }}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Client vs Server Latency Overhead (P50)",
|
||||
"description": "Difference between client-side and server-side P50 latency. Reveals network, serialization, and untracked OpenSearch overhead.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 10, "w": 12, "x": 12, "y": 20 },
|
||||
"id": 6,
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisLabel": "seconds",
|
||||
"axisPlacement": "auto",
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"unit": "s",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "histogram_quantile(0.5, sum by (le) (rate(onyx_opensearch_search_client_duration_seconds_bucket[5m]))) - histogram_quantile(0.5, sum by (le) (rate(onyx_opensearch_search_server_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "Client - Server overhead (P50)",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "histogram_quantile(0.5, sum by (le) (rate(onyx_opensearch_search_client_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "Client P50",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "histogram_quantile(0.5, sum by (le) (rate(onyx_opensearch_search_server_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "Server P50",
|
||||
"refId": "C"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"refresh": "5s",
|
||||
"schemaVersion": 37,
|
||||
"style": "dark",
|
||||
"tags": ["onyx", "opensearch", "search", "latency"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"text": "Prometheus",
|
||||
"value": "prometheus"
|
||||
},
|
||||
"includeAll": false,
|
||||
"name": "DS_PROMETHEUS",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"type": "datasource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": { "from": "now-60m", "to": "now" },
|
||||
"timepicker": {
|
||||
"refresh_intervals": ["5s", "10s", "30s", "1m"]
|
||||
},
|
||||
"timezone": "",
|
||||
"title": "Onyx OpenSearch Search Latency",
|
||||
"uid": "onyx-opensearch-search-latency",
|
||||
"version": 0,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -1,606 +0,0 @@
|
||||
{
|
||||
"id": null,
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 18,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 4,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 10
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 10,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": ["lastNotNull", "max"],
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "onyx_celery_queue_depth{queue=~\"$queue\"}",
|
||||
"legendFormat": "{{queue}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Queue Depth by Queue",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 20
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 10
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(onyx_celery_queue_depth)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Total Queued Tasks",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 20
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 10
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "onyx_celery_unacked_tasks",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Unacked Tasks",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 10
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 12,
|
||||
"y": 10
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "onyx_celery_queue_depth{queue=\"docprocessing\"}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Docprocessing Queue",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 10
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 18,
|
||||
"y": 10
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "onyx_celery_queue_depth{queue=\"connector_doc_fetching\"}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Docfetching Queue",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "bars",
|
||||
"fillOpacity": 80,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineWidth": 1,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 10
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 10,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 14
|
||||
},
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": false
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "topk(10, onyx_celery_queue_depth)",
|
||||
"legendFormat": "{{queue}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Top 10 Queue Backlogs",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
"align": "auto",
|
||||
"cellOptions": {
|
||||
"type": "auto"
|
||||
},
|
||||
"inspect": false
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 10
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 10,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 14
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"cellHeight": "sm",
|
||||
"footer": {
|
||||
"countRows": false,
|
||||
"fields": "",
|
||||
"reducer": ["sum"],
|
||||
"show": false
|
||||
},
|
||||
"showHeader": true,
|
||||
"sortBy": [
|
||||
{
|
||||
"desc": true,
|
||||
"displayName": "Value"
|
||||
}
|
||||
]
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sort_desc(onyx_celery_queue_depth)",
|
||||
"format": "table",
|
||||
"instant": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Current Queue Depth",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "labelsToFields",
|
||||
"options": {
|
||||
"mode": "columns"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "table"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"style": "dark",
|
||||
"tags": ["onyx", "redis", "celery"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": "Prometheus",
|
||||
"value": "Prometheus"
|
||||
},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Datasource",
|
||||
"name": "DS_PROMETHEUS",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"type": "datasource"
|
||||
},
|
||||
{
|
||||
"allValue": ".*",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": "All",
|
||||
"value": ".*"
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"definition": "label_values(onyx_celery_queue_depth, queue)",
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": "Queue",
|
||||
"multi": true,
|
||||
"name": "queue",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(onyx_celery_queue_depth, queue)",
|
||||
"refId": "StandardVariableQuery"
|
||||
},
|
||||
"refresh": 2,
|
||||
"regex": "",
|
||||
"sort": 1,
|
||||
"type": "query"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Onyx Redis Queues",
|
||||
"uid": "onyx-redis-queues",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -12,30 +12,4 @@ metadata:
|
||||
data:
|
||||
onyx-indexing-pipeline.json: |
|
||||
{{- .Files.Get "dashboards/indexing-pipeline.json" | nindent 4 }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "onyx.fullname" . }}-opensearch-search-latency-dashboard
|
||||
labels:
|
||||
{{- include "onyx.labels" . | nindent 4 }}
|
||||
grafana_dashboard: "1"
|
||||
annotations:
|
||||
grafana_folder: "Onyx"
|
||||
data:
|
||||
onyx-opensearch-search-latency.json: |
|
||||
{{- .Files.Get "dashboards/opensearch-search-latency.json" | nindent 4 }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "onyx.fullname" . }}-redis-queues-dashboard
|
||||
labels:
|
||||
{{- include "onyx.labels" . | nindent 4 }}
|
||||
grafana_dashboard: "1"
|
||||
annotations:
|
||||
grafana_folder: "Onyx"
|
||||
data:
|
||||
onyx-redis-queues.json: |
|
||||
{{- .Files.Get "dashboards/redis-queues.json" | nindent 4 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -148,7 +148,7 @@ dev = [
|
||||
"matplotlib==3.10.8",
|
||||
"mypy-extensions==1.0.0",
|
||||
"mypy==1.13.0",
|
||||
"onyx-devtools==0.7.5",
|
||||
"onyx-devtools==0.7.4",
|
||||
"openapi-generator-cli==7.17.0",
|
||||
"pandas-stubs~=2.3.3",
|
||||
"pre-commit==3.2.2",
|
||||
|
||||
@@ -29,8 +29,6 @@ Examples:
|
||||
// runDevExec executes "devcontainer exec --workspace-folder <root> <command...>".
|
||||
func runDevExec(command []string) {
|
||||
checkDevcontainerCLI()
|
||||
ensureDockerSock()
|
||||
ensureRemoteUser()
|
||||
|
||||
root, err := paths.GitRoot()
|
||||
if err != nil {
|
||||
|
||||
@@ -148,53 +148,10 @@ func worktreeGitMount(root string) (string, bool) {
|
||||
return mount, true
|
||||
}
|
||||
|
||||
// sshAgentMount returns a --mount flag value that forwards the host's SSH agent
|
||||
// socket into the container. Returns ("", false) when SSH_AUTH_SOCK is unset or
|
||||
// the socket is not accessible.
|
||||
func sshAgentMount() (string, bool) {
|
||||
sock := os.Getenv("SSH_AUTH_SOCK")
|
||||
if sock == "" {
|
||||
log.Debug("SSH_AUTH_SOCK not set — skipping SSH agent forwarding")
|
||||
return "", false
|
||||
}
|
||||
if _, err := os.Stat(sock); err != nil {
|
||||
log.Debugf("SSH_AUTH_SOCK=%s not accessible: %v", sock, err)
|
||||
return "", false
|
||||
}
|
||||
mount := fmt.Sprintf("type=bind,source=%s,target=/tmp/ssh-agent.sock", sock)
|
||||
log.Debugf("Forwarding SSH agent: %s", sock)
|
||||
return mount, true
|
||||
}
|
||||
|
||||
// ensureRemoteUser sets DEVCONTAINER_REMOTE_USER when rootless Docker is
|
||||
// detected. Container root maps to the host user in rootless mode, so running
|
||||
// as root inside the container avoids the UID mismatch on new files.
|
||||
// Must be called after ensureDockerSock.
|
||||
func ensureRemoteUser() {
|
||||
if os.Getenv("DEVCONTAINER_REMOTE_USER") != "" {
|
||||
return
|
||||
}
|
||||
|
||||
if runtime.GOOS == "linux" {
|
||||
sock := os.Getenv("DOCKER_SOCK")
|
||||
xdg := os.Getenv("XDG_RUNTIME_DIR")
|
||||
// Heuristic: rootless Docker on Linux typically places its socket
|
||||
// under $XDG_RUNTIME_DIR. If DOCKER_SOCK was set to a custom path
|
||||
// outside XDG_RUNTIME_DIR, set DEVCONTAINER_REMOTE_USER=root manually.
|
||||
if xdg != "" && strings.HasPrefix(sock, xdg) {
|
||||
log.Debug("Rootless Docker detected — setting DEVCONTAINER_REMOTE_USER=root")
|
||||
if err := os.Setenv("DEVCONTAINER_REMOTE_USER", "root"); err != nil {
|
||||
log.Warnf("Failed to set DEVCONTAINER_REMOTE_USER: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runDevcontainer executes "devcontainer <action> --workspace-folder <root> [extraArgs...]".
|
||||
func runDevcontainer(action string, extraArgs []string) {
|
||||
checkDevcontainerCLI()
|
||||
ensureDockerSock()
|
||||
ensureRemoteUser()
|
||||
|
||||
root, err := paths.GitRoot()
|
||||
if err != nil {
|
||||
@@ -205,9 +162,6 @@ func runDevcontainer(action string, extraArgs []string) {
|
||||
if mount, ok := worktreeGitMount(root); ok {
|
||||
args = append(args, "--mount", mount)
|
||||
}
|
||||
if mount, ok := sshAgentMount(); ok {
|
||||
args = append(args, "--mount", mount)
|
||||
}
|
||||
args = append(args, extraArgs...)
|
||||
|
||||
log.Debugf("Running: devcontainer %v", args)
|
||||
|
||||
16
uv.lock
generated
16
uv.lock
generated
@@ -4511,7 +4511,7 @@ dev = [
|
||||
{ name = "matplotlib", specifier = "==3.10.8" },
|
||||
{ name = "mypy", specifier = "==1.13.0" },
|
||||
{ name = "mypy-extensions", specifier = "==1.0.0" },
|
||||
{ name = "onyx-devtools", specifier = "==0.7.5" },
|
||||
{ name = "onyx-devtools", specifier = "==0.7.4" },
|
||||
{ name = "openapi-generator-cli", specifier = "==7.17.0" },
|
||||
{ name = "pandas-stubs", specifier = "~=2.3.3" },
|
||||
{ name = "pre-commit", specifier = "==3.2.2" },
|
||||
@@ -4554,19 +4554,19 @@ model-server = [
|
||||
|
||||
[[package]]
|
||||
name = "onyx-devtools"
|
||||
version = "0.7.5"
|
||||
version = "0.7.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "openapi-generator-cli" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/f8/844e34f5126ae40fff0d012bba0b28f031f8871062759bb3789eae4f5e0a/onyx_devtools-0.7.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b3cd434c722ae48a1f651748a9f094711b29d1a9f37fbbadef3144f2cdb0f16d", size = 4238900, upload-time = "2026-04-10T07:02:16.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/97/d1db725f900b199fa3f7a7a7c9b51ae75d4b18755c924f00f06a7703e552/onyx_devtools-0.7.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c50e3d76d4f8cc4faa6250e758d42f0249067f0e17bc82b99c6c00dd48114393", size = 3913672, upload-time = "2026-04-10T07:02:17.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/83/e11bedb0a1321b63c844a418be1990c172ed363c6ee612978c3a38df71f1/onyx_devtools-0.7.5-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:ec01aeaaa14854b0933bb85bbfc51184599d3dbf1c0097ff59c1c72db8222a5a", size = 3779585, upload-time = "2026-04-10T07:02:16.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/85/128d25cd35c1adc436dcff9ab4f2c20cf29528d09415280c1230ff0ca993/onyx_devtools-0.7.5-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:586d50ecb6dcea95611135e4cd4529ebedd8ab84a41b1adf3be1280a48dc52af", size = 4201962, upload-time = "2026-04-10T07:02:14.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5d/83c80f918b399fea998cd41bfe90bda733eda77e133ca4dc1e9ce18a9b4a/onyx_devtools-0.7.5-py3-none-win_amd64.whl", hash = "sha256:c45d80f0093ba738120b77c4c0bde13843e33d786ae8608eb10490f06183d89b", size = 4320088, upload-time = "2026-04-10T07:02:17.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/bf/b9c85cc61981bd71c0f1cbb50192763b11788a7c8636b1e01f750251c92c/onyx_devtools-0.7.5-py3-none-win_arm64.whl", hash = "sha256:9852a7cc29939371e016b794f2cffdb88680280d857d24c191c5188884416a3d", size = 3858839, upload-time = "2026-04-10T07:02:20.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/3f/584bb003333b6e6d632b06bbf99d410c7a71adde1711076fd44fe88d966d/onyx_devtools-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6c51d9199ff8ff8fe64a3cfcf77f8170508722b33a1de54c5474be0447b7afa8", size = 4237700, upload-time = "2026-04-09T21:28:20.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/04/8c28522d51a66b1bdc997a1c72821122eab23f048459646c6ee62a39f6eb/onyx_devtools-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f64a4cec6d3616b9ca7354e326994882c9ff2cb3f9fc9a44e55f0eb6a6ff1c1c", size = 3912751, upload-time = "2026-04-09T21:28:23.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/e6/ae60307cc50064dacb58e003c9a367d5c85118fd89a597abf3de5fd66f0a/onyx_devtools-0.7.4-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:31c7cecaaa329e3f6d53864290bc53fd0b823453c6cfdb8be7931a8925f5c075", size = 3778188, upload-time = "2026-04-09T21:28:23.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d1/5a2789efac7d8f19d30d4d8da1862dd10a16b65d8c9b200542a959094a17/onyx_devtools-0.7.4-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:4c44e3c21253ea92127af483155190c14426c729d93e244aedc33875f74d3514", size = 4200526, upload-time = "2026-04-09T21:28:23.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/40/56a467eaa7b78411971898191cf0dc3ee49b7f448d1cfe76cd432f6458d3/onyx_devtools-0.7.4-py3-none-win_amd64.whl", hash = "sha256:6fa2b63b702bc5ecbeed5f9eadec57d61ac5c4a646cf5fbd66ee340f53b7d81c", size = 4319090, upload-time = "2026-04-09T21:28:23.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/ef/c866fa8ce1f75e1ac67bc239e767b8944cb1a12a44950986ce57e06db17f/onyx_devtools-0.7.4-py3-none-win_arm64.whl", hash = "sha256:c84cbe6a85474dc9f005f079796cf031e80c4249897432ad9f370cd27f72970a", size = 3857229, upload-time = "2026-04-09T21:28:23.484Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -68,9 +68,7 @@ SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
# Run the conversion into a temp file so a failed run doesn't destroy an existing .tsx
|
||||
TMPFILE="${BASE_NAME}.tsx.tmp"
|
||||
bunx @svgr/cli "$SVG_FILE" --typescript --svgo-config "$SVGO_CONFIG" --template "${SCRIPT_DIR}/icon-template.js" > "$TMPFILE"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
if bunx @svgr/cli "$SVG_FILE" --typescript --svgo-config "$SVGO_CONFIG" --template "${SCRIPT_DIR}/icon-template.js" > "$TMPFILE"; then
|
||||
# Verify the temp file has content before replacing the destination
|
||||
if [ ! -s "$TMPFILE" ]; then
|
||||
rm -f "$TMPFILE"
|
||||
@@ -84,16 +82,14 @@ if [ $? -eq 0 ]; then
|
||||
# Using perl for cross-platform compatibility (works on macOS, Linux, Windows with WSL)
|
||||
# Note: perl -i returns 0 even on some failures, so we validate the output
|
||||
|
||||
perl -i -pe 's/<svg/<svg width={size} height={size}/g' "${BASE_NAME}.tsx"
|
||||
if [ $? -ne 0 ]; then
|
||||
if ! perl -i -pe 's/<svg/<svg width={size} height={size}/g' "${BASE_NAME}.tsx"; then
|
||||
echo "Error: Failed to add width/height attributes" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Icons additionally get stroke="currentColor"
|
||||
if [ "$MODE" = "icon" ]; then
|
||||
perl -i -pe 's/\{\.\.\.props\}/stroke="currentColor" {...props}/g' "${BASE_NAME}.tsx"
|
||||
if [ $? -ne 0 ]; then
|
||||
if ! perl -i -pe 's/\{\.\.\.props\}/stroke="currentColor" {...props}/g' "${BASE_NAME}.tsx"; then
|
||||
echo "Error: Failed to add stroke attribute" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Divider } from "@opal/components/divider/components";
|
||||
|
||||
const meta: Meta<typeof Divider> = {
|
||||
title: "opal/components/Divider",
|
||||
component: Divider,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Divider>;
|
||||
|
||||
export const Plain: Story = {
|
||||
render: () => <Divider />,
|
||||
};
|
||||
|
||||
export const WithTitle: Story = {
|
||||
render: () => <Divider title="Section" />,
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
render: () => (
|
||||
<Divider description="Additional configuration options for power users." />
|
||||
),
|
||||
};
|
||||
|
||||
export const Foldable: Story = {
|
||||
render: () => (
|
||||
<Divider title="Advanced Options" foldable defaultOpen={false}>
|
||||
<div style={{ padding: "0.5rem 0" }}>
|
||||
<p>This content is revealed when the divider is expanded.</p>
|
||||
</div>
|
||||
</Divider>
|
||||
),
|
||||
};
|
||||
|
||||
export const FoldableDefaultOpen: Story = {
|
||||
render: () => (
|
||||
<Divider title="Details" foldable defaultOpen>
|
||||
<div style={{ padding: "0.5rem 0" }}>
|
||||
<p>This starts open by default.</p>
|
||||
</div>
|
||||
</Divider>
|
||||
),
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
# Divider
|
||||
|
||||
**Import:** `import { Divider } from "@opal/components";`
|
||||
|
||||
A horizontal rule that optionally displays a title, description, or foldable content section.
|
||||
|
||||
## Props
|
||||
|
||||
The component uses a discriminated union with four variants. `title` and `description` are mutually exclusive; `foldable` requires `title`.
|
||||
|
||||
### Bare divider
|
||||
|
||||
No props — renders a plain horizontal line.
|
||||
|
||||
### Titled divider
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `title` | `string \| RichStr` | **(required)** | Label to the left of the line |
|
||||
|
||||
### Described divider
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `description` | `string \| RichStr` | **(required)** | Text below the line |
|
||||
|
||||
### Foldable divider
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `title` | `string \| RichStr` | **(required)** | Label to the left of the line |
|
||||
| `foldable` | `true` | **(required)** | Enables fold/expand behavior |
|
||||
| `open` | `boolean` | — | Controlled open state |
|
||||
| `defaultOpen` | `boolean` | `false` | Uncontrolled initial open state |
|
||||
| `onOpenChange` | `(open: boolean) => void` | — | Callback when toggled |
|
||||
| `children` | `ReactNode` | — | Content revealed when open |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```tsx
|
||||
import { Divider } from "@opal/components";
|
||||
|
||||
// Plain line
|
||||
<Divider />
|
||||
|
||||
// With title
|
||||
<Divider title="Advanced" />
|
||||
|
||||
// With description
|
||||
<Divider description="Additional configuration options." />
|
||||
|
||||
// Foldable
|
||||
<Divider title="Advanced Options" foldable>
|
||||
<p>Hidden content here</p>
|
||||
</Divider>
|
||||
|
||||
// Controlled foldable
|
||||
const [open, setOpen] = useState(false);
|
||||
<Divider title="Details" foldable open={open} onOpenChange={setOpen}>
|
||||
<p>Controlled content</p>
|
||||
</Divider>
|
||||
```
|
||||
@@ -1,163 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import "@opal/components/divider/styles.css";
|
||||
import { useState, useCallback } from "react";
|
||||
import type { RichStr } from "@opal/types";
|
||||
import { Button, Text } from "@opal/components";
|
||||
import { SvgChevronRight } from "@opal/icons";
|
||||
import { Interactive } from "@opal/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DividerNeverFields {
|
||||
open?: never;
|
||||
defaultOpen?: never;
|
||||
onOpenChange?: never;
|
||||
children?: never;
|
||||
}
|
||||
|
||||
/** Plain line — no title, no description. */
|
||||
interface DividerBareProps extends DividerNeverFields {
|
||||
title?: never;
|
||||
description?: never;
|
||||
foldable?: false;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/** Line with a title to the left. */
|
||||
interface DividerTitledProps extends DividerNeverFields {
|
||||
title: string | RichStr;
|
||||
description?: never;
|
||||
foldable?: false;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/** Line with a description below. */
|
||||
interface DividerDescribedProps extends DividerNeverFields {
|
||||
title?: never;
|
||||
/** Description rendered below the divider line. */
|
||||
description: string | RichStr;
|
||||
foldable?: false;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/** Foldable — requires title, reveals children. */
|
||||
interface DividerFoldableProps {
|
||||
/** Title is required when foldable. */
|
||||
title: string | RichStr;
|
||||
foldable: true;
|
||||
description?: never;
|
||||
/** Controlled open state. */
|
||||
open?: boolean;
|
||||
/** Uncontrolled default open state. */
|
||||
defaultOpen?: boolean;
|
||||
/** Callback when open state changes. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Content revealed when open. */
|
||||
children?: React.ReactNode;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
type DividerProps =
|
||||
| DividerBareProps
|
||||
| DividerTitledProps
|
||||
| DividerDescribedProps
|
||||
| DividerFoldableProps;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Divider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Divider(props: DividerProps) {
|
||||
if (props.foldable) {
|
||||
return <FoldableDivider {...props} />;
|
||||
}
|
||||
|
||||
const { ref } = props;
|
||||
const title = "title" in props ? props.title : undefined;
|
||||
const description = "description" in props ? props.description : undefined;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="opal-divider">
|
||||
<div className="opal-divider-row">
|
||||
{title && (
|
||||
<div className="opal-divider-title">
|
||||
<Text font="secondary-body" color="text-03" nowrap>
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div className="opal-divider-line" />
|
||||
</div>
|
||||
{description && (
|
||||
<div className="opal-divider-description">
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FoldableDivider (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FoldableDivider({
|
||||
title,
|
||||
open: controlledOpen,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: DividerFoldableProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const isOpen = isControlled ? controlledOpen : internalOpen;
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const next = !isOpen;
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
}, [isOpen, isControlled, onOpenChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Interactive.Stateless
|
||||
variant="default"
|
||||
prominence="tertiary"
|
||||
interaction={isOpen ? "hover" : "rest"}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Interactive.Container
|
||||
roundingVariant="sm"
|
||||
heightVariant="fit"
|
||||
widthVariant="full"
|
||||
>
|
||||
<div className="opal-divider">
|
||||
<div className="opal-divider-row">
|
||||
<div className="opal-divider-title">
|
||||
<Text font="secondary-body" color="inherit" nowrap>
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="opal-divider-line" />
|
||||
<div className="opal-divider-chevron" data-open={isOpen}>
|
||||
<Button
|
||||
icon={SvgChevronRight}
|
||||
size="sm"
|
||||
prominence="tertiary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
{isOpen && children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { Divider, type DividerProps };
|
||||
@@ -1,38 +0,0 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
Divider
|
||||
|
||||
A horizontal rule with optional title, foldable chevron, or description.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
.opal-divider {
|
||||
@apply flex flex-col w-full;
|
||||
padding: 0.25rem 0.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.opal-divider-row {
|
||||
@apply flex flex-row items-center w-full;
|
||||
gap: 2px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.opal-divider-title {
|
||||
@apply flex flex-col justify-center;
|
||||
padding: 0px 2px;
|
||||
}
|
||||
|
||||
.opal-divider-line {
|
||||
@apply flex-1 h-px bg-border-01;
|
||||
}
|
||||
|
||||
.opal-divider-description {
|
||||
padding: 0px 2px;
|
||||
}
|
||||
|
||||
.opal-divider-chevron {
|
||||
@apply transition-transform duration-200 ease-in-out;
|
||||
}
|
||||
|
||||
.opal-divider-chevron[data-open="true"] {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
@@ -54,12 +54,6 @@ export {
|
||||
type TagColor,
|
||||
} from "@opal/components/tag/components";
|
||||
|
||||
/* Divider */
|
||||
export {
|
||||
Divider,
|
||||
type DividerProps,
|
||||
} from "@opal/components/divider/components";
|
||||
|
||||
/* Card */
|
||||
export {
|
||||
Card,
|
||||
|
||||
@@ -145,7 +145,6 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
pageSize,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
onColumnVisibilityChange,
|
||||
initialRowSelection,
|
||||
initialViewSelected,
|
||||
draggable,
|
||||
@@ -224,7 +223,6 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
pageSize: effectivePageSize,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
onColumnVisibilityChange,
|
||||
initialRowSelection,
|
||||
initialViewSelected,
|
||||
getRowId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
"use no memo";
|
||||
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
@@ -103,8 +103,6 @@ interface UseDataTableOptions<TData extends RowData> {
|
||||
initialSorting?: SortingState;
|
||||
/** Initial column visibility state. @default {} */
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
/** Called when column visibility changes. */
|
||||
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
|
||||
/** Initial row selection state. Keys are row IDs (from `getRowId`), values are `true`. @default {} */
|
||||
initialRowSelection?: RowSelectionState;
|
||||
/** When true AND `initialRowSelection` is non-empty, start in view-selected mode (filtered to selected rows). @default false */
|
||||
@@ -201,7 +199,6 @@ export default function useDataTable<TData extends RowData>(
|
||||
columnResizeMode = "onChange",
|
||||
initialSorting = [],
|
||||
initialColumnVisibility = {},
|
||||
onColumnVisibilityChange: onColumnVisibilityChangeProp,
|
||||
initialRowSelection = {},
|
||||
initialViewSelected = false,
|
||||
getRowId,
|
||||
@@ -218,19 +215,9 @@ export default function useDataTable<TData extends RowData>(
|
||||
const [rowSelection, setRowSelection] =
|
||||
useState<RowSelectionState>(initialRowSelection);
|
||||
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
|
||||
const [columnVisibility, setColumnVisibilityRaw] = useState<VisibilityState>(
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
initialColumnVisibility
|
||||
);
|
||||
const setColumnVisibility: typeof setColumnVisibilityRaw = useCallback(
|
||||
(updater) => {
|
||||
setColumnVisibilityRaw((prev) => {
|
||||
const next = typeof updater === "function" ? updater(prev) : updater;
|
||||
onColumnVisibilityChangeProp?.(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[onColumnVisibilityChangeProp]
|
||||
);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: pageSizeOption,
|
||||
|
||||
@@ -146,8 +146,6 @@ export interface DataTableProps<TData> {
|
||||
initialSorting?: SortingState;
|
||||
/** Initial column visibility state. */
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
/** Called when column visibility changes. Receives the full visibility state. */
|
||||
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
|
||||
/** Initial row selection state. Keys are row IDs (from `getRowId`), values are `true`. */
|
||||
initialRowSelection?: Record<string, boolean>;
|
||||
/** When true AND `initialRowSelection` is non-empty, start in view-selected mode. @default false */
|
||||
|
||||
@@ -10,7 +10,7 @@ const SvgAnthropic = ({ size, ...props }: IconProps) => (
|
||||
>
|
||||
<path
|
||||
d="M36.1779 9.78003H29.1432L41.9653 42.2095H49L36.1779 9.78003ZM15.8221 9.78003L3 42.2095H10.1844L12.8286 35.4243H26.2495L28.8438 42.2095H36.0282L23.2061 9.78003H15.8221ZM15.1236 29.3874L19.5141 18.0121L23.9046 29.3874H15.1236Z"
|
||||
fill="var(--text-05)"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ const SvgAws = ({ size, ...props }: IconProps) => (
|
||||
<title>AWS</title>
|
||||
<path
|
||||
d="M14.6195 23.2934C14.6195 23.9333 14.7233 24.4522 14.8443 24.8326C14.9827 25.2131 15.1556 25.6282 15.3978 26.0778C15.4842 26.2162 15.5188 26.3546 15.5188 26.4756C15.5188 26.6486 15.4151 26.8215 15.1902 26.9945L14.1007 27.7208C13.945 27.8246 13.7894 27.8765 13.651 27.8765C13.4781 27.8765 13.3051 27.79 13.1322 27.6344C12.89 27.3749 12.6825 27.0982 12.5096 26.8215C12.3366 26.5275 12.1637 26.1989 11.9734 25.8011C10.6245 27.3922 8.92958 28.1878 6.88881 28.1878C5.43606 28.1878 4.27731 27.7727 3.42988 26.9426C2.58244 26.1124 2.15007 25.0056 2.15007 23.622C2.15007 22.152 2.66891 20.9586 3.72389 20.0593C4.77886 19.16 6.17973 18.7103 7.96108 18.7103C8.54909 18.7103 9.15441 18.7622 9.79431 18.8487C10.4342 18.9352 11.0914 19.0735 11.7832 19.2292V17.9667C11.7832 16.6523 11.5065 15.7356 10.9703 15.1995C10.4169 14.6634 9.483 14.404 8.15132 14.404C7.546 14.404 6.9234 14.4731 6.28349 14.6288C5.64359 14.7844 5.02098 14.9747 4.41567 15.2168C4.13896 15.3379 3.93142 15.407 3.81036 15.4416C3.6893 15.4762 3.60282 15.4935 3.53364 15.4935C3.29152 15.4935 3.17046 15.3206 3.17046 14.9574V14.1099C3.17046 13.8332 3.20505 13.6257 3.29152 13.5046C3.37799 13.3836 3.53364 13.2625 3.77577 13.1414C4.38108 12.8301 5.10746 12.5707 5.9549 12.3632C6.80233 12.1384 7.70165 12.0346 8.65286 12.0346C10.7109 12.0346 12.2156 12.5015 13.1841 13.4355C14.1353 14.3694 14.6195 15.7875 14.6195 17.6899V23.2934ZM7.63248 25.9222C8.2032 25.9222 8.79122 25.8184 9.41383 25.6109C10.0364 25.4034 10.5899 25.0229 11.0568 24.504C11.3335 24.1754 11.5411 23.8122 11.6448 23.3972C11.7486 22.9821 11.8178 22.4806 11.8178 21.8925V21.1662C11.3162 21.0451 10.7801 20.9413 10.2267 20.8722C9.67325 20.803 9.13711 20.7684 8.60098 20.7684C7.44224 20.7684 6.5948 20.9932 6.02407 21.4602C5.45335 21.9271 5.17664 22.5843 5.17664 23.4491C5.17664 24.2619 5.38417 24.8672 5.81654 25.2823C6.23161 25.7147 6.83692 25.9222 7.63248 25.9222ZM21.5201 27.79C21.2088 27.79 21.0012 27.7381 20.8629 27.6171C20.7245 27.5133 20.6035 27.2712 20.4997 26.9426L16.4355 13.5738C16.3317 13.2279 16.2798 13.0031 16.2798 12.882C16.2798 12.6053 16.4182 12.4497 16.6949 12.4497H18.3897C18.7183 12.4497 18.9432 12.5015 19.0642 12.6226C19.2026 12.7264 19.3064 12.9685 19.4101 13.2971L22.3156 24.7462L25.0136 13.2971C25.1001 12.9512 25.2038 12.7264 25.3422 12.6226C25.4806 12.5188 25.7227 12.4497 26.034 12.4497H27.4176C27.7462 12.4497 27.971 12.5015 28.1093 12.6226C28.2477 12.7264 28.3688 12.9685 28.4379 13.2971L31.1705 24.8845L34.1625 13.2971C34.2662 12.9512 34.3873 12.7264 34.5084 12.6226C34.6467 12.5188 34.8716 12.4497 35.1829 12.4497H36.7913C37.068 12.4497 37.2236 12.588 37.2236 12.882C37.2236 12.9685 37.2063 13.055 37.189 13.1587C37.1717 13.2625 37.1372 13.4009 37.068 13.5911L32.9 26.9599C32.7962 27.3058 32.6751 27.5306 32.5368 27.6344C32.3984 27.7381 32.1736 27.8073 31.8796 27.8073H30.3922C30.0636 27.8073 29.8388 27.7554 29.7004 27.6344C29.5621 27.5133 29.441 27.2885 29.3719 26.9426L26.6912 15.7875L24.0278 26.9253C23.9413 27.2712 23.8376 27.496 23.6992 27.6171C23.5609 27.7381 23.3187 27.79 23.0074 27.79H21.5201ZM43.7437 28.257C42.8444 28.257 41.9451 28.1532 41.0803 27.9457C40.2156 27.7381 39.5411 27.5133 39.0914 27.2539C38.8147 27.0982 38.6245 26.9253 38.5553 26.7696C38.4861 26.614 38.4515 26.441 38.4515 26.2854V25.4034C38.4515 25.0402 38.5899 24.8672 38.8493 24.8672C38.9531 24.8672 39.0569 24.8845 39.1606 24.9191C39.2644 24.9537 39.42 25.0229 39.593 25.0921C40.181 25.3515 40.8209 25.559 41.4954 25.6974C42.1872 25.8357 42.8617 25.9049 43.5535 25.9049C44.643 25.9049 45.4905 25.7147 46.0785 25.3342C46.6665 24.9537 46.9778 24.4003 46.9778 23.6912C46.9778 23.2069 46.8222 22.8092 46.5109 22.4806C46.1996 22.152 45.6115 21.858 44.7641 21.5812L42.2564 20.803C40.9939 20.4052 40.0599 19.8172 39.4892 19.0389C38.9185 18.278 38.6245 17.4305 38.6245 16.5312C38.6245 15.8048 38.7801 15.1649 39.0914 14.6115C39.4027 14.0581 39.8178 13.5738 40.3367 13.1933C40.8555 12.7956 41.4435 12.5015 42.1353 12.294C42.8271 12.0865 43.5535 12 44.3144 12C44.6949 12 45.0927 12.0173 45.4732 12.0692C45.871 12.1211 46.2341 12.1902 46.5973 12.2594C46.9432 12.3459 47.2718 12.4324 47.5831 12.5361C47.8944 12.6399 48.1366 12.7437 48.3095 12.8474C48.5516 12.9858 48.7246 13.1242 48.8283 13.2798C48.9321 13.4182 48.984 13.6084 48.984 13.8505V14.6634C48.984 15.0266 48.8456 15.2168 48.5862 15.2168C48.4479 15.2168 48.223 15.1476 47.929 15.0093C46.9432 14.5596 45.8364 14.3348 44.6084 14.3348C43.6227 14.3348 42.8444 14.4904 42.3083 14.819C41.7721 15.1476 41.4954 15.6492 41.4954 16.3583C41.4954 16.8425 41.6684 17.2576 42.0142 17.5862C42.3601 17.9148 43 18.2434 43.9167 18.5374L46.3725 19.3156C47.6177 19.7134 48.517 20.2668 49.0532 20.9759C49.5893 21.685 49.8487 22.4979 49.8487 23.3972C49.8487 24.1408 49.6931 24.8153 49.3991 25.4034C49.0878 25.9914 48.6727 26.5102 48.1366 26.9253C47.6004 27.3577 46.9605 27.669 46.2168 27.8938C45.4386 28.1359 44.6257 28.257 43.7437 28.257Z"
|
||||
className="fill-[#252F3E] dark:fill-text-05"
|
||||
fill="#252F3E"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
const SvgCohere = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 52 52"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M18.256 30.224C19.4293 30.224 21.776 30.1653 25.0613 28.816C28.8747 27.232 36.384 24.416 41.84 21.4827C45.6533 19.4293 47.296 16.7307 47.296 13.0933C47.296 8.10667 43.248 4 38.2027 4H17.0827C9.86667 4 4 9.86667 4 17.0827C4 24.2987 9.51467 30.224 18.256 30.224Z"
|
||||
fill="#39594D"
|
||||
/>
|
||||
<path
|
||||
d="M21.8347 39.2C21.8347 35.68 23.9467 32.4533 27.232 31.104L33.8613 28.3467C40.608 25.5893 48 30.5173 48 37.792C48 43.424 43.424 48 37.792 48H30.576C25.7653 48 21.8347 44.0693 21.8347 39.2Z"
|
||||
fill="#D18EE2"
|
||||
/>
|
||||
<path
|
||||
d="M11.568 31.9253C7.40267 31.9253 4 35.328 4 39.4933V40.4907C4 44.5973 7.40267 48 11.568 48C15.7333 48 19.136 44.5973 19.136 40.432V39.4347C19.0773 35.328 15.7333 31.9253 11.568 31.9253Z"
|
||||
fill="#FF7759"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgCohere;
|
||||
@@ -3,7 +3,6 @@ export { default as SvgAws } from "@opal/logos/aws";
|
||||
export { default as SvgAzure } from "@opal/logos/azure";
|
||||
export { default as SvgBifrost } from "@opal/logos/bifrost";
|
||||
export { default as SvgClaude } from "@opal/logos/claude";
|
||||
export { default as SvgCohere } from "@opal/logos/cohere";
|
||||
export { default as SvgDeepseek } from "@opal/logos/deepseek";
|
||||
export { default as SvgDiscord } from "@opal/logos/discord";
|
||||
export { default as SvgGemini } from "@opal/logos/gemini";
|
||||
@@ -12,7 +11,6 @@ export { default as SvgLitellm } from "@opal/logos/litellm";
|
||||
export { default as SvgLmStudio } from "@opal/logos/lm-studio";
|
||||
export { default as SvgMicrosoft } from "@opal/logos/microsoft";
|
||||
export { default as SvgMistral } from "@opal/logos/mistral";
|
||||
export { default as SvgNomic } from "@opal/logos/nomic";
|
||||
export { default as SvgOllama } from "@opal/logos/ollama";
|
||||
export { default as SvgOnyxLogo } from "@opal/logos/onyx-logo";
|
||||
export { default as SvgOnyxLogoTyped } from "@opal/logos/onyx-logo-typed";
|
||||
@@ -21,4 +19,3 @@ export { default as SvgOpenai } from "@opal/logos/openai";
|
||||
export { default as SvgOpenrouter } from "@opal/logos/openrouter";
|
||||
export { default as SvgQwen } from "@opal/logos/qwen";
|
||||
export { default as SvgSlack } from "@opal/logos/slack";
|
||||
export { default as SvgVoyage } from "@opal/logos/voyage";
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
const SvgNomic = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 52 52"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M35.858 6.31995H46V45.6709H35.6146C32.0852 36.8676 25.1481 27.7804 15.7363 24.8189V6.31995H25.4726C26.5274 12.7296 30.1618 18.3744 35.858 21.6546V6.31995Z"
|
||||
fill="var(--text-05)"
|
||||
/>
|
||||
<path
|
||||
d="M15.7363 24.8189V45.6709H6L6 30.0927C9.05968 27.6167 11.9635 25.8737 15.7363 24.8189Z"
|
||||
fill="var(--text-05)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgNomic;
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgOpenai = ({ size, ...props }: IconProps) => {
|
||||
const SvgOpenAI = ({ size, ...props }: IconProps) => {
|
||||
const clipId = React.useId();
|
||||
return (
|
||||
<svg
|
||||
@@ -15,16 +15,16 @@ const SvgOpenai = ({ size, ...props }: IconProps) => {
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
<path
|
||||
d="M6.27989 5.99136V4.58828C6.27989 4.4701 6.32383 4.38143 6.42625 4.32242L9.22206 2.69783C9.60266 2.4763 10.0564 2.37296 10.5247 2.37296C12.2813 2.37296 13.3937 3.74654 13.3937 5.20864C13.3937 5.31199 13.3937 5.43016 13.379 5.54833L10.4808 3.83506C10.3052 3.73172 10.1295 3.73172 9.95386 3.83506L6.27989 5.99136ZM12.8082 11.4561V8.10334C12.8082 7.89651 12.7203 7.74883 12.5447 7.64548L8.87071 5.48918L10.071 4.79498C10.1734 4.73597 10.2613 4.73597 10.3637 4.79498L13.1595 6.41959C13.9647 6.89226 14.5061 7.89651 14.5061 8.87124C14.5061 9.99365 13.8476 11.0277 12.8082 11.4561ZM5.41629 8.50218L4.21603 7.7933C4.11361 7.73429 4.06967 7.64563 4.06967 7.52745V4.27824C4.06967 2.69797 5.26993 1.50157 6.89473 1.50157C7.50955 1.50157 8.08029 1.70841 8.56345 2.07761L5.67991 3.76136C5.5043 3.86471 5.41643 4.01239 5.41643 4.21923L5.41629 8.50218ZM7.99984 10.0086L6.27988 9.03389V6.96624L7.99984 5.99151L9.71963 6.96624V9.03389L7.99984 10.0086ZM9.10494 14.4985C8.49012 14.4985 7.91938 14.2917 7.43622 13.9226L10.3197 12.2387C10.4953 12.1354 10.5832 11.9878 10.5832 11.7809V7.4978L11.7982 8.20668C11.9006 8.2657 11.9445 8.35436 11.9445 8.47254V11.7218C11.9445 13.302 10.7296 14.4985 9.10494 14.4985ZM5.63583 11.205L2.84002 9.58041C2.03489 9.10771 1.4934 8.10348 1.4934 7.12875C1.4934 5.99151 2.16672 4.97244 3.20591 4.5441V7.91148C3.20591 8.11831 3.29379 8.26599 3.46939 8.36934L7.12882 10.5108L5.92856 11.205C5.82613 11.264 5.73825 11.264 5.63583 11.205ZM5.47491 13.6272C3.82088 13.6272 2.60592 12.3717 2.60592 10.821C2.60592 10.7028 2.62061 10.5846 2.63517 10.4665L5.51871 12.1502C5.69432 12.2535 5.87006 12.2535 6.04567 12.1502L9.71964 10.0088V11.4119C9.71964 11.53 9.67571 11.6186 9.57328 11.6777L6.77746 13.3023C6.39688 13.5238 5.94323 13.6272 5.47491 13.6272ZM9.10494 15.3846C10.8761 15.3846 12.3544 14.1145 12.6912 12.4307C14.3305 12.0024 15.3845 10.4516 15.3845 8.87139C15.3845 7.8375 14.9453 6.83326 14.1549 6.10955C14.2281 5.79937 14.2721 5.48918 14.2721 5.17914C14.2721 3.06718 12.5741 1.48677 10.6126 1.48677C10.2175 1.48677 9.83689 1.54578 9.4563 1.67878C8.79753 1.02891 7.88999 0.615387 6.89473 0.615387C5.12357 0.615387 3.64528 1.88548 3.30848 3.56923C1.66914 3.99756 0.615234 5.54834 0.615234 7.1286C0.615234 8.1625 1.05431 9.16673 1.84474 9.89044C1.77155 10.2006 1.72762 10.5108 1.72762 10.8209C1.72762 12.9328 3.42558 14.5132 5.38704 14.5132C5.78218 14.5132 6.16278 14.4542 6.54336 14.3213C7.20198 14.9711 8.10953 15.3846 9.10494 15.3846Z"
|
||||
fill="var(--text-05)"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<rect width="16" height="16" fill="var(--text-05)" />
|
||||
<rect width="16" height="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default SvgOpenai;
|
||||
export default SvgOpenAI;
|
||||
|
||||
@@ -14,7 +14,7 @@ const SvgOpenrouter = ({ size, ...props }: IconProps) => (
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M33.6 0L48 8.19239V8.36602L33.6 16.4V12.2457L31.8202 12.1858C29.7043 12.1299 28.6014 12.1898 27.2887 12.4053C25.1628 12.7546 23.2168 13.5569 21.001 15.1035L16.6733 18.1071C16.1059 18.4962 15.6843 18.7776 15.3147 19.0151L14.2857 19.6577L13.4925 20.1247L14.2617 20.5837L15.3207 21.2583C16.2717 21.8849 17.6583 22.8469 20.7173 24.9823C22.9351 26.529 24.8791 27.3312 27.005 27.6805L27.6044 27.7703C28.991 27.9519 30.7029 27.9579 33.6 27.8362V23.6L48 31.7198V31.8934L33.6 40V36.284L31.9041 36.3279C29.1349 36.4117 27.6344 36.3319 25.6344 36.0046C22.2498 35.4458 19.1209 34.1566 15.8821 31.8954L11.5704 28.9019C11.0745 28.5603 10.5715 28.2289 10.0619 27.908L9.12887 27.3492C8.62495 27.0592 8.11878 26.7731 7.61039 26.491C5.81019 25.4912 1.12488 24.2658 0 24.2658V15.836C1.12687 15.822 6.09391 14.5946 7.89011 13.5928L9.92008 12.4353L10.7952 11.8884C11.6503 11.3296 12.9371 10.4396 16.1618 8.19039C19.4006 5.92925 22.5275 4.63803 25.9141 4.08123C28.2158 3.70204 29.9237 3.65614 33.6 3.80582V0Z"
|
||||
fill="var(--text-05)"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
const SvgVoyage = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 52 52"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M14.1848 8V8.11C14.1408 8.24212 14.1139 8.37935 14.1048 8.51833C14.0865 8.70167 14.0782 8.865 14.0782 9.01C14.0782 9.575 14.1498 10.2017 14.2915 10.8933C14.4532 11.5683 14.7482 12.4133 15.1765 13.4333L27.0515 40.71L38.5248 13.65C38.7932 12.9767 39.0798 12.24 39.3832 11.4383C39.6865 10.6383 39.8382 9.82833 39.8382 9.00833C39.8444 8.70074 39.7901 8.39492 39.6782 8.10833V8H45.1732V8.11C44.8332 8.455 44.4232 9.07333 43.9398 9.96667C43.4565 10.8583 42.9298 11.9583 42.3582 13.27L26.9982 48H24.8532L10.2982 14.6083C9.95818 13.825 9.60151 13.07 9.22484 12.3417C8.86818 11.6133 8.52818 10.9583 8.20818 10.375C7.88484 9.775 7.59984 9.275 7.34984 8.875C7.19246 8.61074 7.02226 8.35432 6.83984 8.10667V8H14.1848Z"
|
||||
className="fill-[#012E33] dark:fill-text-05"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgVoyage;
|
||||
@@ -1,373 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Text, Tag } from "@opal/components";
|
||||
import { Button } from "@opal/components/buttons/button/components";
|
||||
import { SvgUploadCloud } from "@opal/icons";
|
||||
import { IllustrationContent } from "@opal/layouts";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import Checkbox from "@/refresh-components/inputs/Checkbox";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import type { RuleResponse } from "@/app/admin/proposal-review/interfaces";
|
||||
import { RULE_TYPE_LABELS } from "@/app/admin/proposal-review/interfaces";
|
||||
|
||||
interface ImportFlowProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
rulesetId: string;
|
||||
onImportComplete: () => void;
|
||||
}
|
||||
|
||||
type ImportStep = "upload" | "processing" | "review";
|
||||
|
||||
function ImportFlow({
|
||||
open,
|
||||
onClose,
|
||||
rulesetId,
|
||||
onImportComplete,
|
||||
}: ImportFlowProps) {
|
||||
const [step, setStep] = useState<ImportStep>("upload");
|
||||
const [importedRules, setImportedRules] = useState<RuleResponse[]>([]);
|
||||
const [selectedRuleIds, setSelectedRuleIds] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [expandedRuleId, setExpandedRuleId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function handleReset() {
|
||||
setStep("upload");
|
||||
setImportedRules([]);
|
||||
setSelectedRuleIds(new Set());
|
||||
setExpandedRuleId(null);
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
handleReset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setStep("processing");
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await fetch(
|
||||
`/api/proposal-review/rulesets/${rulesetId}/import`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Failed to import checklist");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setImportedRules(data.rules);
|
||||
setSelectedRuleIds(new Set(data.rules.map((r: RuleResponse) => r.id)));
|
||||
setStep("review");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Import failed");
|
||||
setStep("upload");
|
||||
} finally {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRule(ruleId: string) {
|
||||
setSelectedRuleIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ruleId)) {
|
||||
next.delete(ruleId);
|
||||
} else {
|
||||
next.add(ruleId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelectAll() {
|
||||
setSelectedRuleIds(new Set(importedRules.map((r) => r.id)));
|
||||
}
|
||||
|
||||
function handleDeselectAll() {
|
||||
setSelectedRuleIds(new Set());
|
||||
}
|
||||
|
||||
async function handleAccept() {
|
||||
if (selectedRuleIds.size === 0) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const unselectedIds = importedRules
|
||||
.filter((r) => !selectedRuleIds.has(r.id))
|
||||
.map((r) => r.id);
|
||||
|
||||
// Activate selected rules
|
||||
const activateRes = await fetch(
|
||||
`/api/proposal-review/rulesets/${rulesetId}/rules/bulk-update`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "activate",
|
||||
rule_ids: Array.from(selectedRuleIds),
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!activateRes.ok) {
|
||||
const err = await activateRes.json();
|
||||
throw new Error(err.detail || "Failed to activate rules");
|
||||
}
|
||||
|
||||
// Delete unselected rules
|
||||
if (unselectedIds.length > 0) {
|
||||
const deleteRes = await fetch(
|
||||
`/api/proposal-review/rulesets/${rulesetId}/rules/bulk-update`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "delete",
|
||||
rule_ids: unselectedIds,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!deleteRes.ok) {
|
||||
const err = await deleteRes.json();
|
||||
throw new Error(err.detail || "Failed to clean up unselected rules");
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`${selectedRuleIds.size} rule${
|
||||
selectedRuleIds.size === 1 ? "" : "s"
|
||||
} imported.`
|
||||
);
|
||||
onImportComplete();
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save rules");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<Modal.Content width="sm" height="lg">
|
||||
<Modal.Header
|
||||
icon={SvgUploadCloud}
|
||||
title="Import from Checklist"
|
||||
description="Upload a checklist document to generate rules automatically."
|
||||
onClose={handleClose}
|
||||
/>
|
||||
|
||||
<Modal.Body>
|
||||
{step === "upload" && (
|
||||
<>
|
||||
<IllustrationContent
|
||||
illustration={SvgUploadCloud}
|
||||
title="Upload a checklist document (.xlsx, .docx, or .pdf)"
|
||||
description="The document will be analyzed to extract review rules."
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.docx,.pdf"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex justify-center w-full">
|
||||
<Button
|
||||
icon={SvgUploadCloud}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Choose File
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "processing" && (
|
||||
<>
|
||||
<div className="flex justify-center w-full">
|
||||
<SimpleLoader />
|
||||
</div>
|
||||
<IllustrationContent
|
||||
title="Analyzing document and generating rules..."
|
||||
description="This may take up to a minute."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{importedRules.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-8">
|
||||
<Text font="main-ui-body" color="text-03">
|
||||
No rules were generated from the uploaded document.
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<Text font="main-ui-body" color="text-03">
|
||||
{`${importedRules.length} rules generated - ${selectedRuleIds.size} selected`}
|
||||
</Text>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
Select All
|
||||
</Button>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
onClick={handleDeselectAll}
|
||||
>
|
||||
Deselect All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col border border-border-02 rounded-08 overflow-hidden">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-background-tint-01 border-b border-border-02">
|
||||
<div className="w-6" />
|
||||
<div className="flex-1">
|
||||
<Text font="main-ui-action" color="text-03">
|
||||
Name
|
||||
</Text>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Text font="main-ui-action" color="text-03">
|
||||
Type
|
||||
</Text>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Text font="main-ui-action" color="text-03">
|
||||
Category
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rule rows */}
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{importedRules.map((rule) => (
|
||||
<React.Fragment key={rule.id}>
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-border-01 cursor-pointer hover:bg-background-tint-01"
|
||||
onClick={() =>
|
||||
setExpandedRuleId(
|
||||
expandedRuleId === rule.id ? null : rule.id
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="w-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRuleIds.has(rule.id)}
|
||||
onCheckedChange={() => toggleRule(rule.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Text font="main-ui-body" color="text-04">
|
||||
{rule.name}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Tag
|
||||
title={RULE_TYPE_LABELS[rule.rule_type]}
|
||||
color="gray"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{rule.category || "-"}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
{expandedRuleId === rule.id && (
|
||||
<div className="flex flex-col gap-2 px-4 py-3 bg-background-neutral-01 border-b border-border-01">
|
||||
{rule.description && (
|
||||
<div>
|
||||
<Text font="main-ui-action" color="text-03">
|
||||
Description
|
||||
</Text>
|
||||
<Text
|
||||
font="secondary-body"
|
||||
color="text-03"
|
||||
as="p"
|
||||
>
|
||||
{rule.description}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Text font="main-ui-action" color="text-03">
|
||||
Prompt Template
|
||||
</Text>
|
||||
<pre className="p-2 bg-background-neutral-02 rounded-08 text-sm font-mono text-text-02 whitespace-pre-wrap overflow-x-auto">
|
||||
{rule.prompt_template}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
{step === "review" && importedRules.length > 0 && (
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
prominence="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={saving}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAccept}
|
||||
disabled={saving || selectedRuleIds.size === 0}
|
||||
>
|
||||
{saving
|
||||
? "Saving..."
|
||||
: `Accept ${selectedRuleIds.size} Rule${
|
||||
selectedRuleIds.size === 1 ? "" : "s"
|
||||
}`}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
)}
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportFlow;
|
||||
@@ -1,359 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Form, Formik } from "formik";
|
||||
import { Button, Text } from "@opal/components";
|
||||
import { SvgEdit, SvgPlus } from "@opal/icons";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import InputTextArea from "@/refresh-components/inputs/InputTextArea";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { FormikField } from "@/refresh-components/form/FormikField";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import { Vertical as VerticalInput } from "@/layouts/input-layouts";
|
||||
import type {
|
||||
RuleResponse,
|
||||
RuleCreate,
|
||||
RuleUpdate,
|
||||
RuleType,
|
||||
RuleIntent,
|
||||
RuleAuthority,
|
||||
} from "@/app/admin/proposal-review/interfaces";
|
||||
import {
|
||||
RULE_TYPE_LABELS,
|
||||
RULE_INTENT_LABELS,
|
||||
RULE_AUTHORITY_LABELS,
|
||||
} from "@/app/admin/proposal-review/interfaces";
|
||||
|
||||
interface RuleEditorProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (rule: RuleCreate | RuleUpdate) => Promise<void>;
|
||||
existingRule?: RuleResponse | null;
|
||||
}
|
||||
|
||||
interface RuleFormValues {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
rule_type: RuleType;
|
||||
rule_intent: RuleIntent;
|
||||
authority: RuleAuthority | "none";
|
||||
is_hard_stop: string;
|
||||
prompt_template: string;
|
||||
}
|
||||
|
||||
function RuleEditor({ open, onClose, onSave, existingRule }: RuleEditorProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const initialValues: RuleFormValues = existingRule
|
||||
? {
|
||||
name: existingRule.name,
|
||||
description: existingRule.description || "",
|
||||
category: existingRule.category || "",
|
||||
rule_type: existingRule.rule_type,
|
||||
rule_intent: existingRule.rule_intent,
|
||||
authority: existingRule.authority || "none",
|
||||
is_hard_stop: existingRule.is_hard_stop ? "yes" : "no",
|
||||
prompt_template: existingRule.prompt_template,
|
||||
}
|
||||
: {
|
||||
name: "",
|
||||
description: "",
|
||||
category: "",
|
||||
rule_type: "DOCUMENT_CHECK" as RuleType,
|
||||
rule_intent: "CHECK" as RuleIntent,
|
||||
authority: "none" as const,
|
||||
is_hard_stop: "no",
|
||||
prompt_template: "",
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<Modal.Content
|
||||
width="md"
|
||||
height="lg"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Modal.Header
|
||||
icon={existingRule ? SvgEdit : SvgPlus}
|
||||
title={existingRule ? "Edit Rule" : "Add Rule"}
|
||||
description={
|
||||
existingRule
|
||||
? "Update the rule configuration."
|
||||
: "Define a new rule for this ruleset."
|
||||
}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={async (values, { setSubmitting }) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ruleData = {
|
||||
name: values.name.trim(),
|
||||
description: values.description.trim() || undefined,
|
||||
category: values.category.trim() || undefined,
|
||||
rule_type: values.rule_type,
|
||||
rule_intent: values.rule_intent,
|
||||
prompt_template: values.prompt_template,
|
||||
authority:
|
||||
values.authority === "none"
|
||||
? null
|
||||
: (values.authority as RuleAuthority),
|
||||
is_hard_stop: values.is_hard_stop === "yes",
|
||||
};
|
||||
await onSave(ruleData);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to save rule"
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, values }) => (
|
||||
<Form className="w-full">
|
||||
<Modal.Body>
|
||||
<VerticalInput
|
||||
name="name"
|
||||
title="Name"
|
||||
nonInteractive
|
||||
sizePreset="main-ui"
|
||||
>
|
||||
<FormikField<string>
|
||||
name="name"
|
||||
render={(field, helper) => (
|
||||
<InputTypeIn
|
||||
{...field}
|
||||
placeholder="Rule name"
|
||||
onClear={() => helper.setValue("")}
|
||||
showClearButton={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</VerticalInput>
|
||||
|
||||
<VerticalInput
|
||||
name="description"
|
||||
title="Description"
|
||||
nonInteractive
|
||||
sizePreset="main-ui"
|
||||
>
|
||||
<FormikField<string>
|
||||
name="description"
|
||||
render={(field, helper) => (
|
||||
<InputTypeIn
|
||||
{...field}
|
||||
placeholder="Brief description of what this rule checks"
|
||||
onClear={() => helper.setValue("")}
|
||||
showClearButton={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</VerticalInput>
|
||||
|
||||
<VerticalInput
|
||||
name="category"
|
||||
title="Category"
|
||||
nonInteractive
|
||||
sizePreset="main-ui"
|
||||
>
|
||||
<FormikField<string>
|
||||
name="category"
|
||||
render={(field, helper) => (
|
||||
<InputTypeIn
|
||||
{...field}
|
||||
placeholder="e.g., IR-2: Regulatory Compliance"
|
||||
onClear={() => helper.setValue("")}
|
||||
showClearButton={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</VerticalInput>
|
||||
|
||||
<div className="flex gap-4 w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<VerticalInput
|
||||
name="rule_type"
|
||||
title="Rule Type"
|
||||
nonInteractive
|
||||
sizePreset="main-ui"
|
||||
>
|
||||
<FormikField<string>
|
||||
name="rule_type"
|
||||
render={(field, helper) => (
|
||||
<InputSelect
|
||||
value={field.value}
|
||||
onValueChange={(v) => helper.setValue(v)}
|
||||
>
|
||||
<InputSelect.Trigger placeholder="Select type" />
|
||||
<InputSelect.Content>
|
||||
{Object.entries(RULE_TYPE_LABELS).map(
|
||||
([key, label]) => (
|
||||
<InputSelect.Item key={key} value={key}>
|
||||
{label}
|
||||
</InputSelect.Item>
|
||||
)
|
||||
)}
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
)}
|
||||
/>
|
||||
</VerticalInput>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<VerticalInput
|
||||
name="rule_intent"
|
||||
title="Intent"
|
||||
nonInteractive
|
||||
sizePreset="main-ui"
|
||||
>
|
||||
<FormikField<string>
|
||||
name="rule_intent"
|
||||
render={(field, helper) => (
|
||||
<InputSelect
|
||||
value={field.value}
|
||||
onValueChange={(v) => helper.setValue(v)}
|
||||
>
|
||||
<InputSelect.Trigger placeholder="Select intent" />
|
||||
<InputSelect.Content>
|
||||
{Object.entries(RULE_INTENT_LABELS).map(
|
||||
([key, label]) => (
|
||||
<InputSelect.Item key={key} value={key}>
|
||||
{label}
|
||||
</InputSelect.Item>
|
||||
)
|
||||
)}
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
)}
|
||||
/>
|
||||
</VerticalInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<VerticalInput
|
||||
name="authority"
|
||||
title="Authority"
|
||||
nonInteractive
|
||||
sizePreset="main-ui"
|
||||
>
|
||||
<FormikField<string>
|
||||
name="authority"
|
||||
render={(field, helper) => (
|
||||
<InputSelect
|
||||
value={field.value}
|
||||
onValueChange={(v) => helper.setValue(v)}
|
||||
>
|
||||
<InputSelect.Trigger placeholder="Select authority" />
|
||||
<InputSelect.Content>
|
||||
<InputSelect.Item value="none">
|
||||
None
|
||||
</InputSelect.Item>
|
||||
{Object.entries(RULE_AUTHORITY_LABELS).map(
|
||||
([key, label]) => (
|
||||
<InputSelect.Item key={key} value={key}>
|
||||
{label}
|
||||
</InputSelect.Item>
|
||||
)
|
||||
)}
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
)}
|
||||
/>
|
||||
</VerticalInput>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<VerticalInput
|
||||
name="is_hard_stop"
|
||||
title="Hard Stop"
|
||||
nonInteractive
|
||||
sizePreset="main-ui"
|
||||
>
|
||||
<FormikField<string>
|
||||
name="is_hard_stop"
|
||||
render={(field, helper) => (
|
||||
<InputSelect
|
||||
value={field.value}
|
||||
onValueChange={(v) => helper.setValue(v)}
|
||||
>
|
||||
<InputSelect.Trigger />
|
||||
<InputSelect.Content>
|
||||
<InputSelect.Item value="no">No</InputSelect.Item>
|
||||
<InputSelect.Item value="yes">
|
||||
Yes - Fail stops entire review
|
||||
</InputSelect.Item>
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
)}
|
||||
/>
|
||||
</VerticalInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VerticalInput
|
||||
name="prompt_template"
|
||||
title="Prompt Template"
|
||||
nonInteractive
|
||||
sizePreset="main-ui"
|
||||
>
|
||||
<Text font="secondary-body" color="text-04">
|
||||
{
|
||||
"Available variables: {{proposal_text}}, {{metadata}}, {{foa_text}}"
|
||||
}
|
||||
</Text>
|
||||
<FormikField<string>
|
||||
name="prompt_template"
|
||||
render={(field, helper) => (
|
||||
<InputTextArea
|
||||
value={field.value}
|
||||
onChange={(e) => helper.setValue(e.target.value)}
|
||||
placeholder="Enter the LLM prompt template for evaluating this rule..."
|
||||
rows={8}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</VerticalInput>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
prominence="secondary"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!values.name.trim() ||
|
||||
!values.prompt_template.trim()
|
||||
}
|
||||
>
|
||||
{isSubmitting
|
||||
? "Saving..."
|
||||
: existingRule
|
||||
? "Update Rule"
|
||||
: "Add Rule"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RuleEditor;
|
||||
@@ -1,364 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import useSWR from "swr";
|
||||
import { Text } from "@opal/components";
|
||||
import { Button } from "@opal/components/buttons/button/components";
|
||||
import Checkbox from "@/refresh-components/inputs/Checkbox";
|
||||
import InputComboBox from "@/refresh-components/inputs/InputComboBox";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { SvgPlus, SvgTrash } from "@opal/icons";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { Content } from "@opal/layouts";
|
||||
import type {
|
||||
ConfigResponse,
|
||||
ConfigUpdate,
|
||||
JiraConnectorInfo,
|
||||
} from "@/app/admin/proposal-review/interfaces";
|
||||
|
||||
const CONNECTORS_URL = "/api/proposal-review/jira-connectors";
|
||||
|
||||
interface SettingsFormProps {
|
||||
config: ConfigResponse;
|
||||
onSave: (update: ConfigUpdate) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function SettingsForm({ config, onSave, onCancel }: SettingsFormProps) {
|
||||
const [connectorId, setConnectorId] = useState<number | null>(
|
||||
config.jira_connector_id
|
||||
);
|
||||
const [visibleFields, setVisibleFields] = useState<string[]>(
|
||||
config.field_mapping ?? []
|
||||
);
|
||||
const [jiraWriteback, setJiraWriteback] = useState<Record<string, string>>(
|
||||
(config.jira_writeback as Record<string, string>) || {}
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [fieldSearch, setFieldSearch] = useState("");
|
||||
|
||||
// Writeback add-row state
|
||||
const [newWritebackKey, setNewWritebackKey] = useState("");
|
||||
const [newWritebackField, setNewWritebackField] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setConnectorId(config.jira_connector_id);
|
||||
setVisibleFields(config.field_mapping ?? []);
|
||||
setJiraWriteback((config.jira_writeback as Record<string, string>) || {});
|
||||
}, [config]);
|
||||
|
||||
// Fetch available Jira connectors
|
||||
const { data: connectors, isLoading: connectorsLoading } = useSWR<
|
||||
JiraConnectorInfo[]
|
||||
>(CONNECTORS_URL, errorHandlingFetcher);
|
||||
|
||||
// Fetch metadata keys from indexed documents for the selected connector
|
||||
const { data: metadataKeys, isLoading: fieldsLoading } = useSWR<string[]>(
|
||||
connectorId
|
||||
? `/api/proposal-review/jira-connectors/${connectorId}/metadata-keys`
|
||||
: null,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const selectedConnector = (connectors ?? []).find(
|
||||
(c) => c.id === connectorId
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
jira_connector_id: connectorId,
|
||||
jira_project_key: selectedConnector?.project_key || null,
|
||||
field_mapping: visibleFields.length > 0 ? visibleFields : null,
|
||||
jira_writeback:
|
||||
Object.keys(jiraWriteback).length > 0 ? jiraWriteback : null,
|
||||
});
|
||||
toast.success("Settings saved.");
|
||||
} catch {
|
||||
toast.error("Failed to save settings.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleField(key: string) {
|
||||
setVisibleFields((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
|
||||
);
|
||||
}
|
||||
|
||||
function handleAddWriteback() {
|
||||
if (!newWritebackKey) return;
|
||||
setJiraWriteback({
|
||||
...jiraWriteback,
|
||||
[newWritebackKey]: newWritebackField,
|
||||
});
|
||||
setNewWritebackKey("");
|
||||
setNewWritebackField("");
|
||||
}
|
||||
|
||||
const writebackEntries = Object.entries(jiraWriteback);
|
||||
|
||||
// Filter metadata keys by search
|
||||
const allKeys = metadataKeys ?? [];
|
||||
const filteredKeys = fieldSearch
|
||||
? allKeys.filter((k) => k.toLowerCase().includes(fieldSearch.toLowerCase()))
|
||||
: allKeys;
|
||||
|
||||
return (
|
||||
<Section gap={2} alignItems="stretch" height="auto">
|
||||
{/* Jira Connector Selection */}
|
||||
<Section gap={1} alignItems="stretch" height="auto">
|
||||
<Content
|
||||
sizePreset="section"
|
||||
variant="section"
|
||||
title="Jira Connector"
|
||||
description="Select which Jira connector to use for proposal sourcing."
|
||||
/>
|
||||
|
||||
<Section gap={0.25} alignItems="start" height="auto">
|
||||
<Text font="main-ui-action" color="text-04">
|
||||
Connector
|
||||
</Text>
|
||||
{connectorsLoading ? (
|
||||
<Text font="main-ui-body" color="text-03" as="p">
|
||||
Loading connectors...
|
||||
</Text>
|
||||
) : connectors && connectors.length > 0 ? (
|
||||
<InputSelect
|
||||
value={connectorId != null ? String(connectorId) : undefined}
|
||||
onValueChange={(val) => {
|
||||
const newId = val ? Number(val) : null;
|
||||
if (newId !== connectorId) {
|
||||
setConnectorId(newId);
|
||||
setVisibleFields([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputSelect.Trigger placeholder="Select a Jira connector..." />
|
||||
<InputSelect.Content>
|
||||
{connectors.map((c) => (
|
||||
<InputSelect.Item
|
||||
key={c.id}
|
||||
value={String(c.id)}
|
||||
description={
|
||||
c.project_key ? `Project: ${c.project_key}` : undefined
|
||||
}
|
||||
>
|
||||
{c.name}
|
||||
</InputSelect.Item>
|
||||
))}
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
) : (
|
||||
<Text font="main-ui-body" color="text-03" as="p">
|
||||
No Jira connectors found. Add one in the Connectors settings
|
||||
first.
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Separator noPadding />
|
||||
|
||||
{/* Visible Fields Checklist */}
|
||||
<Section gap={1} alignItems="stretch" height="auto">
|
||||
<Content
|
||||
sizePreset="section"
|
||||
variant="section"
|
||||
title="Visible Fields"
|
||||
description="Choose which metadata fields to display in the proposal queue and review interface. If none are selected, all fields are shown."
|
||||
/>
|
||||
|
||||
{fieldsLoading && connectorId && (
|
||||
<Text font="secondary-body" color="text-03" as="p">
|
||||
Loading fields...
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!fieldsLoading && connectorId && allKeys.length > 0 && (
|
||||
<>
|
||||
<InputTypeIn
|
||||
placeholder="Filter fields..."
|
||||
value={fieldSearch}
|
||||
onChange={(e) => setFieldSearch(e.target.value)}
|
||||
onClear={() => setFieldSearch("")}
|
||||
leftSearchIcon
|
||||
/>
|
||||
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto">
|
||||
{filteredKeys.map((key) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-3 px-2 py-1.5 rounded-8 cursor-pointer hover:bg-background-neutral-02"
|
||||
>
|
||||
<Checkbox
|
||||
checked={visibleFields.includes(key)}
|
||||
onCheckedChange={() => toggleField(key)}
|
||||
/>
|
||||
<Text font="main-ui-body" color="text-04">
|
||||
{key}
|
||||
</Text>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{visibleFields.length > 0 && (
|
||||
<Text font="secondary-body" color="text-03" as="p">
|
||||
{`${visibleFields.length} field${
|
||||
visibleFields.length !== 1 ? "s" : ""
|
||||
} selected`}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!fieldsLoading && connectorId && allKeys.length === 0 && (
|
||||
<Text font="secondary-body" color="text-03" as="p">
|
||||
No metadata fields found. Make sure the connector has indexed some
|
||||
documents.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!connectorId && (
|
||||
<Text font="secondary-body" color="text-03" as="p">
|
||||
Select a connector above to see available fields.
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Separator noPadding />
|
||||
|
||||
{/* Write-back Configuration */}
|
||||
<Section gap={1} alignItems="stretch" height="auto">
|
||||
<Content
|
||||
sizePreset="section"
|
||||
variant="section"
|
||||
title="Write-back Configuration"
|
||||
description="Map review outcomes to Jira custom fields for automatic status sync."
|
||||
/>
|
||||
|
||||
{writebackEntries.length > 0 && (
|
||||
<Section gap={0.5} alignItems="stretch" height="auto">
|
||||
<div className="flex gap-3 px-1">
|
||||
<span className="flex-1">
|
||||
<Text font="secondary-action" color="text-03">
|
||||
Outcome
|
||||
</Text>
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
<Text font="secondary-action" color="text-03">
|
||||
Jira Field
|
||||
</Text>
|
||||
</span>
|
||||
<div className="w-8" />
|
||||
</div>
|
||||
{writebackEntries.map(([key, value]) => (
|
||||
<Section
|
||||
key={key}
|
||||
flexDirection="row"
|
||||
gap={0.75}
|
||||
alignItems="center"
|
||||
height="auto"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Text font="main-ui-body" color="text-04">
|
||||
{key}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Text font="main-ui-body" color="text-04">
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
icon={SvgTrash}
|
||||
prominence="tertiary"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const updated = { ...jiraWriteback };
|
||||
delete updated[key];
|
||||
setJiraWriteback(updated);
|
||||
}}
|
||||
tooltip="Remove"
|
||||
/>
|
||||
</Section>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{connectorId && (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
gap={0.75}
|
||||
alignItems="center"
|
||||
height="auto"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<InputSelect
|
||||
value={newWritebackKey || undefined}
|
||||
onValueChange={setNewWritebackKey}
|
||||
>
|
||||
<InputSelect.Trigger placeholder="Select outcome..." />
|
||||
<InputSelect.Content>
|
||||
{["decision_field_id", "completion_field_id"].map((key) => (
|
||||
<InputSelect.Item key={key} value={key}>
|
||||
{key === "decision_field_id"
|
||||
? "Decision Field"
|
||||
: "Completion % Field"}
|
||||
</InputSelect.Item>
|
||||
))}
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{allKeys.length > 0 ? (
|
||||
<InputComboBox
|
||||
placeholder="Search fields..."
|
||||
value={newWritebackField}
|
||||
onValueChange={setNewWritebackField}
|
||||
options={allKeys.map((key) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
}))}
|
||||
strict
|
||||
leftSearchIcon
|
||||
/>
|
||||
) : (
|
||||
<Text font="secondary-body" color="text-03" as="p">
|
||||
Select a connector first
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
icon={SvgPlus}
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
onClick={handleAddWriteback}
|
||||
disabled={!newWritebackKey || !newWritebackField}
|
||||
tooltip="Add entry"
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Separator noPadding />
|
||||
|
||||
{/* Actions */}
|
||||
<Section flexDirection="row" gap={0.75} alignItems="center" height="auto">
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button prominence="secondary" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsForm;
|
||||
@@ -1,145 +0,0 @@
|
||||
/** Shared types for Proposal Review (Argus) admin pages. */
|
||||
|
||||
export interface RulesetResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
rules: RuleResponse[];
|
||||
}
|
||||
|
||||
export interface RuleResponse {
|
||||
id: string;
|
||||
ruleset_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
rule_type: RuleType;
|
||||
rule_intent: RuleIntent;
|
||||
prompt_template: string;
|
||||
source: RuleSource;
|
||||
authority: RuleAuthority | null;
|
||||
is_hard_stop: boolean;
|
||||
priority: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type RuleType =
|
||||
| "DOCUMENT_CHECK"
|
||||
| "METADATA_CHECK"
|
||||
| "CROSS_REFERENCE"
|
||||
| "CUSTOM_NL";
|
||||
|
||||
export type RuleIntent = "CHECK" | "HIGHLIGHT";
|
||||
|
||||
export type RuleSource = "IMPORTED" | "MANUAL";
|
||||
|
||||
export type RuleAuthority = "OVERRIDE" | "RETURN";
|
||||
|
||||
export interface RulesetCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface RulesetUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
is_default?: boolean;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface RuleCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
rule_type: RuleType;
|
||||
rule_intent?: RuleIntent;
|
||||
prompt_template: string;
|
||||
source?: RuleSource;
|
||||
authority?: RuleAuthority | null;
|
||||
is_hard_stop?: boolean;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface RuleUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
rule_type?: RuleType;
|
||||
rule_intent?: RuleIntent;
|
||||
prompt_template?: string;
|
||||
authority?: RuleAuthority | null;
|
||||
is_hard_stop?: boolean;
|
||||
priority?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface BulkRuleUpdateRequest {
|
||||
action: "activate" | "deactivate" | "delete";
|
||||
rule_ids: string[];
|
||||
}
|
||||
|
||||
export interface BulkRuleUpdateResponse {
|
||||
updated_count: number;
|
||||
}
|
||||
|
||||
export interface ImportResponse {
|
||||
rules_created: number;
|
||||
rules: RuleResponse[];
|
||||
}
|
||||
|
||||
export interface ConfigResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
jira_connector_id: number | null;
|
||||
jira_project_key: string | null;
|
||||
field_mapping: string[] | null;
|
||||
jira_writeback: Record<string, string> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ConfigUpdate {
|
||||
jira_connector_id?: number | null;
|
||||
jira_project_key?: string | null;
|
||||
field_mapping?: string[] | null;
|
||||
jira_writeback?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
export interface JiraConnectorInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
project_key: string;
|
||||
project_url: string;
|
||||
}
|
||||
|
||||
/** Labels for display purposes. */
|
||||
export const RULE_TYPE_LABELS: Record<RuleType, string> = {
|
||||
DOCUMENT_CHECK: "Document Check",
|
||||
METADATA_CHECK: "Metadata Check",
|
||||
CROSS_REFERENCE: "Cross Reference",
|
||||
CUSTOM_NL: "Custom NL",
|
||||
};
|
||||
|
||||
export const RULE_INTENT_LABELS: Record<RuleIntent, string> = {
|
||||
CHECK: "Check",
|
||||
HIGHLIGHT: "Highlight",
|
||||
};
|
||||
|
||||
export const RULE_SOURCE_LABELS: Record<RuleSource, string> = {
|
||||
IMPORTED: "Imported",
|
||||
MANUAL: "Manual",
|
||||
};
|
||||
|
||||
export const RULE_AUTHORITY_LABELS: Record<string, string> = {
|
||||
OVERRIDE: "Override",
|
||||
RETURN: "Return",
|
||||
};
|
||||
@@ -1,505 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { Button, Text, Tag } from "@opal/components";
|
||||
import { Content, IllustrationContent } from "@opal/layouts";
|
||||
import SvgNoResult from "@opal/illustrations/no-result";
|
||||
import {
|
||||
SvgCheckSquare,
|
||||
SvgEdit,
|
||||
SvgMoreHorizontal,
|
||||
SvgSettings,
|
||||
SvgTrash,
|
||||
} from "@opal/icons";
|
||||
import { Form, Formik } from "formik";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import { FormikField } from "@/refresh-components/form/FormikField";
|
||||
import AdminListHeader from "@/sections/admin/AdminListHeader";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import { markdown } from "@opal/utils";
|
||||
import { Table } from "@opal/components";
|
||||
import { createTableColumns } from "@opal/components/table/columns";
|
||||
import { Vertical as VerticalInput } from "@/layouts/input-layouts";
|
||||
import type {
|
||||
RulesetResponse,
|
||||
RulesetCreate,
|
||||
RulesetUpdate,
|
||||
} from "@/app/admin/proposal-review/interfaces";
|
||||
|
||||
const API_URL = "/api/proposal-review/rulesets";
|
||||
const route = ADMIN_ROUTES.PROPOSAL_REVIEW;
|
||||
|
||||
const tc = createTableColumns<RulesetResponse>();
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function RulesetsPage() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
data: rulesets,
|
||||
isLoading,
|
||||
error,
|
||||
} = useSWR<RulesetResponse[]>(API_URL, errorHandlingFetcher);
|
||||
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<RulesetResponse | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RulesetResponse | null>(
|
||||
null
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredRulesets = (rulesets ?? []).filter(
|
||||
(rs) =>
|
||||
!search ||
|
||||
rs.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(rs.description ?? "").toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
function handleEditOpen(ruleset: RulesetResponse) {
|
||||
setEditTarget(ruleset);
|
||||
}
|
||||
|
||||
async function handleDelete(ruleset: RulesetResponse) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/${ruleset.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Failed to delete ruleset");
|
||||
}
|
||||
await mutate(API_URL);
|
||||
setDeleteTarget(null);
|
||||
toast.success("Ruleset deleted.");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to delete ruleset"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
tc.qualifier({
|
||||
content: "icon",
|
||||
getContent: () => SvgCheckSquare,
|
||||
}),
|
||||
tc.column("name", {
|
||||
header: "Name",
|
||||
weight: 30,
|
||||
cell: (value, row) =>
|
||||
row.description ? (
|
||||
<Content
|
||||
title={value}
|
||||
description={row.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
) : (
|
||||
<Content title={value} sizePreset="main-ui" variant="body" />
|
||||
),
|
||||
}),
|
||||
tc.displayColumn({
|
||||
id: "rules_count",
|
||||
header: "Rules",
|
||||
width: { weight: 10, minWidth: 80 },
|
||||
cell: (row) => (
|
||||
<Text font="main-ui-body" color="text-03">
|
||||
{String(row.rules.length)}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.displayColumn({
|
||||
id: "status",
|
||||
header: "Status",
|
||||
width: { weight: 15, minWidth: 100 },
|
||||
cell: (row) => (
|
||||
<Tag
|
||||
title={row.is_active ? "Active" : "Inactive"}
|
||||
color={row.is_active ? "green" : "gray"}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
tc.displayColumn({
|
||||
id: "default",
|
||||
header: "Default",
|
||||
width: { weight: 10, minWidth: 80 },
|
||||
cell: (row) =>
|
||||
row.is_default ? <Tag title="Default" color="blue" /> : null,
|
||||
}),
|
||||
tc.column("updated_at", {
|
||||
header: "Last Modified",
|
||||
weight: 15,
|
||||
cell: (value) => (
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{formatDate(value)}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.actions({
|
||||
cell: (row) => (
|
||||
<div className="flex flex-row gap-1">
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
icon={SvgMoreHorizontal}
|
||||
prominence="tertiary"
|
||||
tooltip="More"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content side="bottom" align="end" width="md">
|
||||
<PopoverMenu>
|
||||
<LineItem icon={SvgEdit} onClick={() => handleEditOpen(row)}>
|
||||
Edit Ruleset
|
||||
</LineItem>
|
||||
<LineItem
|
||||
icon={SvgTrash}
|
||||
danger
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
Delete Ruleset
|
||||
</LineItem>
|
||||
</PopoverMenu>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
],
|
||||
[] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SettingsLayouts.Root width="lg">
|
||||
<SettingsLayouts.Header
|
||||
title={route.title}
|
||||
icon={route.icon}
|
||||
description="Manage review rulesets for automated proposal evaluation."
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title="Failed to load rulesets."
|
||||
description="Please check the console for more details."
|
||||
/>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsLayouts.Root width="lg">
|
||||
<SettingsLayouts.Header
|
||||
title={route.title}
|
||||
icon={route.icon}
|
||||
description="Manage review rulesets for automated proposal evaluation."
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<SimpleLoader />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const hasRulesets = (rulesets ?? []).length > 0;
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root width="lg">
|
||||
<SettingsLayouts.Header
|
||||
title={route.title}
|
||||
icon={route.icon}
|
||||
description="Manage review rulesets for automated proposal evaluation."
|
||||
separator
|
||||
rightChildren={
|
||||
<Button
|
||||
icon={SvgSettings}
|
||||
prominence="secondary"
|
||||
onClick={() => router.push("/admin/proposal-review/settings")}
|
||||
>
|
||||
Jira Integration
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingsLayouts.Body>
|
||||
<div className="flex flex-col">
|
||||
<AdminListHeader
|
||||
hasItems={hasRulesets}
|
||||
searchQuery={search}
|
||||
onSearchQueryChange={setSearch}
|
||||
placeholder="Search rulesets..."
|
||||
emptyStateText="Create rulesets to define automated proposal review rules."
|
||||
onAction={() => setShowCreateForm(true)}
|
||||
actionLabel="New Ruleset"
|
||||
/>
|
||||
|
||||
{hasRulesets && (
|
||||
<Table
|
||||
data={filteredRulesets}
|
||||
getRowId={(row) => row.id}
|
||||
columns={columns}
|
||||
searchTerm={search}
|
||||
onRowClick={(row) =>
|
||||
router.push(`/admin/proposal-review/rulesets/${row.id}`)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingsLayouts.Body>
|
||||
|
||||
{/* Create Ruleset Modal */}
|
||||
{showCreateForm && (
|
||||
<Modal open onOpenChange={() => setShowCreateForm(false)}>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgCheckSquare}
|
||||
title="New Ruleset"
|
||||
description="Create a new set of review rules."
|
||||
onClose={() => setShowCreateForm(false)}
|
||||
/>
|
||||
<Formik
|
||||
initialValues={{ name: "", description: "" }}
|
||||
onSubmit={async (values, { setSubmitting }) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/proposal-review/rulesets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const created = await res.json();
|
||||
toast.success("Ruleset created. Add rules to get started.");
|
||||
setShowCreateForm(false);
|
||||
router.push(`/admin/proposal-review/rulesets/${created.id}`);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to create ruleset."
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, values }) => (
|
||||
<Form className="w-full">
|
||||
<Modal.Body>
|
||||
<VerticalInput
|
||||
name="name"
|
||||
title="Name"
|
||||
nonInteractive
|
||||
sizePreset="main-ui"
|
||||
>
|
||||
<FormikField<string>
|
||||
name="name"
|
||||
render={(field, helper) => (
|
||||
<InputTypeIn
|
||||
{...field}
|
||||
placeholder="e.g., Institutional Review"
|
||||
onClear={() => helper.setValue("")}
|
||||
showClearButton={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</VerticalInput>
|
||||
<VerticalInput
|
||||
name="description"
|
||||
title="Description"
|
||||
nonInteractive
|
||||
sizePreset="main-ui"
|
||||
>
|
||||
<FormikField<string>
|
||||
name="description"
|
||||
render={(field, helper) => (
|
||||
<InputTypeIn
|
||||
{...field}
|
||||
placeholder="Optional description"
|
||||
onClear={() => helper.setValue("")}
|
||||
showClearButton={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</VerticalInput>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
prominence="secondary"
|
||||
type="button"
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !values.name.trim()}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Edit Ruleset Modal */}
|
||||
{editTarget && (
|
||||
<Modal open onOpenChange={() => setEditTarget(null)}>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgEdit}
|
||||
title="Edit Ruleset"
|
||||
description="Update the ruleset name and description."
|
||||
onClose={() => setEditTarget(null)}
|
||||
/>
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: editTarget.name,
|
||||
description: editTarget.description || "",
|
||||
}}
|
||||
onSubmit={async (values, { setSubmitting }) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/proposal-review/rulesets/${editTarget.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
toast.success("Ruleset updated.");
|
||||
mutate(API_URL);
|
||||
setEditTarget(null);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to update ruleset."
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, values }) => (
|
||||
<Form className="w-full">
|
||||
<Modal.Body>
|
||||
<VerticalInput
|
||||
name="name"
|
||||
title="Name"
|
||||
nonInteractive
|
||||
sizePreset="main-ui"
|
||||
>
|
||||
<FormikField<string>
|
||||
name="name"
|
||||
render={(field, helper) => (
|
||||
<InputTypeIn
|
||||
{...field}
|
||||
placeholder="Ruleset name"
|
||||
onClear={() => helper.setValue("")}
|
||||
showClearButton={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</VerticalInput>
|
||||
<VerticalInput
|
||||
name="description"
|
||||
title="Description"
|
||||
nonInteractive
|
||||
sizePreset="main-ui"
|
||||
>
|
||||
<FormikField<string>
|
||||
name="description"
|
||||
render={(field, helper) => (
|
||||
<InputTypeIn
|
||||
{...field}
|
||||
placeholder="Optional description"
|
||||
onClear={() => helper.setValue("")}
|
||||
showClearButton={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</VerticalInput>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
prominence="secondary"
|
||||
type="button"
|
||||
onClick={() => setEditTarget(null)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !values.name.trim()}
|
||||
>
|
||||
{isSubmitting ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{deleteTarget && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgTrash}
|
||||
title="Delete Ruleset"
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
submit={
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={async () => {
|
||||
const target = deleteTarget;
|
||||
setDeleteTarget(null);
|
||||
await handleDelete(target);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Text as="p" color="text-03">
|
||||
{markdown(
|
||||
`Are you sure you want to delete *${deleteTarget.name}*? All rules within this ruleset will also be deleted. This action cannot be undone.`
|
||||
)}
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <RulesetsPage />;
|
||||
}
|
||||
@@ -1,577 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { Button, Text, Tag, Table } from "@opal/components";
|
||||
import { Content, IllustrationContent } from "@opal/layouts";
|
||||
import SvgNoResult from "@opal/illustrations/no-result";
|
||||
import { createTableColumns } from "@opal/components/table/columns";
|
||||
import {
|
||||
SvgCheckSquare,
|
||||
SvgEdit,
|
||||
SvgMoreHorizontal,
|
||||
SvgPlus,
|
||||
SvgTrash,
|
||||
SvgUploadCloud,
|
||||
} from "@opal/icons";
|
||||
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import { markdown } from "@opal/utils";
|
||||
import RuleEditor from "@/app/admin/proposal-review/components/RuleEditor";
|
||||
import ImportFlow from "@/app/admin/proposal-review/components/ImportFlow";
|
||||
import type {
|
||||
RulesetResponse,
|
||||
RulesetUpdate,
|
||||
RuleResponse,
|
||||
RuleCreate,
|
||||
RuleUpdate,
|
||||
BulkRuleUpdateRequest,
|
||||
RuleIntent,
|
||||
} from "@/app/admin/proposal-review/interfaces";
|
||||
import {
|
||||
RULE_TYPE_LABELS,
|
||||
RULE_INTENT_LABELS,
|
||||
} from "@/app/admin/proposal-review/interfaces";
|
||||
import type { TagColor } from "@opal/components";
|
||||
|
||||
const tc = createTableColumns<RuleResponse>();
|
||||
|
||||
function intentColor(intent: RuleIntent): TagColor {
|
||||
return intent === "CHECK" ? "green" : "purple";
|
||||
}
|
||||
|
||||
function RulesetDetailPage() {
|
||||
const params = useParams();
|
||||
const rulesetId = params.id as string;
|
||||
const apiUrl = `/api/proposal-review/rulesets/${rulesetId}`;
|
||||
|
||||
const {
|
||||
data: ruleset,
|
||||
isLoading,
|
||||
error,
|
||||
} = useSWR<RulesetResponse>(apiUrl, errorHandlingFetcher);
|
||||
|
||||
// Modal states
|
||||
const [showRuleEditor, setShowRuleEditor] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<RuleResponse | null>(null);
|
||||
const [showImportFlow, setShowImportFlow] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RuleResponse | null>(null);
|
||||
|
||||
// Batch selection
|
||||
const [selectedRuleIds, setSelectedRuleIds] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [batchSaving, setBatchSaving] = useState(false);
|
||||
|
||||
// Toggle handlers
|
||||
async function handleToggleActive() {
|
||||
if (!ruleset) return;
|
||||
try {
|
||||
const body: RulesetUpdate = { is_active: !ruleset.is_active };
|
||||
const res = await fetch(apiUrl, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || "Failed to toggle active status");
|
||||
}
|
||||
await mutate(apiUrl);
|
||||
toast.success(
|
||||
ruleset.is_active ? "Ruleset deactivated." : "Ruleset activated."
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to toggle active status"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleDefault() {
|
||||
if (!ruleset) return;
|
||||
try {
|
||||
const body: RulesetUpdate = { is_default: !ruleset.is_default };
|
||||
const res = await fetch(apiUrl, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || "Failed to toggle default status");
|
||||
}
|
||||
await mutate(apiUrl);
|
||||
toast.success(
|
||||
ruleset.is_default
|
||||
? "Removed default status."
|
||||
: "Set as default ruleset."
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to toggle default status"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleRuleActive(rule: RuleResponse) {
|
||||
try {
|
||||
const update: RuleUpdate = { is_active: !rule.is_active };
|
||||
const res = await fetch(`/api/proposal-review/rules/${rule.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(update),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || "Failed to toggle rule active status");
|
||||
}
|
||||
await mutate(apiUrl);
|
||||
toast.success(rule.is_active ? "Rule deactivated." : "Rule activated.");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to toggle rule active status"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Rule CRUD
|
||||
async function handleSaveRule(ruleData: RuleCreate | RuleUpdate) {
|
||||
if (editingRule) {
|
||||
const res = await fetch(`/api/proposal-review/rules/${editingRule.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(ruleData),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Failed to update rule");
|
||||
}
|
||||
toast.success("Rule updated.");
|
||||
} else {
|
||||
const res = await fetch(
|
||||
`/api/proposal-review/rulesets/${rulesetId}/rules`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(ruleData),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Failed to create rule");
|
||||
}
|
||||
toast.success("Rule created.");
|
||||
}
|
||||
await mutate(apiUrl);
|
||||
}
|
||||
|
||||
async function handleDeleteRule(rule: RuleResponse) {
|
||||
try {
|
||||
const res = await fetch(`/api/proposal-review/rules/${rule.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Failed to delete rule");
|
||||
}
|
||||
setSelectedRuleIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(rule.id);
|
||||
return next;
|
||||
});
|
||||
await mutate(apiUrl);
|
||||
toast.success("Rule deleted.");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to delete rule");
|
||||
}
|
||||
}
|
||||
|
||||
// Batch operations
|
||||
async function handleBulkAction(action: BulkRuleUpdateRequest["action"]) {
|
||||
if (selectedRuleIds.size === 0) return;
|
||||
setBatchSaving(true);
|
||||
try {
|
||||
const body: BulkRuleUpdateRequest = {
|
||||
action,
|
||||
rule_ids: Array.from(selectedRuleIds),
|
||||
};
|
||||
const res = await fetch(
|
||||
`/api/proposal-review/rulesets/${rulesetId}/rules/bulk-update`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Bulk operation failed");
|
||||
}
|
||||
if (action === "delete") {
|
||||
setSelectedRuleIds(new Set());
|
||||
}
|
||||
await mutate(apiUrl);
|
||||
toast.success(
|
||||
`Bulk ${action} completed for ${selectedRuleIds.size} rule${
|
||||
selectedRuleIds.size === 1 ? "" : "s"
|
||||
}.`
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Bulk operation failed");
|
||||
} finally {
|
||||
setBatchSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Group rules by category
|
||||
const groupedRules = useMemo(() => {
|
||||
if (!ruleset?.rules) return {};
|
||||
const groups: Record<string, RuleResponse[]> = {};
|
||||
for (const rule of ruleset.rules) {
|
||||
const cat = rule.category || "Uncategorized";
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(rule);
|
||||
}
|
||||
return groups;
|
||||
}, [ruleset?.rules]);
|
||||
|
||||
const allRuleIds = useMemo(
|
||||
() => new Set(ruleset?.rules.map((r) => r.id) || []),
|
||||
[ruleset?.rules]
|
||||
);
|
||||
|
||||
const allSelected =
|
||||
allRuleIds.size > 0 && selectedRuleIds.size === allRuleIds.size;
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected) {
|
||||
setSelectedRuleIds(new Set());
|
||||
} else {
|
||||
setSelectedRuleIds(new Set(allRuleIds));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectRule(ruleId: string) {
|
||||
setSelectedRuleIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ruleId)) {
|
||||
next.delete(ruleId);
|
||||
} else {
|
||||
next.add(ruleId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const ruleColumns = useMemo(
|
||||
() => [
|
||||
tc.qualifier({
|
||||
content: "icon",
|
||||
getContent: () => SvgCheckSquare,
|
||||
}),
|
||||
tc.column("name", {
|
||||
header: "Name",
|
||||
weight: 25,
|
||||
cell: (value, row) =>
|
||||
row.description ? (
|
||||
<Content
|
||||
title={value}
|
||||
description={row.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
) : (
|
||||
<Content title={value} sizePreset="main-ui" variant="body" />
|
||||
),
|
||||
}),
|
||||
tc.column("rule_type", {
|
||||
header: "Type",
|
||||
weight: 15,
|
||||
cell: (value) => <Tag title={RULE_TYPE_LABELS[value]} color="gray" />,
|
||||
}),
|
||||
tc.column("rule_intent", {
|
||||
header: "Intent",
|
||||
weight: 10,
|
||||
cell: (value) => (
|
||||
<Tag title={RULE_INTENT_LABELS[value]} color={intentColor(value)} />
|
||||
),
|
||||
}),
|
||||
tc.displayColumn({
|
||||
id: "source",
|
||||
header: "Source",
|
||||
width: { weight: 10, minWidth: 80 },
|
||||
cell: (row) => (
|
||||
<Tag
|
||||
title={row.source === "IMPORTED" ? "Imported" : "Manual"}
|
||||
color={row.source === "IMPORTED" ? "blue" : "gray"}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
tc.displayColumn({
|
||||
id: "hard_stop",
|
||||
header: "Hard Stop",
|
||||
width: { weight: 10, minWidth: 80 },
|
||||
cell: (row) =>
|
||||
row.is_hard_stop ? <Tag title="Hard Stop" color="amber" /> : null,
|
||||
}),
|
||||
tc.displayColumn({
|
||||
id: "active",
|
||||
header: "Active",
|
||||
width: { weight: 8, minWidth: 60 },
|
||||
cell: (row) => (
|
||||
<Tag
|
||||
title={row.is_active ? "Yes" : "No"}
|
||||
color={row.is_active ? "green" : "gray"}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
tc.actions({
|
||||
cell: (row) => (
|
||||
<div className="flex flex-row gap-1">
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
icon={SvgMoreHorizontal}
|
||||
prominence="tertiary"
|
||||
tooltip="More"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content side="bottom" align="end" width="md">
|
||||
<PopoverMenu>
|
||||
<LineItem
|
||||
icon={SvgEdit}
|
||||
onClick={() => {
|
||||
setEditingRule(row);
|
||||
setShowRuleEditor(true);
|
||||
}}
|
||||
>
|
||||
Edit Rule
|
||||
</LineItem>
|
||||
<LineItem onClick={() => handleToggleRuleActive(row)}>
|
||||
{row.is_active ? "Deactivate" : "Activate"}
|
||||
</LineItem>
|
||||
<LineItem
|
||||
icon={SvgTrash}
|
||||
danger
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
Delete Rule
|
||||
</LineItem>
|
||||
</PopoverMenu>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
],
|
||||
[] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsLayouts.Root width="lg">
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgCheckSquare}
|
||||
title="Loading..."
|
||||
backButton
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<SimpleLoader />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !ruleset) {
|
||||
return (
|
||||
<SettingsLayouts.Root width="lg">
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgCheckSquare}
|
||||
title="Ruleset"
|
||||
backButton
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title="Failed to load ruleset."
|
||||
description={
|
||||
error?.info?.message ||
|
||||
error?.info?.detail ||
|
||||
"Ruleset not found."
|
||||
}
|
||||
/>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root width="lg">
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgCheckSquare}
|
||||
title={ruleset.name}
|
||||
description={ruleset.description || undefined}
|
||||
backButton
|
||||
rightChildren={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
prominence="secondary"
|
||||
icon={SvgUploadCloud}
|
||||
onClick={() => setShowImportFlow(true)}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
icon={SvgPlus}
|
||||
onClick={() => {
|
||||
setEditingRule(null);
|
||||
setShowRuleEditor(true);
|
||||
}}
|
||||
>
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
{/* Ruleset toggles — only show when rules exist */}
|
||||
{ruleset.rules.length > 0 && (
|
||||
<div className="flex items-center gap-4 pb-2">
|
||||
<Button
|
||||
prominence={ruleset.is_active ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
onClick={handleToggleActive}
|
||||
>
|
||||
{ruleset.is_active ? "Active" : "Inactive"}
|
||||
</Button>
|
||||
<Button
|
||||
prominence={ruleset.is_default ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
onClick={handleToggleDefault}
|
||||
>
|
||||
{ruleset.is_default ? "Default Ruleset" : "Not Default"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch action bar */}
|
||||
{selectedRuleIds.size > 0 && (
|
||||
<div className="flex items-center gap-3 p-3 bg-background-neutral-02 rounded-08">
|
||||
<Text font="main-ui-action" color="text-03">
|
||||
{`${selectedRuleIds.size} selected`}
|
||||
</Text>
|
||||
<Button
|
||||
prominence="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction("activate")}
|
||||
disabled={batchSaving}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
<Button
|
||||
prominence="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction("deactivate")}
|
||||
disabled={batchSaving}
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
prominence="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction("delete")}
|
||||
disabled={batchSaving}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rules table grouped by category */}
|
||||
{ruleset.rules.length === 0 ? (
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title="No rules yet"
|
||||
description="Add rules manually or import from a checklist."
|
||||
/>
|
||||
) : (
|
||||
Object.entries(groupedRules).map(([category, rules]) => (
|
||||
<div key={category} className="flex flex-col gap-2">
|
||||
<Text font="main-ui-action" color="text-03">
|
||||
{category}
|
||||
</Text>
|
||||
<Table
|
||||
data={rules}
|
||||
getRowId={(row) => row.id}
|
||||
columns={ruleColumns}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</SettingsLayouts.Body>
|
||||
|
||||
{/* Rule Editor Modal */}
|
||||
<RuleEditor
|
||||
open={showRuleEditor}
|
||||
onClose={() => {
|
||||
setShowRuleEditor(false);
|
||||
setEditingRule(null);
|
||||
}}
|
||||
onSave={handleSaveRule}
|
||||
existingRule={editingRule}
|
||||
/>
|
||||
|
||||
{/* Import Flow Modal */}
|
||||
<ImportFlow
|
||||
open={showImportFlow}
|
||||
onClose={() => setShowImportFlow(false)}
|
||||
rulesetId={rulesetId}
|
||||
onImportComplete={() => mutate(apiUrl)}
|
||||
/>
|
||||
|
||||
{/* Delete Rule Confirmation */}
|
||||
{deleteTarget && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgTrash}
|
||||
title="Delete Rule"
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
submit={
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={async () => {
|
||||
const target = deleteTarget;
|
||||
setDeleteTarget(null);
|
||||
await handleDeleteRule(target);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Text as="p" color="text-03">
|
||||
{markdown(
|
||||
`Are you sure you want to delete *${deleteTarget.name}*? This action cannot be undone.`
|
||||
)}
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <RulesetDetailPage />;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { IllustrationContent } from "@opal/layouts";
|
||||
import SvgNoResult from "@opal/illustrations/no-result";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { SvgSettings } from "@opal/icons";
|
||||
import SettingsForm from "@/app/admin/proposal-review/components/SettingsForm";
|
||||
import type {
|
||||
ConfigResponse,
|
||||
ConfigUpdate,
|
||||
} from "@/app/admin/proposal-review/interfaces";
|
||||
|
||||
const API_URL = "/api/proposal-review/config";
|
||||
|
||||
function ProposalReviewSettingsPage() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
data: config,
|
||||
isLoading,
|
||||
error,
|
||||
} = useSWR<ConfigResponse>(API_URL, errorHandlingFetcher);
|
||||
|
||||
async function handleSave(update: ConfigUpdate) {
|
||||
const res = await fetch(API_URL, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(update),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Failed to save settings");
|
||||
}
|
||||
await mutate(API_URL);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgSettings}
|
||||
title="Jira Integration"
|
||||
description="Configure which Jira connector to use and how fields are mapped."
|
||||
separator
|
||||
backButton
|
||||
onBack={() => router.push("/admin/proposal-review")}
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
{isLoading && <SimpleLoader />}
|
||||
{error && (
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title="Error loading settings"
|
||||
description={
|
||||
error?.info?.message || error?.info?.detail || "An error occurred"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{config && (
|
||||
<SettingsForm
|
||||
config={config}
|
||||
onSave={handleSave}
|
||||
onCancel={() => router.push("/admin/proposal-review")}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <ProposalReviewSettingsPage />;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ function QueryHistoryTableRow({
|
||||
key={chatSessionMinimal.id}
|
||||
className="hover:bg-accent-background cursor-pointer relative select-none"
|
||||
>
|
||||
<TableCell className="max-w-xs">
|
||||
<TableCell>
|
||||
<Text className="whitespace-normal line-clamp-5">
|
||||
{chatSessionMinimal.first_user_message ||
|
||||
chatSessionMinimal.name ||
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useCallback, useState, useRef } from "react";
|
||||
import { Button, Text } from "@opal/components";
|
||||
import { SvgPlayCircle, SvgChevronDown, SvgChevronUp } from "@opal/icons";
|
||||
import { IllustrationContent } from "@opal/layouts";
|
||||
import SvgEmpty from "@opal/illustrations/empty";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from "@/refresh-components/Collapsible";
|
||||
import RulesetSelector from "@/app/proposal-review/components/RulesetSelector";
|
||||
import ReviewProgress from "@/app/proposal-review/components/ReviewProgress";
|
||||
import FindingCard from "@/app/proposal-review/components/FindingCard";
|
||||
import { useFindings } from "@/app/proposal-review/hooks/useFindings";
|
||||
import { useReviewStatus } from "@/app/proposal-review/hooks/useReviewStatus";
|
||||
import { useProposalReviewContext } from "@/app/proposal-review/contexts/ProposalReviewContext";
|
||||
import { triggerReview } from "@/app/proposal-review/services/apiServices";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ChecklistPanelProps {
|
||||
proposalId: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ChecklistPanel({ proposalId }: ChecklistPanelProps) {
|
||||
const {
|
||||
selectedRulesetId,
|
||||
isReviewRunning,
|
||||
setIsReviewRunning,
|
||||
setCurrentReviewRunId,
|
||||
findingsLoaded,
|
||||
setFindingsLoaded,
|
||||
resetReviewState,
|
||||
} = useProposalReviewContext();
|
||||
|
||||
const [triggerError, setTriggerError] = useState<string | null>(null);
|
||||
|
||||
// Reset review state when navigating to a different proposal
|
||||
const prevProposalIdRef = useRef(proposalId);
|
||||
useEffect(() => {
|
||||
if (prevProposalIdRef.current !== proposalId) {
|
||||
prevProposalIdRef.current = proposalId;
|
||||
resetReviewState();
|
||||
setTriggerError(null);
|
||||
}
|
||||
}, [proposalId, resetReviewState]);
|
||||
|
||||
// Poll review status while running
|
||||
const { reviewStatus } = useReviewStatus(proposalId, isReviewRunning);
|
||||
|
||||
// Fetch findings
|
||||
const {
|
||||
findingsByCategory,
|
||||
isLoading: findingsLoading,
|
||||
mutate: mutateFindings,
|
||||
findings,
|
||||
} = useFindings(proposalId);
|
||||
|
||||
// When review completes, stop polling and load findings
|
||||
useEffect(() => {
|
||||
if (!reviewStatus) return;
|
||||
if (
|
||||
reviewStatus.status === "COMPLETED" ||
|
||||
reviewStatus.status === "FAILED"
|
||||
) {
|
||||
setIsReviewRunning(false);
|
||||
if (reviewStatus.status === "COMPLETED") {
|
||||
setFindingsLoaded(true);
|
||||
mutateFindings();
|
||||
}
|
||||
}
|
||||
}, [reviewStatus, setIsReviewRunning, setFindingsLoaded, mutateFindings]);
|
||||
|
||||
// On mount, if there are existing findings, mark as loaded
|
||||
useEffect(() => {
|
||||
if (findings.length > 0 && !findingsLoaded) {
|
||||
setFindingsLoaded(true);
|
||||
}
|
||||
}, [findings.length, findingsLoaded, setFindingsLoaded]);
|
||||
|
||||
const handleRunReview = useCallback(async () => {
|
||||
if (!selectedRulesetId) return;
|
||||
|
||||
setTriggerError(null);
|
||||
setIsReviewRunning(true);
|
||||
|
||||
try {
|
||||
const result = await triggerReview(proposalId, selectedRulesetId);
|
||||
setCurrentReviewRunId(result.id);
|
||||
} catch (err) {
|
||||
setIsReviewRunning(false);
|
||||
setTriggerError(
|
||||
err instanceof Error ? err.message : "Failed to start review"
|
||||
);
|
||||
}
|
||||
}, [
|
||||
proposalId,
|
||||
selectedRulesetId,
|
||||
setIsReviewRunning,
|
||||
setCurrentReviewRunId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Top bar: ruleset selector + run button */}
|
||||
<div className="flex items-center gap-3 p-4 border-b border-border-01 shrink-0">
|
||||
<div className="flex-1 max-w-[200px]">
|
||||
<RulesetSelector />
|
||||
</div>
|
||||
<Button
|
||||
variant="action"
|
||||
prominence="primary"
|
||||
icon={SvgPlayCircle}
|
||||
disabled={!selectedRulesetId || isReviewRunning}
|
||||
onClick={handleRunReview}
|
||||
>
|
||||
{isReviewRunning ? "Running..." : "Run Review"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{triggerError && (
|
||||
<div className="px-4 pt-2">
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{triggerError}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review progress */}
|
||||
{isReviewRunning && reviewStatus && (
|
||||
<ReviewProgress reviewStatus={reviewStatus} />
|
||||
)}
|
||||
|
||||
{/* Loading spinner while review is starting */}
|
||||
{isReviewRunning && !reviewStatus && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<SimpleLoader className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Findings list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!isReviewRunning && findingsLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<SimpleLoader className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isReviewRunning && !findingsLoading && findings.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12 px-4">
|
||||
<IllustrationContent
|
||||
illustration={SvgEmpty}
|
||||
title="No review results"
|
||||
description="Select a ruleset and click Run Review to evaluate this proposal."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isReviewRunning && findingsByCategory.length > 0 && (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{findingsByCategory.map((group) => (
|
||||
<CategoryGroup
|
||||
key={group.category}
|
||||
category={group.category}
|
||||
findings={group.findings}
|
||||
onDecisionSaved={() => mutateFindings()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CategoryGroup: collapsible group of findings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CategoryGroupProps {
|
||||
category: string;
|
||||
findings: import("@/app/proposal-review/types").Finding[];
|
||||
onDecisionSaved: () => void;
|
||||
}
|
||||
|
||||
function CategoryGroup({
|
||||
category,
|
||||
findings,
|
||||
onDecisionSaved,
|
||||
}: CategoryGroupProps) {
|
||||
const failCount = findings.filter(
|
||||
(f) => f.verdict === "FAIL" || f.verdict === "FLAG"
|
||||
).length;
|
||||
const passCount = findings.filter((f) => f.verdict === "PASS").length;
|
||||
const decidedCount = findings.filter((f) => f.decision !== null).length;
|
||||
|
||||
// Default open if there are failures/flags
|
||||
const [isOpen, setIsOpen] = useState(failCount > 0);
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex items-center justify-between w-full py-2 px-3 rounded-08 hover:bg-background-neutral-02 cursor-pointer"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setIsOpen((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOpen ? (
|
||||
<SvgChevronUp className="h-4 w-4 text-text-03" />
|
||||
) : (
|
||||
<SvgChevronDown className="h-4 w-4 text-text-03" />
|
||||
)}
|
||||
<Text font="main-ui-action" color="text-04">
|
||||
{category}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{failCount > 0 && (
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{`${failCount} issue${failCount !== 1 ? "s" : ""}`}
|
||||
</Text>
|
||||
)}
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{`${decidedCount}/${findings.length} reviewed`}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="flex flex-col gap-2 pt-2 pl-6">
|
||||
{findings.map((finding) => (
|
||||
<FindingCard
|
||||
key={finding.id}
|
||||
finding={finding}
|
||||
onDecisionSaved={onDecisionSaved}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user