Decomposes backend/api/rest/ai.go (which the review flagged at 1180 lines
and which was the largest file in the repo by a wide margin) into six
purpose-built files inside the same package, so no import paths change
for any caller and *Server receivers keep working:
ai.go 198 handlers + feature flags + exported AI* DTOs
ai_context.go 381 buildAIContext + indexed-DB queries
(stats / tx / address / block) + regex patterns +
extractBlockReference
ai_routes.go 139 queryAIRoutes + filterAIRouteMatches +
routeMatchesQuery + normalizeHexString
ai_docs.go 136 loadAIDocSnippets + findAIWorkspaceRoot +
scanDocForTerms + buildDocSearchTerms
ai_xai.go 267 xAI / OpenAI request/response types +
normalizeAIMessages + latestUserMessage +
callXAIChatCompletions + parseXAIError +
extractOutputText
ai_helpers.go 112 pure-function utilities (firstRegexMatch,
compactStringMap, compactAnyMap, stringValue,
stringSliceValue, uniqueStrings, clipString,
fileExists)
ai_runtime.go (rate limiter + metrics + audit log) is unchanged.
This is a pure move: no logic changes, no new public API, no changes to
HTTP routes. Each file carries only the imports it actually uses so
goimports is clean on every file individually. Every exported symbol
retained its original spelling so callers (routes.go, server.go, and
the AI e2e tests) keep compiling without edits.
Verification:
go build ./... clean
go vet ./... clean
go test ./api/rest/... PASS
staticcheck ./... clean on the SA* correctness family
Advances completion criterion 6 (backend maintainability): 'no single
Go file exceeds a few hundred lines; AI/LLM plumbing is separated from
HTTP handlers; context-building is separated from upstream calls.'
382 lines
12 KiB
Go
382 lines
12 KiB
Go
package rest
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
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`)
|
|
)
|
|
|
|
func (s *Server) buildAIContext(ctx context.Context, query string, pageContext map[string]string) (AIContextEnvelope, []string) {
|
|
warnings := []string{}
|
|
envelope := AIContextEnvelope{
|
|
ChainID: s.chainID,
|
|
Explorer: "SolaceScan",
|
|
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(*) FROM (
|
|
SELECT from_address AS address
|
|
FROM transactions
|
|
WHERE chain_id = $1 AND from_address IS NOT NULL AND from_address <> ''
|
|
UNION
|
|
SELECT to_address AS address
|
|
FROM transactions
|
|
WHERE chain_id = $1 AND to_address IS NOT NULL AND to_address <> ''
|
|
) unique_addresses`, 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()
|
|
|
|
address = normalizeAddress(address)
|
|
|
|
result := map[string]any{
|
|
"address": address,
|
|
}
|
|
|
|
var txCount int64
|
|
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(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_contract) FROM token_transfers WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(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 (LOWER(from_address) = $2 OR LOWER(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 extractBlockReference(query string) int64 {
|
|
match := blockRefPattern.FindStringSubmatch(query)
|
|
if len(match) != 2 {
|
|
return 0
|
|
}
|
|
var value int64
|
|
fmt.Sscan(match[1], &value)
|
|
return value
|
|
}
|