Files
gh-commit/internal/ai/parse.go
T
t 9647c854c9 feat: rewrite gh-commit in Go with local Ollama backend
Replaces the Python/Crush/DuckDB implementation with a CGO-free Go binary:
cobra CLI, modernc.org/sqlite storage, huh/lipgloss UI, and speed-tuned raw
/api/generate calls (think:false, keep_alive, capped num_predict) against a
local qwen3.5:2b. Ships as a gh extension via cli/gh-extension-precompile.
2026-08-15 19:10:16 -04:00

111 lines
2.7 KiB
Go

package ai
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var (
thinkRe = regexp.MustCompile(`(?s)<think>.*?</think>`)
fenceOpenRe = regexp.MustCompile("^```[a-zA-Z]*\n")
fenceCloseRe = regexp.MustCompile("\n```$")
scopedMsgRe = regexp.MustCompile(`^\w+\([^)]+\): .+`)
plainMsgRe = regexp.MustCompile(`^\w+: .+`)
)
// stripThink removes <think>...</think> reasoning blocks.
func stripThink(text string) string {
return strings.TrimSpace(thinkRe.ReplaceAllString(text, ""))
}
// ExtractJSONObject returns the first balanced top-level {...} object in text,
// or "" when none is found.
func ExtractJSONObject(text string) string {
depth := 0
start := -1
for i := range len(text) {
switch text[i] {
case '{':
if start == -1 {
start = i
}
depth++
case '}':
if start != -1 {
depth--
if depth == 0 {
return text[start : i+1]
}
}
}
}
return ""
}
// CoerceScopeMap normalizes a decoded JSON/TOML mapping into scope -> paths.
// String values become single-element slices; empty paths are dropped.
// Returns nil when nothing usable remains.
func CoerceScopeMap(data map[string]any) map[string][]string {
scopes := map[string][]string{}
for name, paths := range data {
switch v := paths.(type) {
case string:
scopes[name] = []string{v}
case []any:
var out []string
for _, p := range v {
s := fmt.Sprint(p)
if strings.TrimSpace(s) != "" {
out = append(out, s)
}
}
scopes[name] = out
}
}
if len(scopes) == 0 {
return nil
}
return scopes
}
// ParseScopesResponse extracts and normalizes the scope mapping from a model
// response. Returns nil when no usable JSON object is present.
func ParseScopesResponse(text string) map[string][]string {
blob := ExtractJSONObject(text)
if blob == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(blob), &data); err != nil {
return nil
}
return CoerceScopeMap(data)
}
// CleanCommitMessage normalizes a raw model response into a commit message:
// trims, strips <think> blocks and code fences, prefers the first non-empty
// line when it matches conventional-commit shape, and otherwise falls back to
// the whole trimmed string (never blocks a commit on a regex).
func CleanCommitMessage(raw string) string {
msg := stripThink(strings.TrimSpace(raw))
msg = fenceOpenRe.ReplaceAllString(msg, "")
msg = fenceCloseRe.ReplaceAllString(msg, "")
msg = strings.TrimSpace(msg)
if msg == "" {
return ""
}
for _, line := range strings.Split(msg, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if scopedMsgRe.MatchString(line) || plainMsgRe.MatchString(line) {
return line
}
break
}
return msg
}