From 96541599fd25ab8708b48b5842fae9b9b5a69243 Mon Sep 17 00:00:00 2001 From: Michael Chi Date: Tue, 1 Apr 2025 01:29:32 +0000 Subject: [PATCH 1/3] feat/baseball-simulation --- examples/smart-npc/.gcloudignore | 10 + examples/smart-npc/.gitignore | 25 + examples/smart-npc/Dockerfile | 28 + examples/smart-npc/README.md | 56 ++ examples/smart-npc/config.app.toml.template | 189 +++++ examples/smart-npc/config.gcp.toml.template | 13 + examples/smart-npc/config.toml.example | 208 ++++++ .../smart-npc/docs/0-SmartNPC-API-Flow.md | 24 + examples/smart-npc/docs/1-Game-Flow.md | 25 + examples/smart-npc/docs/3-Database.md | 137 ++++ examples/smart-npc/docs/4-Flow.md | 23 + .../baseball/sql/insert_conv_examples.sql | 39 + .../example/baseball/sql/insert_lineup.sql | 265 +++++++ .../example/baseball/sql/insert_prompts.sql | 691 ++++++++++++++++++ .../example/baseball/sql/insert_roster.sql | 637 ++++++++++++++++ .../example/baseball/sql/insert_scene.sql | 58 ++ .../baseball/sql/insert_teams_json.sql | 495 +++++++++++++ .../smart-npc/example/baseball/sql/schema.sql | 3 + .../sql/schema_conversation_example.sql | 10 + .../baseball/sql/schema_conversation_log.sql | 17 + .../example/baseball/sql/schema_lineup.sql | 10 + .../example/baseball/sql/schema_memory.sql | 16 + .../example/baseball/sql/schema_npc.sql | 14 + .../example/baseball/sql/schema_player.sql | 13 + .../example/baseball/sql/schema_prompt.sql | 11 + .../example/baseball/sql/schema_rosters.sql | 10 + .../example/baseball/sql/schema_scene.sql | 13 + .../example/baseball/sql/schema_teams.sql | 12 + .../baseball/sql/schema_world_background.sql | 12 + examples/smart-npc/k8s.template.yaml | 122 ++++ examples/smart-npc/skaffold.template.yaml | 37 + examples/smart-npc/src/main.py | 104 +++ examples/smart-npc/src/models/baseball.py | 97 +++ examples/smart-npc/src/models/npc.py | 62 ++ examples/smart-npc/src/models/prompt.py | 38 + examples/smart-npc/src/models/scence.py | 54 ++ examples/smart-npc/src/requirements.txt | 46 ++ examples/smart-npc/src/routers/baseball.py | 386 ++++++++++ examples/smart-npc/src/routers/cache.py | 140 ++++ examples/smart-npc/src/routers/knowledge.py | 93 +++ examples/smart-npc/src/routers/npc.py | 68 ++ examples/smart-npc/src/routers/prompts.py | 68 ++ examples/smart-npc/src/routers/scene.py | 206 ++++++ examples/smart-npc/src/utils/baseball.py | 485 ++++++++++++ .../smart-npc/src/utils/baseball_streaming.py | 245 +++++++ examples/smart-npc/src/utils/cacheWrapper.py | 184 +++++ examples/smart-npc/src/utils/const.py | 15 + .../src/utils/conversationManager.py | 191 +++++ examples/smart-npc/src/utils/database.py | 98 +++ examples/smart-npc/src/utils/llm.py | 120 +++ examples/smart-npc/src/utils/llmValidator.py | 78 ++ examples/smart-npc/src/utils/npcManager.py | 85 +++ examples/smart-npc/src/utils/promptManager.py | 258 +++++++ .../smart-npc/src/utils/quickstartWrapper.py | 138 ++++ examples/smart-npc/src/utils/rag.py | 149 ++++ examples/smart-npc/src/utils/sceneManager.py | 88 +++ 56 files changed, 6719 insertions(+) create mode 100644 examples/smart-npc/.gcloudignore create mode 100644 examples/smart-npc/.gitignore create mode 100644 examples/smart-npc/Dockerfile create mode 100644 examples/smart-npc/README.md create mode 100644 examples/smart-npc/config.app.toml.template create mode 100755 examples/smart-npc/config.gcp.toml.template create mode 100644 examples/smart-npc/config.toml.example create mode 100644 examples/smart-npc/docs/0-SmartNPC-API-Flow.md create mode 100644 examples/smart-npc/docs/1-Game-Flow.md create mode 100644 examples/smart-npc/docs/3-Database.md create mode 100644 examples/smart-npc/docs/4-Flow.md create mode 100644 examples/smart-npc/example/baseball/sql/insert_conv_examples.sql create mode 100644 examples/smart-npc/example/baseball/sql/insert_lineup.sql create mode 100644 examples/smart-npc/example/baseball/sql/insert_prompts.sql create mode 100644 examples/smart-npc/example/baseball/sql/insert_roster.sql create mode 100644 examples/smart-npc/example/baseball/sql/insert_scene.sql create mode 100644 examples/smart-npc/example/baseball/sql/insert_teams_json.sql create mode 100644 examples/smart-npc/example/baseball/sql/schema.sql create mode 100644 examples/smart-npc/example/baseball/sql/schema_conversation_example.sql create mode 100644 examples/smart-npc/example/baseball/sql/schema_conversation_log.sql create mode 100644 examples/smart-npc/example/baseball/sql/schema_lineup.sql create mode 100644 examples/smart-npc/example/baseball/sql/schema_memory.sql create mode 100644 examples/smart-npc/example/baseball/sql/schema_npc.sql create mode 100644 examples/smart-npc/example/baseball/sql/schema_player.sql create mode 100644 examples/smart-npc/example/baseball/sql/schema_prompt.sql create mode 100644 examples/smart-npc/example/baseball/sql/schema_rosters.sql create mode 100644 examples/smart-npc/example/baseball/sql/schema_scene.sql create mode 100644 examples/smart-npc/example/baseball/sql/schema_teams.sql create mode 100644 examples/smart-npc/example/baseball/sql/schema_world_background.sql create mode 100644 examples/smart-npc/k8s.template.yaml create mode 100644 examples/smart-npc/skaffold.template.yaml create mode 100644 examples/smart-npc/src/main.py create mode 100644 examples/smart-npc/src/models/baseball.py create mode 100644 examples/smart-npc/src/models/npc.py create mode 100644 examples/smart-npc/src/models/prompt.py create mode 100644 examples/smart-npc/src/models/scence.py create mode 100644 examples/smart-npc/src/requirements.txt create mode 100644 examples/smart-npc/src/routers/baseball.py create mode 100644 examples/smart-npc/src/routers/cache.py create mode 100644 examples/smart-npc/src/routers/knowledge.py create mode 100644 examples/smart-npc/src/routers/npc.py create mode 100644 examples/smart-npc/src/routers/prompts.py create mode 100644 examples/smart-npc/src/routers/scene.py create mode 100644 examples/smart-npc/src/utils/baseball.py create mode 100644 examples/smart-npc/src/utils/baseball_streaming.py create mode 100644 examples/smart-npc/src/utils/cacheWrapper.py create mode 100644 examples/smart-npc/src/utils/const.py create mode 100644 examples/smart-npc/src/utils/conversationManager.py create mode 100644 examples/smart-npc/src/utils/database.py create mode 100644 examples/smart-npc/src/utils/llm.py create mode 100644 examples/smart-npc/src/utils/llmValidator.py create mode 100644 examples/smart-npc/src/utils/npcManager.py create mode 100644 examples/smart-npc/src/utils/promptManager.py create mode 100644 examples/smart-npc/src/utils/quickstartWrapper.py create mode 100644 examples/smart-npc/src/utils/rag.py create mode 100644 examples/smart-npc/src/utils/sceneManager.py diff --git a/examples/smart-npc/.gcloudignore b/examples/smart-npc/.gcloudignore new file mode 100644 index 0000000..37e5fe3 --- /dev/null +++ b/examples/smart-npc/.gcloudignore @@ -0,0 +1,10 @@ +# Python Environment +.venv/ +__pycache__/ +run.log + +# NodeJS +node_modules/ + +# Dev +test/ diff --git a/examples/smart-npc/.gitignore b/examples/smart-npc/.gitignore new file mode 100644 index 0000000..7c18b50 --- /dev/null +++ b/examples/smart-npc/.gitignore @@ -0,0 +1,25 @@ +# Secrets +config-secret.toml + +# Terraform +.terraform +terraform.tfstate +terraform.tfstate.backup +variables.tfvars + +# Python Environment +.venv/ +__pycache__/ +run.log + +# NodeJS +node_modules/ + +# Smart NPC +# the following files is generated by Terraform. +**/config.yaml.template +**/config.yaml +**/config.toml +test/ +k8s.yaml +skaffold.yaml diff --git a/examples/smart-npc/Dockerfile b/examples/smart-npc/Dockerfile new file mode 100644 index 0000000..c27c9ba --- /dev/null +++ b/examples/smart-npc/Dockerfile @@ -0,0 +1,28 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM python:3.11-slim + +# Allow statements and log messages to immediately appear in the Knative logs +ENV PYTHONUNBUFFERED True + +WORKDIR /app + +COPY src /app/ +RUN pip install --no-cache-dir -r requirements.txt +COPY ./config.toml /app/ + +EXPOSE 8080 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/examples/smart-npc/README.md b/examples/smart-npc/README.md new file mode 100644 index 0000000..3e112ff --- /dev/null +++ b/examples/smart-npc/README.md @@ -0,0 +1,56 @@ + +# Smart NPC + +The Smart NPC demonstrates using Gemini-1.5-Flash to +generate NPC dialogues while maintaining the character personality, +storyline and scene settings thorughout the conversation. + +Players are expected to achieve an objective of the scene, Gemini simulate +involving NPCs to respond to the player while implicitly guiding the player +toward the objective. + +## Baseball simulation demo game + +This example using the concept of LLM powered Smart NPC in a baseball simulation game, +where the player plays the coach of a baseball team. The `NPC` which powered by the LLM +provides tactics suggestions to the player. + +## Applicaiton Flow + +[Smart NPC API flow](./docs/0-SmartNPC-API-Flow.md) + +[Game flow](./docs/1-Game-Flow.md) + +## Database Tables + +[Database Tables](./docs/3-Database.md) + +## Configurations + +* [config.app.toml.template](./config.app.toml.template) contains the SQL queries for +game logics in the `baseball` section. + +It also holds SQL queries for the framework itself in `sql` section. + +* [const.py](./src/utils/const.py) determines if the game uses Google for Games Quick Start +as the LLM backend. Update the `USE_QUICK_START` to `False` to invoke Gemini 2.0 API directly. + +## Deploy the Application + +* Deploy the application + +``` +cd $CUR_DIR/examples/smart-npc + +export PROJECT_ID=$(gcloud config list --format 'value(core.project)' 2>/dev/null) + +find . -type f -name "*.template.yaml" -exec \ + bash -c "template_path={}; sed \"s:your-unique-project-id:${PROJECT_ID:?}:g\" < \${template_path} > \${template_path/%.template.yaml/.yaml} " \; + +skaffold run +``` + +* Create database and ingest data + +Use your PostgreSQL client of choice, execute [SQL scripts](./example/baseball/sql/) to create +database tables and insert data to the tables. diff --git a/examples/smart-npc/config.app.toml.template b/examples/smart-npc/config.app.toml.template new file mode 100644 index 0000000..7c96107 --- /dev/null +++ b/examples/smart-npc/config.app.toml.template @@ -0,0 +1,189 @@ + +[game] +game_id = "baseball" +enable_validator = "False" + +[npc] +RESPONSE_LANGUAGE = "en-US" + +[sql] +QUERY_SCENE = """ +select scene_id, scene, status, goal, npcs, knowledge, game_id from smartnpc.scene +where scene_id=:scene_id and game_id=:game_id +""" + +QUERY_NPC_BY_ID = """ +select npc_id, game_id, background, name, class, class_level, status, lore_level from smartnpc.npc +where npc_id=:npc_id and game_id=:game_id +""" + +QUERY_NPC_KNOWLEDGE = """ +select background_id, + background_name, + content, + lore_level, + background, + (1 - (background_embeddings <=> :query_embeddings)) as score + from games.world_background + where lore_level <= :lore_level + order by score desc + limit 5 +""" + +QUERY_SEARCH_QUESTS_ALL = """ +select + game_id, + quest_id, + quest_story, + min_level, + metadata, + quest_name, + provider_id + from games.quests + where provider_id = :provider_id and game_id=:game_id + limit 5 +""" + +QUERY_SEARCH_QUESTS = """ +select + game_id, + quest_id, + quest_story, + min_level, + metadata, + quest_name, + provider_id, + (1 - (quest_embeddings <=> :query_embeddings)) as score + from games.quests + where provider_id = :provider_id and game_id=:game_id + order by (1 - (quest_embeddings <=> :query_embeddings)) desc + limit 5 +""" + +QUERY_PROMPT_TEMPLATE = """ + +SELECT prompt_id, + scene_id, + game_id, + prompt_template, + is_activate +FROM smartnpc.prompt_template +WHERE + is_activate = True and + prompt_id=:prompt_id and + game_id=:game_id and + CASE + WHEN scene_id = :scene_id THEN scene_id + ELSE 'default' + END = scene_id; +""" + +QUERY_CONV_EXAMPLE = """ +select + example_id, + game_id, + scene_id, + conversation_example, + is_activate +from smartnpc.conversation_examples +where + game_id=:game_id and + CASE + WHEN scene_id = :scene_id THEN scene_id + ELSE 'default' + END = scene_id + and + CASE + WHEN example_id = :example_id THEN example_id + ELSE 'default' + END = example_id; + +""" + +[baseball] +QUERY_TEAM = """ +select team_id, + team_name, + team_year, + description, + roster, + default_lineup +from smartnpc.teams +where team_id=:team_id +""" + +QUERY_TEAMS = """ +select team_id, + team_name, + team_year, + description, + roster, + default_lineup +from smartnpc.teams +""" + +QUERY_TEAM_ROSTER = """ +select team_id, + session_id, + player_id, + roster +from smartnpc.rosters +where + team_id=:team_id And + session_id=:session_id And + player_id=:player_id +""" + +QUERY_TEAM_LINEUP = """ +select + team_id, + player_id, + session_id, + lineup +from smartnpc.lineup +where + team_id=:team_id And + session_id=:session_id And + player_id=:player_id +""" + +UPSERT_TEAM_LINEUP = """ +INSERT INTO smartnpc.lineup( + team_id, + player_id, + session_id, + lineup +) +values( + :team_id, + :player_id, + :session_id, + :lineup +) +ON CONFLICT(team_id, player_id) +DO UPDATE SET + session_id = :session_id, + lineup = :lineup; +""" + + +UPSERT_TEAM_ROSTER = """ +INSERT INTO smartnpc.rosters( + team_id, + session_id, + player_id, + roster +) +values( + :team_id, + :session_id, + :player_id, + :roster +) +ON CONFLICT(team_id, session_id) +DO UPDATE SET + player_id = :player_id, + team_id=:team_id, + session_id=:session_id, + roster = :roster; +""" diff --git a/examples/smart-npc/config.gcp.toml.template b/examples/smart-npc/config.gcp.toml.template new file mode 100755 index 0000000..5bbf4ad --- /dev/null +++ b/examples/smart-npc/config.gcp.toml.template @@ -0,0 +1,13 @@ +["gcp"] +database_private_ip_address="" +database_public_ip_address="" +postgres_instance_connection_name="" +database_user_name="llmuser" +database_password_key="pgvector-password" +image_upload_gcs_bucket="" +google-project-id="" +cache-server-host = "" +cache-server-port = 6379 +use-cache-server = "True" # "False" if running locally +google-default-region = "us-central1" +api-key = "" diff --git a/examples/smart-npc/config.toml.example b/examples/smart-npc/config.toml.example new file mode 100644 index 0000000..1708552 --- /dev/null +++ b/examples/smart-npc/config.toml.example @@ -0,0 +1,208 @@ +# ################################################## +# Example config.toml +# - `gcp` section should be fill in by Terraform +# ################################################## + +["gcp"] +database_private_ip_address="" +database_public_ip_address="" +postgres_instance_connection_name="" +database_user_name="llmuser" +database_password_key="pgvector-password" +image_upload_gcs_bucket="" +google-project-id="" +cache-server-host = "" +cache-server-port = 6379 +use-cache-server = "True" # "False" if running locally +google-default-region = "us-central1" +api-key = "" + + +[game] +game_id = "baseball" +enable_validator = "False" + +[npc] +RESPONSE_LANGUAGE = "en-US" + +[sql] +QUERY_SCENE = """ +select scene_id, scene, status, goal, npcs, knowledge, game_id from smartnpc.scene +where scene_id=:scene_id and game_id=:game_id +""" + +QUERY_NPC_BY_ID = """ +select npc_id, game_id, background, name, class, class_level, status, lore_level from smartnpc.npc +where npc_id=:npc_id and game_id=:game_id +""" + +QUERY_NPC_KNOWLEDGE = """ +select background_id, + background_name, + content, + lore_level, + background, + (1 - (background_embeddings <=> :query_embeddings)) as score + from games.world_background + where lore_level <= :lore_level + order by score desc + limit 5 +""" + +QUERY_SEARCH_QUESTS_ALL = """ +select + game_id, + quest_id, + quest_story, + min_level, + metadata, + quest_name, + provider_id + from games.quests + where provider_id = :provider_id and game_id=:game_id + limit 5 +""" + +QUERY_SEARCH_QUESTS = """ +select + game_id, + quest_id, + quest_story, + min_level, + metadata, + quest_name, + provider_id, + (1 - (quest_embeddings <=> :query_embeddings)) as score + from games.quests + where provider_id = :provider_id and game_id=:game_id + order by (1 - (quest_embeddings <=> :query_embeddings)) desc + limit 5 +""" + +QUERY_PROMPT_TEMPLATE = """ + +SELECT prompt_id, + scene_id, + game_id, + prompt_template, + is_activate +FROM smartnpc.prompt_template +WHERE + is_activate = True and + prompt_id=:prompt_id and + game_id=:game_id and + CASE + WHEN scene_id = :scene_id THEN scene_id + ELSE 'default' + END = scene_id; +""" + +QUERY_CONV_EXAMPLE = """ +select + example_id, + game_id, + scene_id, + conversation_example, + is_activate +from smartnpc.conversation_examples +where + game_id=:game_id and + CASE + WHEN scene_id = :scene_id THEN scene_id + ELSE 'default' + END = scene_id + and + CASE + WHEN example_id = :example_id THEN example_id + ELSE 'default' + END = example_id; + +""" + +[baseball] +QUERY_TEAM = """ +select team_id, + team_name, + team_year, + description, + roster, + default_lineup +from smartnpc.teams +where team_id=:team_id +""" + +QUERY_TEAMS = """ +select team_id, + team_name, + team_year, + description, + roster, + default_lineup +from smartnpc.teams +""" + +QUERY_TEAM_ROSTER = """ +select team_id, + session_id, + player_id, + roster +from smartnpc.rosters +where + team_id=:team_id And + session_id=:session_id And + player_id=:player_id +""" + +QUERY_TEAM_LINEUP = """ +select + team_id, + player_id, + session_id, + lineup +from smartnpc.lineup +where + team_id=:team_id And + session_id=:session_id And + player_id=:player_id +""" + +UPSERT_TEAM_LINEUP = """ +INSERT INTO smartnpc.lineup( + team_id, + player_id, + session_id, + lineup +) +values( + :team_id, + :player_id, + :session_id, + :lineup +) +ON CONFLICT(team_id, player_id) +DO UPDATE SET + session_id = :session_id, + lineup = :lineup; +""" + + +UPSERT_TEAM_ROSTER = """ +INSERT INTO smartnpc.rosters( + team_id, + session_id, + player_id, + roster +) +values( + :team_id, + :session_id, + :player_id, + :roster +) +ON CONFLICT(team_id, session_id) +DO UPDATE SET + player_id = :player_id, + team_id=:team_id, + session_id=:session_id, + roster = :roster; +""" diff --git a/examples/smart-npc/docs/0-SmartNPC-API-Flow.md b/examples/smart-npc/docs/0-SmartNPC-API-Flow.md new file mode 100644 index 0000000..bf4f5b1 --- /dev/null +++ b/examples/smart-npc/docs/0-SmartNPC-API-Flow.md @@ -0,0 +1,24 @@ +## REST API Flow + +```mermaid +sequenceDiagram + +Game Frontend ->> Smart NPC API: request +Smart NPC API ->> Quick Start dispatcher endpoint: request +Quick Start dispatcher endpoint ->> Vertex Chat API: request +Vertex Chat API -->> Quick Start dispatcher endpoint: response +Quick Start dispatcher endpoint -->> Smart NPC API: response +Smart NPC API -->>Game Frontend: response +``` + +## Websocket / Streaming API Flow + +```mermaid +sequenceDiagram + +Game Frontend ->> Smart NPC API: streaming request +Smart NPC API ->> Smart NPC API: construct prompt +Smart NPC API -->> Gemini API: generate response +Gemini API -->> Smart NPC API: response +Smart NPC API -->>Game Frontend: response +``` diff --git a/examples/smart-npc/docs/1-Game-Flow.md b/examples/smart-npc/docs/1-Game-Flow.md new file mode 100644 index 0000000..b64cefc --- /dev/null +++ b/examples/smart-npc/docs/1-Game-Flow.md @@ -0,0 +1,25 @@ + + +```mermaid +sequenceDiagram + +participant Game Frontend +participant API +participant Gemini + +alt New Game +Game Frontend ->> Game Frontend: new_game() +Game Frontend ->> Smart NPC API: get_lineup(team.id) +Smart NPC API ->> Game Frontend: lineup +else Get Suggestions +Game Frontend ->> Game Frontend: get_linup(player team) +Game Frontend ->> Game Frontend: get_linup(computer team) +Game Frontend ->> Smart NPC API: get_tactic_suggestion(payload:{player lineup, computer lineup) +Smart NPC API ->> Smart NPC API: construct prompt(scene_id, player lineup, computer lineup) +Smart NPC API ->> Gemini: get response +Gemini -->> Smart NPC API: response:{outcomes, recommendations, tactics} +Smart NPC API -->> Game Frontend: response:{outcomes, recommendations, tactics} +else Simulation +Game Frontend ->> Game Frontend: simulate_at_bat() +Note over Game Frontend, Gemini: Repeat [Get Suggestions] and [Simulation] +end \ No newline at end of file diff --git a/examples/smart-npc/docs/3-Database.md b/examples/smart-npc/docs/3-Database.md new file mode 100644 index 0000000..fef6905 --- /dev/null +++ b/examples/smart-npc/docs/3-Database.md @@ -0,0 +1,137 @@ +# Database + +Key tables for the baseball simulation games are: + +| Table Name | Description | +|:--:|:--:| +| scene | Set up the current scene, for example, the team is defensing. | +| prompts | Prompt database | + + +## Table Schema + +This section explains table design. + +### Scene + +The `scene` table stores scene settings, in the baseball simulation game, player +plays defensive team or offensive team, the LLM provides tactics suggestions in both play. + +The `defensive team` and `offensive team` are two scenes in the game. Other scene like +`creating lineup`. + +* Table schema + +```sql +CREATE TABLE IF NOT EXISTS smartnpc.scene( + scene_id VARCHAR(1024) PRIMARY KEY, + game_id VARCHAR(36) DEFAULT NULL, + goal TEXT DEFAULT NULL, + scene TEXT DEFAULT NULL, + status TEXT DEFAULT NULL, + npcs TEXT DEFAULT NULL, + knowledge TEXT DEFAULT NULL, + conv_example_id VARCHAR(1024) DEFAULT NULL +); +``` + +* Sample data + +```sql +INSERT INTO smartnpc.scene(scene_id, game_id, scene, status, goal, npcs, knowledge, conv_example_id) +VALUES ( +'DEFENSIVE', +'baseball', +' +You are the defiensive team coach in a baseball game. You have to determine what to do next. +', +'ACTIVATE', +' +Based on the given current state, think of possible next states. +', +'', +'', +'default' +); +``` + +### Prompts + +The `prompt_template` table stores prompt templates that will be used in different scenario. +For example, when the player as the deffensive team, is expecting suggestions from the LLM what's +the best next action to prevent the offensive team from gaining runs. + +While in a offensive team scene, the player needs advises on how to drive runs. Each scene may use +different prompts with different statistic to get proper suggestions from the LLM. + +* Table schema + +```sql + +CREATE TABLE IF NOT EXISTS smartnpc.prompt_template( + prompt_id VARCHAR(1024) NOT NULL, + game_id VARCHAR(36) DEFAULT NULL, + scene_id VARCHAR(1024) NOT NULL, + prompt_template TEXT DEFAULT NULL, + is_activate BOOLEAN DEFAULT TRUE, + PRIMARY KEY (scene_id, prompt_id) +); +``` + +* Sample data + +The `Smart NPC API` automatically subsitutes those placeholders in the prompt tempalte if it +founds the placeholder entry in the `prompt_template` table. + +```sql +INSERT INTO smartnpc.prompt_template(prompt_id, game_id, scene_id, prompt_template, is_activate) +VALUES ( +'NPC_CONVERSATION_SCENCE_GOAL_TEMPLATE', +'baseball', +'LINEUP_SUGGESTIONS', +'# SYSTEM +You are an in-game coach of a baseball simulation game. +You will be given rosters of matching teams, +base on the roster, you create the lineup for your team. + +## Your Tasks + +1. You will be given roster of both teams. + +2. **Base on the roster** create the lineup for your team. + * Carefully examine the roster to generate lineup that has best chance to win. + +## Output Format + +{LINEUP_OUTPUT_FORMAT} + +## Important +* Do not include headers, explanations, or extraneous information. +* Walkthrough the roster information. +* Think step by step, make sure the lineup is valid. + +', +True +); + +INSERT INTO smartnpc.prompt_template(prompt_id, game_id, scene_id, prompt_template, is_activate) +VALUES ( +'LINEUP_OUTPUT_FORMAT', +'baseball', +'LINEUP_SUGGESTIONS', +' +{ + "explain": "explain the line up", + "lineup":s + [ + { + "player_name": player name, + "defensive_position" : defensive position, + } + ], + "starting_pitcher": player name +} +', +True +); +``` diff --git a/examples/smart-npc/docs/4-Flow.md b/examples/smart-npc/docs/4-Flow.md new file mode 100644 index 0000000..3964e2a --- /dev/null +++ b/examples/smart-npc/docs/4-Flow.md @@ -0,0 +1,23 @@ +```mermaid +sequenceDiagram + +Player ->> Game Frontend: new_game() +Game Frontend ->> Game Frontend: new_game() +Game Frontend ->> Smart NPC API: Get Team Info and default lineup +Smart NPC API <<->> Database: Get Team Info and default lineup +Smart NPC API <<->> Cache: Update Team Info and default lineup +Smart NPC API -->>Game Frontend: Display Team Info +Game Frontend ->> Smart NPC API: Update player / computer team lineup +Smart NPC API <<->> Cache: Update player / computer team lineup +Smart NPC API -->> Game Frontend: Update player / computer team lineup +Game Frontend ->> Game Frontend: Enter first inning + +Game Frontend <<->> Game Frontend: (Display current state) +Game Frontend ->> Smart NPC API: Get outcomes, tactics and recommendations +Smart NPC API <<->> Smart NPC API: Get outcomes, tactics and recommendations +Smart NPC API ->> Game Frontend: Get outcomes, tactics and recommendations +Game Frontend <<->> Game Frontend: Display tactics options +Player ->> Game Frontend: Select tactics +Game Frontend <<->> Game Frontend: Roll the dice. + +``` \ No newline at end of file diff --git a/examples/smart-npc/example/baseball/sql/insert_conv_examples.sql b/examples/smart-npc/example/baseball/sql/insert_conv_examples.sql new file mode 100644 index 0000000..2edffdc --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/insert_conv_examples.sql @@ -0,0 +1,39 @@ +delete from smartnpc.conversation_examples; +INSERT INTO smartnpc.conversation_examples( + example_id, + game_id, + scene_id, + conversation_example, + is_activate) +VALUES ( +'default', +'baseball', +'default', +' + +**Current state is:** [false, false, true, 2, 3, 2, 7, 2, 0, 2] +**Possible next states:** +* batter strikes out: 35% [false, false, false, 0, 0, 0, 8, -2, 0, 2] +(batter strikes out, current inning ends, so clear out balls / strikes and outs, advance to next inning.) +* single, RBI: 15% [true, false, false, 0, 0, 2, 7, 1, 0, 2] +(single and RBI, runner on first, clear out balls and strikes, 2 outs in 7 inning.) +* batter walks: 15% [true, false, true, 0, 0, 2, 7, 2, 1, 1] +(walks, better to first, runner on third, clear out balls and strikes, outs no change.) +* fly out: 15% [false, false, false, 0, 0, 0, 8, -2, 1, 1] +(walks, better to first, runner on third, clear out balls and strikes, outs no change.) + + +**Current state is:** [false, false, true, 2, 3, 1, 7, 2, 0, 2] +**Possible next states:** +* batter strikes out: 35% [false, false, true, 0, 0, 2, 7, 2, 0, 2] +(batter strikes out, current inning ends, so clear out balls / strikes and outs, advance to next inning.) +* single, RBI: 15% [true, false, false, 0, 0, 1, 7, 1, 0, 2] +(single and RBI, runner on first, clear out balls and strikes, 2 outs in 7 inning.) +* batter walks: 15% [true, false, true, 0, 0, 1, 7, 2, 1, 1] +(walks, better to first, runner on third, clear out balls and strikes, outs no change.) +* Home run: 15% [false, false, false, 0, 0, 1, 7, 0, 1, 1] +(home run, runner + batter = 2 RBI, clear out balls and strikes, outs no change.) + +', +True +); diff --git a/examples/smart-npc/example/baseball/sql/insert_lineup.sql b/examples/smart-npc/example/baseball/sql/insert_lineup.sql new file mode 100644 index 0000000..8d65fb1 --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/insert_lineup.sql @@ -0,0 +1,265 @@ +delete from smartnpc.lineup; +INSERT INTO smartnpc.lineup( + team_id, + player_id, + session_id, + lineup +) +VALUES ( + '1927-New-York-Yankees', + 'JackBuser', + 'random_session2', + ' +{ + "pitchers": [ + { + "name": "Thomas Anderson", + "stats": { + "this_season": { + "GP": 20, + "GS": 20, + "CG": 1, + "SHO": 1, + "IP": 104.0, + "H": 65, + "R": 38, + "ER": 36, + "HR": 6, + "BB": 44, + "K": 145 + }, + "career": { + "GP": 211, + "GS": 211, + "CG": 1, + "SHO": 1, + "IP": 1096.2, + "H": 840, + "R": 419, + "ER": 389, + "HR": 108, + "BB": 495, + "K": 1368 + } + } + } + ], + "fielders": [ + { + "position": "C", + "name": "Jake Miller", + "hand": "R", + "avg": 0.275, + "hr": 15, + "rbi": 75, + "notes": "Solid defense, decent bat." + }, + { + "position": "1B", + "name": "Emily Davis", + "hand": "L", + "avg": 0.305, + "hr": 22, + "rbi": 90, + "notes": "Power hitter, average defense." + }, + { + "position": "2B", + "name": "Carlos Sanchez", + "hand": "R", + "avg": 0.28, + "hr": 10, + "rbi": 55, + "notes": "Good contact hitter, speedy." + }, + { + "position": "SS", + "name": "Sarah Jones", + "hand": "L", + "avg": 0.26, + "hr": 5, + "rbi": 40, + "notes": "Excellent fielder, good on-base percentage." + }, + { + "position": "3B", + "name": "Brandon Lee", + "hand": "R", + "avg": 0.295, + "hr": 18, + "rbi": 80, + "notes": "Consistent hitter, good arm." + }, + { + "position": "LF", + "name": "Megan Green", + "hand": "L", + "avg": 0.27, + "hr": 12, + "rbi": 65, + "notes": "Good range, average hitter." + }, + { + "position": "CF", + "name": "Tyler Wilson", + "hand": "R", + "avg": 0.315, + "hr": 8, + "rbi": 50, + "notes":"Leadoff hitter, good speed." + }, + { + "position": "RF", + "name": "Kayla Martinez", + "hand": "L", + "avg": 0.29, + "hr": 16, + "rbi": 70, + "notes": "Strong arm, good power." + }, + { + "position": "DH", + "name": "Christopher Garcia", + "hand": "R", + "avg": 0.285, + "hr": 20, + "rbi": 85, + "notes": "Power hitter, clutch performer." + } + ] +} + ' +); + +INSERT INTO smartnpc.lineup( + team_id, + player_id, + session_id, + lineup +) +VALUES ( + '1969-New-York-Mets', + 'Computer', + 'random_session2', + ' +{ + "pitchers": [ + { + "name": "Maria Garcia", + "stats": { + "this_season": { + "GP": 18, + "GS": 17, + "CG": 2, + "SHO": 1, + "IP": 98.2, + "H": 72, + "R": 35, + "ER": 32, + "HR": 8, + "BB": 28, + "K": 132 + }, + "career": { + "GP": 192, + "GS": 185, + "CG": 9, + "SHO": 6, + "IP": 1050.1, + "H": 785, + "R": 395, + "ER": 360, + "HR": 92, + "BB": 410, + "K": 1280 + } + } + } + ], + "fielders": [ + { + "position": "C", + "name": "Samuel Rivera", + "hand": "R", + "avg": 0.260, + "hr": 12, + "rbi": 60, + "notes": "Good defensive catcher, improving bat." + }, + { + "position": "1B", + "name": "Olivia Chen", + "hand": "L", + "avg": 0.320, + "hr": 25, + "rbi": 100, + "notes": "Power hitter, solid defender." + }, + { + "position": "2B", + "name": "Daniel Kim", + "hand": "R", + "avg": 0.290, + "hr": 8, + "rbi": 50, + "notes": "Excellent fielder, consistent hitter." + }, + { + "position": "SS", + "name": "Sophia Rodriguez", + "hand": "R", + "avg": 0.275, + "hr": 10, + "rbi": 55, + "notes": "Good range, strong arm at short." + }, + { + "position": "3B", + "name": "Ethan Brown", + "hand": "L", + "avg": 0.300, + "hr": 20, + "rbi": 90, + "notes": "Power hitter, clutch performer." + }, + { + "position": "LF", + "name": "Ava Davis", + "hand": "L", + "avg": 0.280, + "hr": 14, + "rbi": 70, + "notes": "Speedy outfielder, good on-base percentage." + }, + { + "position": "CF", + "name": "Noah Wilson", + "hand": "R", + "avg": 0.310, + "hr": 7, + "rbi": 45, + "notes": "Leadoff hitter, great speed." + }, + { + "position": "RF", + "name": "Isabella Garcia", + "hand": "R", + "avg": 0.295, + "hr": 17, + "rbi": 80, + "notes": "Strong arm, consistent power threat." + }, + { + "position": "DH", + "name": "Jackson Smith", + "hand": "L", + "avg": 0.285, + "hr": 22, + "rbi": 95, + "notes": "Designated hitter, pure power hitter." + } + ] +} + ' +); + diff --git a/examples/smart-npc/example/baseball/sql/insert_prompts.sql b/examples/smart-npc/example/baseball/sql/insert_prompts.sql new file mode 100644 index 0000000..f3405a0 --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/insert_prompts.sql @@ -0,0 +1,691 @@ +delete from smartnpc.prompt_template; +-- ############################## -- +-- GENERAL +-- ############################## -- +INSERT INTO smartnpc.prompt_template(prompt_id, game_id, scene_id, prompt_template, is_activate) +VALUES ( +'OUTPUT_FORMAT', +'baseball', +'default', +'"3-5 word state summary": chance% [10 value state array in the Input Format] + +', +True +); + + +INSERT INTO smartnpc.prompt_template(prompt_id, game_id, scene_id, prompt_template, is_activate) +VALUES ( +'INPUT_FORMAT', +'baseball', +'default', +' +You will be given current state in the following format: +[ +runner on first (true | false), +runner on second (true | false), +runner on third (true | false), +balls (0,1,2,3), +strikes (0,1,2), +outs (0,1,2), +inning (0...12), +defensive score lead (offensive score - defensive score), +defensive team play style (0: conservative, 1: assertive, 2: aggressive), +offensive team play style (0: conservative, 1: assertive, 2: aggressive) +]', +True +); + + +-- ## Line up ## -- +INSERT INTO smartnpc.prompt_template(prompt_id, game_id, scene_id, prompt_template, is_activate) +VALUES ( +'LINEUP_OUTPUT_FORMAT', +'baseball', +'LINEUP_SUGGESTIONS', +' +{ + "explain": "explain the line up", + "lineup":s + [ + { + "player_name": player name, + "defensive_position" : defensive position, + } + ], + "starting_pitcher": player name +} +', +True +); + +INSERT INTO smartnpc.prompt_template(prompt_id, game_id, scene_id, prompt_template, is_activate) +VALUES ( +'NPC_CONVERSATION_SCENCE_GOAL_TEMPLATE', +'baseball', +'LINEUP_SUGGESTIONS', +'# SYSTEM +You are an in-game coach of a baseball simulation game. +You will be given rosters of matching teams, +base on the roster, you create the lineup for your team. + +## Your Tasks + +1. You will be given roster of both teams. + +2. **Base on the roster** create the lineup for your team. + * Carefully examine the roster to generate lineup that has best chance to win. + +## Output Format + +{LINEUP_OUTPUT_FORMAT} + +## Important +* Do not include headers, explanations, or extraneous information. +* Walkthrough the roster information. +* Think step by step, make sure the lineup is valid. + +', +True +); + +-- ## Tactic selection ## -- +INSERT INTO smartnpc.prompt_template(prompt_id, game_id, scene_id, prompt_template, is_activate) +VALUES ( +'NPC_CONVERSATION_SCENCE_GOAL_TEMPLATE', +'baseball', +'TACTICS_SELECTION', +' + +# SYSTEM +This is a baseball management video game where one player is controlling the home team and +another the away team. The player controls what the team does by selecting what +the coach will tell the batter or pitcher for each at-bat. + +## YOUR TASKS + +Based on the current game state and statistics, create options for the coaching +scripts that the video game displays to the players. Each script should be 20-40 +words and advocate for a distinctly different +approach to the current at-bat that makes sense given the current state of the +baseball game. For each combination of the batting and pitching tactics, +generate a valid possible outcome of the current at-bat if the players select that +combination of tactics. Outcomes must represent the entire at-bat, and will +always result in either a hit or an out. Be sure to double-check that the +outcome makes sense given the rules of baseball. + +Also create a meta-level tutorial script for each player, telling them which option +they should choose and why. + + +## Output +Taking into account the pitcher and batter statics and current state of the +game, make 3 pitching tactics scripts, and 3 batting tactics scripts. For each +of the 9 possible batting + pitching tactics combinations, generate a possible +outcome of the current at-bat that results. If you refer to a team member in a +script, be sure to use their last name. + +## IMPORT RULES + +Finally, In the voice of a friendly video game tutorial text box, explain to the +player controlling the pitching team how to select a pitching tactic, which +tactic you think they should select, and why (~50 words) Using the same +approach, also explain how to select a batting tactic to the player controlling +the batting team. Do not refer to tactic indicies in the script, because those are +an implementation detail the players don''t know about. + +## OUTPUT FORMAT +Use this json schema for the tactics scripts and possible outcomes: +{TACTICS_OUTPUT_SCHEMA} + +Here is a table describing each part of the JSON schema: + +{TACTICS_OUTPUT_SCHEMA_DESCRIPTION} + +Don''t include any headers or additional explanation outside of this output format. +', +True +); + +INSERT INTO smartnpc.prompt_template(prompt_id, game_id, scene_id, prompt_template, is_activate) +VALUES ( +'TACTICS_OUTPUT_SCHEMA_DESCRIPTION', +'baseball', +'TACTICS_SELECTION', +' +Element Name Type Description +tactics object Contains the pitching and batting tactic scripts. +tactics.pitching array An array of strings, where each string is a pitching tactic script that advances the goals of the pitcher. +tactics.batting array An array of strings, where each string is a batting tactic script that advances the goals of the pitcher. +outcomes object A map where keys are strings representing the pitching and batting tactic indices (e.g., "0.0", "0.1", etc.) and values are objects describing the outcome of the at-bat. +outcomes[key].outcome string A string describing the play that occurred (e.g., "Flyout to Center Field"). +outcomes[key].game_state object An object describing the state of the game after the at-bat, if this outcome were to occur. +outcomes[key].game_state.r1 boolean true if a runner is on first base; false otherwise. +outcomes[key].game_state.r2 boolean true if a runner is on second base; false otherwise. +outcomes[key].game_state.r3 boolean true if a runner is on third base; false otherwise. +outcomes[key].game_state.outs integer The number of outs after the play. +outcomes[key].game_state.runs integer The number of runs scored as a result of this play. +outcomes[key].title string A brief description of the outcome (e.g., "Ground ball double play"). +recommendations object Contains the video game tutorial coach''s recommended pitching and batting tactics and rationales. +recommendations.pitching object Contains a game tutorial for selecting the recommended pitching tactic. +recommendations.pitching.index integer The index (starting from 0) of the recommended pitching tactic within the tactics.pitching array. +recommendations.pitching.script string The tutorial text script explaining the recommended pitching tactic. +recommendations.batting object Contains a game tutorial for selecting the recommended batting tactic. +recommendations.batting.index integer The index (starting from 0) of the recommended batting tactic within the tactics.batting array. +recommendations.batting.script string The tutorial text explaining the recommended batting tactic. +', +True +); + + +INSERT INTO smartnpc.prompt_template(prompt_id, game_id, scene_id, prompt_template, is_activate) +VALUES ( +'TACTICS_OUTPUT_SCHEMA', +'baseball', +'TACTICS_SELECTION', +' +{ + "type": "object", + "properties": { + "tactics": { + "type": "object", + "properties": { "pitching": { "type": "array", "items": { "type": "string" } }, "batting": { "type": "array", "items": { "type": "string" } } }, + "required": ["pitching", "batting"] + }, + "outcomes": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "outcome": { "type": "string" }, + "game_state": { + "type": "object", + "properties": { + "r1": { "type": "boolean" }, + "r2": { "type": "boolean" }, + "r3": { "type": "boolean" }, + "outs": { "type": "integer" }, + "runs": { "type": "integer" } + }, + "required": ["r1", "r2", "r3", "outs", "runs"] + }, + "title": { "type": "string" } + }, + "required": ["outcome", "game_state", "title"] + } + }, + "recommendations": { + "type": "object", + "properties": { + "pitching": { + "type": "object", + "properties": { "index": { "type": "integer" }, "script": { "type": "string" } }, + "required": ["index", "script"] + }, + "batting": { + "type": "object", + "properties": { "index": { "type": "integer" }, "script": { "type": "string" } }, + "required": ["index", "script"] + } + }, + "required": ["pitching", "batting"] + } + }, + "required": ["tactics", "outcomes", "recommendations"] +} + +const exampleTopOfFirstResponse = { + "tactics": { + "pitching": [ + "Work the corners, keep it low and away. He''s a righty power hitter, so avoid letting him extend his arms.", + "Mix up your pitches. Throw some fastballs inside to jam him, then come back with breaking balls away to keep him off balance.", + "Try to get ahead in the count. If you get to 0-2, throw a slider or changeup. He may be looking fastball early." + ], + "batting": [ + "Be patient, Joseph. Green has a high walk rate. Don''t be afraid to take a walk if he''s not throwing strikes. First at-bat, see what he''s got.", + "Look for a fastball early in the count. He''s likely to try and establish his fastball. Be ready to jump on it.", + "Try to work the count. Green has given up a lot of hits. The deeper you get into the at-bat, the more likely you are to find a pitch you can hit." + ] + }, + "outcomes": { + "0.0": { + "outcome": "Strikeout looking.", + "game_state": { + "r1": false, + "r2": false, + "r3": false, + "outs": 1, + "runs": 0 + }, + "title": "Strikeout" + }, + "0.1": { + "outcome": "Groundout to second base.", + "game_state": { + "r1": false, + "r2": false, + "r3": false, + "outs": 1, + "runs": 0 + }, + "title": "Groundout" + }, + "0.2": { + "outcome": "Walk.", + "game_state": { + "r1": true, + "r2": false, + "r3": false, + "outs": 0, + "runs": 0 + }, + "title": "Walk" + }, + "1.0": { + "outcome": "Foul tip, strike three.", + "game_state": { + "r1": false, + "r2": false, + "r3": false, + "outs": 1, + "runs": 0 + }, + "title": "Strikeout" + }, + "1.1": { + "outcome": "Line drive single to left field.", + "game_state": { + "r1": true, + "r2": false, + "r3": false, + "outs": 0, + "runs": 0 + }, + "title": "Single" + }, + "1.2": { + "outcome": "Flyout to center field.", + "game_state": { + "r1": false, + "r2": false, + "r3": false, + "outs": 1, + "runs": 0 + }, + "title": "Flyout" + }, + "2.0": { + "outcome": "Swinging strikeout.", + "game_state": { + "r1": false, + "r2": false, + "r3": false, + "outs": 1, + "runs": 0 + }, + "title": "Strikeout" + }, + "2.1": { + "outcome": "Ground ball to shortstop, out at first.", + "game_state": { + "r1": false, + "r2": false, + "r3": false, + "outs": 1, + "runs": 0 + }, + "title": "Groundout" + }, + "2.2": { + "outcome": "Double to left field.", + "game_state": { + "r1": false, + "r2": true, + "r3": false, + "outs": 0, + "runs": 0 + }, + "title": "Double" + } + }, + "recommendations": { + "pitching": "Pitching tactic 0: Work the corners. This catcher has some power, so keep the ball away from his sweet spot.", + "batting": "Batting tactic 1: Be patient. The pitcher walks a lot of guys. Let him work and try to get on base." + } +} +', +True +); + +-- ############### +-- Streaming +-- ############### + +-- get tactic suggestions - streaming +INSERT INTO smartnpc.scene(scene_id, game_id, scene, status, goal, npcs, knowledge, conv_example_id) +VALUES ( +'TACTICS_SELECTION_20250317_011', +'baseball', +' +You are a helpful senior manager in a baseball team. +You provide tactics suggestions and possible outcomes to the manager. +', +'ACTIVATE', +' +Based on the given current state, provide your predictions. +', +'', +'', +'default' +); + +INSERT INTO smartnpc.prompt_template(prompt_id, game_id, scene_id, prompt_template, is_activate) +VALUES ( +'STREAMING_GET_SUGGESTIONS', +'baseball', +'TACTICS_SELECTION_20250317_011', +'# SYSTEM +This is a baseball management video game where one player is controlling the home team and +another the away team. The player controls what the team does by selecting what +the coach will tell the batter or pitcher for each at-bat. + +## YOUR TASKS + +Based on the current game state and statistics, create options for the coaching +scripts that the video game displays to the players. Each script should be 20-40 +words and advocate for a distinctly different +approach to the current at-bat that makes sense given the current state of the +baseball game. For each combination of the batting and pitching tactics, +generate a valid possible outcome of the current at-bat if the players select that +combination of tactics. Outcomes must represent the entire at-bat and will +always result in either a hit or an out. Be sure to double-check that the +outcome makes sense given the rules of baseball. + +Also create a meta-level tutorial script for each player, telling them which option +they should choose and why. + +## Output +Taking into account the pitcher and batter statics and current state of the +game, make 3 pitching tactics scripts, and 3 batting tactics scripts. For each +of the 9 possible batting + pitching tactics combinations, generate a possible +outcome of the current at-bat that results. If you refer to a team member in a +script, be sure to use their last name. + +## IMPORT RULES + +Finally, In the voice of a friendly video game tutorial text box, explain to the +player controlling the pitching team how to select a pitching tactic, which +tactic you think they should select, and why (~50 words) Using the same +approach, also explain how to select a batting tactic to the player controlling +the batting team. Do not refer to tactic indicies in the script, because those are +an implementation detail the players don''t know about. + +## IMPORTANT + +* When generating the outcome of an at-bat, consider the current game state (runners on base, outs, score) and ensure the outcome is logically consistent. For example: + * A walk with the bases loaded MUST result in a run scored and the runner on first advancing to second, the runner on second advancing to third, and the runner on third scoring. The number of outs MUST remain the same. + * A fly ball with runners on first and second and one out CANNOT be a sacrifice fly. + * The outcome **MUST BE VALIDATE**, for example, double play isn''t valid if no runner on base. + * If there are 2 outs, any play that results in the batter being out MUST also result in the end of the inning. + * If a runner is on first, a walk MUST result in the runner on first advancing to second. + * If a runner is on second, a sacrifice fly is impossible. +* **Game End Condition**: + * The game ends when the home team is winning after the away team finishes batting in the top of the 9th inning. + * The game ends when the home team is ahead at the end of any subsequent inning after the 9th. + * If the home team is batting in the bottom of the 9th inning (or any extra inning) and takes the lead, the game is over immediately. +* **About Home Runs**: + * A home run with no runners on base results in 1 run scored and no outs recorded. + * If the home team hits a home run in the bottom of the 9th inning or later to take the lead, the game ends immediately, and the number of outs should reflect the state before the home run. + * If there are already two outs, home run does not cause 3 outs. + * **A home run NEVER results in an out.** This is extremely important. The batter and any runners on base always score. +* **About other plays**: + * If the batter hits the ball but reaches first base due to a fielding error, the number of outs does not increase. + * A foul ball should always results in an out. Because this is a per `at-bat` outcome. a at-bat with foul ball outcome should always indicates an out. + * **Fielder''s Choice:** If the batter hits a ground ball and a fielder attempts to get a runner out at a base other than first, but fails, the batter is safe at first. This is scored as a fielder''s choice. Runners may advance, and the number of outs remains the same *unless* the attempt at an out results in the third out. +* **About ending the inning:** + * If there are already two outs, and the at-bat results in an out, you MUST indicate that the inning is over in the outcome (e.g., "Groundout to first base, inning over."). The number of outs MUST be 3. + * If there are fewer than two outs, and the at-bat results in a double play, you MUST indicate that the inning is over in the outcome (e.g., "Double play, inning over"). The number of outs MUST be 3. + * **Crucially, if there are two outs and the current at-bat does NOT result in an out, the inning MUST continue (outs remain at 2), and runners should advance appropriately based on the outcome of the play.** For example, a single will put the batter on first. A double will put the batter on second. A walk will put the batter on first and force other runners to advance. +* **About Walks:** + * A walk always puts the batter on first base. + * A walk **does not** result in an out. + * If there is a runner on first, a walk forces that runner to advance to second. + * If there are runners on first and second, a walk forces the runner on second to advance to third and the runner on first to advance to second, and the batter is on first. + * **If the bases are loaded (runners on first, second, and third), a walk MUST result in a run being scored, and all runners advance one base.** This is a crucial rule; ensure the `runs` value in the `game_state` is updated correctly. After a walk with bases loaded, remember to set `r3` to `false` +* **About Sacrifice Flies:** + * A runner on second or first cannot advance to home with a sacrifice fly. + * If a runner on third is on the base and the batter hits a flyout, the runner on third will score. +* **Inning Over:** + * Inning is over *ONLY* when the 3rd out occurs. +* **About Double Play:** + * Double plays are only possible if there is a runner on first base. + * If there are 2 outs, and the at-bat results in a double play, you MUST indicate that the inning is over in the outcome (e.g., "Double play, inning over"). The number of outs MUST be 3. +* **Advancing Runners:** When a batter gets a hit (single, double, triple), runners on base MUST advance the appropriate number of bases. A single advances each runner one base. A double advances each runner two bases. A triple advances each runner three bases. +* **Runs Scored**: Remember that runs are only scored when a player crosses home plate. A groundout or flyout *does not* score a run unless a runner is on third and tags up (for a flyout) or is forced home (e.g., bases loaded walk). A single, double, or triple only scores runs if runners are in position to reach home plate. Carefully track runs scored. + +## OUTPUT FORMAT +Use this json schema for output, split each parts by a tag: , and . + +{ + "type": "object", + "properties": { + "tactics": { + "type": "object", + "properties": { "pitching": { "type": "array", "items": { "type": "string" } }, "batting": { "type": "array", "items": { "type": "string" } } }, + "required": ["pitching", "batting"] + } +} + + +{ + "type": "object", + "properties": { + "recommendations": { + "type": "object", + "properties": { + "pitching": { + "type": "object", + "properties": { "index": { "type": "integer" }, "script": { "type": "string" } }, + "required": ["index", "script"] + }, + "batting": { + "type": "object", + "properties": { "index": { "type": "integer" }, "script": { "type": "string" } }, + "required": ["index", "script"] + } + }, + "required": ["pitching", "batting"] + } + }, + "required": ["tactics", "outcomes", "recommendations"] +} + + +{ + "type": "object", + "properties": { + "outcomes": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "outcome": { "type": "string" }, + "game_state": { + "type": "object", + "properties": { + "r1": { "type": "boolean" }, + "r2": { "type": "boolean" }, + "r3": { "type": "boolean" }, + "outs": { "type": "integer" }, + "runs": { "type": "integer" } + }, + "required": ["r1", "r2", "r3", "outs", "runs"] + }, + "title": { "type": "string" } + }, + "required": ["outcome", "game_state", "title"] + } + } +} + + + +Example: + +{ + "tactics": { + "pitching": [ + "Work the corners, keep it low and away. He''s a righty power hitter, so avoid letting him extend his arms.", + "Mix up your pitches. Throw some fastballs inside to jam him, then come back with breaking balls away to keep him off balance.", + "Try to get ahead in the count. If you get to 0-2, throw a slider or changeup. He may be looking fastball early." + ], + "batting": [ + "Be patient, Joseph. Green has a high walk rate. Don''t be afraid to take a walk if he''s not throwing strikes. First at-bat, see what he''s got.", + "Look for a fastball early in the count. He''s likely to try and establish his fastball. Be ready to jump on it.", + "Try to work the count. Green has given up a lot of hits. The deeper you get into the at-bat, the more likely you are to find a pitch you can hit." + ] + } +} + + +{ + "recommendations": { + "pitching": "Pitching tactic 0: Work the corners. This catcher has some power, so keep the ball away from his sweet spot.", + "batting": "Batting tactic 1: Be patient. The pitcher walks a lot of guys. Let him work and try to get on base." + } +} + + +{ + "outcomes": { + "0.0": { + "outcome": "Strikeout looking.", + "game_state": { + "r1": false, + "r2": false, + "r3": false, + "outs": 1, + "runs": 0 + }, + "title": "Strikeout" + }, + "0.1": { + "outcome": "Groundout to second base.", + "game_state": { + "r1": false, + "r2": false, + "r3": false, + "outs": 1, + "runs": 0 + }, + "title": "Groundout" + }, + "0.2": { + "outcome": "Walk.", + "game_state": { + "r1": true, + "r2": false, + "r3": false, + "outs": 0, + "runs": 0 + }, + "title": "Walk" + }, + "1.0": { + "outcome": "Foul tip, strike three.", + "game_state": { + "r1": false, + "r2": false, + "r3": false, + "outs": 1, + "runs": 0 + }, + "title": "Strikeout" + }, + "1.1": { + "outcome": "Line drive single to left field.", + "game_state": { + "r1": true, + "r2": false, + "r3": false, + "outs": 0, + "runs": 0 + }, + "title": "Single" + }, + "1.2": { + "outcome": "Flyout to center field.", + "game_state": { + "r1": false, + "r2": false, + "r3": false, + "outs": 1, + "runs": 0 + }, + "title": "Flyout" + }, + "2.0": { + "outcome": "Swinging strikeout.", + "game_state": { + "r1": false, + "r2": false, + "r3": false, + "outs": 1, + "runs": 0 + }, + "title": "Strikeout" + }, + "2.1": { + "outcome": "Ground ball to shortstop, out at first.", + "game_state": { + "r1": false, + "r2": false, + "r3": false, + "outs": 1, + "runs": 0 + }, + "title": "Groundout" + }, + "2.2": { + "outcome": "Double to left field.", + "game_state": { + "r1": false, + "r2": true, + "r3": false, + "outs": 0, + "runs": 0 + }, + "title": "Double" + } + } +} + + +Here is a table describing each part of the output schema: + +Element Name Type Description +tactics object Contains the pitching and batting tactic scripts. +tactics.pitching array An array of strings, where each string is a pitching tactic script that advances the goals of the pitcher. +tactics.batting array An array of strings, where each string is a batting tactic script that advances the goals of the pitcher. +outcomes object A map where keys are strings representing the pitching and batting tactic indices (e.g., "0.0", "0.1", etc.) and values are objects describing the outcome of the at-bat. +outcomes[key].outcome string A string describing the play that occurred (e.g., "Flyout to Center Field"). The outcome **MUST BE VALIDATE**, for example, double play isn''t valid if no runner on base. If the outcome resulting an out, you must explictly indicate Out in the outcome string. +outcomes[key].game_state object An object describing the state of the game after the at-bat, if this outcome were to occur. +outcomes[key].game_state.r1 boolean true if a runner is on first base; false otherwise. +outcomes[key].game_state.r2 boolean true if a runner is on second base; false otherwise. +outcomes[key].game_state.r3 boolean true if a runner is on third base; false otherwise. +outcomes[key].game_state.outs integer The number of outs after the play. +outcomes[key].game_state.runs integer The number of runs scored as a result of this play. +outcomes[key].title string A brief description of the outcome (e.g., "Ground ball double play"). +recommendations object Contains the video game tutorial coach''s recommended pitching and batting tactics and rationales. +recommendations.pitching object Contains a game tutorial for selecting the recommended pitching tactic. +recommendations.pitching.index integer The index (starting from 0) of the recommended pitching tactic within the tactics.pitching array. +recommendations.pitching.script string The tutorial text script explaining the recommended pitching tactic. Simply give the explaination, DO NOT include any index number, jus talk like a coach to player. +recommendations.batting object Contains a game tutorial for selecting the recommended batting tactic. +recommendations.batting.index integer The index (starting from 0) of the recommended batting tactic within the tactics.batting array. +recommendations.batting.script string The tutorial text explaining the recommended batting tactic. + +Don''t include any headers or additional explanation outside of this output format. + +', +True +); diff --git a/examples/smart-npc/example/baseball/sql/insert_roster.sql b/examples/smart-npc/example/baseball/sql/insert_roster.sql new file mode 100644 index 0000000..5027c67 --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/insert_roster.sql @@ -0,0 +1,637 @@ +delete from smartnpc.rosters; +INSERT INTO smartnpc.rosters( + team_id, + session_id, + player_id, + roster +) +VALUES ( + '1927-New-York-Yankees', + 'random_session2', + 'JackBuser', + ' +{ + "pitchers": [ + { + "name": "Thomas Anderson", + "stats": { + "this_season": { + "GP": 20, + "GS": 20, + "CG": 1, + "SHO": 1, + "IP": 104.0, + "H": 65, + "R": 38, + "ER": 36, + "HR": 6, + "BB": 44, + "K": 145 + }, + "career": { + "GP": 211, + "GS": 211, + "CG": 1, + "SHO": 1, + "IP": 1096.2, + "H": 840, + "R": 419, + "ER": 389, + "HR": 108, + "BB": 495, + "K": 1368 + } + } + }, + { + "name": "David Brown", + "stats": { + "this_season": { + "GP": 18, + "GS": 18, + "CG": 0, + "SHO": 0, + "IP": 92.0, + "H": 72, + "R": 42, + "ER": 40, + "HR": 8, + "BB": 32, + "K": 120 + }, + "career": { + "GP": 185, + "GS": 185, + "CG": 2, + "SHO": 2, + "IP": 950.1, + "H": 750, + "R": 380, + "ER": 350, + "HR": 95, + "BB": 400, + "K": 1100 + } + } + }, + { + "name": "Maria Garcia", + "stats": { + "this_season": { + "GP": 22, + "GS": 22, + "CG": 2, + "SHO": 1, + "IP": 120.0, + "H": 80, + "R": 35, + "ER": 32, + "HR": 5, + "BB": 28, + "K": 160 + }, + "career": { + "GP": 200, + "GS": 200, + "CG": 10, + "SHO": 5, + "IP": 1100.0, + "H": 700, + "R": 300, + "ER": 275, + "HR": 75, + "BB": 350, + "K": 1250 + } + } + }, + { + "name": "Michael Johnson", + "stats": { + "this_season": { + "GP": 15, + "GS": 15, + "CG": 0, + "SHO": 0, + "IP": 78.0, + "H": 60, + "R": 30, + "ER": 28, + "HR": 7, + "BB": 25, + "K": 100 + }, + "career": { + "GP": 160, + "GS": 160, + "CG": 5, + "SHO": 3, + "IP": 850.0, + "H": 650, + "R": 320, + "ER": 300, + "HR": 80, + "BB": 380, + "K": 1050 + } + } + }, + { + "name": "Jessica Lee", + "stats": { + "this_season": { + "GP": 19, + "GS": 19, + "CG": 1, + "SHO": 1, + "IP": 98.0, + "H": 70, + "R": 32, + "ER": 30, + "HR": 4, + "BB": 35, + "K": 130 + }, + "career": { + "GP": 195, + "GS": 195, + "CG": 8, + "SHO": 4, + "IP": 1000.0, + "H": 780, + "R": 350, + "ER": 320, + "HR": 90, + "BB": 420, + "K": 1200 + } + } + }, + { + "name": "Kevin Rodriguez", + "stats": { + "this_season": { + "GP": 21, + "GS": 21, + "CG": 0, + "SHO": 0, + "IP": 110.0, + "H": 85, + "R": 45, + "ER": 42, + "HR": 9, + "BB": 40, + "K": 150 + }, + "career": { + "GP": 220, + "GS": 220, + "CG": 3, + "SHO": 1, + "IP": 1150.0, + "H": 900, + "R": 450, + "ER": 420, + "HR": 110, + "BB": 500, + "K": 1400 + } + } + }, + { + "name": "Ashley Wilson", + "stats": { + "this_season": { + "GP": 17, + "GS": 17, + "CG": 2, + "SHO": 2, + "IP": 90.0, + "H": 60, + "R": 25, + "ER": 22, + "HR": 3, + "BB": 20, + "K": 110 + }, + "career": { + "GP": 175, + "GS": 175, + "CG": 12, + "SHO": 7, + "IP": 900.0, + "H": 680, + "R": 280, + "ER": 250, + "HR": 70, + "BB": 300, + "K": 1150 + } + } + } + ], + "fielders": [ + { + "position": "C", + "name": "Jake Miller", + "hand": "R", + "avg": 0.275, + "hr": 15, + "rbi": 75, + "notes": "Solid defense, decent bat." + }, + { + "position": "1B", + "name": "Emily Davis", + "hand": "L", + "avg": 0.305, + "hr": 22, + "rbi": 90, + "notes": "Power hitter, average defense." + }, + { + "position": "2B", + "name": "Carlos Sanchez", + "hand": "R", + "avg": 0.28, + "hr": 10, + "rbi": 55, + "notes": "Good contact hitter, speedy." + }, + { + "position": "SS", + "name": "Sarah Jones", + "hand": "L", + "avg": 0.26, + "hr": 5, + "rbi": 40, + "notes": "Excellent fielder, good on-base percentage." + }, + { + "position": "3B", + "name": "Brandon Lee", + "hand": "R", + "avg": 0.295, + "hr": 18, + "rbi": 80, + "notes": "Consistent hitter, good arm." + }, + { + "position": "LF", + "name": "Megan Green", + "hand": "L", + "avg": 0.27, + "hr": 12, + "rbi": 65, + "notes": "Good range, average hitter." + }, + { + "position": "CF", + "name": "Tyler Wilson", + "hand": "R", + "avg": 0.315, + "hr": 8, + "rbi": 50, + "notes":"Leadoff hitter, good speed." + }, + { + "position": "RF", + "name": "Kayla Martinez", + "hand": "L", + "avg": 0.29, + "hr": 16, + "rbi": 70, + "notes": "Strong arm, good power." + }, + { + "position": "DH", + "name": "Christopher Garcia", + "hand": "R", + "avg": 0.285, + "hr": 20, + "rbi": 85, + "notes": "Power hitter, clutch performer." + } + ] +} + ' +); + +INSERT INTO smartnpc.rosters( + team_id, + session_id, + player_id, + roster +) +VALUES ( + '1969-New-York-Mets', + 'random_session2', + 'Computer', + ' +{ + "pitchers": [ + { + "name": "Maria Garcia", + "stats": { + "this_season": { + "GP": 18, + "GS": 17, + "CG": 2, + "SHO": 1, + "IP": 98.2, + "H": 72, + "R": 35, + "ER": 32, + "HR": 8, + "BB": 28, + "K": 132 + }, + "career": { + "GP": 192, + "GS": 185, + "CG": 9, + "SHO": 6, + "IP": 1050.1, + "H": 785, + "R": 395, + "ER": 360, + "HR": 92, + "BB": 410, + "K": 1280 + } + } + }, + { + "name": "David Lee", + "stats": { + "this_season": { + "GP": 22, + "GS": 22, + "CG": 0, + "SHO": 0, + "IP": 118.0, + "H": 85, + "R": 48, + "ER": 45, + "HR": 11, + "BB": 35, + "K": 155 + }, + "career": { + "GP": 208, + "GS": 205, + "CG": 3, + "SHO": 2, + "IP": 1125.3, + "H": 810, + "R": 430, + "ER": 405, + "HR": 105, + "BB": 465, + "K": 1390 + } + } + }, + { + "name": "Jessica Brown", + "stats": { + "this_season": { + "GP": 15, + "GS": 14, + "CG": 1, + "SHO": 0, + "IP": 82.1, + "H": 62, + "R": 28, + "ER": 25, + "HR": 5, + "BB": 22, + "K": 115 + }, + "career": { + "GP": 165, + "GS": 158, + "CG": 6, + "SHO": 3, + "IP": 890.2, + "H": 705, + "R": 315, + "ER": 290, + "HR": 78, + "BB": 350, + "K": 1210 + } + } + }, + { + "name": "Michael Wilson", + "stats": { + "this_season": { + "GP": 19, + "GS": 19, + "CG": 2, + "SHO": 1, + "IP": 105.2, + "H": 78, + "R": 32, + "ER": 29, + "HR": 7, + "BB": 30, + "K": 140 + }, + "career": { + "GP": 182, + "GS": 178, + "CG": 8, + "SHO": 5, + "IP": 960.0, + "H": 750, + "R": 360, + "ER": 335, + "HR": 85, + "BB": 420, + "K": 1250 + } + } + }, + { + "name": "Ashley Rodriguez", + "stats": { + "this_season": { + "GP": 21, + "GS": 20, + "CG": 0, + "SHO": 0, + "IP": 112.0, + "H": 90, + "R": 52, + "ER": 49, + "HR": 12, + "BB": 40, + "K": 160 + }, + "career": { + "GP": 202, + "GS": 198, + "CG": 4, + "SHO": 2, + "IP": 1080.1, + "H": 820, + "R": 450, + "ER": 425, + "HR": 115, + "BB": 480, + "K": 1420 + } + } + }, + { + "name": "Kevin Martinez", + "stats": { + "this_season": { + "GP": 16, + "GS": 15, + "CG": 1, + "SHO": 1, + "IP": 88.1, + "H": 68, + "R": 25, + "ER": 22, + "HR": 4, + "BB": 25, + "K": 120 + }, + "career": { + "GP": 175, + "GS": 170, + "CG": 7, + "SHO": 5, + "IP": 940.3, + "H": 760, + "R": 330, + "ER": 305, + "HR": 75, + "BB": 380, + "K": 1300 + } + } + }, + { + "name": "Sarah Anderson", + "stats": { + "this_season": { + "GP": 23, + "GS": 23, + "CG": 3, + "SHO": 2, + "IP": 125.0, + "H": 82, + "R": 30, + "ER": 27, + "HR": 6, + "BB": 20, + "K": 170 + }, + "career": { + "GP": 215, + "GS": 210, + "CG": 11, + "SHO": 8, + "IP": 1150.2, + "H": 790, + "R": 350, + "ER": 320, + "HR": 80, + "BB": 390, + "K": 1450 + } + } + } + ], + "fielders": [ + { + "position": "C", + "name": "Samuel Rivera", + "hand": "R", + "avg": 0.260, + "hr": 12, + "rbi": 60, + "notes": "Good defensive catcher, improving bat." + }, + { + "position": "1B", + "name": "Olivia Chen", + "hand": "L", + "avg": 0.320, + "hr": 25, + "rbi": 100, + "notes": "Power hitter, solid defender." + }, + { + "position": "2B", + "name": "Daniel Kim", + "hand": "R", + "avg": 0.290, + "hr": 8, + "rbi": 50, + "notes": "Excellent fielder, consistent hitter." + }, + { + "position": "SS", + "name": "Sophia Rodriguez", + "hand": "R", + "avg": 0.275, + "hr": 10, + "rbi": 55, + "notes": "Good range, strong arm at short." + }, + { + "position": "3B", + "name": "Ethan Brown", + "hand": "L", + "avg": 0.300, + "hr": 20, + "rbi": 90, + "notes": "Power hitter, clutch performer." + }, + { + "position": "LF", + "name": "Ava Davis", + "hand": "L", + "avg": 0.280, + "hr": 14, + "rbi": 70, + "notes": "Speedy outfielder, good on-base percentage." + }, + { + "position": "CF", + "name": "Noah Wilson", + "hand": "R", + "avg": 0.310, + "hr": 7, + "rbi": 45, + "notes": "Leadoff hitter, great speed." + }, + { + "position": "RF", + "name": "Isabella Garcia", + "hand": "R", + "avg": 0.295, + "hr": 17, + "rbi": 80, + "notes": "Strong arm, consistent power threat." + }, + { + "position": "DH", + "name": "Jackson Smith", + "hand": "L", + "avg": 0.285, + "hr": 22, + "rbi": 95, + "notes": "Designated hitter, pure power hitter." + } + ] +} + ' +); + diff --git a/examples/smart-npc/example/baseball/sql/insert_scene.sql b/examples/smart-npc/example/baseball/sql/insert_scene.sql new file mode 100644 index 0000000..c33ce13 --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/insert_scene.sql @@ -0,0 +1,58 @@ +delete from smartnpc.scene; + +-- get tactic suggestions +INSERT INTO smartnpc.scene(scene_id, game_id, scene, status, goal, npcs, knowledge, conv_example_id) +VALUES ( +'TACTICS_SELECTION', +'baseball', +' +You are a helpful senior manager in a baseball team. +You provide tactics suggestions and possible outcomes to the manager. +', +'ACTIVATE', +' +Based on the given current state, provide your predictions. +', +'', +'', +'default' +); + + +-- get lineup suggestions +INSERT INTO smartnpc.scene(scene_id, game_id, scene, status, goal, npcs, knowledge, conv_example_id) +VALUES ( +'LINEUP_SUGGESTIONS', +'baseball', +' +You are the baseball team coach in a baseball game. +You create lineup for the game. +', +'ACTIVATE', +' +Based on the given roster of your team and the opponent team, +Create a line up for your team. +', +'', +'', +'default' +); + + +-- get tactic suggestions - streaming +INSERT INTO smartnpc.scene(scene_id, game_id, scene, status, goal, npcs, knowledge, conv_example_id) +VALUES ( +'TACTICS_SELECTION_20250313', +'baseball', +' +You are a helpful senior manager in a baseball team. +You provide tactics suggestions and possible outcomes to the manager. +', +'ACTIVATE', +' +Based on the given current state, provide your predictions. +', +'', +'', +'default' +); \ No newline at end of file diff --git a/examples/smart-npc/example/baseball/sql/insert_teams_json.sql b/examples/smart-npc/example/baseball/sql/insert_teams_json.sql new file mode 100644 index 0000000..04e45d3 --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/insert_teams_json.sql @@ -0,0 +1,495 @@ +delete from smartnpc.teams; + +INSERT INTO smartnpc.teams( + team_id, + team_name, + team_year, + description, + roster, + default_lineup +) +VALUES ( + 'Red', + 'Red', + 2025, + 'Known for their aggressive batting style and powerful offense. Hailing from the sunny shores, the Coastal Comets bring a dynamic blend of speed and power to the diamond.', + '{ + "pitchers":[{ + "name": "Michael Nguyen", + "stats": { + "this_season": { + "GP": 16, + "GS": 15, + "CG": 3, + "SHO": 3, + "IP": 77.6, + "H": 99, + "R": 36, + "ER": 31, + "HR": 4, + "BB": 44, + "K": 91 + }, + "career": { + "GP": 176, + "GS": 150, + "CG": 9, + "SHO": 12, + "IP": 771.7, + "H": 1062, + "R": 361, + "ER": 310, + "HR": 34, + "BB": 521, + "K": 874 + } + } + }], + "fielders": [ + { + "position": "CF", + "name": "Ryuichi Suzuki", + "hand": "Left", + "avg": 0.315, + "hr": 9, + "rbi": 51, + "notes": "High average, contact hitter, limited power." + }, + { + "position": "2B", + "name": "Robert Ackley", + "hand": "Left", + "avg": 0.226, + "hr": 12, + "rbi": 50, + "notes": "Some power, but struggled with consistency." + }, + { + "position": "C", + "name": "Jon Montero", + "hand": "Left", + "avg": 0.26, + "hr": 15, + "rbi": 62, + "notes": "Developing power hitter, still inconsistent." + }, + { + "position": "1B", + "name": "Mark Smoak", + "hand": "Both", + "avg": 0.217, + "hr": 19, + "rbi": 51, + "notes": "Switch-hitter, power potential, low average." + }, + { + "position": "DH", + "name": "Joel Carp", + "hand": "Left", + "avg": 0.213, + "hr": 5, + "rbi": 18, + "notes": "Limited at-bats, low batting average." + }, + { + "position": "3B", + "name": "Richard Seager", + "hand": "Left", + "avg": 0.259, + "hr": 20, + "rbi": 86, + "notes": "Good power for a middle infielder." + }, + { + "position": "RF", + "name": "Brett Saunders", + "hand": "Left", + "avg": 0.247, + "hr": 19, + "rbi": 57, + "notes": "Power and speed, high strikeout rate." + }, + { + "position": "LF", + "name": "Paul Wells", + "hand": "Right", + "avg": 0.228, + "hr": 10, + "rbi": 36, + "notes": "Power potential, but inconsistent contact overall." + }, + { + "position": "SS", + "name": "James Ryan", + "hand": "Right", + "avg": 0.194, + "hr": 3, + "rbi": 31, + "notes": "Very weak hitter, almost no power." + } + ] +}', + '{ + "pitcher": { + "name": "Michael Nguyen", + "stats": { + "this_season": { + "GP": 16, + "GS": 15, + "CG": 3, + "SHO": 3, + "IP": 77.6, + "H": 99, + "R": 36, + "ER": 31, + "HR": 4, + "BB": 44, + "K": 91 + }, + "career": { + "GP": 176, + "GS": 150, + "CG": 9, + "SHO": 12, + "IP": 771.7, + "H": 1062, + "R": 361, + "ER": 310, + "HR": 34, + "BB": 521, + "K": 874 + } + } + }, + "fielders": [ + { + "position": "CF", + "name": "Ryuichi Suzuki", + "hand": "Left", + "avg": 0.315, + "hr": 9, + "rbi": 51, + "notes": "High average, contact hitter, limited power." + }, + { + "position": "2B", + "name": "Robert Ackley", + "hand": "Left", + "avg": 0.226, + "hr": 12, + "rbi": 50, + "notes": "Some power, but struggled with consistency." + }, + { + "position": "C", + "name": "Jon Montero", + "hand": "Left", + "avg": 0.26, + "hr": 15, + "rbi": 62, + "notes": "Developing power hitter, still inconsistent." + }, + { + "position": "1B", + "name": "Mark Smoak", + "hand": "Both", + "avg": 0.217, + "hr": 19, + "rbi": 51, + "notes": "Switch-hitter, power potential, low average." + }, + { + "position": "DH", + "name": "Joel Carp", + "hand": "Left", + "avg": 0.213, + "hr": 5, + "rbi": 18, + "notes": "Limited at-bats, low batting average." + }, + { + "position": "3B", + "name": "Richard Seager", + "hand": "Left", + "avg": 0.259, + "hr": 20, + "rbi": 86, + "notes": "Good power for a middle infielder." + }, + { + "position": "RF", + "name": "Brett Saunders", + "hand": "Left", + "avg": 0.247, + "hr": 19, + "rbi": 57, + "notes": "Power and speed, high strikeout rate." + }, + { + "position": "LF", + "name": "Paul Wells", + "hand": "Right", + "avg": 0.228, + "hr": 10, + "rbi": 36, + "notes": "Power potential, but inconsistent contact overall." + }, + { + "position": "SS", + "name": "James Ryan", + "hand": "Right", + "avg": 0.194, + "hr": 3, + "rbi": 31, + "notes": "Very weak hitter, almost no power." + } + ] +}' +); + +INSERT INTO smartnpc.teams( + team_id, + team_name, + team_year, + description, + roster, + default_lineup +) +VALUES ( + 'Blue', + 'Blue', + 2025, + 'A team known for its strong pitching and solid defensive play. Forged in the heartland, the Ironclad Armadillos are a team built on grit and resilience.', + '{ + "pitchers": [{ + "name": "Hank Wilder", + "stats": { + "this_season": { + "GP": 16, + "GS": 15, + "CG": 3, + "SHO": 3, + "IP": 77.6, + "H": 99, + "R": 36, + "ER": 31, + "HR": 4, + "BB": 44, + "K": 91 + }, + "career": { + "GP": 176, + "GS": 150, + "CG": 9, + "SHO": 12, + "IP": 771.7, + "H": 1062, + "R": 361, + "ER": 310, + "HR": 34, + "BB": 521, + "K": 874 + } + } + }], + "fielders": [ + { + "position": "CF", + "name": "Grant Trout", + "hand": "Right", + "avg": 0.326, + "hr": 30, + "rbi": 83, + "notes": "Exceptional rookie, speed and power combo." + }, + { + "position": "SS", + "name": "Aris Aybar", + "hand": "Both", + "avg": 0.29, + "hr": 8, + "rbi": 45, + "notes": "Switch-hitter, good contact, solid average." + }, + { + "position": "1B", + "name": "Sandeep Pujols", + "hand": "Right", + "avg": 0.285, + "hr": 30, + "rbi": 105, + "notes": "Still potent, but declining from peak." + }, + { + "position": "RF", + "name": "Jaime Hunter", + "hand": "Right", + "avg": 0.313, + "hr": 16, + "rbi": 92, + "notes": "Consistent hitter, good average and RBIs." + }, + { + "position": "DH", + "name": "Francis Trumbo", + "hand": "Right", + "avg": 0.268, + "hr": 32, + "rbi": 95, + "notes": "Big power, high strikeout, solid production." + }, + { + "position": "LF", + "name": "Maurice Mathers", + "hand": "Right", + "avg": 0.23, + "hr": 11, + "rbi": 29, + "notes": "Struggling veteran, low average, limited power." + }, + { + "position": "2B", + "name": "Dre Kendrick", + "hand": "Right", + "avg": 0.287, + "hr": 8, + "rbi": 67, + "notes": "Solid contact hitter, decent average." + }, + { + "position": "3B", + "name": "Jose Callaspo", + "hand": "Both", + "avg": 0.252, + "hr": 10, + "rbi": 53, + "notes": "Decent contact, switch-hitter, average power." + }, + { + "position": "C", + "name": "Jesus Iannetta", + "hand": "Right", + "avg": 0.24, + "hr": 9, + "rbi": 26, + "notes": "Some power, lower batting average overall." + } + ] +}', + '{ + "pitcher": { + "name": "Hank Wilder", + "stats": { + "this_season": { + "GP": 16, + "GS": 15, + "CG": 3, + "SHO": 3, + "IP": 77.6, + "H": 99, + "R": 36, + "ER": 31, + "HR": 4, + "BB": 44, + "K": 91 + }, + "career": { + "GP": 176, + "GS": 150, + "CG": 9, + "SHO": 12, + "IP": 771.7, + "H": 1062, + "R": 361, + "ER": 310, + "HR": 34, + "BB": 521, + "K": 874 + } + } + }, + "fielders": [ + { + "position": "CF", + "name": "Grant Trout", + "hand": "Right", + "avg": 0.326, + "hr": 30, + "rbi": 83, + "notes": "Exceptional rookie, speed and power combo." + }, + { + "position": "SS", + "name": "Aris Aybar", + "hand": "Both", + "avg": 0.29, + "hr": 8, + "rbi": 45, + "notes": "Switch-hitter, good contact, solid average." + }, + { + "position": "1B", + "name": "Sandeep Pujols", + "hand": "Right", + "avg": 0.285, + "hr": 30, + "rbi": 105, + "notes": "Still potent, but declining from peak." + }, + { + "position": "RF", + "name": "Jaime Hunter", + "hand": "Right", + "avg": 0.313, + "hr": 16, + "rbi": 92, + "notes": "Consistent hitter, good average and RBIs." + }, + { + "position": "DH", + "name": "Francis Trumbo", + "hand": "Right", + "avg": 0.268, + "hr": 32, + "rbi": 95, + "notes": "Big power, high strikeout, solid production." + }, + { + "position": "LF", + "name": "Maurice Mathers", + "hand": "Right", + "avg": 0.23, + "hr": 11, + "rbi": 29, + "notes": "Struggling veteran, low average, limited power." + }, + { + "position": "2B", + "name": "Dre Kendrick", + "hand": "Right", + "avg": 0.287, + "hr": 8, + "rbi": 67, + "notes": "Solid contact hitter, decent average." + }, + { + "position": "3B", + "name": "Jose Callaspo", + "hand": "Both", + "avg": 0.252, + "hr": 10, + "rbi": 53, + "notes": "Decent contact, switch-hitter, average power." + }, + { + "position": "C", + "name": "Jesus Iannetta", + "hand": "Right", + "avg": 0.24, + "hr": 9, + "rbi": 26, + "notes": "Some power, lower batting average overall." + } + ] +}' +); \ No newline at end of file diff --git a/examples/smart-npc/example/baseball/sql/schema.sql b/examples/smart-npc/example/baseball/sql/schema.sql new file mode 100644 index 0000000..3284321 --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/schema.sql @@ -0,0 +1,3 @@ +GRANT USAGE ON SCHEMA smartnpc TO llmuser; +create schema if not exists "smartnpc"; +create extension if not exists "vector"; \ No newline at end of file diff --git a/examples/smart-npc/example/baseball/sql/schema_conversation_example.sql b/examples/smart-npc/example/baseball/sql/schema_conversation_example.sql new file mode 100644 index 0000000..1f6c642 --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/schema_conversation_example.sql @@ -0,0 +1,10 @@ +create schema if not exists "smartnpc"; +CREATE TABLE IF NOT EXISTS smartnpc.conversation_examples( + example_id VARCHAR(1024) PRIMARY KEY, + game_id VARCHAR(36) DEFAULT NULL, + scene_id VARCHAR(1024) DEFAULT NULL, + conversation_example TEXT DEFAULT NULL, + is_activate BOOLEAN DEFAULT TRUE +); +GRANT USAGE ON SCHEMA smartnpc TO llmuser; +GRANT SELECT ON smartnpc.conversation_examples TO llmuser; diff --git a/examples/smart-npc/example/baseball/sql/schema_conversation_log.sql b/examples/smart-npc/example/baseball/sql/schema_conversation_log.sql new file mode 100644 index 0000000..6b74487 --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/schema_conversation_log.sql @@ -0,0 +1,17 @@ +create schema if not exists "smartnpc"; +CREATE TABLE IF NOT EXISTS smartnpc.conversation_logs( + conversation_id VARCHAR(256) PRIMARY KEY, + game_id VARCHAR(36) DEFAULT NULL, + session_id VARCHAR(256), + scene_id VARCHAR(1024), + player_id VARCHAR(1024), + npc_id VARCHAR(1024), + conversation_log TEXT DEFAULT NULL, + date_time TEXT DEFAULT NULL, + status TEXT DEFAULT NULL, + start_gametime TEXT DEFAULT NULL, + end_gametime TEXT DEFAULT NULL, + summary TEXT DEFAULT NULL +); +GRANT USAGE ON SCHEMA smartnpc TO llmuser; +GRANT SELECT ON smartnpc.conversation_logs TO llmuser; diff --git a/examples/smart-npc/example/baseball/sql/schema_lineup.sql b/examples/smart-npc/example/baseball/sql/schema_lineup.sql new file mode 100644 index 0000000..5d66623 --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/schema_lineup.sql @@ -0,0 +1,10 @@ +create schema if not exists "smartnpc"; +CREATE TABLE IF NOT EXISTS smartnpc.lineup( + team_id VARCHAR(256) NOT NULL, + player_id VARCHAR(1024) NOT NULL, + session_id VARCHAR(256), + lineup TEXT DEFAULT NULL, + PRIMARY KEY (team_id, player_id) +); +GRANT USAGE ON SCHEMA smartnpc TO llmuser; +GRANT SELECT ON smartnpc.lineup TO llmuser; diff --git a/examples/smart-npc/example/baseball/sql/schema_memory.sql b/examples/smart-npc/example/baseball/sql/schema_memory.sql new file mode 100644 index 0000000..49084d6 --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/schema_memory.sql @@ -0,0 +1,16 @@ +create schema if not exists "smartnpc"; +CREATE TABLE IF NOT EXISTS smartnpc.memory( + memory_id VARCHAR(256) PRIMARY KEY, + game_id VARCHAR(36) DEFAULT NULL, + session_id VARCHAR(256), + player_id VARCHAR(1024), + npc_id VARCHAR(1024), + summary TEXT DEFAULT NULL, + date_time TEXT DEFAULT NULL, + status TEXT DEFAULT NULL, + memory_type TEXT DEFAULT NULL, + gametime TEXT DEFAULT NULL +); +GRANT USAGE ON SCHEMA smartnpc TO llmuser; +GRANT SELECT ON smartnpc.memory TO llmuser; + diff --git a/examples/smart-npc/example/baseball/sql/schema_npc.sql b/examples/smart-npc/example/baseball/sql/schema_npc.sql new file mode 100644 index 0000000..2b970c1 --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/schema_npc.sql @@ -0,0 +1,14 @@ +create schema if not exists "smartnpc"; +drop table if exists smartnpc.npc; +CREATE TABLE IF NOT EXISTS smartnpc.npc( + npc_id VARCHAR(1024) PRIMARY KEY, + game_id VARCHAR(36) DEFAULT NULL, + background TEXT DEFAULT NULL, + class TEXT DEFAULT NULL, + class_level int DEFAULT 1, + name TEXT DEFAULT NULL, + status TEXT DEFAULT NULL, + lore_level int DEFAULT 1 +); +GRANT USAGE ON SCHEMA smartnpc TO llmuser; +GRANT SELECT ON smartnpc.npc TO llmuser; diff --git a/examples/smart-npc/example/baseball/sql/schema_player.sql b/examples/smart-npc/example/baseball/sql/schema_player.sql new file mode 100644 index 0000000..0c3bdff --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/schema_player.sql @@ -0,0 +1,13 @@ +create schema if not exists "smartnpc"; +CREATE TABLE IF NOT EXISTS smartnpc.player( + player_id VARCHAR(1024) PRIMARY KEY, + game_id VARCHAR(36) DEFAULT NULL, + background TEXT DEFAULT NULL, + class TEXT DEFAULT NULL, + class_level int DEFAULT 1, + name TEXT DEFAULT NULL, + status TEXT DEFAULT NULL, + lore_level int DEFAULT 1 +); +GRANT USAGE ON SCHEMA smartnpc TO llmuser; +GRANT SELECT ON smartnpc.player TO llmuser; diff --git a/examples/smart-npc/example/baseball/sql/schema_prompt.sql b/examples/smart-npc/example/baseball/sql/schema_prompt.sql new file mode 100644 index 0000000..df60179 --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/schema_prompt.sql @@ -0,0 +1,11 @@ +create schema if not exists "smartnpc"; +CREATE TABLE IF NOT EXISTS smartnpc.prompt_template( + prompt_id VARCHAR(1024) NOT NULL, + game_id VARCHAR(36) DEFAULT NULL, + scene_id VARCHAR(1024) NOT NULL, + prompt_template TEXT DEFAULT NULL, + is_activate BOOLEAN DEFAULT TRUE, + PRIMARY KEY (scene_id, prompt_id) +); +GRANT USAGE ON SCHEMA smartnpc TO llmuser; +GRANT SELECT ON smartnpc.prompt_template TO llmuser; diff --git a/examples/smart-npc/example/baseball/sql/schema_rosters.sql b/examples/smart-npc/example/baseball/sql/schema_rosters.sql new file mode 100644 index 0000000..bbeb42f --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/schema_rosters.sql @@ -0,0 +1,10 @@ +create schema if not exists "smartnpc"; +CREATE TABLE IF NOT EXISTS smartnpc.rosters( + team_id VARCHAR(256) NOT NULL, + session_id VARCHAR(256), + player_id VARCHAR(1024) NOT NULL, + roster TEXT DEFAULT NULL, + PRIMARY KEY (team_id, session_id) +); +GRANT USAGE ON SCHEMA smartnpc TO llmuser; +GRANT SELECT ON smartnpc.rosters TO llmuser; diff --git a/examples/smart-npc/example/baseball/sql/schema_scene.sql b/examples/smart-npc/example/baseball/sql/schema_scene.sql new file mode 100644 index 0000000..f43ed7f --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/schema_scene.sql @@ -0,0 +1,13 @@ +create schema if not exists "smartnpc"; +CREATE TABLE IF NOT EXISTS smartnpc.scene( + scene_id VARCHAR(1024) PRIMARY KEY, + game_id VARCHAR(36) DEFAULT NULL, + goal TEXT DEFAULT NULL, + scene TEXT DEFAULT NULL, + status TEXT DEFAULT NULL, + npcs TEXT DEFAULT NULL, + knowledge TEXT DEFAULT NULL, + conv_example_id VARCHAR(1024) DEFAULT NULL +); +GRANT USAGE ON SCHEMA smartnpc TO llmuser; +GRANT SELECT ON smartnpc.scene TO llmuser; diff --git a/examples/smart-npc/example/baseball/sql/schema_teams.sql b/examples/smart-npc/example/baseball/sql/schema_teams.sql new file mode 100644 index 0000000..ef3b23b --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/schema_teams.sql @@ -0,0 +1,12 @@ +create schema if not exists "smartnpc"; +CREATE TABLE IF NOT EXISTS smartnpc.teams( + team_id VARCHAR(256) PRIMARY KEY, + team_name VARCHAR(36) DEFAULT NULL, + team_year int, + description TEXT DEFAULT NULL, + roster TEXT DEFAULT NULL, + default_lineup TEXT DEFAULT NULL +); +GRANT USAGE ON SCHEMA smartnpc TO llmuser; +GRANT SELECT ON smartnpc.teams TO llmuser; + diff --git a/examples/smart-npc/example/baseball/sql/schema_world_background.sql b/examples/smart-npc/example/baseball/sql/schema_world_background.sql new file mode 100644 index 0000000..4bdc48e --- /dev/null +++ b/examples/smart-npc/example/baseball/sql/schema_world_background.sql @@ -0,0 +1,12 @@ +create schema if not exists "smartnpc"; +CREATE TABLE IF NOT EXISTS smartnpc.world_background( + background_id VARCHAR(1024) PRIMARY KEY, + game_id VARCHAR(36) DEFAULT NULL, + background_name TEXT DEFAULT NULL, + content TEXT DEFAULT NULL, + lore_level int DEFAULT 1, + background_embeddings vector(768) NULL, + background TEXT DEFAULT NULL +); +GRANT USAGE ON SCHEMA smartnpc TO llmuser; +GRANT SELECT ON smartnpc.world_background TO llmuser; diff --git a/examples/smart-npc/k8s.template.yaml b/examples/smart-npc/k8s.template.yaml new file mode 100644 index 0000000..30c9404 --- /dev/null +++ b/examples/smart-npc/k8s.template.yaml @@ -0,0 +1,122 @@ +# # Copyright 2024 Google LLC All Rights Reserved. +# # +# # Licensed under the Apache License, Version 2.0 (the "License"); +# # you may not use this file except in compliance with the License. +# # You may obtain a copy of the License at +# # +# # http://www.apache.org/licenses/LICENSE-2.0 +# # +# # Unless required by applicable law or agreed to in writing, software +# # distributed under the License is distributed on an "AS IS" BASIS, +# # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# # See the License for the specific language governing permissions and +# # limitations under the License. + +apiVersion: networking.gke.io/v1 +kind: ManagedCertificate +metadata: + name: gdc-demo-cert +spec: + domains: + - llm-demo.gdc-demo.com +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smart-npc-api + labels: + name: smart-npc-api +spec: + replicas: 1 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 40% + maxUnavailable: 0 + selector: + matchLabels: + name: smart-npc-api + template: + metadata: + labels: + name: smart-npc-api + version: stable + annotations: + instrumentation.opentelemetry.io/inject-python: "genai-instrumentation" + spec: + serviceAccountName: k8s-sa-aiplatform + restartPolicy: Always + containers: + - image: us-central1-docker.pkg.dev/your-unique-project-id/repo-genai-quickstart/smart-npc-api + name: smart-npc-api + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + protocol: TCP + readinessProbe: + httpGet: + path: /docs + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 30 + env: + - name: ENV + value: dev + - name: CONFIG_TOML_PATH + value: /config/config.toml + resources: + requests: + cpu: 500m + memory: 256Mi + limits: + memory: 512Mi + volumeMounts: + - name: config-volume + mountPath: /config/config.toml + subPath: "config.toml" + volumes: + - name: config-volume + configMap: + name: smart-npc-config +--- +apiVersion: v1 +kind: Service +metadata: + name: smart-npc-ssl-svc # Name of Service + namespace: genai + annotations: + cloud.google.com/backend-config: '{"default": "my-backendconfig"}' + cloud.google.com/neg: '{"ingress": true}' # Creates a NEG after an Ingress is created +spec: # Service's specification + type: NodePort # ClusterIP + selector: + name: smart-npc-api + ports: + - name: http + port: 60000 # Service's port + protocol: TCP + targetPort: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: smart-npc-https-ingress + namespace: genai + annotations: + kubernetes.io/ingress.global-static-ip-name: "gdc-demo-baseball-ip" + networking.gke.io/managed-certificates: "gdc-demo-cert" +spec: + defaultBackend: + service: + name: smart-npc-ssl-svc # Name of the Service targeted by the Ingress + port: + number: 60000 # Should match the port used by the Service +--- +apiVersion: cloud.google.com/v1 +kind: BackendConfig +metadata: + name: smart-npc-websocket-timeout + namespace: genai +spec: + timeoutSec: 6000 diff --git a/examples/smart-npc/skaffold.template.yaml b/examples/smart-npc/skaffold.template.yaml new file mode 100644 index 0000000..b7e4272 --- /dev/null +++ b/examples/smart-npc/skaffold.template.yaml @@ -0,0 +1,37 @@ +# Copyright 2024 Google LLC All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: skaffold/v3 +kind: Config +metadata: + name: smart-npc-api-cfg +build: + googleCloudBuild: {} + tagPolicy: + sha256: {} + artifacts: + - image: us-central1-docker.pkg.dev/your-unique-project-id/repo-genai-quickstart/smart-npc-api + context: . +manifests: + rawYaml: + - ./config.yaml + - ./k8s.yaml +deploy: + kubectl: + flags: + global: + - --namespace=genai +requires: +- configs: ["common"] + path: ../../genai/common/skaffold.yaml diff --git a/examples/smart-npc/src/main.py b/examples/smart-npc/src/main.py new file mode 100644 index 0000000..0e500bd --- /dev/null +++ b/examples/smart-npc/src/main.py @@ -0,0 +1,104 @@ +""" +FastAPI Main entrance point +""" +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import tomllib +import os +import google.cloud.logging +import traceback + +import vertexai + +from fastapi import FastAPI, Response, WebSocket +from fastapi.middleware.cors import CORSMiddleware + +from routers.cache import router as cache_router +from routers.npc import router as npc_router +from routers.prompts import router as prompt_router +from routers.scene import router as scene_router +from routers.baseball import router as baseball_router +from typing import Union +# ----------------------------------------------------------------------------# +# Load configuration file (config.toml) and global configs +TOML_PATH = "config.toml" if os.environ["CONFIG_TOML_PATH"] == "" else os.environ["CONFIG_TOML_PATH"] +with open(TOML_PATH, "rb") as f: + config = tomllib.load(f) + +ALLOWED_PATH = ["/docs", "/openapi.json"] + +client = google.cloud.logging.Client( + project=config["gcp"]["google-project-id"] +) +client.setup_logging() + +# TODO: add asset tracking +_USER_AGENT = "cloud-solutions/smart-npc-v1.0" + +vertexai.init( + project=config["gcp"]["google-project-id"], + location=config["gcp"]["google-default-region"] +) + +app = FastAPI(debug=True) + +origins = [ + "http://localhost:8080", + "http://localhost", + "http://104.197.253.75" +] + +@app.middleware("http") +async def check_auth_token(request, call_next): + """ + FastAPI Middleware to authenticate client request using a pre-defined API key. + Args: + request: FastAPI request object. + call_next: FastAPI call_next object. + Returns: + """ + if request.scope['path'] in ALLOWED_PATH: + return await call_next(request) + + if "X-API-KEY" in request.headers: + token = request.headers["X-API-KEY"] + else: + token = None + + if token is not None: + if token != config["gcp"]["api-key"]: + return Response(status_code=401) + else: + return Response(status_code=401) + + response = await call_next(request) + return response + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"] + # allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], + # allow_headers=["X-Requested-With", "Content-Type", "X-API-KEY"], +) + +app.include_router(router=cache_router) +app.include_router(router=scene_router) +app.include_router(router=npc_router) +app.include_router(router=prompt_router) +app.include_router(router=baseball_router) diff --git a/examples/smart-npc/src/models/baseball.py b/examples/smart-npc/src/models/baseball.py new file mode 100644 index 0000000..9d12615 --- /dev/null +++ b/examples/smart-npc/src/models/baseball.py @@ -0,0 +1,97 @@ +""" +FastAPI Baseball Game Request / Response Models +""" +# Copyrightll 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Optional, Union +from pydantic import BaseModel + +class GetTeamsRequest(BaseModel): + """ + GetTeamsRequest is used to get a list of teams. + """ + team_id: Optional[str] + +class TeamData(BaseModel): + """ + TeamData is used to store team data. + """ + team_id: str + team_name: str + description: str + roster: dict + team_year: int + default_lineup: dict + +class GetTeamsResponse(BaseModel): + """ + GetTeamsResponse is used to return a list of teams. + """ + teams: list[TeamData] + +class UpdateRosterRequest(BaseModel): + """ + UpdateRosterRequest is used to update a roster. + """ + team_id: str + player_id: str + roster: dict + session_id: str + +class Roster(BaseModel): + """ + Roster is used to store a roster. + """ + team_id: str + player_id: str + roster: dict + session_id: str + +class UpdateLineupRequest(BaseModel): + """ + UpdateLineupRequest is used to update a lineup. + """ + team_id: str + player_id: str + lineup: dict + session_id: str + +class Lineup(BaseModel): + """ + Lineup is used to store a lineup. + """ + team_id: str + player_id: str + lineup: dict + session_id: str + +class GetSuggestionsRequest(BaseModel): + """ + GetSuggestionsRequest is used to get suggestions. + """ + player_team_id: str + computer_team_id: str + session_id: str + player_id: str + scene_id: str + input: str = "" + +class GetSuggestionsResponse(BaseModel): + """ + GetSuggestionsResponse is used to return suggestions. + """ + player_id: str + response: Union[str, dict] + session_id: str diff --git a/examples/smart-npc/src/models/npc.py b/examples/smart-npc/src/models/npc.py new file mode 100644 index 0000000..80b91fe --- /dev/null +++ b/examples/smart-npc/src/models/npc.py @@ -0,0 +1,62 @@ +""" +FastAPI NPC Request / Response Models +- NPC Conversations +- NPC Knowledge +- NPC Next Actions +""" +# Copyrightll 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pydantic import BaseModel +from typing import Union, Optional + +class SearchNPCKnowledgeRequest(BaseModel): + """ + SearchNPCKnowledgeRequest is used to search for NPC knowledge. + """ + npc_lore_level: int + query: str + +class Knowledge(BaseModel): + """ + Represents knowledge of an item, character, event, location or quest. + """ + knowledge: str + lore_level: int + score: float + +class SearchNPCKnowledgeResponse(BaseModel): + """ + List of knowledge. + """ + knowledge: list[Knowledge] + +class NPCInfoRequest(BaseModel): + """ + Request for NPC information. + """ + npc_id: str + game_id: str + +class NPCInfoResponse(BaseModel): + """ + Represents NPC information + """ + npc_id: str + background: str + npc_class: str + class_level: int + name: str + status: str + lore_level: int diff --git a/examples/smart-npc/src/models/prompt.py b/examples/smart-npc/src/models/prompt.py new file mode 100644 index 0000000..aaa90dd --- /dev/null +++ b/examples/smart-npc/src/models/prompt.py @@ -0,0 +1,38 @@ +""" +FastAPI NPC Request / Response Models +- Prompt Retrieval +""" +# Copyrightll 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pydantic import BaseModel +from typing import Optional + +class PromptRetrievalRequest(BaseModel): + """ + Request for prompt retrieval. + """ + game_id: str + scene_id: str + prompt_id: str + +class PromptRetrievalResponse(BaseModel): + """ + Response for prompt retrieval. + """ + game_id: str + scene_id: str + prompt_id: str + prompt_template: str + place_holders: list[str] diff --git a/examples/smart-npc/src/models/scence.py b/examples/smart-npc/src/models/scence.py new file mode 100644 index 0000000..39878c5 --- /dev/null +++ b/examples/smart-npc/src/models/scence.py @@ -0,0 +1,54 @@ +""" +FastAPI NPC chat in a scence Request / Response Models +""" +# Copyrightll 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pydantic import BaseModel +from typing import Union, Optional + +class NPCSceneConversationRequest(BaseModel): + """ + Represents dialogue and in-game information of a NPC conversation request. + """ + game_id: str + player_id: str + npc_id: str="" + input: str="" + in_game_time: str="" + scene_id: str="" + session_id: str + +class NPCSceneConversationResponse(BaseModel): + """ + Represents dialogue and in-game information of a NPC conversation response. + """ + player_id: str + npc_ids: list[str]=[] + scene_id: str="" + response: Union[str, dict] + in_game_time: str="" + session_id: str + +class Scene(BaseModel): + """ + Represents scene information + """ + game_id: str + scene_id :str + scene: str + npc_ids: list[str] + goal:str + status: str + knowledge: str diff --git a/examples/smart-npc/src/requirements.txt b/examples/smart-npc/src/requirements.txt new file mode 100644 index 0000000..c89e451 --- /dev/null +++ b/examples/smart-npc/src/requirements.txt @@ -0,0 +1,46 @@ +# Create Database +# sqlalchemy +google-cloud-secret-manager==2.16.4 +google-cloud-core==2.3.3 +cloud-sql-python-connector[asyncpg] +pgvector==0.2.3 +pg8000==1.30.3 + +# Data ingestion +pypdf==4.2.0 +cloud-sql-python-connector[pg8000] +SQLAlchemy==2.0.7 +google-cloud-aiplatform==1.59.0 +argparse + +# psycopg2 +psycopg2-binary +# pgvector +unstructured +# tensorflow-hub +# tensorflow +# tensorflow_text +# google-cloud-secret-manager +pandas +asyncpg +cloud-sql-python-connector[asyncpg] +cloud-sql-python-connector[pymysql] +google-cloud-logging +gcloud==0.18.3 +# numpy +# google-cloud-bigquery +fastapi + +proto-plus +protobuf +# google-cloud-pubsub + +# Caching +redis + +# Serving +uvicorn + +# Experimental Websocket +google-genai==1.2.0 +websockets==14.2 \ No newline at end of file diff --git a/examples/smart-npc/src/routers/baseball.py b/examples/smart-npc/src/routers/baseball.py new file mode 100644 index 0000000..c974b79 --- /dev/null +++ b/examples/smart-npc/src/routers/baseball.py @@ -0,0 +1,386 @@ +# Copyrightll 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Baseball Game Router + +Game Logic for Baseball simulation game + +Attributes: + router (object): FastAPI router object for baseball simulation +""" + +import os +import json +import tomllib +import logging + +from fastapi import ( + APIRouter, + HTTPException, + WebSocket, + WebSocketDisconnect +) +from websockets.exceptions import ConnectionClosed + +from typing import Optional, Union +from utils.cacheWrapper import CacheFactory +from utils.baseball import BaseballGameHelper, format_lineup, format_current_state +from models.baseball import ( + TeamData, + GetTeamsRequest, + UpdateLineupRequest, + Lineup, + UpdateRosterRequest, + Roster, + GetSuggestionsResponse, + GetSuggestionsRequest +) +from models.scence import ( + NPCSceneConversationRequest, + NPCSceneConversationResponse +) +from routers.scene import chat +from utils.baseball_streaming import chat_streaming + +# ----------------------------------------------------------------------------# +# Load configuration file (config.toml) and global configs +TOML_PATH = "config.toml" if os.environ["CONFIG_TOML_PATH"] == "" else os.environ["CONFIG_TOML_PATH"] +with open(TOML_PATH, "rb") as f: + config = tomllib.load(f) + +router = APIRouter(prefix="/game", tags=["Game"]) + +# ----------------------------------Variables and Functions---------------------------------------# + +# ----------------------------------GET---------------------------------------# +@router.get( + path="/teams/{team_id}" +) +def get_teams(team_id:str | None = None) -> list[TeamData]: + """ + Get Team data + Args: + session_id (str): session id. + team_id (str): select baseball team id. + if none team id is given, returns all teams. + if team_id = "all", returns all teams. + + Returns: + GetTeamsResponse object. + """ + baseball = BaseballGameHelper(config=config) + try: + if team_id == "all" or team_id is None: + teams = baseball.get_teams() + else: + teams = baseball.get_team(team_id=team_id) + # print(f"===\n{type(teams)}\n{teams}\n===") + return teams + except Exception as e: + raise e + raise HTTPException(status_code=400, + detail=f"Error: {e}") from e + +@router.get( + path="/rosters/{session_id}/{player_id}/{team_id}" +) +def get_rosters(session_id:str, player_id:str, team_id:str) -> Optional[Roster]: + """ + Get Team roster + Args: + session_id (str): session id. + team_id (str): select baseball team id. + if none team id is given, returns all teams. + + Returns: + Roster object. + """ + baseball = BaseballGameHelper(config=config) + try: + team = baseball.get_roster(team_id=team_id, + session_id=session_id, + player_id=player_id) + return team + except Exception as e: + raise HTTPException(status_code=400, + detail=f"Error: {e}") from e + + +@router.get( + path="/lineup/{session_id}/{player_id}/{team_id}" +) +def get_lineup(session_id:str, player_id:str, team_id:str) -> Optional[Lineup]: + """ + Get Team roster + Args: + session_id (str): session id. + team_id (str): select baseball team id. + if none team id is given, returns all teams. + + Returns: + GetTeamsResponse object. + """ + baseball = BaseballGameHelper(config=config) + try: + team = baseball.get_lineup(team_id=team_id, + player_id=player_id, + session_id=session_id) + return team + except Exception as e: + raise HTTPException(status_code=400, + detail=f"Error: {e}") from e + + +# ----------------------------------POST---------------------------------------# +@router.post( + path="/lineup" +) +def update_lineup(lineup:UpdateLineupRequest) -> None: + """ + Update team lineup + Args: + lineup (UpdateLineupRequest): UpdateLineupRequest object. + + Returns: + None. + """ + baseball = BaseballGameHelper(config=config) + baseball.update_lineup( + team_id=lineup.team_id, + session_id=lineup.session_id, + player_id=lineup.player_id, + lineup=lineup.lineup + ) + +@router.post( + path="/rosters" +) +def update_roster(roster:UpdateRosterRequest) -> None: + """ + Update team roster + Args: + roster (UpdateRosterRequest): UpdateRosterRequest object. + + Returns: + None. + """ + baseball = BaseballGameHelper(config=config) + baseball.update_roster( + team_id=roster.team_id, + session_id=roster.session_id, + player_id=roster.player_id, + roster=roster.roster + ) + +@router.post( + path="/get_suggestions" +) +def get_suggestions(req:GetSuggestionsRequest) -> NPCSceneConversationResponse: + """ + Get Tactics suggestions and recommendations + Args: + req (GetSuggestionsRequest): GetSuggestionsRequest object. + + Returns: + NPCSceneConversationResponse object. + """ + # return _get_suggestions(req=req, ws=None, func=None) + baseball = BaseballGameHelper(config=config) + player_lineup = baseball.get_lineup( + team_id=req.player_team_id, + session_id=req.session_id, + player_id=req.player_id + ) + computer_lineup = baseball.get_lineup( + team_id=req.computer_team_id, + session_id=req.session_id, + player_id="Computer" + ) + if player_lineup is not None: + logging.info(f"** player_lineup:{json.dumps(player_lineup)}") + else: + logging.info(f"** player_lineup is None") + if computer_lineup is not None: + logging.info(f"** computer_lineup:{json.dumps(computer_lineup)}") + else: + logging.info(f"** computer_lineup is None") + # TODO: format the current state + final_input = f""" +### Player lineup + +{format_lineup(lineup=player_lineup)} + +### Opponent lineup + +{format_lineup(lineup=computer_lineup)} + +### Current State + +Player plays the `home` team. + +{format_current_state(req.input)} + """ + conv_req = NPCSceneConversationRequest( + game_id = config["game"]["game_id"], + player_id = req.player_id, + npc_id = "coach", + input = final_input, + in_game_time = "", + scene_id = req.scene_id, + session_id = req.session_id + ) + conv_resp = chat(conv_req) + return conv_resp + +###################### +# WebSocket Interface +###################### +async def _get_suggestions(req:GetSuggestionsRequest, + model:str = "gemini-2.0-flash-001", + temperature:float = 1, + top_p:float = 0.95, + max_output_tokens:int = 8192, + ws:Optional[WebSocket] = None, + func:any=None) -> NPCSceneConversationResponse: + """ + Get Tactics suggestions and recommendations + Args: + req (GetSuggestionsRequest): GetSuggestionsRequest object. + + Returns: + NPCSceneConversationResponse object. + """ + baseball = BaseballGameHelper(config=config) + player_lineup = baseball.get_lineup( + team_id=req.player_team_id, + session_id=req.session_id, + player_id=req.player_id + ) + computer_lineup = baseball.get_lineup( + team_id=req.computer_team_id, + session_id=req.session_id, + player_id="Computer" + ) + # TODO: format the current state + final_input = f""" +### Team {req.player_team_id} lineup + +{format_lineup(lineup=player_lineup)} + +### Team {req.computer_team_id} lineup + +{format_lineup(lineup=computer_lineup)} + +### Current State + +{format_current_state(req.input)} + """ + conv_req = NPCSceneConversationRequest( + game_id = config["game"]["game_id"], + player_id = req.player_id, + npc_id = "coach", + input = final_input, + in_game_time = "", + scene_id = req.scene_id, + session_id = req.session_id + ) + + if ws is None: + conv_resp = chat(conv_req) + return conv_resp + else: + await chat_streaming(req=conv_req, websocket=ws, func=func, + model = model, + temperature = temperature, + top_p = top_p, + max_output_tokens = max_output_tokens,) + +async def response_handler(text:str, ws:WebSocket, req:NPCSceneConversationRequest) -> None: + """ + Response handler for websocket interface. + """ + resp = { + "player_id":req.player_id, + "npc_ids":[], + "scene_id":req.scene_id, + "response":text, + "session_id":req.session_id, + "in_game_time":req.in_game_time + } + await ws.send_text(json.dumps(resp)) + + +@router.websocket("/streaming") +async def websocket_endpoint(websocket: WebSocket): + """ + WebSocket endpoint for real-time communication of baseball game suggestions. + + This endpoint allows clients to connect via WebSockets and receive + real-time updates and suggestions for the baseball game. It handles + incoming messages, processes them to generate AI-driven suggestions, + and streams the responses back to the client. + + Args: + websocket (WebSocket): The WebSocket connection object. + + Raises: + WebSocketDisconnect: If the client disconnects from the WebSocket. + ConnectionClosed: If the WebSocket connection is closed unexpectedly. + + Receives: + JSON data from the client with the following structure: + { + "player_team_id": str, # ID of the player's team. + "computer_team_id": str, # ID of the computer's team. + "session_id": str, # ID of the game session. + "player_id": str, # ID of the player. + "scene_id": str, # ID of the current scene. + "input": str, # Current game state or player's input. + "temperature": float, # Temperature for the LLM + } + + Sends: + JSON data to the client with the following structure: + { + "player_id": str, # ID of the player. + "npc_ids": list[str], # List of NPC ids + "scene_id": str, # ID of the current scene. + "response": str, # AI-generated response or suggestion. + "session_id": str, # ID of the game session. + "in_game_time": str # Current in game time + } + """ + await websocket.accept() + try: + while True: + data = await websocket.receive_text() + obj = json.loads(data) + temperature = obj["temperature"] if "temperature" in obj else 0 + print(f"* temperature={temperature}") + req = GetSuggestionsRequest( + player_team_id=obj["player_team_id"], + computer_team_id=obj["computer_team_id"], + session_id=obj["session_id"], + player_id=obj["player_id"], + scene_id=obj["scene_id"], + input=obj["input"] + ) + await _get_suggestions(req=req, + ws=websocket, + func=response_handler, + temperature=temperature) + except (WebSocketDisconnect, ConnectionClosed): + logging.error("websocket connection closed") + diff --git a/examples/smart-npc/src/routers/cache.py b/examples/smart-npc/src/routers/cache.py new file mode 100644 index 0000000..a038a25 --- /dev/null +++ b/examples/smart-npc/src/routers/cache.py @@ -0,0 +1,140 @@ +# Copyrightll 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Cache Router + +Managing Cache + +Attributes: + router (object): FastAPI router object for Cache operations +""" + +import os +import json +import tomllib + +from fastapi import APIRouter +from typing import Optional, Union +from utils.cacheWrapper import CacheFactory, CacheWrapper, RedisCacheWrapper + +# ----------------------------------------------------------------------------# +# Load configuration file (config.toml) and global configs +TOML_PATH = "config.toml" if os.environ["CONFIG_TOML_PATH"] == "" else os.environ["CONFIG_TOML_PATH"] +with open(TOML_PATH, "rb") as f: + config = tomllib.load(f) + + +# ----------------------------------------------------------------------------# + +router = APIRouter(prefix="/cache", tags=["Cache"]) + +__AVAILABLE_CACHES = ["npcs", "conversations", "scenes", "conversation_history", "game"] + +def __get_cache_server(cache_key:str) -> Optional[Union[CacheWrapper, RedisCacheWrapper]]: + if cache_key in __AVAILABLE_CACHES: + return CacheFactory(config).get_cache(cache_key) + else: + return None + + +# ----------------------------------GET---------------------------------------# +@router.get( + path="/keys" +) +def keys(cache_key:str) -> Optional[list[str]]: + """ + List cached item keys + + Parmas: + cache_key: one of ["npc", "quests", "conversations", "scene"] + + Returns: + List of cached keys + """ + cacheServer = __get_cache_server(cache_key=cache_key) + if cacheServer is None: + return None + # keys = [key for key in cacheServer.keys() if cacheServer.get(key[key.index("_") + 1:]) is not None] + return cacheServer.keys() + +@router.get( + path="/{cache_key}/items/{key}" +) +def get_item(cache_key:str, key:str) -> str: + """ + List cached item keys + + Returns: + List of cached keys + """ + cacheServer = __get_cache_server(cache_key=cache_key) + return f"{cacheServer.get(key=key)}" + +# ----------------------------------POST---------------------------------------# +@router.post( + path="/{cache_key}/{key}" +) +def set_item(cache_key:str, key:str, value:str) -> str: + """ + Add a string to the cache + + Returns: + The string + """ + cacheServer = __get_cache_server(cache_key=cache_key) + cacheServer.set(key=key, value=value) + + return value + +# ----------------------------------DELETE---------------------------------------# +@router.post( + path="/delete-all" +) +def delete_all() -> str: + log = "" + for cache_key in __AVAILABLE_CACHES: + cache = CacheFactory(config).get_cache(cache_key) + key_values = cache.keys() + for kv in key_values: + key = kv.split(":")[0].replace("[", "").replace("]", "") + cache.delete(key=key) + log = log + os.linesep + f"Deleted {key}" + return log + +@router.delete( + path="/{cache_key}/items/{key}" +) +def delete_item(cache_key:str, key:str) -> list[str]: + """ + Delete a cached item + + Params: + key(str): key of the cached item. Use "*" for deleting all cached items. + + Returns: + None + """ + keys_deleted = [] + keys_deleted.append(key) + if key == "*": + for k in keys(cache_key): + delete_item(cache_key=cache_key, key=k) + keys_deleted.append(k) + else: + cacheServer = __get_cache_server(cache_key) + cacheServer.delete(key) + keys_deleted.append(key) + + return keys_deleted diff --git a/examples/smart-npc/src/routers/knowledge.py b/examples/smart-npc/src/routers/knowledge.py new file mode 100644 index 0000000..29cf48b --- /dev/null +++ b/examples/smart-npc/src/routers/knowledge.py @@ -0,0 +1,93 @@ +""" +Knowledge Retrival Router + +Retrive relevant documents. + +Attributes: + router (object): FastAPI router object for Knowledge retrival +""" + +# Copyrightll 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import tomllib +import vertexai.preview.generative_models as generative_models + +from fastapi import APIRouter + +from utils.rag import RAG + +from models.npc import ( + SearchNPCKnowledgeRequest, + Knowledge, + SearchNPCKnowledgeResponse +) + +# ----------------------------------------------------------------------------# +# Load configuration file (config.toml) and global configs +TOML_PATH = "config.toml" if os.environ["CONFIG_TOML_PATH"] == "" else os.environ["CONFIG_TOML_PATH"] +with open(TOML_PATH, "rb") as f: + config = tomllib.load(f) + +# ----------------------------------------------------------------------------# + +generation_config = { + "max_output_tokens": 8192, + "temperature": 1, + "top_p": 0.95, +} + +safety_settings = { + generative_models.HarmCategory.HARM_CATEGORY_HATE_SPEECH: generative_models.HarmBlockThreshold.BLOCK_ONLY_HIGH, # pylint: disable=line-too-long + generative_models.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: generative_models.HarmBlockThreshold.BLOCK_ONLY_HIGH, # pylint: disable=line-too-long + generative_models.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: generative_models.HarmBlockThreshold.BLOCK_ONLY_HIGH, # pylint: disable=line-too-long + generative_models.HarmCategory.HARM_CATEGORY_HARASSMENT: generative_models.HarmBlockThreshold.BLOCK_ONLY_HIGH, # pylint: disable=line-too-long +} +# ----------------------------------------------------------------------------# + +router = APIRouter(prefix="/knowledge", tags=["NPC - Knowledge"]) + + +@router.post( + path="/search_knowledge" +) +def search_knowledge( + req:SearchNPCKnowledgeRequest + ) -> SearchNPCKnowledgeResponse: + """Search knowledge relevant to user's input query + + Args: + req: Player's input query. + + Returns: + Knowledge that is relevant to the query. + """ + rag = RAG(config=config) + results = rag.search_knowledge( + query=req.query, + lore_level=req.npc_lore_level + ) + information = [] + for result in results: + information.append( + Knowledge( + knowledge = result["background"], + lore_level = int(result["lore_level"]), + score = float(result["score"]) + ) + ) + return SearchNPCKnowledgeResponse( + knowledge=information + ) diff --git a/examples/smart-npc/src/routers/npc.py b/examples/smart-npc/src/routers/npc.py new file mode 100644 index 0000000..5259094 --- /dev/null +++ b/examples/smart-npc/src/routers/npc.py @@ -0,0 +1,68 @@ +# Copyrightll 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +NPC Router + +Entry points of NPC related actions. + +Attributes: + router (object): FastAPI router object for NPC actions +""" + +import os +import json +import tomllib + +from typing import Optional +from fastapi import APIRouter, HTTPException + +from utils.npcManager import NPCManager +from models.npc import ( + NPCInfoRequest, + NPCInfoResponse +) +from utils.database import DataConnection + + +# ----------------------------------------------------------------------------# +# Load configuration file (config.toml) and global configs +TOML_PATH = "config.toml" if os.environ["CONFIG_TOML_PATH"] == "" else os.environ["CONFIG_TOML_PATH"] +with open(TOML_PATH, "rb") as f: + config = tomllib.load(f) + +# ----------------------------------------------------------------------------# + +router = APIRouter(prefix="/npcs", tags=["NPC - Conversations"]) + +# ----------------------------------GET---------------------------------------# +@router.get( + path="/{game_id}/{npc_id}", response_model=NPCInfoResponse +) +def get_npc(npc_id:str, game_id:str) -> NPCInfoResponse: + """Get NPC configuration by id + + Args: + npc_id: unique id of the NPC. + + Returns: + NPC information object. + """ + manager = NPCManager(config=config) + npc = manager.get_npc( + npc=NPCInfoRequest( + npc_id=npc_id, + game_id=game_id + )) + return npc diff --git a/examples/smart-npc/src/routers/prompts.py b/examples/smart-npc/src/routers/prompts.py new file mode 100644 index 0000000..e8d0e88 --- /dev/null +++ b/examples/smart-npc/src/routers/prompts.py @@ -0,0 +1,68 @@ +# Copyrightll 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Prompt Management Router + +Entry points of Prompt Management related actions. + +Attributes: + router (object): FastAPI router object for Prompt Management +""" + +import os +import tomllib + +from fastapi import APIRouter, HTTPException + +from utils.promptManager import PromptManager +from models.prompt import PromptRetrievalRequest, PromptRetrievalResponse + +router = APIRouter(prefix="/prompts", tags=["Prompt Management"]) + +# ----------------------------------------------------------------------------# +# Load configuration file (config.toml) and global configs +TOML_PATH = "config.toml" if os.environ["CONFIG_TOML_PATH"] == "" else os.environ["CONFIG_TOML_PATH"] +with open(TOML_PATH, "rb") as f: + config = tomllib.load(f) + +# ----------------------------------GET---------------------------------------# +@router.get( + path="/{game_id}/{scene_id}/{prompt_id}" +) +def get_prompt( + game_id:str, scene_id:str, prompt_id:str + ) -> PromptRetrievalResponse: + """ + Get prompt template by id. + Args: + game_id (str): game id. + scene_id (str): scene id. + Returns: + PromptRetrievalResponse object. + Raises: + ValueError: If the game id is invalid. + """ + try: + if game_id != config["game"]["game_id"]: + raise ValueError("Invalid game id.") + + prompt_tempalte = PromptManager(config=config).construct_prompt( + prompt_id=prompt_id, + scene_id=scene_id + ) + return prompt_tempalte + except Exception as e: + raise HTTPException(status_code=400, + detail=f"Error: {e}") from e diff --git a/examples/smart-npc/src/routers/scene.py b/examples/smart-npc/src/routers/scene.py new file mode 100644 index 0000000..805b8f5 --- /dev/null +++ b/examples/smart-npc/src/routers/scene.py @@ -0,0 +1,206 @@ +# Copyrightll 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Scene Router + +Entry points of Scene related actions. + +Attributes: + router (object): FastAPI router object for Scene actions +""" + +import os +import tomllib +import logging +import json + +from fastapi import APIRouter, HTTPException + +from utils.cacheWrapper import CacheFactory +from utils.llmValidator import LLMValidator +from utils.conversationManager import ConversationManager +from models.scence import ( + NPCSceneConversationRequest, + NPCSceneConversationResponse, + Scene +) + +from utils.sceneManager import SceneManager +from utils.promptManager import PromptManager +from utils.conversationManager import ConversationManager + +router = APIRouter(prefix="/scenes", tags=["SCENCE - Conversations"]) + +# ----------------------------------------------------------------------------# +# Load configuration file (config.toml) and global configs +TOML_PATH = "config.toml" if os.environ["CONFIG_TOML_PATH"] == "" else os.environ["CONFIG_TOML_PATH"] +with open(TOML_PATH, "rb") as f: + config = tomllib.load(f) + +# ----------------------------------Variables and Functions---------------------------------------# +cached_conv_example = CacheFactory(config).get_cache("conv_example") + +def substring_occurence(test_str:str, test_sub:str) -> int: + """ + Count the number of occurences of a substring in a string. + Args: + test_str (str): The string to search in. + test_sub (str): The substring to search for + Returns: + int: The number of occurences of the substring in the string. + """ + occurence = len(test_str.split(test_sub)) - 1 + + return occurence + +def ensure_json_dict(input:any) -> dict: + """ + Ensure that the input is a valid JSON dictionary. + Args: + input (any): The input to be converted to a JSON dictionary. + Returns: + dict: The input converted to a JSON dictionary. + """ + if isinstance(input, dict): + return input + elif isinstance(input, str): + try: + return ensure_json_dict(json.loads(input)) + except json.JSONDecodeError as e: + logging.error(f"* Unable to decode json string:{e}") + return {} +# ----------------------------------GET---------------------------------------# +@router.get( + path="/{scene_id}" +) +def get_scence(game_id:str, scene_id:str) -> Scene: + """ + Get Scene configuration. + Args: + game_id (str): game id. + scene_id (str): scene id. + + Returns: + Scene configuraiton object. + """ + if game_id != config["game"]["game_id"]: + raise ValueError("Invalid game id.") + try: + scene = SceneManager(config=config).get_scence( + game_id=game_id, + scene_id=scene_id + ) + return scene + except Exception as e: + raise HTTPException(status_code=400, + detail=f"Error: {e}") from e + +# ----------------------------------POST---------------------------------------# +@router.post( + path="/chat" +) +def chat(req:NPCSceneConversationRequest) -> NPCSceneConversationResponse: + """Generates NPC responses + + Args: + req: Player's input query. + + Returns: + NPC's response to the player's inpput. + """ + if req.game_id != config["game"]["game_id"]: + raise ValueError("Invalid game id.") + + scene = None + if req.scene_id: + logging.info({"message":f"* get_scene: {req.scene_id} | {req.game_id}"}) + scene = get_scence(scene_id=req.scene_id, + game_id=req.game_id) + + if scene is None: + raise HTTPException(status_code=400, + detail=f"Scene not found:{req.scene_id}") + + # TODO: No Knowledge at the moment + # max_lore_level = max([npc.lore_level for npc in npcs]) + # knowledge = search_knowledge(SearchNPCKnowledgeRequest( + # npc_lore_level = max_lore_level, + # query = req.input + # )) + + # TODO: Let's revist how to fetch quests in this scene + # quests = search_quest( + # npc_id=req.npc_id + # ) + + # language_code = config["npc"]["RESPONSE_LANGUAGE"] + + # TODO: In this case, we do not need the player information. + # Because all charachter information, including the main character inforamtion + # is listed with other characters in the prompt. + # player = get_player_info() + + if scene.goal != "" and scene.goal != "NA": + prompt_template_id = "NPC_CONVERSATION_SCENCE_GOAL_TEMPLATE" + else: + prompt_template_id = "NPC_CONVERSATION_SCENCE_NO_GOAL_TEMPLATE" + + prompt_manager = PromptManager(config=config) + prompt_template = prompt_manager.construct_prompt( + prompt_id=prompt_template_id, + scene_id=req.scene_id + ) + logging.info(f"""Final Input: + +{req.input} +""") + answer, history = ConversationManager(config=config).chat( + conversation=req, + prompt=prompt_template + ) + logging.info({"message":f"* answer.candidates={answer.candidates}"}) + if answer is not None: + json_answer_text = answer.candidates[0].content.parts[0].text.replace("```json", "").replace("```", "") # pylint: disable=line-too-long + + logging.info({"message":f"* scene.chat | json_answer_text={json_answer_text}"}) + if config["game"]["enable_validator"] == "True": + validator = LLMValidator(config=config) + json_answer_text = validator.validate( + scene_id=req.scene_id, + player_input=req.input, + npc_response=json_answer_text, + npcs=",".join([id for id in scene.npc_ids]), + conversation_history=history + ) + occurence = substring_occurence(test_str=json_answer_text, test_sub="[CHAR(") + + if occurence > 1: + json_answer_text = json_answer_text.replace("[CHAR(", os.linesep + "[CHAR(") + + json_answer_text = json_answer_text.replace("\\n", "") + response_text = json_answer_text # f"{ensure_json_dict(json_answer_text)}" # json.loads(json.loads(json_answer_text)) + + return NPCSceneConversationResponse( + player_id=req.player_id, + npc_id=",".join(scene.npc_ids), + scene_id=req.scene_id, + response=response_text, # json_answer_text, + session_id=req.session_id, + in_game_time=req.in_game_time + ) + else: + logging.error({"message":f"No llm response."}) + raise HTTPException(status_code=400, + detail=f"Error: no llm response") diff --git a/examples/smart-npc/src/utils/baseball.py b/examples/smart-npc/src/utils/baseball.py new file mode 100644 index 0000000..5941f6d --- /dev/null +++ b/examples/smart-npc/src/utils/baseball.py @@ -0,0 +1,485 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import logging +import os + +from typing import Optional +from models.prompt import PromptRetrievalResponse +from utils.cacheWrapper import CacheFactory +from utils.database import DataConnection +from utils.cacheWrapper import CacheFactory +from vertexai.preview import generative_models +from vertexai.generative_models import GenerativeModel + +""" +Helper functions +""" +def format_current_state(state:str) -> str: + """ + Get formatted current state string. + Args: + state(str): Current state. + Returns: + Formatted current state, which will become part of user input to the LLM. + Example: + state = { + "input":{ + "CURRENT_STATE":"The catcher on the away team is batting in the bottom of the 1 inning, with 0 outs and no runners on base. The away team has 0 runs, and The home has 0.", + "PITCHTER_INFO":"{\"name\":\"Thomas Anderson\",\"stats\":{\\\"this_season\\\":{\\\"GP\\\":20,\\\"GS\\\":20,\\\"CG\\\":1,\\\"SHO\\\":1,\\\"IP\\\":104,\\\"H\\\":65,\\\"R\\\":38,\\\"ER\\\":36,\\\"HR\\\":6,\\\"BB\\\":44,\\\"K\\\":145},\\\"career\\\":{\\\"GP\\\":211,\\\"GS\\\":211,\\\"CG\\\":1,\\\"SHO\\\":1,\\\"IP\\\":1096.2,\\\"H\\\":840,\\\"R\\\":419,\\\"ER\\\":389,\\\"HR\\\":108,\\\"BB\\\":495,\\\"K\\\":1368}}}\", + "BATTING_LINEUP":\"\\nPosition,Player,Batting Hand,Avg,HR,RBI,Notes\\nC,Samuel Rivera,R,0.26,12,60,Good defensive catcher, improving bat.,1B,Olivia Chen,L,0.32,25,100,Power hitter, solid defender.,2B,Daniel Kim,R,0.29,8,50,Excellent fielder, consistent hitter.,SS,Sophia Rodriguez,R,0.275,10,55,Good range, strong arm at short.,3B,Ethan Brown,L,0.3,20,90,Power hitter, clutch performer.,LF,Ava Davis,L,0.28,14,70,Speedy outfielder, good on-base percentage.,CF,Noah Wilson,R,0.31,7,45,Leadoff hitter, great speed.,RF,Isabella Garcia,R,0.295,17,80,Strong arm, consistent power threat.,DH,Jackson Smith,L,0.285,22,95,Designated hitter, pure power hitter.\"}"} + } + """ + stateObj = json.loads(state) + pitcher = f""" +### Pitcher Info +{json.loads(stateObj["PITCHTER_INFO"])} +""" if "PITCHTER_INFO" in stateObj else "" + batters = f""" +### Batting Info +{stateObj["BATTING_LINEUP"]} +""" if "BATTING_LINEUP" in stateObj else "" + formatted_state = f""" +## Current State +{stateObj["CURRENT_STATE"]} +""" + return formatted_state + +def format_lineup(lineup:dict) -> str: + """ + Format the lineup json to markdown table. + Args: + lineup(dict): lineup + Returns: + Markdown table. + """ + markdown = format_pitcher_lineup(lineup=lineup) + format_batters_lineup(lineup=lineup) + + return markdown + +def format_pitcher_lineup(lineup:dict) -> str: + """ + Format the lineup json to markdown table. + Args: + lineup(dict): lineup + Returns: + Markdown table. + """ + pitcher = lineup["lineup"]["pitcher"] + markdown = "" + + markdown = markdown + f""" +* Pitcher: { pitcher["name"]} + +""" + pitcher_header = "||" + "|".join([key for key in pitcher["stats"]["this_season"].keys()]) + "|" + pitcher_header = pitcher_header + """ +|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:| +""" + pitch_stastics = "|This Season|" + "|".join([f"""{pitcher["stats"]["this_season"][key]}""" for key in pitcher["stats"]["this_season"].keys()]) + "|" + os.linesep + pitch_stastics = pitch_stastics + "|Career|" + "|".join([f"""{pitcher["stats"]["career"][key]}""" for key in pitcher["stats"]["this_season"].keys()]) + "|" + os.linesep + markdown = markdown + pitcher_header + pitch_stastics + + return markdown + +def format_batters_lineup(lineup:dict) -> str: + """ + Format the lineup json to markdown table. + Args: + lineup(dict): lineup + Returns: + Markdown table. + """ + batters = lineup["lineup"]["fielders"] + markdown = "* Lineup" + os.linesep+ os.linesep + + columns = "|Position|Name|Hand|Avg|HR|RBI|Notes|" + os.linesep + columns = columns + "|:--:|:--:|:--:|:--:|:--:|:--:|:--:|" + os.linesep + statistics_table = "" + batter_statistics = "" + for batter in batters: + if isinstance(batter, dict): + batter_statistics = "|" + \ + batter["position"] + \ + "|" + \ + batter["name"] + \ + "|" + \ + batter["hand"] + \ + "|" + \ + f"""{batter["avg"]}""" + \ + "|" + \ + f"""{batter["hr"]}""" + \ + "|" + \ + f"""{batter["rbi"]}""" + \ + "|" + \ + batter["notes"] + \ + "|" + \ + os.linesep + elif isinstance(batter, list): + batter_statistics = "|" + \ + batter[0] + \ + "|" + \ + batter[1] + \ + "|" + \ + batter[2] + \ + "|" + \ + f"""{batter[3]}""" + \ + "|" + \ + f"""{batter[4]}""" + \ + "|" + \ + f"""{batter[5]}""" + \ + "|" + \ + batter[6] + \ + "|" + \ + os.linesep + + statistics_table = statistics_table + batter_statistics + markdown = markdown + columns + statistics_table + + return markdown + +""" +Baseball Game Logic Help class +""" +class BaseballGameHelper(): + """ + Helper class for prompt management. + """ + def __init__(self, config:dict): + """ + Initialize the prompt manager helper class. + + Args: + config (dict): config.toml dict. + """ + self._cache = CacheFactory(config=config).get_cache("game") + self._config = config + self._connection = DataConnection(config=config) + + def __format_game_data_cache_key( + self, + game_id:str, + session_id:str, + team_id:str) -> str: + """ + Format conversation cache key + + Args: + game_id (str): game id. + session_id (str): session id. + team_id (str): team id. + Returns: + Formatted cache key. + """ + return f"{game_id}/{session_id}/{team_id}" + + def __format_roster_data_cache_key( + self, + game_id:str, + session_id:str, + team_id:str) -> str: + """ + Format conversation cache key + Args: + game_id (str): game id. + session_id (str): session id. + team_id (str): team id. + Returns: + Formatted cache key. + """ + return f"{game_id}/{session_id}/{team_id}/roster" + + def __format_lineup_data_cache_key( + self, + game_id:str, + session_id:str, + team_id:str) -> str: + """ + Format conversation cache key + Args: + game_id (str): game id. + session_id (str): session id. + team_id (str): team id. + Returns: + Formatted cache key. + """ + return f"{game_id}/{session_id}/{team_id}/lineup" + + def get_teams( + self + ) -> list[dict]: + """ + Get Team data. + + Args: + team_id (str): team id. + if none team id is given, returns all teams. + + Returns: + Team Data. + """ + team_id = "all" + sql = self._config["baseball"]["QUERY_TEAMS"] + sql_params = None + session_id="all" + + game_id = self._config["game"]["game_id"] + key = self.__format_game_data_cache_key( + game_id=game_id, + session_id=session_id, + team_id=team_id) + all_teams = self._cache.get(key=key) + + if all_teams is None or all_teams == "": + results = self._connection.execute( + sql = sql, + sql_params=sql_params + ) + all_teams = [] + for result in results: + t = { + "team_id": result[0], + "team_name": f"{result[2]} {result[1]}", + "team_year": result[2], + "description": result[3], + "roster": json.loads(result[4]), + "default_lineup": json.loads(result[5]) + } + key = self.__format_game_data_cache_key( + game_id=game_id, session_id=session_id, team_id=t["team_id"] + ) + all_teams.append(t) + self._cache.set(key, t) + + key = self.__format_game_data_cache_key( + game_id=game_id, session_id=session_id, team_id="all" + ) + self._cache.set(key, all_teams) + + return all_teams + + def get_team( + self, + team_id:str + ) -> list[dict]: + """ + Get Team data. + + Args: + team_id (str): team id. + if none team id is given, returns all teams. + + Returns: + Team Data. + """ + if team_id == "" or team_id is None: + raise ValueError("team_id must not be empty.") + else: + sql = self._config["baseball"]["QUERY_TEAM"] + sql_params={ + "team_id":team_id + } + + session_id="all" + + game_id = self._config["game"]["game_id"] + key = self.__format_game_data_cache_key( + game_id=game_id, + session_id=session_id, + team_id=team_id) + team = self._cache.get(key=key) + teams = [] + if team is None or team == "": + results = self._connection.execute( + sql = sql, + sql_params=sql_params + ) + for result in results: + team = { + "team_id": result[0], + "team_name": f"{result[2]} {result[1]}", + "team_year": result[2], + "description": result[3], + "roster": json.loads(result[4]), + "default_lineup": json.loads(result[5]) + } + key = self.__format_game_data_cache_key( + game_id=game_id, session_id=session_id, team_id=team["team_id"] + ) + teams.append(team) + self._cache.set(key, team) + else: + teams.append(team) + return teams + + def get_roster(self, team_id:str, session_id:str, player_id:str) -> Optional[dict]: + """ + Get team roster. + + Args: + team_id(str): team id. + session_id(str): session id. + player_id(str): player id. + + Returns: + Team data. + """ + if team_id == "" or team_id is None: + raise ValueError("team_id must not be empty.") + else: + sql = self._config["baseball"]["QUERY_TEAM_ROSTER"] + sql_params={ + "team_id":team_id, + "session_id":session_id, + "player_id":player_id + } + + game_id = self._config["game"]["game_id"] + key = self.__format_roster_data_cache_key( + game_id=game_id, + session_id=session_id, + team_id=team_id) + roster = self._cache.get(key=key) + team = None + if roster is None or roster == "": + results = self._connection.execute( + sql = sql, + sql_params=sql_params + ) + for result in results: + team = { + "team_id": team_id, + "session_id": session_id, + "player_id": player_id, + "roster": json.loads(result[3]) + } + key = self.__format_roster_data_cache_key( + game_id=game_id, session_id=session_id, team_id=team_id + ) + self._cache.set(key, team) + else: + team = { + "team_id": team_id, + "session_id": session_id, + "player_id": player_id, + "roster": roster + } + return team + + def get_lineup(self, team_id:str, session_id:str, player_id:str) -> Optional[dict]: + """ + Get team lineup. + + Args: + team_id(str): team id. + session_id(str): session id. + player_id(str): player id. + + Returns: + Team lineup. + """ + if team_id == "" or team_id is None: + raise ValueError("team_id must not be empty.") + else: + sql = self._config["baseball"]["QUERY_TEAM_LINEUP"] + sql_params={ + "team_id":team_id, + "session_id":session_id, + "player_id":player_id + } + + game_id = self._config["game"]["game_id"] + key = self.__format_lineup_data_cache_key( + game_id=game_id, + session_id=session_id, + team_id=team_id) + lineup = self._cache.get(key=key) + team = None + if lineup is None or lineup == "": + results = self._connection.execute( + sql = sql, + sql_params=sql_params + ) + for result in results: + team = { + "team_id": team_id, + "session_id": session_id, + "player_id": player_id, + "lineup": json.loads(result[3]) + } + self._cache.set(key, team) + else: + team = { + "team_id": team_id, + "session_id": session_id, + "player_id": player_id, + "lineup": lineup["lineup"] if "lineup" in lineup else lineup + } + return team + + def update_lineup(self, team_id:str, session_id:str, player_id:str, lineup:dict) -> None: + """ + Update or Insert team lineup. + + Args: + team_id(str): team id. + session_id(str): session id. + player_id(str): player id. + + Returns + Team lineup + """ + print(f"Updating lineup...{type(lineup)} | {lineup}") + sql = self._config["baseball"]["UPSERT_TEAM_LINEUP"] + sql_params = { + "team_id": team_id, + "session_id": session_id, + "player_id": player_id, + "lineup": json.dumps(lineup) + } + self._connection.execute(sql=sql, sql_params=sql_params) + key = self.__format_lineup_data_cache_key( + game_id = self._config["game"]["game_id"], + session_id=session_id, + team_id=team_id + ) + self._cache.set(key, lineup) + + def update_roster(self, team_id:str, session_id:str, player_id:str, roster:dict) -> None: + """ + Update or Insert team roster. + + Args: + team_id(str): team id. + session_id(str): session id. + player_id(str): player id. + + Returns + Team roster + """ + sql = self._config["baseball"]["UPSERT_TEAM_ROSTER"] + sql_params = { + "team_id": team_id, + "session_id": session_id, + "player_id": player_id, + "roster": json.dumps(roster) + } + self._connection.execute(sql=sql, sql_params=sql_params) + key = self.__format_roster_data_cache_key( + game_id = self._config["game"]["game_id"], + session_id=session_id, + team_id=team_id + ) + self._cache.set(key, roster) diff --git a/examples/smart-npc/src/utils/baseball_streaming.py b/examples/smart-npc/src/utils/baseball_streaming.py new file mode 100644 index 0000000..8644abe --- /dev/null +++ b/examples/smart-npc/src/utils/baseball_streaming.py @@ -0,0 +1,245 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import json +import tomllib +import logging + +from fastapi import HTTPException, WebSocket +from vertexai.preview import generative_models +from vertexai.generative_models import GenerativeModel +from google import genai +from google.genai import types +from utils.baseball import BaseballGameHelper, format_lineup, format_current_state +from models.baseball import ( + GetSuggestionsResponse, + GetSuggestionsRequest +) +from models.scence import ( + NPCSceneConversationRequest, + NPCSceneConversationResponse +) +from routers.scene import chat +from utils.sceneManager import SceneManager +from utils.promptManager import PromptManager +from utils.conversationManager import ConversationManager + +TOML_PATH = "config.toml" if os.environ["CONFIG_TOML_PATH"] == "" else os.environ["CONFIG_TOML_PATH"] +with open(TOML_PATH, "rb") as f: + config = tomllib.load(f) + +""" +ChunkParser Class +""" + +class ChunkParser: + """ + ChunkParser parses chunks of text and extracts specific parts. + The class is expecting three parts: tactics, outcomes and recommendations + """ + def __init__(self): + """ + Initialize the ChunkParser. + """ + self.parts = { + "tactics": { + "completed": False, + "sent": False, + "text": "" # Initialize with an empty string + }, + "outcomes": { + "completed": False, + "sent": False, + "text": "" # Initialize with an empty string + }, + "recommendations": { + "completed": False, + "sent": False, + "text": "" # Initialize with an empty string + } + } + self._current_part = "" + self._full_text = "" + self._delimiters = [ + "", "", + "", "", + "", "" #consider changing this to outcomes + ] + + def parse_chunk(self, chunk_text: str) -> None: + """ + Parse a chunk of text and update the internal state. + Args: + chunk_text (str): The chunk of text to be parsed. + """ + self._full_text += chunk_text + self._process_text() #added processing + + def get_parts(self) -> dict: + """ + Get the extracted parts. + Returns: + dict: A dictionary containing the extracted parts. + """ + return self.parts + + def _process_text(self): + """ + Process the full text and extract parts based on delimiters + """ + for delimiter in self._delimiters: + if delimiter in self._full_text: + if delimiter == "": + start = self._full_text.find("") + len("") + end = self._full_text.find("") + if end != -1: #make sure the end tag exists + self.parts["tactics"]["text"] = self._full_text[start:end].strip().rstrip("```").lstrip("```json") + self.parts["tactics"]["completed"] = True + elif delimiter == "": + pass #covered in the tactics start tag. + elif delimiter == "": + start = self._full_text.find("") + len("") + end = self._full_text.find("") + if end != -1: + self.parts["recommendations"]["text"] = self._full_text[start:end].strip().rstrip("```").lstrip("```json") + self.parts["recommendations"]["completed"] = True + elif delimiter == "": + pass #covered in the recommendations start tag. + elif delimiter == "": + start = self._full_text.find("") + len("") + end = self._full_text.find("") + if end != -1: + self.parts["outcomes"]["text"] = self._full_text[start:end].strip().rstrip("```").lstrip("```json") #change to outcomes + self.parts["outcomes"]["completed"] = True + elif delimiter == "": + pass #covered in the suggestions start tag. + + def reset(self): + """ + Reset the ChunkParser to its initial state. + """ + self.parts = { + "tactics": { + "completed": False, + "text": "", + "sent": False + }, + "outcomes": { + "completed": False, + "text": "", + "sent": False + }, + "recommendations": { + "completed": False, + "text": "", + "sent": False + } + } + self._current_part = "" + self._full_text = "" + +""" +Helper functions +""" + +async def chat_streaming(req:NPCSceneConversationRequest, + websocket: WebSocket, + model:str = "gemini-2.0-flash-001", + temperature:float = 1, + top_p:float = 0.95, + max_output_tokens:int = 8192, + func:any=None) -> None: + """Generates NPC responses + + Args: + req: Player's input query. + + Returns: + NPC's response to the player's inpput. + """ + if req.game_id != config["game"]["game_id"]: + raise ValueError("Invalid game id.") + + scene = None + if req.scene_id: + logging.info({"message":f"* get_scene: {req.scene_id} | {req.game_id}"}) + scene = SceneManager(config=config).get_scence( + game_id=req.game_id, + scene_id=req.scene_id + ) + + if scene is None: + raise HTTPException(status_code=400, + detail=f"Scene not found:{req.scene_id}") + + client = genai.Client( + vertexai=True, + project=config["gcp"]["google-project-id"], + location=config["gcp"]["google-default-region"], + ) + + prompt_template = PromptManager( + config=config + ).construct_prompt( + prompt_id="STREAMING_GET_SUGGESTIONS", + scene_id=req.scene_id,#"TACTICS_SELECTION" + ).prompt_template + system_prompt = types.Part.from_text(text=prompt_template) + player_input = types.Part.from_text(text=req.input) + print(f"""========== system_prompt ========= +{prompt_template} + """) + print(f"""========== player_input ========= +{req.input} + """) + logging.info(f"""========== system_prompt ========= +{prompt_template} + """) + logging.info(f"""========== player_input ========= +{req.input} + """) + model = "gemini-2.0-flash-001" + contents = [ + types.Content( + role="user", + parts=[ + player_input + ] + ), + ] + generate_content_config = types.GenerateContentConfig( + temperature = temperature, + top_p = top_p, + max_output_tokens = max_output_tokens, + response_modalities = ["TEXT"], + system_instruction=[system_prompt], + ) + parser = ChunkParser() + print("Sending player input...") + for chunk in client.models.generate_content_stream( + model = model, + contents = contents, + config = generate_content_config, + ): + parser.parse_chunk(chunk.text) + json_parts = parser.get_parts() + for json_part_key in json_parts.keys(): + if (json_parts[json_part_key]["completed"] and + not json_parts[json_part_key]["sent"]): + await func( + text=json_parts[json_part_key]["text"], + ws=websocket, + req=req) + json_parts[json_part_key]["sent"] = True diff --git a/examples/smart-npc/src/utils/cacheWrapper.py b/examples/smart-npc/src/utils/cacheWrapper.py new file mode 100644 index 0000000..9075793 --- /dev/null +++ b/examples/smart-npc/src/utils/cacheWrapper.py @@ -0,0 +1,184 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# https://github.com/GoogleCloudPlatform/python-docs-samples/blob/main/memorystore/redis/main.py +# https://redis.io/learn/develop/python/fastapi +"""Cache Wrapper + +Wrapper of cache mechanism. +This implementation uses in-memory cache. + +Attributes: + router (object): FastAPI router object for cache wrapper + +TODO: + Migrate to Redis cache +""" + +import os +import redis + +from pickle import loads, dumps +from typing import Union + +class RedisCacheWrapper: + """ + Wraps the cache mechanism. + + Note: + This implementation uses in-memory cache. + """ + def __init__(self, config, prefix): + """ + Initialize the CacheWrapper object + """ + self.__key_prefix = prefix + self.__config = config + redis_host = self.__config["gcp"]["cache-server-host"] + redis_port = int(self.__config["gcp"]["cache-server-port"]) + self.__client = redis.StrictRedis(host=redis_host, port=redis_port) + + def __key(self, key:str) -> str: + return f"{self.__key_prefix}/{key}" + + def get(self, key:str) -> any: + """Get cached item by key. + + Args: + key: Key of the cached item. + + Returns: + Cached item. + """ + if self.__client.get(dumps(self.__key(key))) is not None: + return loads(self.__client.get(dumps(self.__key(key)))) + else: + return None + + def keys(self) -> list[str]: + """Get keys. + + Returns: + Cached keys. + """ + results = [] + for key in self.__client.keys(): + results.append( + f"""{loads(key)}:{loads(self.__client.get(loads(key))) if self.__client.get(loads(key)) is not None else ""}""" + ) + return results + # return [f"{loads(key)}:{loads(self.__client.get(loads(key)))}" for key in self.__client.keys()] + # return [loads(key) for key in self.__client.keys() if loads(key).startswith(self.__key_prefix + "_")] + + def set(self, key:str, value:any) -> None: + """Add item to the cache. + + Args: + key: Key of the cached item. + value: Item to be cached + """ + self.__client.set(name=dumps(self.__key(key)), value=dumps(value)) + + def delete(self, key:str) -> None: + """Delete an item from the cache. + + Args: + key: Key of the cached item. + """ + self.set(key, None) + self.__client.delete(dumps(self.__key(key))) + self.__client.delete(dumps(key)) + + +class CacheWrapper: + """ + Wraps the cache mechanism. + + Note: + This implementation uses in-memory cache. + """ + def __key(self, key:str) -> str: + return f"{self.__key_prefix}/{key}" + + def __init__(self, config, prefix): + """ + Initialize the CacheWrapper object + """ + self.__key_prefix = prefix + self.__cache = {} + + def get(self, key:str) -> any: + """Get cached item by key. + + Args: + key: Key of the cached item. + + Returns: + Cached item. + """ + print(f"* getting key={key}") + print(f"self.__key(key)={self.__key(key)}") + + if self.__key(key) in self.__cache.keys(): + return self.__cache[self.__key(key)] + else: + print(f"* no {key}:{self.__key(key)}") + return None + + def keys(self) -> any: + """Get keys. + + Returns: + Cached keys. + """ + results = [] + for key in self.__cache.keys(): + results.append(f"[{key}]:{self.__cache[key]}") + # return self.__cache.keys() + return results + + def set(self, key:str, value:any) -> None: + """Add item to the cache. + + Args: + key: Key of the cached item. + value: Item to be cached + """ + self.__cache[self.__key(key)] = value + + def delete(self, key:str) -> None: + """Delete an item from the cache. + + Args: + key: Key of the cached item. + """ + self.__cache.pop(self.__key(key), None) + +in_memory_cache = {} + +class CacheFactory: + def __init__(self, config): + self.__config = config + + def get_cache(self, key_prefix:str) -> Union[CacheWrapper, RedisCacheWrapper]: + if self.__config["gcp"]["use-cache-server"] == "True": + return RedisCacheWrapper(self.__config, key_prefix) + else: + global in_memory_cache + if key_prefix in in_memory_cache.keys(): + return in_memory_cache[key_prefix] + else: + cache = CacheWrapper(self.__config, key_prefix) + in_memory_cache[key_prefix] = cache + return cache diff --git a/examples/smart-npc/src/utils/const.py b/examples/smart-npc/src/utils/const.py new file mode 100644 index 0000000..927efe9 --- /dev/null +++ b/examples/smart-npc/src/utils/const.py @@ -0,0 +1,15 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +USE_QUICK_START = True # Use Google for Games quickstart dispatcher diff --git a/examples/smart-npc/src/utils/conversationManager.py b/examples/smart-npc/src/utils/conversationManager.py new file mode 100644 index 0000000..98131e5 --- /dev/null +++ b/examples/smart-npc/src/utils/conversationManager.py @@ -0,0 +1,191 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import logging + +from utils.const import USE_QUICK_START +from models.scence import ( + NPCSceneConversationRequest, + NPCSceneConversationResponse, + Scene +) +from models.prompt import PromptRetrievalResponse +from utils.cacheWrapper import CacheFactory +from utils.database import DataConnection +from utils.cacheWrapper import CacheFactory +from vertexai.preview import generative_models +from vertexai.generative_models import GenerativeModel +from utils.quickstartWrapper import quick_start_wrapper + +EMBEDDING_MODEL_NAME = "text-multilingual-embedding-002" +TEXT_GENERATION_MODEL_NAME = "gemini-1.5-flash-001" +GEMINI_GENERATION_MODEL_NAME = "gemini-1.5-pro-001" +FLASH_MODEL_NAME = "gemini-1.5-flash-002" +PRO_MODEL_NAME= "gemini-1.5-pro-002" + +""" +Conversation Management Help class +""" +class ConversationManager(): + """ + Helper class for prompt management. + """ + def __init__(self, config:dict): + """ + Initialize the prompt manager helper class. + + Args: + config (dict): config.toml dict. + """ + self._cache = CacheFactory(config=config).get_cache("conversations") + self._conversation_history = CacheFactory(config=config).get_cache("conversation_history") + self._config = config + self._connection = DataConnection(config=config) + self._llm_model_name = FLASH_MODEL_NAME + + def __format_conversation_history_cache_key( + self, + game_id:str, + scene_id:str, + session_id:str, + player_id:str) -> str: + """ + Format conversation cache key + Args: + game_id (str): game id. + scene_id (str): scene id. + session_id (str): session id. + player_id (str): player id. + Returns: + Formatted cache key. + """ + return f"{game_id}/{scene_id}/{session_id}" + + def __format_conv_example_cache_key(self, + game_id:str, + scene_id:str="default", + example_id:str="default") -> str: + """ + Format conversation cache key + Args: + game_id (str): game id. + scene_id (str): scene id. + example_id (str): example id. + Returns: + Formatted cache key. + """ + return f"{game_id}/{scene_id}/{example_id}" + + def get_conv_example( + self, + example_id:str="default", + scene_id:str="default" + ) -> str: + """ + Get conversation example. + + Args: + example_id (str): example id, defaults to "default" + scene_id (str): scene_id, defaults to "default" + + Returns: + Conversation Example. + """ + game_id = self._config["game"]["game_id"] + key = self.__format_conv_example_cache_key( + game_id=game_id, + scene_id=scene_id, + example_id=example_id) + example = self._cache.get(key=key) + + if example is None or example == "": + sql = self._config["sql"]["QUERY_CONV_EXAMPLE"] + results = self._connection.execute( + sql = sql, + sql_params={ + "example_id":example_id, + "scene_id":scene_id, + "game_id":game_id + } + ) + for result in results: + example = result[3] + self._cache.set(key, example) + + return example + + def chat( + self, + conversation:NPCSceneConversationRequest, + prompt:PromptRetrievalResponse + ): + """Generate Multi-turn response + + Args: + conversation (NPCSceneConversationRequest): Player's chat request. + prompt (PromptRetrievalResponse): Prompt to generate NPC responses. +. + Returns: + NPC's response to the player's query and conversation history + """ + generation_config = { + "max_output_tokens": 8192, + "temperature": 1, + "top_p": 0.95, + } + + safety_settings = { + generative_models.HarmCategory.HARM_CATEGORY_HATE_SPEECH: generative_models.HarmBlockThreshold.BLOCK_NONE, # pylint: disable=line-too-long + generative_models.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: generative_models.HarmBlockThreshold.BLOCK_NONE, # pylint: disable=line-too-long + generative_models.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: generative_models.HarmBlockThreshold.BLOCK_NONE, # pylint: disable=line-too-long + generative_models.HarmCategory.HARM_CATEGORY_HARASSMENT: generative_models.HarmBlockThreshold.BLOCK_NONE, # pylint: disable=line-too-long + } + + key = self.__format_conversation_history_cache_key( + game_id=conversation.game_id, + scene_id=conversation.scene_id, + session_id=conversation.session_id, + player_id=conversation.player_id + ) + history = self._conversation_history.get(key=key) + + if not USE_QUICK_START: + model = GenerativeModel( + self._llm_model_name, + system_instruction=[prompt.prompt_template] + ) + chat = model.start_chat(history=history) + else: + model = quick_start_wrapper( + model_name=self._llm_model_name, + system_instruction=prompt.prompt_template, + ) + logging.info(f"* conversationManager:chat|history : {type(history)} | {history}") + + chat = model.start_chat(history=history) + + result = chat.send_message( + [conversation.input], + generation_config=generation_config, + safety_settings=safety_settings + ) + logging.info(f"* conversationManager:chat|result : {result}") + if result is not None: + self._conversation_history.set( + key, chat.history + ) + return result, chat.history + else: + return None, chat.history diff --git a/examples/smart-npc/src/utils/database.py b/examples/smart-npc/src/utils/database.py new file mode 100644 index 0000000..e064be6 --- /dev/null +++ b/examples/smart-npc/src/utils/database.py @@ -0,0 +1,98 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Database connection Wrapper + +Wrapper of database operations. +""" + +import sqlalchemy + +from google.cloud import secretmanager +from google.cloud.sql.connector import Connector + +class DataConnection: + """ + Wraps the database connection. + """ + def __get_db_password(self) -> str: + """Get database user's password from secret manager. + + Returns: + Password. + """ + if self.database_password is not None: + return self.database_password + + if self.config["gcp"]["google-project-id"] == "": + raise ValueError("google-project-id not set in config-secrets.toml") + + client = secretmanager.SecretManagerServiceClient() + + request = secretmanager.AccessSecretVersionRequest( + name=f"projects/{self.config['gcp']['google-project-id']}/secrets/{self.config['gcp']['database_password_key']}/versions/latest", # pylint: disable=line-too-long, inconsistent-quotes + ) + response = client.access_secret_version(request) + + payload = response.payload.data.decode("UTF-8") + self.database_password = payload + return self.database_password + + def __init__(self, config): + """Initialize the DataConnection object + + Args: + config (dict): Database configuration object. + """ + self.config = config + self.connector = Connector() + self.database_password = None + self.__pool = None + + def __getconn(self): + """Opens the connetion to the database. + + Returns: + Connection object. + """ + conn = self.connector.connect( + self.config["gcp"]["postgres_instance_connection_name"], + "pg8000", + user=self.config["gcp"]["database_user_name"], + password=self.__get_db_password(), + db="postgres", + ) + + return conn + + def execute(self, sql:str, sql_params:dict) -> any: + """Execute SQL query and returns the results. + + Args: + sql (str): SQL query. + sql_params (dict): SQL parameters. + + Returns: + Query result. + """ + if self.__pool is None: + self.__pool = sqlalchemy.create_engine( + "postgresql+pg8000://", + creator=self.__getconn, + ) + + with self.__pool.connect() as db_conn: + rows = db_conn.execute(sqlalchemy.text(sql), sql_params) + db_conn.commit() + return rows diff --git a/examples/smart-npc/src/utils/llm.py b/examples/smart-npc/src/utils/llm.py new file mode 100644 index 0000000..5155e8b --- /dev/null +++ b/examples/smart-npc/src/utils/llm.py @@ -0,0 +1,120 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""LLM operation help class + +Wrapper of LLM operations. +""" + +import os +import json +import logging + +from utils.const import USE_QUICK_START +from vertexai.language_models import TextEmbeddingInput, TextEmbeddingModel +from vertexai.preview import generative_models +from vertexai.generative_models import GenerativeModel +from utils.quickstartWrapper import quick_start_wrapper + +EMBEDDING_MODEL_NAME = "text-multilingual-embedding-002" +TEXT_GENERATION_MODEL_NAME = "gemini-1.5-flash-001" +GEMINI_GENERATION_MODEL_NAME = "gemini-1.5-pro-001" +FLASH_MODEL_NAME = "gemini-1.5-flash-002" +PRO_MODEL_NAME= "gemini-1.5-pro-002" + +logger = logging.getLogger("smart-npc") +logger.setLevel(logging.DEBUG) + +def text_embedding( + task_type: str, + text: str, + title: str = "", + model_name: str = EMBEDDING_MODEL_NAME + ) -> list: + """Generate text embedding with a Large Language Model. + + Args: + task_type (str): Task type, + Please see: + https://cloud.google.com/vertex-ai/generative-ai/docs/embeddings/task-types#supported_task_types + + text (str): input text + title (str): Optional title of the input text + model_name (str): Defaults to text-multilingual-embedding-002 + + Returns: + Embeddings + """ + model = TextEmbeddingModel.from_pretrained(model_name) + if task_type == "" or task_type is None: + logger.info("[Info]NO Emgedding Task Type") + embeddings = model.get_embeddings([text]) + else: + text_embedding_input = TextEmbeddingInput( + task_type=task_type, title=title, text=text) + embeddings = model.get_embeddings([text_embedding_input]) + return embeddings[0].values + +def ask_llm(prompt:str, + model_name = TEXT_GENERATION_MODEL_NAME, + generation_configuration=None, + safety_configuration=None) -> str: + """Invoke Language Model. + + Args: + model_name (str): Model name, defaults to "gemini-1.5-flash-001" + generation_configuration (dict): Generation config + safety_configuration (dict): Safty config + + Returns: + Large Language Model prediction results. + """ + if generation_configuration is None: + generation_configuration = { + "max_output_tokens": 8192, + "temperature": 1, + "top_p": 0.95, + } + if safety_configuration is None: + safety_configuration = { + generative_models.HarmCategory.HARM_CATEGORY_HATE_SPEECH: generative_models.HarmBlockThreshold.BLOCK_NONE, # pylint: disable=line-too-long + generative_models.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: generative_models.HarmBlockThreshold.BLOCK_NONE, # pylint: disable=line-too-long + generative_models.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: generative_models.HarmBlockThreshold.BLOCK_NONE, # pylint: disable=line-too-long + generative_models.HarmCategory.HARM_CATEGORY_HARASSMENT: generative_models.HarmBlockThreshold.BLOCK_NONE, # pylint: disable=line-too-long + } + + if not USE_QUICK_START: + model = GenerativeModel( + FLASH_MODEL_NAME, + system_instruction=[""] + ) + else: + model = quick_start_wrapper( + model_name=FLASH_MODEL_NAME, + system_instruction="", + ) + + responses = model.generate_content( + [prompt], + generation_config=generation_configuration, + safety_settings=safety_configuration, + stream=True, + ) + result_text = "" + for response in responses: + result_text += response.text + if "```" in result_text: + result_text = result_text.replace("```json", "").replace("```html", "") + result_text = result_text[0:result_text.index("```") - 1] + return result_text diff --git a/examples/smart-npc/src/utils/llmValidator.py b/examples/smart-npc/src/utils/llmValidator.py new file mode 100644 index 0000000..af5f77a --- /dev/null +++ b/examples/smart-npc/src/utils/llmValidator.py @@ -0,0 +1,78 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +from models.prompt import PromptRetrievalResponse +from utils.promptManager import PromptManager +from utils.llm import ask_llm + +""" +LLM prediction validator +""" +class LLMValidator(): + """ + Helper class to validate LLM predictions. + """ + def __init__(self, config:dict): + """ + Initialize the prompt manager helper class. + + Args: + config (dict): config.toml dict. + """ + self.prompt_manager = PromptManager(config=config) + + def validate(self, + scene_id:str, + player_input:str, + npc_response:str, + npcs:str, + conversation_history:list) -> str: + + """ + Validate LLM prediction. + Args: + scene_id (str): scene id. + player_input (str): player input. + npc_response (str): npc response. + npcs (str): npcs. + conversation_history (list): conversation history. + Returns: + Validated LLM prediction + """ + history = "" + if conversation_history is not None and conversation_history != []: + for turn in conversation_history: + history += f"{turn}" + os.linesep + + if history == "": + history = "N/A" + try: + prompt = self.prompt_manager.construct_prompt( + prompt_id="NPC_CONVERSATION_REVIEW", + scene_id=scene_id + ) + prompt.prompt_template = prompt.prompt_template.format( + NON_PLAYER_CHARACTERS=npcs, + PLAYER_INPUT=player_input, + NPC_RESPONSE=npc_response, + CONVERSATION_HISTORY=conversation_history + ) + + answer = ask_llm(prompt=prompt.prompt_template) + return answer + except Exception: + return npc_response + diff --git a/examples/smart-npc/src/utils/npcManager.py b/examples/smart-npc/src/utils/npcManager.py new file mode 100644 index 0000000..4abb0b9 --- /dev/null +++ b/examples/smart-npc/src/utils/npcManager.py @@ -0,0 +1,85 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re + +from models.npc import NPCInfoRequest, NPCInfoResponse +from models.prompt import PromptRetrievalResponse +from utils.cacheWrapper import CacheFactory +from utils.database import DataConnection +from utils.sceneManager import SceneManager + +""" +NPC Management Help class +""" +class NPCManager(): + """ + Helper class for npc management. + """ + def __init__(self, config:dict): + """ + Initialize the prompt manager helper class. + + Args: + config (dict): config.toml dict. + """ + self._cache = CacheFactory(config=config).get_cache("npcs") + self._config = config + self._connection = DataConnection(config=config) + + def __format_cache_key(self, game_id:str, npc_id:str) -> str: + """ + Format prompt cache key + """ + return f"{game_id}/{npc_id}" + + def get_npc(self, npc:NPCInfoRequest) -> NPCInfoResponse: + """Get NPC configuration by id + + Args: + npc_id: unique id of the NPC. + + Returns: + NPC information object. + """ + key = self.__format_cache_key( + game_id=npc.game_id, + npc_id=npc.npc_id + ) + resp = self._cache.get(key) + if resp is not None: + return resp + try: + sql = self._config["sql"]["QUERY_NPC_BY_ID"] + npc = self._connection.execute(sql, + { + "npc_id": npc.npc_id, + "game_id": npc.game_id + }) + for row in npc: + resp = NPCInfoResponse( + game_id = row[1], + background = row[2], + name = row[3], + npc_class = row[4], + class_level = row[5], + npc_id = row[0], + status = row[6], + lore_level = int(row[7]) + ) + self._cache.set(key, resp) + return resp + return None + except Exception as e: + raise e diff --git a/examples/smart-npc/src/utils/promptManager.py b/examples/smart-npc/src/utils/promptManager.py new file mode 100644 index 0000000..531ca80 --- /dev/null +++ b/examples/smart-npc/src/utils/promptManager.py @@ -0,0 +1,258 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re +import os +import logging + +from models.prompt import PromptRetrievalResponse +from models.npc import ( + NPCInfoResponse, + NPCInfoRequest +) +from utils.cacheWrapper import CacheFactory +from utils.database import DataConnection +from utils.sceneManager import SceneManager +from utils.npcManager import NPCManager +from utils.conversationManager import ConversationManager + +""" +Prompt Management Help class +""" +class PromptManager(): + """ + Helper class for prompt management. + """ + def __init__(self, config:dict): + """ + Initialize the prompt manager helper class. + + Args: + config (dict): config.toml dict. + """ + self._cache = CacheFactory(config=config).get_cache("prompts") + self._config = config + self._connection = DataConnection(config=config) + + def __format_cache_key(self, game_id:str, scene_id:str, prompt_id:str) -> str: + """ + Format prompt cache key + """ + return f"{game_id}/{scene_id}/{prompt_id}" + + def __extract_placeholders(self, prompt:str): + """ + Extracts placeholder names from a prompt string. + + Args: + prompt (str): The prompt string containing placeholders enclosed in curly braces. + + Returns: + A list of placeholder names found in the prompt. + """ + pattern = r"{([^}]+)}" + matches = re.findall(pattern, prompt) + + # some prompts may use {}, disregard those. + return [m for m in matches if not any(c in m for c in (" ", "\"", "\n"))] + + def get_prompt_template( + self, + prompt_id:str, + scene_id:str="default" + ) -> str: + """ + Get Prompt template. + + Args: + prompt_id (str): prompt id + scene_id (str): scene_id, defaults to "default" + + Returns: + Prompt template. + """ + game_id = self._config["game"]["game_id"] + key = self.__format_cache_key(game_id=game_id, + scene_id=scene_id, + prompt_id=prompt_id) + prompt_template = self._cache.get(key=key) + + if not prompt_template: + # If prompt template is not cached. + sql = self._config["sql"]["QUERY_PROMPT_TEMPLATE"] + results = self._connection.execute( + sql = sql, + sql_params={ + "prompt_id":prompt_id, + "scene_id":scene_id, + "game_id":game_id + } + ) + for prompt in results: + prompt_template = prompt[3] + if prompt[0] == scene_id: + self._cache.set(key, prompt_template) + break + else: + prompt_id = prompt[0] + logging.info("* setting cache; %s | %s | %s", + game_id, + scene_id, + prompt_id + ) + self._cache.set( + self.__format_cache_key( + game_id, + scene_id, + prompt_id + ), + prompt_template + ) + return prompt_template + + def __construct_prompt(self, + prompt_template:str, + scene_id:str="default") -> str: + """ + Replace prompe placeholders with actual prompts + + Args: + prompt_id (str): prompt id. + scene_id (str): scene id, defaults to "default" + Returns: + Prompt template. + """ + if prompt_template: + place_holders = self.__extract_placeholders(prompt_template) + for place_holder in place_holders: + place_holder_prompt_template = self.get_prompt_template( + prompt_id=place_holder, + scene_id=scene_id + ) + if place_holder_prompt_template: + place_holder_prompt_template = self.__construct_prompt( + prompt_template=place_holder_prompt_template, + scene_id=scene_id) + prompt_template = prompt_template.replace( + "{" + place_holder + "}", + place_holder_prompt_template + ) + logging.info("Final prompt:\n%s", prompt_template) + return prompt_template + + def construct_prompt(self, prompt_id:str, + scene_id:str="default" + ) -> PromptRetrievalResponse: + """ + Get prompt template from the database, parse the template. + Fill in placeholders with corresponding prompts in the database. + Returns the conducted prompt. + + Args: + prompt_id (str): prompt id. + scene_id (str): scene id, defaults to "default" + Returns: + Prompt template. + """ + logging.info("construct_prompt: %s | %s" ,scene_id, prompt_id) + prompt_template = self.get_prompt_template( + prompt_id=prompt_id, + scene_id=scene_id + ) + + if prompt_template: + new_prompt_template = self.__construct_prompt( + prompt_template=prompt_template, + scene_id=scene_id + ) + place_holders = self.__extract_placeholders( + prompt=new_prompt_template + ) + return self.__update_place_holders( + prompt_template=PromptRetrievalResponse( + game_id=self._config["game"]["game_id"], + scene_id=scene_id, + prompt_id=prompt_id, + prompt_template=new_prompt_template, + place_holders=place_holders + ) + ) + else: + logging.error(f"construct_prompt():No Prompte Template found:{scene_id} | {prompt_id}") + raise ValueError("Prompt template not found.") + + def __update_place_holders(self, + prompt_template:PromptRetrievalResponse + ) -> PromptRetrievalResponse: + scene_manager = SceneManager(config=self._config) + scene = scene_manager.get_scence( + game_id=prompt_template.game_id, + scene_id=prompt_template.scene_id + ) + npc_in_the_scene = ",".join([id for id in scene.npc_ids]) if scene.npc_ids is not None else "" + npc_manager = NPCManager(config=self._config) + for place_holder in prompt_template.place_holders: + match place_holder: + case "NON_PLAYER_CHARACTERS": + prompt_template.prompt_template = prompt_template.prompt_template.replace( + "{NON_PLAYER_CHARACTERS}", + npc_in_the_scene + ) + case "SCENE_GOAL": + prompt_template.prompt_template = prompt_template.prompt_template.replace( + "{SCENE_GOAL}", + scene.goal + ) + case "CHARACTER_BACKGROUND": + npcs = [] + for npcid in scene.npc_ids if scene.npc_ids is not None else []: + npc = npc_manager.get_npc( + NPCInfoRequest( + npc_id=npcid, + game_id=prompt_template.game_id + )) + if npc is not None: + npcs.append(npc) + prompt_template.prompt_template = prompt_template.prompt_template.replace( + "{CHARACTER_BACKGROUND}", + f"{os.linesep}".join([npc.background for npc in npcs]), + ) + case "CONVERSATION_EXAMPLE": + conv_manager = ConversationManager(config=self._config) + example = conv_manager.get_conv_example( + example_id="default" + ) + if example: + prompt_template.prompt_template = prompt_template.prompt_template.replace( + "{CONVERSATION_EXAMPLE}", + example + ) + case "CURRENT_SCENE": + prompt_template.prompt_template = prompt_template.prompt_template.replace( + "{CURRENT_SCENE}", + scene.scene + ) + case _: + if "NPC:" in place_holder: + npcid = place_holder.split(":")[-1] + npc = npc_manager.get_npc(npc_id=npcid) + prompt_template.prompt_template = prompt_template.prompt_template.replace( + place_holder, + npc.background + ) + else: + logging.warning({ + "message": f"Placeholder: {place_holder} not implemented." + }) + return prompt_template \ No newline at end of file diff --git a/examples/smart-npc/src/utils/quickstartWrapper.py b/examples/smart-npc/src/utils/quickstartWrapper.py new file mode 100644 index 0000000..4b69630 --- /dev/null +++ b/examples/smart-npc/src/utils/quickstartWrapper.py @@ -0,0 +1,138 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +GenAI-Quickstart Chat API Wrapper. +""" + +import os +import json +import requests +import logging +from vertexai.generative_models import GenerationResponse, Content + +class quick_start_wrapper(): + """ + Google for Games GenAI-Quickstart Chat API Wrapper. + The class aims to provide Google Unified interface to the GenAI-Quickstart client. + """ + def __init__(self, model_name:str, + system_instruction:str): + """ + Initialize the wrapper + Args: + model_name(str): Name of the model, should be "Gemini" + system_instruction: System prompt. + host(str): GenAI-Quickstart chat api host url. ex, http://api.genai.svc + """ + self.system_instruction = system_instruction + self.model_name = model_name + self.host = "http://genai-api.genai.svc/genai/chat/" + self.history = [] + self.logger = logging.getLogger("smart-npc") + self.logger.setLevel(logging.DEBUG) + + def start_chat(self, history:list[dict]) -> any: + """ + Start a chat. + Args: + history(list[dict]): conversation history + Retuns: + Response from the LLM + """ + self.history = history if history is not None else [] + return self + + def send_message(self, + query:list[str], + generation_config:dict=None, + safety_settings:dict=None) -> GenerationResponse: + """ + Send message to the chat model. + Args: + query(str): User's input + generation_config(dict): generation config. + safety_settings(dict): safety settings. + Retuns: + LLM response. + """ + self.logger.info( +f""" +===================== +* System Instruction: +{self.system_instruction} +* Final Player Input: +{query[0]} +===================== +""" + ) + request = { + "prompt": query[0], + "max_output_tokens": 8192, + "temperature": 1, + "top_p": 0.95, + "top_k": 40, + "message_history": None, # self.history if self.history is not None and self.history != [] else None, + "context": self.system_instruction + } + try: + response = requests.post(self.host, json = request, timeout=30) + self.logger.info({"message": f"quickStart API elapsed: {response.elapsed.total_seconds()} seconds."}) + except Exception as e: + self.logger.error(f"* Request:{request}") + self.logger.error(f"* Exception:{e}") + raise e + + self.logger.info(f"""****** +response: +{response} + +Response.Text: +{response.text} +******") +""") + if self.history is None: + self.history = [] + if response.status_code == 200: # response.text != "{}": + answer = { + "candidates": [ + { + "content":{ + "parts":[ + {"text":json.loads(response.text.lstrip("```json").rstrip("```"))} + ] + } + } + ] + } + self.logger.info(f"---> ANSWER <---\n{answer}") + resp = GenerationResponse.from_dict(answer) + # resp.candidates[0].content.parts[0].text = json.loads(resp.candidates[0].content.parts[0].text) + self.history.append( + { + "role":"user", + "parts":[{"text": q} for q in query] + } + ) + self.history.append( + { + "role":"model", + "parts":[ + {"text":response.text} + ] + } + ) + return resp + else: + self.logger.error(f"error send_message:response.status_code={response.status_code}") + return None diff --git a/examples/smart-npc/src/utils/rag.py b/examples/smart-npc/src/utils/rag.py new file mode 100644 index 0000000..6dcb42f --- /dev/null +++ b/examples/smart-npc/src/utils/rag.py @@ -0,0 +1,149 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""RAG helper module +""" +from utils.cacheWrapper import CacheFactory +from utils.llm import text_embedding +from utils.database import DataConnection +from utils.cacheWrapper import CacheWrapper + + +class RAG: + """ + Wrapper class for querying Vector Store + """ + + def __init__(self, config:dict): + """Initialize the RAG object + + Args: + config (dict): Configuration object. + """ + # self.__knowledge_cache = CacheFactory(config).get_cache("knowledge") + self.__quests_cache = CacheFactory(config).get_cache("quests") + self.config = config + + def __get_query_embedding(self, query:str) -> list[float]: + """Get Text embeddings of the input query + Args: + query (str): input query + + Returns: + Embeddings + """ + embeddings = text_embedding( + task_type="RETRIEVAL_QUERY", + text=query + ) + return embeddings + + def __search_relevant_knowledge( + self, + sql:str, + lore_level:int, + query_embeddings:list[float]) -> list[dict]: + """Search knowledge relevant to user's query + Args: + sql (str): SQL query + lore_level (int): Min. lore level required + query_embeddings (list[float]): query's embeddings + + Returns: + Embeddings + """ + db = DataConnection(config=self.config) + rows = db.execute(sql=sql, + sql_params={"query_embeddings": f"{query_embeddings}", + "lore_level":lore_level}) + resutls = [] + print(f"* __search_relevant_knowledge={rows}") + for row in rows: + resutls.append({ + "background_name": row[0], + "content": row[1], + "lore_level": row[3], + "background": row[4], + "score": row[5] + }) + + return resutls + + def search_knowledge(self, query:str, lore_level:int) -> list[dict]: + """Search knowledge relevant to user's query + Args: + sql (str): SQL query + lore_level (int): Min. lore level required + query_embeddings (list[float]): query's embeddings + + Returns: + Relevant knowledge + """ + query_embeddings = self.__get_query_embedding(query=query) + print(self.config["sql"]["QUERY_NPC_KNOWLEDGE"]) + knowledge = self.__search_relevant_knowledge( + sql=self.config["sql"]["QUERY_NPC_KNOWLEDGE"], + lore_level=lore_level, + query_embeddings=query_embeddings + ) + + return knowledge + + def __search_quests(self, sql:str, provider_id:str) -> list[dict]: + """Search quests provided by the NPC + Args: + sql (str): SQL query + provider_id (str): NPC's unique id + + Returns: + Quests can be provided by this NPC + """ + resp = self.__quests_cache.get(provider_id) + if resp is not None: + return resp + else: + db = DataConnection(config=self.config) + rows = db.execute(sql=sql, sql_params={"provider_id": provider_id}) + + resutls = [] + for row in rows: + print(f"** row={row}") + resutls.append({ + "quest_id": row[0], + "quest_story": row[1], + "min_level": int(row[2]), + "metadata": row[3], + "quest_name": row[4], + "provider_id": row[5] + }) + + self.__quests_cache.set(provider_id, resutls) + return resutls + + def search_quests(self, provider_id:str) -> list[dict]: + """Search quests provided by the NPC + Args: + provider_id (str): NPC's unique id + + Returns: + Quests can be provided by this NPC + """ + # Instead of query relevant quests, + # fetch all quests that can be provided by this NPC + quests = self.__search_quests( + sql=self.config["sql"]["QUERY_SEARCH_QUESTS_ALL"], + provider_id=provider_id + ) + + return quests diff --git a/examples/smart-npc/src/utils/sceneManager.py b/examples/smart-npc/src/utils/sceneManager.py new file mode 100644 index 0000000..1d5d895 --- /dev/null +++ b/examples/smart-npc/src/utils/sceneManager.py @@ -0,0 +1,88 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re + +from models.scence import Scene +from utils.cacheWrapper import CacheFactory +from utils.database import DataConnection + +""" +Scene Management Help class +""" +class SceneManager(): + """ + Helper class for scene management. + """ + def __init__(self, config:dict): + """ + Initialize the scene manager helper class. + + Args: + config (dict): config.toml dict. + """ + self._cache = CacheFactory(config=config).get_cache("scenes") + self._config = config + self._connection = DataConnection(config=config) + + def __format_cache_key(self, game_id:str, scene_id:str) -> str: + """ + Format prompt cache key + """ + return f"{game_id}/{scene_id}" + + def get_scence(self, + game_id:str, + scene_id:str) -> Scene: + """ + Get Scene configuration. + Args: + game_id (str): game id. + scene_id (str): scene id. + + Returns: + Scene configuraiton object. + """ + if game_id != self._config["game"]["game_id"]: + raise ValueError("Invalid game id.") + key = self.__format_cache_key( + game_id=game_id, + scene_id=scene_id + ) + scene = self._cache.get(key) + + if scene is not None: + return scene + try: + connection = DataConnection(config=self._config) + + sql = self._config["sql"]["QUERY_SCENE"] + rows = connection.execute(sql, { + "scene_id": scene_id, + "game_id": self._config["game"]["game_id"] + }) + for row in rows: + resp = Scene( + game_id=row[6], + scene_id=row[0], + scene=row[1], + goal=row[3], + npc_ids=[n.strip() for n in f"{row[4]}".split(",")], + status=row[2], + knowledge=row[5] + ) + self._cache.set(key, resp) + return resp + except Exception as e: + raise e From f8f19297b0a660b25886e459caa2ca829d616ac7 Mon Sep 17 00:00:00 2001 From: Michael Chi Date: Tue, 1 Apr 2025 02:09:16 +0000 Subject: [PATCH 2/3] Add Terraform for setup Baseball Simulation Game --- terraform/iam.tf | 41 ++++++++ terraform/provider.tf | 2 +- terraform/smart-npc.tf | 20 ++++ terraform/smart-npc/backend.tf | 13 +++ terraform/smart-npc/cache.tf | 31 ++++++ terraform/smart-npc/cloud-run.tf | 29 ++++++ terraform/smart-npc/cloud-sql.tf | 75 +++++++++++++++ terraform/smart-npc/config-file.tf | 56 +++++++++++ terraform/smart-npc/gcs.tf | 26 +++++ terraform/smart-npc/main.tf | 19 ++++ terraform/smart-npc/outputs.tf | 67 +++++++++++++ terraform/smart-npc/project.tf | 46 +++++++++ terraform/smart-npc/secret-manager.tf | 37 ++++++++ terraform/smart-npc/service-accounts.tf | 56 +++++++++++ terraform/smart-npc/variables.tf | 52 ++++++++++ terraform/smart-npc/version.tf | 28 ++++++ terraform/smart-npc/vpc.tf | 120 ++++++++++++++++++++++++ 17 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 terraform/smart-npc.tf create mode 100644 terraform/smart-npc/backend.tf create mode 100644 terraform/smart-npc/cache.tf create mode 100644 terraform/smart-npc/cloud-run.tf create mode 100644 terraform/smart-npc/cloud-sql.tf create mode 100644 terraform/smart-npc/config-file.tf create mode 100644 terraform/smart-npc/gcs.tf create mode 100644 terraform/smart-npc/main.tf create mode 100644 terraform/smart-npc/outputs.tf create mode 100644 terraform/smart-npc/project.tf create mode 100644 terraform/smart-npc/secret-manager.tf create mode 100644 terraform/smart-npc/service-accounts.tf create mode 100644 terraform/smart-npc/variables.tf create mode 100644 terraform/smart-npc/version.tf create mode 100644 terraform/smart-npc/vpc.tf diff --git a/terraform/iam.tf b/terraform/iam.tf index b40d49e..05614a4 100644 --- a/terraform/iam.tf +++ b/terraform/iam.tf @@ -29,6 +29,9 @@ resource "google_service_account_iam_binding" "sa_gke_cluster_wi_binding" { members = [ "serviceAccount:${var.project_id}.svc.id.goog[genai/k8s-sa-cluster]", ] + depends_on = [ + google_iam_workload_identity_pool.sa_gke_cluster + ] } module "member_roles_gke_cluster" { @@ -45,6 +48,7 @@ module "member_roles_gke_cluster" { "roles/monitoring.viewer", "roles/stackdriver.resourceMetadata.writer", "roles/cloudtrace.agent", + "roles/secretmanager.secretAccessor" ] } @@ -61,6 +65,9 @@ resource "google_service_account_iam_binding" "sa_gke_aiplatform_wi_binding" { members = [ "serviceAccount:${var.project_id}.svc.id.goog[genai/k8s-sa-aiplatform]", ] + depends_on = [ + google_iam_workload_identity_pool.sa_gke_cluster + ] } module "member_roles_gke_aiplatform" { @@ -72,6 +79,20 @@ module "member_roles_gke_aiplatform" { "roles/aiplatform.user", "roles/storage.objectUser", "roles/spanner.databaseUser", + "roles/secretmanager.secretAccessor", + # Cloud SQL Client + "roles/cloudsql.client", + # Log Writer + "roles/logging.logWriter", + # Able to upload and download conversation logs from GCS + "roles/storage.objectCreator", + "roles/storage.objectViewer", + # Need storage.objects.delete + "roles/storage.objectAdmin", + # For Gen2 cloud functions invoker + "roles/run.invoker", + # For deploying to Cloud Run using this service account + "roles/iam.serviceAccountUser", ] } @@ -88,6 +109,9 @@ resource "google_service_account_iam_binding" "sa_gke_telemetry_wi_binding" { members = [ "serviceAccount:${var.project_id}.svc.id.goog[genai/k8s-sa-telemetry]", ] + depends_on = [ + google_iam_workload_identity_pool.sa_gke_cluster + ] } module "member_roles_gke_telemetry" { @@ -114,3 +138,20 @@ module "member_roles_cloudbuild" { "roles/storage.objectAdmin", ] } + +resource "google_iam_workload_identity_pool" "sa_gke_cluster" { + workload_identity_pool_id = var.project_id +} + +module "member_roles_artifact_registry" { + source = "terraform-google-modules/iam/google//modules/member_iam" + service_account_address = "${data.google_project.project.number}-compute@developer.gserviceaccount.com" + prefix = "serviceAccount" + project_id = var.project_id + project_roles = [ + "roles/storage.admin", + "roles/storage.objectViewer", + "roles/artifactregistry.reader", + "roles/artifactregistry.writer" + ] +} diff --git a/terraform/provider.tf b/terraform/provider.tf index c675517..a7bcbe7 100644 --- a/terraform/provider.tf +++ b/terraform/provider.tf @@ -20,7 +20,7 @@ terraform { } google = { source = "hashicorp/google" - version = "~> 5.6.0" + version = "< 7.0.0" } } diff --git a/terraform/smart-npc.tf b/terraform/smart-npc.tf new file mode 100644 index 0000000..177d281 --- /dev/null +++ b/terraform/smart-npc.tf @@ -0,0 +1,20 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module "smart-npc" { + google_project_id = var.project_id + vpc_id = module.vpc.network_id + vpc_name = module.vpc.network_name + source = "./smart-npc" +} \ No newline at end of file diff --git a/terraform/smart-npc/backend.tf b/terraform/smart-npc/backend.tf new file mode 100644 index 0000000..633bb7f --- /dev/null +++ b/terraform/smart-npc/backend.tf @@ -0,0 +1,13 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/terraform/smart-npc/cache.tf b/terraform/smart-npc/cache.tf new file mode 100644 index 0000000..1ef628d --- /dev/null +++ b/terraform/smart-npc/cache.tf @@ -0,0 +1,31 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +resource "google_redis_instance" "cache" { + name = "npc-cache" + tier = "BASIC" + memory_size_gb = 1 + + location_id = var.google_default_zone + # alternative_location_id = "us-central1-f" + + authorized_network = var.vpc_id + + redis_version = "REDIS_4_0" + display_name = "NPC Cache" + + lifecycle { + prevent_destroy = true + } +} diff --git a/terraform/smart-npc/cloud-run.tf b/terraform/smart-npc/cloud-run.tf new file mode 100644 index 0000000..b46841c --- /dev/null +++ b/terraform/smart-npc/cloud-run.tf @@ -0,0 +1,29 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +resource "google_cloud_run_v2_service" "npc-server" { + name = "npc-server-vpc" + location = var.google_default_region + + template { + containers { + image = "us-docker.pkg.dev/cloudrun/container/hello" + } + vpc_access { + connector = google_vpc_access_connector.connector.id + egress = "PRIVATE_RANGES_ONLY" + } + } +} + diff --git a/terraform/smart-npc/cloud-sql.tf b/terraform/smart-npc/cloud-sql.tf new file mode 100644 index 0000000..4f74f08 --- /dev/null +++ b/terraform/smart-npc/cloud-sql.tf @@ -0,0 +1,75 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +resource "null_resource" "workstation_public_ip" { + provisioner "local-exec" { + command = < "../genai/examples/smart-npc/config.toml" +EOF + } + depends_on = [ + local_file.config-gcp + ] +} + +resource "null_resource" "generate_config_yaml" { + provisioner "local-exec" { + command = < Date: Wed, 2 Apr 2025 02:20:57 +0000 Subject: [PATCH 3/3] add WS url output --- terraform/smart-npc/outputs.tf | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/terraform/smart-npc/outputs.tf b/terraform/smart-npc/outputs.tf index 14dbe2c..fb85283 100644 --- a/terraform/smart-npc/outputs.tf +++ b/terraform/smart-npc/outputs.tf @@ -65,3 +65,7 @@ output "smart-npc-https-name" { output "smart-npc-https-ip" { value = google_compute_global_address.https_static_ip_address.address } + +output "smart-npc-ws-url" { + value = "ws://${google_compute_global_address.https_static_ip_address.name}/game/streaming" +}