// ingestion/internal/api/handler.go package api import ( "encoding/json" "fmt" "log/slog" "net/http" "os" "path/filepath" "strings" "time" "github.com/mathiasbq/hyperguild/ingestion/internal/extract" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" "github.com/mathiasbq/hyperguild/ingestion/internal/search" ) // Handler serves the ingestion HTTP API. 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, pipelineCfg pipeline.Config) *Handler { if logger == nil { logger = slog.Default() } return &Handler{brainDir: brainDir, logger: logger, pipeline: pipelineCfg} } type queryRequest struct { Query string `json:"query"` Limit int `json:"limit,omitempty"` } type writeRequest struct { Content string `json:"content"` Filename string `json:"filename,omitempty"` Type string `json:"type,omitempty"` 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 { writeError(w, http.StatusBadRequest, "invalid JSON") return } if strings.TrimSpace(req.Query) == "" { writeError(w, http.StatusBadRequest, "query is required") return } if req.Limit == 0 { req.Limit = 5 } results, err := search.Query(h.brainDir, req.Query, req.Limit) if err != nil { h.logger.Error("query failed", "err", err) writeError(w, http.StatusInternalServerError, "search error") return } writeJSON(w, map[string]any{"results": results}) } // 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 { writeError(w, http.StatusBadRequest, "invalid JSON") return } if req.Content == "" { writeError(w, http.StatusBadRequest, "content is required") return } filename := req.Filename if filename == "" { filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405")) } rawDir := filepath.Join(h.brainDir, "knowledge") if err := os.MkdirAll(rawDir, 0o755); err != nil { writeError(w, http.StatusInternalServerError, "failed to create raw dir") return } finalContent := req.Content if req.Type != "" || req.Domain != "" { var fm strings.Builder fm.WriteString("---\n") if req.Type != "" { fmt.Fprintf(&fm, "type: %s\n", req.Type) } if req.Domain != "" { fmt.Fprintf(&fm, "domain: %s\n", req.Domain) } fm.WriteString("---\n") finalContent = fm.String() + req.Content } base := filepath.Base(filename) if !strings.HasSuffix(base, ".md") { base += ".md" } dest := filepath.Join(rawDir, base) if !strings.HasPrefix(filepath.Clean(dest)+string(os.PathSeparator), filepath.Clean(rawDir)+string(os.PathSeparator)) { writeError(w, http.StatusBadRequest, "invalid filename") return } if err := os.WriteFile(dest, []byte(finalContent), 0o644); err != nil { h.logger.Error("write failed", "err", err) writeError(w, http.StatusInternalServerError, "write error") return } rel, _ := filepath.Rel(h.brainDir, dest) 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, "ingest error") 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 := extract.Text(path) if readErr != nil { allWarnings = append(allWarnings, fmt.Sprintf("extract %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, 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 := extract.Text(req.Path) if readErr != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("extract text: %v", readErr)) return } source := req.Source if source == "" { source = filepath.Base(req.Path) } result, runErr := pipeline.Run(r.Context(), h.pipeline, h.brainDir, content, source, req.DryRun) if runErr != nil { h.logger.Error("ingest-path failed", "path", req.Path, "err", runErr) writeError(w, http.StatusInternalServerError, "ingest error") 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 }