Sign In
Poe-Bots

Godot 4.2 Copilot 개발하기

gogamza

소개

Poe 기반 서버봇

Poe?

Poe는 "Platform For Open, Exploration"의 약자로, 질문과 답변을 주고받는 유명한 웹사이트인 Quora에서 개발한 챗봇 소프트웨어입니다.
Poe는 다양한 AI 챗봇을 편리하게 이용할 수 있는 환경을 제공합니다.

Server Bot

프롬프트 봇과 서버봇 제공
검색 결과를 기반으로 응답을 생성해야 되기 때문에 서버봇 활용

Godot-4.2-Copilot

Poe 기반 서버 봇
고도엔진 4.2 공식 문서 를 수집하고 검색하는 시스템 구현
검색 API를 function call로 연동 (RAG with Function call)

RAG(Retrieval Augmented Generation)

검색 결과를 응답 생성 LLM에 주입해 결과를 고려한 응답을 생성하게 한다.

RAG with Function calling

Function calling의 역할
질의에 따라 godot_search 함수 호출해서 응답할지 아닐지 결정
function - description 활용
Function to search gdscript 2.0 syntax, Godot engine-based game tutorial code, and Godot engine API
godot_search 함수를 호출하기로 했다면 함수의 인자에 어떠한 파라메터를 전달할지 결정
function - parameters - description 활용
search keywords, english only, spaced, max 2 keywords. ex) navigation2d debug
LLM에게 전달하는 함수 스펙 형식
class GodotBot(fp.PoeBot): def __init__(self): super().__init__() self.db = GodotDatabase() self.tools = [ fp.ToolDefinition( type="function", function={ "name": "godot_search", "description": "Function to search gdscript 2.0 syntax, Godot engine-based game tutorial code, and Godot engine API", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "search keywords, english only, spaced, max 2 keywords. ex) navigation2d debug", } }, "required": ["query"], }, }, ) ] .....

고도엔진 문서 수집

문서 수집 결과 예시

→ 전체 문서를 하나의 jsonline 파일로 저장

검색기 구현

1.
벡터DB인 lancedb의 키워드 검색 기능만 활용
a.
embedding API 비용 절감
b.
잘 구성되어 있는 문서는 키워드 검색만으로도 충분하다.
class GodotDatabase: def __init__(self, uri: str = "data/godot-docdb"): self.uri = uri self.db = None self.table = None async def initialize(self): self.db = lancedb.connect(self.uri) docs = self.load_jsonl("data/output_all.jsonl") self.table = self.db.create_table("godot_doc", data=docs, exist_ok=True) self.table.create_fts_index(["title", "content"], replace=True) async def search(self, query: str, top_k: int = 1) -> List[Dict]: if not self.table: await self.initialize() results = self.table.search(query, query_type='fts').limit(top_k).select(["title", "content"]).to_list() print(query) return [{"rank": idx, "title": i['title'], "content": i['content'][:13000]} for idx, i in enumerate(results)] @staticmethod def load_jsonl(file_path: str) -> List[Dict]: data = [] with open(file_path, 'r', encoding='utf-8') as f: for line in f: try: data.append(json.loads(line.strip())) except json.JSONDecodeError: continue return data
검색 함수
async def godot_search(self, query: str) -> str: results = await self.db.search(query) return str({"DOCUMENT": results})

응답 프롬프트

LLM의 프롬프트는 영어가 대부분의 경우 유리하다. 비용적인 측면이나 따르는(instruction following) 측면이나.
def add_instruction() -> str: return """## QA guidelines You are a very friendly AI assistant for use in the Godot 4.X game engine editor. Please respond based on the Godot Engine 4.0 specification as much as possible. ### Like an assistant answering questions about the Godot engine and gdscript coding, refer to the DOCUMENT by using 'godot_search' tool as much as possible and use your ASSISTANT knowledge to answer in the following cases - When the DOCUMENTs are not adequate to answer a user question - No DOCUMENTs are available If your answer is based on DOCUMENT, MUST refer "Answer is based on document's \"{$title}\". Based on official Godot documentation." at the end of your answer. Where $title is the "title" of the DOCUMENTs. If you didn't reference any DOCUMENT entries to create your response, state at the end of your response that "This response is answered with the knowledge of an LLM". Make sure the language of your answer is the same as the language of the user's question. ## Example Code guidelines ### Provide example code where necessary, and be sure to write the example code in GDScript 2.0. GDScript 2.0 has the following features that are different from 1.X. - Use @export annotation for exports - Use Node3D instead of Spatial, and position instead of translation - Use randf_range and randi_range instead of rand_range - Connect signals via node.SIGNAL_NAME.connect(Callable(TARGET_OBJECT, TARGET_FUNC)) - Use rad_to_deg instead of rad2deg - Use PackedByteArray instead of PoolByteArray - Use instantiate instead of instance - You can't use enumerate(OBJECT). Instead, use "for i in len(OBJECT):" """
위 내용을 간략히 정리하면 아래와 같다.
1.
Godot 4.X 게임 엔진 에디터용 AI 보조자 역할을 수행합니다.
2.
가능한 한 Godot Engine 4.0 사양을 기반으로 응답합니다.
3.
질문에 답할 때:
가능하면 'godot_search' 도구를 사용해 문서를 참조합니다.
문서가 부적절하거나 없는 경우 AI 지식을 활용합니다.
4.
문서 기반 답변 시 출처를 명시하고, AI 지식 기반 답변 시 그 사실을 언급합니다.
5.
사용자 질문 언어와 동일한 언어로 답변합니다.
6.
필요 시 GDScript 2.0 기반의 예제 코드를 제공합니다. 이때 다음 특징들을 고려합니다:
@export 어노테이션 사용
Node3D와 position 사용
randf_range와 randi_range 사용
신호 연결 방식 변경
rad_to_deg 사용
PackedByteArray 사용
instantiate 사용
enumerate 대신 for 루프 사용
LLM이 도구를 사용해 응답하고 스트리밍 하는 코드
.... request.query[-1].content = self.add_instruction() + request.query[-1].content async for msg in fp.stream_request( request, ANS_MODEL, request.access_key, tools=self.tools, tool_executables=[self.godot_search] ): yield msg ....

Poe 봇 디플로이

Poe 서버봇은 아래와 같은 형태로 동작한다. 앞서 설명한 내용은 'Your bot server'에 해당한 부분이며 서버를 어딘가에 띄워 놓아야 된다.
Poe에서는 modal이라는 서버리스 환경에서 동작하는걸 추천하는데, 서버리스 환경이기 때문에 별도 스토리지를 구현하는게 꽤 복잡해서 필자는 개인용 크레딧으로 사용하는 클라우드 환경에서 진행했다.
modal 사용시 필자가 작성한 특징적인 코드 부분은 아래와 같다.
def load_jsonl(file_path): data = [] with open(file_path, 'r', encoding='utf-8') as f: for line in f: try: data.append(json.loads(line.strip())) except json.decoder.JSONDecodeError: continue return data dict = Dict.from_name("godot-dict", create_if_missing=True) if modal.is_local(): docs = load_jsonl("output_all.jsonl") dict["docs"] = docs @app.function(image=image) @asgi_app() def fastapi_app(): bot = EchoBot(should_insert_attachment_messages=False) # Optionally, provide your Poe access key here: # 1. You can go to https://poe.com/create_bot?server=1 to generate an access key. # 2. We strongly recommend using a key for a production bot to prevent abuse, # but the starter examples disable the key check for convenience. # 3. You can also store your access key on modal.com and retrieve it in this function # by following the instructions at: https://modal.com/docs/guide/secrets # POE_ACCESS_KEY = "" app = fp.make_app(bot, access_key="....") #app = fp.make_app(bot, allow_without_key=True) return app
서버리스 환경에서 사용 가능한 dict를 정의하는게 다소 특징적인 부분이고 이 부분을 좀더 매끄럽게 구현하기 위해서는 공식 문서를 참고하기 바란다.
필자는 이 부분에서 추가적인 디스크 마운트를 피하기 위해 몇가지 꼼수를 썼는데, 응답 시간이 느려지는 단점이 있어서 개인 클라우드 환경으로 옮겼다.
물론 서버를 별도 띄우는 방법이 구현상의 자유로움이 있지만 https기반의 API를 구성해야 되는 등 추가적으로 고려해야 될 부분이 많아서 지금 생각은 modal로 최대한 구현하는것도 좋을거라는 예상을 해본다. 따라서 필자처럼 무료 클라우드 환경이 없는 경우는 modal로 가는걸 추천드린다.
전체 소스코드
from __future__ import annotations from typing import AsyncIterable, List, Dict import fastapi_poe as fp import json import lancedb import uvicorn from fastapi_poe.templates import TEXT_ATTACHMENT_TEMPLATE # MODEL = "GPT-4o" ANS_MODEL = "GPT-4o-Mini" DIS_MODEL = "GPT-4o-Mini" class GodotDatabase: def __init__(self, uri: str = "data/godot-docdb"): self.uri = uri self.db = None self.table = None async def initialize(self): self.db = lancedb.connect(self.uri) docs = self.load_jsonl("data/output_all.jsonl") self.table = self.db.create_table("godot_doc", data=docs, exist_ok=True) self.table.create_fts_index(["title", "content"], replace=True) async def search(self, query: str, top_k: int = 1) -> List[Dict]: if not self.table: await self.initialize() results = self.table.search(query, query_type='fts').limit(top_k).select(["title", "content"]).to_list() print(query) return [{"rank": idx, "title": i['title'], "content": i['content'][:13000]} for idx, i in enumerate(results)] @staticmethod def load_jsonl(file_path: str) -> List[Dict]: data = [] with open(file_path, 'r', encoding='utf-8') as f: for line in f: try: data.append(json.loads(line.strip())) except json.JSONDecodeError: continue return data class GodotBot(fp.PoeBot): def __init__(self): super().__init__() self.db = GodotDatabase() self.tools = [ fp.ToolDefinition( type="function", function={ "name": "godot_search", "description": "Function to search Godot game engine API and gdscript 2.0 syntax, Godot engine-based game tutorial code.", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "search keywords, ENGLISH only, spaced, max 2 keywords. ex) navigation2d debug", } }, "required": ["query"], }, }, ) ] async def godot_search(self, query: str) -> str: print("검색어 : " + query) results = await self.db.search(query) return str({"DOCUMENT": results}) @staticmethod def add_instruction() -> str: return """## Guidelines You are a very friendly AI assistant for use in the Godot 4 game engine editor. Please answer based on the Godot Engine 4.0 specification and example codes. ## Example Code guidelines ### Provide example code where necessary, and be sure to write the example code in GDScript 2.0. GDScript 2.0 has the following features that are different from 1.X. - Use @export annotation for exports - Use Node3D instead of Spatial, and position instead of translation - Use randf_range and randi_range instead of rand_range - Connect signals via node.SIGNAL_NAME.connect(Callable(TARGET_OBJECT, TARGET_FUNC)) - Use rad_to_deg instead of rad2deg - Use PackedByteArray instead of PoolByteArray - Use instantiate instead of instance - You can't use enumerate(OBJECT). Instead, use "for i in len(OBJECT):" """ @staticmethod def user_inst() -> str: return """## Like an assistant answering questions about the Godot engine and gdscript coding, refer to the DOCUMENT by using 'godot_search' tool as much as possible and use your ASSISTANT knowledge to answer in the following cases - When the DOCUMENTs are not adequate to answer a user question - No DOCUMENTs are available If your answer is based on DOCUMENT, MUST refer "Answer is based on document's \"{$title}\". Based on official Godot documentation." at the end of your answer. Where $title is the "title" of the DOCUMENTs. If you didn't reference any DOCUMENT entries to create your response, state at the end of your response that "This response is answered with the knowledge of an LLM". Make sure the language of your answer is the same as the language of the user's question. Please respond with a detailed, easy-to-understand explanation and, if possible, with example code. user's question: """ def make_prompt(self, system: str, user: str, request: ft.QueryRequest) -> ft.QueryRequest: sys_message = request.query[-1].model_copy(deep=True) user_message = request.query[-1].model_copy(deep=True) sys_message = sys_message.model_copy(update={ 'role': 'system', 'content': system }) user_message = user_message.model_copy(update={ "role": "user", "content": user }) new_request = request.model_copy( update={"query": request.query[:-1] + [sys_message, user_message]} ) return new_request async def get_response(self, request: fp.QueryRequest) -> AsyncIterable[fp.PartialResponse]: new_request = self.make_prompt(self.add_instruction(), self.user_inst() + request.query[-1].content, request) async for msg in fp.stream_request( new_request, ANS_MODEL, request.access_key, tools=self.tools, tool_executables=[self.godot_search] ): yield msg async def get_settings(self, setting: fp.SettingsRequest) -> fp.SettingsResponse: return fp.SettingsResponse( introduction_message="Hi, I am the Godot 4 QA bot. Please provide me a Godot engine related questions or gdscript coding requests.", allow_attachments=True, expand_text_attachments=True, server_bot_dependencies={ANS_MODEL: 1} #, DIS_MODEL: 1} ) if __name__ == "__main__": bot = GodotBot() app = fp.make_app(bot, access_key="...") uvicorn.run( app, host="0.0.0.0", port=443, ssl_keyfile="...", ssl_certfile="..." )
Subscribe to 'llm-bots'
Subscribe to my site to be the first to receive notifications and emails about the latest updates, including new posts.
Join Slashpage and subscribe to 'llm-bots'!
Subscribe
👍
1