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
2 changes: 1 addition & 1 deletion Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ api.opengrantflow.com {
# Custom response headers are invisible to browser JS unless explicitly exposed —
# without this, the frontend can never read X-Session-Id and every chat turn
# starts a brand-new session with no history.
header Access-Control-Expose-Headers "X-Session-Id, X-Conversation-Id"
header Access-Control-Expose-Headers "X-Session-Id, X-Conversation-Id, Retry-After"
header @allowed_apex Access-Control-Allow-Origin "https://opengrantflow.com"
header @allowed_www Access-Control-Allow-Origin "https://www.opengrantflow.com"

Expand Down
23 changes: 23 additions & 0 deletions docs/deployment/DEPLOYMENT_MODES.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,29 @@ All set already: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `RABBITMQ_

**Not yet set — needed for the platform-funded AI fallback (`services/ai`):** `ANTHROPIC_API_KEY`. Without it, `resolve_platform_funded_model()` returns `None` and every platform-funded fallback path (excel import for zero-key orgs, and the superuser-gated `/ai/decide` / `/ai/parse-budget/stream` fallback) fails closed even when it should recover — confirmed missing in prod on 2026-08-25.

### Public site assistant (`services/ai`)

The anonymous "ask a question" box on the public website is off unless `SITE_ASSISTANT_ENABLED=true`. While it is off, `POST /api/v1/ai/site-assistant/ask` returns 404 and the Home page hides the box. Don't enable it in production until the live eval suite has passed and the privacy-policy wording has legal sign-off (see the `ai-feat-387-site-assistant-grounded-answers` change's design.md, Migration Plan).

| Setting | Default | Meaning |
|---|---|---|
| `SITE_ASSISTANT_ENABLED` | `false` | Feature flag for the endpoint and the Home box |
| `SITE_ASSISTANT_PROVIDER` | `anthropic` | `anthropic` (platform `ANTHROPIC_API_KEY`) or `ollama` (`OLLAMA_URL`, which must be in `AI_PROVIDER_APPROVED_ORIGINS`). Any other value fails startup |
| `SITE_ASSISTANT_MODEL` | unset | Model name; unset uses Claude Haiku for `anthropic` and `OLLAMA_MODEL` for `ollama` |
| `SITE_ASSISTANT_PER_IP_DAILY_CAP` | unset | Questions per visitor IP per UTC day. Unset uses the provider profile (`anthropic` 20, `ollama` 200); `0` means unlimited |
| `SITE_ASSISTANT_GLOBAL_DAILY_CAP` | unset | Model calls per UTC day across all visitors. Unset uses the provider profile (`anthropic` 300, `ollama` none); `0` means unlimited |
| `SITE_ASSISTANT_MAX_QUESTION_CHARS` | `500` | Longer questions get a 422 |
| `SITE_ASSISTANT_TOP_K` | `5` | Site chunks retrieved per question |
| `SITE_ASSISTANT_MIN_SCORE` | `0.25` | Top retrieval score below this answers "not covered" without calling the model |
| `SITE_ASSISTANT_HUMAN_VERIFIER` | `none` | Bot-check hook; `none` is the only verifier today |
| `SITE_ASSISTANT_LINK_HOSTS` | `opengrantflow.com,www.opengrantflow.com` | Hosts an answer may link to. An answer linking to any other host is replaced with the "not covered" reply. Dev adds `localhost`; self-hosters set their own domain |

If the configured model can't be resolved (missing key, unapproved Ollama origin), `ask` returns 503 and the Home box falls back to browse links.

Caps reset at 00:00 UTC and are counted in Redis. If Redis is unreachable, `ask` returns 503 rather than serving without limits.

**Never publish the ai service's port directly.** In production, uvicorn runs with `--proxy-headers --forwarded-allow-ips='*'`, so it takes the client address from the leftmost `X-Forwarded-For` entry. That is only safe because Caddy (with no `trusted_proxies` set) replaces any client-sent `X-Forwarded-For` with the real peer address. If the ai port were reachable without going through Caddy, or if `trusted_proxies` or a `header_up X-Forwarded-For` passthrough were added to the Caddyfile, a visitor could choose their own IP and get past the per-IP cap. `services/ai/tests/test_site_assistant_client_ip.py` fails if the Caddyfile changes in either way.

### Observability

Traces, metrics, and logs from all five services (the four FastAPI services
Expand Down
5 changes: 4 additions & 1 deletion docs/security/subprocessors.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ subprocessor list depends entirely on which providers you configure.
| [Mailjet](https://www.mailjet.com/) | Transactional email (account verification, email-change confirmation, notifications) | Recipient name and email address, email content/template data | EU — France (Mailjet SAS) |
| [MailerSend](https://www.mailersend.com/) | Transactional email — supported as an alternate provider alongside Mailjet (`EMAIL_PROVIDER` setting, see `services/worker/tasks/users/send_verification_email.py`) | Recipient name and email address, email content/template data | EU (per provider's published documentation) — verify against MailerSend's current terms before relying on this for a compliance claim |
| [Anthropic](https://www.anthropic.com/) (and any other BYOK LLM provider a customer configures) | AI-assisted budget parsing and chat — the customer supplies their own API key (BYOK), but the request still transits GrandFlow's `ai` service on the way to the provider | Prompt content the user submits (may include budget/financial text), which can incidentally include personal data if the user includes it | US (Anthropic, PBC) — outside the EU/EEA; see the cross-border-transfer note below. Other BYOK providers a customer configures (e.g. self-hosted Ollama) have their own, separate hosting location |
| [Anthropic](https://www.anthropic.com/) — public site assistant | Answers questions anonymous visitors type into the assistant on the public website (`POST /api/v1/ai/site-assistant/ask`, off unless `SITE_ASSISTANT_ENABLED=true`). Sent under OpenGrantFlow's own platform account, not a customer's key | The visitor's question plus excerpts of OpenGrantFlow's public site content. Questions are not stored by OpenGrantFlow: they are not written to the database or audit log, and trace spans carry only chunk IDs, scores and token counts. The visitor's IP is used only as a keyed hash for daily rate limits | US (Anthropic, PBC) — see the cross-border-transfer note below. If an operator sets `SITE_ASSISTANT_PROVIDER=ollama`, questions go to that operator's own Ollama server instead |
| [Grafana Cloud](https://grafana.com/products/cloud/) | Production observability — traces, logs, and metrics (see `docs/observability/GRAFANA_CLOUD_PRODUCTION.md`) | Telemetry data (request traces, structured logs, metrics). Care is taken not to log raw PII into trace/log attributes, but identifiers such as user IDs and email addresses can appear in log lines during debugging | Depends on the Grafana Cloud stack selected at setup — check under your stack's Connections → Collector page. Confirm and record here once fixed |

## Cross-border transfer note
Expand All @@ -30,7 +31,9 @@ safeguard is needed for those two.
data that transits the `ai` service on its way to Anthropic's API is a
cross-border transfer and needs an appropriate safeguard under GDPR
Chapter V (e.g., Standard Contractual Clauses — Anthropic publishes a DPA
covering this for commercial customers). Any other BYOK provider a
covering this for commercial customers). The same applies to questions
visitors send through the public site assistant, which go to Anthropic
under OpenGrantFlow's own account. Any other BYOK provider a
customer configures should be assessed the same way based on where that
specific provider is hosted.

Expand Down
10 changes: 8 additions & 2 deletions docs/user-guide/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**Status:** Open Grant Flow is under active development. Some features described here may still be evolving, and this guide reflects the platform's current functionality rather than a finished, locked product.

**Last reviewed:** 2026-09-25
**Last reviewed:** 2026-10-17

This guide is split by audience:

Expand Down Expand Up @@ -99,7 +99,13 @@ Open Grant Flow is open source and under active development — feedback directl

The fastest way to report something that isn't working: click the message icon in the top bar, from any page. Describe what you were doing — the page, browser, and time are attached automatically — and optionally attach a screenshot (you can also paste one directly into the description field). The team is notified right away, and you don't need a GitHub account.

### 4.2 Other ways to reach us
### 4.2 Asking the website assistant

When it's switched on, the home page of [opengrantflow.com](https://opengrantflow.com) has a question box. You don't need an account to use it. Pick whether you're asking as a grantee, a funder or a technical reader (or leave it on "Anyone"), and you'll get a short answer drawn only from the public website and guides, with links to the pages it came from. If the site doesn't cover your question, or the assistant has reached its daily limit, it points you to the main pages instead.

Questions are sent to an AI provider to generate the answer and are not stored. Don't type personal details into the box. For anything about your own account or data, use the contact options below.

### 4.3 Other ways to reach us

- **Source & issues:** [github.com/arutsh/GrantFlow](https://github.com/arutsh/GrantFlow)
- **Contact:** n.arutshyan@gmail.com
Expand Down
87 changes: 87 additions & 0 deletions frontend-typescript/src/api/siteAssistantApi.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { renderHook, waitFor } from "@testing-library/react";
import gatewayApi from "@/api/gatewayApi";
import { axiosHttpError, QueryClientWrapper as wrapper } from "@/test/queryClient";
import {
classifySiteAssistantError,
useAskSiteAssistant,
useSiteAssistantConfig,
} from "./siteAssistantApi";

vi.mock("@/api/gatewayApi", () => ({ default: { get: vi.fn(), post: vi.fn() } }));

const getMock = vi.mocked(gatewayApi.get);
const postMock = vi.mocked(gatewayApi.post);

beforeEach(() => {
getMock.mockReset();
postMock.mockReset();
});

describe("useSiteAssistantConfig", () => {
it("returns the config from the ai service", async () => {
getMock.mockResolvedValue({ data: { enabled: true, max_question_chars: 500 } });

const { result } = renderHook(() => useSiteAssistantConfig(), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ enabled: true, max_question_chars: 500 });
expect(getMock).toHaveBeenCalledWith("/ai/site-assistant/config");
});

it("does not fetch while disabled", () => {
renderHook(() => useSiteAssistantConfig({ enabled: false }), { wrapper });

expect(getMock).not.toHaveBeenCalled();
});

it("does not retry a failed config request", async () => {
getMock.mockRejectedValue(axiosHttpError(503));

const { result } = renderHook(() => useSiteAssistantConfig(), { wrapper });

await waitFor(() => expect(result.current.isError).toBe(true));
expect(getMock).toHaveBeenCalledTimes(1);
});
});

describe("useAskSiteAssistant", () => {
it("posts the question and persona and returns the answer", async () => {
const answer = {
answer: "Budgets export to Excel.",
answerable: true,
citations: [{ content_id: "a", url: "/how-it-works#export", title: "Export" }],
};
postMock.mockResolvedValue({ data: answer });

const { result } = renderHook(() => useAskSiteAssistant(), { wrapper });
result.current.mutate({ question: "Can I export?", persona: "funder" });

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(answer);
expect(postMock).toHaveBeenCalledWith("/ai/site-assistant/ask", {
question: "Can I export?",
persona: "funder",
});
});

it.each([
[429, "daily_cap"],
[503, "unavailable"],
[404, "unavailable"],
[403, "unavailable"],
[500, "error"],
])("classifies a %i response as %s", async (status, expected) => {
postMock.mockRejectedValue(axiosHttpError(status));

const { result } = renderHook(() => useAskSiteAssistant(), { wrapper });
result.current.mutate({ question: "Hi?", persona: null });

await waitFor(() => expect(result.current.isError).toBe(true));
expect(postMock).toHaveBeenCalledTimes(1);
expect(classifySiteAssistantError(result.current.error)).toBe(expected);
});

it("classifies a network error as a generic error", () => {
expect(classifySiteAssistantError(new Error("offline"))).toBe("error");
});
});
65 changes: 65 additions & 0 deletions frontend-typescript/src/api/siteAssistantApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { isAxiosError } from "axios";
import { useMutation, useQuery } from "@tanstack/react-query";
import gatewayApi from "@/api/gatewayApi";

export type SiteAssistantPersona = "grantee" | "funder" | "technical";

export interface SiteAssistantConfig {
enabled: boolean;
max_question_chars: number;
}

export interface SiteAssistantCitation {
content_id: string;
url: string;
title: string;
}

export interface SiteAssistantAnswer {
answer: string;
answerable: boolean;
citations: SiteAssistantCitation[];
}

export interface AskSiteAssistantParams {
question: string;
persona: SiteAssistantPersona | null;
}

export type SiteAssistantFailure = "daily_cap" | "unavailable" | "error";

export const getSiteAssistantConfig = async (): Promise<SiteAssistantConfig> => {
const { data } = await gatewayApi.get("/ai/site-assistant/config");
return data;
};

export const askSiteAssistant = async (
params: AskSiteAssistantParams,
): Promise<SiteAssistantAnswer> => {
const { data } = await gatewayApi.post("/ai/site-assistant/ask", params);
return data;
};

// 404 = switched off since config loaded; 403 = verifier rejected us (no token is sent yet).
const UNAVAILABLE_STATUSES = new Set([403, 404, 503]);

export function classifySiteAssistantError(error: unknown): SiteAssistantFailure {
const status = isAxiosError(error) ? error.response?.status : undefined;
if (status === 429) return "daily_cap";
if (status !== undefined && UNAVAILABLE_STATUSES.has(status)) return "unavailable";
return "error";
}

export function useSiteAssistantConfig({ enabled = true }: { enabled?: boolean } = {}) {
return useQuery({
queryKey: ["siteAssistant", "config"],
queryFn: getSiteAssistantConfig,
enabled,
retry: false,
staleTime: 5 * 60 * 1000,
});
}

export function useAskSiteAssistant() {
return useMutation({ mutationFn: askSiteAssistant, retry: false });
}
Loading
Loading