Files
2025-01-18 18:12:21 -05:00

862 lines
25 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/invopop/jsonschema"
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
"github.com/pkoukk/tiktoken-go"
)
const idLen = 8
/*
When deciding whether a value belongs in Config or Ranker structs, consider the following:
- Does this value change during operation? → Ranker if yes, Config if no
- Should users be able to configure this directly? → Config if yes, Ranker if no
- Is this derived from other configuration? → Usually Ranker
- Does this require initialization or cleanup? → Usually Ranker
- Is this part of the public API? → Config if yes, Ranker if no
*/
type Config struct {
InitialPrompt string
BatchSize int
NumRuns int
OllamaModel string
OpenAIModel openai.ChatModel
TokenLimit int
RefinementRatio float64
OpenAIKey string
OllamaAPIURL string
Encoding string
BatchTokens int
}
// TODO: Move all CLI flag validation this func instead.
func (c *Config) Validate() error {
if c.InitialPrompt == "" {
return fmt.Errorf("initial prompt cannot be empty")
}
if c.BatchSize <= 0 {
return fmt.Errorf("batch size must be greater than 0")
}
if c.NumRuns <= 0 {
return fmt.Errorf("number of runs must be greater than 0")
}
if c.TokenLimit <= 0 {
return fmt.Errorf("token limit must be greater than 0")
}
if c.OllamaModel == "" && c.OpenAIKey == "" {
return fmt.Errorf("openai key cannot be empty")
}
return nil
}
type Ranker struct {
cfg *Config
encoding *tiktoken.Tiktoken
rng *rand.Rand
numBatches int
round int
}
func NewRanker(config *Config) (*Ranker, error) {
if err := config.Validate(); err != nil {
return nil, err
}
encoding, err := tiktoken.GetEncoding(config.Encoding)
if err != nil {
return nil, fmt.Errorf("failed to get tiktoken encoding: %w", err)
}
return &Ranker{
cfg: config,
encoding: encoding,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}, nil
}
type Object struct {
ID string `json:"id"`
Value string `json:"value"`
}
type RankedObject struct {
Object Object
Score float64
}
type RankedObjectResponse struct {
Objects []string `json:"objects" jsonschema_description:"List of ranked object IDs"`
}
type FinalResult struct {
Key string `json:"key"`
Value string `json:"value"`
Score float64 `json:"score"`
Exposure int `json:"exposure"`
Rank int `json:"rank"`
}
var dryRun bool
func GenerateSchema[T any]() interface{} {
reflector := jsonschema.Reflector{
AllowAdditionalProperties: false,
DoNotReference: true,
}
var v T
schema := reflector.Reflect(v)
return schema
}
var RankedObjectResponseSchema = GenerateSchema[RankedObjectResponse]()
func ShortDeterministicID(input string, length int) string {
// Step 1: Hash the input using SHA-256
hash := sha256.Sum256([]byte(input))
// Step 2: Encode the hash in Base64 (URL-safe)
base64Encoded := base64.URLEncoding.EncodeToString(hash[:])
// Step 3: Truncate to the desired length
if length > len(base64Encoded) {
length = len(base64Encoded)
}
return base64Encoded[:length]
}
// TODO: Move all of this CLI-related code to a separate package.
func main() {
log.SetOutput(os.Stderr)
inputFile := flag.String("f", "", "Input file")
batchSize := flag.Int("s", 10, "Number of items per batch")
numRuns := flag.Int("r", 10, "Number of runs")
batchTokens := flag.Int("t", 128000, "Max tokens per batch")
initialPrompt := flag.String("p", "", "Initial prompt")
ollamaModel := flag.String("ollama-model", "", "Ollama model name (if not set, OpenAI will be used)")
flag.BoolVar(&dryRun, "dry-run", false, "Enable dry run mode (log API calls without making them)")
refinementRatio := flag.Float64("ratio", 0.5, "Refinement ratio as a decimal (e.g., 0.5 for 50%)")
flag.Parse()
// TODO: This should be a more resilient check. We're assuming that if the
// batchTokens is 128000, then a user didn't pass that value via CLI (i.e.,
// that it's the default value).
if *ollamaModel != "" && *batchTokens == 128000 {
*batchTokens = 4096
}
// This "threshold" is a way to add some padding to our estimation of
// average token usage per batch. We're effectively leaving 5% of
// wiggle room.
var tokenLimitThreshold = int(0.95 * float64(*batchTokens))
if *inputFile == "" {
log.Println("Usage: go run main.go -f <input_file> [-s <batch_size>] [-r <num_runs>] [-p <initial_prompt>] [-t <batch_tokens>] [-ollama-model <model_name>] [-ratio <refinement_ratio>]")
return
}
if *refinementRatio < 0 || *refinementRatio >= 1 {
fmt.Println("Error: Refinement ratio must be >= 0 and < 1")
os.Exit(1)
}
apiKey := os.Getenv("OPENAI_API_KEY")
apiURL := os.Getenv("OLLAMA_API_URL")
if apiURL == "" {
apiURL = "http://localhost:11434/api/chat"
}
// TODO: Make this configurable.
// https://pkg.go.dev/github.com/openai/[email protected]#ChatModel
// const model = openai.ChatModelGPT4o2024_08_06
const model = openai.ChatModelGPT4oMini
const enc = "o200k_base"
config := &Config{
InitialPrompt: *initialPrompt,
BatchSize: *batchSize,
NumRuns: *numRuns,
OllamaModel: *ollamaModel,
TokenLimit: tokenLimitThreshold,
RefinementRatio: *refinementRatio,
OpenAIKey: apiKey,
OllamaAPIURL: apiURL,
OpenAIModel: model,
Encoding: enc,
BatchTokens: *batchTokens,
}
ranker, err := NewRanker(config)
if err != nil {
log.Fatal(err)
}
file, err := os.Open(*inputFile)
if err != nil {
log.Fatal(err)
}
defer file.Close()
var objects []Object
reader := bufio.NewReader(file)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
log.Fatal(err)
}
line = strings.TrimSpace(line)
id := ShortDeterministicID(line, idLen)
objects = append(objects, Object{ID: id, Value: line})
}
// Dynamically adjust batch size upfront.
// TODO: Move this to a separate function.
samples := 10
for {
valid := true
var estTotalTokens int
var numBatches int
for i := 0; i < samples; i++ {
ranker.rng.Shuffle(len(objects), func(i, j int) {
objects[i], objects[j] = objects[j], objects[i]
})
numBatches = len(objects) / ranker.cfg.BatchSize
for j := 0; j < numBatches; j++ {
batch := objects[j*ranker.cfg.BatchSize : (j+1)*ranker.cfg.BatchSize]
estBatchTokens := ranker.estimateTokens(batch)
estTotalTokens += estBatchTokens
if estBatchTokens > ranker.cfg.TokenLimit {
log.Printf("Sample %d: estimated tokens %d > max token threshold %d", i, estBatchTokens, ranker.cfg.TokenLimit)
ranker.logTokenSizes(batch)
valid = false
break
}
}
if !valid {
break
}
}
if numBatches > 0 {
avgEstTokens := estTotalTokens / (samples * numBatches)
avgEstPct := float64(avgEstTokens) / float64(ranker.cfg.BatchTokens) * 100
log.Printf("Average estimated tokens: %d (%.2f%% of max %d tokens)", avgEstTokens, avgEstPct, ranker.cfg.BatchTokens)
}
if valid {
break
}
ranker.cfg.BatchSize--
log.Printf("Decreasing batch size to %d", ranker.cfg.BatchSize)
if ranker.cfg.BatchSize == 0 {
log.Fatal("Cannot create a valid batch within the token limit")
}
}
// Recursive processing
finalResults := ranker.Rank(objects, 1)
// Add the rank key to each final result based on its position in the list
for i := range finalResults {
finalResults[i].Rank = i + 1
}
jsonResults, err := json.MarshalIndent(finalResults, "", " ")
if err != nil {
panic(err)
}
if !dryRun {
fmt.Println(string(jsonResults))
}
}
// TODO: The final exposure value should be the sum of all exposures from all
// refinement rounds (not just the last one). This isn't crucial since exposure
// is just a helpful metric to show that objects compared to a sufficiently
// large number of other objects.
func (r *Ranker) Rank(objects []Object, round int) []FinalResult {
r.round = round
log.Printf("Round %d: Ranking %d objects\n", r.round, len(objects))
// If we've narrowed down to a single object, we're done.
if len(objects) == 1 {
return []FinalResult{
{
Key: objects[0].ID,
Value: objects[0].Value,
Score: 0, // 0 is guaranteed to be the "highest" score.
Exposure: 1,
},
}
}
// Downstream ranking gets unhappy if we try to rank more objects than we
// have.
if r.cfg.BatchSize > len(objects) {
r.cfg.BatchSize = len(objects)
}
r.numBatches = len(objects) / r.cfg.BatchSize
// Process the objects and get the sorted results.
results := r.shuffleBatchRank(objects)
// If the refinement ratio is 0, that effectively means we're refining
// _none_ of the top objects, so we're done.
if r.cfg.RefinementRatio == 0 {
return results
}
// Calculate the mid index based on the refinement ratio.
mid := int(float64(len(results)) * r.cfg.RefinementRatio)
topPortion := results[:mid]
bottomPortion := results[mid:]
// If we haven't reduced the number of objects (as may eventually happen
// for a ratio above 0.5), we're done.
if len(topPortion) == len(objects) {
return results
}
log.Println("Top items being sent back into recursion:")
for i, obj := range topPortion {
log.Printf("Rank %d: ID=%s, Score=%.2f, Value=%s", i+1, obj.Key, obj.Score, obj.Value)
}
var topPortionObjects []Object
for _, result := range topPortion {
topPortionObjects = append(topPortionObjects, Object{ID: result.Key, Value: result.Value})
}
refinedTopPortion := r.Rank(topPortionObjects, round+1)
// Adjust scores by recursion depth; this serves as an inverted weight so
// that later rounds are guaranteed to sit higher in the final list.
for i := range refinedTopPortion {
refinedTopPortion[i].Score /= float64(2 * round)
}
// Combine the refined top portion with the unrefined bottom portion.
finalResults := append(refinedTopPortion, bottomPortion...)
return finalResults
}
// TODO: Also log the request/retry attempt number.
func (r *Ranker) logFromApiCall(runNum, batchNum int, message string, args ...interface{}) {
formattedMessage := fmt.Sprintf("Round %d, Run %*d/%d, Batch %*d/%d: "+message, r.round, len(strconv.Itoa(r.cfg.NumRuns)), runNum, r.cfg.NumRuns, len(strconv.Itoa(r.numBatches)), batchNum, r.numBatches)
log.Printf(formattedMessage, args...)
}
func (r *Ranker) shuffleBatchRank(objects []Object) []FinalResult {
scores := make(map[string][]float64)
exposureCounts := make(map[string]int)
resultsChan := make(chan []RankedObject, r.numBatches)
var firstRunRemainderItems []Object
for i := 0; i < r.cfg.NumRuns; i++ {
r.rng.Shuffle(len(objects), func(i, j int) {
objects[i], objects[j] = objects[j], objects[i]
})
// Ensure remainder items from the first run are not in the remainder
// range in the second run
if i == 1 && len(firstRunRemainderItems) > 0 {
for {
remainderStart := r.numBatches * r.cfg.BatchSize
remainderItems := objects[remainderStart:]
conflictFound := false
for _, item := range remainderItems {
for _, firstRunItem := range firstRunRemainderItems {
if item.ID == firstRunItem.ID {
log.Printf("Conflicting remainder item found: %v, %v\n", item, firstRunItem)
conflictFound = true
break
}
}
if conflictFound {
break
}
}
if !conflictFound {
break
}
r.rng.Shuffle(len(objects), func(i, j int) {
objects[i], objects[j] = objects[j], objects[i]
})
}
}
// Split into groups of batchSize and process them concurrently
log.Printf("Round %d, Run %*d/%d: Submitting batches to API\n", r.round, len(strconv.Itoa(r.cfg.NumRuns)), i+1, r.cfg.NumRuns)
for j := 0; j < r.numBatches; j++ {
batch := objects[j*r.cfg.BatchSize : (j+1)*r.cfg.BatchSize]
go func(runNumber, batchNumber int, batch []Object) {
rankedBatch := r.rankObjects(batch, runNumber, batchNumber)
resultsChan <- rankedBatch
}(i+1, j+1, batch)
}
// Collect results from all batches
for j := 0; j < r.numBatches; j++ {
rankedBatch := <-resultsChan
for _, rankedObject := range rankedBatch {
scores[rankedObject.Object.ID] = append(scores[rankedObject.Object.ID], rankedObject.Score)
exposureCounts[rankedObject.Object.ID]++ // Update exposure count
}
}
// Save remainder items from the first run
if i == 0 {
remainderStart := r.numBatches * r.cfg.BatchSize
if remainderStart < len(objects) {
firstRunRemainderItems = make([]Object, len(objects[remainderStart:]))
copy(firstRunRemainderItems, objects[remainderStart:])
log.Printf("First run remainder items: %v\n", firstRunRemainderItems)
}
}
}
// Calculate average scores
finalScores := make(map[string]float64)
for id, scoreList := range scores {
var sum float64
for _, score := range scoreList {
sum += score
}
finalScores[id] = sum / float64(len(scoreList))
}
var results []FinalResult
for id, score := range finalScores {
for _, obj := range objects {
if obj.ID == id {
results = append(results, FinalResult{
Key: id,
Value: obj.Value,
Score: score,
Exposure: exposureCounts[id], // Include exposure count
})
break
}
}
}
sort.Slice(results, func(i, j int) bool {
return results[i].Score < results[j].Score
})
return results
}
func (r *Ranker) logTokenSizes(group []Object) {
log.Println("Logging token sizes for each object in the batch:")
for _, obj := range group {
tokenSize := r.estimateTokens([]Object{obj})
valuePreview := obj.Value
if len(valuePreview) > 100 {
valuePreview = valuePreview[:100]
}
log.Printf("Object ID: %s, Token Size: %d, Value Preview: %s", obj.ID, tokenSize, valuePreview)
}
}
const promptFmt = "id: `%s`\nvalue:\n```\n%s\n```\n\n"
// TODO: Merge these and clean them up.
var promptDisclaimer = fmt.Sprintf(
"\n\nREMEMBER to:\n"+
"- ALWAYS respond with the short %d-character ID of each item found above the value "+
"(i.e., I'll provide you with `id: <ID>` above the value, and you should respond with that same ID in your response)\n"+
"— NEVER respond with the actual value!\n"+
"— NEVER include backticks around IDs in your response!\n"+
"— NEVER include scores or a written reason/justification in your response!\n"+
"- Respond in RANKED DESCENDING order, where the FIRST item in your response is the MOST RELEVANT\n"+
"- Respond in JSON format, with the following schema:\n {\"objects\": [\"<ID1>\", \"<ID2>\", ...]}\n\n"+
"Here are the objects to be ranked:\n\n",
idLen,
)
const missingIDsStr = "Your last response was missing the following IDs: [%s]. " +
"Try again—and make ABSOLUTELY SURE to remember to:\n" +
"- ALWAYS return the IDs and NOT THE VALUES! " +
"- ALWAYS respond in JSON format as specified! " +
"- ALWAYS return ALL of the IDs in the list!" +
"- NEVER include backticks around IDs in your response!" +
"— NEVER include scores or a written reason/justification in your response!"
const invalidJSONStr = "Your last response was not valid JSON. Try again!"
func (r *Ranker) estimateTokens(group []Object) int {
prompt := r.cfg.InitialPrompt + promptDisclaimer
for _, obj := range group {
prompt += fmt.Sprintf(promptFmt, obj.ID, obj.Value)
}
if r.cfg.OllamaModel != "" {
// TODO: Update to use Ollama tokenize API when this PR is merged:
// https://github.com/ollama/ollama/pull/6586
return len(prompt) / 4
} else {
return len(r.encoding.Encode(prompt, nil, nil))
}
}
func (r *Ranker) rankObjects(group []Object, runNumber int, batchNumber int) []RankedObject {
prompt := r.cfg.InitialPrompt + promptDisclaimer
for _, obj := range group {
prompt += fmt.Sprintf(promptFmt, obj.ID, obj.Value)
}
if dryRun {
log.Printf("Dry run API call")
// Simulate a ranked response for dry run
var rankedObjects []RankedObject
for i, obj := range group {
rankedObjects = append(rankedObjects, RankedObject{
Object: obj,
Score: float64(i + 1), // Simulate scores based on position
})
}
return rankedObjects
}
var rankedResponse RankedObjectResponse
inputIDs := make(map[string]bool)
for _, obj := range group {
inputIDs[obj.ID] = true
}
if r.cfg.OllamaModel != "" {
rankedResponse = r.callOllama(prompt, runNumber, batchNumber, inputIDs)
} else {
rankedResponse = r.callOpenAI(prompt, runNumber, batchNumber, inputIDs)
}
// Assign scores based on position in the ranked list
var rankedObjects []RankedObject
for i, id := range rankedResponse.Objects {
for _, obj := range group {
if obj.ID == id {
rankedObjects = append(rankedObjects, RankedObject{
Object: obj,
Score: float64(i + 1), // Score based on position (1 for first, 2 for second, etc.)
})
break
}
}
}
return rankedObjects
}
type CustomTransport struct {
Transport http.RoundTripper
Headers http.Header
StatusCode int
Body []byte
}
func (t *CustomTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.Transport.RoundTrip(req)
if err != nil {
return nil, err
}
t.Headers = resp.Header
t.StatusCode = resp.StatusCode
t.Body, err = io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
resp.Body = io.NopCloser(bytes.NewBuffer(t.Body))
return resp, nil
}
// Updates the rankedResponse in place to fix case-insensitive ID mismatches.
// If any IDs are missing, returns the missing IDs along with an error.
// TODO: Also error on IDs in rankedResponse that are not in inputIDs. For example:
// Run 1/10, Batch 8/10: Missing IDs: [VkCMOyV9]
// Ollama API response: {"objects": ["5reULTRv", "KTJsPKHz", "eBFIaWo7", "AhqhnGsE", "Ug_hOxYp", "bWfMDUnE", "4sSg4Ojz", "VkJMOyV9", "UJ1-iMmW", "v6Puwf8K"]}
func validateIDs(rankedResponse *RankedObjectResponse, inputIDs map[string]bool) ([]string, error) {
// Create a map for case-insensitive ID matching
inputIDsLower := make(map[string]string)
for id := range inputIDs {
inputIDsLower[strings.ToLower(id)] = id
}
missingIDs := make(map[string]bool)
for id := range inputIDs {
missingIDs[id] = true
}
for i, id := range rankedResponse.Objects {
id = strings.ReplaceAll(id, "`", "")
lowerID := strings.ToLower(id)
if correctID, found := inputIDsLower[lowerID]; found {
if correctID != id {
// Replace the case-wrong match with the correct ID
rankedResponse.Objects[i] = correctID
}
delete(missingIDs, correctID)
}
}
if len(missingIDs) == 0 {
return nil, nil
} else {
missingIDsKeys := make([]string, 0, len(missingIDs))
for id := range missingIDs {
missingIDsKeys = append(missingIDsKeys, id)
}
return missingIDsKeys, fmt.Errorf("missing IDs: %s", strings.Join(missingIDsKeys, ", "))
}
}
func (r *Ranker) callOpenAI(prompt string, runNum int, batchNum int, inputIDs map[string]bool) RankedObjectResponse {
customTransport := &CustomTransport{Transport: http.DefaultTransport}
customClient := &http.Client{Transport: customTransport}
client := openai.NewClient(
option.WithAPIKey(r.cfg.OpenAIKey),
option.WithHTTPClient(customClient),
option.WithMaxRetries(5),
)
backoff := time.Second
conversationHistory := []openai.ChatCompletionMessageParamUnion{
openai.UserMessage(prompt),
}
var rankedResponse RankedObjectResponse
for {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
completion, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
Messages: openai.F(conversationHistory),
ResponseFormat: openai.F[openai.ChatCompletionNewParamsResponseFormatUnion](
openai.ResponseFormatJSONSchemaParam{
Type: openai.F(openai.ResponseFormatJSONSchemaTypeJSONSchema),
JSONSchema: openai.F(openai.ResponseFormatJSONSchemaJSONSchemaParam{
Name: openai.F("ranked_object_response"),
Description: openai.F("List of ranked object IDs"),
Schema: openai.F(RankedObjectResponseSchema),
Strict: openai.Bool(true),
}),
},
),
Model: openai.F(r.cfg.OpenAIModel),
})
if err == nil {
conversationHistory = append(conversationHistory,
openai.AssistantMessage(completion.Choices[0].Message.Content),
)
err = json.Unmarshal([]byte(completion.Choices[0].Message.Content), &rankedResponse)
if err != nil {
r.logFromApiCall(runNum, batchNum, fmt.Sprintf("Error unmarshalling response: %v\n", err))
conversationHistory = append(conversationHistory,
openai.UserMessage(invalidJSONStr),
)
trimmedContent := strings.TrimSpace(completion.Choices[0].Message.Content)
log.Printf("OpenAI API response: %s", trimmedContent)
continue
}
missingIDs, err := validateIDs(&rankedResponse, inputIDs)
if err != nil {
r.logFromApiCall(runNum, batchNum, fmt.Sprintf("Missing IDs: [%s]", strings.Join(missingIDs, ", ")))
conversationHistory = append(conversationHistory,
openai.UserMessage(fmt.Sprintf(missingIDsStr, strings.Join(missingIDs, ", "))),
)
trimmedContent := strings.TrimSpace(completion.Choices[0].Message.Content)
log.Printf("OpenAI API response: %s", trimmedContent)
continue
}
return rankedResponse
}
if err == context.DeadlineExceeded {
r.logFromApiCall(runNum, batchNum, "Context deadline exceeded, retrying...")
time.Sleep(backoff)
backoff *= 2
continue
}
if customTransport.StatusCode == http.StatusTooManyRequests {
for key, values := range customTransport.Headers {
if strings.HasPrefix(key, "X-Ratelimit") {
for _, value := range values {
r.logFromApiCall(runNum, batchNum, fmt.Sprintf("Rate limit header: %s: %s", key, value))
}
}
}
respBody := customTransport.Body
if respBody == nil {
r.logFromApiCall(runNum, batchNum, "Error reading response body: %v", "response body is nil")
} else {
r.logFromApiCall(runNum, batchNum, "Response body: %s", string(respBody))
}
remainingTokensStr := customTransport.Headers.Get("X-Ratelimit-Remaining-Tokens")
resetTokensStr := customTransport.Headers.Get("X-Ratelimit-Reset-Tokens")
remainingTokens, _ := strconv.Atoi(remainingTokensStr)
resetDuration, _ := time.ParseDuration(strings.Replace(resetTokensStr, "s", "s", 1))
r.logFromApiCall(runNum, batchNum, fmt.Sprintf("Rate limit exceeded. Suggested wait time: %v. Remaining tokens: %d", resetDuration, remainingTokens))
if resetDuration > 0 {
r.logFromApiCall(runNum, batchNum, fmt.Sprintf("Waiting for %v before retrying...", resetDuration))
time.Sleep(resetDuration)
} else {
r.logFromApiCall(runNum, batchNum, fmt.Sprintf("Waiting for %v before retrying...", backoff))
time.Sleep(backoff)
backoff *= 2
}
} else {
log.Fatalf("Run %*d/%d, Batch %*d/%d: Unexpected error: %v", len(strconv.Itoa(r.cfg.NumRuns)), runNum, r.cfg.NumRuns, len(strconv.Itoa(r.numBatches)), batchNum, r.numBatches, err)
}
}
}
func (r *Ranker) callOllama(prompt string, runNum int, batchNum int, inputIDs map[string]bool) RankedObjectResponse {
var rankedResponse RankedObjectResponse
// Initialize the conversation history with the initial prompt
conversationHistory := []map[string]interface{}{
{"role": "user", "content": prompt},
}
for {
requestBody, err := json.Marshal(map[string]interface{}{
"model": r.cfg.OllamaModel,
"stream": false,
"format": "json",
"num_ctx": r.cfg.BatchTokens,
"messages": conversationHistory,
})
if err != nil {
log.Fatalf("Error creating Ollama API request body: %v", err)
}
req, err := http.NewRequest("POST", r.cfg.OllamaAPIURL, bytes.NewReader(requestBody))
if err != nil {
log.Fatalf("Error creating Ollama API request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Fatalf("Error making request to Ollama API: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Fatalf("Ollama API returned an error: %v, body: %s", resp.StatusCode, body)
}
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatalf("Error reading Ollama API response body: %v", err)
}
var ollamaResponse struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
}
err = json.Unmarshal(responseBody, &ollamaResponse)
if err != nil {
log.Fatalf("Error parsing Ollama API response: %v", err)
}
conversationHistory = append(
conversationHistory,
map[string]interface{}{
"role": "assistant",
"content": ollamaResponse.Message.Content,
},
)
err = json.Unmarshal([]byte(ollamaResponse.Message.Content), &rankedResponse)
if err != nil {
r.logFromApiCall(runNum, batchNum, fmt.Sprintf("Error unmarshalling response: %v\n", err))
conversationHistory = append(conversationHistory,
map[string]interface{}{
"role": "user",
"content": invalidJSONStr,
},
)
trimmedContent := strings.TrimSpace(ollamaResponse.Message.Content)
log.Printf("Ollama API response: %s", trimmedContent)
continue
}
missingIDs, err := validateIDs(&rankedResponse, inputIDs)
if err != nil {
r.logFromApiCall(runNum, batchNum, fmt.Sprintf("Missing IDs: [%s]", strings.Join(missingIDs, ", ")))
conversationHistory = append(conversationHistory,
map[string]interface{}{
"role": "user",
"content": fmt.Sprintf(missingIDsStr, strings.Join(missingIDs, ", ")),
},
)
trimmedContent := strings.TrimSpace(ollamaResponse.Message.Content)
log.Printf("Ollama API response: %s", trimmedContent)
continue
}
return rankedResponse
}
}