Files

236 lines
7.2 KiB
Go
Raw Permalink Normal View History

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()
}