From fc1dc34695c11f2dede7d2bd6a2ea726815b9b59 Mon Sep 17 00:00:00 2001 From: Josh Bradley Date: Wed, 19 Jun 2024 16:37:44 -0400 Subject: [PATCH] add prompt autogeneration (#9) --- .../graphrag-0.0.1-py3-none-any.whl | Bin 362779 -> 359363 bytes backend/graphrag-wheel/note.txt | 3 +- backend/run-indexing-job.py | 4 - backend/src/aks-batch-job-template.yaml | 4 +- backend/src/api/experimental.py | 2 +- backend/src/api/index.py | 244 +++++++++--------- backend/src/api/index_configuration.py | 94 ++++++- ...e_settings.yaml => pipeline-settings.yaml} | 11 +- backend/src/api/query.py | 4 +- backend/src/models.py | 127 +++++---- docs/DEPLOYMENT-GUIDE.md | 39 ++- docs/DEVELOPMENT-GUIDE.md | 17 -- infra/core/ai-search/ai-search.bicep | 5 +- infra/core/cosmosdb/cosmosdb.bicep | 5 +- infra/core/vnet/private-dns-zone.bicep | 1 - infra/deploy.parameters.json.backup | 12 + infra/deploy.sh | 121 ++++++++- infra/helm/graphrag/values.yaml | 9 +- infra/main.bicep | 37 +-- notebooks/HelloWorld.ipynb | 167 ++++++------ poetry.lock | 105 ++++---- 21 files changed, 604 insertions(+), 407 deletions(-) rename backend/src/api/{pipeline_settings.yaml => pipeline-settings.yaml} (92%) create mode 100644 infra/deploy.parameters.json.backup diff --git a/backend/graphrag-wheel/graphrag-0.0.1-py3-none-any.whl b/backend/graphrag-wheel/graphrag-0.0.1-py3-none-any.whl index 38d7263381482a73c4f4e0d918c8e85641173e24..31302fa6bb5f077fa9c4ba15696ae3246ecaf71f 100644 GIT binary patch delta 29467 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 iznq!1xgy3`<8r>NZME?hLnDhex delta 32840 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
  • O0)Q&r1=VHU~I061ShqW6B+3a3c4){<>5=>W4 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 diff --git a/backend/graphrag-wheel/note.txt b/backend/graphrag-wheel/note.txt index 6bdda64e..769a1d06 100644 --- a/backend/graphrag-wheel/note.txt +++ b/backend/graphrag-wheel/note.txt @@ -2,5 +2,4 @@ This graphrag wheel file was built from the following repo https://github.com/microsoft/graphrag -on commit hash a389514b4723189803097dc0d602b50d139ee92c - +on commit hash 8cb189635e90d49231f3f09b54e69d4daae1371d diff --git a/backend/run-indexing-job.py b/backend/run-indexing-job.py index 26c790ed..a6e52cb4 100644 --- a/backend/run-indexing-job.py +++ b/backend/run-indexing-job.py @@ -9,14 +9,10 @@ parser = argparse.ArgumentParser(description="Kickoff indexing job.") parser.add_argument("-i", "--index-name", required=True) -parser.add_argument("-s", "--storage-name", required=True) -parser.add_argument("-e", "--entity-config", required=False) args = parser.parse_args() asyncio.run( _start_indexing_pipeline( index_name=args.index_name, - storage_name=args.storage_name, - entity_config_name=args.entity_config, ***REMOVED*** ) diff --git a/backend/src/aks-batch-job-template.yaml b/backend/src/aks-batch-job-template.yaml index 5abd0c40..98faaae5 100644 --- a/backend/src/aks-batch-job-template.yaml +++ b/backend/src/aks-batch-job-template.yaml @@ -8,8 +8,8 @@ kind: Job metadata: name: PLACEHOLDER spec: - ttlSecondsAfterFinished: 0 - backoffLimit: 30 + ttlSecondsAfterFinished: 120 + backoffLimit: 6 template: metadata: labels: diff --git a/backend/src/api/experimental.py b/backend/src/api/experimental.py index 848e8916..e0527104 100644 --- a/backend/src/api/experimental.py +++ b/backend/src/api/experimental.py @@ -131,7 +131,7 @@ def stream_response(report_df, query, end_callback=(lambda x: x), timeout=300): this_directory = os.path.dirname( os.path.abspath(inspect.getfile(inspect.currentframe())) ***REMOVED*** - data = yaml.safe_load(open(f"{this_directory***REMOVED***/pipeline_settings.yaml")) + data = yaml.safe_load(open(f"{this_directory***REMOVED***/pipeline-settings.yaml")) # layer the custom settings on top of the default configuration settings of graphrag parameters = create_graphrag_config(data, ".") diff --git a/backend/src/api/index.py b/backend/src/api/index.py index 02010240..5553ea2f 100644 --- a/backend/src/api/index.py +++ b/backend/src/api/index.py @@ -13,9 +13,9 @@ from datashaper import WorkflowCallbacksManager from fastapi import ( APIRouter, - BackgroundTasks, Depends, HTTPException, + UploadFile, ) from graphrag.config import create_graphrag_config from graphrag.index import create_pipeline_config @@ -35,20 +35,16 @@ from src.api.common import ( delete_blob_container, retrieve_original_blob_container_name, - retrieve_original_entity_config_name, sanitize_name, validate_blob_container_name, verify_subscription_key_exist, ) -from src.api.index_configuration import get_entity from src.models import ( BaseResponse, IndexNameList, - IndexRequest, IndexStatusResponse, PipelineJob, ) -from src.prompts import graph_extraction_prompt from src.reporting import ReporterSingleton from src.reporting.load_reporter import load_pipeline_reporter from src.reporting.pipeline_job_workflow_callbacks import PipelineJobWorkflowCallbacks @@ -78,83 +74,95 @@ response_model=BaseResponse, responses={200: {"model": BaseResponse***REMOVED******REMOVED***, ) -def setup_indexing_pipeline( - request: IndexRequest, background_tasks: BackgroundTasks = None +async def setup_indexing_pipeline( + storage_name: str, + index_name: str, + entity_extraction_prompt: UploadFile | None = None, + community_report_prompt: UploadFile | None = None, + summarize_descriptions_prompt: UploadFile | None = None, ): _blob_service_client = BlobServiceClientSingleton().get_instance() - pipelinejob = PipelineJob() # TODO: fix class so initiliazation is not required + pipelinejob = PipelineJob() # validate index name against blob container naming rules - sanitized_index_name = sanitize_name(request.index_name) + sanitized_index_name = sanitize_name(index_name) ***REMOVED*** validate_blob_container_name(sanitized_index_name) except ValueError: raise HTTPException( status_code=500, - detail=f"Invalid index name: {request.index_name***REMOVED***", + detail=f"Invalid index name: {index_name***REMOVED***", ***REMOVED*** # check for data container existence - sanitized_storage_name = sanitize_name(request.storage_name) + sanitized_storage_name = sanitize_name(storage_name) if not _blob_service_client.get_container_client(sanitized_storage_name).exists(): raise HTTPException( status_code=500, - detail=f"Data container '{request.storage_name***REMOVED***' does not exist.", + detail=f"Data container '{storage_name***REMOVED***' does not exist.", ***REMOVED*** - # check for entity configuration existence - sanitized_entity_config_name = sanitize_name(request.entity_config_name) - if request.entity_config_name: - entity_container_client = get_database_container_client( - database_name="graphrag", container_name="entities" -***REMOVED*** - ***REMOVED*** - entity_container_client.read_item( # noqa - item=sanitized_entity_config_name, - partition_key=sanitized_entity_config_name, - ***REMOVED*** - except Exception: - raise HTTPException( - status_code=500, - detail=f"Entity configuration '{request.entity_config_name***REMOVED***' does not exist.", - ***REMOVED*** - # check for existing index job # it is okay if job doesn't exist, but if it does, # it must not be scheduled or running - if pipelinejob.item_exist(request.index_name): - existing_job = pipelinejob.load_item(request.index_name) + if pipelinejob.item_exist(sanitized_index_name): + existing_job = pipelinejob.load_item(sanitized_index_name) if (PipelineJobState(existing_job.status) == PipelineJobState.SCHEDULED) or ( PipelineJobState(existing_job.status) == PipelineJobState.RUNNING ***REMOVED***: raise HTTPException( status_code=202, # request has been accepted for processing but is not complete. - detail=f"an index with name {request.index_name***REMOVED*** already exists and has not finished building.", + detail=f"an index with name {index_name***REMOVED*** already exists and has not finished building.", ***REMOVED*** # if indexing job is in a failed state, delete the associated K8s job and pod to allow for a new job to be scheduled if PipelineJobState(existing_job.status) == PipelineJobState.FAILED: - _delete_k8s_job(f"indexing-job-{request.index_name***REMOVED***", "graphrag") + _delete_k8s_job(f"indexing-job-{sanitized_index_name***REMOVED***", "graphrag") # reset the job to scheduled state existing_job.status = PipelineJobState.SCHEDULED existing_job.percent_complete = 0 existing_job.progress = "" - else: - # create or update state in cosmos db - pipelinejob.create_item( - id=sanitized_index_name, - index_name=sanitized_index_name, - storage_name=sanitized_storage_name, - entity_config_name=sanitized_entity_config_name, - status=PipelineJobState.SCHEDULED, + existing_job.all_workflows = existing_job.completed_workflows = ( + existing_job.failed_workflows +***REMOVED*** = [] + existing_job.entity_extraction_prompt = None + existing_job.community_report_prompt = None + existing_job.summarize_descriptions_prompt = None + + # create or update state in cosmos db + entity_extraction_prompt_content = ( + entity_extraction_prompt.file.read().decode("utf-8") + if entity_extraction_prompt + else None +***REMOVED*** + community_report_prompt_content = ( + community_report_prompt.file.read().decode("utf-8") + if community_report_prompt + else None +***REMOVED*** + summarize_descriptions_prompt_content = ( + summarize_descriptions_prompt.file.read().decode("utf-8") + if summarize_descriptions_prompt + else None +***REMOVED*** + print(f"ENTITY EXTRACTION PROMPT:\n{entity_extraction_prompt_content***REMOVED***") + print(f"COMMUNITY REPORT PROMPT:\n{community_report_prompt_content***REMOVED***") + print(f"SUMMARIZE DESCRIPTIONS PROMPT:\n{summarize_descriptions_prompt_content***REMOVED***") + pipelinejob.create_item( + id=sanitized_index_name, + index_name=sanitized_index_name, + storage_name=sanitized_storage_name, + entity_extraction_prompt=entity_extraction_prompt_content, + community_report_prompt=community_report_prompt_content, + summarize_descriptions_prompt=summarize_descriptions_prompt_content, + status=PipelineJobState.SCHEDULED, ***REMOVED*** ***REMOVED*** At this point, we know: 1) the index name is valid 2) the data container exists - 3) the entity configuration exists. - 4) there is no indexing job with this name currently running or a previous job has finished + 3) there is no indexing job with this name currently running or a previous job has finished ***REMOVED*** # update or create new item in container-store in cosmosDB if not _blob_service_client.get_container_client(sanitized_index_name).exists(): @@ -166,55 +174,42 @@ def setup_indexing_pipeline( container_store_client.upsert_item( { "id": sanitized_index_name, - "human_readable_name": request.index_name, + "human_readable_name": index_name, "type": "index", ***REMOVED*** ***REMOVED*** + # schedule AKS job +***REMOVED*** + config.load_incluster_config() + # get container image name + core_v1 = client.CoreV1Api() + pod_name = os.environ["HOSTNAME"] + pod = core_v1.read_namespaced_pod( + name=pod_name, namespace=os.environ["AKS_NAMESPACE"] +***REMOVED*** + # retrieve job manifest template and replace necessary values + job_manifest = _generate_aks_job_manifest( + docker_image_name=pod.spec.containers[0].image, + index_name=index_name, + service_account_name=pod.spec.service_account_name, ***REMOVED*** - # schedule AKS job if possible - if os.getenv("KUBERNETES_SERVICE_HOST"): # only found if in AKS - config.load_incluster_config() - # get container image name - core_v1 = client.CoreV1Api() - pod_name = os.environ["HOSTNAME"] - pod = core_v1.read_namespaced_pod( - name=pod_name, namespace=os.environ["AKS_NAMESPACE"] ***REMOVED*** - # retrieve job manifest template and replace necessary values - job_manifest = _generate_aks_job_manifest( - docker_image_name=pod.spec.containers[0].image, - index_name=request.index_name, - storage_name=request.storage_name, - service_account_name=pod.spec.service_account_name, - entity_config_name=request.entity_config_name, + batch_v1 = client.BatchV1Api() + batch_v1.create_namespaced_job( + body=job_manifest, namespace=os.environ["AKS_NAMESPACE"] + ***REMOVED*** + except ApiException as e: + raise HTTPException( + status_code=500, + detail=f"exception when calling BatchV1Api->create_namespaced_job: {str(e)***REMOVED***", ***REMOVED*** - print(f"Created job manifest:\n{job_manifest***REMOVED***") - ***REMOVED*** - batch_v1 = client.BatchV1Api() - batch_v1.create_namespaced_job( - body=job_manifest, namespace=os.environ["AKS_NAMESPACE"] - ***REMOVED*** - except ApiException as e: - raise HTTPException( - status_code=500, - detail=f"exception when calling BatchV1Api->create_namespaced_job: {str(e)***REMOVED***", - ***REMOVED*** -***REMOVED*** # run locally - if background_tasks: - background_tasks.add_task( - _start_indexing_pipeline, - request.index_name, - request.storage_name, - request.entity_config_name, - request.webhook_url, - ***REMOVED*** return BaseResponse(status="indexing operation has been scheduled.") ***REMOVED*** reporter = ReporterSingleton().get_instance() job_details = { - "storage_name": request.storage_name, - "index_name": request.index_name, + "storage_name": storage_name, +***REMOVED*** ***REMOVED*** reporter.on_error( "Error creating a new index", @@ -222,25 +217,25 @@ def setup_indexing_pipeline( ***REMOVED*** raise HTTPException( status_code=500, - detail=f"Error occurred during setup of indexing job for '{request.index_name***REMOVED***'.", + detail=f"Error occurred during setup of indexing job for '{index_name***REMOVED***'.", ***REMOVED*** async def _start_indexing_pipeline( index_name: str, - storage_name: str, - entity_config_name: str | None = None, webhook_url: str | None = None, ): - # get sanitized names + # get sanitized name sanitized_index_name = sanitize_name(index_name) - sanitized_storage_name = sanitize_name(storage_name) reporter = ReporterSingleton().get_instance() - pipelinejob = PipelineJob() # TODO: fix class so initiliazation is not required + pipelinejob = PipelineJob() + pipeline_job = pipelinejob.load_item(sanitized_index_name) + sanitized_storage_name = pipeline_job.storage_name + storage_name = retrieve_original_blob_container_name(sanitized_storage_name) # download nltk dependencies - bootstrap() # todo: expose the quiet flag to the user + bootstrap() # create new reporters/callbacks just for this job reporters = [] @@ -259,7 +254,7 @@ async def _start_indexing_pipeline( this_directory = os.path.dirname( os.path.abspath(inspect.getfile(inspect.currentframe())) ***REMOVED*** - data = yaml.safe_load(open(f"{this_directory***REMOVED***/pipeline_settings.yaml")) + data = yaml.safe_load(open(f"{this_directory***REMOVED***/pipeline-settings.yaml")) # dynamically set some values data["input"]["container_name"] = sanitized_storage_name data["storage"]["container_name"] = sanitized_index_name @@ -270,28 +265,46 @@ async def _start_indexing_pipeline( f"{sanitized_index_name***REMOVED***_description_embedding" ***REMOVED*** - # if entity_config_name was provided, load entity configuration and incorporate into pipeline - # otherwise, set prompt and entity types to None to use the default values provided by graphrag - if entity_config_name: - entity_configuration = get_entity(entity_config_name) - data["entity_extraction"]["entity_types"] = entity_configuration.entity_types - with open("entity-extraction-prompt.txt", "w") as f: - prompt = graph_extraction_prompt.get_prompt( - entity_types=entity_configuration.entity_types, - entity_examples=entity_configuration.entity_examples, - ***REMOVED*** - f.write(prompt) - data["entity_extraction"]["prompt"] = "entity-extraction-prompt.txt" - else: - data["entity_extraction"]["prompt"] = None - data["entity_extraction"]["entity_types"] = None + # set prompts for entity extraction, community report, and summarize descriptions. + # an environment variable is set to the file path of the prompt + if pipeline_job.entity_extraction_prompt: + fname = "entity-extraction-prompt.txt" + with open(fname, "w") as outfile: + outfile.write(pipeline_job.entity_extraction_prompt) + os.environ["GRAPHRAG_ENTITY_EXTRACTION_PROMPT_FILE"] = fname + # data["entity_extraction"]["prompt"] = fname + # else: + # data["entity_extraction"]["prompt"] = None + if pipeline_job.community_report_prompt: + fname = "community-report-prompt.txt" + with open(fname, "w") as outfile: + outfile.write(pipeline_job.community_report_prompt) + os.environ["GRAPHRAG_COMMUNITY_REPORT_PROMPT_FILE"] = fname + # data["community_reports"]["prompt"] = fname + # else: + # data["community_reports"]["prompt"] = None + if pipeline_job.summarize_descriptions_prompt: + fname = "summarize-descriptions-prompt.txt" + with open(fname, "w") as outfile: + outfile.write(pipeline_job.summarize_descriptions_prompt) + os.environ["GRAPHRAG_SUMMARIZE_DESCRIPTIONS_PROMPT_FILE"] = fname + # data["summarize_descriptions"]["prompt"] = fname + # else: + # data["summarize_descriptions"]["prompt"] = None + + # set placeholder values to None if they have not been set + # if data["entity_extraction"]["prompt"] == "PLACEHOLDER": + # data["entity_extraction"]["prompt"] = None + # if data["community_reports"]["prompt"] == "PLACEHOLDER": + # data["community_reports"]["prompt"] = None + # if data["summarize_descriptions"]["prompt"] == "PLACEHOLDER": + # data["summarize_descriptions"]["prompt"] = None # generate the default pipeline from default parameters and override with custom settings parameters = create_graphrag_config(data, ".") pipeline_config = create_pipeline_config(parameters, True) - # create a new pipeline job object - pipeline_job = pipelinejob.load_item(sanitized_index_name) + # reset pipeline job details pipeline_job.status = PipelineJobState.RUNNING pipeline_job.all_workflows = [] pipeline_job.completed_workflows = [] @@ -304,8 +317,13 @@ async def _start_indexing_pipeline( PipelineJobWorkflowCallbacks(pipeline_job) ***REMOVED*** + # print("#################### PIPELINE JOB:") + # pprint(pipeline_job.dump_model()) + print("#################### PIPELINE CONFIG:") + print(pipeline_config) + + # run the pipeline ***REMOVED*** - # run the pipeline async for workflow_result in run_pipeline_with_config( config_or_path=pipeline_config, callbacks=workflow_callbacks, @@ -317,7 +335,7 @@ async def _start_indexing_pipeline( pipeline_job.failed_workflows.append(workflow_result.workflow) pipeline_job.update_db() - # jobs are done, check if any failed + # if job is done, check if any workflow steps failed if len(pipeline_job.failed_workflows) > 0: pipeline_job.status = PipelineJobState.FAILED ***REMOVED*** @@ -391,10 +409,8 @@ async def _start_indexing_pipeline( def _generate_aks_job_manifest( docker_image_name: str, - storage_name: str, index_name: str, service_account_name: str, - entity_config_name: str | None = None, ) -> dict: ***REMOVED***Generate an AKS Jobs manifest file with the specified parameters. @@ -410,12 +426,7 @@ def _generate_aks_job_manifest( "python", "run-indexing-job.py", f"-i={index_name***REMOVED***", - f"-s={storage_name***REMOVED***", ] - if entity_config_name: - manifest["spec"]["template"]["spec"]["containers"][0]["command"].append( - f"-e={entity_config_name***REMOVED***" -***REMOVED*** return manifest @@ -569,9 +580,6 @@ async def get_index_job_status(index_name: str): index_name=retrieve_original_blob_container_name(pipeline_job.index_name), storage_name=retrieve_original_blob_container_name( pipeline_job.storage_name - ***REMOVED***, - entity_config_name=retrieve_original_entity_config_name( - pipeline_job.entity_config_name ***REMOVED***, status=pipeline_job.status.value, percent_complete=pipeline_job.percent_complete, diff --git a/backend/src/api/index_configuration.py b/backend/src/api/index_configuration.py index 5d8fa7cb..0ec691a5 100644 --- a/backend/src/api/index_configuration.py +++ b/backend/src/api/index_configuration.py @@ -1,16 +1,24 @@ ***REMOVED*** ***REMOVED*** +import inspect ***REMOVED*** +import shutil from typing import Union +import yaml from fastapi import ( APIRouter, Depends, HTTPException, ) +from fastapi.responses import StreamingResponse +from graphrag.prompt_tune.cli import fine_tune as generate_fine_tune_prompts -from src.api.azure_clients import AzureStorageClientManager +from src.api.azure_clients import ( + AzureStorageClientManager, + BlobServiceClientSingleton, +) from src.api.common import ( sanitize_name, verify_subscription_key_exist, @@ -23,7 +31,6 @@ from src.reporting import ReporterSingleton azure_storage_client_manager = AzureStorageClientManager() - index_configuration_route = APIRouter( prefix="/index/config", tags=["Index Configuration"] ) @@ -33,14 +40,17 @@ Depends(verify_subscription_key_exist) ***REMOVED*** +# NOTE: currently disable all /entity endpoints - to be replaced by the auto-generation of prompts + @index_configuration_route.get( "/entity", summary="Get all entity configurations", response_model=EntityNameList, responses={200: {"model": EntityNameList***REMOVED***, 400: {"model": EntityNameList***REMOVED******REMOVED***, + include_in_schema=False, ) -def get_all_entitys(): +async def get_all_entitys(): ***REMOVED*** Retrieve a list of all entity configuration names. ***REMOVED*** @@ -62,8 +72,9 @@ def get_all_entitys(): summary="Create an entity configuration", response_model=BaseResponse, responses={200: {"model": BaseResponse***REMOVED******REMOVED***, + include_in_schema=False, ) -def create_entity(request: EntityConfiguration): +async def create_entity(request: EntityConfiguration): # check for entity configuration existence entity_container = azure_storage_client_manager.get_cosmos_container_client( database_name="graphrag", container_name="entities" @@ -121,8 +132,9 @@ def create_entity(request: EntityConfiguration): summary="Update an existing entity configuration", response_model=BaseResponse, responses={200: {"model": BaseResponse***REMOVED******REMOVED***, + include_in_schema=False, ) -def update_entity(request: EntityConfiguration): +async def update_entity(request: EntityConfiguration): # check for entity configuration existence reporter = ReporterSingleton.get_instance() existing_item = None @@ -179,8 +191,9 @@ def update_entity(request: EntityConfiguration): summary="Get a specified entity configuration", response_model=Union[EntityConfiguration, BaseResponse], responses={200: {"model": EntityConfiguration***REMOVED***, 400: {"model": BaseResponse***REMOVED******REMOVED***, + include_in_schema=False, ) -def get_entity(entity_configuration_name: str): +async def get_entity(entity_configuration_name: str): reporter = ReporterSingleton.get_instance() ***REMOVED*** existing_item = None @@ -213,8 +226,9 @@ def get_entity(entity_configuration_name: str): summary="Delete a specified entity configuration", response_model=BaseResponse, responses={200: {"model": BaseResponse***REMOVED******REMOVED***, + include_in_schema=False, ) -def delete_entity(entity_configuration_name: str): +async def delete_entity(entity_configuration_name: str): reporter = ReporterSingleton.get_instance() ***REMOVED*** entity_container = azure_storage_client_manager.get_cosmos_container_client( @@ -235,3 +249,69 @@ def delete_entity(entity_configuration_name: str): status_code=500, detail=f"Entity configuration '{entity_configuration_name***REMOVED***' not found.", ***REMOVED*** + + +@index_configuration_route.get( + "/prompts", + summary="Generate graphrag prompts from user-provided data.", + description="Generating custom prompts from user-provided data may take several minutes to run based on the amount of data used.", +) +async def generate_prompts(storage_name: str, limit: int = 5): +***REMOVED*** + Automatically generate custom prompts for entity entraction, + community reports, and summarize descriptions based on a sample of provided data. +***REMOVED*** + # check for storage container existence + blob_service_client = BlobServiceClientSingleton().get_instance() + sanitized_storage_name = sanitize_name(storage_name) + if not blob_service_client.get_container_client(sanitized_storage_name).exists(): + raise HTTPException( + status_code=500, + detail=f"Data container '{storage_name***REMOVED***' does not exist.", +***REMOVED*** + this_directory = os.path.dirname( + os.path.abspath(inspect.getfile(inspect.currentframe())) +***REMOVED*** + print("THIS DIRECTORY: ", this_directory) + print("CWD: ", os.getcwd()) + + # write custom settings.yaml to a file and store in a temporary directory + data = yaml.safe_load(open(f"{this_directory***REMOVED***/pipeline-settings.yaml")) + data["input"]["container_name"] = sanitized_storage_name + temp_dir = f"/tmp/{sanitized_storage_name***REMOVED***_prompt_tuning" + shutil.rmtree(temp_dir, ignore_errors=True) + os.makedirs(temp_dir, exist_ok=True) + print(f"TEMP SETTINGS DIR: {temp_dir***REMOVED***") + with open(f"{temp_dir***REMOVED***/settings.yaml", "w") as f: + yaml.dump(data, f, default_flow_style=False) + + # generate prompts +***REMOVED*** + await generate_fine_tune_prompts( + root=temp_dir, + domain="", + select="random", + limit=limit, + skip_entity_types=True, + output="prompts", +***REMOVED*** + except Exception: + raise HTTPException( + status_code=500, + detail=f"Error generating prompts for data in '{storage_name***REMOVED***'. Please try a lower limit.", +***REMOVED*** + + # zip up the generated prompt files and return the zip file + temp_archive = ( + f"{temp_dir***REMOVED***/prompts" # will become a zip file with the name prompts.zip +***REMOVED*** + shutil.make_archive(temp_archive, "zip", root_dir=temp_dir, base_dir="prompts") + print(f"ARCHIVE: {temp_archive***REMOVED***.zip") + for f in os.listdir(temp_dir): + print(f"FILE: {f***REMOVED***") + + def iterfile(file_path: str): + with open(file_path, mode="rb") as file_like: + yield from file_like + + return StreamingResponse(iterfile(f"{temp_archive***REMOVED***.zip")) diff --git a/backend/src/api/pipeline_settings.yaml b/backend/src/api/pipeline-settings.yaml similarity index 92% rename from backend/src/api/pipeline_settings.yaml rename to backend/src/api/pipeline-settings.yaml index 6175cd5f..34dd1074 100644 --- a/backend/src/api/pipeline_settings.yaml +++ b/backend/src/api/pipeline-settings.yaml @@ -73,9 +73,14 @@ embeddings: overwrite: True url: $AI_SEARCH_URL -entity_extraction: - prompt: PLACEHOLDER - entity_types: PLACEHOLDER +# entity_extraction: +# prompt: PLACEHOLDER + +# community_reports: +# prompt: PLACEHOLDER + +# summarize_descriptions: +# prompt: PLACEHOLDER snapshots: graphml: True diff --git a/backend/src/api/query.py b/backend/src/api/query.py index e6f5e670..fc1531e3 100644 --- a/backend/src/api/query.py +++ b/backend/src/api/query.py @@ -106,7 +106,7 @@ async def global_query(request: GraphRequest): this_directory = os.path.dirname( os.path.abspath(inspect.getfile(inspect.currentframe())) ***REMOVED*** - data = yaml.safe_load(open(f"{this_directory***REMOVED***/pipeline_settings.yaml")) + data = yaml.safe_load(open(f"{this_directory***REMOVED***/pipeline-settings.yaml")) # layer the custom settings on top of the default configuration settings of graphrag parameters = create_graphrag_config(data, ".") @@ -340,7 +340,7 @@ async def local_query(request: GraphRequest): this_directory = os.path.dirname( os.path.abspath(inspect.getfile(inspect.currentframe())) ***REMOVED*** - data = yaml.safe_load(open(f"{this_directory***REMOVED***/pipeline_settings.yaml")) + data = yaml.safe_load(open(f"{this_directory***REMOVED***/pipeline-settings.yaml")) # layer the custom settings on top of the default configuration settings of graphrag parameters = create_graphrag_config(data, ".") diff --git a/backend/src/models.py b/backend/src/models.py index 2bd76a89..638f3156 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -5,7 +5,6 @@ from typing import ( Any, List, - Optional, ) from azure.cosmos.exceptions import CosmosHttpResponseError @@ -71,29 +70,45 @@ class IndexNameList(BaseModel): index_name: List[str] -class IndexRequest(BaseModel): - storage_name: str - index_name: str - entity_config_name: Optional[str | None] = None - webhook_url: Optional[str | None] = None - - class IndexStatusResponse(BaseModel): status_code: int index_name: str storage_name: str - entity_config_name: Optional[str | None] = None status: str percent_complete: float progress: str +class ReportResponse(BaseModel): + text: str + + +class RelationshipResponse(BaseModel): + source: str + source_id: int + target: str + target_id: int + description: str + text_units: list[str] + + +class StorageNameList(BaseModel): + storage_name: List[str] + + +class TextUnitResponse(BaseModel): + text: str + source_document: str + + @dataclass class PipelineJob: _id: str = field(default=None, init=False) _index_name: str = field(default=None, init=False) _storage_name: str = field(default=None, init=False) - _entity_config_name: str = field(default=None, init=False) + _entity_extraction_prompt: str = field(default=None, init=False) + _community_report_prompt: str = field(default=None, init=False) + _summarize_descriptions_prompt: str = field(default=None, init=False) _all_workflows: List[str] = field(default_factory=list, init=False) _completed_workflows: List[str] = field(default_factory=list, init=False) _failed_workflows: List[str] = field(default_factory=list, init=False) @@ -114,7 +129,9 @@ def create_item( id: str, index_name: str, storage_name: str, - entity_config_name: str, + entity_extraction_prompt: str | None = None, + community_report_prompt: str | None = None, + summarize_descriptions_prompt: str | None = None, **kwargs, ***REMOVED*** -> "PipelineJob": ***REMOVED*** @@ -124,7 +141,9 @@ def create_item( id (str): The ID of the pipeline job. index_name (str): The name of the index. storage_name (str): The name of the storage. - entity_config_name (str): The name of the entity configuration. + entity_extraction_prompt (str): The entity extraction prompt. + community_prompt (str): The community prompt. + summarize_descriptions_prompt (str): The prompt for summarizing descriptions. all_workflows (List[str]): List of all workflows. completed_workflows (List[str]): List of completed workflows. failed_workflows (List[str]): List of failed workflows. @@ -146,13 +165,13 @@ def create_item( assert storage_name is not None, "storage_name cannot be None." assert len(storage_name) > 0, "storage_name cannot be empty." - instance = cls.__new__( - cls, id, index_name, storage_name, entity_config_name, **kwargs -***REMOVED*** + instance = cls.__new__(cls, id, index_name, storage_name, **kwargs) instance._id = id instance._index_name = index_name instance._storage_name = storage_name - instance._entity_config_name = entity_config_name + instance._entity_extraction_prompt = entity_extraction_prompt + instance._community_report_prompt = community_report_prompt + instance._summarize_descriptions_prompt = summarize_descriptions_prompt instance._all_workflows = kwargs.get("all_workflows", []) instance._completed_workflows = kwargs.get("completed_workflows", []) instance._failed_workflows = kwargs.get("failed_workflows", []) @@ -189,7 +208,11 @@ def load_item(cls, id: str) -> "PipelineJob": instance._id = db_item.get("id") instance._index_name = db_item.get("index_name") instance._storage_name = db_item.get("storage_name") - instance._entity_config_name = db_item.get("entity_config_name") + instance._entity_extraction_prompt = db_item.get("entity_extraction_prompt") + instance._community_report_prompt = db_item.get("community_report_prompt") + instance._summarize_descriptions_prompt = db_item.get( + "summarize_descriptions_prompt" +***REMOVED*** instance._all_workflows = db_item.get("all_workflows", []) instance._completed_workflows = db_item.get("completed_workflows", []) instance._failed_workflows = db_item.get("failed_workflows", []) @@ -220,11 +243,10 @@ def calculate_percent_complete(self) -> float: ***REMOVED*** def dump_model(self) -> dict: - return { + model = { "id": self._id, "index_name": self._index_name, "storage_name": self._storage_name, - "entity_config_name": self._entity_config_name, "all_workflows": self._all_workflows, "completed_workflows": self._completed_workflows, "failed_workflows": self._failed_workflows, @@ -232,6 +254,13 @@ def dump_model(self) -> dict: "percent_complete": self._percent_complete, "progress": self._progress, ***REMOVED*** + if self._entity_extraction_prompt: + model["entity_extraction_prompt"] = self._entity_extraction_prompt + if self._community_report_prompt: + model["community_report_prompt"] = self._community_report_prompt + if self._summarize_descriptions_prompt: + model["summarize_descriptions_prompt"] = self._summarize_descriptions_prompt + return model def update_db(self): PipelineJob._jobs_container().upsert_item(body=self.dump_model()) @@ -257,25 +286,39 @@ def index_name(self, index_name: str) -> None: self.update_db() @property - def entity_config_name(self) -> str: - return self._entity_config_name + def storage_name(self) -> str: + return self._storage_name + + @storage_name.setter + def storage_name(self, storage_name: str) -> None: + self._storage_name = storage_name + self.update_db() + + @property + def entity_extraction_prompt(self) -> str: + return self._entity_extraction_prompt - @entity_config_name.setter - def entity_config_name(self, entity_config_name: str) -> None: - self._entity_config_name = entity_config_name + @entity_extraction_prompt.setter + def entity_extraction_prompt(self, entity_extraction_prompt: str) -> None: + self._entity_extraction_prompt = entity_extraction_prompt self.update_db() @property - def storage_name(self) -> str: - return self._storage_name + def community_report_prompt(self) -> str: + return self._community_report_prompt + + @community_report_prompt.setter + def community_report_prompt(self, community_report_prompt: str) -> None: + self._community_report_prompt = community_report_prompt + self.update_db() @property - def entity_config_name(self) -> str: - return self._entity_config_name + def summarize_descriptions_prompt(self) -> str: + return self._summarize_descriptions_prompt - @storage_name.setter - def storage_name(self, storage_name: str) -> None: - self._storage_name = storage_name + @summarize_descriptions_prompt.setter + def summarize_descriptions_prompt(self, summarize_descriptions_prompt: str) -> None: + self._summarize_descriptions_prompt = summarize_descriptions_prompt self.update_db() @property @@ -331,25 +374,3 @@ def progress(self) -> str: def progress(self, progress: str) -> None: self._progress = progress self.update_db() - - -class ReportResponse(BaseModel): - text: str - - -class RelationshipResponse(BaseModel): - source: str - source_id: int - target: str - target_id: int - description: str - text_units: list[str] - - -class StorageNameList(BaseModel): - storage_name: List[str] - - -class TextUnitResponse(BaseModel): - text: str - source_document: str diff --git a/docs/DEPLOYMENT-GUIDE.md b/docs/DEPLOYMENT-GUIDE.md index 31e79448..ec3be900 100644 --- a/docs/DEPLOYMENT-GUIDE.md +++ b/docs/DEPLOYMENT-GUIDE.md @@ -36,11 +36,11 @@ Login with Azure CLI and set the appropriate Azure subscription. # check what subscription you are logged into > az account show # set appropriate subscription if necessary -> az account set --subscription "" +> az account set --subscription "" ``` The Azure subscription that you deploy the accelerator in will require the `Microsoft.OperationsManagement` resource provider to be registered. -This can be accomplished via the [Portal](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-providers-and-types#azure-ortal) or these [Azure CLI](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-providers-and-types#azure-cli) commands: +This can be accomplished via the [Portal](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-providers-and-types#azure-ortal) or with the following [Azure CLI](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-providers-and-types#azure-cli) commands: ```shell # Register provider @@ -49,15 +49,13 @@ az provider register --namespace Microsoft.OperationsManagement az provider show --namespace Microsoft.OperationsManagement -o table ``` -## 3. Deploy Azure Container Registry (ACR) and host the `graphrag` docker image in the registry +## 3. Create an Azure Container Registry (ACR) ACR may be deployed using the [Portal](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-get-started-portal?tabs=azure-cli) or [Azure CLI](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-get-started-azure-cli). ```shell # create a new resource group and deploy ACR > az group create --name --location > az acr create --resource-group --name --sku Standard -# cd to the root directory of this repo and build/push the docker image to ACR -> az acr build --registry -f docker/Dockerfile-backend --image graphrag:backend . ``` ## 4. Fill out `infra/deploy.parameters.json` @@ -66,26 +64,27 @@ In the `deploy.parameters.json` file, provide values for the following required | Variable | Expected Value | Required | Description | :--- | :--- | --- | ---: | -`RESOURCE_GROUP` | | Yes | The resource group that GraphRAG will be deployed in. Will get created automatically if the resource group does not exist. -`LOCATION` | | Yes | The azure cloud region to deploy GraphRAG resources in. -`CONTAINER_REGISTRY_SERVER` | .azurecr.io | Yes | Name of the Azure Container Registry where the `graphrag` docker image is hosted. -`GRAPHRAG_IMAGE` | graphrag:backend | Yes | The name and tag of the graphrag docker image in the container registry. -`GRAPHRAG_API_BASE` | | Yes | Azure OpenAI service endpoint. -`GRAPHRAG_API_VERSION` | 2023-03-15-preview | Yes | Azure OpenAI API version. -`GRAPHRAG_LLM_MODEL` | gpt-4 | Yes | Name of the gpt-4 turbo model. -`GRAPHRAG_LLM_DEPLOYMENT_NAME` | | Yes | Deployment name of the gpt-4 turbo model. -`GRAPHRAG_EMBEDDING_MODEL` | text-embedding-ada-002 | Yes | Name of the Azure OpenAI embedding model. -`GRAPHRAG_EMBEDDING_DEPLOYMENT_NAME` | | Yes | Deployment name of the Azure OpenAI embedding model. -`APIM_NAME` | | No | Hostname of the API. Must be a globally unique name. The API will be accessible at `https://.azure-api.net`. If not provided a unique name will be generated. -`RESOURCE_BASE_NAME` | | No | Suffix to apply to all azure resource names. If not provided a unique suffix will be generated. -`AISEARCH_ENDPOINT_SUFFIX` | | No | Suffix to apply to AI search endpoint. Will default to `search.windows.net` for Azure Commercial cloud but should be overriden for deployments in other Azure clouds. -`REPORTERS` | | No | The type of logging to enable. If not provided, logging will be saved to a file in Azure Storage and to the console in AKS. +`RESOURCE_GROUP` | | Yes | The resource group that GraphRAG will be deployed in. Will get created automatically if the resource group does not exist. +`LOCATION` | | Yes | The azure cloud region to deploy GraphRAG resources in. +`CONTAINER_REGISTRY_SERVER` | .azurecr.io | Yes | Name of the Azure Container Registry where the `graphrag` docker image is hosted. +`GRAPHRAG_IMAGE` | graphrag:backend | No | The name and tag of the graphrag docker image in the container registry. Will default to `graphrag:backend`. +`GRAPHRAG_API_BASE` | | Yes | Azure OpenAI service endpoint. +`GRAPHRAG_API_VERSION` | 2023-03-15-preview | Yes | Azure OpenAI API version. +`GRAPHRAG_LLM_MODEL` | gpt-4 | Yes | Name of the gpt-4 turbo model. +`GRAPHRAG_LLM_DEPLOYMENT_NAME` | | Yes | Deployment name of the gpt-4 turbo model. +`GRAPHRAG_EMBEDDING_MODEL` | text-embedding-ada-002 | Yes | Name of the Azure OpenAI embedding model. +`GRAPHRAG_EMBEDDING_DEPLOYMENT_NAME` | | Yes | Deployment name of the Azure OpenAI embedding model. `GRAPHRAG_COGNITIVE_SERVICES_ENDPOINT` | | No | Endpoint for cognitive services identity authorization. Will default to `https://cognitiveservices.azure.com/.default` for Azure Commercial cloud but should be defined for deployments in other Azure clouds. +`APIM_NAME` | | No | Hostname of the API. Must be a globally unique name. The API will be accessible at `https://.azure-api.net`. If not provided a unique name will be generated. +`RESOURCE_BASE_NAME` | | No | Suffix to apply to all azure resource names. If not provided a unique suffix will be generated. +`AISEARCH_ENDPOINT_SUFFIX` | | No | Suffix to apply to AI search endpoint. Will default to `search.windows.net` for Azure Commercial cloud but should be overriden for deployments in other Azure clouds. +`REPORTERS` | | No | The type of logging to enable. If not provided, logging will be saved to a file in Azure Storage and to the console in AKS. ## 5. Deploy the solution accelerator ``` > cd infra -> bash deploy.sh deploy.parameters.json +> bash deploy.sh -h # view help menu for additional options +> bash deploy.sh -p deploy.parameters.json ``` When deploying for the first time, it will take ~40-50 minutes to deploy. Subsequent runs of this command will be faster. diff --git a/docs/DEVELOPMENT-GUIDE.md b/docs/DEVELOPMENT-GUIDE.md index 31bc9950..34253f3d 100644 --- a/docs/DEVELOPMENT-GUIDE.md +++ b/docs/DEVELOPMENT-GUIDE.md @@ -44,23 +44,6 @@ Development is best done in a unix environment (Linux, Mac, or [Windows WSL](htt ### Deploying GraphRAG The GraphRAG service consist of two components (a `backend` application and a `frontend` application). GraphRAG can be launched in multiple ways depending on where in the application stack you are developing and debugging. -- Local Development Option 1 - running uvicorn natively: - - Navigate to the `backend` directory - ``` - > export PYTHONPATH=/backend - > uvicorn src.main:app --env-file --host 0.0.0.0 --port 80 - ``` - -- Local Development Option 2 - as a docker container: - - Navigate to the root directory of the repository - ``` - > docker build -t graphrag:backend -f docker/Dockerfile-backend . - > docker run -d --env-file -p 80:80 graphrag:backend - > docker logs # prints log messages - ``` - - In Azure Kubernetes Service (AKS): Navigate to the root directory of the repository. First build and publish the `backend` docker image to an azure container registry. diff --git a/infra/core/ai-search/ai-search.bicep b/infra/core/ai-search/ai-search.bicep index 4f7cff64..f81ba303 100644 --- a/infra/core/ai-search/ai-search.bicep +++ b/infra/core/ai-search/ai-search.bicep @@ -10,6 +10,9 @@ param location string = resourceGroup().location @description('Array of objects with fields principalId, principalType, roleDefinitionId') param roleAssignments array = [] +@allowed([ 'enabled', 'disabled' ]) +param publicNetworkAccess string = 'enabled' + resource aiSearch 'Microsoft.Search/searchServices@2024-03-01-preview' = { name: name location: location @@ -20,7 +23,7 @@ resource aiSearch 'Microsoft.Search/searchServices@2024-03-01-preview' = { disableLocalAuth: true replicaCount: 1 partitionCount: 1 - publicNetworkAccess: 'disabled' + publicNetworkAccess: publicNetworkAccess semanticSearch: 'disabled' ***REMOVED*** ***REMOVED*** diff --git a/infra/core/cosmosdb/cosmosdb.bicep b/infra/core/cosmosdb/cosmosdb.bicep index 9e19d521..1a73fa73 100644 --- a/infra/core/cosmosdb/cosmosdb.bicep +++ b/infra/core/cosmosdb/cosmosdb.bicep @@ -5,6 +5,9 @@ param cosmosDbName string param location string = resourceGroup().location param principalId string +@allowed([ 'Enabled', 'Disabled' ]) +param publicNetworkAccess string = 'Disabled' + @description('Role definition id to assign to the principal. Learn more: https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac') @allowed([ '00000000-0000-0000-0000-000000000001' // Built-in role 'Azure Cosmos DB Built-in Data Reader' @@ -25,7 +28,7 @@ resource cosmosDb 'Microsoft.DocumentDB/databaseAccounts@2022-11-15' = { type: 'SystemAssigned' ***REMOVED*** properties: { - publicNetworkAccess: 'Disabled' + publicNetworkAccess: publicNetworkAccess enableAutomaticFailover: false enableMultipleWriteLocations: false isVirtualNetworkFilterEnabled: false diff --git a/infra/core/vnet/private-dns-zone.bicep b/infra/core/vnet/private-dns-zone.bicep index b87c7575..6caa56fa 100644 --- a/infra/core/vnet/private-dns-zone.bicep +++ b/infra/core/vnet/private-dns-zone.bicep @@ -18,7 +18,6 @@ resource vnets 'Microsoft.Network/virtualNetworks@2023-06-01' existing = [for vn name: vnetName ***REMOVED***] - resource dnsZoneLinks 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = [for (vnetName, index) in vnetNames: { name: vnetName location: 'global' diff --git a/infra/deploy.parameters.json.backup b/infra/deploy.parameters.json.backup new file mode 100644 index 00000000..fe63b744 --- /dev/null +++ b/infra/deploy.parameters.json.backup @@ -0,0 +1,12 @@ +{ + "CONTAINER_REGISTRY_SERVER": "graphrag.azurecr.io", + "GRAPHRAG_API_BASE": "https://smt-openai-agg-gpt4-uksouth.openai.azure.com", + "GRAPHRAG_API_VERSION": "2023-03-15-preview", + "GRAPHRAG_EMBEDDING_DEPLOYMENT_NAME": "graphrag-text-embedding-ada-002", + "GRAPHRAG_EMBEDDING_MODEL": "text-embedding-ada-002", + "GRAPHRAG_IMAGE": "backend:jb-image-dev", + "GRAPHRAG_LLM_DEPLOYMENT_NAME": "graphrag-gpt-4", + "GRAPHRAG_LLM_MODEL": "gpt-4", + "LOCATION": "eastus2", + "RESOURCE_GROUP": "joshbradley-graphrag-deployment27" +***REMOVED*** diff --git a/infra/deploy.sh b/infra/deploy.sh index 073605d3..8c438939 100755 --- a/infra/deploy.sh +++ b/infra/deploy.sh @@ -15,7 +15,6 @@ GRAPHRAG_COGNITIVE_SERVICES_ENDPOINT="" requiredParams=( CONTAINER_REGISTRY_SERVER - GRAPHRAG_IMAGE LOCATION GRAPHRAG_API_BASE GRAPHRAG_API_VERSION @@ -157,6 +156,10 @@ populateOptionalParams () { GRAPHRAG_COGNITIVE_SERVICES_ENDPOINT="https://cognitiveservices.azure.com/.default" printf "\tsetting GRAPHRAG_COGNITIVE_SERVICES_ENDPOINT=$GRAPHRAG_COGNITIVE_SERVICES_ENDPOINT\n" fi + if [ -z "$GRAPHRAG_IMAGE" ]; then + GRAPHRAG_IMAGE="graphrag:backend" + printf "\tsetting GRAPHRAG_IMAGE=$GRAPHRAG_IMAGE\n" + fi printf "Done.\n" ***REMOVED*** @@ -235,7 +238,8 @@ deployAzureResources () { --parameters "apimName=$APIM_NAME" \ --parameters "publisherName=$PUBLISHER_NAME" \ --parameters "aksSshRsaPublicKey=$SSH_PUBLICKEY" \ - --parameters "publisherEmail=$PUBLISHER_EMAIL") + --parameters "publisherEmail=$PUBLISHER_EMAIL" \ + --parameters "enablePrivateEndpoints=$ENABLE_PRIVATE_ENDPOINTS") exitIfCommandFailed $? "Error deploying Azure resources..." AZURE_OUTPUTS=$(jq -r .properties.outputs <<< $AZURE_DEPLOY_RESULTS) exitIfCommandFailed $? "Error parsing outputs from Azure resource deployment..." @@ -450,18 +454,100 @@ deployGraphragAPI () { rm core/apim/graphrag-openapi.json ***REMOVED*** -##### START EXECUTION ##### -if [ $# -ne 1 ]; then - echo "Usage: deploy.sh " - exit 1 -fi +grantDevAccessToAzureResources() { + # This function is used to grant the deployer of this script "developer" access to GraphRAG Azure resources + # by assigning the necessary RBAC roles for Azure Storage, AI Search, and CosmosDB to the signed-in user. + # This will grant the deployer access to the storage account, cosmos db, and AI search services in the resource group via the Azure portal. + echo "Granting deployer developer access to Azure resources..." + + # get subscription id of the active subscription + local azureAccount=$(az account show -o json) + local subscriptionId=$(jq -r .id <<< $azureAccount) + exitIfValueEmpty $subscriptionId "Subscription ID not found" + + # get principal/object id of the signed in user + local azureUserDetails=$(az ad signed-in-user show -o json) + local principalId=$(jq -r .id <<< $azureUserDetails) + exitIfValueEmpty $principalId "Principal ID not found" + + # assign storage account roles + local storageAccountDetails=$(az storage account list --resource-group $RESOURCE_GROUP -o json) + local storageAccountName=$(jq -r .[0].name <<< $storageAccountDetails) + exitIfValueEmpty $storageAccountName "Storage account not found" + az role assignment create --role "Storage Blob Data Contributor" --assignee $principalId --scope "/subscriptions/$subscriptionId/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Storage/storageAccounts/$storageAccountName" > /dev/null + az role assignment create --role "Storage Queue Data Contributor" --assignee $principalId --scope "/subscriptions/$subscriptionId/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Storage/storageAccounts/$storageAccountName" > /dev/null + + # assign cosmos db role + local cosmosDbDetails=$(az cosmosdb list --resource-group $RESOURCE_GROUP -o json) + local cosmosDbName=$(jq -r .[0].name <<< $cosmosDbDetails) + exitIfValueEmpty $cosmosDbName "CosmosDB account not found" + az cosmosdb sql role assignment create --account-name $cosmosDbName --resource-group $RESOURCE_GROUP --scope "/" --principal-id $principalId --role-definition-id /subscriptions/$subscriptionId/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.DocumentDB/databaseAccounts/graphrag/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002 > /dev/null + + # assign AI search roles + local searchServiceDetails=$(az search service list --resource-group $RESOURCE_GROUP -o json) + local searchServiceName=$(jq -r .[0].name <<< $searchServiceDetails) + exitIfValueEmpty $searchServiceName "AI Search service not found" + az role assignment create --role "Contributor" --assignee $principalId --scope "/subscriptions/$subscriptionId/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Search/searchServices/$searchServiceName" > /dev/null + az role assignment create --role "Search Index Data Contributor" --assignee $principalId --scope "/subscriptions/$subscriptionId/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Search/searchServices/$searchServiceName" > /dev/null + az role assignment create --role "Search Index Data Reader" --assignee $principalId --scope "/subscriptions/$subscriptionId/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Search/searchServices/$searchServiceName" > /dev/null +***REMOVED*** + +deployDockerImageToACR() { + printf "Deploying docker image '${GRAPHRAG_IMAGE***REMOVED***' to container registry '${CONTAINER_REGISTRY_SERVER***REMOVED***'..." + local SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]:-$0***REMOVED***"; )" &> /dev/null && pwd 2> /dev/null; )"; + az acr build --registry $CONTAINER_REGISTRY_SERVER -f $SCRIPT_DIR/../docker/Dockerfile-backend --image $GRAPHRAG_IMAGE $SCRIPT_DIR/../ > /dev/null 2>&1 + exitIfCommandFailed $? "Error deploying docker image, exiting..." + printf " Done.\n" +***REMOVED*** -PARAMS_FILE=$1 +################################################################################ +# Help menu # +################################################################################ +usage() { + echo + echo "Usage: bash $0 [-h|d|g] -p " + echo "Description: Deployment script for the GraphRAG Solution Accelerator." + echo "options:" + echo " -h Print this help menu." + echo " -d Disable private endpoint usage." + echo " -g Developer user only. Grants deployer of this script access to Azure Storage, AI Search, and CosmosDB. Will also disable private endpoints (-d)." + echo " -p A JSON file containing the deployment parameters (deploy.parameters.json)." + echo +***REMOVED*** +# print usage if no arguments are supplied +[ $# -eq 0 ] && usage && exit 0 +# parse arguments +ENABLE_PRIVATE_ENDPOINTS=true +GRANT_DEV_ACCESS=0 # false +PARAMS_FILE="" +while getopts ":dgp:h" option; do + case "${option***REMOVED***" in + d) + ENABLE_PRIVATE_ENDPOINTS=false + ;; + g) + ENABLE_PRIVATE_ENDPOINTS=false + GRANT_DEV_ACCESS=1 # true + ;; + p) + PARAMS_FILE=${OPTARG***REMOVED*** + ;; + h | *) + usage + exit 0 + ;; + esac +done +shift $((OPTIND-1)) +# check if required arguments are supplied if [ ! -f $PARAMS_FILE ]; then - echo "Parameters file $PARAMS_FILE not found" + echo "Error: invalid required argument." + usage exit 1 fi - +################################################################################ +# Main Program # +################################################################################ checkRequiredTools populateParams $PARAMS_FILE @@ -469,7 +555,10 @@ populateParams $PARAMS_FILE # Create resource group createResourceGroupIfNotExists $LOCATION $RESOURCE_GROUP -# AKS ssh key setup +# Deploy the graphrag backend docker image to ACR +deployDockerImageToACR + +# Generate ssh key for AKS createSshkeyIfNotExists $RESOURCE_GROUP # Deploy Azure resources @@ -478,15 +567,15 @@ deployAzureResources # Setup RBAC roles to access external services already deployed. AKS_NAME=$(jq -r .azure_aks_name.value <<< $AZURE_OUTPUTS) exitIfValueEmpty "$AKS_NAME" "Unable to parse AKS name from azure deployment outputs, exiting..." -# setup RBAC for managed identity to access the AOAI instance assignAOAIRoleToManagedIdentity -# setup RBAC for AKS to access the container registry assignAKSPullRoleToRegistry $RESOURCE_GROUP $AKS_NAME $CONTAINER_REGISTRY_SERVER # Deploy kubernetes resources setupAksCredentials $RESOURCE_GROUP $AKS_NAME populateAksVnetInfo $RESOURCE_GROUP $AKS_NAME -linkPrivateDnsToAks +if [ "$ENABLE_PRIVATE_ENDPOINTS" = "true" ]; then + linkPrivateDnsToAks +fi peerVirtualNetworks deployHelmChart deployGraphragDnsRecord @@ -494,4 +583,8 @@ deployGraphragDnsRecord # Import GraphRAG API into APIM deployGraphragAPI +if [ $GRANT_DEV_ACCESS -eq 1 ]; then + grantDevAccessToAzureResources +fi + echo "SUCCESS: GraphRAG deployment to resource group $RESOURCE_GROUP complete" diff --git a/infra/helm/graphrag/values.yaml b/infra/helm/graphrag/values.yaml index c03e0dbd..c6366bee 100644 --- a/infra/helm/graphrag/values.yaml +++ b/infra/helm/graphrag/values.yaml @@ -20,10 +20,11 @@ ingress: className: "nginx" host: graphrag.graphrag.io annotations: - nginx.ingress.kubernetes.io/send-timeout: "900" - nginx.ingress.kubernetes.io/proxy-connect-timeout: "900" - nginx.ingress.kubernetes.io/proxy-read-timeout: "900" - nginx.ingress.kubernetes.io/proxy-send-timeout: "900" + nginx.ingress.kubernetes.io/send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-connect-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-next-upstream-timeout: "3600" tls: [] graphragConfig: diff --git a/infra/main.bicep b/infra/main.bicep index faadffe7..3048046a 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -37,6 +37,9 @@ param aksNamespace string = 'graphrag' @description('Public key to allow access to AKS Linux nodes.') param aksSshRsaPublicKey string +@description('Whether to enable private endpoints.') +param enablePrivateEndpoints bool = false + param apimName string = '' param storageAccountName string = '' param cosmosDbName string = '' @@ -108,6 +111,7 @@ module cosmosdb 'core/cosmosdb/cosmosdb.bicep' = { params: { cosmosDbName: !empty(cosmosDbName) ? cosmosDbName : '${abbrs.documentDBDatabaseAccounts***REMOVED***${resourceBaseNameFinal***REMOVED***' location: rg.location + publicNetworkAccess: enablePrivateEndpoints ? 'Disabled' : 'Enabled' principalId: workloadIdentity.outputs.principal_id ***REMOVED*** ***REMOVED*** @@ -118,6 +122,7 @@ module aiSearch 'core/ai-search/ai-search.bicep' = { params: { name: !empty(aiSearchName) ? aiSearchName : '${abbrs.searchSearchServices***REMOVED***${resourceBaseNameFinal***REMOVED***' location: rg.location + publicNetworkAccess: enablePrivateEndpoints ? 'disabled' : 'enabled' roleAssignments: [ { principalId: workloadIdentity.outputs.principal_id @@ -144,6 +149,7 @@ module storage 'core/blob/storage.bicep' = { params: { name: !empty(storageAccountName) ? storageAccountName : '${abbrs.storageStorageAccounts***REMOVED***${replace(resourceBaseNameFinal, '-', '')***REMOVED***' location: rg.location + publicNetworkAccess: enablePrivateEndpoints ? 'Disabled' : 'Enabled' tags: tags roleAssignments: [ { @@ -157,7 +163,6 @@ module storage 'core/blob/storage.bicep' = { roleDefinitionId: roles.storageQueueDataContributor ***REMOVED*** ] - publicNetworkAccess: 'Disabled' deleteRetentionPolicy: { enabled: true days: 5 @@ -221,7 +226,7 @@ module privateDnsZone 'core/vnet/private-dns-zone.bicep' = { ***REMOVED*** ***REMOVED*** -module privatelinkPrivateDns 'core/vnet/privatelink-private-dns-zones.bicep' = { +module privatelinkPrivateDns 'core/vnet/privatelink-private-dns-zones.bicep' = if (enablePrivateEndpoints) { name: 'privatelink-private-dns-zones' scope: rg params: { @@ -231,7 +236,7 @@ module privatelinkPrivateDns 'core/vnet/privatelink-private-dns-zones.bicep' = { ***REMOVED*** ***REMOVED*** -module azureMonitorPrivateLinkScope 'core/monitor/private-link-scope.bicep' = { +module azureMonitorPrivateLinkScope 'core/monitor/private-link-scope.bicep' = if (enablePrivateEndpoints) { name: 'azureMonitorPrivateLinkScope' scope: rg params: { @@ -243,7 +248,7 @@ module azureMonitorPrivateLinkScope 'core/monitor/private-link-scope.bicep' = { ***REMOVED*** ***REMOVED*** -module cosmosDbPrivateEndpoint 'core/vnet/private-endpoint.bicep' = { +module cosmosDbPrivateEndpoint 'core/vnet/private-endpoint.bicep' = if (enablePrivateEndpoints) { name: 'cosmosDbPrivateEndpoint' scope: rg params: { @@ -252,11 +257,11 @@ module cosmosDbPrivateEndpoint 'core/vnet/private-endpoint.bicep' = { privateLinkServiceId: cosmosdb.outputs.id subnetId: apim.outputs.defaultSubnetId groupId: 'Sql' - privateDnsZoneConfigs: privatelinkPrivateDns.outputs.cosmosDbPrivateDnsZoneConfigs + privateDnsZoneConfigs: enablePrivateEndpoints ? privatelinkPrivateDns.outputs.cosmosDbPrivateDnsZoneConfigs : [] ***REMOVED*** ***REMOVED*** -module blobStoragePrivateEndpoint 'core/vnet/private-endpoint.bicep' = { +module blobStoragePrivateEndpoint 'core/vnet/private-endpoint.bicep' = if (enablePrivateEndpoints) { name: 'blobStoragePrivateEndpoint' scope: rg params: { @@ -265,11 +270,11 @@ module blobStoragePrivateEndpoint 'core/vnet/private-endpoint.bicep' = { privateLinkServiceId: storage.outputs.id subnetId: apim.outputs.defaultSubnetId groupId: 'blob' - privateDnsZoneConfigs: privatelinkPrivateDns.outputs.blobStoragePrivateDnsZoneConfigs + privateDnsZoneConfigs: enablePrivateEndpoints ? privatelinkPrivateDns.outputs.blobStoragePrivateDnsZoneConfigs : [] ***REMOVED*** ***REMOVED*** -module queueStoragePrivateEndpoint 'core/vnet/private-endpoint.bicep' = { +module queueStoragePrivateEndpoint 'core/vnet/private-endpoint.bicep' = if (enablePrivateEndpoints) { name: 'queueStoragePrivateEndpoint' scope: rg params: { @@ -278,11 +283,11 @@ module queueStoragePrivateEndpoint 'core/vnet/private-endpoint.bicep' = { privateLinkServiceId: storage.outputs.id subnetId: apim.outputs.defaultSubnetId groupId: 'queue' - privateDnsZoneConfigs: privatelinkPrivateDns.outputs.queueStoragePrivateDnsZoneConfigs + privateDnsZoneConfigs: enablePrivateEndpoints ? privatelinkPrivateDns.outputs.queueStoragePrivateDnsZoneConfigs : [] ***REMOVED*** ***REMOVED*** -module aiSearchPrivateEndpoint 'core/vnet/private-endpoint.bicep' = { +module aiSearchPrivateEndpoint 'core/vnet/private-endpoint.bicep' = if (enablePrivateEndpoints) { name: 'aiSearchPrivateEndpoint' scope: rg params: { @@ -291,20 +296,20 @@ module aiSearchPrivateEndpoint 'core/vnet/private-endpoint.bicep' = { privateLinkServiceId: aiSearch.outputs.id subnetId: apim.outputs.defaultSubnetId groupId: 'searchService' - privateDnsZoneConfigs: privatelinkPrivateDns.outputs.aiSearchPrivateDnsZoneConfigs + privateDnsZoneConfigs: enablePrivateEndpoints ? privatelinkPrivateDns.outputs.aiSearchPrivateDnsZoneConfigs : [] ***REMOVED*** ***REMOVED*** -module privateLinkScopePrivateEndpoint 'core/vnet/private-endpoint.bicep' = { +module privateLinkScopePrivateEndpoint 'core/vnet/private-endpoint.bicep' = if (enablePrivateEndpoints) { name: 'privateLinkScopePrivateEndpoint' scope: rg params: { privateEndpointName: '${abbrs.privateEndpoint***REMOVED***pls-${resourceBaseNameFinal***REMOVED***' location: location - privateLinkServiceId: azureMonitorPrivateLinkScope.outputs.privateLinkScopeId + privateLinkServiceId: enablePrivateEndpoints ? azureMonitorPrivateLinkScope.outputs.privateLinkScopeId : '' subnetId: apim.outputs.defaultSubnetId groupId: 'azuremonitor' - privateDnsZoneConfigs: privatelinkPrivateDns.outputs.azureMonitorPrivateDnsZoneConfigs + privateDnsZoneConfigs: enablePrivateEndpoints ? privatelinkPrivateDns.outputs.azureMonitorPrivateDnsZoneConfigs : [] ***REMOVED*** ***REMOVED*** @@ -330,7 +335,7 @@ output azure_graphrag_url string = graphRagUrl output azure_workload_identity_client_id string = workloadIdentity.outputs.client_id output azure_workload_identity_principal_id string = workloadIdentity.outputs.principal_id output azure_workload_identity_name string = workloadIdentity.outputs.name -output azure_private_dns_zones array = union( +output azure_private_dns_zones array = enablePrivateEndpoints ? union( privatelinkPrivateDns.outputs.privateDnsZones, [privateDnsZone.outputs.dns_zone_name] -) +) : [] diff --git a/notebooks/HelloWorld.ipynb b/notebooks/HelloWorld.ipynb index c42f91f9..3980cdf3 100644 --- a/notebooks/HelloWorld.ipynb +++ b/notebooks/HelloWorld.ipynb @@ -70,9 +70,11 @@ "source": [ "import getpass\n", "***REMOVED***\n", + "***REMOVED***\n", "import sys\n", "***REMOVED***\n", "from pathlib import Path\n", + "from zipfile import ZipFile\n", "\n", "import magic\n", "***REMOVED***\n", @@ -86,7 +88,7 @@ "id": "4f33e4a2-d5c5-4c0a-8644-09f799c3ab2e", "metadata": {***REMOVED***, "source": [ - "## Configuration required by User\n" + "## (REQUIRED) User Configuration\n" ] ***REMOVED***, { @@ -94,8 +96,9 @@ "id": "128cd1c2-5134-4fa0-9582-e9b973053f21", "metadata": {***REMOVED***, "source": [ - "#### Get API Key for API Management Service\n", - "For authentication, the API requires a *subscription key* to be passed in the header of all requests. To find this key, visit the Azure Portal. The API subscription key will be located under ` --> --> --> --> Primary Key`." + "#### Get API Key for API Management Service (APIM)\n", + "\n", + "APIM supports multiple forms of authentication and access control (e.g. managed identity). For this notebook demonstration, we will use a **[subscription key](https://learn.microsoft.com/en-us/azure/api-management/api-management-subscriptions)**. To locate this key, visit the Azure Portal. The subscription key can be found under under ` --> --> --> --> Primary Key`. For multiple API users, individual subscription keys can be generated." ] ***REMOVED***, { @@ -107,6 +110,7 @@ ***REMOVED***, "outputs": [], "source": [ + "# dd17e7f3f38e408c8f2c8910fa27cb76\n", "ocp_apim_subscription_key = getpass.getpass(\n", " \"Enter the subscription key to the GraphRag APIM:\"\n", ")" @@ -275,20 +279,26 @@ "def build_index(\n", " storage_name: str,\n", " index_name: str,\n", - " entity_config_name: str = None,\n", - " merge_with_index: str = None,\n", + " entity_extraction_prompt_filepath: str = None,\n", + " community_prompt_filepath: str = None,\n", + " summarize_description_prompt_filepath: str = None,\n", ") -> requests.Response:\n", " \"\"\"Create a search index.\n", " This function kicks off a job that builds a knowledge graph (KG) index from files located in a blob storage container.\n", " \"\"\"\n", " url = endpoint + \"/index\"\n", - " request = {\n", - " \"storage_name\": storage_name,\n", - " \"index_name\": index_name,\n", - " \"entity_config_name\": entity_config_name,\n", - " \"merge_with_index\": merge_with_index,\n", - " ***REMOVED***\n", - " return requests.post(url, json=request, headers=headers)\n", + " prompt_files = dict()\n", + " if entity_extraction_prompt_filepath:\n", + " prompt_files[\"entity_extraction_prompt\"] = open(entity_extraction_prompt_filepath, \"r\")\n", + " if community_prompt_filepath:\n", + " prompt_files[\"community_report_prompt\"] = open(community_prompt_filepath, \"r\")\n", + " if summarize_description_prompt_filepath:\n", + " prompt_files[\"summarize_descriptions_prompt\"] = open(summarize_description_prompt_filepath, \"r\")\n", + " return requests.post(url,\n", + " files=prompt_files if len(prompt_files)>0 else None,\n", + " params={\"index_name\": index_name,\n", + " \"storage_name\": storage_name***REMOVED***,\n", + " headers=headers)\n", "\n", "\n", "def delete_index(index_name: str) -> requests.Response:\n", @@ -314,62 +324,6 @@ " return requests.get(url, headers=headers)\n", "\n", "\n", - "def list_entity_configs() -> list:\n", - " \"\"\"List all entity configurations.\"\"\"\n", - " url = endpoint + \"/index/config/entity\"\n", - " response = requests.get(url, headers=headers)\n", - "***REMOVED***\n", - " entity_types = json.loads(response.text)\n", - " return entity_types[\"entity_configuration_name\"]\n", - " except json.JSONDecodeError:\n", - " print(response.text)\n", - " return response\n", - "\n", - "\n", - "def create_entity_config(\n", - " name: str,\n", - " entity_type: list[str],\n", - " examples,\n", - ") -> requests.Response:\n", - " \"\"\"Create a new entity configuration.\"\"\"\n", - " url = endpoint + \"/index/config/entity\"\n", - " request = json.dumps(\n", - " {\n", - " \"entity_configuration_name\": name,\n", - " \"entity_types\": entity_type,\n", - " \"entity_examples\": examples,\n", - " ***REMOVED***\n", - "***REMOVED***\n", - " return requests.post(url=url, data=request, headers=headers)\n", - "\n", - "\n", - "def delete_entity_config(name: str) -> requests.Response:\n", - " \"\"\"Delete an existing entity configuration.\"\"\"\n", - " url = endpoint + f\"/index/config/entity/{name***REMOVED***\"\n", - " return requests.delete(url, headers=headers)\n", - "\n", - "\n", - "def modify_entity_config(\n", - " name: str,\n", - " entity_type: list[str],\n", - " examples,\n", - ") -> requests.Response:\n", - " \"\"\"Modify an existing entity configuration.\"\"\"\n", - " url = endpoint + \"/index/config/entity\"\n", - " request = {\n", - " \"entity_configuration_name\": name,\n", - " \"entity_types\": entity_type,\n", - " \"entity_examples\": examples,\n", - " ***REMOVED***\n", - " return requests.put(url=url, json=request, headers=headers)\n", - "\n", - "\n", - "def get_entity_config(name: str) -> requests.Response:\n", - " \"\"\"Get an existing entity configuration.\"\"\"\n", - " url = endpoint + f\"/index/config/entity/{name***REMOVED***\"\n", - " return requests.get(url, headers=headers)\n", - "\n", - "\n", "def global_search(index_name: str | list[str], query: str) -> requests.Response:\n", " \"\"\"Run a global query over the knowledge graph(s) associated with one or more indexes\"\"\"\n", " url = endpoint + \"/query/global\"\n", @@ -474,7 +428,18 @@ " else:\n", " print(response.reason)\n", " print(response.content)\n", - " return response" + " return response\n", + "\n", + "\n", + "def generate_prompts(storage_name: str, zip_file_name: str, limit:int = 1) -> None:\n", + " \"\"\"Generate graphrag prompts using data provided in a specific storage container.\"\"\"\n", + " url = endpoint + \"/index/config/prompts\"\n", + " params = {\"storage_name\": storage_name, \"limit\": limit***REMOVED***\n", + " with requests.get(url, params=params, headers=headers, stream=True) as r:\n", + " r.raise_for_status()\n", + " with open(zip_file_name, \"wb\") as f:\n", + " for chunk in r.iter_content():\n", + " f.write(chunk)" ] ***REMOVED***, { @@ -548,18 +513,6 @@ "# pprint(response.text)" ] ***REMOVED***, - { - "cell_type": "markdown", - "id": "f38638d9-7511-4e57-b4b1-c402b9fbdd78", - "metadata": {***REMOVED***, - "source": [ - "## Entity Configuration (optional)\n", - "\n", - "GraphRAG builds a knowledge graph (KG) from data based on the ability to first identify entities and the relationships between them. Defining a _good_ schema for entities can be a critical step to constructing a high-quality KG. An example is provided below.\n", - "\n", - "Note: Defining an entity configuration is optional but highly encouraged for better performance in domain-specific scenarios. If an entity configuration is not provided, a default entity configuration by the graphrag python package will be used." - ] - ***REMOVED***, { "cell_type": "markdown", "id": "38e0c2f7", @@ -723,6 +676,36 @@ "# pprint(response.json())" ] ***REMOVED***, + { + "cell_type": "markdown", + "id": "0dc278e7", + "metadata": {***REMOVED***, + "source": [ + "## Auto-Template Generation (Optional)\n", + "\n", + "GraphRAG constructs a knowledge graph (KG) from data based on the ability to identify entities and the relationships between them. To improve the quality of the knowledge graph constructed by GraphRAG over private data, we provide a feature called \"Automatic Templating\". This capability takes user-provided data samples and generates custom-tailored prompts based on characteristics of that data. These custom prompts contain few-shot examples of entities and relationships, which can then be used to build a graphrag index." + ] + ***REMOVED***, + { + "cell_type": "code", + "execution_count": null, + "id": "fc6c64ee", + "metadata": {***REMOVED***, + "outputs": [], + "source": [ + "generate_prompts(storage_name=storage_name, limit=5, zip_file_name=\"prompts.zip\")\n", + "with ZipFile(\"prompts.zip\", \"r\") as zip_ref:\n", + " zip_ref.extractall()" + ] + ***REMOVED***, + { + "cell_type": "markdown", + "id": "c4eea409", + "metadata": {***REMOVED***, + "source": [ + "After running the previous cell, a new local directory (`prompts`) has been created. Please look at the prompts (`prompts/entity_extraction.txt`, `prompts/community_report.txt`, and `prompts/summarize_descriptions.txt`) that were generated from the user-provided data. Users are encouraged to spend some time and inspect/modify these prompts, taking into account characteristics of their data and their own goals of what kind/type of knowledge they wish to extract and model with graphrag." + ] + ***REMOVED***, { "cell_type": "markdown", "id": "05c9da8a-f1e6-4d5e-8a2d-83f36575c45c", @@ -730,7 +713,7 @@ "source": [ "## Indexing\n", "\n", - "After data files have been uploaded and an (optional) entity configuration has been created in the GraphRAG service, it is now possible to construct a knowledge graph by creating a search index. If an entity configuration is not provided, a default entity configuration will be used that has been shown to generally work well." + "After data files have been uploaded and (optionally) custom promps have been generated, it is now possible to construct a knowledge graph by creating a search index. If custom prompts are not provided, default built-in prompts will be used that have been shown to generally work well." ] ***REMOVED***, { @@ -750,17 +733,23 @@ ***REMOVED***, "outputs": [], "source": [ + "# check if prompt files exist\n", + "entity_extraction_prompt_filepath = \"prompts/entity_extraction.txt\"\n", + "community_prompt_filepath = \"prompts/community_report.txt\"\n", + "summarize_description_prompt_filepath = \"prompts/summarize_descriptions.txt\"\n", + "entity_prompt = entity_extraction_prompt_filepath if os.path.isfile(entity_extraction_prompt_filepath) else None\n", + "community_prompt = community_prompt_filepath if os.path.isfile(community_prompt_filepath) else None\n", + "summarize_prompt = summarize_description_prompt_filepath if os.path.isfile(summarize_description_prompt_filepath) else None\n", + "\n", "response = build_index(\n", " storage_name=storage_name,\n", " index_name=index_name,\n", - " entity_config_name=entity_configuration_name\n", - " if \"entity_configuration_name\" in locals() and entity_configuration_name\n", - " else None,\n", + " entity_extraction_prompt_filepath=entity_prompt,\n", + " community_prompt_filepath=community_prompt,\n", + " summarize_description_prompt_filepath=summarize_prompt,\n", ")\n", - "print(response)\n", - "if response.ok:\n", - " print(response.text)\n", - "else:\n", + "pprint(response.json())\n", + "if not response.ok:\n", " print(f\"Failed to submit job.\\nStatus: {response.text***REMOVED***\")" ] ***REMOVED***, diff --git a/poetry.lock b/poetry.lock index 136cfcaf..92756b65 100644 --- a/poetry.lock +++ b/poetry.lock @@ -465,13 +465,13 @@ requests = ">=2.20.0" [[package]] name = "azure-identity" -version = "1.16.1" +version = "1.17.0" description = "Microsoft Azure Identity Library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "azure-identity-1.16.1.tar.gz", hash = "sha256:6d93f04468f240d59246d8afde3091494a5040d4f141cad0f49fc0c399d0d91e"***REMOVED***, - {file = "azure_identity-1.16.1-py3-none-any.whl", hash = "sha256:8fb07c25642cd4ac422559a8b50d3e77f73dcc2bbfaba419d06d6c9d7cff6726"***REMOVED***, + {file = "azure-identity-1.17.0.tar.gz", hash = "sha256:a1168f223b2d7fa3968362b78affd157a1f3772f310a8cdce883cc515c6c8998"***REMOVED***, + {file = "azure_identity-1.17.0-py3-none-any.whl", hash = "sha256:501d6c2cbb07826ff5e7cebc05ef319ba45612f31f0bdadca260005798e4ef16"***REMOVED***, ] [package.dependencies] @@ -479,6 +479,7 @@ azure-core = ">=1.23.0" cryptography = ">=2.5" msal = ">=1.24.0" msal-extensions = ">=0.3.0" +typing-extensions = ">=4.0.0" [[package]] name = "azure-search-documents" @@ -1165,13 +1166,13 @@ tests = ["pytest", "pytest-cov", "pytest-xdist"] [[package]] name = "dask" -version = "2024.5.2" +version = "2024.6.0" description = "Parallel PyData with Task Scheduling" optional = false python-versions = ">=3.9" files = [ - {file = "dask-2024.5.2-py3-none-any.whl", hash = "sha256:acc2cfe41d9e0151c216ac40396dbe34df13bc3d8c51dfece190349e4f2243af"***REMOVED***, - {file = "dask-2024.5.2.tar.gz", hash = "sha256:5c9722c44d0195e78b6e54197aa3302e6fcaaac2310fd3014560bcb86253dcb3"***REMOVED***, + {file = "dask-2024.6.0-py3-none-any.whl", hash = "sha256:de0ced6cd46dbc6c01120c8870457af46d667940805a4be063a74dd467466804"***REMOVED***, + {file = "dask-2024.6.0.tar.gz", hash = "sha256:6882ce7e485336d707e540080ed48e01f9c09485d52a2928ea05f9a9e44bb433"***REMOVED***, ] [package.dependencies] @@ -1192,22 +1193,22 @@ array = ["numpy (>=1.21)"] complete = ["dask[array,dataframe,diagnostics,distributed]", "lz4 (>=4.3.2)", "pyarrow (>=7.0)", "pyarrow-hotfix"] dataframe = ["dask-expr (>=1.1,<1.2)", "dask[array]", "pandas (>=1.3)"] diagnostics = ["bokeh (>=2.4.2)", "jinja2 (>=2.10.3)"] -distributed = ["distributed (==2024.5.2)"] +distributed = ["distributed (==2024.6.0)"] test = ["pandas[test]", "pre-commit", "pytest", "pytest-cov", "pytest-rerunfailures", "pytest-timeout", "pytest-xdist"] [[package]] name = "dask-expr" -version = "1.1.2" +version = "1.1.3" description = "High Level Expressions for Dask" optional = false python-versions = ">=3.9" files = [ - {file = "dask_expr-1.1.2-py3-none-any.whl", hash = "sha256:3be69fb2d449b5edf4404e953b7f6e688426872c6eb10f239539ead716a06f7a"***REMOVED***, - {file = "dask_expr-1.1.2.tar.gz", hash = "sha256:ce2e3803b638cdc67bc75326e1b0d36ea9d231fdddf086e727145a5a2769bed4"***REMOVED***, + {file = "dask_expr-1.1.3-py3-none-any.whl", hash = "sha256:e6ad2fab9ffe7dbe0fc52451b5a0dc5588f36cd5677168cfb0b73c70f05e465f"***REMOVED***, + {file = "dask_expr-1.1.3.tar.gz", hash = "sha256:ce8e44dfed30b4d9e6a549d0ed8cb5798273645fb9a16733d0687dc84615a94b"***REMOVED***, ] [package.dependencies] -dask = "2024.5.2" +dask = "2024.6.0" pandas = ">=2" pyarrow = ">=7.0.0" @@ -1472,13 +1473,13 @@ pgp = ["gpg"] [[package]] name = "email-validator" -version = "2.1.1" +version = "2.1.2" description = "A robust email address syntax and deliverability validation library." optional = false python-versions = ">=3.8" files = [ - {file = "email_validator-2.1.1-py3-none-any.whl", hash = "sha256:97d882d174e2a65732fb43bfce81a3a834cbc1bde8bf419e30ef5ea976370a05"***REMOVED***, - {file = "email_validator-2.1.1.tar.gz", hash = "sha256:200a70680ba08904be6d1eef729205cc0d687634399a5924d842533efb824b84"***REMOVED***, + {file = "email_validator-2.1.2-py3-none-any.whl", hash = "sha256:d89f6324e13b1e39889eab7f9ca2f91dc9aebb6fa50a6d8bd4329ab50f251115"***REMOVED***, + {file = "email_validator-2.1.2.tar.gz", hash = "sha256:14c0f3d343c4beda37400421b39fa411bbe33a75df20825df73ad53e06a9f04c"***REMOVED***, ] [package.dependencies] @@ -1579,13 +1580,13 @@ standard = ["fastapi", "uvicorn[standard] (>=0.15.0)"] [[package]] name = "fastjsonschema" -version = "2.19.1" +version = "2.20.0" description = "Fastest Python implementation of JSON schema" optional = false python-versions = "*" files = [ - {file = "fastjsonschema-2.19.1-py3-none-any.whl", hash = "sha256:3672b47bc94178c9f23dbb654bf47440155d4db9df5f7bc47643315f9c405cd0"***REMOVED***, - {file = "fastjsonschema-2.19.1.tar.gz", hash = "sha256:e3126a94bdc4623d3de4485f8d468a12f02a67921315ddc87836d6e456dc789d"***REMOVED***, + {file = "fastjsonschema-2.20.0-py3-none-any.whl", hash = "sha256:5875f0b0fa7a0043a91e93a9b8f793bcbbba9691e7fd83dca95c28ba26d21f0a"***REMOVED***, + {file = "fastjsonschema-2.20.0.tar.gz", hash = "sha256:3d48fc5300ee96f5d116f10fe6f28d938e6008f59a6a025c2649475b87f76a23"***REMOVED***, ] [package.extras] @@ -1660,18 +1661,18 @@ typing = ["typing-extensions (>=4.8)"] [[package]] name = "flake8" -version = "7.0.0" +version = "7.1.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false python-versions = ">=3.8.1" files = [ - {file = "flake8-7.0.0-py2.py3-none-any.whl", hash = "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3"***REMOVED***, - {file = "flake8-7.0.0.tar.gz", hash = "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132"***REMOVED***, + {file = "flake8-7.1.0-py2.py3-none-any.whl", hash = "sha256:2e416edcc62471a64cea09353f4e7bdba32aeb079b6e360554c659a122b1bc6a"***REMOVED***, + {file = "flake8-7.1.0.tar.gz", hash = "sha256:48a07b626b55236e0fb4784ee69a465fbf59d79eec1f5b4785c3d3bc57d17aa5"***REMOVED***, ] [package.dependencies] mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.11.0,<2.12.0" +pycodestyle = ">=2.12.0,<2.13.0" pyflakes = ">=3.2.0,<3.3.0" [[package]] @@ -2029,7 +2030,7 @@ description = "" optional = false python-versions = ">=3.10,<3.13" files = [ - {file = "graphrag-0.0.1-py3-none-any.whl", hash = "sha256:a2ea8997552378906bde9d997e4209206c7009b34c33b4365ce738a072035a18"***REMOVED***, + {file = "graphrag-0.0.1-py3-none-any.whl", hash = "sha256:afcf23c41f91f1be084e7634afefb2567681fb6ce72033b4b2b8a331006f79c6"***REMOVED***, ] [package.dependencies] @@ -4601,13 +4602,13 @@ pyasn1 = ">=0.4.6,<0.7.0" [[package]] name = "pycodestyle" -version = "2.11.1" +version = "2.12.0" description = "Python style guide checker" optional = false python-versions = ">=3.8" files = [ - {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"***REMOVED***, - {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"***REMOVED***, + {file = "pycodestyle-2.12.0-py2.py3-none-any.whl", hash = "sha256:949a39f6b86c3e1515ba1787c2022131d165a8ad271b11370a8819aa070269e4"***REMOVED***, + {file = "pycodestyle-2.12.0.tar.gz", hash = "sha256:442f950141b4f43df752dd303511ffded3a04c2b6fb7f65980574f0c31e6e79c"***REMOVED***, ] [[package]] @@ -4823,13 +4824,13 @@ torch = ["torch"] [[package]] name = "pynndescent" -version = "0.5.12" +version = "0.5.13" description = "Nearest Neighbor Descent" optional = false python-versions = "*" files = [ - {file = "pynndescent-0.5.12-py3-none-any.whl", hash = "sha256:9023dc5fea520a4e84d0633ae735db97d2509da927bfa86c897e61f3315473c7"***REMOVED***, - {file = "pynndescent-0.5.12.tar.gz", hash = "sha256:0736291fcbbedfd5e0a3a280f71a63f8eb2f8bd9670d4c0b51ac1b4d081adf70"***REMOVED***, + {file = "pynndescent-0.5.13-py3-none-any.whl", hash = "sha256:69aabb8f394bc631b6ac475a1c7f3994c54adf3f51cd63b2730fefba5771b949"***REMOVED***, + {file = "pynndescent-0.5.13.tar.gz", hash = "sha256:d74254c0ee0a1eeec84597d5fe89fedcf778593eeabe32c2f97412934a9800fb"***REMOVED***, ] [package.dependencies] @@ -5658,28 +5659,28 @@ pyasn1 = ">=0.1.3" [[package]] name = "ruff" -version = "0.4.8" +version = "0.4.9" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.4.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7663a6d78f6adb0eab270fa9cf1ff2d28618ca3a652b60f2a234d92b9ec89066"***REMOVED***, - {file = "ruff-0.4.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eeceb78da8afb6de0ddada93112869852d04f1cd0f6b80fe464fd4e35c330913"***REMOVED***, - {file = "ruff-0.4.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aad360893e92486662ef3be0a339c5ca3c1b109e0134fcd37d534d4be9fb8de3"***REMOVED***, - {file = "ruff-0.4.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:284c2e3f3396fb05f5f803c9fffb53ebbe09a3ebe7dda2929ed8d73ded736deb"***REMOVED***, - {file = "ruff-0.4.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7354f921e3fbe04d2a62d46707e569f9315e1a613307f7311a935743c51a764"***REMOVED***, - {file = "ruff-0.4.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:72584676164e15a68a15778fd1b17c28a519e7a0622161eb2debdcdabdc71883"***REMOVED***, - {file = "ruff-0.4.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9678d5c9b43315f323af2233a04d747409d1e3aa6789620083a82d1066a35199"***REMOVED***, - {file = "ruff-0.4.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704977a658131651a22b5ebeb28b717ef42ac6ee3b11e91dc87b633b5d83142b"***REMOVED***, - {file = "ruff-0.4.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05f8d6f0c3cce5026cecd83b7a143dcad503045857bc49662f736437380ad45"***REMOVED***, - {file = "ruff-0.4.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ea874950daca5697309d976c9afba830d3bf0ed66887481d6bca1673fc5b66a"***REMOVED***, - {file = "ruff-0.4.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fc95aac2943ddf360376be9aa3107c8cf9640083940a8c5bd824be692d2216dc"***REMOVED***, - {file = "ruff-0.4.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:384154a1c3f4bf537bac69f33720957ee49ac8d484bfc91720cc94172026ceed"***REMOVED***, - {file = "ruff-0.4.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e9d5ce97cacc99878aa0d084c626a15cd21e6b3d53fd6f9112b7fc485918e1fa"***REMOVED***, - {file = "ruff-0.4.8-py3-none-win32.whl", hash = "sha256:6d795d7639212c2dfd01991259460101c22aabf420d9b943f153ab9d9706e6a9"***REMOVED***, - {file = "ruff-0.4.8-py3-none-win_amd64.whl", hash = "sha256:e14a3a095d07560a9d6769a72f781d73259655919d9b396c650fc98a8157555d"***REMOVED***, - {file = "ruff-0.4.8-py3-none-win_arm64.whl", hash = "sha256:14019a06dbe29b608f6b7cbcec300e3170a8d86efaddb7b23405cb7f7dcaf780"***REMOVED***, - {file = "ruff-0.4.8.tar.gz", hash = "sha256:16d717b1d57b2e2fd68bd0bf80fb43931b79d05a7131aa477d66fc40fbd86268"***REMOVED***, + {file = "ruff-0.4.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b262ed08d036ebe162123170b35703aaf9daffecb698cd367a8d585157732991"***REMOVED***, + {file = "ruff-0.4.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:98ec2775fd2d856dc405635e5ee4ff177920f2141b8e2d9eb5bd6efd50e80317"***REMOVED***, + {file = "ruff-0.4.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4555056049d46d8a381f746680db1c46e67ac3b00d714606304077682832998e"***REMOVED***, + {file = "ruff-0.4.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e91175fbe48f8a2174c9aad70438fe9cb0a5732c4159b2a10a3565fea2d94cde"***REMOVED***, + {file = "ruff-0.4.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e8e7b95673f22e0efd3571fb5b0cf71a5eaaa3cc8a776584f3b2cc878e46bff"***REMOVED***, + {file = "ruff-0.4.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2d45ddc6d82e1190ea737341326ecbc9a61447ba331b0a8962869fcada758505"***REMOVED***, + {file = "ruff-0.4.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:78de3fdb95c4af084087628132336772b1c5044f6e710739d440fc0bccf4d321"***REMOVED***, + {file = "ruff-0.4.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06b60f91bfa5514bb689b500a25ba48e897d18fea14dce14b48a0c40d1635893"***REMOVED***, + {file = "ruff-0.4.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88bffe9c6a454bf8529f9ab9091c99490578a593cc9f9822b7fc065ee0712a06"***REMOVED***, + {file = "ruff-0.4.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:673bddb893f21ab47a8334c8e0ea7fd6598ecc8e698da75bcd12a7b9d0a3206e"***REMOVED***, + {file = "ruff-0.4.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c1aff58c31948cc66d0b22951aa19edb5af0a3af40c936340cd32a8b1ab7438"***REMOVED***, + {file = "ruff-0.4.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:784d3ec9bd6493c3b720a0b76f741e6c2d7d44f6b2be87f5eef1ae8cc1d54c84"***REMOVED***, + {file = "ruff-0.4.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:732dd550bfa5d85af8c3c6cbc47ba5b67c6aed8a89e2f011b908fc88f87649db"***REMOVED***, + {file = "ruff-0.4.9-py3-none-win32.whl", hash = "sha256:8064590fd1a50dcf4909c268b0e7c2498253273309ad3d97e4a752bb9df4f521"***REMOVED***, + {file = "ruff-0.4.9-py3-none-win_amd64.whl", hash = "sha256:e0a22c4157e53d006530c902107c7f550b9233e9706313ab57b892d7197d8e52"***REMOVED***, + {file = "ruff-0.4.9-py3-none-win_arm64.whl", hash = "sha256:5d5460f789ccf4efd43f265a58538a2c24dbce15dbf560676e430375f20a8198"***REMOVED***, + {file = "ruff-0.4.9.tar.gz", hash = "sha256:f1cb0828ac9533ba0135d148d214e284711ede33640465e706772645483427e3"***REMOVED***, ] [[package]] @@ -6067,13 +6068,13 @@ notebook = ["ipywidgets (>=7.0.0)"] [[package]] name = "tenacity" -version = "8.3.0" +version = "8.4.1" description = "Retry code until it succeeds" optional = false python-versions = ">=3.8" files = [ - {file = "tenacity-8.3.0-py3-none-any.whl", hash = "sha256:3649f6443dbc0d9b01b9d8020a9c4ec7a1ff5f6f3c6c8a036ef371f573fe9185"***REMOVED***, - {file = "tenacity-8.3.0.tar.gz", hash = "sha256:953d4e6ad24357bceffbc9707bc74349aca9d245f68eb65419cf0c249a1949a2"***REMOVED***, + {file = "tenacity-8.4.1-py3-none-any.whl", hash = "sha256:28522e692eda3e1b8f5e99c51464efcc0b9fc86933da92415168bc1c4e2308fa"***REMOVED***, + {file = "tenacity-8.4.1.tar.gz", hash = "sha256:54b1412b878ddf7e1f1577cd49527bad8cdef32421bd599beac0c6c3f10582fd"***REMOVED***, ] [package.extras] @@ -6502,13 +6503,13 @@ dev = ["flake8", "flake8-annotations", "flake8-bandit", "flake8-bugbear", "flake [[package]] name = "urllib3" -version = "2.2.1" +version = "2.2.2" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"***REMOVED***, - {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"***REMOVED***, + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"***REMOVED***, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"***REMOVED***, ] [package.extras]