From fbc071fe36982c6720cc3e778a2ec93b03bb1c7d Mon Sep 17 00:00:00 2001 From: Chengbiao Jin Date: Sun, 17 May 2026 16:24:52 -0700 Subject: [PATCH 01/18] Fix the on-prem graphrag-ui image build - Supply the build context the UI image requires so its workflow build no longer fails. Refs: GML-2093 --- .github/workflows/onprem-build-nightly.yaml | 5 +++++ .github/workflows/onprem-build-test.yaml | 5 +++++ .github/workflows/onprem-build.yaml | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/.github/workflows/onprem-build-nightly.yaml b/.github/workflows/onprem-build-nightly.yaml index 92b7c3c..fbde147 100644 --- a/.github/workflows/onprem-build-nightly.yaml +++ b/.github/workflows/onprem-build-nightly.yaml @@ -77,6 +77,11 @@ jobs: with: context: graphrag-ui/ file: ./graphrag-ui/Dockerfile + # The UI Dockerfile reads VERSION from a named ``repo`` context; + # docker/build-push-action interprets undeclared contexts as image + # refs and tries to pull docker.io/library/repo:latest otherwise. + build-contexts: | + repo=. push: true tags: | ${{ env.IMAGE }} diff --git a/.github/workflows/onprem-build-test.yaml b/.github/workflows/onprem-build-test.yaml index 40d2914..8e1f9b0 100644 --- a/.github/workflows/onprem-build-test.yaml +++ b/.github/workflows/onprem-build-test.yaml @@ -63,6 +63,11 @@ jobs: with: context: graphrag-ui/ file: ./graphrag-ui/Dockerfile + # The UI Dockerfile reads VERSION from a named ``repo`` context; + # docker/build-push-action interprets undeclared contexts as image + # refs and tries to pull docker.io/library/repo:latest otherwise. + build-contexts: | + repo=. push: true tags: | tigergraph/graphrag-ui:${{steps.get-image.outputs.IMAGE}} diff --git a/.github/workflows/onprem-build.yaml b/.github/workflows/onprem-build.yaml index 9fb26a4..4a32251 100644 --- a/.github/workflows/onprem-build.yaml +++ b/.github/workflows/onprem-build.yaml @@ -82,6 +82,11 @@ jobs: with: context: graphrag-ui/ file: ./graphrag-ui/Dockerfile + # The UI Dockerfile reads VERSION from a named ``repo`` context; + # docker/build-push-action interprets undeclared contexts as image + # refs and tries to pull docker.io/library/repo:latest otherwise. + build-contexts: | + repo=. push: true tags: | tigergraph/graphrag-ui:${{steps.get-image.outputs.IMAGE}} From a0e2fbdca8001d72752edc5283b7523fa5da3a19 Mon Sep 17 00:00:00 2001 From: Chengbiao Jin Date: Mon, 18 May 2026 22:45:36 -0700 Subject: [PATCH 02/18] Consolidate v1.4.1 patch: scoped :latest tagging + cleaner login response - Skip the :latest image tag on builds from non-main branches so backport rebuilds don't overwrite the most recently published image - Reject invalid credentials cleanly and include the signed-in username in the login response Refs: GML-2094 --- .github/workflows/onprem-build.yaml | 29 ++++++++++++++++++++++------- graphrag/app/routers/ui.py | 20 +++++++++++++++++++- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/.github/workflows/onprem-build.yaml b/.github/workflows/onprem-build.yaml index 4a32251..d28dfd4 100644 --- a/.github/workflows/onprem-build.yaml +++ b/.github/workflows/onprem-build.yaml @@ -43,7 +43,22 @@ jobs: echo "IMAGE=$IMAGE" >> $GITHUB_OUTPUT VERSION=$(cat VERSION) echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - + + # Only builds triggered from main update the :latest tag. Manual + # rebuilds of older release branches (e.g. release_1.3.1) skip + # :latest so a backport build doesn't overwrite the most recent + # published image. + - name: Decide whether to update :latest + id: latest-check + run: | + if [ "${GITHUB_REF}" = "refs/heads/main" ]; then + echo "push_latest=true" >> $GITHUB_OUTPUT + echo "Building from main — :latest will be updated." + else + echo "push_latest=false" >> $GITHUB_OUTPUT + echo "Not on main (ref=${GITHUB_REF}) — :latest will be left as-is." + fi + - name: Build and push Docker image GraphRAG uses: docker/build-push-action@v5 with: @@ -53,8 +68,8 @@ jobs: tags: | tigergraph/graphrag:${{steps.get-image.outputs.IMAGE}} tigergraph/graphrag:${{steps.get-image.outputs.VERSION}} - tigergraph/graphrag:latest - + ${{ steps.latest-check.outputs.push_latest == 'true' && 'tigergraph/graphrag:latest' || '' }} + - name: Build and push Docker image ECC uses: docker/build-push-action@v5 with: @@ -64,7 +79,7 @@ jobs: tags: | tigergraph/graphrag-ecc:${{steps.get-image.outputs.IMAGE}} tigergraph/graphrag-ecc:${{steps.get-image.outputs.VERSION}} - tigergraph/graphrag-ecc:latest + ${{ steps.latest-check.outputs.push_latest == 'true' && 'tigergraph/graphrag-ecc:latest' || '' }} - name: Build and push Docker image chat-history uses: docker/build-push-action@v5 @@ -75,8 +90,8 @@ jobs: tags: | tigergraph/chat-history:${{steps.get-image.outputs.IMAGE}} tigergraph/chat-history:${{steps.get-image.outputs.VERSION}} - tigergraph/chat-history:latest - + ${{ steps.latest-check.outputs.push_latest == 'true' && 'tigergraph/chat-history:latest' || '' }} + - name: Build and push Docker image graphrag-ui uses: docker/build-push-action@v5 with: @@ -91,7 +106,7 @@ jobs: tags: | tigergraph/graphrag-ui:${{steps.get-image.outputs.IMAGE}} tigergraph/graphrag-ui:${{steps.get-image.outputs.VERSION}} - tigergraph/graphrag-ui:latest + ${{ steps.latest-check.outputs.push_latest == 'true' && 'tigergraph/graphrag-ui:latest' || '' }} # - name: Set SSH key # run: | diff --git a/graphrag/app/routers/ui.py b/graphrag/app/routers/ui.py index ff27e57..ebf32ae 100644 --- a/graphrag/app/routers/ui.py +++ b/graphrag/app/routers/ui.py @@ -49,6 +49,7 @@ from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security.http import HTTPBase from pyTigerGraph import TigerGraphConnection +from pyTigerGraph.common.exception import TigerGraphException from tools.validation_utils import MapQuestionToSchemaException from common.config import db_config, graphrag_config, embedding_service, llm_config, service_status, get_chat_config, get_completion_config, get_embedding_config, get_multimodal_config, validate_graphname, get_llm_service, resolve_llm_services @@ -357,6 +358,18 @@ def auth(usr: str, password: str, conn=None) -> tuple[list[str], TigerGraphConne status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", ) + except TigerGraphException as e: + # pyTigerGraph wraps auth rejections as a TigerGraphException + # ("Authentication failed.", ...) rather than HTTPError. Convert + # that class explicitly so the client sees a clean 401, not a + # generic 500. + msg = (str(e.args[0]) if e.args else str(e)).lower() + if "authentic" in msg or "token" in msg or "password" in msg: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication failed", + ) + raise except Exception as e: raise e return graphs, conn @@ -392,7 +405,12 @@ def login(auth: Annotated[list[str], Depends(ui_basic_auth)]): except Exception as e: logger.warning(f"Failed to fetch roles at login: {e}") global_roles, graph_roles = [], {} - return {"graphs": graphs, "roles": global_roles, "graph_roles": graph_roles} + return { + "graphs": graphs, + "roles": global_roles, + "graph_roles": graph_roles, + "username": creds.username, + } def _read_local_version(component: str) -> dict: From cc2adf58ec7b6d1350e76baa2d070ec20ea6a36f Mon Sep 17 00:00:00 2001 From: Chengbiao Jin Date: Thu, 21 May 2026 16:10:57 -0700 Subject: [PATCH 03/18] Add token login option with API token / Secret choice - Login keeps Username / Password as the default and adds a "Use token login" option offering API Token or Secret. - API tokens authenticate as bearer tokens; username/password and Secret use basic auth. The backend accepts both schemes everywhere. - The signed-in username and roles are resolved from TigerGraph after login, so the UI shows the real user for chat history, traces, and conversation ownership. - Knowledge-graph rebuilds run under the signed-in user's credentials, including token logins. - Connections always authenticate as the caller; a configured static token no longer overrides the per-user identity, and token acquisition is automatic. - Login inputs no longer clip underscores on Chrome / macOS. Refs: GML-2094 --- CHANGELOG.md | 12 + README.md | 6 +- common/config.py | 5 +- common/db/connections.py | 109 +---- docs/tutorials/configs/server_config.json | 1 - .../configs/server_config.json.gemini | 1 - .../configs/server_config.json.openai | 1 - graphrag-ui/src/actions/ActionProvider.tsx | 2 +- .../src/components/CustomChatMessage.tsx | 8 +- graphrag-ui/src/components/Interact.tsx | 4 +- graphrag-ui/src/components/Login.tsx | 285 +++++++----- graphrag-ui/src/components/SideMenu.tsx | 8 +- graphrag-ui/src/hooks/useIdleTimeout.ts | 4 +- graphrag-ui/src/hooks/useRoles.ts | 4 +- graphrag-ui/src/main.tsx | 2 +- graphrag-ui/src/pages/Setup.tsx | 76 ++-- graphrag-ui/src/pages/TraceLogs.tsx | 4 +- .../src/pages/setup/CustomizePrompts.tsx | 8 +- graphrag-ui/src/pages/setup/GraphDBConfig.tsx | 14 +- .../src/pages/setup/GraphRAGConfig.tsx | 8 +- graphrag-ui/src/pages/setup/IngestGraph.tsx | 62 +-- graphrag-ui/src/pages/setup/KGAdmin.tsx | 42 +- graphrag-ui/src/pages/setup/LLMConfig.tsx | 14 +- graphrag-ui/src/pages/setup/SetupLayout.tsx | 4 +- graphrag/app/routers/ui.py | 421 +++++++++++++----- 25 files changed, 642 insertions(+), 463 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4c1d94..0a13c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.4.1] + +### Added +- **Token login** — the sign-in page adds a "Use token login" option with a choice of API Token or Secret, alongside the default username / password. The signed-in username and roles are resolved from TigerGraph after login so the UI shows the real user. + +### Changed +- **Every request authenticates as the signed-in user**, end to end — graph operations, chat history, traces, and knowledge-graph rebuilds all run under the caller's identity (username / password, secret, or API token). +- **TigerGraph token handling is automatic** — an api token is obtained from the caller's credentials only when the database requires one, unless a static api token is configured. The `getToken` config option is no longer needed and is now ignored. + +### Removed +- **A configured static `apiToken` no longer overrides per-user credentials.** It is used only for the service's background operations; interactive requests always authenticate as the signed-in user. + ## [1.4.0] ### Added diff --git a/README.md b/README.md index ce4f10d..01e44be 100644 --- a/README.md +++ b/README.md @@ -423,7 +423,7 @@ For examples of how to ingest documents through the backend API, refer to the ** ## More Detailed Configurations ### DB configuration -Copy the below into `configs/server_config.json` and edit the `hostname` and `getToken` fields to match your database's configuration. If token authentication is enabled in TigerGraph, set `getToken` to `true`. Set the timeout, memory threshold, and thread limit parameters as desired to control how much of the database's resources are consumed when answering a question. +Copy the below into `configs/server_config.json` and edit the `hostname` to match your database's configuration. Token authentication is handled automatically — an api token is obtained from the username/password when the database requires one, unless a static api token is configured. Set the timeout, memory threshold, and thread limit parameters as desired to control how much of the database's resources are consumed when answering a question. ```json { @@ -433,7 +433,6 @@ Copy the below into `configs/server_config.json` and edit the `hostname` and `ge "gsPort": "14240", "username": "tigergraph", "password": "tigergraph", - "getToken": false, "default_timeout": 300, "default_mem_threshold": 5000, "default_thread_limit": 8 @@ -448,9 +447,8 @@ Copy the below into `configs/server_config.json` and edit the `hostname` and `ge | `gsPort` | string | `"14240"` | GSQL port for TigerGraph admin operations. | | `username` | string | `"tigergraph"` | TigerGraph database username. | | `password` | string | `"tigergraph"` | TigerGraph database password. | -| `getToken` | bool | `false` | Set to `true` if token authentication is enabled on TigerGraph. | | `graphname` | string | `""` | Default graph name. Usually left empty (selected at runtime). | -| `apiToken` | string | `""` | Pre-generated API token. If set, token-based auth is used instead of username/password. | +| `apiToken` | string | `""` | Optional pre-generated token for the service's background operations. Interactive requests always authenticate as the signed-in user. | | `default_timeout` | int | `300` | Default query timeout in seconds. | | `default_mem_threshold` | int | `5000` | Memory threshold (MB) for query execution. | | `default_thread_limit` | int | `8` | Max threads for query execution. | diff --git a/common/config.py b/common/config.py index dd59ad1..a18fe4d 100644 --- a/common/config.py +++ b/common/config.py @@ -532,6 +532,9 @@ def _build_embedding_store(graphname: str = "") -> TigerGraphEmbeddingStore: ``embedding_service`` for the model) so the result reflects the current config. """ + # A static apiToken stays a service-side credential here; otherwise + # pyTigerGraph mints a REST++ token from the service username/password + # on demand, so no explicit getToken() is needed. conn = TigerGraphConnection( host=db_config.get("hostname", "http://tigergraph"), username=db_config.get("username", "tigergraph"), @@ -541,8 +544,6 @@ def _build_embedding_store(graphname: str = "") -> TigerGraphEmbeddingStore: graphname=graphname or db_config.get("graphname", ""), apiToken=db_config.get("apiToken", ""), ) - if not db_config.get("apiToken") and db_config.get("getToken"): - conn.getToken() store = TigerGraphEmbeddingStore( conn, diff --git a/common/db/connections.py b/common/db/connections.py index fab87c3..8b0840c 100644 --- a/common/db/connections.py +++ b/common/db/connections.py @@ -120,94 +120,31 @@ def get_db_connection_pwd_manual( return conn def elevate_db_connection_to_token(host, username, password, graphname, async_conn: bool = False) -> TigerGraphConnectionProxy: - # If a pre-existing apiToken is provided in config, use it directly - # and skip the getToken() call to avoid conflicts. - static_token = db_config.get("apiToken", "") - - if static_token: - LogWriter.info("Using pre-configured apiToken from db_config") - if async_conn: - conn = AsyncTigerGraphConnection( - host=host, - username=username, - password=password, - graphname=graphname, - apiToken=static_token, - restppPort=db_config.get("restppPort", "9000"), - gsPort=db_config.get("gsPort", "14240"), - ) - else: - conn = TigerGraphConnection( - host=host, - username=username, - password=password, - graphname=graphname, - apiToken=static_token, - restppPort=db_config.get("restppPort", "9000"), - gsPort=db_config.get("gsPort", "14240"), - ) - return conn - - conn = TigerGraphConnection( - host=host, - username=username, - password=password, - graphname=graphname, - restppPort=db_config.get("restppPort", "9000"), - gsPort=db_config.get("gsPort", "14240") - ) - - if db_config.get("getToken"): - try: - apiToken = conn.getToken()[0] - except HTTPError: - LogWriter.error("Failed to get token") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", - headers={"WWW-Authenticate": "Basic"}, - ) - except TigerGraphException as e: - LogWriter.error(f"Failed to get token: {e}") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Failed to get token - is the database running?" - ) + # pyTigerGraph determines on its own whether a REST++ token is needed + # and mints one from the username/password when so; we just build the + # connection with the caller's credentials. + if async_conn: + conn = AsyncTigerGraphConnection( + host=host, + username=username, + password=password, + graphname=graphname, + restppPort=db_config.get("restppPort", "9000"), + gsPort=db_config.get("gsPort", "14240") + ) - if async_conn: - conn = AsyncTigerGraphConnection( - host=host, - username=username, - password=password, - graphname=graphname, - apiToken=apiToken, - restppPort=db_config.get("restppPort", "9000"), - gsPort=db_config.get("gsPort", "14240") - ) - else: - conn = TigerGraphConnection( - host=db_config["hostname"], - username=username, - password=password, - graphname=graphname, - apiToken=apiToken, - restppPort=db_config.get("restppPort", "9000"), - gsPort=db_config.get("gsPort", "14240") - ) + # temp fix for path + if conn.restppPort == conn.gsPort and "/restpp" not in conn.restppUrl: + conn.restppUrl = conn.restppUrl+"/restpp" else: - if async_conn: - conn = AsyncTigerGraphConnection( - host=host, - username=username, - password=password, - graphname=graphname, - restppPort=db_config.get("restppPort", "9000"), - gsPort=db_config.get("gsPort", "14240") - ) - - # temp fix for path - if conn.restppPort == conn.gsPort and "/restpp" not in conn.restppUrl: - conn.restppUrl = conn.restppUrl+"/restpp" + conn = TigerGraphConnection( + host=host, + username=username, + password=password, + graphname=graphname, + restppPort=db_config.get("restppPort", "9000"), + gsPort=db_config.get("gsPort", "14240") + ) return conn diff --git a/docs/tutorials/configs/server_config.json b/docs/tutorials/configs/server_config.json index da46e28..2ee25f4 100644 --- a/docs/tutorials/configs/server_config.json +++ b/docs/tutorials/configs/server_config.json @@ -3,7 +3,6 @@ "hostname": "http://tigergraph", "restppPort": "14240", "gsPort": "14240", - "getToken": false, "default_timeout": 300, "default_mem_threshold": 5000, "default_thread_limit": 8 diff --git a/docs/tutorials/configs/server_config.json.gemini b/docs/tutorials/configs/server_config.json.gemini index 1e8740e..7a2da90 100644 --- a/docs/tutorials/configs/server_config.json.gemini +++ b/docs/tutorials/configs/server_config.json.gemini @@ -3,7 +3,6 @@ "hostname": "http://tigergraph", "restppPort": "14240", "gsPort": "14240", - "getToken": false, "default_timeout": 300, "default_mem_threshold": 5000, "default_thread_limit": 8 diff --git a/docs/tutorials/configs/server_config.json.openai b/docs/tutorials/configs/server_config.json.openai index da46e28..2ee25f4 100644 --- a/docs/tutorials/configs/server_config.json.openai +++ b/docs/tutorials/configs/server_config.json.openai @@ -3,7 +3,6 @@ "hostname": "http://tigergraph", "restppPort": "14240", "gsPort": "14240", - "getToken": false, "default_timeout": 300, "default_mem_threshold": 5000, "default_thread_limit": 8 diff --git a/graphrag-ui/src/actions/ActionProvider.tsx b/graphrag-ui/src/actions/ActionProvider.tsx index c73c182..22fee0b 100644 --- a/graphrag-ui/src/actions/ActionProvider.tsx +++ b/graphrag-ui/src/actions/ActionProvider.tsx @@ -89,7 +89,7 @@ const ActionProvider: React.FC = ({ const { sendMessage, lastMessage, readyState } = useWebSocket(WS_URL, { onOpen: () => { // Send authentication credentials - const creds = sessionStorage.getItem("creds"); + const creds = sessionStorage.getItem("auth"); console.log("Sending credentials, length:", creds ? creds.length : 0); queryGraphragWs2(creds!); diff --git a/graphrag-ui/src/components/CustomChatMessage.tsx b/graphrag-ui/src/components/CustomChatMessage.tsx index 43937ef..4043c24 100755 --- a/graphrag-ui/src/components/CustomChatMessage.tsx +++ b/graphrag-ui/src/components/CustomChatMessage.tsx @@ -105,7 +105,7 @@ const AuthenticatedImage: FC<{ src: string; alt: string }> = ({ src, alt }) => { const fetchImage = async () => { try { // Get credentials from sessionStorage (same pattern as Interact.tsx and SideMenu.tsx) - const creds = sessionStorage.getItem("creds"); + const creds = sessionStorage.getItem("auth"); if (!creds) { console.error("No credentials found in sessionStorage"); setError(true); @@ -119,7 +119,7 @@ const AuthenticatedImage: FC<{ src: string; alt: string }> = ({ src, alt }) => { // Fetch image with authentication header const response = await fetch(src, { headers: { - Authorization: `Basic ${creds}`, + Authorization: creds!, }, credentials: 'include', // Include credentials in CORS requests }); @@ -259,7 +259,7 @@ export const CustomChatMessage: FC = ({ // HTTPBasic returns 401 + ``WWW-Authenticate: Basic`` and // the browser pops up its native auth dialog. Better to // tell the user to sign in again than to flash that popup. - const creds = sessionStorage.getItem("creds"); + const creds = sessionStorage.getItem("auth"); if (!creds) { await alert("Your session has expired. Please log in again."); return; @@ -270,7 +270,7 @@ export const CustomChatMessage: FC = ({ try { const probe = await fetch(`/ui/trace/${messageId}`, { method: "GET", - headers: { Authorization: `Basic ${creds}` }, + headers: { Authorization: creds! }, }); if (!probe.ok) { await alert("Trace log not found."); diff --git a/graphrag-ui/src/components/Interact.tsx b/graphrag-ui/src/components/Interact.tsx index ae93539..f5fff48 100644 --- a/graphrag-ui/src/components/Interact.tsx +++ b/graphrag-ui/src/components/Interact.tsx @@ -39,14 +39,14 @@ export const Interactions: FC = ({ const canViewTrace = isSuperuser || isGlobalDesigner || isGraphAdmin; const sendFeedback = async (action: Feedback, message: Message) => { - const creds = sessionStorage.getItem("creds"); + const creds = sessionStorage.getItem("auth"); setFeedback(action); message.feedback = action; await fetch(`${GRAPHRAG_URL}/ui/feedback`, { method: "POST", body: JSON.stringify(message), headers: { - Authorization: `Basic ${creds}`, + Authorization: creds!, "Content-Type": "application/json", }, }); diff --git a/graphrag-ui/src/components/Login.tsx b/graphrag-ui/src/components/Login.tsx index d753124..170c712 100644 --- a/graphrag-ui/src/components/Login.tsx +++ b/graphrag-ui/src/components/Login.tsx @@ -1,100 +1,116 @@ "use client"; -import { useContext, createContext, useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - import { Button } from "@/components/ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormDescription, - FormMessage, -} from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { LANGUAGES } from "../constants"; -const formSchema = z.object({ - email: z.string().min(2, { - message: "Username must be at least 2 characters.", - }), - password: z.string().min(2, { - message: "Password must be at least 2 characters.", - }), -}); - +// TigerGraph's native sentinel for secret-based auth. pyTigerGraph +// handles this directly when sent as plain username/password. +const SECRET_USERNAME = "__GSQL__secret"; const WS_URL = "/ui/ui-login"; +type TokenType = "apiToken" | "secret"; + +// Style applied to every credential input so Chrome on macOS doesn't +// clip descenders / underscores when rendering long values. +const INPUT_CLIP_FIX: React.CSSProperties = { + WebkitAppearance: "none", + appearance: "none", + lineHeight: "1.5", +}; + +const INPUT_STYLE = "dark:border-[#3D3D3D] h-14 py-3 dark:bg-shadeA"; + export function Login() { const { i18n, t } = useTranslation(); - const [user, setUser] = useState(""); - const [token, setToken] = useState(sessionStorage.getItem("site") || ""); + const [useTokenLogin, setUseTokenLogin] = useState(false); + const [tokenType, setTokenType] = useState("apiToken"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [tokenValue, setTokenValue] = useState(""); const [hint, setHint] = useState(""); + const [submitting, setSubmitting] = useState(false); const navigate = useNavigate(); useEffect(() => { - const parseStore = JSON.parse(sessionStorage.getItem("site") || "{}"); - setToken(parseStore); - }, []); - - const loginAction = async (data: z.infer) => { - const creds = btoa(`${data.email}:${data.password}`); - const username = data.email; + setHint(""); + }, [useTokenLogin, tokenType, email, password, tokenValue]); + + const loginAction = async (e: React.FormEvent) => { + e.preventDefault(); + + // Build the full Authorization header value. API tokens use + // ``Bearer ``; classic user/pass and TG ``__GSQL__secret`` + // logins use ``Basic ``. Backend resolves the real TG + // identity from SHOW USER and returns it in the response. + let authHeader: string; + let typedUsername = ""; + if (useTokenLogin) { + const value = tokenValue.trim(); + if (value.length < 8) { + setHint( + tokenType === "apiToken" + ? "Please enter an API token." + : "Please enter a secret." + ); + return; + } + if (tokenType === "apiToken") { + authHeader = `Bearer ${value}`; + } else { + authHeader = `Basic ${btoa(`${SECRET_USERNAME}:${value}`)}`; + } + } else { + const username = email.trim(); + if (username.length < 2 || password.length < 2) { + setHint("Please enter your username and password."); + return; + } + authHeader = `Basic ${btoa(`${username}:${password}`)}`; + typedUsername = username; + } + setSubmitting(true); try { - const res = await fetch("/ui/ui-login", { + const res = await fetch(WS_URL, { method: "POST", - headers: { - Authorization: `Basic ${creds}`, - }, + headers: { Authorization: authHeader }, }); if (res.ok) { const data = await res.json(); - sessionStorage.setItem("creds", creds); + sessionStorage.setItem("auth", authHeader); sessionStorage.setItem("site", JSON.stringify(data)); - setUser(username); - sessionStorage.setItem("username", username); + // Server-resolved username works in every mode; fall back to + // the typed value for classic logins on older backends. + const resolved = + (typeof data.username === "string" && data.username) || + typedUsername; + if (resolved) sessionStorage.setItem("username", resolved); + else sessionStorage.removeItem("username"); navigate("/chat"); } else if (res.status === 401 || res.status === 403) { - setHint("Invalid credentials"); - navigate("/"); + setHint( + useTokenLogin ? "Invalid or unauthorized token." : "Invalid credentials." + ); } else { setHint(`Server error (${res.status}). Please try again later.`); - navigate("/"); } } catch { setHint("Unable to connect to the server. Please try again later."); - navigate("/"); + } finally { + setSubmitting(false); } }; - const logOut = () => { - setUser(""); - setToken(""); - sessionStorage.removeItem("site"); - navigate("/"); - }; - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - email: "", - password: "", - }, - }); - const onChangeLang = (e: React.ChangeEvent) => { - const lang_code = e.target.value; - i18n.changeLanguage(lang_code); + i18n.changeLanguage(e.target.value); }; return ( @@ -106,70 +122,101 @@ export function Login() {
TigerGraph GraphRAG -

+

{t("login")}

-
- - ( - <> - - - - - - - - - )} - /> - ( - <> - - - - - - - - - )} + + {useTokenLogin ? ( + <> +
+ + +
+
+ setTokenValue(e.target.value)} + autoComplete="off" + className={INPUT_STYLE} + style={INPUT_CLIP_FIX} + /> +
+ + ) : ( + <> +
+ setEmail(e.target.value)} + autoComplete="username" + className={INPUT_STYLE} + style={INPUT_CLIP_FIX} + /> +
+
+ setPassword(e.target.value)} + autoComplete="current-password" + className={INPUT_STYLE} + style={INPUT_CLIP_FIX} + /> +
+ + )} + +
+ { + setUseTokenLogin(e.target.checked); + setTokenValue(""); + setPassword(""); + }} /> - {/* - {t("forgotPassword")} - */} - - - {
-
- - {hint} - -
} - - {/* - {t("signUp")} - */} - - + +
+ + + +
+
+ + {hint} + +
+ setExtractImages(e.target.checked)} + /> + + +

+ Sends each extracted image to the multimodal LLM for alt-text. Disable to skip image content entirely. +

+ + +
+ + setMinImageDimPx(e.target.value)} + disabled={!extractImages} + /> +

+ Smallest side (in px) an image must have to be described. +

+
)} From 3f9fa40bf3a963f5db7d417f86701e316129a547 Mon Sep 17 00:00:00 2001 From: Chengbiao Jin Date: Thu, 4 Jun 2026 09:58:29 -0700 Subject: [PATCH 12/18] Fall back to hybrid search when community search misses or fails - Auto-selected community search now retries once with hybrid search when it returns no community summaries or the retriever raises, instead of short-circuiting to "couldn't find" - Manual community search remains unchanged so user picks are not second-guessed Refs: GML-2098 --- CHANGELOG.md | 1 + graphrag/app/agent/agent_graph.py | 64 +++++++++++++++++++------- graphrag/app/agent/method_selector.py | 46 +++++++++--------- graphrag/tests/test_method_selector.py | 19 ++++---- 4 files changed, 83 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a42d941..a50cf0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - **Chat stays available when vector search is unavailable.** The chat WebSocket no longer closes hard with 1013 on vector-store failures. Instead it accepts the connection, surfaces a notice to the client, and lets graph-traversal questions answer normally — only questions that genuinely require a vector lookup fail, and they fail gracefully through the synthesizer. - **PDF ingestion is faster on image-heavy documents.** Image-description workers now run with a larger parallel pool, and tiny decorative images skip the multimodal LLM entirely. On AWS Bedrock deployments the connection pool default is also raised so concurrent describe calls no longer queue behind a 20-connection cap. - **Image description is tunable per graph or globally.** Two new `graphrag_config` keys — `extract_images` and `min_image_dim_px` — control whether the multimodal LLM is invoked on extracted images and the smallest image dimension that goes to the LLM (smaller images skip the call). Both are editable from the *GraphRAG Configuration* page in the UI, globally or per graph. Disabling does not alter the Image vertex type or loading job, so re-enabling later requires no schema change. The multimodal describe pass now reuses `default_concurrency` instead of a separate knob, so one setting tunes parallelism across the pipeline. +- **Community search falls back to hybrid search when it returns nothing or fails.** Auto-selected community queries that miss (no relevant community summaries) or hit a retriever error are now retried once with hybrid graph-hop search before returning a "couldn't find" answer. Manually-picked community search is unchanged. ### Removed - **A configured static `apiToken` no longer overrides per-user credentials.** It is used only for the service's background operations; interactive requests always authenticate as the signed-in user. diff --git a/graphrag/app/agent/agent_graph.py b/graphrag/app/agent/agent_graph.py index 921fa8e..1c2925e 100644 --- a/graphrag/app/agent/agent_graph.py +++ b/graphrag/app/agent/agent_graph.py @@ -561,11 +561,12 @@ def supportai_search(self, state): auto-selection regardless of configuration — manual users still get the best vector method when the structured-data path has exhausted its retries. - 2. **In-lane fallback.** After the first chunk-based retriever runs, if - it returned fewer than `top_k` chunks (signal: insufficient context), - runs a second method per `INLANE_FALLBACK_TABLE` and uses its context - for downstream generation. Single retry only; skipped for manual - mode and community search. + 2. **In-lane fallback.** After the first retriever runs, if it returned + insufficient context or raised an exception, runs a second method + per `INLANE_FALLBACK_TABLE` and uses its context for downstream + generation. Single retry only; skipped for manual mode. Insufficient + is defined per method: chunk-based methods need at least `top_k` + chunks; community needs at least one community summary. 3. **Out-of-corpus short-circuit.** If after all retrieval attempts the result is still empty, marks the context so `generate_answer` returns an honest "couldn't find" message instead of letting the @@ -604,37 +605,68 @@ def supportai_search(self, state): state["chosen_retriever_source"] = chosen_source self._record_selection_metric(method, chosen_source) - # First retrieval attempt - result_state = self._dispatch_retriever(method, state) + # First retrieval attempt. Catch exceptions so a transient retriever + # failure becomes an in-lane fallback rather than a 500 — the user + # gets the same outcome as "returned empty," and any method with an + # entry in INLANE_FALLBACK_TABLE is eligible. + retriever_error: Optional[Exception] = None + try: + result_state = self._dispatch_retriever(method, state) + except Exception as exc: # noqa: BLE001 + retriever_error = exc + logger.warning( + f"Retriever {method} raised: {exc}; " + "treating as empty result for fallback consideration" + ) + result_state = dict(state) + result_state["context"] = { + "function_call": None, + "result": {"final_retrieval": {}}, + } + result_state["lookup_source"] = "supportai" - # In-lane fallback (Feature 2) — chunk-based methods only, single retry, - # skipped for manual users so we don't second-guess their pick. + # In-lane fallback — a single retry through INLANE_FALLBACK_TABLE. + # Skipped for manual users so we don't second-guess their pick. ctx = result_state.get("context") if isinstance(result_state.get("context"), dict) else {} result = ctx.get("result") if isinstance(ctx.get("result"), dict) else {} final_retrieval = result.get("final_retrieval") if isinstance(result, dict) else None top_k = self._graphrag_cfg.get("top_k", 5) can_inlane_fallback = ( chosen_source != "manual" - and method in CHUNK_BASED_METHODS + and method in INLANE_FALLBACK_TABLE and not result_state.get("inlane_fallback_attempted") - and has_insufficient_context(final_retrieval, method, top_k) + and (retriever_error is not None + or has_insufficient_context(final_retrieval, method, top_k)) ) if can_inlane_fallback: fallback_method = INLANE_FALLBACK_TABLE.get(method) if fallback_method: label_old = self._METHOD_DISPLAY_NAMES.get(method, method) label_new = self._METHOD_DISPLAY_NAMES.get(fallback_method, fallback_method) - self.emit_progress( - f"Insufficient context from {label_old} search, trying {label_new} search" - ) + if retriever_error is not None: + fallback_reason = f"fallback from {label_old} (search failed)" + progress = ( + f"{label_old} search did not complete, trying {label_new} search" + ) + else: + fallback_reason = ( + f"fallback from {label_old} (returned insufficient context)" + ) + progress = ( + f"Insufficient context from {label_old} search, trying {label_new} search" + ) + self.emit_progress(progress) result_state["inlane_fallback_attempted"] = True result_state["inlane_fallback_from"] = method - # Update the active method/source for the second pass. method = fallback_method chosen_source = "inlane_fallback" - chosen_reason = f"fallback from {label_old} (returned fewer than top_k chunks)" + chosen_reason = fallback_reason self._record_selection_metric(method, chosen_source) result_state = self._dispatch_retriever(method, result_state) + elif retriever_error is not None: + # No fallback path available — re-raise so the caller sees the + # original failure instead of an empty-result short-circuit. + raise retriever_error # Mirror the (final) choice onto the context dict so it lands on # GraphRAGResponse.query_sources without further plumbing. diff --git a/graphrag/app/agent/method_selector.py b/graphrag/app/agent/method_selector.py index 15d46ad..724c735 100644 --- a/graphrag/app/agent/method_selector.py +++ b/graphrag/app/agent/method_selector.py @@ -55,27 +55,26 @@ FALLBACK_METHOD = METHOD_HYBRID -# In-lane fallback table: when a chunk-based method returns insufficient context, -# try this method instead. Subset-aware — never falls back to a method whose -# results are a strict subset of the failing method's seeds (e.g., similarity is -# a subset of contextual/hybrid, so we don't fall back to it from those). +# In-lane fallback table: when a retriever returns insufficient context (or +# raises), try this method instead. Subset-aware — never falls back to a method +# whose results are a strict subset of the failing method's seeds (e.g., +# similarity is a subset of contextual/hybrid, so we don't fall back to it from +# those). # -# The table fires once per question. Community is the terminal step from hybrid -# because its retrieval surface (community summaries) is fundamentally different -# from chunk retrieval — when chunk-based search finds little, thematic -# summaries may still cover the question. +# The table fires once per question. Community falls back to hybrid so that +# thematic questions that miss (no relevant community summaries) still get a +# chance at the entity-driven graph-hop retriever before short-circuiting to +# "couldn't find." INLANE_FALLBACK_TABLE = { METHOD_SIMILARITY: METHOD_HYBRID, # point lookup → graph-hop expansion METHOD_CONTEXTUAL: METHOD_HYBRID, # sibling expansion thin → try graph hops METHOD_HYBRID: METHOD_COMMUNITY, # entity-driven thin → try thematic summaries - # No fallback FROM community — its top-k semantics differ; the in-lane - # trigger doesn't apply, and falling back to a chunk method when community - # missed is a different problem (handled by router_fallback / out-of-corpus). + METHOD_COMMUNITY: METHOD_HYBRID, # no relevant communities → try entity-driven graph hops } def has_insufficient_context(retrieval_dict, method: str, top_k: int) -> bool: - """Decide whether a chunk-based retriever returned fewer items than asked. + """Decide whether a retriever returned too little context to answer from. Args: retrieval_dict: the `final_retrieval` dict from the retriever output, or None. @@ -83,19 +82,22 @@ def has_insufficient_context(retrieval_dict, method: str, top_k: int) -> bool: top_k: the requested number of chunks for this retrieval. Returns: - True if the result is "insufficient" — i.e., the method is chunk-based and - the retrieved count is strictly below `top_k`. Empty results count as - insufficient. Returns False for community search (different semantics) and - for any non-dict input. - - Note: this is the trigger for the in-lane fallback in supportai_search. - Community search is excluded because its top_k caps community summaries, not - chunks, and a small number of returned summaries doesn't mean "no context." + True if the result is "insufficient": + * For chunk-based methods: fewer than `top_k` chunks returned. + * For community search: zero communities returned (top_k caps + community summaries, not chunks, so any non-empty result counts + as having some context). + * For any unknown method or non-dict input: True if missing / malformed, + otherwise False. + + This is the trigger for the in-lane fallback in supportai_search. """ - if method not in CHUNK_BASED_METHODS: - return False if not isinstance(retrieval_dict, dict): return True # empty / malformed → insufficient + if method == METHOD_COMMUNITY: + return len(retrieval_dict) == 0 + if method not in CHUNK_BASED_METHODS: + return False return len(retrieval_dict) < top_k diff --git a/graphrag/tests/test_method_selector.py b/graphrag/tests/test_method_selector.py index 6f6f975..bdcd238 100644 --- a/graphrag/tests/test_method_selector.py +++ b/graphrag/tests/test_method_selector.py @@ -329,10 +329,10 @@ def test_hybrid_falls_back_to_community(self): # Different retrieval surface (community summaries vs chunks). self.assertEqual(INLANE_FALLBACK_TABLE[METHOD_HYBRID], METHOD_COMMUNITY) - def test_community_has_no_fallback(self): - # Community's top-k semantics differ; the in-lane trigger doesn't fire - # for it, so a fallback entry would be unused. - self.assertNotIn(METHOD_COMMUNITY, INLANE_FALLBACK_TABLE) + def test_community_falls_back_to_hybrid(self): + # When community search misses (no relevant summaries) or fails, try + # the entity-driven graph-hop retriever before giving up. + self.assertEqual(INLANE_FALLBACK_TABLE[METHOD_COMMUNITY], METHOD_HYBRID) def test_no_self_fallback(self): # A method should never fall back to itself. @@ -365,11 +365,12 @@ def test_above_top_k_is_sufficient(self): above = {f"chunk{i}": "text" for i in range(7)} self.assertFalse(has_insufficient_context(above, METHOD_HYBRID, top_k=5)) - def test_community_always_returns_false(self): - """Community has different top_k semantics (community summaries, not - chunks). It should never trigger the insufficient-context path.""" - self.assertFalse(has_insufficient_context({}, METHOD_COMMUNITY, top_k=5)) - self.assertFalse(has_insufficient_context(None, METHOD_COMMUNITY, top_k=5)) + def test_empty_community_is_insufficient(self): + """Community returning zero summaries is treated as insufficient so the + in-lane fallback can fire. A non-empty result is sufficient regardless + of the top_k cap (community top_k counts summaries, not chunks).""" + self.assertTrue(has_insufficient_context({}, METHOD_COMMUNITY, top_k=5)) + self.assertTrue(has_insufficient_context(None, METHOD_COMMUNITY, top_k=5)) partial = {f"comm{i}": "summary" for i in range(2)} self.assertFalse(has_insufficient_context(partial, METHOD_COMMUNITY, top_k=5)) From 628298013df77687f10bfccdd93d2f8c18618214 Mon Sep 17 00:00:00 2001 From: Chengbiao Jin Date: Thu, 4 Jun 2026 10:12:16 -0700 Subject: [PATCH 13/18] Harden v1.4.1 paths surfaced by code review - Ensure the decorative-image marker stays in English even when document descriptions are localized, so small/decorative images stay out of the JSONL - Release the image file handle when an image is skipped, preventing handle build-up on image-heavy PDFs - Serialize embedding-store rebuilds across the background retry loop, the manual retry endpoint, and the db-config reload so concurrent calls cannot stomp shared state - Send the vector-search-unavailable notice only to authenticated chat clients, and drop the raw error string from it - Wrap the API-token TigerGraph connection the same way the password path does so version checks work for token logins - Block the GraphRAG Configuration save button when the page failed to load the current config, so a stale reference cannot strip valid overrides - Cancel in-flight GraphRAG Configuration fetches when the scope or graph changes so the latest selection always wins - Prevent the Document Ingestion dialog from re-enabling the Ingest button between a click and the server registering the job, removing the double-submit window - Cancel pending GraphDB redirect/refresh timers when the page is left so they cannot run on an unrelated page Refs: GML-2092, GML-2094, GML-2095, GML-2097 --- common/config.py | 26 ++++++--- common/utils/image_data_extractor.py | 10 ++-- common/utils/text_extractors.py | 43 +++++++------- graphrag-ui/src/pages/setup/GraphDBConfig.tsx | 24 ++++++-- .../src/pages/setup/GraphRAGConfig.tsx | 25 +++++++- graphrag-ui/src/pages/setup/IngestGraph.tsx | 37 +++++++++++- graphrag/app/routers/ui.py | 57 +++++++++++-------- 7 files changed, 157 insertions(+), 65 deletions(-) diff --git a/common/config.py b/common/config.py index 170259e..cd51d6a 100644 --- a/common/config.py +++ b/common/config.py @@ -519,6 +519,11 @@ def get_llm_service(service_config: dict) -> LLM_Model: _embedding_store_ready = threading.Event() _embedding_stores: dict = {} _embedding_stores_lock = threading.Lock() +# Serializes default-store init across the background retry loop, the manual +# /ui/admin/retry_embedding_store endpoint, and reset_embedding_store callers +# (db-config reload). Without it two _init_embedding_store threads could run +# concurrently and stomp ``embedding_store`` + ``service_status``. +_embedding_store_init_lock = threading.Lock() service_status["embedding_store"] = { "status": "initializing", "error": "Embedding store is still initializing", @@ -560,16 +565,21 @@ def _init_embedding_store(): """Background thread target. Builds the default embedding store without blocking module import — TigerGraph may be slow on first connect, and we don't want app startup to wait on it. + + Serialized via ``_embedding_store_init_lock`` so concurrent calls + (initial startup + background retry loop + manual retry endpoint + + db-config reload) cannot stomp the shared globals. """ global embedding_store - try: - embedding_store = _build_embedding_store() - service_status["embedding_store"] = {"status": "ok", "error": None} - except Exception as e: - service_status["embedding_store"] = {"status": "error", "error": str(e)} - logger.error(f"Failed to initialize embedding store: {e}") - finally: - _embedding_store_ready.set() + with _embedding_store_init_lock: + try: + embedding_store = _build_embedding_store() + service_status["embedding_store"] = {"status": "ok", "error": None} + except Exception as e: + service_status["embedding_store"] = {"status": "error", "error": str(e)} + logger.error(f"Failed to initialize embedding store: {e}") + finally: + _embedding_store_ready.set() def get_embedding_store(graphname: str | None = None, timeout: float = 0): diff --git a/common/utils/image_data_extractor.py b/common/utils/image_data_extractor.py index 5ffd189..95b4581 100644 --- a/common/utils/image_data_extractor.py +++ b/common/utils/image_data_extractor.py @@ -125,10 +125,12 @@ def describe_image_with_llm(file_path): "has no text, infer the document's language from any " "visible labels, captions, or branding and match that. " "Default to English only if no language signal is " - "present. If the image is purely decorative (no text, " - "no data, no diagram), reply with just \"decorative " - "image\" and nothing else. Respond as a SINGLE " - "plain-text paragraph — no markdown headings, no " + "present. EXCEPTION: if the image is purely decorative " + "(no text, no data, no diagram), reply with exactly the " + "English phrase \"decorative image\" (lowercase, no " + "punctuation, no translation) and nothing else — this " + "is a fixed sentinel, never localized. Respond as a " + "SINGLE plain-text paragraph — no markdown headings, no " "bullet lists, no blank lines. The reply is used " "verbatim as the alt-text inside `![alt](url)`." ), diff --git a/common/utils/text_extractors.py b/common/utils/text_extractors.py index cee0419..503acad 100644 --- a/common/utils/text_extractors.py +++ b/common/utils/text_extractors.py @@ -560,28 +560,27 @@ def _describe_and_encode(img_ref: dict) -> dict: """ try: img_path = Path(img_ref["path"]) - pil_image = PILImage.open(img_path) - too_small = ( - pil_image.width < min_dim or pil_image.height < min_dim - ) - if not extract_images_enabled or too_small: - return {"ok": True, "skip": True, "img_ref": img_ref} - description = describe_image_with_llm(str(img_path)) - if _is_decorative(description): - return {"ok": True, "skip": True, "img_ref": img_ref} - if pil_image.mode != "RGB": - pil_image = pil_image.convert("RGB") - buffer = io.BytesIO() - pil_image.save(buffer, format="JPEG", quality=95) - image_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") - return { - "ok": True, - "img_ref": img_ref, - "description": description, - "image_base64": image_base64, - "width": pil_image.width, - "height": pil_image.height, - } + with PILImage.open(img_path) as pil_image: + too_small = ( + pil_image.width < min_dim or pil_image.height < min_dim + ) + if not extract_images_enabled or too_small: + return {"ok": True, "skip": True, "img_ref": img_ref} + description = describe_image_with_llm(str(img_path)) + if _is_decorative(description): + return {"ok": True, "skip": True, "img_ref": img_ref} + rgb_image = pil_image if pil_image.mode == "RGB" else pil_image.convert("RGB") + buffer = io.BytesIO() + rgb_image.save(buffer, format="JPEG", quality=95) + image_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + return { + "ok": True, + "img_ref": img_ref, + "description": description, + "image_base64": image_base64, + "width": pil_image.width, + "height": pil_image.height, + } except Exception as img_error: # noqa: BLE001 — keep going return {"ok": False, "img_ref": img_ref, "error": img_error} diff --git a/graphrag-ui/src/pages/setup/GraphDBConfig.tsx b/graphrag-ui/src/pages/setup/GraphDBConfig.tsx index 66015f5..06e66af 100644 --- a/graphrag-ui/src/pages/setup/GraphDBConfig.tsx +++ b/graphrag-ui/src/pages/setup/GraphDBConfig.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Server, Save, CheckCircle2, AlertCircle, RefreshCw, Loader2 } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -74,6 +74,22 @@ const GraphDBConfig = () => { fetchStoreStatus(); }, []); + // Track every setTimeout we schedule so we can cancel them on unmount. + // Otherwise a redirect/alert/refresh timer fires after the user navigates + // away, possibly wiping auth from a page they're now on. + const timeoutsRef = useRef[]>([]); + const scheduleTimeout = (fn: () => void, ms: number) => { + const id = setTimeout(fn, ms); + timeoutsRef.current.push(id); + return id; + }; + useEffect(() => { + return () => { + timeoutsRef.current.forEach(clearTimeout); + timeoutsRef.current = []; + }; + }, []); + const handleRetryEmbeddingStore = async () => { setIsRetryingStore(true); setRetryMessage(""); @@ -262,7 +278,7 @@ const GraphDBConfig = () => { ? "GraphDB hostname changed. Please relogin with the new credentials to connect to the new instance." : "GraphDB username changed. Please relogin with the new credentials."; - setTimeout(() => { + scheduleTimeout(() => { // Clear sessionStorage and redirect to login sessionStorage.removeItem("auth"); alert(reason); @@ -277,8 +293,8 @@ const GraphDBConfig = () => { // seconds; poll a second time so the operator sees the real // outcome (ok or error) without navigating away. fetchStoreStatus(); - setTimeout(fetchStoreStatus, 3000); - setTimeout(fetchStoreStatus, 8000); + scheduleTimeout(fetchStoreStatus, 3000); + scheduleTimeout(fetchStoreStatus, 8000); } } else { setMessage(result.detail || "Failed to save configuration"); diff --git a/graphrag-ui/src/pages/setup/GraphRAGConfig.tsx b/graphrag-ui/src/pages/setup/GraphRAGConfig.tsx index aa24821..dd618f8 100644 --- a/graphrag-ui/src/pages/setup/GraphRAGConfig.tsx +++ b/graphrag-ui/src/pages/setup/GraphRAGConfig.tsx @@ -60,6 +60,7 @@ const GraphRAGConfig = () => { const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); const [message, setMessage] = useState(""); const [messageType, setMessageType] = useState<"success" | "error" | "">(""); @@ -70,11 +71,18 @@ const GraphRAGConfig = () => { // Track configs as loaded from API so we only save what's needed const loadedGlobalConfig = useRef>({}); const loadedGraphOverrides = useRef>({}); + // AbortController for in-flight fetchConfig. Toggling scope/graph rapidly + // would otherwise let the older request resolve last, leaving the UI + // showing the wrong scope's values. + const fetchAbortRef = useRef(null); useEffect(() => { const site = JSON.parse(sessionStorage.getItem("site") || "{}"); setAvailableGraphs(site.graphs || []); fetchConfig(); + return () => { + fetchAbortRef.current?.abort(); + }; }, []); @@ -120,6 +128,12 @@ const GraphRAGConfig = () => { const queryString = params.toString() ? `?${params.toString()}` : ""; const url = `/ui/config${queryString}`; + // Cancel any prior in-flight fetch; only the latest scope/graph + // selection should win. + fetchAbortRef.current?.abort(); + const ac = new AbortController(); + fetchAbortRef.current = ac; + // Transient backend failures (cold start, brief upstream timeouts via // nginx, momentary 502/503/504) are common right after a service // restart and produced the intermittent "Failed to fetch configuration" @@ -139,6 +153,7 @@ const GraphRAGConfig = () => { try { const response = await fetch(url, { headers: { Authorization: creds! }, + signal: ac.signal, }); lastStatus = response.status; if (!response.ok) { @@ -168,9 +183,13 @@ const GraphRAGConfig = () => { // Clear any prior transient error banner on success. setMessage(""); setMessageType(""); + setLoadFailed(false); setIsLoading(false); return; } catch (error: any) { + // Superseded by a newer fetch — bail silently so the loading + // banner doesn't flicker an error for a request we cancelled. + if (error?.name === "AbortError") return; lastErr = error; if (attempt < maxAttempts && shouldRetry(lastStatus, error)) { await new Promise((r) => setTimeout(r, 500 * attempt)); @@ -185,6 +204,10 @@ const GraphRAGConfig = () => { `Failed to load configuration${lastStatus ? ` (HTTP ${lastStatus})` : ""}. Please retry.` ); setMessageType("error"); + // Block Save: the loaded reference is stale/empty, so diffing against it + // can silently strip valid overrides (per-graph mode) or save defaults + // over server state (global mode). + setLoadFailed(true); setIsLoading(false); }; @@ -1038,7 +1061,7 @@ const GraphRAGConfig = () => { )} - - - setDraftProposal((p) => - p - ? { - ...p, - vertices: p.vertices.map((vv, i) => - i === vIdx ? { ...vv, name: e.target.value } : vv - ), - } - : p - ) - } - disabled={isInitializing || isExtractingSchema} - className="flex-1 h-8 text-sm dark:border-[#3D3D3D] dark:bg-shadeA" - /> +
+ + setDraftProposal((p) => + p + ? { + ...p, + vertices: p.vertices.map((vv, i) => + i === vIdx ? { ...vv, name: e.target.value } : vv + ), + } + : p + ) + } + disabled={isInitializing || isExtractingSchema} + className="flex-1 bg-transparent outline-none border-0 p-0 text-sm text-black dark:text-white placeholder:text-muted-foreground disabled:opacity-50" + style={INPUT_CLIP_FIX} + /> +
{collapsedVertices.has(vIdx) && ( {v.attributes.length} attr{v.attributes.length === 1 ? "" : "s"} @@ -1589,26 +1603,30 @@ const KGAdmin = () => { {!collapsedVertices.has(vIdx) && (<> - - setDraftProposal((p) => - p - ? { - ...p, - vertices: p.vertices.map((vv, i) => - i === vIdx - ? { ...vv, description: e.target.value } - : vv - ), - } - : p - ) - } - disabled={isInitializing || isExtractingSchema} - className="h-8 text-sm dark:border-[#3D3D3D] dark:bg-shadeA" - /> +
+ + setDraftProposal((p) => + p + ? { + ...p, + vertices: p.vertices.map((vv, i) => + i === vIdx + ? { ...vv, description: e.target.value } + : vv + ), + } + : p + ) + } + disabled={isInitializing || isExtractingSchema} + className="flex-1 bg-transparent outline-none border-0 p-0 text-sm text-black dark:text-white placeholder:text-muted-foreground disabled:opacity-50" + style={INPUT_CLIP_FIX} + /> +
Attributes ({v.attributes.length}); primary key id auto-added {attributesCollapsed && ( @@ -1823,26 +1841,30 @@ const KGAdmin = () => { > {collapsedEdges.has(eIdx) ? "▶" : "▼"} - - setDraftProposal((p) => - p - ? { - ...p, - edges: p.edges.map((ee, i) => - i === eIdx - ? { ...ee, name: ev.target.value } - : ee - ), - } - : p - ) - } - disabled={isInitializing || isExtractingSchema} - className="flex-1 h-8 text-sm dark:border-[#3D3D3D] dark:bg-shadeA" - /> +
+ + setDraftProposal((p) => + p + ? { + ...p, + edges: p.edges.map((ee, i) => + i === eIdx + ? { ...ee, name: ev.target.value } + : ee + ), + } + : p + ) + } + disabled={isInitializing || isExtractingSchema} + className="flex-1 bg-transparent outline-none border-0 p-0 text-sm text-black dark:text-white placeholder:text-muted-foreground disabled:opacity-50" + style={INPUT_CLIP_FIX} + /> +
{collapsedEdges.has(eIdx) && ( {e.pairs.length} pair{e.pairs.length === 1 ? "" : "s"}, {e.attributes.length} attr @@ -1868,26 +1890,30 @@ const KGAdmin = () => {
{!collapsedEdges.has(eIdx) && (<> - - setDraftProposal((p) => - p - ? { - ...p, - edges: p.edges.map((ee, i) => - i === eIdx - ? { ...ee, description: ev.target.value } - : ee - ), - } - : p - ) - } - disabled={isInitializing || isExtractingSchema} - className="h-8 text-sm dark:border-[#3D3D3D] dark:bg-shadeA" - /> +
+ + setDraftProposal((p) => + p + ? { + ...p, + edges: p.edges.map((ee, i) => + i === eIdx + ? { ...ee, description: ev.target.value } + : ee + ), + } + : p + ) + } + disabled={isInitializing || isExtractingSchema} + className="flex-1 bg-transparent outline-none border-0 p-0 text-sm text-black dark:text-white placeholder:text-muted-foreground disabled:opacity-50" + style={INPUT_CLIP_FIX} + /> +
Endpoints (FROM → TO):
From b6e64fe284700ab42cd9701dca92b9a7bc31a935 Mon Sep 17 00:00:00 2001 From: Chengbiao Jin Date: Fri, 5 Jun 2026 11:43:46 -0700 Subject: [PATCH 18/18] Improve answer fidelity for multilingual numeric-heavy questions - Synthesizer prompt: answer in the question's language, quote exact values verbatim, list candidates before stating a max / min / comparison conclusion - Multimodal image-describe prompt: for time-series charts, transcribe every (period, value) pair instead of summarizing the trend - README tuning guideline: document raising chunk_size to keep large statistical tables whole on table-heavy corpora --- README.md | 3 ++- common/llm_services/base_llm.py | 3 +++ common/utils/image_data_extractor.py | 40 +++++++++++++++------------- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index c368979..4e85aff 100644 --- a/README.md +++ b/README.md @@ -923,8 +923,9 @@ A bad answer at step 4 is rarely fixed by editing the response prompt; usually i | Answers miss context that's clearly in the source | chunks too small or no overlap | raise `chunk_size`; bump `overlap_size` (default 1/8 of `chunk_size`); lower `threshold` (`semantic`) | | Tables / figures get fragmented | wrong chunker for the source | use `markdown` for markdown / docs converted to markdown; use `html` for HTML pages with structure; use `regex` with a custom `pattern` for structured logs | | Cross-section reasoning fails | no overlap | increase `overlap_size` to ~25% of `chunk_size` | +| Long tables get split mid-row and the answer loses column headers | `chunk_size` (default `2048`) is smaller than the table's serialized length | raise `chunker_config.chunk_size` to fit the largest table whole — for table-heavy regulator / industry reports, **`4096`–`8192` is often the right range** | -Default starting point for prose: `chunker: "semantic"`, `threshold: 0.95`, `chunker_config.method: "percentile"`. Move to `markdown` chunker with `chunk_size: 2048` and `overlap_size: 256` if your source is markdown-heavy and table integrity matters. +Default starting point for prose: `chunker: "semantic"`, `threshold: 0.95`, `chunker_config.method: "percentile"`. Move to `markdown` chunker with `chunk_size: 2048` and `overlap_size: 256` if your source is markdown-heavy and table integrity matters. For corpora dominated by large statistical tables (regulatory reports, fiscal yearbooks, multi-year financial summaries), start with `markdown`/`html` chunker and `chunk_size: 8192` so each table stays in one chunk. ### 3. Extraction — make the graph clean before tuning retrieval diff --git a/common/llm_services/base_llm.py b/common/llm_services/base_llm.py index bf24588..e5f04dc 100644 --- a/common/llm_services/base_llm.py +++ b/common/llm_services/base_llm.py @@ -498,6 +498,9 @@ def chatbot_response_prompt(self): - **Tables**: every row, including the header, starts on a new line. - **Output as JSON** — escape characters as needed so the response is valid JSON. Include every field required by the format instructions; set unknown fields to empty. - Treat context keys as citations only when asked; otherwise do NOT include citations in the final answer. +- **Match the question's language.** Write the entire response (titles, bullet labels, prose, numeric formatting) in the same language the user asked in. Keep proper-noun terms (BSI, DeFi, GDP, etc.) in their original script. +- **Quote exact values from the source.** Numbers, units, time periods, and named entities must appear verbatim — do not round, approximate, or translate units. If the source says `10,678億円`, write `10,678億円`, not `about 10 trillion yen`. +- **For comparison or "which is the highest" questions, list each candidate's value before stating the conclusion.** Show the working — do not jump directly to a one-line answer. ## Inputs - **Question**: {question} diff --git a/common/utils/image_data_extractor.py b/common/utils/image_data_extractor.py index 95b4581..6be929c 100644 --- a/common/utils/image_data_extractor.py +++ b/common/utils/image_data_extractor.py @@ -115,24 +115,28 @@ def describe_image_with_llm(file_path): "captions, and footnotes; (2) the data and structure of " "any chart, graph, or table — name the chart type, the " "axes / columns, and the values or trend the chart " - "actually shows; (3) the entities, relationships, or " - "process steps in any diagram or flowchart; (4) any logo " - "or branding mark, identified by name. Do NOT describe " - "layout, background color, decorative styling, slide " - "templates, or generic visual impressions — those add " - "no retrieval value. Write the description in the same " - "language as the text inside the image; if the image " - "has no text, infer the document's language from any " - "visible labels, captions, or branding and match that. " - "Default to English only if no language signal is " - "present. EXCEPTION: if the image is purely decorative " - "(no text, no data, no diagram), reply with exactly the " - "English phrase \"decorative image\" (lowercase, no " - "punctuation, no translation) and nothing else — this " - "is a fixed sentinel, never localized. Respond as a " - "SINGLE plain-text paragraph — no markdown headings, no " - "bullet lists, no blank lines. The reply is used " - "verbatim as the alt-text inside `![alt](url)`." + "actually shows. For time-series charts (line / bar / " + "stacked bar with a time-period axis), TRANSCRIBE every " + "(period, value) pair you can read in the format " + "`period: value; period: value; …` — do not summarize " + "the trend in place of the values; (3) the entities, " + "relationships, or process steps in any diagram or " + "flowchart; (4) any logo or branding mark, identified by " + "name. Do NOT describe layout, background color, " + "decorative styling, slide templates, or generic visual " + "impressions — those add no retrieval value. Write the " + "description in the same language as the text inside the " + "image; if the image has no text, infer the document's " + "language from any visible labels, captions, or branding " + "and match that. Default to English only if no language " + "signal is present. EXCEPTION: if the image is purely " + "decorative (no text, no data, no diagram), reply with " + "exactly the English phrase \"decorative image\" " + "(lowercase, no punctuation, no translation) and nothing " + "else — this is a fixed sentinel, never localized. " + "Respond as a SINGLE plain-text paragraph — no markdown " + "headings, no bullet lists, no blank lines. The reply is " + "used verbatim as the alt-text inside `![alt](url)`." ), }, _build_image_content_block(image_base64, "image/jpeg"),