feat(ingestion): add /ingest and /ingest-path HTTP handlers
Wires pipeline.Run into the HTTP layer so callers can ingest raw text or files/directories without touching the filesystem directly. Rewrites main.go to parse LLM and watcher env vars and build pipeline.Config. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||
)
|
||||
|
||||
@@ -18,11 +19,15 @@ import (
|
||||
type Handler struct {
|
||||
brainDir string
|
||||
logger *slog.Logger
|
||||
pipeline pipeline.Config
|
||||
}
|
||||
|
||||
// NewHandler constructs a Handler. brainDir is the absolute path to brain/.
|
||||
func NewHandler(brainDir string, logger *slog.Logger) *Handler {
|
||||
return &Handler{brainDir: brainDir, logger: logger}
|
||||
func NewHandler(brainDir string, logger *slog.Logger, pipelineCfg pipeline.Config) *Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Handler{brainDir: brainDir, logger: logger, pipeline: pipelineCfg}
|
||||
}
|
||||
|
||||
type queryRequest struct {
|
||||
@@ -37,15 +42,32 @@ type writeRequest struct {
|
||||
Domain string `json:"domain,omitempty"`
|
||||
}
|
||||
|
||||
type ingestRequest struct {
|
||||
Content string `json:"content"`
|
||||
Source string `json:"source"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
type ingestPathRequest struct {
|
||||
Path string `json:"path"`
|
||||
Source string `json:"source"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
type ingestResponse struct {
|
||||
Pages []string `json:"pages"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
// Query handles POST /query — full-text search across the brain wiki.
|
||||
func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
||||
var req queryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Query) == "" {
|
||||
http.Error(w, "query is required", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "query is required")
|
||||
return
|
||||
}
|
||||
if req.Limit == 0 {
|
||||
@@ -55,22 +77,22 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
||||
results, err := search.Query(h.brainDir, req.Query, req.Limit)
|
||||
if err != nil {
|
||||
h.logger.Error("query failed", "err", err)
|
||||
http.Error(w, "search error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "search error")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"results": results})
|
||||
}
|
||||
|
||||
// Write handles POST /write — write raw content to brain/raw/.
|
||||
// Write handles POST /write — write raw content to brain/knowledge/.
|
||||
func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
||||
var req writeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Content == "" {
|
||||
http.Error(w, "content is required", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "content is required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,7 +103,7 @@ func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
rawDir := filepath.Join(h.brainDir, "knowledge")
|
||||
if err := os.MkdirAll(rawDir, 0o755); err != nil {
|
||||
http.Error(w, "failed to create raw dir", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create raw dir")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -106,7 +128,7 @@ func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
||||
dest := filepath.Join(rawDir, base)
|
||||
if err := os.WriteFile(dest, []byte(finalContent), 0o644); err != nil {
|
||||
h.logger.Error("write failed", "err", err)
|
||||
http.Error(w, "write error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "write error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -114,7 +136,144 @@ func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]string{"path": filepath.ToSlash(rel)})
|
||||
}
|
||||
|
||||
// Ingest handles POST /ingest — run the pipeline on provided content.
|
||||
func (h *Handler) Ingest(w http.ResponseWriter, r *http.Request) {
|
||||
var req ingestRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Content) == "" {
|
||||
writeError(w, http.StatusBadRequest, "content is required")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Source) == "" {
|
||||
writeError(w, http.StatusBadRequest, "source is required")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := pipeline.Run(r.Context(), h.pipeline, h.brainDir, req.Content, req.Source, req.DryRun)
|
||||
if err != nil {
|
||||
h.logger.Error("ingest failed", "source", req.Source, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("ingest error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
pages := result.Pages
|
||||
if pages == nil {
|
||||
pages = []string{}
|
||||
}
|
||||
warnings := result.Warnings
|
||||
if warnings == nil {
|
||||
warnings = []string{}
|
||||
}
|
||||
writeJSON(w, ingestResponse{Pages: pages, Warnings: warnings})
|
||||
}
|
||||
|
||||
// supportedExtensions lists file extensions that IngestPath will process.
|
||||
var supportedExtensions = map[string]bool{
|
||||
".md": true,
|
||||
".txt": true,
|
||||
".pdf": true,
|
||||
}
|
||||
|
||||
// IngestPath handles POST /ingest-path — ingest a file or directory.
|
||||
func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
||||
var req ingestPathRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Path) == "" {
|
||||
writeError(w, http.StatusBadRequest, "path is required")
|
||||
return
|
||||
}
|
||||
|
||||
info, err := os.Stat(req.Path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("path not accessible: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var allPages []string
|
||||
var allWarnings []string
|
||||
|
||||
if info.IsDir() {
|
||||
err = filepath.WalkDir(req.Path, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if !supportedExtensions[ext] {
|
||||
return nil
|
||||
}
|
||||
content, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
allWarnings = append(allWarnings, fmt.Sprintf("read %s: %v", path, readErr))
|
||||
return nil
|
||||
}
|
||||
source := req.Source
|
||||
if source == "" {
|
||||
source = filepath.Base(path)
|
||||
}
|
||||
result, runErr := pipeline.Run(r.Context(), h.pipeline, h.brainDir, string(content), source, req.DryRun)
|
||||
if runErr != nil {
|
||||
allWarnings = append(allWarnings, fmt.Sprintf("ingest %s: %v", path, runErr))
|
||||
return nil
|
||||
}
|
||||
allPages = append(allPages, result.Pages...)
|
||||
allWarnings = append(allWarnings, result.Warnings...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("walk dir failed", "path", req.Path, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("walk error: %v", err))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
ext := strings.ToLower(filepath.Ext(req.Path))
|
||||
if !supportedExtensions[ext] {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext))
|
||||
return
|
||||
}
|
||||
content, readErr := os.ReadFile(req.Path)
|
||||
if readErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("read file: %v", readErr))
|
||||
return
|
||||
}
|
||||
source := req.Source
|
||||
if source == "" {
|
||||
source = filepath.Base(req.Path)
|
||||
}
|
||||
result, runErr := pipeline.Run(r.Context(), h.pipeline, h.brainDir, string(content), source, req.DryRun)
|
||||
if runErr != nil {
|
||||
h.logger.Error("ingest-path failed", "path", req.Path, "err", runErr)
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("ingest error: %v", runErr))
|
||||
return
|
||||
}
|
||||
allPages = result.Pages
|
||||
allWarnings = result.Warnings
|
||||
}
|
||||
|
||||
if allPages == nil {
|
||||
allPages = []string{}
|
||||
}
|
||||
if allWarnings == nil {
|
||||
allWarnings = []string{}
|
||||
}
|
||||
writeJSON(w, ingestResponse{Pages: allPages, Warnings: allWarnings})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg}) //nolint:errcheck
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user