repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

10.1 KB · 341 lines · Go Raw History
  1// Files a turn can carry.
  2//
  3// Nothing is kept. A file arrives with the turn, is read into text, and the
  4// bytes are dropped, so there is no upload directory to age out and incognito
  5// stays true to its name. What persists is the extracted text, which is already
  6// in the conversation, so a follow up question about a file works without the
  7// file being sent again.
  8package main
  9
 10import (
 11	"bytes"
 12	"context"
 13	"errors"
 14	"fmt"
 15	"io"
 16	"mime/multipart"
 17	"os"
 18	"os/exec"
 19	"path/filepath"
 20	"strings"
 21	"time"
 22	"unicode/utf8"
 23
 24	"github.com/ledongthuc/pdf"
 25)
 26
 27const (
 28	maxFiles      = 10
 29	maxFileBytes  = 20 << 20
 30	maxFileChars  = 40000
 31	maxTotalChars = 80000
 32)
 33
 34// Attachment is what the UI shows on the message and what the database keeps.
 35// The text itself is not here, it is folded into the message content.
 36type Attachment struct {
 37	Name  string `json:"name"`
 38	Size  int64  `json:"size"`
 39	Kind  string `json:"kind"`
 40	Chars int    `json:"chars,omitempty"`
 41	Err   string `json:"err,omitempty"`
 42}
 43
 44type filePart struct {
 45	Attachment
 46	Text string
 47}
 48
 49// readFiles turns the uploaded parts into text. A file that cannot be read is
 50// not an error for the turn: it comes back with Err set and the model is told
 51// which files it did not get, which is better than refusing the whole message
 52// because one of five was a screenshot.
 53func readFiles(headers []*multipart.FileHeader) []filePart {
 54	if len(headers) > maxFiles {
 55		headers = headers[:maxFiles]
 56	}
 57	out := make([]filePart, 0, len(headers))
 58	budget := maxTotalChars
 59	for _, h := range headers {
 60		p := filePart{Attachment: Attachment{Name: filepath.Base(h.Filename), Size: h.Size}}
 61		switch {
 62		case h.Size > maxFileBytes:
 63			p.Kind, p.Err = "skipped", fmt.Sprintf("larger than %s", humanSize(maxFileBytes))
 64		default:
 65			text, kind, err := extract(h)
 66			p.Kind = kind
 67			if err != nil {
 68				p.Err = err.Error()
 69			} else {
 70				if len(text) > maxFileChars {
 71					text = text[:maxFileChars] + fmt.Sprintf("\n\n[truncated, this is the first %d characters of %d]", maxFileChars, len(text))
 72				}
 73				if len(text) > budget {
 74					text = text[:budget] + "\n\n[truncated, the turn ran out of room for the rest]"
 75				}
 76				budget -= len(text)
 77				if budget < 0 {
 78					budget = 0
 79				}
 80				p.Text, p.Chars = text, len(text)
 81			}
 82		}
 83		out = append(out, p)
 84	}
 85	return out
 86}
 87
 88func extract(h *multipart.FileHeader) (string, string, error) {
 89	f, err := h.Open()
 90	if err != nil {
 91		return "", "unreadable", err
 92	}
 93	defer f.Close()
 94	raw, err := io.ReadAll(io.LimitReader(f, maxFileBytes))
 95	if err != nil {
 96		return "", "unreadable", err
 97	}
 98	if strings.EqualFold(filepath.Ext(h.Filename), ".pdf") || bytes.HasPrefix(raw, []byte("%PDF-")) {
 99		text, err := pdfText(raw)
100		return text, "pdf", err
101	}
102	if bytes.HasPrefix(raw, []byte("PK\x03\x04")) {
103		text, kind, err := officeText(raw)
104		if err == nil || kind != "" {
105			return text, kind, err
106		}
107		return "", "zip", fmt.Errorf("an archive cannot be read, send the files inside it instead")
108	}
109	if kind, ok := imageKind(raw); ok {
110		return "", kind, fmt.Errorf("this model reads text only, so an image cannot be looked at")
111	}
112	if !looksTextual(raw) {
113		return "", "binary", fmt.Errorf("not a text file, so there is nothing to read out of it")
114	}
115	return normalise(string(raw)), textKind(h.Filename), nil
116}
117
118// pdfText tries poppler first and the pure Go reader second.
119//
120// pdftotext is here because the Go reader fails on the pdfs that matter most. A
121// bank statement is normally encrypted with an empty owner password, which it
122// will not open at all, and even when it does open one it returns the words in
123// object order with no column alignment, so a statement comes out as a list of
124// numbers with nothing saying which row they belonged to. -layout keeps the
125// columns, which is the difference between a table and a pile of figures.
126func pdfText(raw []byte) (string, error) {
127	if out, err := popplerText(raw); err == nil {
128		return out, nil
129	} else if !errors.Is(err, errNoPoppler) {
130		// Poppler ran and could not read it, so the Go reader will not do
131		// better. Its own message is the useful one.
132		return "", err
133	}
134	return goPDFText(raw)
135}
136
137var errNoPoppler = errors.New("pdftotext is not installed")
138
139func popplerText(raw []byte) (string, error) {
140	bin, err := exec.LookPath("pdftotext")
141	if err != nil {
142		return "", errNoPoppler
143	}
144	dir, err := os.MkdirTemp("", "pdf")
145	if err != nil {
146		return "", errNoPoppler
147	}
148	defer os.RemoveAll(dir)
149	in := filepath.Join(dir, "in.pdf")
150	if err := os.WriteFile(in, raw, 0o600); err != nil {
151		return "", errNoPoppler
152	}
153
154	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
155	defer cancel()
156	// -layout keeps the columns, -nopgbrk drops the form feeds, and the empty
157	// -opw is what opens a statement encrypted with no password set.
158	cmd := exec.CommandContext(ctx, bin, "-layout", "-nopgbrk", "-enc", "UTF-8", "-opw", "", in, "-")
159	var out, errBuf bytes.Buffer
160	cmd.Stdout, cmd.Stderr = &out, &errBuf
161	if err := cmd.Run(); err != nil {
162		if ctx.Err() != nil {
163			return "", fmt.Errorf("this pdf took too long to read")
164		}
165		// poppler's exit codes are specific and its stderr wording is not, so
166		// the code decides and the text only ever adds detail.
167		var ee *exec.ExitError
168		if errors.As(err, &ee) {
169			switch ee.ExitCode() {
170			case 1:
171				return "", fmt.Errorf("this pdf could not be opened, it may be damaged")
172			case 3:
173				return "", fmt.Errorf("this pdf is locked against copying text")
174			}
175		}
176		if msg := strings.TrimSpace(errBuf.String()); msg != "" {
177			return "", fmt.Errorf("this pdf could not be read: %s", firstLine(msg))
178		}
179		return "", fmt.Errorf("this pdf could not be read")
180	}
181	text := normalise(out.String())
182	if strings.TrimSpace(text) == "" {
183		return "", fmt.Errorf("this pdf has no text in it, it is probably a scan and would need OCR")
184	}
185	return text, nil
186}
187
188// goPDFText is the fallback for a development run outside the container, where
189// poppler is not on the path. It recovers rather than propagating a panic,
190// because the reader panics on a malformed cross reference table instead of
191// returning an error.
192func goPDFText(raw []byte) (text string, err error) {
193	defer func() {
194		if r := recover(); r != nil {
195			text, err = "", fmt.Errorf("this pdf could not be parsed")
196		}
197	}()
198	r, err := pdf.NewReader(bytes.NewReader(raw), int64(len(raw)))
199	if err != nil {
200		return "", fmt.Errorf("this pdf could not be parsed")
201	}
202	rd, err := r.GetPlainText()
203	if err != nil {
204		return "", fmt.Errorf("this pdf has no extractable text, it is probably scanned")
205	}
206	var sb strings.Builder
207	if _, err := io.Copy(&sb, io.LimitReader(rd, 8<<20)); err != nil {
208		return "", fmt.Errorf("this pdf could not be read to the end")
209	}
210	out := normalise(sb.String())
211	if strings.TrimSpace(out) == "" {
212		return "", fmt.Errorf("this pdf has no extractable text, it is probably scanned")
213	}
214	return out, nil
215}
216
217// looksTextual decides by content and not by extension, so an unknown suffix on
218// a config file still reads and a .txt holding a binary blob still does not.
219func looksTextual(raw []byte) bool {
220	if len(raw) == 0 {
221		return false
222	}
223	head := raw
224	if len(head) > 8192 {
225		head = head[:8192]
226	}
227	if bytes.IndexByte(head, 0) >= 0 || !utf8.Valid(head) {
228		return false
229	}
230	odd := 0
231	for _, b := range head {
232		if b < 0x09 || (b > 0x0d && b < 0x20) {
233			odd++
234		}
235	}
236	return odd*20 < len(head)
237}
238
239func imageKind(raw []byte) (string, bool) {
240	switch {
241	case bytes.HasPrefix(raw, []byte("\x89PNG\r\n\x1a\n")):
242		return "png", true
243	case bytes.HasPrefix(raw, []byte{0xff, 0xd8, 0xff}):
244		return "jpeg", true
245	case bytes.HasPrefix(raw, []byte("GIF8")):
246		return "gif", true
247	case bytes.HasPrefix(raw, []byte("RIFF")) && len(raw) > 12 && bytes.Equal(raw[8:12], []byte("WEBP")):
248		return "webp", true
249	case bytes.HasPrefix(raw, []byte("BM")):
250		return "bmp", true
251	}
252	return "", false
253}
254
255func textKind(name string) string {
256	ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
257	if ext == "" {
258		return "text"
259	}
260	return ext
261}
262
263// normalise strips carriage returns and the byte order mark Windows editors
264// leave at the front, both of which reach the model as noise it has to spend
265// attention on.
266func normalise(s string) string {
267	s = strings.TrimPrefix(s, "\ufeff")
268	return strings.ReplaceAll(s, "\r\n", "\n")
269}
270
271// composeTurn builds what the model is handed. Files come first and the message
272// last, since the question is what the answer has to follow and the end of the
273// prompt is where a small model reads most carefully.
274func composeTurn(message string, parts []filePart) string {
275	if len(parts) == 0 {
276		return message
277	}
278	var sb strings.Builder
279	sb.WriteString("The user attached ")
280	if len(parts) == 1 {
281		sb.WriteString("a file. Its contents are below.\n\n")
282	} else {
283		fmt.Fprintf(&sb, "%d files. Their contents are below.\n\n", len(parts))
284	}
285	for _, p := range parts {
286		if p.Err != "" {
287			fmt.Fprintf(&sb, "--- %s (%s): not readable, %s ---\n\n", p.Name, humanSize(p.Size), p.Err)
288			continue
289		}
290		fmt.Fprintf(&sb, "--- %s (%s, %s) ---\n%s\n--- end of %s ---\n\n", p.Name, p.Kind, humanSize(p.Size), p.Text, p.Name)
291	}
292	sb.WriteString("The user's message about them:\n\n")
293	if strings.TrimSpace(message) == "" {
294		sb.WriteString("Read these and say what they are and what is in them.")
295	} else {
296		sb.WriteString(message)
297	}
298	return sb.String()
299}
300
301func attachments(parts []filePart) []Attachment {
302	out := make([]Attachment, 0, len(parts))
303	for _, p := range parts {
304		out = append(out, p.Attachment)
305	}
306	return out
307}
308
309func humanSize(n int64) string {
310	switch {
311	case n < 1024:
312		return fmt.Sprintf("%d B", n)
313	case n < 1024*1024:
314		return fmt.Sprintf("%.1f KB", float64(n)/1024)
315	}
316	return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
317}
318
319// titleSeed keeps the text of a file out of the conversation title. A turn that
320// is only attachments is named after them instead.
321func titleSeed(message string, parts []filePart) string {
322	if strings.TrimSpace(message) != "" {
323		return message
324	}
325	if len(parts) == 0 {
326		return ""
327	}
328	names := make([]string, 0, len(parts))
329	for _, p := range parts {
330		names = append(names, p.Name)
331	}
332	return "A message attaching " + strings.Join(names, ", ")
333}
334
335func firstLine(s string) string {
336	if i := strings.IndexByte(s, '\n'); i >= 0 {
337		return s[:i]
338	}
339	return s
340}