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
27 changes: 21 additions & 6 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,29 @@ ENABLE_OSM_MAPS=true
# 1. claude → if ANTHROPIC_API_KEY is set
# 2. azure_foundry → if AZURE_AI_PROJECT_ENDPOINT + deployment name are set
# 3. ollama_cloud → if OLLAMA_CLOUD_API_KEY is set (hosted, metered)
# 4. ollama → fallback (local self-hosted, no API key, free)
# Claude wins over Ollama Cloud when both keys are present. A user's own key
# (BYOK, set in their profile) overrides this entirely. With the local Ollama
# provider the LLM-access gate is open (no per-use cost); Claude / Ollama Cloud
# keep the paid gate (BYOK or subscription). Force a specific provider by
# setting it explicitly (ollama|ollama_cloud|claude|azure_foundry).
# 4. llamacpp → fallback (server llama.cpp self-hosted, no API key, free)
# Claude wins over Ollama Cloud when both keys are present, e qualunque chiave
# cloud vince sul motore locale: valorizzare ANTHROPIC_API_KEY *e' gia'* la
# configurazione che seleziona Claude. A user's own key (BYOK, set in their
# profile) overrides this entirely. Con un provider self-hosted (llamacpp o
# ollama) il gate di accesso LLM e' aperto (nessun costo per chiamata); Claude e
# Ollama Cloud mantengono il gate a pagamento (BYOK o abbonamento). Force a
# specific provider by setting it explicitly
# (llamacpp|ollama|ollama_cloud|claude|azure_foundry).
#
# ATTENZIONE: il fallback era `ollama`, ora e' `llamacpp`. Chi si affidava al
# fallback implicito deve dichiarare LLM_PROVIDER=ollama.
LLM_PROVIDER=auto

# ── llama.cpp / llama-swap (DEFAULT: server di inferenza self-hosted) ──
# Compatibile OpenAI. Il /v1 finale e' facoltativo: viene aggiunto se manca.
# Dai container usa host.docker.internal, non 127.0.0.1.
LLAMACPP_BASE_URL=http://host.docker.internal:8080
# Id logico del modello = chiave del config.yaml di llama-swap, non un file.
LLAMACPP_MODEL=qwen3.5-9b
# Solo se llama-server e' avviato con --api-key.
LLAMACPP_API_KEY=

# ── Ollama (used when auto falls back to local, or LLM_PROVIDER=ollama) ──
# Pre-built image with qwen2.5:32k (num_ctx=16384, temperature 0) already baked in.
# Fallback: set OLLAMA_IMAGE=ollama/ollama:latest and run `make pull-models`.
Expand Down
14 changes: 11 additions & 3 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,19 @@ ENABLE_OSM_MAPS=true
# ═════════════════════════════════════════════════════════════════
# "auto" resolves by credential presence: claude (if ANTHROPIC_API_KEY) →
# azure_foundry (if Azure project set) → ollama_cloud (if OLLAMA_CLOUD_API_KEY)
# → ollama (local). Claude wins over Ollama Cloud when both keys are set. Set
# the key(s) below and auto picks the right provider; a user's own BYOK key
# overrides it. Force explicitly: claude | ollama_cloud | azure_foundry | ollama.
# → llamacpp (server self-hosted). Claude wins over Ollama Cloud when both keys
# are set. Set the key(s) below and auto picks the right provider; a user's own
# BYOK key overrides it. Force explicitly:
# claude | ollama_cloud | azure_foundry | llamacpp | ollama.
#
# ATTENZIONE: il fallback era `ollama`, ora e' `llamacpp`.
LLM_PROVIDER=auto

# ── Variante D: llama.cpp self-hosted (scelto da auto senza chiavi cloud) ──
# LLAMACPP_BASE_URL=http://host.docker.internal:8080
# LLAMACPP_MODEL=qwen3.5-9b
# LLAMACPP_API_KEY=

# ── Variant A: Anthropic Claude (recommended; auto picks this when key set) ──
# Required — store as a secret in your platform (not in this file once filled).
# Get a key at https://console.anthropic.com/settings/keys
Expand Down
17 changes: 14 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,21 @@ opendata-backend-api # http://localhost:8000

## LLM provider

`LLM_PROVIDER = ollama | azure_foundry | claude | auto` resolved by
`opendata_backend.config.resolve_provider`. `auto` (default) picks
`LLM_PROVIDER = llamacpp | ollama | ollama_cloud | azure_foundry | claude | auto`
resolved by `opendata_backend.config.resolve_provider`. `auto` (default) picks
`claude` if `ANTHROPIC_API_KEY` is set, `azure_foundry` if the Azure
endpoint + deployment name are set, else `ollama`. **Every** LLM path —
endpoint + deployment name are set, `ollama_cloud` if its key is set, else
**`llamacpp`** — the self-hosted llama.cpp/llama-swap server, driven through
`agent_framework.openai.OpenAIChatClient` because it speaks the OpenAI wire
format. Claude stays available but opt-in: setting `ANTHROPIC_API_KEY` *is* the
configuration that selects it.

> The no-credentials fallback moved from `ollama` to `llamacpp`. A deploy that
> relied on the implicit Ollama fallback must now set `LLM_PROVIDER=ollama`.

`config.is_self_hosted(provider)` groups `llamacpp` + `ollama`: both are served
by us, so the LLM-access gate stays open (nothing to meter) and the territory
report runs in `concise` mode (small local models). **Every** LLM path —
synth, classify, semantic-maturità, narratives, use-case `explain` — now
follows the resolved provider. The auxiliary paths route through the shared
one-shot helper `opendata_backend.llm.complete()` (provider-agnostic);
Expand Down
9 changes: 9 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -435,8 +435,17 @@ services:
container_name: opendata-backend
ports:
- "${BACKEND_PORT:-18000}:8000"
# Il server di inferenza llama.cpp gira sull'host, non in compose:
# su Linux host.docker.internal non si risolve senza questa riga.
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
LLM_PROVIDER: ${LLM_PROVIDER:-auto}
# Motore predefinito: server llama.cpp self-hosted sull'host.
# host.docker.internal richiede extra_hosts su Linux (sotto).
LLAMACPP_BASE_URL: ${LLAMACPP_BASE_URL:-http://host.docker.internal:8080}
LLAMACPP_MODEL: ${LLAMACPP_MODEL:-qwen3.5-9b}
LLAMACPP_API_KEY: ${LLAMACPP_API_KEY:-}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://opendata-ai-ollama:11434}
OLLAMA_LLM_MODEL: ${OLLAMA_LLM_MODEL:-qwen2.5:32k}
OLLAMA_NUM_CTX: ${OLLAMA_NUM_CTX:-16384}
Expand Down
4 changes: 4 additions & 0 deletions opendata-ai-ui/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@ ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ARG NEXT_PUBLIC_CLERK_SIGN_IN_URL
ARG NEXT_PUBLIC_CLERK_SIGN_UP_URL
# Path prefix the app is served under, e.g. `/opendata` when it shares a
# hostname with other projects. Empty (the default) keeps the bundle rooted.
ARG NEXT_PUBLIC_BASE_PATH
Comment on lines +22 to +24

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 Forward the base-path argument from supported image builds

Declaring this build argument is insufficient for the repository's supported build paths: the opendata-ai-ui build args in docker-compose.yml and the build-args block in .github/workflows/docker-publish.yml forward the other NEXT_PUBLIC_* values but not NEXT_PUBLIC_BASE_PATH. Consequently, setting the new variable in the compose environment or GitHub repository variables still produces a root-based static bundle, so the advertised prefixed deployment fails unless someone bypasses both build paths and invokes Docker manually.

Useful? React with 👍 / 👎.

ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \
NEXT_PUBLIC_CLERK_SIGN_IN_URL=$NEXT_PUBLIC_CLERK_SIGN_IN_URL \
NEXT_PUBLIC_CLERK_SIGN_UP_URL=$NEXT_PUBLIC_CLERK_SIGN_UP_URL \
NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH \
NEXT_TELEMETRY_DISABLED=1
RUN npm run build

Expand Down
3 changes: 2 additions & 1 deletion opendata-ai-ui/app/account/api-keys/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import Image from "next/image";
import Link from "next/link";
import { withBasePath } from "@/lib/basePath";
import { DashboardGate } from "@/components/DashboardGate";

function Inner() {
Expand All @@ -20,7 +21,7 @@ function Inner() {

<div className="text-center mt-4">
<Image
src="/coming-soon.svg"
src={withBasePath("/coming-soon.svg")}
alt="Coming soon…"
width={800}
height={340}
Expand Down
3 changes: 2 additions & 1 deletion opendata-ai-ui/app/approfondimenti/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { withBasePath } from "@/lib/basePath";

import { PageHero } from "@/components/PageHero";

Expand Down Expand Up @@ -313,7 +314,7 @@ export default function Page() {
},
{
label: "OpenData AI — guida all'apertura dei dati in un Comune",
href: "/guida-open-data",
href: withBasePath("/guida-open-data"),
},
]}
/>
Expand Down
3 changes: 2 additions & 1 deletion opendata-ai-ui/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import type { Metadata } from "next";
import { AuthShell } from "@/components/AuthShell";
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
import { withBasePath } from "@/lib/basePath";

import "./globals.css";

export const metadata: Metadata = {
title: "OpenData AI",
description:
"Il tuo agente di intelligenza artificiale per gli open data — portali CKAN e statistiche ufficiali (ISTAT, Eurostat, OCSE)",
icons: { icon: "/logo-mark.svg" },
icons: { icon: withBasePath("/logo-mark.svg") },
};

export default function RootLayout({
Expand Down
3 changes: 2 additions & 1 deletion opendata-ai-ui/app/sostieni/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { withBasePath } from "@/lib/basePath";

import { BuyMeACoffeeButton } from "@/components/BuyMeACoffeeButton";
import { SostieniButton } from "@/components/SostieniButton";
Expand Down Expand Up @@ -186,7 +187,7 @@ export default function Page() {
<a href={OLLAMA_URL} target="_blank" rel="noopener noreferrer">
Ollama Cloud
</a>{" "}
o un Ollama locale) dal <a href="/account/llm-key">tuo profilo</a>.
o un Ollama locale) dal <a href={withBasePath("/account/llm-key")}>tuo profilo</a>.
</div>
</div>
</div>
Expand Down
23 changes: 23 additions & 0 deletions opendata-ai-ui/lib/basePath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Deployment base path helper.
*
* When the app is served under a path prefix (NEXT_PUBLIC_BASE_PATH, see
* `next.config.ts`), Next.js prefixes what it controls on its own: `<Link>`
* hrefs, `router.push`, and every `_next/*` asset URL.
*
* It does NOT touch two things, which is what this helper is for:
* 1. files served from `public/` and referenced by a literal path
* (`/logo-mark.svg`) — documented Next.js behaviour;
* 2. plain `<a href="/…">` anchors to internal routes, which bypass the
* router entirely.
*
* With no prefix configured (the GitHub Pages and dedicated-host deploys)
* BASE_PATH is "" and every value passes through unchanged.
*/
export const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");

/** Prefix a root-relative URL with the deployment base path. */
export function withBasePath(path: string): string {
if (!BASE_PATH || !path.startsWith("/")) return path;
return `${BASE_PATH}${path}`;
}
9 changes: 9 additions & 0 deletions opendata-ai-ui/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@ import type { NextConfig } from "next";

// Static export for GitHub Pages — no server-side runtime in production.
// The frontend talks directly to the backend at `NEXT_PUBLIC_API_URL`.
//
// `basePath` is opt-in via NEXT_PUBLIC_BASE_PATH at BUILD time (a static
// export inlines every URL, so it cannot be a runtime setting). Unset — the
// GitHub Pages and dedicated-host deploys — keeps the bundle at the root
// exactly as before. Set it to e.g. `/opendata` when the app is served under
// a path prefix on a host shared with other projects.
const basePath = process.env.NEXT_PUBLIC_BASE_PATH?.replace(/\/+$/, "") || undefined;

const nextConfig: NextConfig = {
output: "export",
images: { unoptimized: true },
poweredByHeader: false,
trailingSlash: true,
...(basePath ? { basePath } : {}),

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 Make OIDC redirects honor the configured base path

When a prefixed build also has OIDC enabled, authentication remains rooted at the host: lib/oidc.ts::redirectUri() returns ${window.location.origin}/, and logout uses the same root-relative destination. On the shared-host deployment this feature targets, the identity provider therefore returns the authorization code to another application at /, so this UI never processes the callback and protected pages cannot be used. The callback, post-logout, and local logout destinations need to include the configured base path.

Useful? React with 👍 / 👎.

};

export default nextConfig;
4 changes: 3 additions & 1 deletion opendata-backend/src/opendata_backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

def _active_model(settings: Settings) -> str:
provider = resolve_provider(settings)
if provider == "llamacpp":
return settings.llamacpp_model
if provider == "ollama":
return settings.ollama_llm_model
if provider == "azure_foundry":
Expand Down Expand Up @@ -78,7 +80,7 @@ def cli() -> None:
)
parser.add_argument(
"--provider",
choices=["auto", "ollama", "azure_foundry", "claude"],
choices=["auto", "llamacpp", "ollama", "azure_foundry", "claude"],
default=None,
)
parser.add_argument("--ckan-mcp-url", default=None)
Expand Down
52 changes: 42 additions & 10 deletions opendata-backend/src/opendata_backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,17 @@
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict

Provider = Literal["auto", "ollama", "ollama_cloud", "azure_foundry", "claude"]
Provider = Literal["auto", "llamacpp", "ollama", "ollama_cloud", "azure_foundry", "claude"]

# Provider serviti da noi, senza costo per chiamata: niente da tariffare e
# modelli piccoli, quindi report in modalita' sintetica. Ollama Cloud e Claude
# NON sono qui: sono a consumo.
SELF_HOSTED_PROVIDERS: frozenset[str] = frozenset({"llamacpp", "ollama"})


def is_self_hosted(provider: str) -> bool:
"""True per i motori locali che gestiamo noi (llama.cpp, Ollama)."""
return provider in SELF_HOSTED_PROVIDERS


# Verbatim copy of ckan_agent.config.AGENT_INSTRUCTIONS — keep in sync.
Expand Down Expand Up @@ -1280,6 +1290,16 @@ class Settings(BaseSettings):
eurostat_sdmx_base_url: str = Field(default=_EUROSTAT_BASE_URL)
oecd_sdmx_base_url: str = Field(default=_OECD_BASE_URL)

# llama.cpp / llama-swap — server di inferenza self-hosted, compatibile
# OpenAI. E' il motore predefinito quando non ci sono credenziali cloud.
# Il valore puo' includere o meno /v1: build_chat_client normalizza.
llamacpp_base_url: str = Field(default="http://localhost:8080")
# Id logico del modello = chiave del config.yaml di llama-swap.
llamacpp_model: str = Field(default="qwen3.5-9b")
# Solo se llama-server gira con --api-key. L'SDK OpenAI pretende comunque
# una stringa non vuota, quindi build_chat_client ne mette una fittizia.
llamacpp_api_key: str | None = Field(default=None)

# Ollama (OpenAI-compatible)
ollama_base_url: str = Field(default="http://localhost:11434")
ollama_llm_model: str = Field(default="qwen2.5:16k")
Expand Down Expand Up @@ -1456,6 +1476,13 @@ class Settings(BaseSettings):
# (e.g. https://opendata.<your-domain>). In local dev keep localhost:3000.
cors_allow_origins: str = Field(default="http://localhost:3000")

# Path prefix a reverse proxy strips before forwarding (e.g.
# ``/opendata/api``). Empty when the app owns its hostname. It does not
# change the routes the app matches — it only makes the OpenAPI schema and
# the /docs page advertise URLs that are valid from the outside, instead of
# a root-relative /openapi.json that would land on another service.
root_path: str = Field(default="")

# Base URL pubblica del frontend OpenData AI. Usata per i link assoluti negli
# export embeddabili (es. la scorecard di maturità in Markdown linka la scheda
# `/maturita` e la guida `/guida-open-data`). In prod è il dominio pubblico.
Expand Down Expand Up @@ -1832,11 +1859,16 @@ def resolve_provider(settings: Settings) -> Provider:
1. claude — if ANTHROPIC_API_KEY is set
2. azure_foundry — if AZURE_AI_PROJECT_ENDPOINT + deployment name are set
3. ollama_cloud — if OLLAMA_CLOUD_API_KEY is set (hosted, metered)
4. ollama — fallback (local inference; OLLAMA_BASE_URL may point at
a remote inference container in production)
4. llamacpp — fallback (self-hosted llama.cpp/llama-swap inference
server; LLAMACPP_BASE_URL may point at a remote host)

Claude wins over Ollama Cloud when both keys are present, and any cloud key
wins over the local engine — impostare ANTHROPIC_API_KEY *e'* la
configurazione che seleziona Claude. A user's own BYOK credential overrides
this entirely (see llm_access / build_chat_client).

Claude wins over Ollama Cloud when both keys are present. A user's own BYOK
credential overrides this entirely (see llm_access / build_chat_client).
NOTA — il fallback era "ollama": chi ci contava implicitamente deve ora
dichiarare LLM_PROVIDER=ollama.
"""
if settings.llm_provider != "auto":
return settings.llm_provider
Expand All @@ -1846,7 +1878,7 @@ def resolve_provider(settings: Settings) -> Provider:
return "azure_foundry"
if settings.ollama_cloud_api_key:
return "ollama_cloud"
return "ollama"
return "llamacpp"

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 Keep the default CPU stack on its bundled Ollama provider

With the documented make up flow, .env.local.example leaves LLM_PROVIDER=auto and Makefile routes up to up-cpu, which starts ollama-cpu but no llama.cpp server. This fallback therefore directs every LLM request to host.docker.internal:8080, where a fresh setup has nothing listening, while the bundled Ollama container is ignored. Either force LLM_PROVIDER=ollama in the CPU/GPU targets or include and validate the new inference server in the default stack.

Useful? React with 👍 / 👎.



# ── Report depth tiering (territorio) ──────────────────────────────────────
Expand All @@ -1869,14 +1901,14 @@ def resolve_provider(settings: Settings) -> Provider:
def resolve_report_depth(settings: "Settings") -> Literal["full", "concise"]:
"""Profondità del report territorio in base alla capacità del modello.

`auto` (default): `concise` per ollama locale (modelli piccoli), `full` per
claude/azure_foundry/ollama_cloud (capaci/cloud). Forzabile con
`REPORT_DEPTH=full|concise`.
`auto` (default): `concise` per i motori self-hosted (llama.cpp, Ollama —
modelli piccoli), `full` per claude/azure_foundry/ollama_cloud
(capaci/cloud). Forzabile con `REPORT_DEPTH=full|concise`.
"""
mode = settings.report_depth
if mode in ("full", "concise"):
return mode # type: ignore[return-value]
return "concise" if resolve_provider(settings) == "ollama" else "full"
return "concise" if is_self_hosted(resolve_provider(settings)) else "full"


def get_settings() -> Settings:
Expand Down
26 changes: 26 additions & 0 deletions opendata-backend/src/opendata_backend/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,32 @@ def build_chat_client(settings: Settings, byok: "BYOKCreds | None" = None) -> An
provider = resolve_provider(settings)
log.info("Building chat client for provider=%s (configured=%s)", provider, settings.llm_provider)

if provider == "llamacpp":
# llama-server / llama-swap speak the OpenAI wire format, so the
# framework's OpenAI client drives them unchanged.
from agent_framework.openai import OpenAIChatClient

base_url = settings.llamacpp_base_url.rstrip("/")
# The OpenAI SDK treats base_url as the API root and appends
# "/chat/completions" to it, so the "/v1" has to be part of it.
# Accept both forms in config rather than making it a footgun.
if not base_url.endswith("/v1"):
base_url = f"{base_url}/v1"

log.info(
"llama.cpp: base_url=%s model=%s",
base_url, settings.llamacpp_model,
)
return OpenAIChatClient(
model=settings.llamacpp_model,
# llama-server without --api-key ignores the header, but the SDK
# refuses to construct without a non-empty key and would otherwise
# fall back to OPENAI_API_KEY from the environment — which would
# silently ship prompts to OpenAI instead of the local server.
api_key=settings.llamacpp_api_key or "sk-local-no-auth",
base_url=base_url,
)

if provider == "ollama":
from agent_framework_ollama import OllamaChatClient

Expand Down
Loading
Loading