Add Hermes Reasoner mode to battleship with game ending fixes

- Add new Hermes Reasoner AI mode that combines probability analysis with LLM reasoning
- Implement pre-script and post-script architecture in app.py for flexible YAML processing
- Fix game ending detection by adding transition override mechanism
- Add probability matrix visualization and strategic move analysis
- Support both legacy processing_script and new pre_script/post_script naming
- Restore full ship complement for complete battleship gameplay
This commit is contained in:
Russell Ballestrini 2025-08-10 11:57:22 -04:00
parent 38f414c5a9
commit 368c7d290e
2 changed files with 459 additions and 68 deletions

127
app.py
View file

@ -62,10 +62,12 @@ for i in range(MAX_ENDPOINTS):
continue
# API key is optional; if not provided, use a default.
api_key = os.environ.get(f"MODEL_API_KEY_{i}", "not-needed")
ENDPOINTS.append({
"base_url": endpoint,
"api_key": api_key,
})
ENDPOINTS.append(
{
"base_url": endpoint,
"api_key": api_key,
}
)
if not ENDPOINTS:
raise Exception("No MODEL_ENDPOINT_x environment variables found!")
@ -121,6 +123,7 @@ def get_client_for_model(model_name: str):
print(f"Completion Endpoint Processing: {MODEL_CLIENT_MAP[model_name][1]}")
return MODEL_CLIENT_MAP[model_name][0]
def get_openai_client_and_model(
model_name="adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic",
):
@ -353,21 +356,25 @@ def search_messages(keywords):
# Split the keywords by spaces and sanitize
keyword_list = keywords.lower().split()
# Sanitize keywords to prevent SQL injection
sanitized_keywords = []
for keyword in keyword_list:
# Remove potentially dangerous characters and limit length
sanitized_keyword = ''.join(c for c in keyword if c.isalnum() or c.isspace() or c in '-_')[:50]
sanitized_keyword = "".join(
c for c in keyword if c.isalnum() or c.isspace() or c in "-_"
)[:50]
if sanitized_keyword.strip(): # Only add non-empty keywords
sanitized_keywords.append(sanitized_keyword.strip())
if not sanitized_keywords:
return {}
# Search for messages containing any of the sanitized keywords using parameterized query
messages = Message.query.filter(
db.or_(*[Message.content.ilike(f"%{keyword}%") for keyword in sanitized_keywords])
db.or_(
*[Message.content.ilike(f"%{keyword}%") for keyword in sanitized_keywords]
)
).all()
for message in messages:
@ -827,7 +834,6 @@ def chat_gpt(username, room_name, model_name="gpt-4o-mini"):
if "o4-" in model_name:
temperature = 1
with app.app_context():
room = get_room(room_name)
last_messages = (
@ -1168,6 +1174,7 @@ def generate_dalle_image(room_name, message, username):
# Create an HTML img tag with the base64 data (escape user input for XSS protection)
import html
escaped_message = html.escape(message)
escaped_prompt = html.escape(revised_prompt)
content = f'<img src="data:image/jpeg;base64,{image_data}" alt="{escaped_message}"><p>{escaped_prompt}</p>'
@ -1449,24 +1456,28 @@ def get_activity_content(file_path):
if app.config["LOCAL_ACTIVITIES"]:
# Load the activity YAML from a local file with path traversal protection
import os.path
# Normalize the path and ensure it's within the research directory
normalized_path = os.path.normpath(file_path)
# Ensure path doesn't contain dangerous patterns
if '..' in normalized_path or normalized_path.startswith('/'):
if ".." in normalized_path or normalized_path.startswith("/"):
raise ValueError(f"Invalid file path: {file_path}")
# Ensure file is within research directory and has .yaml extension
if not normalized_path.startswith('research/') or not normalized_path.endswith('.yaml'):
raise ValueError(f"File must be in research/ directory and end with .yaml: {file_path}")
if not normalized_path.startswith("research/") or not normalized_path.endswith(
".yaml"
):
raise ValueError(
f"File must be in research/ directory and end with .yaml: {file_path}"
)
# Additional safety check - ensure resolved path is still in research dir
full_path = os.path.abspath(normalized_path)
research_dir = os.path.abspath('research/')
research_dir = os.path.abspath("research/")
if not full_path.startswith(research_dir):
raise ValueError(f"Path traversal attempt detected: {file_path}")
with open(normalized_path, "r") as file:
activity_yaml = file.read()
else:
@ -1725,6 +1736,17 @@ def handle_activity_response(room_name, user_response, username):
# Check if the step has a question
if "question" in step:
# Execute pre-script if it exists (runs before categorization)
if "pre_script" in step:
print(f"DEBUG: Executing pre-script")
pre_result = execute_processing_script(
activity_state.dict_metadata, step["pre_script"]
)
# Update metadata with pre-script results
for key, value in pre_result.get("metadata", {}).items():
activity_state.add_metadata(key, value)
print(f"DEBUG: Pre-script completed, updated metadata")
# Categorize the user's response
category = categorize_response(
step["question"],
@ -1956,12 +1978,15 @@ def handle_activity_response(room_name, user_response, username):
metadata_tmp_keys.append(random_key)
activity_state.add_metadata(random_key, random_value)
# Execute the processing script if it exists
if "processing_script" in step and transition.get(
"run_processing_script", False
# Execute the post-script if it exists (supports both old and new naming)
post_script = step.get("post_script") or step.get("processing_script")
if post_script and (
transition.get("run_post_script", False)
or transition.get("run_processing_script", False)
):
print(f"DEBUG: Executing post-script")
result = execute_processing_script(
activity_state.dict_metadata, step["processing_script"]
activity_state.dict_metadata, post_script
)
plot_image_base64 = result.pop("plot_image", None)
@ -1974,6 +1999,13 @@ def handle_activity_response(room_name, user_response, username):
for key, value in result.get("metadata", {}).items():
activity_state.add_metadata(key, value)
# Check if processing script wants to override the transition
if "next_section_and_step" in result:
next_section_and_step = result["next_section_and_step"]
print(
f"DEBUG: Processing script overriding transition to: {next_section_and_step}"
)
# Check if the result contains a plot image
if plot_image_base64:
plot_image_html = f'<img alt="Plot Image" src="data:image/png;base64,{plot_image_base64}">'
@ -2106,6 +2138,7 @@ def handle_activity_response(room_name, user_response, username):
"off_topic",
]
or activity_state.attempts >= activity_state.max_attempts
or next_section_and_step # Processing script override takes precedence
):
if next_section_and_step:
(
@ -2346,14 +2379,24 @@ def get_next_step(activity_content, current_section_id, current_step_id):
def categorize_response(question, response, buckets, tokens_for_ai):
openai_client, model_name = get_openai_client_and_model()
bucket_list = ", ".join([str(bucket) for bucket in buckets])
# Check if tokens_for_ai already includes format instructions (ANALYSIS/BUCKET format)
if "ANALYSIS:" in tokens_for_ai and "BUCKET:" in tokens_for_ai:
# YAML already specifies output format, don't override
system_content = f"{tokens_for_ai}"
user_content = f"Question: {question}\nResponse: {response}"
else:
# Use old simple format for backwards compatibility
system_content = f"{tokens_for_ai} Categorize the following response into one of the following buckets: {bucket_list}. Return ONLY a bucket label."
user_content = f"Question: {question}\nResponse: {response}\n\nCategory:"
messages = [
{
"role": "system",
"content": f"{tokens_for_ai} Categorize the following response into one of the following buckets: {bucket_list}. Return ONLY a bucket label.",
"content": system_content,
},
{
"role": "user",
"content": f"Question: {question}\nResponse: {response}\n\nCategory:",
"content": user_content,
},
]
@ -2362,12 +2405,40 @@ def categorize_response(question, response, buckets, tokens_for_ai):
model=model_name,
messages=messages,
n=1,
max_tokens=10,
max_tokens=150, # Increased for ANALYSIS + BUCKET format
temperature=0,
)
category = (
completion.choices[0].message.content.strip().lower().replace(" ", "_")
)
full_response = completion.choices[0].message.content.strip()
print(f"DEBUG BUCKET CATEGORIZATION: Full Hermes response: {full_response}")
# Handle both ANALYSIS/BUCKET format and simple bucket response
if "BUCKET:" in full_response:
# New ANALYSIS/BUCKET format
bucket_lines = [
line for line in full_response.split("\n") if "BUCKET:" in line
]
if bucket_lines:
category = (
bucket_lines[0]
.split("BUCKET:")[1]
.strip()
.lower()
.replace(" ", "_")
)
else:
category = full_response.lower().replace(" ", "_")
elif "ANALYSIS:" in full_response:
# Has analysis but no explicit BUCKET: line, try to extract from end
lines = [line.strip() for line in full_response.split("\n") if line.strip()]
if lines:
category = lines[-1].lower().replace(" ", "_")
else:
category = full_response.lower().replace(" ", "_")
else:
# Simple bucket response (old format)
category = full_response.lower().replace(" ", "_")
print(f"DEBUG BUCKET CATEGORIZATION: Extracted category: {category}")
return category
except Exception as e:
return f"Error: {e}"