fix qr decoding, spool edit dialog, and opus 5 token budget
This commit is contained in:
+17
-2
@@ -161,8 +161,8 @@ func readOnce(img image.Image, auth Auth) (float64, string, string, error) {
|
||||
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"model": "claude-opus-4-8",
|
||||
"max_tokens": 600,
|
||||
"model": "claude-opus-5",
|
||||
"max_tokens": 4000,
|
||||
"messages": []map[string]interface{}{{
|
||||
"role": "user",
|
||||
"content": []map[string]interface{}{
|
||||
@@ -185,7 +185,9 @@ func readOnce(img image.Image, auth Auth) (float64, string, string, error) {
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
auth.apply(req)
|
||||
|
||||
endPost := trace("claude.post")
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
endPost()
|
||||
if err != nil {
|
||||
return 0, "", "", err
|
||||
}
|
||||
@@ -196,15 +198,28 @@ func readOnce(img image.Image, auth Auth) (float64, string, string, error) {
|
||||
}
|
||||
var parsed struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Usage struct {
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return 0, "", "", err
|
||||
}
|
||||
var text string
|
||||
var blocks []string
|
||||
for _, c := range parsed.Content {
|
||||
text += c.Text
|
||||
blocks = append(blocks, c.Type)
|
||||
}
|
||||
slog.Debug("claude reply", "stopReason", parsed.StopReason,
|
||||
"blocks", strings.Join(blocks, ","), "outputTokens", parsed.Usage.OutputTokens)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return 0, "", "", fmt.Errorf("empty reply (stopReason %q, blocks %q, outputTokens %d)",
|
||||
parsed.StopReason, strings.Join(blocks, ","), parsed.Usage.OutputTokens)
|
||||
}
|
||||
var wr struct {
|
||||
Weight float64 `json:"weight"`
|
||||
|
||||
@@ -219,8 +219,8 @@ func processImg(img image.Image, name string, auth claude.Auth, dryRun bool) res
|
||||
r.Error = fmt.Sprintf("scale is set to imperial units (%s); switch it to grams", reading.Unit)
|
||||
return r
|
||||
}
|
||||
if reading.Weight < 0 {
|
||||
r.Error = fmt.Sprintf("scale read a negative weight (%g g); refusing to update", reading.Weight)
|
||||
if reading.Weight <= 0 {
|
||||
r.Error = fmt.Sprintf("scale read a non-positive weight (%g g); refusing to update", reading.Weight)
|
||||
return r
|
||||
}
|
||||
r.Unit = reading.Unit
|
||||
@@ -234,8 +234,8 @@ func processImg(img image.Image, name string, auth claude.Auth, dryRun bool) res
|
||||
if info != nil {
|
||||
r.NewWeight.Spool = ptr(info.EmptySpoolGrams)
|
||||
r.NewWeight.Filament = ptr(reading.Weight - info.EmptySpoolGrams)
|
||||
if reading.Weight-info.EmptySpoolGrams < 0 {
|
||||
r.Error = fmt.Sprintf("measured total %g g is below the empty-spool weight %g g (negative filament); refusing to update", reading.Weight, info.EmptySpoolGrams)
|
||||
if reading.Weight-info.EmptySpoolGrams <= 0 {
|
||||
r.Error = fmt.Sprintf("measured total %g g leaves no filament above the empty-spool weight %g g; refusing to update", reading.Weight, info.EmptySpoolGrams)
|
||||
return r
|
||||
}
|
||||
}
|
||||
@@ -291,7 +291,12 @@ func spoolID(url string) string {
|
||||
}
|
||||
|
||||
func decodeQR(img image.Image) (string, error) {
|
||||
bmp, err := gozxing.NewBinaryBitmapFromImage(img)
|
||||
// gozxing's NewBinaryBitmapFromImage uses a HybridBinarizer, whose adaptive
|
||||
// per-block thresholding turns the paper texture of a full-resolution photo
|
||||
// into noise and loses the code. The global-histogram binarizer thresholds
|
||||
// the whole frame at once and reads these photos fine.
|
||||
src := gozxing.NewLuminanceSourceFromImage(img)
|
||||
bmp, err := gozxing.NewBinaryBitmap(gozxing.NewGlobalHistgramBinarizer(src))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
+80
-15
@@ -32,6 +32,9 @@ func trace(op string) func() {
|
||||
const (
|
||||
baseURL = "https://3dfilamentprofiles.com"
|
||||
challengeText = "Security Checkpoint"
|
||||
// browserLaunchTimeout bounds Chrome startup, which otherwise has no
|
||||
// deadline and would hang the caller indefinitely.
|
||||
browserLaunchTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// Client is a logged-in session against 3dfilamentprofiles.com. It owns a
|
||||
@@ -45,7 +48,6 @@ type Client struct {
|
||||
|
||||
type config struct {
|
||||
chromePath string
|
||||
headless bool
|
||||
}
|
||||
|
||||
// Option configures a Client.
|
||||
@@ -54,12 +56,9 @@ type Option func(*config)
|
||||
// WithChromePath sets an explicit Chrome/Chromium executable path.
|
||||
func WithChromePath(p string) Option { return func(c *config) { c.chromePath = p } }
|
||||
|
||||
// WithHeadful runs a visible browser window (useful for debugging).
|
||||
func WithHeadful() Option { return func(c *config) { c.headless = false } }
|
||||
|
||||
// New launches a browser and returns a Client. It does not log in yet.
|
||||
func New(opts ...Option) (*Client, error) {
|
||||
cfg := config{headless: true}
|
||||
var cfg config
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
@@ -74,12 +73,8 @@ func New(opts ...Option) (*Client, error) {
|
||||
// A real-looking UA; the default headless UA advertises "HeadlessChrome".
|
||||
chromedp.UserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"),
|
||||
chromedp.WindowSize(1280, 900),
|
||||
chromedp.Flag("headless", "new"),
|
||||
)
|
||||
if cfg.headless {
|
||||
execOpts = append(execOpts, chromedp.Flag("headless", "new"))
|
||||
} else {
|
||||
execOpts = append(execOpts, chromedp.Flag("headless", false))
|
||||
}
|
||||
|
||||
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), execOpts...)
|
||||
browserCtx, browserCancel := chromedp.NewContext(allocCtx, chromedp.WithErrorf(chromeErrorf))
|
||||
@@ -87,11 +82,22 @@ func New(opts ...Option) (*Client, error) {
|
||||
// Allocate the browser bound to the long-lived browserCtx. chromedp ties the
|
||||
// Chrome process lifetime to the context of the first Run, so this must not
|
||||
// be a short-lived per-call context (otherwise the browser dies after it).
|
||||
// That rules out a WithTimeout wrapper here, so bound the wait instead: if
|
||||
// Chrome never comes up we tear the whole thing down rather than hang.
|
||||
defer trace("spooldb.launchBrowser")()
|
||||
if err := chromedp.Run(browserCtx, chromedp.Navigate("about:blank")); err != nil {
|
||||
launched := make(chan error, 1)
|
||||
go func() { launched <- chromedp.Run(browserCtx, chromedp.Navigate("about:blank")) }()
|
||||
select {
|
||||
case err := <-launched:
|
||||
if err != nil {
|
||||
browserCancel()
|
||||
allocCancel()
|
||||
return nil, fmt.Errorf("start browser: %w", err)
|
||||
}
|
||||
case <-time.After(browserLaunchTimeout):
|
||||
browserCancel()
|
||||
allocCancel()
|
||||
return nil, fmt.Errorf("start browser: %w", err)
|
||||
return nil, fmt.Errorf("start browser: timed out after %s", browserLaunchTimeout)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
@@ -214,7 +220,7 @@ func (c *Client) openEdit(ctx context.Context, spoolID string) error {
|
||||
|
||||
const clickJS = `(() => {
|
||||
if (document.getElementById('weight_with_spool')) return 'open';
|
||||
const qr = document.querySelector('button[aria-label="barcode"]');
|
||||
const qr = document.querySelector('button[aria-label="barcode" i]');
|
||||
if (!qr) return 'loading';
|
||||
const group = qr.parentElement;
|
||||
const btns = [...group.querySelectorAll(':scope > button')];
|
||||
@@ -224,11 +230,16 @@ func (c *Client) openEdit(ctx context.Context, spoolID string) error {
|
||||
return 'clicked';
|
||||
})()`
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
lastState := "none"
|
||||
for poll := 0; time.Now().Before(deadline); poll++ {
|
||||
var state string
|
||||
if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(clickJS, &state)); err != nil {
|
||||
return err
|
||||
}
|
||||
if state != lastState {
|
||||
slog.Debug("openEdit poll", "spool", spoolID, "poll", poll, "state", state)
|
||||
}
|
||||
lastState = state
|
||||
switch state {
|
||||
case "open":
|
||||
return nil
|
||||
@@ -241,7 +252,61 @@ func (c *Client) openEdit(ctx context.Context, spoolID string) error {
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
return errors.New("spool edit dialog did not open")
|
||||
return fmt.Errorf("spool edit dialog did not open (last state %q; %s)", lastState, c.diagnose(ctx, spoolID))
|
||||
}
|
||||
|
||||
// diagnose captures why a page interaction failed: where the browser actually
|
||||
// ended up, which landmark elements are present, and a screenshot plus page text
|
||||
// written to temp files. It returns a one-line summary for the error message.
|
||||
func (c *Client) diagnose(ctx context.Context, spoolID string) string {
|
||||
const js = `(() => JSON.stringify({
|
||||
login: !!document.getElementById('email'),
|
||||
qr: !!document.querySelector('button[aria-label="barcode" i]'),
|
||||
dialog: !!document.getElementById('weight_with_spool'),
|
||||
buttons: [...document.querySelectorAll('button[aria-label]')].map(b => b.getAttribute('aria-label')).join(','),
|
||||
text: document.body ? document.body.innerText : '',
|
||||
}))()`
|
||||
|
||||
var loc, title, out string
|
||||
if err := c.run(ctx, 15*time.Second,
|
||||
chromedp.Location(&loc),
|
||||
chromedp.Title(&title),
|
||||
chromedp.Evaluate(js, &out),
|
||||
); err != nil {
|
||||
return fmt.Sprintf("diagnostics unavailable: %v", err)
|
||||
}
|
||||
var d struct {
|
||||
Login, QR, Dialog bool
|
||||
Buttons, Text string
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &d); err != nil {
|
||||
return fmt.Sprintf("diagnostics unparsable: %v", err)
|
||||
}
|
||||
|
||||
slog.Warn("spooldb: openEdit failed",
|
||||
"spool", spoolID, "url", loc, "title", title,
|
||||
"loginFormPresent", d.Login, "qrButtonPresent", d.QR, "dialogPresent", d.Dialog,
|
||||
"ariaButtons", d.Buttons)
|
||||
|
||||
stem := filepath.Join(os.TempDir(), "spooldb-fail-"+spoolID)
|
||||
if err := os.WriteFile(stem+".txt", []byte("url: "+loc+"\ntitle: "+title+"\n\n"+d.Text), 0o600); err == nil {
|
||||
slog.Warn("spooldb: wrote page text", "path", stem+".txt")
|
||||
}
|
||||
var shot []byte
|
||||
if err := c.run(ctx, 15*time.Second, chromedp.CaptureScreenshot(&shot)); err == nil {
|
||||
if err := os.WriteFile(stem+".png", shot, 0o600); err == nil {
|
||||
slog.Warn("spooldb: wrote screenshot", "path", stem+".png")
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case d.Login:
|
||||
return "browser is on the login page — session not established"
|
||||
case !d.QR:
|
||||
return "spool page never rendered (no barcode button); url " + loc
|
||||
default:
|
||||
return "clicked the edit button but the dialog never appeared; url " + loc
|
||||
}
|
||||
}
|
||||
|
||||
// SpoolInfo opens a spool's edit dialog and reads its location and weights.
|
||||
|
||||
Reference in New Issue
Block a user