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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 26 additions & 20 deletions .github/workflows/ai-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,32 +52,37 @@ jobs:
- name: Faithfulness demo
run: python testing_suite/calculate_faithfulness.py --demo

ai-evaluation:
name: DeepEval RAG / SQL checks
deepeval-optional:
runs-on: ubuntu-latest
needs: [python-tests]
# Optional: OpenAI quota / billing failures should not block the pipeline
continue-on-error: true
needs: [python-lint]
if: ${{ !secrets.OPENAI_API_KEY }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace invalid secret reference in job-level if

This if uses secrets directly at the job level, which GitHub Actions does not support for conditionals; workflow validation fails before execution, so the DeepEval jobs never run in CI. Move the secret into an allowed context (for example via vars/needs outputs or a preceding job output) and gate on that value instead.

Useful? React with 👍 / 👎.

steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: |
python -m pip install --upgrade pip
pip install -r requirements-local.txt
pip install -r testing_suite/requirements.txt
- name: DeepEval (requires OPENAI_API_KEY)
python-version: "3.12"
- run: pip install -e packages/ai-core[dev] deepeval
- run: pytest -q testing_suite/test_deepeval_sql_rag.py
env:
OPENAI_API_KEY: ""

deepeval-required:
runs-on: ubuntu-latest
continue-on-error: false
needs: [deepeval-optional]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Decouple keyed DeepEval job from skipped dependency

deepeval-required depends on deepeval-optional, but deepeval-optional only runs when OPENAI_API_KEY is missing. In repos where the secret is set, the optional job is skipped, and GitHub Actions skips downstream needs jobs in that dependency chain, so the required DeepEval path never executes even though HAS_KEY is true.

Useful? React with 👍 / 👎.

env:
HAS_KEY: ${{ secrets.OPENAI_API_KEY != '' }}
if: env.HAS_KEY == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e packages/ai-core[dev] deepeval
- run: pytest -q testing_suite/test_deepeval_sql_rag.py
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
if [ -z "$OPENAI_API_KEY" ]; then
echo "OPENAI_API_KEY not set; skipping DeepEval"
exit 0
fi
pytest -q testing_suite/test_deepeval_sql_rag.py

web-build:
name: Next.js production build
Expand Down Expand Up @@ -114,8 +119,9 @@ jobs:
uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Install and type-check
working-directory: apps/web
run: npm ci && npx tsc --noEmit
run: |
npm ci
npm run typecheck -w apps/web

python-lint:
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Deploy (Cloudflare)
on:
workflow_dispatch:
push:
branches: [main, develop]
branches: [main]

jobs:
validate-deploy-config:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/github-action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ name: CI

on:
push:
branches: [main, master, develop]
branches: [main]
pull_request:
branches: [main, master, develop]
branches: [main]

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
Expand Down
5 changes: 5 additions & 0 deletions apps/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ SYNTHESIS_MODEL=claude-3-5-sonnet-20241022
# SHADOW_ANALYST_ENABLED=true
# NEWS_API_KEY= # e.g. newsapi.org; optional, deep research still runs RAG
# NEWS_API_URL=https://newsapi.org/v2/everything

# LangSmith observability (optional)
# LANGSMITH_TRACING=true
# LANGSMITH_API_KEY=ls__your_key_here
# LANGSMITH_PROJECT=aequitas-fi-production
4 changes: 4 additions & 0 deletions apps/server/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ class Settings(BaseSettings):
# If true, do not fall back to MemorySaver when Postgres checkpointer init fails.
checkpointer_postgres_required: bool = False

langsmith_api_key: str | None = None
langsmith_project: str = "aequitas-fi"
langsmith_tracing: bool = False

def parsed_cors_origins(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]

Expand Down
11 changes: 9 additions & 2 deletions apps/server/app/graph/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@


def get_portfolio_graph():
from aequitas_ai.agents.portfolio_agent import build_portfolio_agent
from aequitas_ai.agents.portfolio_agent import build_portfolio_agent, PortfolioAgentConfig
from langchain_openai import ChatOpenAI
from app.config import settings

return build_portfolio_agent()
analysis_llm = ChatOpenAI(
model=settings.synthesis_model,
temperature=0.0
Comment on lines +11 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use provider-compatible chat model for portfolio graph

This constructs a ChatOpenAI client with settings.synthesis_model, but the project’s default synthesis model is Anthropic (claude-3-5-sonnet-20241022), so resolving/invoking the portfolio graph will fail at runtime with a provider/model mismatch unless every environment overrides that setting to an OpenAI model. Use the Anthropic client here (or provider-based routing) to keep the graph callable with default configuration.

Useful? React with 👍 / 👎.

)
config = PortfolioAgentConfig(analysis_llm=analysis_llm)
return build_portfolio_agent(config)
15 changes: 14 additions & 1 deletion apps/server/app/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import logging
import os
import sys
from contextlib import asynccontextmanager

Expand All @@ -14,7 +15,7 @@
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine

from api.ingest import router as ingest_router
from app.config import settings
from app.config import settings, Settings
from app.graph import (
GraphRegistry,
get_alert_triage_graph,
Expand All @@ -41,6 +42,17 @@
log = logging.getLogger("aequitas")


def configure_langsmith(settings: Settings) -> None:
if settings.langsmith_tracing and settings.langsmith_api_key is not None:
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = settings.langsmith_api_key
os.environ["LANGCHAIN_PROJECT"] = settings.langsmith_project
os.environ["LANGCHAIN_ENDPOINT"] = "https://api.smith.langchain.com"
log.info("LangSmith tracing enabled for project: %s", settings.langsmith_project)
else:
log.info("LangSmith tracing disabled")


def _is_dev_mode() -> bool:
return (settings.app_env or "dev").strip().lower() in {
"dev",
Expand All @@ -53,6 +65,7 @@ def _is_dev_mode() -> bool:

@asynccontextmanager
async def lifespan(app: FastAPI):
configure_langsmith(settings)
if settings.environment in ("staging", "production") and settings.auth_provider == "dev":
raise RuntimeError(
f"FATAL: auth_provider='dev' is not allowed in environment='{settings.environment}'. "
Expand Down
7 changes: 4 additions & 3 deletions apps/server/app/routers/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from decimal import Decimal
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import StreamingResponse
from langchain_anthropic import ChatAnthropic
from langchain_core.language_models.chat_models import BaseChatModel
Expand Down Expand Up @@ -188,13 +188,13 @@ async def list_positions(
return [PositionOut.from_row(r) for r in rows]


@router.delete("/{portfolio_id}/positions/{position_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{portfolio_id}/positions/{position_id}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document DELETE endpoint as 204 in OpenAPI

Removing status_code=HTTP_204_NO_CONTENT from the route decorator makes FastAPI document this operation with its default success code (200), even though the handler now returns Response(status_code=204). That mismatch can break generated SDK/client behavior and API contracts that rely on the OpenAPI spec, especially around no-body handling for successful deletes.

Useful? React with 👍 / 👎.

async def delete_position(
request: Request,
portfolio_id: UUID,
position_id: UUID,
session: AsyncSession = Depends(_get_session),
) -> None:
) -> Response:
ident = await get_identity(request)
user_id = _parse_user_id_or_400(ident.sub)
portfolio = await portfolio_svc.get_portfolio(session, portfolio_id, user_id)
Expand All @@ -203,6 +203,7 @@ async def delete_position(
deleted = await portfolio_svc.delete_position(session, position_id, portfolio_id)
if not deleted:
raise HTTPException(status_code=404, detail="Position not found")
return Response(status_code=status.HTTP_204_NO_CONTENT)


@router.get("/{portfolio_id}/pnl")
Expand Down
118 changes: 118 additions & 0 deletions apps/server/tests/test_portfolio_delete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""DELETE /v1/portfolio/.../positions/... returns explicit 204 Response."""

from __future__ import annotations

from unittest.mock import AsyncMock, patch
from uuid import uuid4

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

from app.auth.identity import Identity
from app.routers.portfolio import _get_session, router


@pytest.fixture
def portfolio_id() -> str:
return str(uuid4())


@pytest.fixture
def position_id() -> str:
return str(uuid4())


@pytest.fixture
def user_id() -> str:
return str(uuid4())


@pytest.fixture
def client() -> TestClient:
app = FastAPI()
app.include_router(router)

async def _mock_session():
yield AsyncMock()

app.dependency_overrides[_get_session] = _mock_session
return TestClient(app)


def test_delete_position_returns_204(
client: TestClient,
portfolio_id: str,
position_id: str,
user_id: str,
) -> None:
ident = Identity(sub=user_id, role="analyst")
portfolio = object()

with (
patch("app.routers.portfolio.get_identity", new_callable=AsyncMock, return_value=ident),
patch(
"app.routers.portfolio.portfolio_svc.get_portfolio",
new_callable=AsyncMock,
return_value=portfolio,
),
patch(
"app.routers.portfolio.portfolio_svc.delete_position",
new_callable=AsyncMock,
return_value=True,
),
):
res = client.delete(f"/v1/portfolio/{portfolio_id}/positions/{position_id}")

assert res.status_code == 204
assert res.content == b""


def test_delete_position_404_when_portfolio_missing(
client: TestClient,
portfolio_id: str,
position_id: str,
user_id: str,
) -> None:
ident = Identity(sub=user_id, role="analyst")

with (
patch("app.routers.portfolio.get_identity", new_callable=AsyncMock, return_value=ident),
patch(
"app.routers.portfolio.portfolio_svc.get_portfolio",
new_callable=AsyncMock,
return_value=None,
),
):
res = client.delete(f"/v1/portfolio/{portfolio_id}/positions/{position_id}")

assert res.status_code == 404
assert res.json()["detail"] == "Portfolio not found"


def test_delete_position_404_when_position_missing(
client: TestClient,
portfolio_id: str,
position_id: str,
user_id: str,
) -> None:
ident = Identity(sub=user_id, role="analyst")
portfolio = object()

with (
patch("app.routers.portfolio.get_identity", new_callable=AsyncMock, return_value=ident),
patch(
"app.routers.portfolio.portfolio_svc.get_portfolio",
new_callable=AsyncMock,
return_value=portfolio,
),
patch(
"app.routers.portfolio.portfolio_svc.delete_position",
new_callable=AsyncMock,
return_value=False,
),
):
res = client.delete(f"/v1/portfolio/{portfolio_id}/positions/{position_id}")

assert res.status_code == 404
assert res.json()["detail"] == "Position not found"
33 changes: 33 additions & 0 deletions apps/web/components/layout/page-template.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import type { ReactNode } from "react";
import { cn } from "@/lib/utils";

type PageTemplateProps = {
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
maxWidthClassName?: string;
};

export function PageTemplate({
title,
subtitle,
actions,
children,
maxWidthClassName = "max-w-6xl",
}: PageTemplateProps) {
return (
<section className={cn("mx-auto w-full animate-fade-in-up px-4 py-6", maxWidthClassName)}>
<header className="mb-5 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-foreground md:text-3xl">{title}</h1>
{subtitle ? <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</header>
{children}
</section>
);
}
11 changes: 10 additions & 1 deletion apps/web/lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export type ApiRequestInit = {
method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
body?: unknown;
headers?: Record<string, string>;
/** When true, treat 204 No Content as success and return undefined (no JSON parse). */
noContent?: boolean;
};

export async function apiRequest<T>(
Expand All @@ -27,7 +29,14 @@ export async function apiRequest<T>(
const message = await response.text();
throw new Error(message || `API request failed (${response.status})`);
}
return (await response.json()) as T;
if (init.noContent || response.status === 204) {
return undefined as T;
}
const text = await response.text();
if (!text) {
return undefined as T;
}
return JSON.parse(text) as T;
}

export async function streamInsight(
Expand Down
Binary file added apps/web/tsc_output.txt
Binary file not shown.
Binary file added apps/web/typecheck_out.txt
Binary file not shown.
4 changes: 4 additions & 0 deletions apps/web/typecheck_out_utf8.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

> @aequitas/web@0.1.0 typecheck
> tsc --noEmit

16 changes: 16 additions & 0 deletions debug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import asyncio, json
from aequitas_ai.tools.market_data import fetch_market_price
from unittest.mock import patch, AsyncMock
from httpx import Response

async def test():
mock_yahoo_data = {'chart': {'result': [{'meta': {'regularMarketPrice': 150.25}}]}}
mock_response = Response(200, json=mock_yahoo_data, request=None)
mock_get = AsyncMock(return_value=mock_response)
with patch('httpx.AsyncClient.get', mock_get):
result = await fetch_market_price('AAPL')
print("RESULT:")
print(result)

if __name__ == '__main__':
asyncio.run(test())
Loading
Loading