Copilot commented on code in PR #361:
URL: https://github.com/apache/hugegraph-ai/pull/361#discussion_r3378892619
##########
hugegraph-llm/src/hugegraph_llm/operators/llm_op/property_graph_extract.py:
##########
@@ -91,15 +98,28 @@ def run(self, context: Dict[str, Any]) -> Dict[str,
List[Any]]:
if "edges" not in context:
context["edges"] = []
items = []
- for chunk in chunks:
- proceeded_chunk = self.extract_property_graph_by_llm(schema, chunk)
+ try:
+ max_parallel_chunks = max(1,
int(context.get("max_parallel_chunks") or self.max_parallel_chunks))
+ except (TypeError, ValueError):
+ max_parallel_chunks = max(1, self.max_parallel_chunks)
+ chunk_count = len(chunks)
+ worker_count = min(max_parallel_chunks, chunk_count)
+ context["max_parallel_chunks"] = worker_count
+ if worker_count <= 1:
+ proceeded_chunks = [self.extract_property_graph_by_llm(schema,
chunk) for chunk in chunks]
+ else:
+ with ThreadPoolExecutor(max_workers=worker_count) as executor:
+ proceeded_chunks = list(
+ executor.map(lambda chunk:
self.extract_property_graph_by_llm(schema, chunk), chunks)
+ )
Review Comment:
`worker_count` becomes `0` when `chunks` is empty, and the code stores
`context["max_parallel_chunks"] = 0`. This conflicts with the rest of the
codebase/API contract where parallelism is treated as `>= 1`, and can produce
confusing metadata downstream. Consider either early-returning when
`chunk_count == 0` (and keeping `max_parallel_chunks` unset/None), or setting
`context["max_parallel_chunks"]` to `max(1, worker_count)` when there is any
work to do.
##########
hugegraph-llm/src/hugegraph_llm/api/graph_extract_api.py:
##########
@@ -15,55 +15,270 @@
# specific language governing permissions and limitations
# under the License.
-import json
+from typing import Optional
-from fastapi import APIRouter, HTTPException, status
+from fastapi import APIRouter, HTTPException, Request, status
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+from fastapi.routing import APIRoute
-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.api.models.graph_extract_requests import (
+ GraphExtractAndImportRequest,
+ GraphExtractRequest,
+ GraphImportRequest,
+)
+from hugegraph_llm.api.models.graph_extract_responses import (
+ GraphExtractAndImportResponse,
+ GraphExtractError,
+ GraphExtractJobCreateResponse,
+ GraphExtractJobStatusResponse,
+ GraphExtractResponse,
+ GraphImportResponse,
+)
+from hugegraph_llm.services.graph_extract_jobs import (
+ GraphExtractJob,
+ GraphExtractJobStatus,
+ InMemoryGraphExtractJobStore,
+)
+from hugegraph_llm.services.graph_extract_service import GraphExtractService,
GraphImportService
from hugegraph_llm.utils.log import log
-class GraphExtractService:
- @staticmethod
- def extract_sync(req: GraphExtractRequest) -> GraphExtractResponse:
+def _error(code: str, message: str, phase: str, job_id: Optional[str] = None)
-> dict:
+ return GraphExtractError(code=code, message=message, phase=phase,
job_id=job_id).model_dump(exclude_none=True)
+
+
+def _job_ts(value) -> Optional[str]:
+ return value.isoformat() if value else None
+
+
+def _job_status_response(job: GraphExtractJob) ->
GraphExtractJobStatusResponse:
+ return GraphExtractJobStatusResponse(
+ job_id=job.job_id,
+ status=job.status,
+ created_at=_job_ts(job.created_at),
+ updated_at=_job_ts(job.updated_at),
+ started_at=_job_ts(job.started_at),
+ finished_at=_job_ts(job.finished_at),
+ expires_at=_job_ts(job.expires_at),
+ error=job.error,
+ )
+
+
+class GraphExtractAPIRoute(APIRoute):
+ def get_route_handler(self):
+ original_route_handler = super().get_route_handler()
+
+ async def custom_route_handler(request: Request):
+ try:
+ return await original_route_handler(request)
+ except RequestValidationError as exc:
+ return JSONResponse(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ content={
+ "detail": _error(
+ "GRAPH_EXTRACT_VALIDATION_ERROR",
+ str(exc),
+ "request",
+ )
+ },
+ )
+
+ return custom_route_handler
+
+
+def graph_extract_http_api(
+ router: APIRouter,
+ service=None,
+ job_store=None,
+ import_service=None,
+ run_jobs_inline: Optional[bool] = None,
+):
+ extract_service = service or GraphExtractService()
+ graph_import_service = import_service or GraphImportService()
+ jobs = job_store or InMemoryGraphExtractJobStore()
+ original_route_class = router.route_class
+ router.route_class = GraphExtractAPIRoute
+
+ @router.post("/graph/extract", status_code=status.HTTP_200_OK,
response_model=GraphExtractResponse)
+ def graph_extract_api(req: GraphExtractRequest) -> GraphExtractResponse:
+ try:
+ return extract_service.extract_sync(req)
+ except ValueError as exc:
+ log.error("Graph extraction request failed validation: %s", exc)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=_error("GRAPH_EXTRACT_INVALID_FLOW_OUTPUT", str(exc),
"extract"),
+ ) from exc
+ except Exception as exc:
+ log.error("Unexpected graph extraction error: %s", exc,
exc_info=True)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=_error("GRAPH_EXTRACT_FAILED", str(exc), "extract"),
+ ) from exc
+
+ @router.post("/graph/extract/jobs", status_code=status.HTTP_202_ACCEPTED)
+ def create_graph_extract_job(
+ req: GraphExtractRequest,
+ ) -> GraphExtractJobCreateResponse:
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,
+ job = jobs.create(req)
+ except ValueError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+ detail=_error("GRAPH_EXTRACT_JOB_LIMIT_EXCEEDED", str(exc),
"job"),
+ ) from exc
+ if run_jobs_inline is True:
+ jobs.run_job(job.job_id, extract_service)
+ elif run_jobs_inline is None:
+ try:
+ jobs.submit_job(job.job_id, extract_service)
+ except ValueError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+ detail=_error("GRAPH_EXTRACT_JOB_QUEUE_FULL", str(exc),
"job", job.job_id),
+ ) from exc
+ return GraphExtractJobCreateResponse(
+ job_id=job.job_id,
+ status=job.status,
+ result_url=f"/graph/extract/jobs/{job.job_id}/result",
+ created_at=_job_ts(job.created_at),
+ updated_at=_job_ts(job.updated_at),
+ )
+
+ @router.get("/graph/extract/jobs/{job_id}", status_code=status.HTTP_200_OK)
+ def get_graph_extract_job(job_id: str) -> GraphExtractJobStatusResponse:
+ jobs.expire_jobs()
+ job = jobs.get(job_id)
+ if job is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=_error("GRAPH_EXTRACT_JOB_NOT_FOUND", f"Job {job_id}
was not found", "job", job_id),
+ )
+ return _job_status_response(job)
+
+ @router.get("/graph/extract/jobs/{job_id}/result",
status_code=status.HTTP_200_OK)
+ def get_graph_extract_job_result(job_id: str) -> GraphExtractResponse:
+ jobs.expire_jobs()
+ job = jobs.get(job_id)
+ if job is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=_error("GRAPH_EXTRACT_JOB_NOT_FOUND", f"Job {job_id}
was not found", "job", job_id),
+ )
+ if job.status == GraphExtractJobStatus.EXPIRED:
+ raise HTTPException(
+ status_code=status.HTTP_410_GONE,
+ detail=_error("GRAPH_EXTRACT_JOB_EXPIRED", f"Job {job_id}
result has expired", "job", job_id),
)
- 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)
+ if job.status in {GraphExtractJobStatus.PENDING,
GraphExtractJobStatus.RUNNING}:
+ raise HTTPException(
+ status_code=status.HTTP_202_ACCEPTED,
+ detail=_error(
+ "GRAPH_EXTRACT_JOB_NOT_COMPLETE",
+ f"Job {job_id} is not complete",
+ "job",
+ job_id,
+ ),
+ )
+ if job.status == GraphExtractJobStatus.CANCELLED:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=_error("GRAPH_EXTRACT_JOB_CANCELLED", f"Job {job_id}
was cancelled", "job", job_id),
+ )
+ if job.status == GraphExtractJobStatus.FAILED:
+ raise
HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=job.error.model_dump())
+ if job.result is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="An unexpected error occurred during graph extraction.",
- ) from e
+ detail=_error(
+ "GRAPH_EXTRACT_JOB_RESULT_MISSING", f"Job {job_id}
finished without a result", "job", job_id
+ ),
+ )
+ return job.result
+ @router.delete("/graph/extract/jobs/{job_id}",
status_code=status.HTTP_200_OK)
+ def cancel_graph_extract_job(job_id: str) -> GraphExtractJobStatusResponse:
+ job = jobs.cancel(job_id)
+ if job is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=_error("GRAPH_EXTRACT_JOB_NOT_FOUND", f"Job {job_id}
was not found", "job", job_id),
+ )
+ if job.status == GraphExtractJobStatus.RUNNING:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=_error(
+ "GRAPH_EXTRACT_JOB_NOT_CANCELLABLE",
+ f"Job {job_id} is already running and cannot be
interrupted",
+ "job",
+ job_id,
+ ),
+ )
+ return _job_status_response(job)
-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)
+ @router.post("/graph/import", status_code=status.HTTP_200_OK)
+ def graph_import_api(req: GraphImportRequest) -> GraphImportResponse:
Review Comment:
These endpoints don’t declare `response_model=...` in the decorator, which
means FastAPI won’t apply response-model validation/serialization guarantees or
generate as precise OpenAPI output as it could. Consider adding
`response_model=GraphImportResponse` and
`response_model=GraphExtractAndImportResponse` (and similarly for job
endpoints) to keep the API contract explicit and consistent with
`/graph/extract`.
##########
hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/commit_to_hugegraph.py:
##########
@@ -27,31 +27,51 @@
class Commit2Graph:
- def __init__(self):
+ def __init__(self, graph_config=None):
+ graph_config = graph_config or {}
self.client = PyHugeClient(
- url=huge_settings.graph_url,
- graph=huge_settings.graph_name,
- user=huge_settings.graph_user,
- pwd=huge_settings.graph_pwd,
- graphspace=huge_settings.graph_space,
+ url=graph_config.get("url") or huge_settings.graph_url,
+ graph=graph_config.get("graph") or huge_settings.graph_name,
+ user=graph_config.get("user") or huge_settings.graph_user,
+ pwd=graph_config.get("pwd") or huge_settings.graph_pwd,
+ graphspace=graph_config.get("gs") or huge_settings.graph_space,
)
self.schema = self.client.schema()
+ def _empty_import_result(self, vertices=None, edges=None, triples=None) ->
Dict[str, Any]:
+ return {
+ "vertices_attempted": len(vertices or []),
+ "vertices_created": 0,
+ "vertices_skipped": 0,
+ "edges_attempted": len(edges or []),
+ "edges_created": 0,
+ "edges_skipped": 0,
+ "triples_attempted": len(triples or []),
+ "triples_created": 0,
+ "triples_skipped": 0,
+ "errors": [],
+ }
+
def run(self, data: dict) -> Dict[str, Any]:
schema = data.get("schema")
- vertices = data.get("vertices", [])
- edges = data.get("edges", [])
- if not vertices and not edges:
+ vertices = data.get("vertices", []) or []
+ edges = data.get("edges", []) or []
+ triples = data.get("triples", []) or []
+ if not vertices and not edges and not triples:
log.critical("(Loading) Both vertices and edges are empty. Please
check the input data again.")
raise ValueError("Both vertices and edges input are empty.")
Review Comment:
The raised/logged message says “Both vertices and edges are empty” but the
preceding condition also checks `triples`, and the schema-present branch
performs a second (slightly different) emptiness check that ignores `triples`.
To reduce confusion for callers (and avoid duplicated logic), consider
consolidating these checks and updating the message to reflect the actual
accepted inputs (e.g., “vertices/edges/triples are empty” or “property-graph
vertices/edges required when schema is provided”).
##########
hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/commit_to_hugegraph.py:
##########
@@ -27,31 +27,51 @@
class Commit2Graph:
- def __init__(self):
+ def __init__(self, graph_config=None):
+ graph_config = graph_config or {}
self.client = PyHugeClient(
- url=huge_settings.graph_url,
- graph=huge_settings.graph_name,
- user=huge_settings.graph_user,
- pwd=huge_settings.graph_pwd,
- graphspace=huge_settings.graph_space,
+ url=graph_config.get("url") or huge_settings.graph_url,
+ graph=graph_config.get("graph") or huge_settings.graph_name,
+ user=graph_config.get("user") or huge_settings.graph_user,
+ pwd=graph_config.get("pwd") or huge_settings.graph_pwd,
+ graphspace=graph_config.get("gs") or huge_settings.graph_space,
)
self.schema = self.client.schema()
+ def _empty_import_result(self, vertices=None, edges=None, triples=None) ->
Dict[str, Any]:
+ return {
+ "vertices_attempted": len(vertices or []),
+ "vertices_created": 0,
+ "vertices_skipped": 0,
+ "edges_attempted": len(edges or []),
+ "edges_created": 0,
+ "edges_skipped": 0,
+ "triples_attempted": len(triples or []),
+ "triples_created": 0,
+ "triples_skipped": 0,
+ "errors": [],
+ }
+
def run(self, data: dict) -> Dict[str, Any]:
schema = data.get("schema")
- vertices = data.get("vertices", [])
- edges = data.get("edges", [])
- if not vertices and not edges:
+ vertices = data.get("vertices", []) or []
+ edges = data.get("edges", []) or []
+ triples = data.get("triples", []) or []
+ if not vertices and not edges and not triples:
log.critical("(Loading) Both vertices and edges are empty. Please
check the input data again.")
raise ValueError("Both vertices and edges input are empty.")
if not schema:
# TODO: ensure the function works correctly (update the logic
later)
- self.schema_free_mode(data.get("triples", []))
+ import_result = self.schema_free_mode(triples)
log.warning("Using schema_free mode, could try schema_define mode
for better effect!")
else:
+ if not vertices and not edges:
+ log.critical("(Loading) Both vertices and edges are empty.
Please check the input data again.")
+ raise ValueError("Both vertices and edges input are empty.")
Review Comment:
The raised/logged message says “Both vertices and edges are empty” but the
preceding condition also checks `triples`, and the schema-present branch
performs a second (slightly different) emptiness check that ignores `triples`.
To reduce confusion for callers (and avoid duplicated logic), consider
consolidating these checks and updating the message to reflect the actual
accepted inputs (e.g., “vertices/edges/triples are empty” or “property-graph
vertices/edges required when schema is provided”).
##########
hugegraph-llm/src/hugegraph_llm/api/graph_extract_api.py:
##########
@@ -15,55 +15,270 @@
# specific language governing permissions and limitations
# under the License.
-import json
+from typing import Optional
-from fastapi import APIRouter, HTTPException, status
+from fastapi import APIRouter, HTTPException, Request, status
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+from fastapi.routing import APIRoute
-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.api.models.graph_extract_requests import (
+ GraphExtractAndImportRequest,
+ GraphExtractRequest,
+ GraphImportRequest,
+)
+from hugegraph_llm.api.models.graph_extract_responses import (
+ GraphExtractAndImportResponse,
+ GraphExtractError,
+ GraphExtractJobCreateResponse,
+ GraphExtractJobStatusResponse,
+ GraphExtractResponse,
+ GraphImportResponse,
+)
+from hugegraph_llm.services.graph_extract_jobs import (
+ GraphExtractJob,
+ GraphExtractJobStatus,
+ InMemoryGraphExtractJobStore,
+)
+from hugegraph_llm.services.graph_extract_service import GraphExtractService,
GraphImportService
from hugegraph_llm.utils.log import log
-class GraphExtractService:
- @staticmethod
- def extract_sync(req: GraphExtractRequest) -> GraphExtractResponse:
+def _error(code: str, message: str, phase: str, job_id: Optional[str] = None)
-> dict:
+ return GraphExtractError(code=code, message=message, phase=phase,
job_id=job_id).model_dump(exclude_none=True)
+
+
+def _job_ts(value) -> Optional[str]:
+ return value.isoformat() if value else None
+
+
+def _job_status_response(job: GraphExtractJob) ->
GraphExtractJobStatusResponse:
+ return GraphExtractJobStatusResponse(
+ job_id=job.job_id,
+ status=job.status,
+ created_at=_job_ts(job.created_at),
+ updated_at=_job_ts(job.updated_at),
+ started_at=_job_ts(job.started_at),
+ finished_at=_job_ts(job.finished_at),
+ expires_at=_job_ts(job.expires_at),
+ error=job.error,
+ )
+
+
+class GraphExtractAPIRoute(APIRoute):
+ def get_route_handler(self):
+ original_route_handler = super().get_route_handler()
+
+ async def custom_route_handler(request: Request):
+ try:
+ return await original_route_handler(request)
+ except RequestValidationError as exc:
+ return JSONResponse(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ content={
+ "detail": _error(
+ "GRAPH_EXTRACT_VALIDATION_ERROR",
+ str(exc),
+ "request",
+ )
+ },
+ )
Review Comment:
Using `str(exc)` for request validation errors tends to be verbose and
unstable (it can vary across FastAPI/Pydantic versions) and may not reliably
highlight which field failed in a user-friendly way. A more actionable error
payload would derive the message from `exc.errors()` (e.g., include `loc`,
`msg`, and `type`) and format it into a predictable structure.
##########
hugegraph-llm/src/hugegraph_llm/operators/llm_op/property_graph_extract.py:
##########
@@ -91,15 +98,28 @@ def run(self, context: Dict[str, Any]) -> Dict[str,
List[Any]]:
if "edges" not in context:
context["edges"] = []
items = []
- for chunk in chunks:
- proceeded_chunk = self.extract_property_graph_by_llm(schema, chunk)
+ try:
+ max_parallel_chunks = max(1,
int(context.get("max_parallel_chunks") or self.max_parallel_chunks))
+ except (TypeError, ValueError):
+ max_parallel_chunks = max(1, self.max_parallel_chunks)
+ chunk_count = len(chunks)
+ worker_count = min(max_parallel_chunks, chunk_count)
+ context["max_parallel_chunks"] = worker_count
+ if worker_count <= 1:
+ proceeded_chunks = [self.extract_property_graph_by_llm(schema,
chunk) for chunk in chunks]
+ else:
+ with ThreadPoolExecutor(max_workers=worker_count) as executor:
+ proceeded_chunks = list(
+ executor.map(lambda chunk:
self.extract_property_graph_by_llm(schema, chunk), chunks)
+ )
Review Comment:
Parallelizing `extract_property_graph_by_llm()` via threads can be unsafe if
the underlying `llm` client maintains mutable shared state (e.g., shared
session objects, retry/backoff counters, streaming buffers). If the LLM client
is not explicitly thread-safe, this can lead to intermittent failures or
cross-talk between requests. A tangible mitigation is to serialize calls behind
a lock (while still parallelizing JSON parsing/normalization), or ensure each
worker uses an isolated client instance (e.g., thread-local client
construction).
##########
hugegraph-llm/src/hugegraph_llm/api/graph_extract_api.py:
##########
@@ -15,55 +15,270 @@
# specific language governing permissions and limitations
# under the License.
-import json
+from typing import Optional
-from fastapi import APIRouter, HTTPException, status
+from fastapi import APIRouter, HTTPException, Request, status
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+from fastapi.routing import APIRoute
-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.api.models.graph_extract_requests import (
+ GraphExtractAndImportRequest,
+ GraphExtractRequest,
+ GraphImportRequest,
+)
+from hugegraph_llm.api.models.graph_extract_responses import (
+ GraphExtractAndImportResponse,
+ GraphExtractError,
+ GraphExtractJobCreateResponse,
+ GraphExtractJobStatusResponse,
+ GraphExtractResponse,
+ GraphImportResponse,
+)
+from hugegraph_llm.services.graph_extract_jobs import (
+ GraphExtractJob,
+ GraphExtractJobStatus,
+ InMemoryGraphExtractJobStore,
+)
+from hugegraph_llm.services.graph_extract_service import GraphExtractService,
GraphImportService
from hugegraph_llm.utils.log import log
-class GraphExtractService:
- @staticmethod
- def extract_sync(req: GraphExtractRequest) -> GraphExtractResponse:
+def _error(code: str, message: str, phase: str, job_id: Optional[str] = None)
-> dict:
+ return GraphExtractError(code=code, message=message, phase=phase,
job_id=job_id).model_dump(exclude_none=True)
+
+
+def _job_ts(value) -> Optional[str]:
+ return value.isoformat() if value else None
+
+
+def _job_status_response(job: GraphExtractJob) ->
GraphExtractJobStatusResponse:
+ return GraphExtractJobStatusResponse(
+ job_id=job.job_id,
+ status=job.status,
+ created_at=_job_ts(job.created_at),
+ updated_at=_job_ts(job.updated_at),
+ started_at=_job_ts(job.started_at),
+ finished_at=_job_ts(job.finished_at),
+ expires_at=_job_ts(job.expires_at),
+ error=job.error,
+ )
+
+
+class GraphExtractAPIRoute(APIRoute):
+ def get_route_handler(self):
+ original_route_handler = super().get_route_handler()
+
+ async def custom_route_handler(request: Request):
+ try:
+ return await original_route_handler(request)
+ except RequestValidationError as exc:
+ return JSONResponse(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ content={
+ "detail": _error(
+ "GRAPH_EXTRACT_VALIDATION_ERROR",
+ str(exc),
+ "request",
+ )
+ },
+ )
+
+ return custom_route_handler
+
+
+def graph_extract_http_api(
+ router: APIRouter,
+ service=None,
+ job_store=None,
+ import_service=None,
+ run_jobs_inline: Optional[bool] = None,
+):
+ extract_service = service or GraphExtractService()
+ graph_import_service = import_service or GraphImportService()
+ jobs = job_store or InMemoryGraphExtractJobStore()
+ original_route_class = router.route_class
+ router.route_class = GraphExtractAPIRoute
+
+ @router.post("/graph/extract", status_code=status.HTTP_200_OK,
response_model=GraphExtractResponse)
+ def graph_extract_api(req: GraphExtractRequest) -> GraphExtractResponse:
+ try:
+ return extract_service.extract_sync(req)
+ except ValueError as exc:
+ log.error("Graph extraction request failed validation: %s", exc)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=_error("GRAPH_EXTRACT_INVALID_FLOW_OUTPUT", str(exc),
"extract"),
+ ) from exc
+ except Exception as exc:
+ log.error("Unexpected graph extraction error: %s", exc,
exc_info=True)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=_error("GRAPH_EXTRACT_FAILED", str(exc), "extract"),
+ ) from exc
+
+ @router.post("/graph/extract/jobs", status_code=status.HTTP_202_ACCEPTED)
+ def create_graph_extract_job(
+ req: GraphExtractRequest,
+ ) -> GraphExtractJobCreateResponse:
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,
+ job = jobs.create(req)
+ except ValueError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+ detail=_error("GRAPH_EXTRACT_JOB_LIMIT_EXCEEDED", str(exc),
"job"),
+ ) from exc
+ if run_jobs_inline is True:
+ jobs.run_job(job.job_id, extract_service)
+ elif run_jobs_inline is None:
+ try:
+ jobs.submit_job(job.job_id, extract_service)
+ except ValueError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+ detail=_error("GRAPH_EXTRACT_JOB_QUEUE_FULL", str(exc),
"job", job.job_id),
+ ) from exc
+ return GraphExtractJobCreateResponse(
+ job_id=job.job_id,
+ status=job.status,
+ result_url=f"/graph/extract/jobs/{job.job_id}/result",
+ created_at=_job_ts(job.created_at),
+ updated_at=_job_ts(job.updated_at),
+ )
+
+ @router.get("/graph/extract/jobs/{job_id}", status_code=status.HTTP_200_OK)
+ def get_graph_extract_job(job_id: str) -> GraphExtractJobStatusResponse:
+ jobs.expire_jobs()
+ job = jobs.get(job_id)
+ if job is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=_error("GRAPH_EXTRACT_JOB_NOT_FOUND", f"Job {job_id}
was not found", "job", job_id),
+ )
+ return _job_status_response(job)
+
+ @router.get("/graph/extract/jobs/{job_id}/result",
status_code=status.HTTP_200_OK)
+ def get_graph_extract_job_result(job_id: str) -> GraphExtractResponse:
+ jobs.expire_jobs()
+ job = jobs.get(job_id)
+ if job is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=_error("GRAPH_EXTRACT_JOB_NOT_FOUND", f"Job {job_id}
was not found", "job", job_id),
+ )
+ if job.status == GraphExtractJobStatus.EXPIRED:
+ raise HTTPException(
+ status_code=status.HTTP_410_GONE,
+ detail=_error("GRAPH_EXTRACT_JOB_EXPIRED", f"Job {job_id}
result has expired", "job", job_id),
)
- 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)
+ if job.status in {GraphExtractJobStatus.PENDING,
GraphExtractJobStatus.RUNNING}:
+ raise HTTPException(
+ status_code=status.HTTP_202_ACCEPTED,
+ detail=_error(
+ "GRAPH_EXTRACT_JOB_NOT_COMPLETE",
+ f"Job {job_id} is not complete",
+ "job",
+ job_id,
+ ),
+ )
+ if job.status == GraphExtractJobStatus.CANCELLED:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=_error("GRAPH_EXTRACT_JOB_CANCELLED", f"Job {job_id}
was cancelled", "job", job_id),
+ )
+ if job.status == GraphExtractJobStatus.FAILED:
+ raise
HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=job.error.model_dump())
+ if job.result is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="An unexpected error occurred during graph extraction.",
- ) from e
+ detail=_error(
+ "GRAPH_EXTRACT_JOB_RESULT_MISSING", f"Job {job_id}
finished without a result", "job", job_id
+ ),
+ )
+ return job.result
+ @router.delete("/graph/extract/jobs/{job_id}",
status_code=status.HTTP_200_OK)
+ def cancel_graph_extract_job(job_id: str) -> GraphExtractJobStatusResponse:
+ job = jobs.cancel(job_id)
+ if job is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=_error("GRAPH_EXTRACT_JOB_NOT_FOUND", f"Job {job_id}
was not found", "job", job_id),
+ )
+ if job.status == GraphExtractJobStatus.RUNNING:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=_error(
+ "GRAPH_EXTRACT_JOB_NOT_CANCELLABLE",
+ f"Job {job_id} is already running and cannot be
interrupted",
+ "job",
+ job_id,
+ ),
+ )
+ return _job_status_response(job)
-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)
+ @router.post("/graph/import", status_code=status.HTTP_200_OK)
+ def graph_import_api(req: GraphImportRequest) -> GraphImportResponse:
+ if not req.write_to_graph:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=_error(
+ "GRAPH_IMPORT_CONFIRMATION_REQUIRED",
+ "write_to_graph=true is required before writing graph data
to HugeGraph",
+ "import",
+ ),
+ )
+ try:
+ return graph_import_service.import_graph(req)
+ except ValueError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=_error("GRAPH_IMPORT_INVALID_INPUT", str(exc),
"import"),
+ ) from exc
+ except Exception as exc:
+ log.error("Unexpected graph import error: %s", exc, exc_info=True)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=_error("GRAPH_IMPORT_FAILED", str(exc), "import"),
+ ) from exc
+
+ @router.post("/graph/extract-and-import", status_code=status.HTTP_200_OK)
+ def graph_extract_and_import_api(req: GraphExtractAndImportRequest) ->
GraphExtractAndImportResponse:
Review Comment:
These endpoints don’t declare `response_model=...` in the decorator, which
means FastAPI won’t apply response-model validation/serialization guarantees or
generate as precise OpenAPI output as it could. Consider adding
`response_model=GraphImportResponse` and
`response_model=GraphExtractAndImportResponse` (and similarly for job
endpoints) to keep the API contract explicit and consistent with
`/graph/extract`.
--
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]