diff --git a/Caddyfile b/Caddyfile index a7d2dd9e..1cb9a90b 100644 --- a/Caddyfile +++ b/Caddyfile @@ -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" diff --git a/docs/deployment/DEPLOYMENT_MODES.md b/docs/deployment/DEPLOYMENT_MODES.md index 9159bf4f..55008041 100644 --- a/docs/deployment/DEPLOYMENT_MODES.md +++ b/docs/deployment/DEPLOYMENT_MODES.md @@ -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 diff --git a/docs/security/subprocessors.md b/docs/security/subprocessors.md index a173b888..d696e989 100644 --- a/docs/security/subprocessors.md +++ b/docs/security/subprocessors.md @@ -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 @@ -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. diff --git a/docs/user-guide/README.md b/docs/user-guide/README.md index ca8ccb54..d0fb0410 100644 --- a/docs/user-guide/README.md +++ b/docs/user-guide/README.md @@ -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: @@ -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 diff --git a/frontend-typescript/src/api/siteAssistantApi.test.tsx b/frontend-typescript/src/api/siteAssistantApi.test.tsx new file mode 100644 index 00000000..97612991 --- /dev/null +++ b/frontend-typescript/src/api/siteAssistantApi.test.tsx @@ -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"); + }); +}); diff --git a/frontend-typescript/src/api/siteAssistantApi.ts b/frontend-typescript/src/api/siteAssistantApi.ts new file mode 100644 index 00000000..5292fb15 --- /dev/null +++ b/frontend-typescript/src/api/siteAssistantApi.ts @@ -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 => { + const { data } = await gatewayApi.get("/ai/site-assistant/config"); + return data; +}; + +export const askSiteAssistant = async ( + params: AskSiteAssistantParams, +): Promise => { + 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 }); +} diff --git a/frontend-typescript/src/components/site/SiteAssistantBox.test.tsx b/frontend-typescript/src/components/site/SiteAssistantBox.test.tsx new file mode 100644 index 00000000..8b3aa4b7 --- /dev/null +++ b/frontend-typescript/src/components/site/SiteAssistantBox.test.tsx @@ -0,0 +1,195 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { axiosHttpError, withQueryClient } from "@/test/queryClient"; +import gatewayApi from "@/api/gatewayApi"; +import type { SiteAssistantAnswer } from "@/api/siteAssistantApi"; +import { SiteAssistantBox } from "./SiteAssistantBox"; + +vi.mock("@/api/gatewayApi", () => ({ default: { post: vi.fn() } })); + +const postMock = vi.mocked(gatewayApi.post); + +function answerWith(data: SiteAssistantAnswer) { + postMock.mockResolvedValue({ data }); +} + +function sentBody() { + return postMock.mock.calls[0][1]; +} + + +function renderBox(maxQuestionChars = 500) { + return render( + withQueryClient( + + + , + ), + ); +} + +async function askQuestion(text = "How do exports work?") { + const user = userEvent.setup(); + await user.type(screen.getByLabelText("Your question"), text); + await user.click(screen.getByRole("button", { name: "Ask" })); + return user; +} + +function expectBrowseLinks() { + expect(screen.getByRole("link", { name: "How it works" })).toHaveAttribute( + "href", + "/how-it-works", + ); + expect(screen.getByRole("link", { name: "Security & data" })).toHaveAttribute( + "href", + "/security", + ); +} + +beforeEach(() => { + postMock.mockReset(); +}); + +describe("SiteAssistantBox", () => { + it("shows the AI-processing notice and privacy link before any question", () => { + renderBox(); + + expect(screen.getByText(/sent to an AI provider/)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "privacy policy" })).toHaveAttribute( + "href", + "/legal#privacy-processors", + ); + }); + + it("sends the selected persona with the question", async () => { + answerWith({ answer: "No.", answerable: false, citations: [] }); + renderBox(); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: "Funder" })); + expect(screen.getByRole("button", { name: "Funder" })).toHaveAttribute("aria-pressed", "true"); + await user.type(screen.getByLabelText("Your question"), "Can I see grantee reports?"); + await user.click(screen.getByRole("button", { name: "Ask" })); + + await waitFor(() => expect(postMock).toHaveBeenCalled()); + expect(sentBody()).toEqual({ + question: "Can I see grantee reports?", + persona: "funder", + }); + }); + + it("sends no persona when none is chosen", async () => { + answerWith({ answer: "No.", answerable: false, citations: [] }); + renderBox(); + + await askQuestion(); + + await waitFor(() => expect(postMock).toHaveBeenCalled()); + expect(sentBody()).toEqual({ question: "How do exports work?", persona: null }); + }); + + it("limits the field to the configured maximum length", async () => { + renderBox(10); + const field = screen.getByLabelText("Your question"); + + expect(field).toHaveAttribute("maxLength", "10"); + await userEvent.setup().type(field, "this is far longer than ten"); + expect(field).toHaveValue("this is fa"); + }); + + it("disables submit until the response arrives", async () => { + let resolve: (value: { data: SiteAssistantAnswer }) => void = () => {}; + postMock.mockReturnValue(new Promise((r) => (resolve = r))); + renderBox(); + + await askQuestion(); + + expect(await screen.findByRole("button", { name: "Asking…" })).toBeDisabled(); + resolve({ data: { answer: "Done.", answerable: true, citations: [] } }); + expect(await screen.findByRole("button", { name: "Ask" })).toBeEnabled(); + }); + + it("shows a cited answer with one link per citation", async () => { + answerWith({ + answer: "You can export budgets to Excel.", + answerable: true, + citations: [ + { content_id: "a", url: "/how-it-works#export", title: "How it works: Export" }, + { content_id: "b", url: "/guides/budgets#excel", title: "Budgets guide: Excel" }, + ], + }); + renderBox(); + + await askQuestion(); + + expect(await screen.findByText("You can export budgets to Excel.")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "How it works: Export" })).toHaveAttribute( + "href", + "/how-it-works#export", + ); + expect(screen.getByRole("link", { name: "Budgets guide: Excel" })).toHaveAttribute( + "href", + "/guides/budgets#excel", + ); + }); + + it("drops citations that are not site paths", async () => { + answerWith({ + answer: "See this.", + answerable: true, + citations: [ + { content_id: "a", url: "https://evil.example/x", title: "Evil" }, + { content_id: "b", url: "//evil.example/y", title: "Also evil" }, + { content_id: "d", url: "/\\evil.example/z", title: "Backslash evil" }, + { content_id: "c", url: "/about#team", title: "About: Team" }, + ], + }); + renderBox(); + + await askQuestion(); + + expect(await screen.findByRole("link", { name: "About: Team" })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Evil" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Also evil" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Backslash evil" })).not.toBeInTheDocument(); + }); + + it("renders markup in the answer as literal text", async () => { + const markup = 'bold'; + answerWith({ answer: markup, answerable: true, citations: [] }); + const { container } = renderBox(); + + await askQuestion(); + + expect(await screen.findByText(markup)).toBeInTheDocument(); + expect(container.querySelector("b")).toBeNull(); + expect(container.querySelector("img")).toBeNull(); + }); + + it("falls back to browse links for an unanswerable question", async () => { + answerWith({ answer: "n/a", answerable: false, citations: [] }); + renderBox(); + + await askQuestion(); + + expect(await screen.findByText(/couldn't find that/)).toBeInTheDocument(); + expect(screen.queryByText("n/a")).not.toBeInTheDocument(); + expectBrowseLinks(); + }); + + it.each([ + [429, /Try again tomorrow/], + [503, /isn't available right now/], + [404, /isn't available right now/], + [500, /Something went wrong/], + ])("falls back to browse links on a %i response", async (status, message) => { + postMock.mockRejectedValue(axiosHttpError(status)); + renderBox(); + + await askQuestion(); + + expect(await screen.findByText(message)).toBeInTheDocument(); + expectBrowseLinks(); + }); +}); diff --git a/frontend-typescript/src/components/site/SiteAssistantBox.tsx b/frontend-typescript/src/components/site/SiteAssistantBox.tsx new file mode 100644 index 00000000..1de8d14a --- /dev/null +++ b/frontend-typescript/src/components/site/SiteAssistantBox.tsx @@ -0,0 +1,172 @@ +import { useState, type FormEvent } from "react"; +import { Link } from "react-router-dom"; +import { getSection } from "@/lib/siteContent"; +import { + classifySiteAssistantError, + useAskSiteAssistant, + type SiteAssistantFailure, + type SiteAssistantPersona, +} from "@/api/siteAssistantApi"; + +const PERSONAS: { value: SiteAssistantPersona | null; label: string }[] = [ + { value: null, label: "Anyone" }, + { value: "grantee", label: "Grantee" }, + { value: "funder", label: "Funder" }, + { value: "technical", label: "Technical" }, +]; + +// Same links as Home's own Browse-instead cards, so renames stay in one content file. +function browseLinks() { + const items = getSection("home.browse-instead")?.items ?? []; + return items.flatMap((item) => + item.title && item.href ? [{ to: item.href, label: item.title }] : [], + ); +} + +// Resolving against the origin also catches "/\host" and other forms browsers treat as off-site. +function isSitePath(url: string): boolean { + if (!url.startsWith("/")) return false; + try { + return new URL(url, window.location.origin).origin === window.location.origin; + } catch { + return false; + } +} + +const FALLBACK_MESSAGES: Record = { + unanswerable: "Sorry, I couldn't find that on this site. Try browsing instead:", + daily_cap: "The assistant has answered all it can for today. Try again tomorrow, or browse:", + unavailable: "The assistant isn't available right now. You can browse instead:", + error: "Something went wrong asking the assistant. You can browse instead:", +}; + +function BrowseInstead({ message }: { message: string }) { + return ( +
+

{message}

+
    + {browseLinks().map((link) => ( +
  • + + {link.label} + +
  • + ))} +
+
+ ); +} + +export function SiteAssistantBox({ maxQuestionChars }: { maxQuestionChars: number }) { + const [question, setQuestion] = useState(""); + const [persona, setPersona] = useState(null); + const ask = useAskSiteAssistant(); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + const trimmed = question.trim(); + if (!trimmed || ask.isPending) return; + ask.mutate({ question: trimmed, persona }); + } + + const answer = ask.data; + // Server citation URLs are site paths; anything else is dropped rather than linked. + const citations = answer?.citations.filter((c) => isSitePath(c.url)); + + return ( +
+
+

Ask about Open Grant Flow

+

+ Questions are sent to an AI provider to generate an answer from this website's + content, and are not stored by us. Please don't include personal details. See our{" "} + + privacy policy + + . +

+ +
+
+ I am asking as +
+ {PERSONAS.map((option) => ( + + ))} +
+
+ +
+ +