package rest import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "regexp" "sort" "strings" "time" ) const ( defaultExplorerAIModel = "gpt-5.4-mini" defaultExplorerAIReasoningEffort = "low" maxExplorerAIMessages = 12 maxExplorerAIMessageChars = 4000 maxExplorerAIContextChars = 22000 maxExplorerAIDocSnippets = 6 ) var ( addressPattern = regexp.MustCompile(`0x[a-fA-F0-9]{40}`) transactionPattern = regexp.MustCompile(`0x[a-fA-F0-9]{64}`) blockRefPattern = regexp.MustCompile(`(?i)\bblock\s+#?(\d+)\b`) ) type AIChatMessage struct { Role string `json:"role"` Content string `json:"content"` } type AIChatRequest struct { Messages []AIChatMessage `json:"messages"` PageContext map[string]string `json:"pageContext,omitempty"` } type AIContextResponse struct { Enabled bool `json:"enabled"` Query string `json:"query,omitempty"` GeneratedAt string `json:"generatedAt"` Model string `json:"model"` Context AIContextEnvelope `json:"context"` Warnings []string `json:"warnings,omitempty"` } type AIChatResponse struct { Reply string `json:"reply"` Model string `json:"model"` GeneratedAt string `json:"generatedAt"` Context AIContextEnvelope `json:"context"` Warnings []string `json:"warnings,omitempty"` } type AIContextEnvelope struct { ChainID int `json:"chainId"` Explorer string `json:"explorer"` PageContext map[string]string `json:"pageContext,omitempty"` Stats map[string]any `json:"stats,omitempty"` Address map[string]any `json:"address,omitempty"` Transaction map[string]any `json:"transaction,omitempty"` Block map[string]any `json:"block,omitempty"` RouteMatches []map[string]any `json:"routeMatches,omitempty"` DocSnippets []AIDocSnippet `json:"docSnippets,omitempty"` CapabilityNotice string `json:"capabilityNotice"` Sources []AIContextSource `json:"sources,omitempty"` } type AIDocSnippet struct { Path string `json:"path"` Line int `json:"line"` Snippet string `json:"snippet"` } type AIContextSource struct { Type string `json:"type"` Label string `json:"label"` Origin string `json:"origin,omitempty"` } type openAIResponsesRequest struct { Model string `json:"model"` Input []openAIInputMessage `json:"input"` Reasoning *openAIReasoning `json:"reasoning,omitempty"` } type openAIReasoning struct { Effort string `json:"effort,omitempty"` } type openAIInputMessage struct { Role string `json:"role"` Content []openAIInputContent `json:"content"` } type openAIInputContent struct { Type string `json:"type"` Text string `json:"text"` } type openAIResponsesResponse struct { Model string `json:"model"` OutputText string `json:"output_text"` Output []openAIOutputItem `json:"output"` } type openAIOutputItem struct { Type string `json:"type"` Content []openAIOutputContent `json:"content"` } type openAIOutputContent struct { Type string `json:"type"` Text string `json:"text"` } func (s *Server) handleAIContext(w http.ResponseWriter, r *http.Request) { startedAt := time.Now() clientIP := clientIPAddress(r) if r.Method != http.MethodGet { writeMethodNotAllowed(w) return } if allowed, retryAfter := s.allowAIRequest(r, "context"); !allowed { w.Header().Set("Retry-After", fmt.Sprintf("%.0f", retryAfter.Seconds())) s.aiMetrics.Record("context", http.StatusTooManyRequests, time.Since(startedAt), "rate_limited", clientIP) s.logAIRequest("context", http.StatusTooManyRequests, time.Since(startedAt), clientIP, explorerAIModel(), "rate_limited") writeErrorDetailed(w, http.StatusTooManyRequests, "rate_limited", "explorer ai context rate limit exceeded", "please retry shortly") return } query := strings.TrimSpace(r.URL.Query().Get("q")) pageContext := map[string]string{ "path": strings.TrimSpace(r.URL.Query().Get("path")), "view": strings.TrimSpace(r.URL.Query().Get("view")), } ctxEnvelope, warnings := s.buildAIContext(r.Context(), query, pageContext) response := AIContextResponse{ Enabled: explorerAIEnabled(), Query: query, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Model: explorerAIModel(), Context: ctxEnvelope, Warnings: warnings, } s.aiMetrics.Record("context", http.StatusOK, time.Since(startedAt), "", clientIP) s.logAIRequest("context", http.StatusOK, time.Since(startedAt), clientIP, explorerAIModel(), "") writeJSON(w, http.StatusOK, response) } func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { startedAt := time.Now() clientIP := clientIPAddress(r) if r.Method != http.MethodPost { writeMethodNotAllowed(w) return } if allowed, retryAfter := s.allowAIRequest(r, "chat"); !allowed { w.Header().Set("Retry-After", fmt.Sprintf("%.0f", retryAfter.Seconds())) s.aiMetrics.Record("chat", http.StatusTooManyRequests, time.Since(startedAt), "rate_limited", clientIP) s.logAIRequest("chat", http.StatusTooManyRequests, time.Since(startedAt), clientIP, explorerAIModel(), "rate_limited") writeErrorDetailed(w, http.StatusTooManyRequests, "rate_limited", "explorer ai chat rate limit exceeded", "please retry shortly") return } if !explorerAIEnabled() { s.aiMetrics.Record("chat", http.StatusServiceUnavailable, time.Since(startedAt), "service_unavailable", clientIP) s.logAIRequest("chat", http.StatusServiceUnavailable, time.Since(startedAt), clientIP, explorerAIModel(), "service_unavailable") writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer ai is not configured; set OPENAI_API_KEY on the backend") return } defer r.Body.Close() body := http.MaxBytesReader(w, r.Body, 1<<20) var chatReq AIChatRequest if err := json.NewDecoder(body).Decode(&chatReq); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "invalid ai chat payload") return } messages := normalizeAIMessages(chatReq.Messages) if len(messages) == 0 { writeError(w, http.StatusBadRequest, "bad_request", "at least one non-empty ai message is required") return } latestUser := latestUserMessage(messages) ctxEnvelope, warnings := s.buildAIContext(r.Context(), latestUser, chatReq.PageContext) reply, model, err := s.callOpenAIResponses(r.Context(), messages, ctxEnvelope) if err != nil { statusCode, code, message, details := mapAIUpstreamError(err) s.aiMetrics.Record("chat", statusCode, time.Since(startedAt), code, clientIP) s.logAIRequest("chat", statusCode, time.Since(startedAt), clientIP, model, code) writeErrorDetailed(w, statusCode, code, message, details) return } response := AIChatResponse{ Reply: reply, Model: model, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Context: ctxEnvelope, Warnings: warnings, } s.aiMetrics.Record("chat", http.StatusOK, time.Since(startedAt), "", clientIP) s.logAIRequest("chat", http.StatusOK, time.Since(startedAt), clientIP, model, "") writeJSON(w, http.StatusOK, response) } func explorerAIEnabled() bool { return strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) != "" } func explorerAIModel() string { if model := strings.TrimSpace(os.Getenv("OPENAI_MODEL")); model != "" { return model } if model := strings.TrimSpace(os.Getenv("EXPLORER_AI_MODEL")); model != "" { return model } return defaultExplorerAIModel } func explorerAIReasoningEffort() string { if effort := strings.TrimSpace(os.Getenv("OPENAI_REASONING_EFFORT")); effort != "" { return effort } if effort := strings.TrimSpace(os.Getenv("EXPLORER_AI_REASONING_EFFORT")); effort != "" { return effort } return defaultExplorerAIReasoningEffort } func (s *Server) buildAIContext(ctx context.Context, query string, pageContext map[string]string) (AIContextEnvelope, []string) { warnings := []string{} envelope := AIContextEnvelope{ ChainID: s.chainID, Explorer: "SolaceScanScout", PageContext: compactStringMap(pageContext), CapabilityNotice: "This assistant is wired for read-only explorer analysis. It can summarize indexed chain data, liquidity routes, and curated workspace docs, but it does not sign transactions or execute private operations.", } sources := []AIContextSource{ {Type: "system", Label: "Explorer REST backend"}, } if stats, err := s.queryAIStats(ctx); err == nil { envelope.Stats = stats sources = append(sources, AIContextSource{Type: "database", Label: "Explorer indexer database"}) } else if err != nil { warnings = append(warnings, "indexed explorer stats unavailable: "+err.Error()) } if strings.TrimSpace(query) != "" { if txHash := firstRegexMatch(transactionPattern, query); txHash != "" && s.db != nil { if tx, err := s.queryAITransaction(ctx, txHash); err == nil && len(tx) > 0 { envelope.Transaction = tx } else if err != nil { warnings = append(warnings, "transaction context unavailable: "+err.Error()) } } if addr := firstRegexMatch(addressPattern, query); addr != "" && s.db != nil { if addressInfo, err := s.queryAIAddress(ctx, addr); err == nil && len(addressInfo) > 0 { envelope.Address = addressInfo } else if err != nil { warnings = append(warnings, "address context unavailable: "+err.Error()) } } if blockNumber := extractBlockReference(query); blockNumber > 0 && s.db != nil { if block, err := s.queryAIBlock(ctx, blockNumber); err == nil && len(block) > 0 { envelope.Block = block } else if err != nil { warnings = append(warnings, "block context unavailable: "+err.Error()) } } } if routeMatches, routeWarning := s.queryAIRoutes(ctx, query); len(routeMatches) > 0 { envelope.RouteMatches = routeMatches sources = append(sources, AIContextSource{Type: "routes", Label: "Token aggregation live routes", Origin: firstNonEmptyEnv("TOKEN_AGGREGATION_API_BASE", "TOKEN_AGGREGATION_URL", "TOKEN_AGGREGATION_BASE_URL")}) } else if routeWarning != "" { warnings = append(warnings, routeWarning) } if docs, root, docWarning := loadAIDocSnippets(query); len(docs) > 0 { envelope.DocSnippets = docs sources = append(sources, AIContextSource{Type: "docs", Label: "Workspace docs", Origin: root}) } else if docWarning != "" { warnings = append(warnings, docWarning) } envelope.Sources = sources return envelope, uniqueStrings(warnings) } func (s *Server) queryAIStats(ctx context.Context) (map[string]any, error) { if s.db == nil { return nil, fmt.Errorf("database unavailable") } ctx, cancel := context.WithTimeout(ctx, 4*time.Second) defer cancel() stats := map[string]any{} var totalBlocks int64 if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM blocks WHERE chain_id = $1`, s.chainID).Scan(&totalBlocks); err == nil { stats["total_blocks"] = totalBlocks } var totalTransactions int64 if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions WHERE chain_id = $1`, s.chainID).Scan(&totalTransactions); err == nil { stats["total_transactions"] = totalTransactions } var totalAddresses int64 if err := s.db.QueryRow(ctx, `SELECT COUNT(DISTINCT from_address) + COUNT(DISTINCT to_address) FROM transactions WHERE chain_id = $1`, s.chainID).Scan(&totalAddresses); err == nil { stats["total_addresses"] = totalAddresses } var latestBlock int64 if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(number), 0) FROM blocks WHERE chain_id = $1`, s.chainID).Scan(&latestBlock); err == nil { stats["latest_block"] = latestBlock } if len(stats) == 0 { var totalBlocks int64 if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM blocks`).Scan(&totalBlocks); err == nil { stats["total_blocks"] = totalBlocks } var totalTransactions int64 if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions`).Scan(&totalTransactions); err == nil { stats["total_transactions"] = totalTransactions } var totalAddresses int64 if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM addresses`).Scan(&totalAddresses); err == nil { stats["total_addresses"] = totalAddresses } var latestBlock int64 if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(number), 0) FROM blocks`).Scan(&latestBlock); err == nil { stats["latest_block"] = latestBlock } } if len(stats) == 0 { return nil, fmt.Errorf("no indexed stats available") } return stats, nil } func (s *Server) queryAITransaction(ctx context.Context, hash string) (map[string]any, error) { ctx, cancel := context.WithTimeout(ctx, 4*time.Second) defer cancel() query := ` SELECT hash, block_number, from_address, to_address, value, gas_used, gas_price, status, timestamp_iso FROM transactions WHERE chain_id = $1 AND hash = $2 LIMIT 1 ` var txHash, fromAddress, value string var blockNumber int64 var toAddress *string var gasUsed, gasPrice *int64 var status *int64 var timestampISO *string err := s.db.QueryRow(ctx, query, s.chainID, hash).Scan( &txHash, &blockNumber, &fromAddress, &toAddress, &value, &gasUsed, &gasPrice, &status, ×tampISO, ) if err != nil { normalizedHash := normalizeHexString(hash) blockscoutQuery := ` SELECT concat('0x', encode(hash, 'hex')) AS hash, block_number, concat('0x', encode(from_address_hash, 'hex')) AS from_address, CASE WHEN to_address_hash IS NULL THEN NULL ELSE concat('0x', encode(to_address_hash, 'hex')) END AS to_address, COALESCE(value::text, '0') AS value, gas_used, gas_price, status, TO_CHAR(block_timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS timestamp_iso FROM transactions WHERE hash = decode($1, 'hex') LIMIT 1 ` if fallbackErr := s.db.QueryRow(ctx, blockscoutQuery, normalizedHash).Scan( &txHash, &blockNumber, &fromAddress, &toAddress, &value, &gasUsed, &gasPrice, &status, ×tampISO, ); fallbackErr != nil { return nil, err } } tx := map[string]any{ "hash": txHash, "block_number": blockNumber, "from_address": fromAddress, "value": value, } if toAddress != nil { tx["to_address"] = *toAddress } if gasUsed != nil { tx["gas_used"] = *gasUsed } if gasPrice != nil { tx["gas_price"] = *gasPrice } if status != nil { tx["status"] = *status } if timestampISO != nil { tx["timestamp_iso"] = *timestampISO } return tx, nil } func (s *Server) queryAIAddress(ctx context.Context, address string) (map[string]any, error) { ctx, cancel := context.WithTimeout(ctx, 4*time.Second) defer cancel() result := map[string]any{ "address": address, } var txCount int64 if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`, s.chainID, address).Scan(&txCount); err == nil { result["transaction_count"] = txCount } var tokenCount int64 if err := s.db.QueryRow(ctx, `SELECT COUNT(DISTINCT token_address) FROM token_transfers WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`, s.chainID, address).Scan(&tokenCount); err == nil { result["token_count"] = tokenCount } var recentHashes []string rows, err := s.db.Query(ctx, ` SELECT hash FROM transactions WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2) ORDER BY block_number DESC, transaction_index DESC LIMIT 5 `, s.chainID, address) if err == nil { defer rows.Close() for rows.Next() { var hash string if scanErr := rows.Scan(&hash); scanErr == nil { recentHashes = append(recentHashes, hash) } } } if len(recentHashes) > 0 { result["recent_transactions"] = recentHashes } if len(result) == 1 { normalizedAddress := normalizeHexString(address) var blockscoutTxCount int64 var blockscoutTokenCount int64 blockscoutAddressQuery := ` SELECT COALESCE(transactions_count, 0), COALESCE(token_transfers_count, 0) FROM addresses WHERE hash = decode($1, 'hex') LIMIT 1 ` if err := s.db.QueryRow(ctx, blockscoutAddressQuery, normalizedAddress).Scan(&blockscoutTxCount, &blockscoutTokenCount); err == nil { result["transaction_count"] = blockscoutTxCount result["token_count"] = blockscoutTokenCount } var liveTxCount int64 if err := s.db.QueryRow(ctx, ` SELECT COUNT(*) FROM transactions WHERE from_address_hash = decode($1, 'hex') OR to_address_hash = decode($1, 'hex') `, normalizedAddress).Scan(&liveTxCount); err == nil && liveTxCount > 0 { result["transaction_count"] = liveTxCount } var liveTokenCount int64 if err := s.db.QueryRow(ctx, ` SELECT COUNT(DISTINCT token_contract_address_hash) FROM token_transfers WHERE from_address_hash = decode($1, 'hex') OR to_address_hash = decode($1, 'hex') `, normalizedAddress).Scan(&liveTokenCount); err == nil && liveTokenCount > 0 { result["token_count"] = liveTokenCount } rows, err := s.db.Query(ctx, ` SELECT concat('0x', encode(hash, 'hex')) FROM transactions WHERE from_address_hash = decode($1, 'hex') OR to_address_hash = decode($1, 'hex') ORDER BY block_number DESC, index DESC LIMIT 5 `, normalizedAddress) if err == nil { defer rows.Close() for rows.Next() { var hash string if scanErr := rows.Scan(&hash); scanErr == nil { recentHashes = append(recentHashes, hash) } } } if len(recentHashes) > 0 { result["recent_transactions"] = recentHashes } } if len(result) == 1 { return nil, fmt.Errorf("address not found") } return result, nil } func (s *Server) queryAIBlock(ctx context.Context, blockNumber int64) (map[string]any, error) { ctx, cancel := context.WithTimeout(ctx, 4*time.Second) defer cancel() query := ` SELECT number, hash, parent_hash, transaction_count, gas_used, gas_limit, timestamp_iso FROM blocks WHERE chain_id = $1 AND number = $2 LIMIT 1 ` var number int64 var hash, parentHash string var transactionCount int64 var gasUsed, gasLimit int64 var timestampISO *string err := s.db.QueryRow(ctx, query, s.chainID, blockNumber).Scan(&number, &hash, &parentHash, &transactionCount, &gasUsed, &gasLimit, ×tampISO) if err != nil { blockscoutQuery := ` SELECT number, concat('0x', encode(hash, 'hex')) AS hash, concat('0x', encode(parent_hash, 'hex')) AS parent_hash, (SELECT COUNT(*) FROM transactions WHERE block_number = b.number) AS transaction_count, gas_used, gas_limit, TO_CHAR(timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS timestamp_iso FROM blocks b WHERE number = $1 LIMIT 1 ` if fallbackErr := s.db.QueryRow(ctx, blockscoutQuery, blockNumber).Scan(&number, &hash, &parentHash, &transactionCount, &gasUsed, &gasLimit, ×tampISO); fallbackErr != nil { return nil, err } } block := map[string]any{ "number": number, "hash": hash, "parent_hash": parentHash, "transaction_count": transactionCount, "gas_used": gasUsed, "gas_limit": gasLimit, } if timestampISO != nil { block["timestamp_iso"] = *timestampISO } return block, nil } func (s *Server) queryAIRoutes(ctx context.Context, query string) ([]map[string]any, string) { baseURL := strings.TrimSpace(firstNonEmptyEnv( "TOKEN_AGGREGATION_API_BASE", "TOKEN_AGGREGATION_URL", "TOKEN_AGGREGATION_BASE_URL", )) if baseURL == "" { return nil, "token aggregation api base url is not configured for ai route retrieval" } req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+"/api/v1/routes/ingestion?fromChainId=138", nil) if err != nil { return nil, "unable to build token aggregation ai request" } client := &http.Client{Timeout: 6 * time.Second} resp, err := client.Do(req) if err != nil { return nil, "token aggregation live routes unavailable: " + err.Error() } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, fmt.Sprintf("token aggregation live routes returned %d", resp.StatusCode) } var payload struct { Routes []map[string]any `json:"routes"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return nil, "unable to decode token aggregation live routes" } if len(payload.Routes) == 0 { return nil, "token aggregation returned no live routes" } matches := filterAIRouteMatches(payload.Routes, query) return matches, "" } func filterAIRouteMatches(routes []map[string]any, query string) []map[string]any { query = strings.ToLower(strings.TrimSpace(query)) matches := make([]map[string]any, 0, 6) for _, route := range routes { if query != "" && !routeMatchesQuery(route, query) { continue } trimmed := map[string]any{ "routeId": route["routeId"], "status": route["status"], "routeType": route["routeType"], "fromChainId": route["fromChainId"], "toChainId": route["toChainId"], "tokenInSymbol": route["tokenInSymbol"], "tokenOutSymbol": route["tokenOutSymbol"], "assetSymbol": route["assetSymbol"], "label": route["label"], "aggregatorFamilies": route["aggregatorFamilies"], "hopCount": route["hopCount"], "bridgeType": route["bridgeType"], "tags": route["tags"], } matches = append(matches, compactAnyMap(trimmed)) if len(matches) >= 6 { break } } if len(matches) == 0 { for _, route := range routes { trimmed := map[string]any{ "routeId": route["routeId"], "status": route["status"], "routeType": route["routeType"], "fromChainId": route["fromChainId"], "toChainId": route["toChainId"], "tokenInSymbol": route["tokenInSymbol"], "tokenOutSymbol": route["tokenOutSymbol"], "assetSymbol": route["assetSymbol"], "label": route["label"], "aggregatorFamilies": route["aggregatorFamilies"], } matches = append(matches, compactAnyMap(trimmed)) if len(matches) >= 4 { break } } } return matches } func normalizeHexString(value string) string { trimmed := strings.TrimSpace(strings.ToLower(value)) return strings.TrimPrefix(trimmed, "0x") } func routeMatchesQuery(route map[string]any, query string) bool { fields := []string{ stringValue(route["routeId"]), stringValue(route["routeType"]), stringValue(route["tokenInSymbol"]), stringValue(route["tokenOutSymbol"]), stringValue(route["assetSymbol"]), stringValue(route["label"]), } for _, field := range fields { if strings.Contains(strings.ToLower(field), query) { return true } } for _, value := range stringSliceValue(route["aggregatorFamilies"]) { if strings.Contains(strings.ToLower(value), query) { return true } } for _, value := range stringSliceValue(route["tags"]) { if strings.Contains(strings.ToLower(value), query) { return true } } for _, symbol := range []string{"cusdt", "cusdc", "cxauc", "ceurt", "usdt", "usdc", "weth"} { if strings.Contains(query, symbol) { if strings.Contains(strings.ToLower(strings.Join(fields, " ")), symbol) { return true } } } return false } func loadAIDocSnippets(query string) ([]AIDocSnippet, string, string) { root := findAIWorkspaceRoot() if root == "" { return nil, "", "workspace docs root unavailable for ai doc retrieval" } relativePaths := []string{ "docs/11-references/ADDRESS_MATRIX_AND_STATUS.md", "docs/11-references/LIQUIDITY_POOLS_MASTER_MAP.md", "docs/11-references/DEPLOYED_TOKENS_BRIDGES_LPS_AND_ROUTING_STATUS.md", "docs/11-references/EXPLORER_TOKEN_LIST_CROSSCHECK.md", "explorer-monorepo/docs/EXPLORER_API_ACCESS.md", } terms := buildDocSearchTerms(query) if len(terms) == 0 { terms = []string{"chain 138", "bridge", "liquidity"} } snippets := []AIDocSnippet{} for _, rel := range relativePaths { fullPath := filepath.Join(root, rel) fileSnippets := scanDocForTerms(fullPath, rel, terms) snippets = append(snippets, fileSnippets...) if len(snippets) >= maxExplorerAIDocSnippets { break } } if len(snippets) == 0 { return nil, root, "no matching workspace docs found for ai context" } if len(snippets) > maxExplorerAIDocSnippets { snippets = snippets[:maxExplorerAIDocSnippets] } return snippets, root, "" } func findAIWorkspaceRoot() string { candidates := []string{} if envRoot := strings.TrimSpace(os.Getenv("EXPLORER_AI_WORKSPACE_ROOT")); envRoot != "" { candidates = append(candidates, envRoot) } if cwd, err := os.Getwd(); err == nil { candidates = append(candidates, cwd) dir := cwd for i := 0; i < 4; i++ { dir = filepath.Dir(dir) candidates = append(candidates, dir) } } candidates = append(candidates, "/opt/explorer-monorepo", "/home/intlc/projects/proxmox") for _, candidate := range candidates { if candidate == "" { continue } if fileExists(filepath.Join(candidate, "docs")) && (fileExists(filepath.Join(candidate, "explorer-monorepo")) || fileExists(filepath.Join(candidate, "smom-dbis-138")) || fileExists(filepath.Join(candidate, "config"))) { return candidate } } return "" } func scanDocForTerms(fullPath, relativePath string, terms []string) []AIDocSnippet { file, err := os.Open(fullPath) if err != nil { return nil } defer file.Close() normalizedTerms := make([]string, 0, len(terms)) for _, term := range terms { term = strings.ToLower(strings.TrimSpace(term)) if len(term) >= 3 { normalizedTerms = append(normalizedTerms, term) } } scanner := bufio.NewScanner(file) lineNumber := 0 snippets := []AIDocSnippet{} for scanner.Scan() { lineNumber++ line := scanner.Text() lower := strings.ToLower(line) for _, term := range normalizedTerms { if strings.Contains(lower, term) { snippets = append(snippets, AIDocSnippet{ Path: relativePath, Line: lineNumber, Snippet: clipString(strings.TrimSpace(line), 280), }) break } } if len(snippets) >= 2 { break } } return snippets } func buildDocSearchTerms(query string) []string { words := strings.Fields(strings.ToLower(query)) stopWords := map[string]bool{ "what": true, "when": true, "where": true, "which": true, "with": true, "from": true, "that": true, "this": true, "have": true, "about": true, "into": true, "show": true, "live": true, "help": true, "explain": true, "tell": true, } terms := []string{} for _, word := range words { word = strings.Trim(word, ".,:;!?()[]{}\"'") if len(word) < 4 || stopWords[word] { continue } terms = append(terms, word) } for _, match := range addressPattern.FindAllString(query, -1) { terms = append(terms, strings.ToLower(match)) } for _, symbol := range []string{"cUSDT", "cUSDC", "cXAUC", "cEURT", "USDT", "USDC", "WETH", "WETH10", "Mainnet", "bridge", "liquidity", "pool"} { if strings.Contains(strings.ToLower(query), strings.ToLower(symbol)) { terms = append(terms, strings.ToLower(symbol)) } } return uniqueStrings(terms) } func normalizeAIMessages(messages []AIChatMessage) []AIChatMessage { normalized := make([]AIChatMessage, 0, len(messages)) for _, message := range messages { role := strings.ToLower(strings.TrimSpace(message.Role)) if role != "assistant" && role != "user" && role != "system" { continue } content := clipString(strings.TrimSpace(message.Content), maxExplorerAIMessageChars) if content == "" { continue } normalized = append(normalized, AIChatMessage{ Role: role, Content: content, }) } if len(normalized) > maxExplorerAIMessages { normalized = normalized[len(normalized)-maxExplorerAIMessages:] } return normalized } func latestUserMessage(messages []AIChatMessage) string { for i := len(messages) - 1; i >= 0; i-- { if messages[i].Role == "user" { return messages[i].Content } } if len(messages) == 0 { return "" } return messages[len(messages)-1].Content } func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessage, contextEnvelope AIContextEnvelope) (string, string, error) { apiKey := strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) if apiKey == "" { return "", "", fmt.Errorf("OPENAI_API_KEY is not configured") } model := explorerAIModel() baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("OPENAI_BASE_URL")), "/") if baseURL == "" { baseURL = "https://api.openai.com/v1" } contextJSON, _ := json.MarshalIndent(contextEnvelope, "", " ") contextText := clipString(string(contextJSON), maxExplorerAIContextChars) input := []openAIInputMessage{ { Role: "system", Content: []openAIInputContent{ { Type: "input_text", Text: "You are the SolaceScanScout ecosystem assistant for Chain 138. Answer using the supplied indexed explorer data, route inventory, and workspace documentation. Be concise, operationally useful, and explicit about uncertainty. Never claim a route, deployment, or production status is live unless the provided context says it is live. If data is missing, say exactly what is missing.", }, }, }, { Role: "system", Content: []openAIInputContent{ { Type: "input_text", Text: "Retrieved ecosystem context:\n" + contextText, }, }, }, } for _, message := range messages { input = append(input, openAIInputMessage{ Role: message.Role, Content: []openAIInputContent{ { Type: "input_text", Text: message.Content, }, }, }) } payload := openAIResponsesRequest{ Model: model, Input: input, Reasoning: &openAIReasoning{ Effort: explorerAIReasoningEffort(), }, } body, err := json.Marshal(payload) if err != nil { return "", model, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/responses", bytes.NewReader(body)) if err != nil { return "", model, err } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(req) if err != nil { if errors.Is(err, context.DeadlineExceeded) { return "", model, &AIUpstreamError{ StatusCode: http.StatusGatewayTimeout, Code: "upstream_timeout", Message: "explorer ai upstream timed out", Details: "OpenAI request exceeded the configured timeout", } } return "", model, &AIUpstreamError{ StatusCode: http.StatusBadGateway, Code: "upstream_transport_error", Message: "explorer ai upstream transport failed", Details: err.Error(), } } defer resp.Body.Close() responseBody, err := io.ReadAll(resp.Body) if err != nil { return "", model, &AIUpstreamError{ StatusCode: http.StatusBadGateway, Code: "upstream_bad_response", Message: "explorer ai upstream body could not be read", Details: err.Error(), } } if resp.StatusCode >= 400 { return "", model, parseOpenAIError(resp.StatusCode, responseBody) } var response openAIResponsesResponse if err := json.Unmarshal(responseBody, &response); err != nil { return "", model, &AIUpstreamError{ StatusCode: http.StatusBadGateway, Code: "upstream_bad_response", Message: "explorer ai upstream returned invalid JSON", Details: err.Error(), } } reply := strings.TrimSpace(response.OutputText) if reply == "" { reply = strings.TrimSpace(extractOutputText(response.Output)) } if reply == "" { return "", model, &AIUpstreamError{ StatusCode: http.StatusBadGateway, Code: "upstream_bad_response", Message: "explorer ai upstream returned no output text", Details: "OpenAI response did not include output_text or content text", } } if strings.TrimSpace(response.Model) != "" { model = response.Model } return reply, model, nil } func parseOpenAIError(statusCode int, responseBody []byte) error { var parsed struct { Error struct { Message string `json:"message"` Type string `json:"type"` Code string `json:"code"` } `json:"error"` } _ = json.Unmarshal(responseBody, &parsed) details := clipString(strings.TrimSpace(parsed.Error.Message), 280) if details == "" { details = clipString(strings.TrimSpace(string(responseBody)), 280) } switch statusCode { case http.StatusUnauthorized, http.StatusForbidden: return &AIUpstreamError{ StatusCode: statusCode, Code: "upstream_auth_failed", Message: "explorer ai upstream authentication failed", Details: details, } case http.StatusTooManyRequests: return &AIUpstreamError{ StatusCode: statusCode, Code: "upstream_quota_exhausted", Message: "explorer ai upstream quota exhausted", Details: details, } case http.StatusRequestTimeout, http.StatusGatewayTimeout: return &AIUpstreamError{ StatusCode: statusCode, Code: "upstream_timeout", Message: "explorer ai upstream timed out", Details: details, } default: return &AIUpstreamError{ StatusCode: statusCode, Code: "upstream_error", Message: "explorer ai upstream request failed", Details: details, } } } func extractOutputText(items []openAIOutputItem) string { parts := []string{} for _, item := range items { for _, content := range item.Content { if strings.TrimSpace(content.Text) != "" { parts = append(parts, strings.TrimSpace(content.Text)) } } } return strings.Join(parts, "\n\n") } func extractBlockReference(query string) int64 { match := blockRefPattern.FindStringSubmatch(query) if len(match) != 2 { return 0 } var value int64 fmt.Sscan(match[1], &value) return value } func firstRegexMatch(pattern *regexp.Regexp, value string) string { match := pattern.FindString(value) return strings.TrimSpace(match) } func compactStringMap(values map[string]string) map[string]string { if len(values) == 0 { return nil } out := map[string]string{} for key, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { out[key] = trimmed } } if len(out) == 0 { return nil } return out } func compactAnyMap(values map[string]any) map[string]any { out := map[string]any{} for key, value := range values { if value == nil { continue } switch typed := value.(type) { case string: if strings.TrimSpace(typed) == "" { continue } case []string: if len(typed) == 0 { continue } case []any: if len(typed) == 0 { continue } } out[key] = value } return out } func stringValue(value any) string { switch typed := value.(type) { case string: return typed case fmt.Stringer: return typed.String() default: return fmt.Sprintf("%v", value) } } func stringSliceValue(value any) []string { switch typed := value.(type) { case []string: return typed case []any: out := make([]string, 0, len(typed)) for _, item := range typed { out = append(out, stringValue(item)) } return out default: return nil } } func uniqueStrings(values []string) []string { seen := map[string]bool{} out := []string{} for _, value := range values { trimmed := strings.TrimSpace(value) if trimmed == "" || seen[trimmed] { continue } seen[trimmed] = true out = append(out, trimmed) } sort.Strings(out) return out } func clipString(value string, limit int) string { value = strings.TrimSpace(value) if limit <= 0 || len(value) <= limit { return value } return strings.TrimSpace(value[:limit]) + "..." } func fileExists(path string) bool { if path == "" { return false } info, err := os.Stat(path) return err == nil && info != nil }