|
9 | 9 | "log" |
10 | 10 | "net/http" |
11 | 11 | "os" |
| 12 | + "regexp" |
| 13 | + "strconv" |
12 | 14 | "strings" |
13 | 15 | "sync" |
14 | 16 |
|
@@ -65,6 +67,8 @@ const defaultOpenAIModel = "gpt-4o-mini" |
65 | 67 | const maxTemplateGuidanceChars = 8000 |
66 | 68 | const defaultLLMMaxConcurrentBatches = 4 |
67 | 69 |
|
| 70 | +var confidenceFieldRe = regexp.MustCompile(`("confidence"\s*:\s*)([^,}\]]+)`) |
| 71 | + |
68 | 72 | func llmBatchConcurrencyLimit(totalBatches int) int { |
69 | 73 | if totalBatches <= 0 { |
70 | 74 | return 1 |
@@ -295,7 +299,7 @@ Also: |
295 | 299 | - choose normalized_status from: done, in testing, in progress, other |
296 | 300 | - extract ticket IDs if present (e.g. [1247202] or bare ticket numbers) |
297 | 301 | - if this item is the same underlying work as an existing item, set duplicate_of to that existing key (Kxx); otherwise empty string |
298 | | -- set confidence between 0 and 1. |
| 302 | +- set confidence between 0 and 1, using digits only (example: 0.91). Never spell out numbers. |
299 | 303 | %s%s |
300 | 304 |
|
301 | 305 | Respond with JSON only (no markdown): |
@@ -362,7 +366,14 @@ func parseSectionClassifiedResponse(responseText string) (map[int64]LLMSectionDe |
362 | 366 |
|
363 | 367 | var classified []sectionClassifiedItem |
364 | 368 | if err := json.Unmarshal([]byte(responseText), &classified); err != nil { |
365 | | - return nil, fmt.Errorf("parsing LLM section response: %w (response: %s)", err, responseText) |
| 369 | + repaired := repairSectionClassifiedResponse(responseText) |
| 370 | + if repaired == responseText { |
| 371 | + return nil, fmt.Errorf("parsing LLM section response: %w (response: %s)", err, responseText) |
| 372 | + } |
| 373 | + if repairErr := json.Unmarshal([]byte(repaired), &classified); repairErr != nil { |
| 374 | + return nil, fmt.Errorf("parsing LLM section response after repair: %w (original error: %v, response: %s)", repairErr, err, repaired) |
| 375 | + } |
| 376 | + log.Printf("llm section response repaired before parse") |
366 | 377 | } |
367 | 378 |
|
368 | 379 | decisions := make(map[int64]LLMSectionDecision) |
@@ -424,6 +435,92 @@ func parseTicketIDsField(raw json.RawMessage) string { |
424 | 435 | return "" |
425 | 436 | } |
426 | 437 |
|
| 438 | +func repairSectionClassifiedResponse(responseText string) string { |
| 439 | + return confidenceFieldRe.ReplaceAllStringFunc(responseText, func(match string) string { |
| 440 | + parts := confidenceFieldRe.FindStringSubmatch(match) |
| 441 | + if len(parts) != 3 { |
| 442 | + return match |
| 443 | + } |
| 444 | + return parts[1] + normalizeConfidenceLiteral(parts[2]) |
| 445 | + }) |
| 446 | +} |
| 447 | + |
| 448 | +func normalizeConfidenceLiteral(raw string) string { |
| 449 | + text := strings.TrimSpace(strings.Trim(raw, `"`)) |
| 450 | + if text == "" { |
| 451 | + return "0" |
| 452 | + } |
| 453 | + if parsed, ok := parseLooseConfidence(text); ok { |
| 454 | + return strconv.FormatFloat(parsed, 'f', -1, 64) |
| 455 | + } |
| 456 | + return "0" |
| 457 | +} |
| 458 | + |
| 459 | +func parseLooseConfidence(raw string) (float64, bool) { |
| 460 | + text := strings.TrimSpace(raw) |
| 461 | + if text == "" { |
| 462 | + return 0, false |
| 463 | + } |
| 464 | + if parsed, err := strconv.ParseFloat(text, 64); err == nil { |
| 465 | + return clampConfidence(parsed), true |
| 466 | + } |
| 467 | + |
| 468 | + compact := strings.ReplaceAll(strings.ToLower(text), " ", "") |
| 469 | + if parsed, err := strconv.ParseFloat(compact, 64); err == nil { |
| 470 | + return clampConfidence(parsed), true |
| 471 | + } |
| 472 | + |
| 473 | + if strings.HasPrefix(compact, "0.") { |
| 474 | + if digit, ok := digitWord(compact[2:]); ok { |
| 475 | + return float64(digit) / 10, true |
| 476 | + } |
| 477 | + } |
| 478 | + if compact == "zero" { |
| 479 | + return 0, true |
| 480 | + } |
| 481 | + if compact == "one" { |
| 482 | + return 1, true |
| 483 | + } |
| 484 | + return 0, false |
| 485 | +} |
| 486 | + |
| 487 | +func digitWord(s string) (int, bool) { |
| 488 | + switch s { |
| 489 | + case "zero": |
| 490 | + return 0, true |
| 491 | + case "one": |
| 492 | + return 1, true |
| 493 | + case "two": |
| 494 | + return 2, true |
| 495 | + case "three": |
| 496 | + return 3, true |
| 497 | + case "four": |
| 498 | + return 4, true |
| 499 | + case "five": |
| 500 | + return 5, true |
| 501 | + case "six": |
| 502 | + return 6, true |
| 503 | + case "seven": |
| 504 | + return 7, true |
| 505 | + case "eight": |
| 506 | + return 8, true |
| 507 | + case "nine": |
| 508 | + return 9, true |
| 509 | + default: |
| 510 | + return 0, false |
| 511 | + } |
| 512 | +} |
| 513 | + |
| 514 | +func clampConfidence(v float64) float64 { |
| 515 | + if v < 0 { |
| 516 | + return 0 |
| 517 | + } |
| 518 | + if v > 1 { |
| 519 | + return 1 |
| 520 | + } |
| 521 | + return v |
| 522 | +} |
| 523 | + |
427 | 524 | func loadGlossaryIfConfigured(cfg Config) (*LLMGlossary, error) { |
428 | 525 | if strings.TrimSpace(cfg.LLMGlossaryPath) == "" { |
429 | 526 | return nil, nil |
|
0 commit comments