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
39 changes: 38 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
- name: Install dev dependencies
run: pip install ".[dev]"
- name: Ruff lint
run: ruff check backend ingestion tests
run: ruff check backend ingestion router db tests

typecheck:
timeout-minutes: 30
Expand All @@ -45,6 +45,23 @@ jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
# db/tests/test_schema.py::test_forward_then_rollback_restores_baseline runs
# a live migration round-trip against DATABASE_URL instead of skipping, so
# this job needs an actual Postgres server for that DSN to resolve.
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U test"
--health-interval=5s
--health-timeout=5s
--health-retries=10
env:
DATABASE_URL: "postgresql+asyncpg://test:test@localhost:5432/test"
QDRANT_URL: "http://localhost:6333"
Expand Down Expand Up @@ -120,6 +137,26 @@ jobs:
)
PY

frontend-test:
timeout-minutes: 30
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install frontend dependencies
run: npm ci
- name: ESLint
run: npm run lint
- name: TypeScript typecheck
run: npm run typecheck
- name: Vitest
run: npm run test

frontend-security:
timeout-minutes: 30
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ WORKDIR /build
COPY pyproject.toml ./
COPY backend ./backend
COPY ingestion ./ingestion
COPY router ./router
COPY db ./db
COPY README.md ./

RUN pip install --prefix=/install .
Expand All @@ -30,6 +32,8 @@ WORKDIR /app
COPY --from=builder /install /usr/local
COPY backend ./backend
COPY ingestion ./ingestion
COPY router ./router
COPY db ./db

USER app

Expand Down
5 changes: 4 additions & 1 deletion backend/app/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ def __getattr__(name: str) -> object:
AttributeError: When ``name`` is not a known adapter.
"""
_lazy_map = {
"SqlAlchemyDocumentStore": ("backend.app.adapters.document_store", "SqlAlchemyDocumentStore"),
"SqlAlchemyDocumentStore": (
"backend.app.adapters.document_store",
"SqlAlchemyDocumentStore",
),
"ClaudeGenerationLLM": ("backend.app.adapters.generation", "ClaudeGenerationLLM"),
"PipelineIngestor": ("backend.app.adapters.ingestor", "PipelineIngestor"),
"RouterModuleAdapter": ("backend.app.adapters.router", "RouterModuleAdapter"),
Expand Down
11 changes: 11 additions & 0 deletions backend/app/adapters/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@
from __future__ import annotations

import asyncio
import logging
import os
from collections.abc import AsyncIterator

from backend.app.api.interfaces import DependencyUnavailable, RoutedSection
from backend.app.settings import get_settings

_logger = logging.getLogger(__name__)

DEFAULT_GENERATION_MODEL = os.environ.get("GENERATION_MODEL", "claude-opus-4-8")
"""Answer-synthesis model id (overridable via the GENERATION_MODEL env var)."""

Expand All @@ -49,6 +52,10 @@ def _default_max_tokens() -> int:
try:
return get_settings().generation_max_tokens
except Exception:
_logger.warning(
"Failed to read generation_max_tokens from settings; using fallback",
exc_info=True,
)
return 16000


Expand All @@ -61,6 +68,10 @@ def _default_thinking_budget() -> int:
try:
return get_settings().generation_thinking_budget_tokens
except Exception:
_logger.warning(
"Failed to read generation_thinking_budget_tokens from settings; using fallback",
exc_info=True,
)
return 5000


Expand Down
3 changes: 2 additions & 1 deletion backend/app/adapters/ingestor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Ingestor adapter binding ``ingestion.ingest_document`` to the backend Protocol.
"""Ingestor adapter binding ``ingestion.ingest_document`` to the backend Protocol.

The backend :class:`Ingestor` Protocol is ``ingest_document(tenant_id, content,
filename, title, domain, no_retention, residency_region, ocr) -> IngestOutcome``.
Expand Down Expand Up @@ -247,6 +247,7 @@ async def ingest_document(
toc = list(result.get("toc") or [])
fallback_only = bool(result.get("fallback_only", False))
extracted_title = result.get("title") or title

return IngestOutcome(
doc_id=doc_id_str,
title=extracted_title,
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/answer.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ async def answer_query(
raise document_not_found()

if document.fallback_only:
# TODO: product owner to confirm — Option B (whole-document RAG) may replace this
# TODO: product owner to confirm - Option B (whole-document RAG) may replace this
raise validation_error(
detail=(
"This document does not support section-level routing."
Expand Down
9 changes: 6 additions & 3 deletions backend/app/api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from __future__ import annotations

import logging
import threading

from backend.app.api.interfaces import (
Expand All @@ -33,6 +34,8 @@
from backend.app.errors import service_unavailable
from backend.app.settings import get_settings

logger = logging.getLogger(__name__)

_singleton_lock = threading.RLock()
"""Single re-entrant process-wide lock for all singleton builders (issue #222).

Expand Down Expand Up @@ -146,10 +149,10 @@ def _generation_llm_singleton() -> GenerationLLM:
from backend.app.settings import get_settings

settings = get_settings()
_generation_llm_cache = ClaudeGenerationLLM( # type: ignore[assignment]
_generation_llm_cache = ClaudeGenerationLLM(
thinking_budget_tokens=settings.generation_thinking_budget_tokens
)
return _generation_llm_cache # type: ignore[return-value]
return _generation_llm_cache


def get_document_store() -> DocumentStore:
Expand Down Expand Up @@ -207,4 +210,4 @@ async def _dispose_engines() -> None:
if engine is not None:
await engine.dispose()
except Exception:
pass
logger.warning("Error disposing document store engine on shutdown", exc_info=True)
5 changes: 3 additions & 2 deletions backend/app/api/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from __future__ import annotations

from collections.abc import AsyncGenerator, AsyncIterator
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable

Expand Down Expand Up @@ -214,7 +214,8 @@ async def stream_answer(
sections: list[RoutedSection],
) -> AsyncIterator[str]:
"""Yield answer token fragments for the routed sections."""
...
raise NotImplementedError
yield # pragma: no cover - unreachable, marks this an async generator


class DependencyUnavailable(Exception): # noqa: N818 - maps to SERVICE_UNAVAILABLE, not an *Error
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/observability.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Observability surface: the ``/metrics`` scrape endpoint (PRD §21, NFR-009).
"""Observability surface: the ``/metrics`` scrape endpoint (PRD Sec. 21, NFR-009).

Exposes the four product KPIs (token reduction %, answer accuracy, routing
latency, fallback rate) in Prometheus text exposition format. LangSmith
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ async def route_query(
"""
document_ids = list(dict.fromkeys(_target_document_ids(body)))

# Fix #262: rerank=True is not supported — reject with 422 instead of
# Fix #262: rerank=True is not supported - reject with 422 instead of
# silently downgrading and returning HTTP 200.
if body.rerank:
raise validation_error(
Expand Down
4 changes: 2 additions & 2 deletions backend/app/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ class Citation(_Strict):
page_end: Annotated[int, Field(ge=1)]

@model_validator(mode="after")
def _validate_page_range(self) -> "Citation":
def _validate_page_range(self) -> Citation:
"""Validate page_start <= page_end.

Returns:
Expand Down Expand Up @@ -267,7 +267,7 @@ class Document(_Strict):
doc_id: DocumentId
title: str | None = None
total_pages: Annotated[int, Field(ge=0)]
domain: Annotated[str, Field(max_length=100, pattern=r"^[a-zA-Z0-9._-]+$")] | None = None
domain: str | None = None
residency_region: ResidencyRegion
fallback_only: bool
created_at: str | None = None
Expand Down
4 changes: 2 additions & 2 deletions backend/app/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,11 @@ def unauthorized(
"""Build a 401 UNAUTHORIZED problem.

The ``WWW-Authenticate: Bearer`` header is included on every 401 response
per RFC 6750 §3 so that HTTP clients and API gateways can identify the
per RFC 6750 Sec. 3 so that HTTP clients and API gateways can identify the
accepted credential scheme. This is intentional even when only API-key
auth is configured: the header is informational and harmless, and omitting
it would require threading settings through every error-raise site (issue
#183 — accepted as RFC-compliant behaviour).
#183 - accepted as RFC-compliant behaviour).

Args:
detail: Occurrence-specific explanation.
Expand Down
19 changes: 12 additions & 7 deletions backend/app/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from fastapi import APIRouter, Request, Response, status
from pydantic import BaseModel

from backend.app.errors import ProblemException
from backend.app.security.auth import resolve_principal
from backend.app.settings import Settings, get_settings

router = APIRouter(tags=["Operations"])
Expand Down Expand Up @@ -61,9 +63,10 @@ async def _check_postgres(database_url: str, timeout_seconds: float) -> bool:
Returns:
True if the authenticated query succeeded, otherwise False.
"""
import asyncpg
import re

import asyncpg

try:
# asyncpg rejects SQLAlchemy dialect prefixes like postgresql+asyncpg://
clean_url = re.sub(r"^postgresql\+\w+://", "postgresql://", database_url)
Expand Down Expand Up @@ -165,10 +168,13 @@ async def get_readiness(request: Request, response: Response) -> ReadinessStatus
avoid disclosing infrastructure details (postgres/qdrant host reachability)
to unauthenticated probers. K8s liveness and readiness probes do not send
auth headers, so they still receive the correct status code; only the
``dependencies`` breakdown is hidden from them.
``dependencies`` breakdown is hidden from them. The credential is fully
verified via :func:`resolve_principal` (signature/expiry/key-hash checks) -
a caller cannot see the breakdown by merely sending an arbitrary header
value, since that would defeat the purpose of hiding it.

Args:
request: The incoming request (used to detect auth headers).
request: The incoming request (used to verify the caller's credential).
response: The response object whose status code is set to 503 on a
degraded result.

Expand All @@ -181,9 +187,8 @@ async def get_readiness(request: Request, response: Response) -> ReadinessStatus
readiness = await evaluate_readiness(settings)
if readiness.status != "ready":
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
has_auth = bool(
request.headers.get("X-API-Key") or request.headers.get("Authorization")
)
if not has_auth:
try:
resolve_principal(request, settings)
except ProblemException:
return ReadinessStatus(status=readiness.status, dependencies={})
return readiness
6 changes: 3 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Builds the real ASGI application: the liveness/readiness probe router
(NFR-009, ADR-5), the /v1 routing, answer, and document/compliance routers
(openapi.yaml), the ``/metrics`` observability surface (PRD §21), and the
(openapi.yaml), the ``/metrics`` observability surface (PRD Sec. 21), and the
RFC-7807 exception handlers. LangSmith tracing is configured from the
environment at construction time. The factory is side-effect free with respect
to external dependencies so it can be imported by the test suite and run
Expand All @@ -16,8 +16,8 @@
from __future__ import annotations

import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import AsyncIterator

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
Expand All @@ -38,7 +38,7 @@
async def _lifespan(_app: FastAPI) -> AsyncIterator[None]:
"""Manage application startup and shutdown lifecycle.

On startup no eager initialisation is performed — heavyweight singletons
On startup no eager initialisation is performed - heavyweight singletons
(SQLAlchemy engine, Qdrant client, etc.) are created lazily on first
request so the process starts quickly and without requiring credentials in
the test environment.
Expand Down
4 changes: 2 additions & 2 deletions backend/app/productization/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
behavior:

- ``roi_calculator``: projects monthly LLM-spend savings from the documented
40-70% token-reduction range (PRD §15.1, §21) for sales/GTM modeling.
40-70% token-reduction range (PRD Sec. 15.1, Sec. 21) for sales/GTM modeling.
- ``metrics``: an in-process registry for the four product KPIs (token
reduction %, answer accuracy, routing latency, fallback rate) exposed at
``/metrics`` for scrape-based observability (PRD §21, NFR-009).
``/metrics`` for scrape-based observability (PRD Sec. 21, NFR-009).
"""

from __future__ import annotations
2 changes: 1 addition & 1 deletion backend/app/productization/metrics.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""In-process product-KPI metrics registry (PRD §21, NFR-009).
"""In-process product-KPI metrics registry (PRD Sec. 21, NFR-009).

Tracks the four product KPIs the PRD measures the system against:

Expand Down
9 changes: 5 additions & 4 deletions backend/app/productization/roi_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
documented token-reduction range when they place this refinement layer in
front of their existing RAG stack. The savings band is anchored to the
PRD-verified range of 40-70% average token reduction vs. full-document RAG
(PRD §15.1 / §21), with a documented conservative/expected/optimistic split.
(PRD Sec. 15.1 / Sec. 21), with a documented conservative/expected/optimistic split.

This module is a pure-function GTM/sales utility. It performs no I/O, calls no
provider, and does not touch routing, ingestion, or persistence. The dollar
Expand Down Expand Up @@ -124,7 +124,8 @@ def calculate_roi(

Args:
current_monthly_spend_usd: Current monthly LLM spend in USD. Must be a
finite, non-negative number.
finite, positive number; a zero or negative spend has no
addressable savings to project.
context_spend_fraction: Fraction (0.0-1.0] of the spend attributable to
retrieved context that this layer can reduce. Defaults to 0.7, a
common context-heavy RAG profile.
Expand All @@ -134,8 +135,8 @@ def calculate_roi(
projections plus the expected annual saving.

Raises:
ValueError: If the spend is negative/non-finite or the context
fraction is outside the (0.0, 1.0] range.
ValueError: If the spend is not finite or not positive, or the
context fraction is outside the (0.0, 1.0] range.
"""
if not math.isfinite(current_monthly_spend_usd) or current_monthly_spend_usd <= 0:
raise ValueError(
Expand Down
Loading
Loading