Repository: tmc/langchaingo
Pull request #200 (2023-07-18 02:26:19)

feat(outputparser): Add Combining Output Parser
References:
- [langchain (python) combining output parser](https://github.com/hwchase17/langchain/blob/master/langchain/output_parsers/combining.py).

Changes:
- Adds the output parser combining.
- Small update to the regex parser instructions.

### 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 the 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 the 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.



Patch:
diff --git a/docs/parity_matrix.md b/docs/parity_matrix.md
index f209e533..2c0df583 100644
--- a/docs/parity_matrix.md
+++ b/docs/parity_matrix.md
@@ -81,7 +81,7 @@ Please note that this page lists the current state of the LangChain Go project,
 | Simple                      | ✅     |
 | Structured                  | ✅     |
 | Boolean                     | ❌     |
-| Combining Parsers           | ❌     |
+| Combining Parsers           | ✅     |
 | Regex                       | ✅     |
 | Regex Dictionary            | ❌     |
 | Comma separated list        | ✅     |
diff --git a/outputparser/combining.go b/outputparser/combining.go
new file mode 100644
index 00000000..325bd72f
--- /dev/null
+++ b/outputparser/combining.go
@@ -0,0 +1,98 @@
+package outputparser
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/tmc/langchaingo/schema"
+)
+
+// Combining is a parser that combines multiple parsers into one.
+type Combining struct {
+	Parsers []schema.OutputParser[any]
+}
+
+// NewCombining creates a new combining parser.
+func NewCombining(parsers []schema.OutputParser[any]) Combining {
+	p := make([]schema.OutputParser[any], len(parsers))
+
+	copy(p, parsers)
+
+	return Combining{
+		Parsers: p,
+	}
+}
+
+// Statically assert that Combining implements the OutputParser interface.
+var _ schema.OutputParser[any] = Combining{}
+
+// GetFormatInstructions returns the format instructions.
+func (p Combining) GetFormatInstructions() string {
+	text := "Your response will be a map of strings combining the output of parsers\n"
+	text += "using text delimited by two successive newline characters, to the respective parser.\n\n"
+	text += "The output parser instructions are:"
+
+	for _, parser := range p.Parsers {
+		text += "\n- " + parser.GetFormatInstructions()
+	}
+
+	return text
+}
+
+func (p Combining) parse(text string) (map[string]any, error) {
+	texts := strings.Split(text, "\n\n")
+	output := make(map[string]any)
+
+	if len(p.Parsers) <= 1 {
+		return nil, ParseError{
+			Text:   text,
+			Reason: fmt.Sprintf("Combining parser requires at least 2 parsers, got %d", len(p.Parsers)),
+		}
+	}
+
+	if len(texts) != len(p.Parsers) {
+		return nil, ParseError{
+			Text:   text,
+			Reason: fmt.Sprintf("Texts count (%d) does not match parsers count (%d)", len(texts), len(p.Parsers)),
+		}
+	}
+
+	for i, textChunk := range texts {
+		textChunk = strings.TrimSpace(textChunk)
+		parser := p.Parsers[i]
+
+		parsed, err := parser.Parse(textChunk)
+		if err != nil {
+			return nil, err
+		}
+
+		parsedMap, ok := parsed.(map[string]string)
+		if !ok {
+			return nil, ParseError{
+				Text:   textChunk,
+				Reason: fmt.Sprintf("Parser %d does not return a map of strings", parsed),
+			}
+		}
+
+		for k, result := range parsedMap {
+			output[k] = result
+		}
+	}
+
+	return output, nil
+}
+
+// Parse parses text delimited by two successive newline (`\n\n`) characters to the respective output parsers.
+func (p Combining) Parse(text string) (any, error) {
+	return p.parse(text)
+}
+
+// Parse with prompts does the same as Parse.
+func (p Combining) ParseWithPrompt(text string, _ schema.PromptValue) (any, error) {
+	return p.parse(text)
+}
+
+// Type returns the type of the parser.
+func (p Combining) Type() string {
+	return "combining_parser"
+}
diff --git a/outputparser/doc.go b/outputparser/doc.go
index 4590c323..cc6d9385 100644
--- a/outputparser/doc.go
+++ b/outputparser/doc.go
@@ -7,6 +7,7 @@ The outputparser package includes the following parsers:
   - Simple: a basic parser that returns the raw text as-is without any processing.
   - Structured: a parser that expects a JSON-formatted response and returns it as
     a map[string]string while validating against a provided schema.
+  - Combining: a parser that combines the output of multiple parsers into a single parser.
   - CommaSeparatedList: a parser that takes a string with comma-separated values
     and returns them as a string slice.
   - RegexParser: a parser that takes a string, compiles it into a regular expression,
diff --git a/outputparser/regex_parser.go b/outputparser/regex_parser.go
index f6e00259..0ca85fd8 100644
--- a/outputparser/regex_parser.go
+++ b/outputparser/regex_parser.go
@@ -30,7 +30,7 @@ var _ schema.OutputParser[any] = RegexParser{}
 // GetFormatInstructions returns instructions on the expected output format.
 func (p RegexParser) GetFormatInstructions() string {
 	instructions := "Your output should be a map of strings. e.g.:\n"
-	instructions += "map[string]string{\"key1\": \"value1\", \"key2\": \"value2\"}\n"
+	instructions += "map[string]string{\"key1\": \"value1\", \"key2\": \"value2\"}"
 
 	return instructions
 }
diff --git a/outputparser/structured.go b/outputparser/structured.go
index 7f68285a..c76bfe30 100644
--- a/outputparser/structured.go
+++ b/outputparser/structured.go
@@ -54,12 +54,12 @@ func NewStructured(schema []ResponseSchema) Structured {
 }
 
 // Statically assert that Structured implement the OutputParser interface.
-var _ schema.OutputParser[map[string]string] = Structured{}
+var _ schema.OutputParser[any] = 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) {
+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")
@@ -99,9 +99,13 @@ func (p Structured) Parse(text string) (map[string]string, error) {
 	return parsed, nil
 }
 
+func (p Structured) Parse(text string) (any, error) {
+	return p.parse(text)
+}
+
 // ParseWithPrompt does the same as Parse.
-func (p Structured) ParseWithPrompt(text string, _ schema.PromptValue) (map[string]string, error) {
-	return p.Parse(text)
+func (p Structured) ParseWithPrompt(text string, _ schema.PromptValue) (any, error) {
+	return p.parse(text)
 }
 
 // GetFormatInstructions returns a string explaining how the llm should format


Test patch:
diff --git a/outputparser/combining_test.go b/outputparser/combining_test.go
new file mode 100644
index 00000000..4f994c5f
--- /dev/null
+++ b/outputparser/combining_test.go
@@ -0,0 +1,92 @@
+package outputparser
+
+import (
+	"errors"
+	"reflect"
+	"testing"
+
+	"github.com/tmc/langchaingo/schema"
+)
+
+func TestCombine(t *testing.T) {
+	t.Parallel()
+
+	testText := "```" + `json
+{
+    "answer": "Paris",
+    "source": "https://en.wikipedia.org/wiki/France"
+}
+` + "```" + `
+
+//Confidence: A, Explanation: Paris is the capital of France according to Wikipedia.`
+
+	invalidText := "\n\n\n\n"
+
+	structuredParser := NewStructured(
+		[]ResponseSchema{
+			{Name: "answer", Description: "The answer to the question"},
+			{Name: "source", Description: "A link to the source"},
+		},
+	)
+
+	regexParser := NewRegexParser("Confidence: (?P<confidence>A|B|C), Explanation: (?P<explanation>.*)")
+
+	validParsers := []schema.OutputParser[any]{
+		structuredParser,
+		regexParser,
+	}
+
+	validOutput := map[string]any{
+		"answer":      "Paris",
+		"source":      "https://en.wikipedia.org/wiki/France",
+		"confidence":  "A",
+		"explanation": "Paris is the capital of France according to Wikipedia.",
+	}
+
+	invalidParsers := []schema.OutputParser[any]{
+		structuredParser,
+	}
+
+	testCases := []struct {
+		text     string
+		parsers  Combining
+		expected map[string]any
+		err      error
+	}{
+		{
+			text:     testText,
+			parsers:  NewCombining(validParsers),
+			expected: validOutput,
+			err:      nil,
+		},
+		{
+			text:     testText,
+			parsers:  NewCombining(invalidParsers),
+			expected: nil,
+			err: ParseError{
+				Text:   testText,
+				Reason: "Combining parser requires at least 2 parsers, got 1",
+			},
+		},
+		{
+			text:     invalidText,
+			parsers:  NewCombining(validParsers),
+			expected: nil,
+			err: ParseError{
+				Text:   invalidText,
+				Reason: "Texts count (3) does not match parsers count (2)",
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		actual, err := tc.parsers.Parse(tc.text)
+		if tc.err != nil && !errors.Is(tc.err, err) {
+			t.Errorf("Expected error %v, got %v", err, tc.err)
+		}
+
+		if !reflect.DeepEqual(actual, tc.expected) {
+			t.Errorf("Expected %v, got %v", tc.expected, actual)
+		}
+	}
+}