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
1 change: 1 addition & 0 deletions .github/workflows/ai.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
AI_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/grandflow_ai
LOG_LEVEL: INFO
PYTHONPATH: ${{ github.workspace }}/services/ai:${{ github.workspace }}
AI_PROVIDER_APPROVED_ORIGINS: '[{"origin": "http://ollama:11434", "allow_private": true}]'
steps:
- uses: actions/checkout@v4

Expand Down
2 changes: 2 additions & 0 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ services:
- ./services/ai/.env.ai.private.local
environment:
- PYTHONPATH=/app
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
grandflow-db:
condition: service_healthy
Expand Down
6 changes: 6 additions & 0 deletions frontend-typescript/src/api/aiSettingsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ export interface ProviderKeyConfig {
is_default: boolean;
}

export interface ApprovedEndpoint {
origin: string;
label: string;
}

export interface AiSettings {
configs: ProviderKeyConfig[];
platform_fallback_enabled: boolean;
approved_endpoints: ApprovedEndpoint[];
}

export const getAiSettings = async (): Promise<AiSettings> => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { vi, type Mock } from "vitest";
import { AiIntegrationsSection } from "./AiIntegrationsSection";
import * as aiSettingsApi from "@/api/aiSettingsApi";
import * as authContext from "@/context/AuthContext";

vi.mock("@/api/aiSettingsApi", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/api/aiSettingsApi")>();
return {
...actual,
getAiSettings: vi.fn(),
createAiKey: vi.fn(),
};
});

vi.mock("@/context/AuthContext", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/context/AuthContext")>();
return {
...actual,
useAuth: vi.fn(),
};
});

const getAiSettingsMock = aiSettingsApi.getAiSettings as unknown as Mock;
const createAiKeyMock = aiSettingsApi.createAiKey as unknown as Mock;
const useAuthMock = authContext.useAuth as unknown as Mock;

function renderSection() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<AiIntegrationsSection />
</QueryClientProvider>,
);
}

async function openAddKeyModalOnOllama() {
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Add key" }));
await user.click(screen.getByRole("button", { name: "Ollama (Local)" }));
return user;
}

function submitButton() {
return screen.getAllByRole("button", { name: "Add key" })[1];
}

describe("AiIntegrationsSection", () => {
beforeEach(() => {
vi.clearAllMocks();
useAuthMock.mockReturnValue({ isSuperuser: false });
createAiKeyMock.mockResolvedValue({
configs: [],
platform_fallback_enabled: false,
approved_endpoints: [],
});
});

it("offers the approved endpoints as a picker instead of a free-text URL", async () => {
getAiSettingsMock.mockResolvedValue({
configs: [],
platform_fallback_enabled: false,
approved_endpoints: [
{ origin: "http://ollama:11434", label: "Local Ollama" },
{
origin: "http://host.docker.internal:11434",
label: "http://host.docker.internal:11434",
},
],
});
renderSection();

await openAddKeyModalOnOllama();

expect(screen.queryByPlaceholderText("http://localhost:11434")).not.toBeInTheDocument();
expect(screen.getByText("Local Ollama", { selector: "option" })).toBeInTheDocument();
expect(
screen.getByText("http://host.docker.internal:11434", { selector: "option" }),
).toBeInTheDocument();
});

it("preselects the only approved endpoint and submits its origin as base_url", async () => {
getAiSettingsMock.mockResolvedValue({
configs: [],
platform_fallback_enabled: false,
approved_endpoints: [{ origin: "http://ollama:11434", label: "Local Ollama" }],
});
renderSection();
const user = await openAddKeyModalOnOllama();

await user.click(submitButton());

await waitFor(() =>
expect(createAiKeyMock).toHaveBeenCalledWith(
expect.objectContaining({ base_url: "http://ollama:11434" }),
),
);
});

it("shows a message and disables Save when no endpoints are approved", async () => {
getAiSettingsMock.mockResolvedValue({
configs: [],
platform_fallback_enabled: false,
approved_endpoints: [],
});
renderSection();

await openAddKeyModalOnOllama();

expect(
screen.getByText("No approved endpoints are configured. Contact your operator."),
).toBeInTheDocument();
expect(submitButton()).toBeDisabled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
setDefaultAiKey,
deleteAiKey,
setPlatformFallback,
ApprovedEndpoint,
ProviderKeyConfig,
} from "@/api/aiSettingsApi";
import { useAuth } from "@/context/AuthContext";
Expand Down Expand Up @@ -69,23 +70,28 @@ function AddKeyModal({
isOpen,
onClose,
isFirstKey,
approvedEndpoints,
}: {
isOpen: boolean;
onClose: () => void;
isFirstKey: boolean;
approvedEndpoints: ApprovedEndpoint[];
}) {
const queryClient = useQueryClient();
const [provider, setProvider] = useState(PROVIDERS[0].name);
const [model, setModel] = useState(MODELS_BY_PROVIDER[PROVIDERS[0].name][0].value);
const [label, setLabel] = useState("");
const [keyInput, setKeyInput] = useState("");
const [showKey, setShowKey] = useState(false);
const [urlInput, setUrlInput] = useState("http://localhost:11434");
const [selectedOrigin, setSelectedOrigin] = useState(approvedEndpoints[0]?.origin ?? "");
const [makeDefault, setMakeDefault] = useState(isFirstKey);

useEffect(() => {
if (isOpen) setMakeDefault(isFirstKey);
}, [isOpen, isFirstKey]);
if (isOpen) {
setMakeDefault(isFirstKey);
setSelectedOrigin(approvedEndpoints[0]?.origin ?? "");
}
}, [isOpen, isFirstKey, approvedEndpoints]);

const providerMeta = providerMetaFor(provider);
const availableModels = MODELS_BY_PROVIDER[provider] ?? [];
Expand All @@ -102,20 +108,22 @@ function AddKeyModal({
label: label.trim() || null,
key: providerMeta.requires_key ? keyInput : null,
model,
base_url: providerMeta.requires_key ? null : urlInput || null,
base_url: providerMeta.requires_key ? null : selectedOrigin || null,
is_default: makeDefault,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ai-settings"] });
setLabel("");
setKeyInput("");
setUrlInput("http://localhost:11434");
setSelectedOrigin(approvedEndpoints[0]?.origin ?? "");
setMakeDefault(false);
onClose();
},
});

const canSave = providerMeta.requires_key ? keyInput.trim().length > 0 : true;
const canSave = providerMeta.requires_key
? keyInput.trim().length > 0
: selectedOrigin.length > 0;
const help = PROVIDER_HELP[provider];

return (
Expand Down Expand Up @@ -196,14 +204,24 @@ function AddKeyModal({
</div>
) : (
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">Base URL</label>
<input
type="text"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
placeholder="http://localhost:11434"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<label className="block text-xs font-medium text-gray-700 mb-1.5">Endpoint</label>
{approvedEndpoints.length > 0 ? (
<select
value={selectedOrigin}
onChange={(e) => setSelectedOrigin(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{approvedEndpoints.map((endpoint) => (
<option key={endpoint.origin} value={endpoint.origin}>
{endpoint.label}
</option>
))}
</select>
) : (
<p className="text-sm text-gray-500">
No approved endpoints are configured. Contact your operator.
</p>
)}
</div>
)}

Expand Down Expand Up @@ -489,6 +507,7 @@ export function AiIntegrationsSection() {
isOpen={addModalOpen}
onClose={() => setAddModalOpen(false)}
isFirstKey={(data?.configs.length ?? 0) === 0}
approvedEndpoints={data?.approved_endpoints ?? []}
/>

{data && deletingConfig && (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
schema: spec-driven
created: 2026-09-29
author: Norair Arutshyan
priority: low
depends_on:
- ai-feat-site-assistant-grounded-answers
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## Why

After launch, the assistant will fail in ways the golden dataset didn't predict. This change closes the loop: production failure → review → new eval case → fix → permanent regression test. It also makes prompt or model changes a measured release decision (quality × latency × cost) instead of a config edit.

> **Status: skeleton.** The open questions below must be resolved before specs, design and tasks are written.

## What Changes

- **Feedback capture:** thumbs up/down on each answer, with optional reasons (incorrect, didn't answer, outdated, missing information, other). The stored record holds the question (redacted), retrieved IDs, answer, prompt version, model and feedback. It has a retention TTL and is purged automatically.
- **Review → dataset path:** a superuser-only way to list negative feedback and promote a record into the golden dataset as a new eval case, with its expected sources.
- **Candidate vs production comparison:** the eval harness runs two configurations (prompt version and/or model) over the same dataset. It reports groundedness, answer correctness, refusal correctness, hallucination rate, P95 latency, tokens and cost side by side.
- **LLM-as-judge:** only for semantic dimensions that code can't check (groundedness, completeness), with a pinned judge model and prompt version. Everything deterministic stays deterministic.
- **Release gates:** answer-level evals run nightly and on a PR label (they cost money and results vary run to run). A tolerance-band gate blocks promotion of a candidate prompt version or model that regresses.
- **Content reindex pipeline:** a content change triggers extract → chunk → index → retrieval evals → answer evals before the new index goes live.

## Capabilities

### New Capabilities
- `site-assistant-feedback`: capture, storage, retention and review of visitor feedback.
- `ai-release-evaluation`: candidate-vs-production comparison, judge usage rules and release gate semantics. This is written to be reusable by other AI features, not only the site assistant.

### Modified Capabilities
- `site-assistant-ui`: feedback controls on answers.
- `retrieval-evaluation`: the dataset gains production-sourced cases.

## Impact

- **Depends on:** `ai-feat-site-assistant-grounded-answers` (hard).
- **Related:** `gdpr-iso27001-priority-2`, whose `data-retention-policy` should own the feedback purge job if it has landed; otherwise this change adds its own Celery beat task and registers it in the worker's `include=[]`. Also related: `platform-chore-328-compliance-guardrails` (the PII-in-logs rule applies to stored questions).
- **ai service:** feedback model and migration, a superuser review endpoint, eval-runner extensions.
- **CI:** a new scheduled workflow for nightly answer evals, needing the platform Anthropic key as a GitHub secret.
- **Python dependencies:** none expected. Judge calls reuse `pydantic-ai`.

## Open Questions

1. **Retention period** for stored questions and feedback: 30 days to match the AI-session job, or 90 days to allow meaningful review?
2. **Redaction approach:** regex for emails, phones and names, or no raw question storage at all (store only the question embedding or a hash)?
3. **Where review happens:** inside the planned superuser admin console (`superuser-admin-console`), or a script/CLI for now?
4. **Nightly eval budget:** the maximum spend per run and per month on the platform key.
5. **Judge model:** same provider as generation (cheaper, correlated blind spots) or a different one (for example the Mistral BYOK path, which adds cost and a key)?
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
schema: spec-driven
created: 2026-09-29
author: Norair Arutshyan
priority: medium
depends_on:
- ai-feat-site-assistant-retrieval-evals
- frontend-feat-public-site-content
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## Why

Once retrieval is measured, visitors can ask questions on the landing page and get grounded, cited answers. This is the first **anonymous, internet-facing** AI endpoint in the platform, funded by the platform's own key. Abuse resistance, output handling and grounding are therefore requirements, not polish.

> **Status: skeleton.** The open questions below must be resolved before specs, design and tasks are written.

## What Changes

- **Endpoint:** `POST /api/v1/ai/site-assistant/ask` in the ai service. It is unauthenticated and behind a feature flag (off by default, so self-hosters opt in). It follows a fixed workflow: query → retrieve (`site-retrieval`) → generate → cite. There are no tools, planner or loop. It stays deliberately separate from the chat service, which is an authenticated agent host with write tools.
- **Model:** the platform-funded Haiku path (`resolve_platform_funded_model`). The system prompt is versioned in `ai_prompts`.
- **Structured output:** `{answer, cited_chunk_ids[], answerable: bool}`. The model **never emits URLs or titles**. The server resolves citations from `site_chunks` and drops any ID that was not in the retrieved set. That makes citation correctness and URL allow-listing a deterministic check.
- **Grounding policy:** answer only from the provided context, never invent pricing, partners, endorsements, guarantees or capabilities, and state plainly when information is unavailable. Retrieved text is wrapped and labelled as untrusted data, never as instructions.
- **Abuse controls:** per-IP and global daily request caps (the existing Redis limiter with new scopes), a question length limit, and off-topic refusal. When caps are exhausted it fails closed to "browse instead" links.
- **Observability:** OTEL GenAI span attributes (prompt version, model, retrieved IDs and scores, token usage, per-stage latency). Raw question text is **not** put on spans.
- **UI:** the redesign's assistant box on Home (persona toggle, answer with source links, "browse instead" fallback).
- **Evals:** deterministic checks (schema, citation subset, allow-listed URLs, refusal on the unanswerable set), plus an opt-in live suite: hallucination probes ("revenue last year", "government endorsement", "guaranteed funding") and injection fixtures planted in a test corpus.

## Capabilities

### New Capabilities
- `site-assistant`: the ask endpoint, grounding, citation resolution, refusal and abuse-control behaviour.
- `site-assistant-ui`: the landing-page assistant box.

### Modified Capabilities
- `public-site`: Home gains the assistant box.

## Impact

- **Depends on:** `ai-feat-site-assistant-retrieval-evals` (hard), `frontend-feat-public-site-content` (hard, via Home).
- **Related:** `chat-fix-prompt-injection-provenance` (same untrusted-data principle and eval layout); `ai-fix-361-provider-ssrf` (egress policy must permit the platform Anthropic origin).
- **Gateway:** covered by the existing `/api/v1/ai/` routes in `nginx.conf`, `nginx-dev.conf` and the Caddyfile. The per-IP limit needs a trusted `X-Forwarded-For` from Caddy.
- **Python dependencies:** none new expected (`pydantic-ai-slim[anthropic]` is already present).
- **Docs:** `docs/security/subprocessors.md`: visitor questions go to Anthropic. The privacy policy needs a line about the assistant.

## Open Questions

1. **Cap values:** per-IP per day, global per day, and a monthly spend ceiling. What is the maximum acceptable monthly cost?
2. **Bot protection:** is Cloudflare Turnstile (or similar) required from day one, or are caps enough until abuse shows up?
3. **Streaming:** is a non-streaming response acceptable for v1? Structured output with citations is simpler without streaming.
4. **Conversation memory:** single-turn only (recommended for v1), or short follow-ups?
5. **Legal copy:** does the privacy policy wording need review before launch, given that anonymous questions are sent to a US subprocessor?
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
schema: spec-driven
created: 2026-09-29
author: Norair Arutshyan
priority: medium
depends_on:
- frontend-feat-public-site-content
Loading
Loading