236 lines
7.2 KiB
Go
236 lines
7.2 KiB
Go
package extraction
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/kaptinlin/jsonrepair"
|
||
|
|
)
|
||
|
|
|
||
|
|
var ErrModelAccountOverdue = errors.New("model account overdue")
|
||
|
|
|
||
|
|
// A whole memoir needs more output space than a single extraction result.
|
||
|
|
// Keep its limit separate so ordinary chat extraction retains its existing cap.
|
||
|
|
func NewArkJSONGenerator(baseURL, apiKey, model string, timeout time.Duration, maxTokens int) (*ArkGenerator, error) {
|
||
|
|
if maxTokens < 256 || maxTokens > 262144 {
|
||
|
|
return nil, errors.New("invalid structured output limit")
|
||
|
|
}
|
||
|
|
g, err := NewArkGenerator(baseURL, apiKey, model, timeout)
|
||
|
|
if err == nil {
|
||
|
|
g.jsonMaxTokens = maxTokens
|
||
|
|
}
|
||
|
|
return g, err
|
||
|
|
}
|
||
|
|
|
||
|
|
// GenerateJSON reuses the configured tenant model for an explicitly separate
|
||
|
|
// structured task. The caller validates schema, provenance and authorization.
|
||
|
|
func (g *ArkGenerator) GenerateJSON(ctx context.Context, content, prompt string) (json.RawMessage, error) {
|
||
|
|
return g.generateJSON(ctx, content, prompt, nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Schema mode keeps long book prose inside correctly escaped paragraph arrays.
|
||
|
|
func (g *ArkGenerator) GenerateJSONSchema(ctx context.Context, content, prompt string, schema json.RawMessage) (json.RawMessage, error) {
|
||
|
|
if !json.Valid(schema) {
|
||
|
|
return nil, errors.New("invalid structured output schema")
|
||
|
|
}
|
||
|
|
return g.generateJSON(ctx, content, prompt, schema)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *ArkGenerator) generateJSON(ctx context.Context, content, prompt string, schema json.RawMessage) (json.RawMessage, error) {
|
||
|
|
if strings.TrimSpace(content) == "" || len(content) > 2<<20 {
|
||
|
|
return nil, errors.New("invalid structured input")
|
||
|
|
}
|
||
|
|
maxTokens := g.jsonMaxTokens
|
||
|
|
if maxTokens == 0 {
|
||
|
|
maxTokens = 8192
|
||
|
|
}
|
||
|
|
payload := map[string]any{"model": g.model, "messages": []map[string]string{{"role": "system", "content": prompt}, {"role": "user", "content": content}}, "temperature": 0, "max_tokens": maxTokens}
|
||
|
|
if g.provider == "ark" {
|
||
|
|
payload["thinking"] = map[string]string{"type": "disabled"}
|
||
|
|
}
|
||
|
|
if schema != nil {
|
||
|
|
payload["response_format"] = map[string]any{"type": "json_schema", "json_schema": map[string]any{"name": "memoir_book", "strict": true, "schema": schema}}
|
||
|
|
} else if providerSupportsJSONMode(g.provider) {
|
||
|
|
payload["response_format"] = map[string]string{"type": "json_object"}
|
||
|
|
}
|
||
|
|
raw, e := json.Marshal(payload)
|
||
|
|
if e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
req, e := http.NewRequestWithContext(ctx, "POST", g.endpoint, bytes.NewReader(raw))
|
||
|
|
if e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
req.Header.Set("Authorization", "Bearer "+g.apiKey)
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
resp, e := g.client.Do(req)
|
||
|
|
if e != nil {
|
||
|
|
return nil, errors.New("structured model unavailable")
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
|
|
var failure struct {
|
||
|
|
Error struct {
|
||
|
|
Code string `json:"code"`
|
||
|
|
} `json:"error"`
|
||
|
|
}
|
||
|
|
_ = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&failure)
|
||
|
|
if failure.Error.Code == "AccountOverdueError" {
|
||
|
|
return nil, ErrModelAccountOverdue
|
||
|
|
}
|
||
|
|
return nil, errors.New("structured model request rejected")
|
||
|
|
}
|
||
|
|
var out struct {
|
||
|
|
Choices []struct {
|
||
|
|
Message struct {
|
||
|
|
Content string `json:"content"`
|
||
|
|
} `json:"message"`
|
||
|
|
FinishReason string `json:"finish_reason"`
|
||
|
|
} `json:"choices"`
|
||
|
|
}
|
||
|
|
if e = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); e != nil || len(out.Choices) != 1 || out.Choices[0].FinishReason == "length" {
|
||
|
|
return nil, errors.New("structured model result incomplete")
|
||
|
|
}
|
||
|
|
result := strings.TrimSpace(extractJSONObject(out.Choices[0].Message.Content))
|
||
|
|
// Some book models emit prose with an unescaped quotation mark or newline,
|
||
|
|
// even when schema output is requested. Repair syntax locally; the book
|
||
|
|
// validator still requires every original story ID and its unchanged date.
|
||
|
|
// A length-truncated completion was already rejected above.
|
||
|
|
if schema != nil && !json.Valid([]byte(result)) {
|
||
|
|
result = repairSchemaArrayBraces(result, schema)
|
||
|
|
if !json.Valid([]byte(result)) {
|
||
|
|
if repaired, err := jsonrepair.Repair(result); err == nil && json.Valid([]byte(repaired)) {
|
||
|
|
result = repaired
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !json.Valid([]byte(result)) {
|
||
|
|
var parsed any
|
||
|
|
parseErr := json.Unmarshal([]byte(result), &parsed)
|
||
|
|
return json.RawMessage(result), fmt.Errorf("structured model result invalid (content bytes=%d, extracted bytes=%d, finish=%s): %w", len(out.Choices[0].Message.Content), len(result), out.Choices[0].FinishReason, parseErr)
|
||
|
|
}
|
||
|
|
return json.RawMessage(result), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// The Seed book endpoint can surround array values with braces, for example
|
||
|
|
// "paragraphs":{"first paragraph","second paragraph"}. The schema gives an
|
||
|
|
// unambiguous repair: change only those container delimiters, keeping every
|
||
|
|
// byte of prose, identifier and date. Braces inside strings remain untouched.
|
||
|
|
func repairSchemaArrayBraces(input string, schema json.RawMessage) string {
|
||
|
|
var root map[string]any
|
||
|
|
if json.Unmarshal(schema, &root) != nil {
|
||
|
|
return input
|
||
|
|
}
|
||
|
|
arrays := map[string]bool{}
|
||
|
|
var visit func(map[string]any)
|
||
|
|
visit = func(n map[string]any) {
|
||
|
|
if properties, ok := n["properties"].(map[string]any); ok {
|
||
|
|
for key, value := range properties {
|
||
|
|
if child, ok := value.(map[string]any); ok {
|
||
|
|
if child["type"] == "array" {
|
||
|
|
arrays[key] = true
|
||
|
|
}
|
||
|
|
visit(child)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if items, ok := n["items"].(map[string]any); ok {
|
||
|
|
visit(items)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
visit(root)
|
||
|
|
output := []byte(input)
|
||
|
|
opens := map[int]bool{}
|
||
|
|
missingOpens := map[int]bool{}
|
||
|
|
stack := []bool{}
|
||
|
|
space := func(b byte) bool { return b == ' ' || b == '\n' || b == '\r' || b == '\t' }
|
||
|
|
for i := 0; i < len(input); i++ {
|
||
|
|
if input[i] == '"' {
|
||
|
|
start := i
|
||
|
|
for i++; i < len(input); i++ {
|
||
|
|
if input[i] == '\\' {
|
||
|
|
i++
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if input[i] == '"' {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if i >= len(input) {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
var key string
|
||
|
|
if json.Unmarshal([]byte(input[start:i+1]), &key) == nil && arrays[key] {
|
||
|
|
j := i + 1
|
||
|
|
for j < len(input) && space(input[j]) {
|
||
|
|
j++
|
||
|
|
}
|
||
|
|
if j < len(input) && input[j] == ':' {
|
||
|
|
j++
|
||
|
|
for j < len(input) && space(input[j]) {
|
||
|
|
j++
|
||
|
|
}
|
||
|
|
if j < len(input) && input[j] == '{' {
|
||
|
|
opens[j] = true
|
||
|
|
}
|
||
|
|
// Also repair a missing opening bracket in a string sequence:
|
||
|
|
// "paragraphs":"first","second"]. Its closing ] must exist.
|
||
|
|
if j < len(input) && input[j] == '"' {
|
||
|
|
quoted := false
|
||
|
|
for k := j; k < len(input); k++ {
|
||
|
|
if input[k] == '\\' && quoted {
|
||
|
|
k++
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if input[k] == '"' {
|
||
|
|
quoted = !quoted
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if !quoted && input[k] == '}' {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
if !quoted && input[k] == ']' {
|
||
|
|
missingOpens[j] = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if input[i] == '{' {
|
||
|
|
array := opens[i]
|
||
|
|
stack = append(stack, array)
|
||
|
|
if array {
|
||
|
|
output[i] = '['
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if input[i] == '}' && len(stack) > 0 {
|
||
|
|
array := stack[len(stack)-1]
|
||
|
|
stack = stack[:len(stack)-1]
|
||
|
|
if array {
|
||
|
|
output[i] = ']'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(missingOpens) == 0 {
|
||
|
|
return string(output)
|
||
|
|
}
|
||
|
|
var repaired strings.Builder
|
||
|
|
for i, b := range output {
|
||
|
|
if missingOpens[i] {
|
||
|
|
repaired.WriteByte('[')
|
||
|
|
}
|
||
|
|
repaired.WriteByte(b)
|
||
|
|
}
|
||
|
|
return repaired.String()
|
||
|
|
}
|