Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,13 @@ crashlytics-build.properties
/[Aa]ssets/[Ss]treamingAssets/aa/*

*.DS_Store

# GameEngine .NET build artifacts
GameEngine/**/bin/
GameEngine/**/obj/

# GameEngine project files (override *.csproj exclusion above)
!GameEngine/**/*.csproj

# API keys
Assets/StreamingAssets/config.json
58 changes: 58 additions & 0 deletions Assets/Scripts/GameEngine/Data/ConceptData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Collections.Generic;

namespace GameEngine.Data;

/// <summary>
/// Concept vocabulary data for protein synthesis tutoring.
/// Port of concept_utils.py constants.
/// </summary>
public static class ConceptData
{
/// <summary>Advanced scientific terms the tutor should handle carefully</summary>
public static readonly List<string> AdvancedConcepts = new()
{
"protein synthesis", "transcription", "translation",
"ribosomes", "mRNA", "tRNA", "amino acids", "codon"
};

/// <summary>Foundational concepts that map to everyday language</summary>
public static readonly HashSet<string> FoundationalConcepts = new()
{
"cell", "nucleus", "instruction", "building blocks", "growth", "jobs"
};

/// <summary>Entry in the student's concept language dictionary</summary>
public class PhraseEntry
{
public string Phrase { get; set; } = "";
public string Source { get; set; } = "student"; // "student" or "default"
}

/// <summary>
/// Merge pending phrase updates into the student concept language dictionary.
/// Port of merge_phrase_updates from reflexion.py.
/// </summary>
public static void MergePhraseUpdates(
Dictionary<string, List<PhraseEntry>> studentConceptLanguage,
List<Models.PhraseUpdate>? updates)
{
if (updates == null) return;

foreach (var update in updates)
{
var concept = update.Concept?.Trim();
var phrase = update.Phrase?.Trim();
if (string.IsNullOrEmpty(concept) || string.IsNullOrEmpty(phrase))
continue;

if (!studentConceptLanguage.ContainsKey(concept))
studentConceptLanguage[concept] = new List<PhraseEntry>();

studentConceptLanguage[concept].Add(new PhraseEntry
{
Phrase = phrase,
Source = "student"
});
}
}
}
16 changes: 16 additions & 0 deletions Assets/Scripts/GameEngine/Data/ProteinData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.Collections.Generic;

namespace GameEngine.Data;

/// <summary>
/// Protein list and related constants.
/// Port of protein_selection.py.
/// </summary>
public static class ProteinData
{
public static readonly List<string> ProteinsList = new()
{
"lactase", "hemoglobin", "insulin", "myosin",
"keratin", "immunoglobulins", "tyrosinase", "cytokines"
};
}
205 changes: 205 additions & 0 deletions Assets/Scripts/GameEngine/LLM/AnthropicClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using GameEngine.Models;

namespace GameEngine.LLM;

/// <summary>
/// Direct Anthropic API client using HttpClient.
/// Replaces the entire LangGraph pipeline with a single Claude API call.
/// </summary>
public class AnthropicClient
{
private readonly HttpClient _httpClient;
private readonly string _apiKey;
private readonly string _model;
private const string ApiUrl = "https://api.anthropic.com/v1/messages";

public AnthropicClient(string apiKey, string model = "claude-haiku-4-5-20251001")
{
_apiKey = apiKey;
_model = model;
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);
_httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
}

/// <summary>
/// Send a message to Claude and get a structured DrafterOutput response.
/// Uses tool_use (function calling) for reliable structured output.
/// </summary>
public async Task<DrafterOutput> SendMessageAsync(string systemPrompt, CancellationToken cancellationToken = default)
{
var requestBody = new
{
model = _model,
max_tokens = 2048,
system = systemPrompt,
messages = new[]
{
new { role = "user", content = "(Respond based on the context provided above.)" }
},
tools = new[]
{
new
{
name = "respond",
description = "Generate a tutoring response with goal tracking and action selection.",
input_schema = GetDrafterOutputSchema()
}
},
tool_choice = new { type = "tool", name = "respond" }
};

var jsonContent = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});

var request = new HttpRequestMessage(HttpMethod.Post, ApiUrl)
{
Content = new StringContent(jsonContent, Encoding.UTF8, "application/json")
};

var response = await _httpClient.SendAsync(request, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);

if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException(
$"Anthropic API error ({response.StatusCode}): {responseBody}");
}

return ParseToolUseResponse(responseBody);
}

/// <summary>
/// Parse the Anthropic API response to extract the tool_use input as DrafterOutput.
/// </summary>
private DrafterOutput ParseToolUseResponse(string responseBody)
{
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;

// Find the tool_use content block
if (root.TryGetProperty("content", out var content))
{
foreach (var block in content.EnumerateArray())
{
if (block.TryGetProperty("type", out var type) &&
type.GetString() == "tool_use" &&
block.TryGetProperty("input", out var input))
{
var inputJson = input.GetRawText();
return JsonSerializer.Deserialize<DrafterOutput>(inputJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new DrafterOutput { Message = "I'm sorry, something went wrong. Could you try again?" };
}
}
}

// Fallback: try to parse the text content as JSON
if (root.TryGetProperty("content", out var textContent))
{
foreach (var block in textContent.EnumerateArray())
{
if (block.TryGetProperty("type", out var type) &&
type.GetString() == "text" &&
block.TryGetProperty("text", out var text))
{
var textStr = text.GetString() ?? "";
try
{
return JsonSerializer.Deserialize<DrafterOutput>(textStr,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new DrafterOutput { Message = textStr };
}
catch
{
return new DrafterOutput { Message = textStr };
}
}
}
}

return new DrafterOutput { Message = "I'm sorry, I'm having trouble responding. Could you try again?" };
}

/// <summary>
/// JSON schema for the DrafterOutput tool parameter.
/// </summary>
private static object GetDrafterOutputSchema()
{
return new
{
type = "object",
properties = new Dictionary<string, object>
{
["chosen_goal_for_turn"] = new
{
type = new[] { "string", "null" },
description = "The single goal you have chosen to focus on for this turn, or null if none."
},
["message"] = new
{
type = "string",
description = "The peer tutor's dialogue response."
},
["chosen_protein"] = new
{
type = new[] { "string", "null" },
description = "The single protein you chose to introduce to the student, if any."
},
["pending_phrase_updates"] = new
{
type = "array",
items = new
{
type = "object",
properties = new Dictionary<string, object>
{
["phrase"] = new { type = "string" },
["concept"] = new { type = "string" }
},
required = new[] { "phrase", "concept" }
},
description = "Pending phrase updates based on student input of current turn."
},
["goal_relevance_score"] = new
{
type = new[] { "integer", "null" },
description = "Score between 1-5 for goal relevance, or null."
},
["responsiveness_score"] = new
{
type = new[] { "integer", "null" },
description = "Score between 1-5 for responsiveness."
},
["summary_critique"] = new
{
type = new[] { "string", "null" },
description = "2-3 sentences summarizing how to improve the response."
},
["action"] = new
{
type = new[] { "string", "null" },
description = "The name of the action being taken, or null if none."
},
["student_interest"] = new
{
type = new[] { "string", "null" },
description = "The single student interest being referenced, if any."
}
},
required = new[] { "message" }
};
}
}
88 changes: 88 additions & 0 deletions Assets/Scripts/GameEngine/LLM/EvalContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System.Collections.Generic;

namespace GameEngine.LLM;

/// <summary>
/// Evaluation context for determining which criteria to include in the prompt.
/// Port of EvalContext from schemas.py and eval_context.py.
/// </summary>
public enum EvalContextType
{
/// <summary>There is at least one unmet goal remaining</summary>
PursuingGoal = 1,

/// <summary>All goals met, available actions to pursue</summary>
ActNow = 2,

/// <summary>All goals met, all available actions taken</summary>
JustResponsiveness = 3
}

/// <summary>
/// Utilities for evaluation context determination and prompt assembly.
/// Port of eval_context.py and eval_prompt.py.
/// </summary>
public static class EvalContextUtil
{
/// <summary>
/// Determine the evaluation context based on unmet goals and available actions.
/// Port of determine_eval_context() from utils/eval_context.py.
/// </summary>
public static EvalContextType DetermineEvalContext(
List<string> unmetGoals,
Dictionary<string, string> availableActions)
{
if (unmetGoals.Count > 0)
return EvalContextType.PursuingGoal;
if (availableActions.Count > 0)
return EvalContextType.ActNow;
return EvalContextType.JustResponsiveness;
}

/// <summary>
/// Build the evaluation context text for the prompt.
/// Port of make_evaluator_context() from utils/eval_prompt.py.
/// </summary>
/// <param name="evalCondition">Current eval context type</param>
/// <param name="parameterSetting">"strict" or "lenient"</param>
/// <param name="vocabList">Advanced concepts vocabulary list</param>
/// <param name="evalBaseText">Contents of EVAL_BASE.txt</param>
/// <param name="criteriaFullText">Contents of CRITERIA_FULL.txt</param>
/// <param name="criteriaRespOnlyText">Contents of CRITERIA_RESP_ONLY.txt</param>
/// <param name="reflectionStrictText">Contents of REFLECTION_STRICT.txt</param>
/// <param name="reflectionLenientText">Contents of REFLECTION_LENIENT.txt</param>
public static string MakeEvaluatorContext(
EvalContextType evalCondition,
string? parameterSetting,
List<string>? vocabList,
string evalBaseText,
string criteriaFullText,
string criteriaRespOnlyText,
string reflectionStrictText,
string reflectionLenientText)
{
var criteriaBlock = evalCondition == EvalContextType.JustResponsiveness
? criteriaRespOnlyText
: criteriaFullText;

var vocabStr = vocabList != null ? string.Join(", ", vocabList) : "";

string reflectionBlock;
switch (parameterSetting)
{
case "strict":
reflectionBlock = reflectionStrictText.Replace("{vocab_list}", vocabStr);
break;
case "lenient":
reflectionBlock = reflectionLenientText.Replace("{vocab_list}", vocabStr);
break;
default:
reflectionBlock = "";
break;
}

return evalBaseText
.Replace("{criteria_block}", criteriaBlock)
.Replace("{reflection_block}", reflectionBlock);
}
}
Loading