This is an automated email from the ASF dual-hosted git repository.

imbajin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hugegraph-ai.git


The following commit(s) were added to refs/heads/main by this push:
     new eb8af86f Add POST /graph/extract REST API for programmatic graph 
extraction. (#351)
eb8af86f is described below

commit eb8af86fa2c1401af58b1f45116b7139c394c7d9
Author: Nishita Matlani <[email protected]>
AuthorDate: Mon Jun 8 02:24:32 2026 -0400

    Add POST /graph/extract REST API for programmatic graph extraction. (#351)
    
    ## Summary
    
    Closes #348.
    
    HugeGraph-LLM already supports graph extraction through the Gradio demo,
    but there was no public REST endpoint for it. This PR adds `POST
    /graph/extract` to the existing FastAPI app, routing requests through
    `SchedulerSingleton` and `FlowName.GRAPH_EXTRACT` — the same path the
    demo uses.
    
    ### Key changes
    
    - Add `GraphExtractRequest` with validation for `texts`, `schema`,
    `split_type`, and related options
    - Add `graph_http_api` and register it on the existing auth router
    - Make `split_type` configurable in `GraphExtractFlow` (default
    `"document"`, so demo behavior is unchanged)
    - Return structured JSON (`vertices` / `edges` as arrays), with optional
    `warning` and `meta`
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
    Co-authored-by: imbajin <[email protected]>
---
 .../src/hugegraph_llm/api/graph_extract_api.py     |  69 ++++
 .../api/models/graph_extract_requests.py           | 127 +++++++
 .../api/models/graph_extract_responses.py          |  27 ++
 .../src/hugegraph_llm/api/models/rag_requests.py   |   2 +-
 .../src/hugegraph_llm/demo/rag_demo/app.py         |   2 +
 .../src/hugegraph_llm/flows/graph_extract.py       |  13 +
 .../hugegraph_llm/nodes/hugegraph_node/schema.py   |   2 +-
 .../operators/hugegraph_op/schema_manager.py       |  22 +-
 hugegraph-llm/src/hugegraph_llm/state/ai_state.py  |   3 +
 .../src/tests/api/test_graph_extract_api.py        | 396 +++++++++++++++++++++
 .../src/tests/document/test_vector_index_utils.py  |   5 +-
 .../operators/hugegraph_op/test_schema_manager.py  |  66 ++++
 12 files changed, 726 insertions(+), 8 deletions(-)

diff --git a/hugegraph-llm/src/hugegraph_llm/api/graph_extract_api.py 
b/hugegraph-llm/src/hugegraph_llm/api/graph_extract_api.py
new file mode 100644
index 00000000..6884412d
--- /dev/null
+++ b/hugegraph-llm/src/hugegraph_llm/api/graph_extract_api.py
@@ -0,0 +1,69 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import json
+
+from fastapi import APIRouter, HTTPException, status
+
+from hugegraph_llm.api.models.graph_extract_requests import GraphExtractRequest
+from hugegraph_llm.api.models.graph_extract_responses import 
GraphExtractResponse
+from hugegraph_llm.config import prompt
+from hugegraph_llm.flows import FlowName
+from hugegraph_llm.flows.scheduler import SchedulerSingleton
+from hugegraph_llm.utils.log import log
+
+
+class GraphExtractService:
+    @staticmethod
+    def extract_sync(req: GraphExtractRequest) -> GraphExtractResponse:
+        try:
+            scheduler = SchedulerSingleton.get_instance()
+            result_str = scheduler.schedule_flow(
+                FlowName.GRAPH_EXTRACT,
+                req.graph_schema,
+                req.texts,
+                req.example_prompt or prompt.extract_graph_prompt,
+                req.extract_type,
+                language=req.language,
+                split_type=req.split_type,
+                client_config=req.client_config,
+            )
+            raw = json.loads(result_str)
+            warnings = [raw.pop("warning")] if "warning" in raw else []
+            result = {"vertices": raw.get("vertices", []), "edges": 
raw.get("edges", [])}
+            meta = {}
+            if req.include_meta:
+                meta = {
+                    "vertex_count": len(result["vertices"]),
+                    "edge_count": len(result["edges"]),
+                    "text_count": len(req.texts),
+                }
+            return GraphExtractResponse(result=result, warnings=warnings, 
meta=meta)
+        except HTTPException:
+            raise
+        except Exception as e:
+            log.error("Error in graph_extract_api: %s", e)
+            raise HTTPException(
+                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+                detail="An unexpected error occurred during graph extraction.",
+            ) from e
+
+
+def graph_extract_http_api(router: APIRouter):
+    @router.post("/graph/extract", status_code=status.HTTP_200_OK, 
response_model=GraphExtractResponse)
+    def graph_extract_api(req: GraphExtractRequest):
+        return GraphExtractService.extract_sync(req)
diff --git 
a/hugegraph-llm/src/hugegraph_llm/api/models/graph_extract_requests.py 
b/hugegraph-llm/src/hugegraph_llm/api/models/graph_extract_requests.py
new file mode 100644
index 00000000..d3e2654a
--- /dev/null
+++ b/hugegraph-llm/src/hugegraph_llm/api/models/graph_extract_requests.py
@@ -0,0 +1,127 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import json
+from typing import Any, Dict, List, Literal, Optional, Union
+
+from fastapi import Query
+from pydantic import BaseModel, ConfigDict, Field, field_validator, 
model_validator
+
+
+class GraphExtractClientConfig(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    graph: Optional[str] = None
+    user: Optional[str] = None
+    pwd: Optional[str] = None
+    gs: Optional[str] = None
+
+
+class GraphExtractRequest(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    texts: Union[str, List[str]] = Field(..., description="Text or list of 
texts to extract a graph from.")
+    graph_schema: Union[str, Dict[str, Any]] = Field(
+        ...,
+        alias="schema",
+        description="Graph schema as a JSON string/object, or an existing 
graph name.",
+    )
+    example_prompt: Optional[str] = Query(None, description="Optional graph 
extraction prompt header.")
+    extract_type: Literal["property_graph"] = Query("property_graph", 
description="Extraction type.")
+    language: Literal["zh", "en"] = Query("zh", description="Language for 
chunk splitting.")
+    split_type: Literal["document", "paragraph", "sentence"] = 
Query("document", description="Chunk split granularity.")
+    include_meta: bool = Query(False, description="Include vertex/edge/text 
counts in the response.")
+    client_config: Optional[GraphExtractClientConfig] = Field(None, 
description="Request-scoped HugeGraph connection.")
+
+    @field_validator("texts")
+    @classmethod
+    def normalize_texts(cls, v):
+        items = [v] if isinstance(v, str) else list(v)
+        items = [t for t in items if t and t.strip()]
+        if not items:
+            raise ValueError("texts must not be empty.")
+        return items
+
+    @field_validator("graph_schema")
+    @classmethod
+    def normalize_schema(cls, v):
+        def validate_schema_obj(schema_obj: Any) -> None:
+            if not isinstance(schema_obj, dict):
+                raise ValueError("schema JSON must be an object.")
+            if "vertexlabels" not in schema_obj or "edgelabels" not in 
schema_obj:
+                raise ValueError("schema must contain 'vertexlabels' and 
'edgelabels'.")
+            if not isinstance(schema_obj["vertexlabels"], list) or not 
isinstance(schema_obj["edgelabels"], list):
+                raise ValueError("'vertexlabels' and 'edgelabels' must be 
lists.")
+
+            for vlabel in schema_obj["vertexlabels"]:
+                if not isinstance(vlabel, dict):
+                    raise ValueError("Each item in 'vertexlabels' must be an 
object.")
+                if not isinstance(vlabel.get("name"), str) or not 
vlabel["name"].strip():
+                    raise ValueError("Each vertex label must have a non-empty 
string 'name'.")
+                props = vlabel.get("properties")
+                if not isinstance(props, list) or len(props) == 0:
+                    raise ValueError("Each vertex label must have a non-empty 
'properties' list.")
+
+            for elabel in schema_obj["edgelabels"]:
+                if not isinstance(elabel, dict):
+                    raise ValueError("Each item in 'edgelabels' must be an 
object.")
+                for key in ("name", "source_label", "target_label"):
+                    if not isinstance(elabel.get(key), str) or not 
elabel[key].strip():
+                        raise ValueError(f"Each edge label must have a 
non-empty string '{key}'.")
+                if "properties" in elabel and not 
isinstance(elabel["properties"], list):
+                    raise ValueError("'properties' in edge labels must be a 
list when provided.")
+
+            if "propertykeys" in schema_obj and not 
isinstance(schema_obj["propertykeys"], list):
+                raise ValueError("'propertykeys' must be a list when 
provided.")
+
+        if isinstance(v, dict):
+            validate_schema_obj(v)
+            return json.dumps(v, ensure_ascii=False)
+        v = v.strip()
+        if not v:
+            raise ValueError("schema must not be empty.")
+        if v.startswith("{"):
+            try:
+                schema_obj = json.loads(v)
+            except json.JSONDecodeError as e:
+                raise ValueError(f"Invalid JSON schema: {e}") from e
+            validate_schema_obj(schema_obj)
+            return v
+        return v
+
+    @model_validator(mode="after")
+    def validate_schema_and_client_config(self):
+        schema = self.graph_schema
+        is_named_schema = isinstance(schema, str) and not 
schema.strip().startswith("{")
+        if not is_named_schema:
+            if self.client_config is not None:
+                raise ValueError(
+                    "client_config is not allowed when 'schema' is inline 
JSON; graph extraction "
+                    "from an inline schema does not connect to HugeGraph."
+                )
+            return self
+        if self.client_config is None:
+            raise ValueError(
+                "client_config is required when 'schema' refers to an existing 
graph name; "
+                "provide inline schema JSON instead to extract without a 
HugeGraph connection."
+            )
+        if self.client_config.graph != schema:
+            raise ValueError(
+                "When 'schema' is a graph name, client_config.graph must match 
it "
+                f"(got schema='{schema}', 
client_config.graph='{self.client_config.graph}')."
+            )
+        return self
diff --git 
a/hugegraph-llm/src/hugegraph_llm/api/models/graph_extract_responses.py 
b/hugegraph-llm/src/hugegraph_llm/api/models/graph_extract_responses.py
new file mode 100644
index 00000000..25eab197
--- /dev/null
+++ b/hugegraph-llm/src/hugegraph_llm/api/models/graph_extract_responses.py
@@ -0,0 +1,27 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any, Dict, List, Literal
+
+from pydantic import BaseModel, Field
+
+
+class GraphExtractResponse(BaseModel):
+    status: Literal["succeeded"] = "succeeded"
+    result: Dict[str, Any]
+    warnings: List[str] = Field(default_factory=list)
+    meta: Dict[str, Any] = Field(default_factory=dict)
diff --git a/hugegraph-llm/src/hugegraph_llm/api/models/rag_requests.py 
b/hugegraph-llm/src/hugegraph_llm/api/models/rag_requests.py
index 77155978..df65e268 100644
--- a/hugegraph-llm/src/hugegraph_llm/api/models/rag_requests.py
+++ b/hugegraph-llm/src/hugegraph_llm/api/models/rag_requests.py
@@ -32,7 +32,7 @@ class GraphConfigRequest(BaseModel):
     graph: str = Query("hugegraph", description="hugegraph client name.")
     user: str = Query("", description="hugegraph client user.")
     pwd: str = Query("", description="hugegraph client pwd.")
-    gs: str = None
+    gs: Optional[str] = None
 
 
 class RAGRequest(BaseModel):
diff --git a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py 
b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py
index c687df6d..fcb7cac2 100644
--- a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py
+++ b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py
@@ -24,6 +24,7 @@ from fastapi import APIRouter, Depends, FastAPI
 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
 
 from hugegraph_llm.api.admin_api import admin_http_api
+from hugegraph_llm.api.graph_extract_api import graph_extract_http_api
 from hugegraph_llm.api.rag_api import rag_http_api
 from hugegraph_llm.config import admin_settings, huge_settings, prompt
 from hugegraph_llm.demo.rag_demo.admin_block import create_admin_block, 
log_stream
@@ -178,6 +179,7 @@ def create_app():
         gremlin_generate_selective,
     )
     admin_http_api(api_auth, log_stream)
+    graph_extract_http_api(api_auth)
 
     app.include_router(api_auth)
     # Mount Gradio inside FastAPI
diff --git a/hugegraph-llm/src/hugegraph_llm/flows/graph_extract.py 
b/hugegraph-llm/src/hugegraph_llm/flows/graph_extract.py
index 13629e2b..4c96434a 100644
--- a/hugegraph-llm/src/hugegraph_llm/flows/graph_extract.py
+++ b/hugegraph-llm/src/hugegraph_llm/flows/graph_extract.py
@@ -17,6 +17,7 @@ import json
 
 from pycgraph import GPipeline
 
+from hugegraph_llm.config import huge_settings
 from hugegraph_llm.flows.common import BaseFlow
 from hugegraph_llm.nodes.document_node.chunk_split import ChunkSplitNode
 from hugegraph_llm.nodes.hugegraph_node.schema import SchemaNode
@@ -55,6 +56,17 @@ class GraphExtractFlow(BaseFlow):
         prepared_input.example_prompt = example_prompt
         prepared_input.schema = schema
         prepared_input.extract_type = extract_type
+        client_config = kwargs.get("client_config")
+        if client_config:
+            # URL stays server-controlled; only identity/graphspace are 
request-scoped.
+            prepared_input.graph_client_config = {
+                "url": huge_settings.graph_url,
+                "user": client_config.user,
+                "pwd": client_config.pwd,
+                "graphspace": client_config.gs,
+            }
+        else:
+            prepared_input.graph_client_config = None
 
     def build_flow(
         self,
@@ -77,6 +89,7 @@ class GraphExtractFlow(BaseFlow):
             extract_type,
             split_type,
             language,
+            **kwargs,
         )
 
         pipeline.createGParam(prepared_input, "wkflow_input")
diff --git a/hugegraph-llm/src/hugegraph_llm/nodes/hugegraph_node/schema.py 
b/hugegraph-llm/src/hugegraph_llm/nodes/hugegraph_node/schema.py
index e9f9c608..c2c659b6 100644
--- a/hugegraph-llm/src/hugegraph_llm/nodes/hugegraph_node/schema.py
+++ b/hugegraph-llm/src/hugegraph_llm/nodes/hugegraph_node/schema.py
@@ -39,7 +39,7 @@ class SchemaNode(BaseNode):
         from_user_defined=None,
     ):
         if from_hugegraph:
-            return SchemaManager(from_hugegraph)
+            return SchemaManager(from_hugegraph, 
connection=self.wk_input.graph_client_config)
         if from_user_defined:
             return CheckSchema(from_user_defined)
         if from_extraction:
diff --git 
a/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/schema_manager.py 
b/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/schema_manager.py
index c51cb958..a862ea44 100644
--- a/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/schema_manager.py
+++ b/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/schema_manager.py
@@ -23,14 +23,26 @@ from hugegraph_llm.config import huge_settings
 
 
 class SchemaManager:
-    def __init__(self, graph_name: str):
+    def __init__(self, graph_name: str, *, connection: Optional[Dict[str, 
Any]] = None):
         self.graph_name = graph_name
+        # Apply a request-scoped connection as a complete unit (omitted fields 
stay as
+        # given) so it cannot fall back to global huge_settings per-field.
+        if connection is not None:
+            url = connection.get("url")
+            user = connection.get("user")
+            pwd = connection.get("pwd")
+            graphspace = connection.get("graphspace")
+        else:
+            url = huge_settings.graph_url
+            user = huge_settings.graph_user
+            pwd = huge_settings.graph_pwd
+            graphspace = huge_settings.graph_space
         self.client = PyHugeClient(
-            url=huge_settings.graph_url,
+            url=url,
             graph=self.graph_name,
-            user=huge_settings.graph_user,
-            pwd=huge_settings.graph_pwd,
-            graphspace=huge_settings.graph_space,
+            user=user,
+            pwd=pwd,
+            graphspace=graphspace,
         )
         self.schema = self.client.schema()
 
diff --git a/hugegraph-llm/src/hugegraph_llm/state/ai_state.py 
b/hugegraph-llm/src/hugegraph_llm/state/ai_state.py
index 7dce7e85..739588c5 100644
--- a/hugegraph-llm/src/hugegraph_llm/state/ai_state.py
+++ b/hugegraph-llm/src/hugegraph_llm/state/ai_state.py
@@ -26,6 +26,8 @@ class WkFlowInput(GParam):
     split_type: Optional[str] = None  # split type used by ChunkSplit Node
     example_prompt: Optional[str] = None  # need by graph information extract
     schema: Optional[str] = None  # Schema information requeired by SchemaNode
+    # Request-scoped HugeGraph connection; None falls back to global 
huge_settings.
+    graph_client_config: Optional[Dict[str, Any]] = None
     data_json: Optional[Dict[str, Any]] = None
     extract_type: Optional[str] = None
     query_examples: Optional[Any] = None
@@ -86,6 +88,7 @@ class WkFlowInput(GParam):
         self.split_type = None
         self.example_prompt = None
         self.schema = None
+        self.graph_client_config = None
         self.data_json = None
         self.extract_type = None
         self.query_examples = None
diff --git a/hugegraph-llm/src/tests/api/test_graph_extract_api.py 
b/hugegraph-llm/src/tests/api/test_graph_extract_api.py
new file mode 100644
index 00000000..29a3a827
--- /dev/null
+++ b/hugegraph-llm/src/tests/api/test_graph_extract_api.py
@@ -0,0 +1,396 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import json
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+from fastapi import APIRouter, FastAPI, HTTPException, status
+from fastapi.testclient import TestClient
+from pydantic import ValidationError
+
+from hugegraph_llm.api.graph_extract_api import GraphExtractService, 
graph_extract_http_api
+from hugegraph_llm.api.models.graph_extract_requests import 
GraphExtractClientConfig, GraphExtractRequest
+from hugegraph_llm.api.models.graph_extract_responses import 
GraphExtractResponse
+from hugegraph_llm.api.rag_api import rag_http_api
+from hugegraph_llm.config import huge_settings
+from hugegraph_llm.flows.graph_extract import GraphExtractFlow
+from hugegraph_llm.state.ai_state import WkFlowInput
+
+INLINE_SCHEMA = {"vertexlabels": [], "edgelabels": []}
+
+
+class CapturePipeline:
+    def __init__(self):
+        self.params = {}
+
+    def createGParam(self, value, name):
+        self.params[name] = value
+
+    def registerGElement(self, *args):
+        return None
+
+
+def _graph_client():
+    router = APIRouter()
+    graph_extract_http_api(router)
+    app = FastAPI()
+    app.include_router(router)
+    return TestClient(app)
+
+
+def _named_client_config(graph="custom_graph"):
+    return {"graph": graph, "user": "admin", "pwd": "secret", "gs": "space_a"}
+
+
+@patch("hugegraph_llm.api.graph_extract_api.SchedulerSingleton")
+def test_graph_extract_returns_envelope(mock_singleton):
+    scheduler = MagicMock()
+    scheduler.schedule_flow.return_value = json.dumps({"vertices": [{"id": 
"1"}], "edges": []})
+    mock_singleton.get_instance.return_value = scheduler
+
+    response = _graph_client().post(
+        "/graph/extract",
+        json={"texts": "张三在北京工作。", "schema": INLINE_SCHEMA, "include_meta": 
True},
+    )
+
+    assert response.status_code == status.HTTP_200_OK
+    body = response.json()
+    assert body["status"] == "succeeded"
+    assert body["result"] == {"vertices": [{"id": "1"}], "edges": []}
+    assert body["warnings"] == []
+    assert body["meta"] == {"vertex_count": 1, "edge_count": 0, "text_count": 
1}
+
+
+@patch("hugegraph_llm.api.graph_extract_api.SchedulerSingleton")
+def test_graph_extract_omits_meta_by_default(mock_singleton):
+    scheduler = MagicMock()
+    scheduler.schedule_flow.return_value = json.dumps({"vertices": [], 
"edges": []})
+    mock_singleton.get_instance.return_value = scheduler
+
+    response = _graph_client().post("/graph/extract", json={"texts": "x", 
"schema": INLINE_SCHEMA})
+
+    assert response.status_code == status.HTTP_200_OK
+    assert response.json()["meta"] == {}
+
+
+@patch("hugegraph_llm.api.graph_extract_api.SchedulerSingleton")
+def test_graph_extract_moves_warning_into_warnings(mock_singleton):
+    scheduler = MagicMock()
+    scheduler.schedule_flow.return_value = json.dumps(
+        {"vertices": [], "edges": [], "warning": "The schema may not match the 
Doc"}
+    )
+    mock_singleton.get_instance.return_value = scheduler
+
+    response = _graph_client().post("/graph/extract", json={"texts": "x", 
"schema": INLINE_SCHEMA})
+
+    body = response.json()
+    assert body["warnings"] == ["The schema may not match the Doc"]
+    assert "warning" not in body["result"]
+
+
+@patch("hugegraph_llm.api.graph_extract_api.SchedulerSingleton")
+def test_graph_extract_accepts_text_and_list(mock_singleton):
+    scheduler = MagicMock()
+    scheduler.schedule_flow.return_value = json.dumps({"vertices": [], 
"edges": []})
+    mock_singleton.get_instance.return_value = scheduler
+
+    client = _graph_client()
+    client.post("/graph/extract", json={"texts": "single", "schema": 
INLINE_SCHEMA})
+    assert scheduler.schedule_flow.call_args.args[2] == ["single"]
+
+    client.post("/graph/extract", json={"texts": ["a", "b"], "schema": 
INLINE_SCHEMA})
+    assert scheduler.schedule_flow.call_args.args[2] == ["a", "b"]
+
+
+def test_graph_extract_rejects_empty_texts():
+    response = _graph_client().post("/graph/extract", json={"texts": "  ", 
"schema": INLINE_SCHEMA})
+    assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+
+
+def test_graph_extract_rejects_invalid_schema():
+    response = _graph_client().post("/graph/extract", json={"texts": "x", 
"schema": "{bad"})
+    assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+
+
+def test_graph_extract_rejects_incomplete_schema():
+    response = _graph_client().post("/graph/extract", json={"texts": "x", 
"schema": {"vertexlabels": []}})
+    assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+
+
+@patch("hugegraph_llm.api.graph_extract_api.SchedulerSingleton")
+def 
test_graph_extract_rejects_malformed_inline_schema_before_scheduler(mock_singleton):
+    response = _graph_client().post(
+        "/graph/extract",
+        json={"texts": "x", "schema": {"vertexlabels": [{"name": "person"}], 
"edgelabels": []}},
+    )
+    assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+    mock_singleton.get_instance.assert_not_called()
+
+
+def test_graph_extract_rejects_invalid_split_type():
+    response = _graph_client().post(
+        "/graph/extract",
+        json={"texts": "x", "schema": INLINE_SCHEMA, "split_type": "doc"},
+    )
+    assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+
+
+def test_graph_extract_rejects_triples_extract_type():
+    response = _graph_client().post(
+        "/graph/extract",
+        json={"texts": "x", "schema": INLINE_SCHEMA, "extract_type": 
"triples"},
+    )
+    assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+
+
+def test_graph_extract_rejects_named_schema_without_client_config():
+    response = _graph_client().post("/graph/extract", json={"texts": "x", 
"schema": "hugegraph"})
+    assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+
+
+def test_graph_extract_rejects_client_config_with_inline_schema():
+    response = _graph_client().post(
+        "/graph/extract",
+        json={"texts": "x", "schema": INLINE_SCHEMA, "client_config": 
_named_client_config()},
+    )
+    assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+
+
+def test_graph_extract_rejects_mismatched_schema_and_client_config_graph():
+    response = _graph_client().post(
+        "/graph/extract",
+        json={"texts": "x", "schema": "custom_graph", "client_config": 
_named_client_config("other_graph")},
+    )
+    assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+
+
+def test_graph_extract_rejects_url_in_client_config():
+    response = _graph_client().post(
+        "/graph/extract",
+        json={
+            "texts": "x",
+            "schema": "custom_graph",
+            "client_config": {"graph": "custom_graph", "url": "10.0.0.1:8080"},
+        },
+    )
+    assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+
+
+@patch("hugegraph_llm.api.graph_extract_api.SchedulerSingleton")
+def test_graph_extract_named_schema_does_not_mutate_globals(mock_singleton):
+    scheduler = MagicMock()
+    scheduler.schedule_flow.return_value = json.dumps({"vertices": [], 
"edges": []})
+    mock_singleton.get_instance.return_value = scheduler
+
+    original = (
+        huge_settings.graph_url,
+        huge_settings.graph_name,
+        huge_settings.graph_user,
+        huge_settings.graph_pwd,
+        huge_settings.graph_space,
+    )
+    response = _graph_client().post(
+        "/graph/extract",
+        json={"texts": "x", "schema": "custom_graph", "client_config": 
_named_client_config()},
+    )
+
+    assert response.status_code == status.HTTP_200_OK
+    assert (
+        huge_settings.graph_url,
+        huge_settings.graph_name,
+        huge_settings.graph_user,
+        huge_settings.graph_pwd,
+        huge_settings.graph_space,
+    ) == original
+    assert scheduler.schedule_flow.call_args.kwargs["client_config"].graph == 
"custom_graph"
+
+
+@patch("hugegraph_llm.api.graph_extract_api.SchedulerSingleton")
+def test_graph_extract_scheduler_error_returns_500(mock_singleton):
+    scheduler = MagicMock()
+    scheduler.schedule_flow.side_effect = RuntimeError("Error in flow init")
+    mock_singleton.get_instance.return_value = scheduler
+
+    response = _graph_client().post("/graph/extract", json={"texts": "x", 
"schema": INLINE_SCHEMA})
+    assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
+
+
+@patch("hugegraph_llm.api.graph_extract_api.SchedulerSingleton")
+def test_service_extract_sync_builds_envelope(mock_singleton):
+    scheduler = MagicMock()
+    scheduler.schedule_flow.return_value = json.dumps({"vertices": [{"id": 
"1"}], "edges": []})
+    mock_singleton.get_instance.return_value = scheduler
+
+    resp = GraphExtractService.extract_sync(GraphExtractRequest(texts="x", 
schema=INLINE_SCHEMA, include_meta=True))
+
+    assert isinstance(resp, GraphExtractResponse)
+    assert resp.status == "succeeded"
+    assert resp.result == {"vertices": [{"id": "1"}], "edges": []}
+    assert resp.warnings == []
+    assert resp.meta == {"vertex_count": 1, "edge_count": 0, "text_count": 1}
+
+
+@patch("hugegraph_llm.api.graph_extract_api.SchedulerSingleton")
+def test_service_extract_sync_maps_errors_to_500(mock_singleton):
+    scheduler = MagicMock()
+    scheduler.schedule_flow.side_effect = RuntimeError("boom")
+    mock_singleton.get_instance.return_value = scheduler
+
+    with pytest.raises(HTTPException) as exc_info:
+        GraphExtractService.extract_sync(GraphExtractRequest(texts="x", 
schema=INLINE_SCHEMA))
+    assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
+
+
+def test_request_model_validation():
+    req = GraphExtractRequest(texts="hello", schema=INLINE_SCHEMA)
+    assert req.texts == ["hello"]
+    assert req.graph_schema == json.dumps(INLINE_SCHEMA, ensure_ascii=False)
+    assert req.client_config is None
+
+    with pytest.raises(ValidationError):
+        GraphExtractRequest(texts=[], schema="hugegraph")
+
+
+def test_request_model_named_schema_requires_matching_client_config():
+    with pytest.raises(ValidationError):
+        GraphExtractRequest(texts="hello", schema="hugegraph")
+
+    with pytest.raises(ValidationError):
+        GraphExtractRequest(
+            texts="hello",
+            schema="custom_graph",
+            client_config=GraphExtractClientConfig(graph="other_graph"),
+        )
+
+    req = GraphExtractRequest(
+        texts="hello",
+        schema="hugegraph",
+        client_config=GraphExtractClientConfig(graph="hugegraph", 
user="admin", pwd="secret", gs="space_a"),
+    )
+    assert req.client_config.graph == "hugegraph"
+
+
+def test_request_model_rejects_client_config_with_inline_schema():
+    with pytest.raises(ValidationError):
+        GraphExtractRequest(
+            texts="hello",
+            schema=INLINE_SCHEMA,
+            client_config=GraphExtractClientConfig(graph="hugegraph"),
+        )
+
+
+def test_client_config_forbids_unknown_fields():
+    with pytest.raises(ValidationError):
+        GraphExtractClientConfig(graph="custom_graph", url="10.0.0.1:8080")
+
+
+def test_flow_prepare_sets_request_local_graph_config():
+    flow = GraphExtractFlow()
+    prepared_input = WkFlowInput()
+    client_config = GraphExtractClientConfig(graph="custom_graph", 
user="admin", pwd="secret", gs="space_a")
+
+    flow.prepare(prepared_input, "custom_graph", ["text"], "prompt", 
"property_graph", client_config=client_config)
+
+    assert prepared_input.graph_client_config == {
+        "url": huge_settings.graph_url,
+        "user": "admin",
+        "pwd": "secret",
+        "graphspace": "space_a",
+    }
+
+
+def test_flow_prepare_keeps_omitted_graphspace_none():
+    flow = GraphExtractFlow()
+    prepared_input = WkFlowInput()
+    client_config = GraphExtractClientConfig(graph="custom_graph", 
user="admin", pwd="secret")
+
+    flow.prepare(prepared_input, "custom_graph", ["text"], "prompt", 
"property_graph", client_config=client_config)
+
+    assert prepared_input.graph_client_config["graphspace"] is None
+
+
+def test_flow_prepare_does_not_leak_config_across_runs():
+    # A pooled pipeline is reused across requests, so prepare() must clear 
config
+    # when a later request omits client_config.
+    flow = GraphExtractFlow()
+    prepared_input = WkFlowInput()
+    client_config = GraphExtractClientConfig(graph="custom_graph", 
user="admin", pwd="secret", gs="space_a")
+
+    flow.prepare(prepared_input, "custom_graph", ["text"], "prompt", 
"property_graph", client_config=client_config)
+    assert prepared_input.graph_client_config is not None
+
+    flow.prepare(prepared_input, "custom_graph", ["text"], "prompt", 
"property_graph")
+    assert prepared_input.graph_client_config is None
+
+
+def test_flow_build_flow_preserves_split_type_and_client_config(monkeypatch):
+    monkeypatch.setattr(
+        "hugegraph_llm.flows.graph_extract.GPipeline",
+        CapturePipeline,
+    )
+    client_config = GraphExtractClientConfig(graph="custom_graph", 
user="admin", pwd="secret", gs="space_a")
+
+    pipeline = GraphExtractFlow().build_flow(
+        "custom_graph",
+        ["text"],
+        "prompt",
+        "property_graph",
+        split_type="paragraph",
+        client_config=client_config,
+    )
+
+    prepared_input = pipeline.params["wkflow_input"]
+    assert prepared_input.split_type == "paragraph"
+    assert prepared_input.graph_client_config == {
+        "url": huge_settings.graph_url,
+        "user": "admin",
+        "pwd": "secret",
+        "graphspace": "space_a",
+    }
+
+
+def test_wkflow_input_reset_clears_graph_client_config():
+    prepared_input = WkFlowInput()
+    prepared_input.graph_client_config = {"url": "10.0.0.1:8080"}
+
+    prepared_input.reset(None)
+
+    assert prepared_input.graph_client_config is None
+
+
+def test_existing_routes_still_register():
+    router = APIRouter()
+    rag_http_api(
+        router,
+        rag_answer_func=Mock(),
+        graph_rag_recall_func=Mock(),
+        apply_graph_conf=Mock(),
+        apply_llm_conf=Mock(),
+        apply_embedding_conf=Mock(),
+        apply_reranker_conf=Mock(),
+        gremlin_generate_selective_func=Mock(),
+    )
+    graph_extract_http_api(router)
+    app = FastAPI()
+    app.include_router(router)
+
+    paths = {route.path for route in app.routes if hasattr(route, "path")}
+    assert "/rag" in paths
+    assert "/text2gremlin" in paths
+    assert "/config/graph" in paths
+    assert "/graph/extract" in paths
diff --git a/hugegraph-llm/src/tests/document/test_vector_index_utils.py 
b/hugegraph-llm/src/tests/document/test_vector_index_utils.py
index 33213cdc..059121f0 100644
--- a/hugegraph-llm/src/tests/document/test_vector_index_utils.py
+++ b/hugegraph-llm/src/tests/document/test_vector_index_utils.py
@@ -192,9 +192,11 @@ def 
test_read_documents_rejects_unsupported_file_type(tmp_path):
 class DummyScheduler:
     def __init__(self):
         self.calls = []
+        self.kwargs = []
 
-    def schedule_flow(self, *args):
+    def schedule_flow(self, *args, **kwargs):
         self.calls.append(args)
+        self.kwargs.append(kwargs)
         return "scheduled"
 
 
@@ -251,6 +253,7 @@ def 
test_extract_graph_accepts_pdf_upload_and_forwards_text(monkeypatch, tmp_pat
     assert "Extract Graph Entrypoint PDF" in texts[0]
     assert forwarded_prompt == example_prompt
     assert graph_mode == "property_graph"
+    assert scheduler.kwargs[0] == {"split_type": "document"}
 
 
 def test_read_documents_rejects_encrypted_pdf(tmp_path):
diff --git 
a/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py 
b/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py
index 05d9659f..7ccf7310 100644
--- a/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py
+++ b/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py
@@ -91,6 +91,72 @@ class TestSchemaManager(unittest.TestCase):
         self.assertEqual(self.schema_manager.client, self.mock_client)
         self.assertEqual(self.schema_manager.schema, self.mock_schema)
 
+    @patch("hugegraph_llm.operators.hugegraph_op.schema_manager.PyHugeClient")
+    @patch("hugegraph_llm.operators.hugegraph_op.schema_manager.huge_settings")
+    def test_init_uses_request_local_connection_settings(self, mock_settings, 
mock_client_class):
+        mock_settings.graph_url = "default:8080"
+        mock_settings.graph_user = "default_user"
+        mock_settings.graph_pwd = "default_pwd"
+        mock_settings.graph_space = "default_space"
+
+        SchemaManager(
+            "custom_graph",
+            connection={
+                "url": "10.0.0.1:8080",
+                "user": "admin",
+                "pwd": "secret",
+                "graphspace": "space_a",
+            },
+        )
+
+        mock_client_class.assert_called_once_with(
+            url="10.0.0.1:8080",
+            graph="custom_graph",
+            user="admin",
+            pwd="secret",
+            graphspace="space_a",
+        )
+
+    @patch("hugegraph_llm.operators.hugegraph_op.schema_manager.PyHugeClient")
+    @patch("hugegraph_llm.operators.hugegraph_op.schema_manager.huge_settings")
+    def test_init_request_config_does_not_inherit_global_graphspace(self, 
mock_settings, mock_client_class):
+        mock_settings.graph_url = "default:8080"
+        mock_settings.graph_user = "default_user"
+        mock_settings.graph_pwd = "default_pwd"
+        mock_settings.graph_space = "global_space"
+
+        SchemaManager(
+            "custom_graph",
+            connection={
+                "url": "10.0.0.1:8080",
+                "user": "admin",
+                "pwd": "secret",
+                "graphspace": None,
+            },
+        )
+
+        _, kwargs = mock_client_class.call_args
+        assert kwargs["graphspace"] is None
+        assert kwargs["url"] == "10.0.0.1:8080"
+
+    @patch("hugegraph_llm.operators.hugegraph_op.schema_manager.PyHugeClient")
+    @patch("hugegraph_llm.operators.hugegraph_op.schema_manager.huge_settings")
+    def test_init_falls_back_to_globals_without_connection(self, 
mock_settings, mock_client_class):
+        mock_settings.graph_url = "default:8080"
+        mock_settings.graph_user = "default_user"
+        mock_settings.graph_pwd = "default_pwd"
+        mock_settings.graph_space = "global_space"
+
+        SchemaManager("custom_graph")
+
+        mock_client_class.assert_called_once_with(
+            url="default:8080",
+            graph="custom_graph",
+            user="default_user",
+            pwd="default_pwd",
+            graphspace="global_space",
+        )
+
     def test_simple_schema_with_full_schema(self):
         """Test simple_schema method with a full schema."""
         # Call the method


Reply via email to