imbajin commented on code in PR #361:
URL: https://github.com/apache/hugegraph-ai/pull/361#discussion_r3497517737


##########
hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/commit_to_hugegraph.py:
##########
@@ -162,13 +247,18 @@ def load_into_graph(self, vertices, edges, schema):  # 
pylint: disable=too-many-
             else:
                 result = 
self._handle_graph_creation(self.client.graph().addVertex, input_label, 
input_properties)
             if result is None:
-                raise ValueError(f"Failed to create vertex '{input_label}' 
with properties {input_properties}")
+                import_result["vertices_skipped"] += 1
+                import_result["errors"].append(
+                    self._import_error("vertex", vertex_index, 
"create_failed", label=input_label)
+                )
+                continue
             vid = result.id
+            import_result["vertices_created"] += 1
             vertex["id"] = vid
             if mapping_id:
                 vid_mapping[mapping_id] = vid
 
-        for edge in edges:
+        for edge_index, edge in enumerate(edges):
             start = vid_mapping.get(edge.get("outV"), edge.get("outV"))

Review Comment:
   ‼️ This fallback can still write edges that reference vertices skipped 
earlier in the same import. If a vertex fails validation or creation, 
`vid_mapping` has no entry, so the edge falls back to the raw `outV`/`inV` and 
may connect to an existing vertex with the same id in the graph. Please track 
failed/missing endpoints for the current batch and skip those edges with an 
explicit `missing_endpoint`/`endpoint_vertex_failed` error instead of falling 
back to a raw id.



##########
.github/workflows/hugegraph-llm.yml:
##########
@@ -131,89 +67,16 @@ jobs:
         uv sync --extra llm --extra dev
         uv run python -c "import nltk; nltk.download('stopwords'); 
nltk.download('punkt')"
 
-    - name: Run HugeGraph boundary tests
+    - name: Run unit tests
+      working-directory: hugegraph-llm
       env:
-        HUGEGRAPH_REQUIRED: true
-        HUGEGRAPH_URL: http://127.0.0.1:8080
-        HUGEGRAPH_GRAPH: hugegraph
-        HUGEGRAPH_USER: admin
-        HUGEGRAPH_PASSWORD: admin
-        SKIP_EXTERNAL_SERVICES: false
-      run: |
-        uv run pytest hugegraph-llm/src/tests -m "integration and hugegraph" 
-v --tb=short
-
-    - name: Dump HugeGraph diagnostics
-      if: failure()
-      run: |
-        docker ps -a
-        container_id=$(docker ps -aq --filter 
ancestor=hugegraph/hugegraph:1.7.0 | head -n 1)
-        if [ -n "$container_id" ]; then
-          docker logs "$container_id"
-        fi
-
-  llm-core-smoke:
-    runs-on: ubuntu-latest
-    strategy:
-      fail-fast: false
-      matrix:
-        python-version: ["3.10", "3.11"]
-
-    services:
-      hugegraph:
-        image: hugegraph/hugegraph:1.7.0
-        env:
-          PASSWORD: admin
-        options: --health-cmd="curl -f http://localhost:8080/versions || exit 
1" --health-interval=10s --health-timeout=5s --health-retries=8
-        ports:
-          - 8080:8080
-
-    steps:
-    - uses: actions/checkout@v6
-      with:
-        persist-credentials: false
-
-    - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v6
-      with:
-        python-version: ${{ matrix.python-version }}
-
-    - name: Install uv
-      run: |
-        curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh"; | sh
-        echo "$HOME/.local/bin" >> "$GITHUB_PATH"
-        "$HOME/.local/bin/uv" --version
-
-    - name: Cache dependencies
-      uses: actions/cache@v5
-      with:
-        path: |
-          ~/.cache/uv
-          ~/nltk_data
-        key: ${{ runner.os }}-uv-${{ matrix.python-version }}-${{ 
hashFiles('**/pyproject.toml', 'uv.lock') }}
-        restore-keys: |
-          ${{ runner.os }}-uv-${{ matrix.python-version }}-
-
-    - name: Install dependencies
+        SKIP_EXTERNAL_SERVICES: true
       run: |
-        uv sync --extra llm --extra dev
-        uv run python -c "import nltk; nltk.download('stopwords'); 
nltk.download('punkt')"
+        uv run pytest src/tests/config/ src/tests/document/ 
src/tests/middleware/ src/tests/operators/ src/tests/models/ src/tests/indices/ 
src/tests/test_utils.py -v --tb=short

Review Comment:
   ‼️ The new CI command no longer runs the API/flow/node tests that cover this 
PR's main behavior. The PR adds or rewrites `/graph/extract`, jobs, 
`/graph/import`, and extract-and-import tests under `src/tests/api`, plus 
related flow/node/utils coverage, but this job only runs 
config/document/middleware/operators/models/indices/test_utils. Please restore 
an equivalent full unit/contract lane, or at least include `src/tests/api 
src/tests/flows src/tests/nodes src/tests/utils`, so these API regressions 
cannot pass with green checks.



##########
hugegraph-llm/src/hugegraph_llm/api/models/graph_extract_requests.py:
##########
@@ -16,96 +16,183 @@
 # under the License.
 
 import json
+from copy import deepcopy
 from typing import Any, Dict, List, Literal, Optional, Union
 
-from fastapi import Query
 from pydantic import BaseModel, ConfigDict, Field, field_validator, 
model_validator
 
+from hugegraph_llm.config import llm_settings
+from hugegraph_llm.operators.common_op.check_schema import CheckSchema
+from hugegraph_llm.utils.schema_property import is_schema_property_value
+
+SchemaInput = Union[str, Dict[str, Any]]
+ContentInput = Union[str, List[str]]
+REQUIRED_VERTEX_KEYS = {"label", "properties"}
+REQUIRED_EDGE_KEYS = {"label", "outV", "outVLabel", "inV", "inVLabel", 
"properties"}
+
+
+def _validate_schema_value(schema: SchemaInput) -> SchemaInput:
+    if isinstance(schema, dict):
+        if not schema:
+            raise ValueError("schema must not be an empty object")
+        CheckSchema(deepcopy(schema)).run()
+        return schema
+
+    schema_text = str(schema).strip()
+    if not schema_text:
+        raise ValueError("schema must not be empty")
+    if schema_text.startswith("{"):
+        try:
+            parsed_schema = json.loads(schema_text)
+        except json.JSONDecodeError as exc:
+            raise ValueError(f"schema must be valid JSON: {exc.msg}") from exc
+        if not isinstance(parsed_schema, dict) or not parsed_schema:
+            raise ValueError("schema JSON must be a non-empty object")
+        CheckSchema(deepcopy(parsed_schema)).run()
+    return schema_text
+
+
+def _schema_object(schema: SchemaInput) -> Optional[Dict[str, Any]]:
+    if isinstance(schema, dict):
+        return schema
+    schema_text = str(schema).strip()
+    if not schema_text.startswith("{"):
+        return None
+    try:
+        parsed_schema = json.loads(schema_text)
+    except json.JSONDecodeError:
+        return None
+    return parsed_schema if isinstance(parsed_schema, dict) else None
+
+
+class GraphExtractOptions(BaseModel):
+    include_meta: bool = Field(default=False, description="Whether to include 
response metadata.")
+    include_warnings: bool = Field(default=True, description="Whether to 
include extraction warnings.")
+
+
+class GraphImportOptions(BaseModel):
+    update_vid_embeddings: bool = Field(default=False, description="Whether to 
rebuild vid embeddings after import.")
+
 
 class GraphExtractClientConfig(BaseModel):
     model_config = ConfigDict(extra="forbid")
 
-    graph: Optional[str] = None
-    user: Optional[str] = None
-    pwd: Optional[str] = None
-    gs: Optional[str] = None
+    graph: Optional[str] = Field(default=None, description="HugeGraph graph 
name.")
+    user: Optional[str] = Field(default=None, description="HugeGraph user.")
+    pwd: Optional[str] = Field(default=None, description="HugeGraph password.")
+    gs: Optional[str] = Field(default=None, description="HugeGraph 
graphspace.")
+
+    @field_validator("graph", "user", "pwd", "gs", mode="before")
+    @classmethod
+    def blank_strings_to_none(cls, value):
+        if isinstance(value, str) and not value.strip():
+            return None
+        return value
 
 
 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.",
+    content_type: Literal["text", "chunks"] = Field(
+        default="text", description="Whether content is raw text or chunks."
     )
-    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()]
+    content: Optional[ContentInput] = Field(default=None, description="Raw 
document text or pre-split chunks.")
+    texts: Optional[ContentInput] = Field(default=None, 
description="Deprecated alias for text or chunk content.")
+    schema_data: SchemaInput = Field(..., alias="schema", description="Graph 
schema JSON object/string, or graph name.")

Review Comment:
   ⚠️ This renames the model input field from `graph_schema` to `schema_data` 
while keeping only `schema` as the alias. In the previous model, 
`populate_by_name=True` made `GraphExtractRequest(graph_schema=...)` a valid 
Python-side contract; now `graph_schema` is only a read-only property and old 
callers will fail with a missing `schema`. Please keep `graph_schema` as an 
accepted validation alias, or add an explicit compatibility test and migration 
note.



##########
hugegraph-llm/src/hugegraph_llm/services/graph_extract_service.py:
##########
@@ -0,0 +1,393 @@
+# 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
+import time
+from typing import Any, Dict, List, Optional
+
+from hugegraph_llm.api.models.graph_extract_requests import (
+    GraphExtractAndImportRequest,
+    GraphExtractRequest,
+    GraphImportRequest,
+    SchemaInput,
+    _validate_schema_value,
+)
+from hugegraph_llm.api.models.graph_extract_responses import (
+    GraphExtractResponse,
+    GraphImportResponse,
+)
+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
+
+SENSITIVE_CLIENT_CONFIG_KEYS = {"pwd", "password", "token", "api_key", 
"secret"}
+SAFE_IMPORT_ERROR_KEYS = {"kind", "index", "label", "key", "reason"}
+
+
+class FlowOutputValidationError(ValueError):
+    """Raised when a workflow returns malformed output."""
+
+
+def normalize_schema(schema: SchemaInput) -> str:
+    schema = _validate_schema_value(schema)
+    if isinstance(schema, dict):
+        return json.dumps(schema, ensure_ascii=False)
+
+    schema_text = str(schema).strip()
+    if schema_text.startswith("{"):
+        try:
+            parsed_schema = json.loads(schema_text)
+        except json.JSONDecodeError as exc:
+            raise ValueError(f"schema must be valid JSON: {exc.msg}") from exc
+        return json.dumps(parsed_schema, ensure_ascii=False)
+    return schema_text
+
+
+def _redact_client_config(client_config) -> Dict[str, Any]:
+    if client_config is None:
+        return {}
+    config = client_config if isinstance(client_config, dict) else 
client_config.model_dump(exclude_none=True)
+    return {key: ("***" if key in SENSITIVE_CLIENT_CONFIG_KEYS and value else 
value) for key, value in config.items()}
+
+
+def _schema_graph_name(schema: str) -> Optional[str]:
+    schema_text = str(schema).strip()
+    return None if schema_text.startswith("{") else schema_text
+
+
+def apply_client_config(
+    client_config,
+    schema: Optional[str] = None,
+    align_graph_with_schema: bool = False,
+) -> Optional[Dict[str, Any]]:
+    if client_config is None:
+        config = {}
+    elif isinstance(client_config, dict):
+        config = {key: value for key, value in client_config.items() if value 
is not None}
+    else:
+        config = client_config.model_dump(exclude_none=True)
+    schema_graph = _schema_graph_name(schema) if schema else None
+    if schema_graph:
+        target_graph = config.get("graph")
+        if target_graph and target_graph != schema_graph:
+            raise ValueError("schema graph name must match 
client_config.graph")
+        if align_graph_with_schema:
+            config["graph"] = schema_graph
+    return config or None
+
+
+def _parse_flow_json(raw_result: Any, error_message: str) -> Dict[str, Any]:
+    if isinstance(raw_result, dict):
+        return raw_result
+    if not isinstance(raw_result, str):
+        raise FlowOutputValidationError(error_message)
+    try:
+        parsed = json.loads(raw_result)
+    except json.JSONDecodeError as exc:
+        raise FlowOutputValidationError(error_message) from exc
+    if not isinstance(parsed, dict):
+        raise FlowOutputValidationError(error_message)
+    return parsed
+
+
+def _pop_warnings(result: Dict[str, Any]) -> List[str]:
+    warnings = []
+    warning = result.pop("warning", None)
+    if warning:
+        warnings.append(str(warning))
+    extra_warnings = result.pop("warnings", None)
+    if isinstance(extra_warnings, list):
+        warnings.extend(str(item) for item in extra_warnings)
+    elif extra_warnings:
+        warnings.append(str(extra_warnings))
+    return warnings
+
+
+def _count_items(result: Dict[str, Any], key: str) -> int:
+    value = result.get(key)
+    return len(value) if isinstance(value, list) else 0
+
+
+def _validate_property_graph_result(result: Dict[str, Any]) -> None:
+    vertices = result.get("vertices", [])
+    edges = result.get("edges", [])
+    if not isinstance(vertices, list) or not isinstance(edges, list):
+        raise FlowOutputValidationError("property graph result must contain 
list vertices and edges")
+    for index, vertex in enumerate(vertices):
+        if not isinstance(vertex, dict) or "label" not in vertex or 
"properties" not in vertex:
+            raise FlowOutputValidationError("canonical property graph vertex 
must include label and properties")
+        if not _is_non_empty_string(vertex["label"]):
+            raise FlowOutputValidationError(
+                f"canonical property graph vertex[{index}].label must be a 
non-empty string"
+            )
+        if not isinstance(vertex["properties"], dict):
+            raise FlowOutputValidationError(f"canonical property graph 
vertex[{index}].properties must be an object")
+    required_edge_keys = {"label", "outV", "outVLabel", "inV", "inVLabel", 
"properties"}
+    for index, edge in enumerate(edges):
+        if not isinstance(edge, dict) or not required_edge_keys.issubset(edge):
+            raise FlowOutputValidationError(
+                "canonical property graph edge must include label, outV, 
outVLabel, inV, inVLabel, and properties"
+            )
+        for key in ("label", "outV", "outVLabel", "inV", "inVLabel"):
+            if not _is_non_empty_string(edge[key]):
+                raise FlowOutputValidationError(
+                    f"canonical property graph edge[{index}].{key} must be a 
non-empty string"
+                )
+        if not isinstance(edge["properties"], dict):
+            raise FlowOutputValidationError(f"canonical property graph 
edge[{index}].properties must be an object")
+
+
+def _is_non_empty_string(value: Any) -> bool:
+    return isinstance(value, str) and bool(value.strip())
+
+
+def _build_import_status(import_result: Dict[str, Any]) -> str:
+    skipped = (
+        import_result.get("vertices_skipped", 0)
+        + import_result.get("edges_skipped", 0)
+        + import_result.get("triples_skipped", 0)
+    )
+    created = (
+        import_result.get("vertices_created", 0)
+        + import_result.get("edges_created", 0)
+        + import_result.get("triples_created", 0)
+    )
+    if skipped and created:
+        return "partial"
+    if skipped and not created:
+        return "failed"
+    return "succeeded"
+
+
+def _sanitize_import_error(error: Any) -> Dict[str, Any]:
+    if not isinstance(error, dict):
+        return {"kind": "import", "reason": "import_error"}
+    sanitized = {
+        key: value for key, value in error.items() if key in 
SAFE_IMPORT_ERROR_KEYS and isinstance(value, (str, int))
+    }
+    return sanitized or {"kind": "import", "reason": "import_error"}
+
+
+def _format_import_warning(error: Dict[str, Any]) -> str:
+    kind = error.get("kind", "import")
+    reason = error.get("reason", "import_error")
+    parts = ["import error" if kind == "import" else f"{kind} import error"]
+    if "index" in error:
+        parts.append(f"index={error['index']}")
+    if "label" in error:
+        parts.append(f"label={error['label']}")
+    if "key" in error:
+        parts.append(f"key={error['key']}")
+    parts.append(f"reason={reason}")
+    return " ".join(parts)
+
+
+class GraphExtractService:
+    def __init__(self, scheduler=None):
+        self._scheduler = scheduler
+
+    @property
+    def scheduler(self):
+        return self._scheduler or SchedulerSingleton.get_instance()
+
+    def extract_sync(self, request: GraphExtractRequest) -> 
GraphExtractResponse:
+        started = time.perf_counter()
+        schema = normalize_schema(request.schema)
+        extract_client_config = request.client_config if 
_schema_graph_name(schema) else None
+        client_config_meta = 
_redact_client_config(apply_client_config(extract_client_config, schema=schema))
+        example_prompt = request.example_prompt or prompt.extract_graph_prompt
+        try:
+            raw_result = self.scheduler.schedule_flow(
+                FlowName.GRAPH_EXTRACT,
+                schema,
+                request.texts,
+                example_prompt,
+                request.extract_type,
+                language=request.language,
+                split_type=request.split_type,
+                client_config=extract_client_config,
+                content_type=request.content_type,
+                max_parallel_chunks=request.max_parallel_chunks,
+            )
+        except Exception:
+            log.exception("Graph extraction failed during scheduler execution")
+            raise
+
+        parsed_result = _parse_flow_json(raw_result, "Invalid graph extraction 
flow JSON")
+        warnings = _pop_warnings(parsed_result)
+        result = self._build_result(parsed_result, request.extract_type)
+        if not request.options.include_warnings:
+            warnings = []
+        meta = (
+            self._build_extract_meta(request, parsed_result, result, started, 
client_config_meta)
+            if request.options.include_meta
+            else {}
+        )
+        return GraphExtractResponse(status="succeeded", result=result, 
warnings=warnings, meta=meta)
+
+    def _build_result(self, parsed_result: Dict[str, Any], extract_type: str) 
-> Dict[str, Any]:
+        if extract_type == "triples":
+            triples = parsed_result.get("triples")
+            if not triples:
+                triples = 
self._legacy_edges_to_triples(parsed_result.get("edges", []))
+            return {"triples": triples}
+        result = {
+            "vertices": parsed_result.get("vertices", []),
+            "edges": parsed_result.get("edges", []),
+        }
+        _validate_property_graph_result(result)
+        return result
+
+    def _legacy_edges_to_triples(self, edges: Any) -> List[Dict[str, Any]]:
+        if not isinstance(edges, list):
+            return []
+        triples = []
+        for edge in edges:
+            if not isinstance(edge, dict):
+                continue
+            start = edge.get("start", edge.get("outV"))
+            end = edge.get("end", edge.get("inV"))
+            edge_type = edge.get("type", edge.get("label"))
+            if start is not None and end is not None and edge_type is not None:
+                triples.append({"start": start, "type": edge_type, "end": end})
+        return triples
+
+    def _build_extract_meta(
+        self,
+        request: GraphExtractRequest,
+        parsed_result: Dict[str, Any],
+        result: Dict[str, Any],
+        started: float,
+        client_config_meta: Dict[str, Any],
+    ) -> Dict[str, Any]:
+        chunk_count = parsed_result.get("chunk_count")
+        if chunk_count is None:
+            chunk_count = (
+                len(request.texts)
+                if request.content_type == "chunks" and 
isinstance(request.texts, (list, tuple))
+                else parsed_result.get("call_count")
+            )
+        max_parallel_chunks = parsed_result.get("max_parallel_chunks")
+        if max_parallel_chunks is None:
+            max_parallel_chunks = (
+                min(request.max_parallel_chunks, chunk_count)
+                if isinstance(chunk_count, int) and chunk_count >= 0
+                else request.max_parallel_chunks
+            )
+
+        meta = {
+            "extract_type": request.extract_type,
+            "content_type": request.content_type,
+            "language": request.language,
+            "split_type": request.split_type,
+            "text_count": 1 if request.content_type == "text" else 0,
+            "chunk_count": chunk_count,
+            "max_parallel_chunks": max_parallel_chunks,
+            "vertex_count": _count_items(result, "vertices"),
+            "edge_count": _count_items(result, "edges"),
+            "triple_count": _count_items(result, "triples"),
+            "call_count": parsed_result.get("call_count"),
+            "duration_ms": int((time.perf_counter() - started) * 1000),
+        }
+        if client_config_meta:
+            meta["client_config"] = client_config_meta
+        return meta
+
+
+class GraphImportService:
+    def __init__(self, scheduler=None):
+        self._scheduler = scheduler
+
+    @property
+    def scheduler(self):
+        return self._scheduler or SchedulerSingleton.get_instance()
+
+    def import_graph(self, request: GraphImportRequest) -> GraphImportResponse:
+        if not request.write_to_graph:
+            raise ValueError("write_to_graph must be True to confirm graph 
import")
+
+        started = time.perf_counter()
+        schema = normalize_schema(request.schema)
+        graph_config = apply_client_config(request.client_config, 
schema=schema, align_graph_with_schema=True)
+        client_config_meta = _redact_client_config(graph_config)
+        try:
+            raw_result = self.scheduler.schedule_flow(
+                FlowName.IMPORT_GRAPH_DATA,
+                request.data,
+                schema,
+                graph_config=graph_config,
+            )
+        except Exception:
+            log.exception("Graph import failed during scheduler execution")
+            raise
+
+        parsed_result = _parse_flow_json(raw_result, "Invalid graph import 
flow JSON")
+        warnings = _pop_warnings(parsed_result)
+        import_result = parsed_result.get("import_result")

Review Comment:
   ‼️ Missing or malformed `import_result` is treated as a successful import 
below. If the import flow returns `{}` or the commit node fails to populate 
`import_result`, this branch reports `status="succeeded"` and counts the 
requested input items, even though there is no evidence that anything was 
written. Please validate that `import_result` is present and well-formed, 
otherwise raise `FlowOutputValidationError`, and add a regression test for 
scheduler output without `import_result`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to