Repository: tmc/langchaingo
Pull request #55 (2023-04-24 19:21:17)

output parsers: add structured and list
- Add structured output parser. The implementation is most similar to the python implementation: https://github.com/hwchase17/langchain/blob/master/langchain/output_parsers/structured.py
And does the same as from names and descriptions in the ts version: https://github.com/hwchase17/langchainjs/blob/main/langchain/src/output_parsers/structured.ts
- Add response schema struct used in the structured output parser. This is like the python version.
- Add comma separated list.
https://github.com/hwchase17/langchainjs/blob/main/langchain/src/output_parsers/list.ts
https://github.com/hwchase17/langchain/blob/master/langchain/output_parsers/list.py
- Add parse with prompt to interface to match python and ts version and increase flexibility.

### PR Checklist

- [x] Read the [Contributing documentation](https://github.com/tmc/langchaingo/blob/main/CONTRIBUTING.md).
- [x] Read the [Code of conduct documentation](https://github.com/tmc/langchaingo/blob/main/CODE_OF_CONDUCT.md).
- [x] Name your Pull Request title clearly, concisely, and prefixed with the name of primarily affected package you changed according to [Good commit messages](https://go.dev/doc/contribute#commit_messages) (such as `memory: add interfaces for X, Y` or `util: add whizzbang helpers`).
- [x] Check that there isn't already a PR that solves the problem the same way to avoid creating a duplicate.
- [x] Provide a description in this PR that addresses **what** the PR is solving, or reference the issue that it solves (e.g. `fixes #123`).
- [x] Describes source of new concepts.
- [x] References existing implementations as appropriate.
- [x] Contains test coverage for new functions.
- [x] Passes all [`golangci-lint`](https://golangci-lint.run/) checks.
- [x] (remove and do not use this row unless you are a trusted contributor) I am an existing
  contributor.


Patch:
diff --git a/exp/chains/llm_chain.go b/exp/chains/llm_chain.go
index e286ce27..d295025f 100644
--- a/exp/chains/llm_chain.go
+++ b/exp/chains/llm_chain.go
@@ -16,6 +16,7 @@ type LLMChain struct {
 	OutputParser output_parsers.OutputParser
 }
 
+
 func NewLLMChain(llm llms.LLM, prompt prompts.FormatPrompter) LLMChain {
 	chain := LLMChain{
 		prompt:       prompt,
diff --git a/exp/output_parsers/output_parsers.go b/exp/output_parsers/output_parsers.go
index 92d313b8..d0cac6dc 100644
--- a/exp/output_parsers/output_parsers.go
+++ b/exp/output_parsers/output_parsers.go
@@ -3,9 +3,9 @@ package output_parsers
 import "github.com/tmc/langchaingo/schema"
 
 type OutputParser interface {
-	GetFormatInstructions() string
 	Parse(text string) (any, error)
 	ParseWithPrompt(text string, prompt schema.PromptValue) (any, error)
+	GetFormatInstructions() string
 }
 
 type OutputParserException struct {
diff --git a/outputparser/comma_seperated_list.go b/outputparser/comma_seperated_list.go
new file mode 100644
index 00000000..7b8e9c7f
--- /dev/null
+++ b/outputparser/comma_seperated_list.go
@@ -0,0 +1,43 @@
+package outputparser
+
+import (
+	"strings"
+
+	"github.com/tmc/langchaingo/schema"
+)
+
+// CommaSeparatedList is an output parser used to parse the output of an llm as a
+// string slice. Splits in the output from the llm are done every comma.
+type CommaSeparatedList struct{}
+
+// NewCommaSeparatedList creates a new CommaSeparatedList.
+func NewCommaSeparatedList() CommaSeparatedList {
+	return CommaSeparatedList{}
+}
+
+// Statically assert that CommaSeparatedList implement the OutputParser interface.
+var _ schema.OutputParser[[]string] = CommaSeparatedList{}
+
+// GetFormatInstructions returns the format instruction.
+func (p CommaSeparatedList) GetFormatInstructions() string {
+	return "Your response should be a list of comma separated values, eg: `foo, bar, baz`"
+}
+
+// Parse parses the output of an llm into a string slice.
+func (p CommaSeparatedList) Parse(text string) ([]string, error) {
+	values := strings.Split(strings.TrimSpace(text), ",")
+	for i := 0; i < len(values); i++ {
+		values[i] = strings.TrimSpace(values[i])
+	}
+
+	return values, nil
+}
+
+// Parse with prompts does the same as Parse.
+func (p CommaSeparatedList) ParseWithPrompt(text string, _ schema.PromptValue) ([]string, error) {
+	return p.Parse(text)
+}
+
+func (p CommaSeparatedList) Type() string {
+	return "comma_separated_list_parser"
+}
diff --git a/outputparser/doc.go b/outputparser/doc.go
new file mode 100644
index 00000000..ec2086ce
--- /dev/null
+++ b/outputparser/doc.go
@@ -0,0 +1,2 @@
+// Package output parser contains structs for parsing the output of llms.
+package outputparser
diff --git a/outputparser/simple.go b/outputparser/simple.go
new file mode 100644
index 00000000..248cfa1b
--- /dev/null
+++ b/outputparser/simple.go
@@ -0,0 +1,17 @@
+package outputparser
+
+import "github.com/tmc/langchaingo/schema"
+
+// Simple is an output parser that does nothing.
+type Simple struct{}
+
+func NewSimple() Simple { return Simple{} }
+
+var _ schema.OutputParser[any] = Simple{}
+
+func (p Simple) GetFormatInstructions() string  { return "" }
+func (p Simple) Parse(text string) (any, error) { return text, nil }
+func (p Simple) ParseWithPrompt(text string, _ schema.PromptValue) (any, error) {
+	return text, nil
+}
+func (p Simple) Type() string { return "simple_parser" }
diff --git a/outputparser/structured.go b/outputparser/structured.go
new file mode 100644
index 00000000..8ecc784f
--- /dev/null
+++ b/outputparser/structured.go
@@ -0,0 +1,126 @@
+package outputparser
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/tmc/langchaingo/schema"
+)
+
+// ParseError is the error type returned by output parsers.
+type ParseError struct {
+	Text   string
+	Reason string
+}
+
+func (e ParseError) Error() string {
+	return fmt.Sprintf("parse text %s. %s", e.Text, e.Reason)
+}
+
+const (
+	// _structuredFormatInstructionTemplate is a template for the format
+	// instructions of the structured output parser.
+	_structuredFormatInstructionTemplate = "The output should be a markdown code snippet formatted in the following schema: \n```json\n{\n%s}\n```" //nolint
+
+	// _structuredLineTemplate is a single line of the json schema in the
+	// format instruction of the structured output parser. The fist verb is
+	// the name, the second verb is the type and the third is a description of
+	// what the field should contain.
+	_structuredLineTemplate = "\"%s\": %s // %s\n"
+)
+
+// ResponseSchema is struct used in the structured output parser to describe
+// how the llm should format it's response. Name is a key in the parsed
+// output map. Description is a description of what the value should contain.
+type ResponseSchema struct {
+	Name        string
+	Description string
+}
+
+// Structured is an output parser that parses the output of an llm into key value
+// pairs. The name and description of what values the output of the llm should
+// contain is stored in a list of response schema.
+type Structured struct {
+	ResponseSchemas []ResponseSchema
+}
+
+// NewStructured is a function that creates a new structured output parser from
+// a list of response schemas.
+func NewStructured(schema []ResponseSchema) Structured {
+	return Structured{
+		ResponseSchemas: schema,
+	}
+}
+
+// Statically assert that CommaSeparatedList implement the OutputParser interface.
+var _ schema.OutputParser[map[string]string] = Structured{}
+
+// Parse parses the output of an llm into a map. If the output of the llm doesn't
+// contain every filed specified in the response schemas, the function will return
+// an error.
+func (p Structured) Parse(text string) (map[string]string, error) {
+	// Remove the ```json that should be at the start of the text, and the ```
+	// that should be at the end of the text.
+	withoutJSONStart := strings.Split(text, "```json")
+	if !(len(withoutJSONStart) > 1) {
+		return nil, ParseError{Text: text, Reason: "no ```json at start of output"}
+	}
+
+	withoutJSONEnd := strings.Split(withoutJSONStart[1], "```")
+	if len(withoutJSONEnd) < 1 {
+		return nil, ParseError{Text: text, Reason: "no ``` at end of output"}
+	}
+
+	jsonString := withoutJSONEnd[0]
+
+	var parsed map[string]string
+	err := json.Unmarshal([]byte(jsonString), &parsed)
+	if err != nil {
+		return nil, err
+	}
+
+	// Validate that the parsed map contains all fields specified in the response
+	// schemas.
+	missingKeys := make([]string, 0)
+	for _, rs := range p.ResponseSchemas {
+		if _, ok := parsed[rs.Name]; !ok {
+			missingKeys = append(missingKeys, rs.Name)
+		}
+	}
+
+	if len(missingKeys) > 0 {
+		return nil, ParseError{
+			Text:   text,
+			Reason: fmt.Sprintf("output is missing the following fields %v", missingKeys),
+		}
+	}
+
+	return parsed, nil
+}
+
+// ParseWithPrompt does the same as Parse.
+func (p Structured) ParseWithPrompt(text string, _ schema.PromptValue) (map[string]string, error) {
+	return p.Parse(text)
+}
+
+// GetFormatInstructions returns a string explaining how the llm should format
+// it's response.
+func (p Structured) GetFormatInstructions() string {
+	jsonLines := ""
+	for _, rs := range p.ResponseSchemas {
+		jsonLines += "\t" + fmt.Sprintf(
+			_structuredLineTemplate,
+			rs.Name,
+			"string", /* type of the filed*/
+			rs.Description,
+		)
+	}
+
+	return fmt.Sprintf(_structuredFormatInstructionTemplate, jsonLines)
+}
+
+// Type returns the type of the output parser.
+func (p Structured) Type() string {
+	return "structured_parser"
+}
diff --git a/schema/output_parsers.go b/schema/output_parsers.go
index 817bb5a4..d1c1cba5 100644
--- a/schema/output_parsers.go
+++ b/schema/output_parsers.go
@@ -4,6 +4,8 @@ package schema
 type OutputParser[T any] interface {
 	// Parse parses the output of an LLM call.
 	Parse(text string) (T, error)
+	// ParseWithPrompt parses the output of an LLM call with the prompt used.
+	ParseWithPrompt(text string, prompt PromptValue) (T, error)
 	// GetFormatInstructions returns a string describing the format of the output.
 	GetFormatInstructions() string
 	// Type returns the string type key uniquely identifying this class of parser


Test patch:
diff --git a/outputparser/comma_seperated_list_test.go b/outputparser/comma_seperated_list_test.go
new file mode 100644
index 00000000..ce52d7b3
--- /dev/null
+++ b/outputparser/comma_seperated_list_test.go
@@ -0,0 +1,38 @@
+package outputparser_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/tmc/langchaingo/outputparser"
+)
+
+func TestCommaSeparatedList(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		input    string
+		expected []string
+	}{
+		{
+			input:    "foo, bar, baz",
+			expected: []string{"foo", "bar", "baz"},
+		},
+		{
+			input:    "	foo, bar  , b az ",
+			expected: []string{"foo", "bar", "b az"},
+		},
+		{
+			input:    " foo bar  , baz",
+			expected: []string{"foo bar", "baz"},
+		},
+	}
+
+	parser := outputparser.NewCommaSeparatedList()
+
+	for _, tc := range testCases {
+		output, err := parser.Parse(tc.input)
+		assert.NoError(t, err)
+		assert.Equal(t, tc.expected, output)
+	}
+}
diff --git a/outputparser/structured_test.go b/outputparser/structured_test.go
new file mode 100644
index 00000000..4a089a86
--- /dev/null
+++ b/outputparser/structured_test.go
@@ -0,0 +1,71 @@
+package outputparser
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestStructured(t *testing.T) {
+	t.Parallel()
+
+	type test struct {
+		name              string
+		responseSchema    []ResponseSchema
+		llmOutput         string
+		formatInstruction string
+		parsed            map[string]string
+		expectError       bool
+	}
+
+	testCases := []test{
+		{
+			name: "Single",
+			responseSchema: []ResponseSchema{
+				{Name: "url", Description: "A link to the resource"},
+			},
+			llmOutput:         "```json\n{\n\t\"url\": \"https://google.com\" \n}\n```",
+			formatInstruction: "The output should be a markdown code snippet formatted in the following schema: \n```json\n{\n\t\"url\": string // A link to the resource\n}\n```", //nolint
+			parsed:            map[string]string{"url": "https://google.com"},
+			expectError:       false,
+		},
+		{
+			name: "Double",
+			responseSchema: []ResponseSchema{
+				{Name: "answer", Description: "The answer to the question"},
+				{Name: "source", Description: "A link to the source"},
+			},
+			llmOutput:         " ``` foo```json \n{\n\t\"answer\": \"Paris\",\n\t\"source\": \"https://google.com\" \n}\n``` ``` bar zoo",                                                                                               //nolint
+			formatInstruction: "The output should be a markdown code snippet formatted in the following schema: \n```json\n{\n\t\"answer\": string // The answer to the question\n\t\"source\": string // A link to the source\n}\n```", //nolint
+			parsed:            map[string]string{"answer": "Paris", "source": "https://google.com"},
+			expectError:       false,
+		},
+		{
+			name: "MissingKey",
+			responseSchema: []ResponseSchema{
+				{Name: "answer", Description: "The answer to the question"},
+				{Name: "source", Description: "A link to the source"},
+			},
+			llmOutput:         "```json \n{\n\t\"source\": \"https://google.com\" \n}\n```",
+			formatInstruction: "The output should be a markdown code snippet formatted in the following schema: \n```json\n{\n\t\"answer\": string // The answer to the question\n\t\"source\": string // A link to the source\n}\n```", //nolint
+			parsed:            nil,
+			expectError:       true,
+		},
+	}
+
+	for _, tc := range testCases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			parser := NewStructured(tc.responseSchema)
+			assert.Equal(t, tc.formatInstruction, parser.GetFormatInstructions())
+
+			parsed, err := parser.Parse(tc.llmOutput)
+			if (err != nil) != tc.expectError {
+				t.Fatalf("expected error: %v, got: %v", tc.expectError, err)
+			}
+
+			assert.Equal(t, parsed, tc.parsed)
+		})
+	}
+}