372 lines
14 KiB
Go
372 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"fire-safety-ymd/internal/domain"
|
|
)
|
|
|
|
const (
|
|
statusOK = "ok"
|
|
statusNoResults = "no_results"
|
|
allTownsScopeWarning = "results_include_all_towns_in_configured_database"
|
|
townAllowlistScopeWarning = "results_limited_to_server_authorized_towns"
|
|
invalidGeometryWarning = "source_records_with_invalid_geometries_are_excluded"
|
|
// MaxPageOffset bounds pagination work and is reused by the MCP input schema.
|
|
MaxPageOffset = 10_000
|
|
)
|
|
|
|
var (
|
|
// ErrInvalidArgument indicates a caller-controlled spatial query is invalid.
|
|
ErrInvalidArgument = errors.New("invalid spatial query argument")
|
|
// ErrDataSourceUnavailable indicates a repository query could not complete safely.
|
|
ErrDataSourceUnavailable = errors.New("spatial data source unavailable")
|
|
// ErrQueryTimeout indicates the bounded repository query timed out.
|
|
ErrQueryTimeout = errors.New("spatial query timeout")
|
|
)
|
|
|
|
// SpatialRepository is the fixed read-only persistence port used by spatial use cases.
|
|
type SpatialRepository interface {
|
|
SearchPlaceCandidates(context.Context, domain.PlaceSearchQuery) (domain.Page[domain.PlaceCandidate], error)
|
|
ResolveIncidentContext(context.Context, domain.PointQuery) (domain.Page[domain.IncidentContext], error)
|
|
FindNearbyWaterSources(context.Context, domain.NearbyQuery) (domain.Page[domain.WaterSource], error)
|
|
FindCommandPostCandidates(context.Context, domain.NearbyQuery) (domain.Page[domain.CommandPostCandidate], error)
|
|
ListNearbyAccessLines(context.Context, domain.NearbyQuery) (domain.Page[domain.AccessLine], error)
|
|
GetResponsibleUnits(context.Context, domain.PointQuery) (domain.Page[domain.ResponsibleUnit], error)
|
|
FindNearbyRiskAreas(context.Context, domain.NearbyQuery) (domain.Page[domain.RiskArea], error)
|
|
}
|
|
|
|
// ResultMetadata records the provenance and bounds of a tool result.
|
|
type ResultMetadata struct {
|
|
GeneratedAt string `json:"generated_at"`
|
|
DataSources []string `json:"data_sources"`
|
|
SpatialReference string `json:"spatial_reference"`
|
|
ResultCount int `json:"result_count"`
|
|
TotalCount int64 `json:"total_count"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
HasMore bool `json:"has_more"`
|
|
NextOffset *int `json:"next_offset"`
|
|
SearchRadiusMeters *float64 `json:"search_radius_meters,omitempty"`
|
|
}
|
|
|
|
// QueryResult is the common structured MCP result envelope.
|
|
type QueryResult[T any] struct {
|
|
Status string `json:"status"`
|
|
Data T `json:"data"`
|
|
Metadata ResultMetadata `json:"metadata"`
|
|
Warnings []string `json:"warnings"`
|
|
}
|
|
|
|
// SpatialService executes bounded read-only forest-fire spatial queries.
|
|
type SpatialService struct {
|
|
repository SpatialRepository
|
|
scope domain.SpatialScope
|
|
queryTimeout time.Duration
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewSpatialService creates a spatial service with a trusted server-side data scope.
|
|
func NewSpatialService(repository SpatialRepository, scope domain.SpatialScope, queryTimeout time.Duration) (*SpatialService, error) {
|
|
if repository == nil {
|
|
return nil, errors.New("spatial repository is required")
|
|
}
|
|
if scope.AllTowns && len(scope.AllowedTowns) > 0 {
|
|
return nil, errors.New("all-towns scope must not include an allowlist")
|
|
}
|
|
if !scope.AllTowns && len(scope.AllowedTowns) == 0 {
|
|
return nil, errors.New("at least one trusted town is required")
|
|
}
|
|
if queryTimeout <= 0 {
|
|
return nil, errors.New("query timeout must be greater than zero")
|
|
}
|
|
return &SpatialService{
|
|
repository: repository,
|
|
scope: domain.SpatialScope{
|
|
AllTowns: scope.AllTowns,
|
|
AllowedTowns: slices.Clone(scope.AllowedTowns),
|
|
},
|
|
queryTimeout: queryTimeout,
|
|
now: time.Now,
|
|
}, nil
|
|
}
|
|
|
|
// SearchPlaceCandidates searches existing fire-safety records without treating a match as a confirmed incident point.
|
|
func (s *SpatialService) SearchPlaceCandidates(ctx context.Context, placeName string, limit, offset int) (QueryResult[[]domain.PlaceCandidate], error) {
|
|
placeName = strings.TrimSpace(placeName)
|
|
if count := len([]rune(placeName)); count < 2 || count > 100 || containsControlCharacter(placeName) {
|
|
return QueryResult[[]domain.PlaceCandidate]{}, fmt.Errorf("%w: place_name must contain 2 to 100 characters without controls", ErrInvalidArgument)
|
|
}
|
|
limit, err := validatePagination(limit, offset, 10)
|
|
if err != nil {
|
|
return QueryResult[[]domain.PlaceCandidate]{}, err
|
|
}
|
|
|
|
queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout)
|
|
defer cancel()
|
|
items, err := s.repository.SearchPlaceCandidates(queryCtx, domain.PlaceSearchQuery{
|
|
PlaceName: placeName,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
Scope: s.scope,
|
|
})
|
|
if err != nil {
|
|
return QueryResult[[]domain.PlaceCandidate]{}, classifyRepositoryError("search place candidates", err)
|
|
}
|
|
return result(items, limit, offset, []string{
|
|
"water_source",
|
|
"storage_pool",
|
|
"fire_access_line",
|
|
"fire_check_station",
|
|
"fire_lookout",
|
|
"fire_grid",
|
|
"forest_enterprise",
|
|
"cemetery_area",
|
|
}, nil, s.now, s.scopeWarning(), []string{
|
|
"place_search_is_limited_to_existing_fire_safety_records",
|
|
"place_candidates_require_user_confirmation",
|
|
"representative_points_are_not_exact_incident_locations",
|
|
}), nil
|
|
}
|
|
|
|
// ResolveIncidentContext returns fire grids covering a WGS84 point.
|
|
func (s *SpatialService) ResolveIncidentContext(ctx context.Context, point domain.Coordinate, limit, offset int) (QueryResult[[]domain.IncidentContext], error) {
|
|
if err := validateCoordinate(point); err != nil {
|
|
return QueryResult[[]domain.IncidentContext]{}, err
|
|
}
|
|
limit, err := validatePagination(limit, offset, 20)
|
|
if err != nil {
|
|
return QueryResult[[]domain.IncidentContext]{}, err
|
|
}
|
|
queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout)
|
|
defer cancel()
|
|
|
|
items, err := s.repository.ResolveIncidentContext(queryCtx, domain.PointQuery{Point: point, Limit: limit, Offset: offset, Scope: s.scope})
|
|
if err != nil {
|
|
return QueryResult[[]domain.IncidentContext]{}, classifyRepositoryError("resolve incident context", err)
|
|
}
|
|
return result(items, limit, offset, []string{"fire_grid"}, nil, s.now, s.scopeWarning(), []string{
|
|
"grid_records_may_be_incomplete_or_stale",
|
|
}), nil
|
|
}
|
|
|
|
// FindNearbyWaterSources returns bounded water-source candidates by distance.
|
|
func (s *SpatialService) FindNearbyWaterSources(ctx context.Context, point domain.Coordinate, radiusMeters float64, limit, offset int) (QueryResult[[]domain.WaterSource], error) {
|
|
query, err := s.nearbyQuery(point, radiusMeters, 10_000, 30_000, limit, offset)
|
|
if err != nil {
|
|
return QueryResult[[]domain.WaterSource]{}, err
|
|
}
|
|
queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout)
|
|
defer cancel()
|
|
|
|
items, err := s.repository.FindNearbyWaterSources(queryCtx, query)
|
|
if err != nil {
|
|
return QueryResult[[]domain.WaterSource]{}, classifyRepositoryError("find nearby water sources", err)
|
|
}
|
|
return result(items, query.Limit, query.Offset, []string{
|
|
"water_source",
|
|
"storage_pool",
|
|
}, &query.RadiusMeters, s.now, s.scopeWarning(), []string{
|
|
"resource_availability_not_verified",
|
|
"distance_is_spatial_proximity_not_operational_accessibility",
|
|
"source_timestamp_raw_unit_and_timezone_are_unconfirmed",
|
|
}), nil
|
|
}
|
|
|
|
// FindCommandPostCandidates returns existing nearby facilities that require field assessment.
|
|
func (s *SpatialService) FindCommandPostCandidates(ctx context.Context, point domain.Coordinate, radiusMeters float64, limit, offset int) (QueryResult[[]domain.CommandPostCandidate], error) {
|
|
query, err := s.nearbyQuery(point, radiusMeters, 10_000, 20_000, limit, offset)
|
|
if err != nil {
|
|
return QueryResult[[]domain.CommandPostCandidate]{}, err
|
|
}
|
|
queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout)
|
|
defer cancel()
|
|
|
|
items, err := s.repository.FindCommandPostCandidates(queryCtx, query)
|
|
if err != nil {
|
|
return QueryResult[[]domain.CommandPostCandidate]{}, classifyRepositoryError("find command post candidates", err)
|
|
}
|
|
return result(items, query.Limit, query.Offset, []string{
|
|
"fire_check_station",
|
|
"fire_lookout",
|
|
}, &query.RadiusMeters, s.now, s.scopeWarning(), []string{
|
|
"candidate_only_requires_field_safety_communications_capacity_and_access_review",
|
|
"facility_availability_not_verified",
|
|
}), nil
|
|
}
|
|
|
|
// ListNearbyAccessLines returns nearby mapped fire-access lines, not calculated routes.
|
|
func (s *SpatialService) ListNearbyAccessLines(ctx context.Context, point domain.Coordinate, radiusMeters float64, limit, offset int) (QueryResult[[]domain.AccessLine], error) {
|
|
query, err := s.nearbyQuery(point, radiusMeters, 5_000, 10_000, limit, offset)
|
|
if err != nil {
|
|
return QueryResult[[]domain.AccessLine]{}, err
|
|
}
|
|
queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout)
|
|
defer cancel()
|
|
|
|
items, err := s.repository.ListNearbyAccessLines(queryCtx, query)
|
|
if err != nil {
|
|
return QueryResult[[]domain.AccessLine]{}, classifyRepositoryError("list nearby access lines", err)
|
|
}
|
|
return result(items, query.Limit, query.Offset, []string{"fire_access_line"}, &query.RadiusMeters, s.now, s.scopeWarning(), []string{
|
|
"candidate_lines_only_not_a_route_plan",
|
|
"passability_surface_width_slope_vehicle_limits_and_closures_are_unknown",
|
|
"source_updated_raw_has_no_confirmed_timezone",
|
|
}), nil
|
|
}
|
|
|
|
// GetResponsibleUnits returns non-personal team responsibility recorded on covering grids.
|
|
func (s *SpatialService) GetResponsibleUnits(ctx context.Context, point domain.Coordinate, limit, offset int) (QueryResult[[]domain.ResponsibleUnit], error) {
|
|
if err := validateCoordinate(point); err != nil {
|
|
return QueryResult[[]domain.ResponsibleUnit]{}, err
|
|
}
|
|
limit, err := validatePagination(limit, offset, 20)
|
|
if err != nil {
|
|
return QueryResult[[]domain.ResponsibleUnit]{}, err
|
|
}
|
|
queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout)
|
|
defer cancel()
|
|
|
|
items, err := s.repository.GetResponsibleUnits(queryCtx, domain.PointQuery{Point: point, Limit: limit, Offset: offset, Scope: s.scope})
|
|
if err != nil {
|
|
return QueryResult[[]domain.ResponsibleUnit]{}, classifyRepositoryError("get responsible units", err)
|
|
}
|
|
return result(items, limit, offset, []string{"fire_grid"}, nil, s.now, s.scopeWarning(), []string{
|
|
"source_does_not_store_live_team_positions",
|
|
"source_does_not_store_confirmed_assembly_sites_or_readiness",
|
|
}), nil
|
|
}
|
|
|
|
// FindNearbyRiskAreas returns nearby cemetery and forest-enterprise polygons.
|
|
func (s *SpatialService) FindNearbyRiskAreas(ctx context.Context, point domain.Coordinate, radiusMeters float64, limit, offset int) (QueryResult[[]domain.RiskArea], error) {
|
|
query, err := s.nearbyQuery(point, radiusMeters, 3_000, 10_000, limit, offset)
|
|
if err != nil {
|
|
return QueryResult[[]domain.RiskArea]{}, err
|
|
}
|
|
queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout)
|
|
defer cancel()
|
|
|
|
items, err := s.repository.FindNearbyRiskAreas(queryCtx, query)
|
|
if err != nil {
|
|
return QueryResult[[]domain.RiskArea]{}, classifyRepositoryError("find nearby risk areas", err)
|
|
}
|
|
return result(items, query.Limit, query.Offset, []string{
|
|
"cemetery_area",
|
|
"forest_enterprise",
|
|
}, &query.RadiusMeters, s.now, s.scopeWarning(), []string{
|
|
"risk_records_may_be_incomplete_or_stale",
|
|
"proximity_does_not_establish_current_hazard_severity",
|
|
}), nil
|
|
}
|
|
|
|
func (s *SpatialService) nearbyQuery(point domain.Coordinate, radiusMeters, defaultRadius, maximumRadius float64, limit, offset int) (domain.NearbyQuery, error) {
|
|
if err := validateCoordinate(point); err != nil {
|
|
return domain.NearbyQuery{}, err
|
|
}
|
|
if radiusMeters == 0 {
|
|
radiusMeters = defaultRadius
|
|
}
|
|
if radiusMeters < 100 || radiusMeters > maximumRadius || math.IsNaN(radiusMeters) || math.IsInf(radiusMeters, 0) {
|
|
return domain.NearbyQuery{}, fmt.Errorf("%w: radius_meters must be between 100 and %.0f", ErrInvalidArgument, maximumRadius)
|
|
}
|
|
limit, err := validatePagination(limit, offset, 10)
|
|
if err != nil {
|
|
return domain.NearbyQuery{}, err
|
|
}
|
|
return domain.NearbyQuery{
|
|
Point: point,
|
|
RadiusMeters: radiusMeters,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
Scope: s.scope,
|
|
}, nil
|
|
}
|
|
|
|
func validateCoordinate(point domain.Coordinate) error {
|
|
if math.IsNaN(point.Longitude) || math.IsInf(point.Longitude, 0) || point.Longitude < -180 || point.Longitude > 180 {
|
|
return fmt.Errorf("%w: longitude must be between -180 and 180", ErrInvalidArgument)
|
|
}
|
|
if math.IsNaN(point.Latitude) || math.IsInf(point.Latitude, 0) || point.Latitude < -90 || point.Latitude > 90 {
|
|
return fmt.Errorf("%w: latitude must be between -90 and 90", ErrInvalidArgument)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validatePagination(limit, offset, defaultLimit int) (int, error) {
|
|
if limit == 0 {
|
|
limit = defaultLimit
|
|
}
|
|
if limit < 1 || limit > 20 {
|
|
return 0, fmt.Errorf("%w: limit must be between 1 and 20", ErrInvalidArgument)
|
|
}
|
|
if offset < 0 || offset > MaxPageOffset {
|
|
return 0, fmt.Errorf("%w: offset must be between 0 and %d", ErrInvalidArgument, MaxPageOffset)
|
|
}
|
|
return limit, nil
|
|
}
|
|
|
|
func containsControlCharacter(value string) bool {
|
|
for _, character := range value {
|
|
if unicode.IsControl(character) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func classifyRepositoryError(action string, err error) error {
|
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
|
return fmt.Errorf("%w: %s", ErrQueryTimeout, action)
|
|
}
|
|
return fmt.Errorf("%w: %s: %w", ErrDataSourceUnavailable, action, err)
|
|
}
|
|
|
|
func (s *SpatialService) scopeWarning() string {
|
|
if s.scope.AllTowns {
|
|
return allTownsScopeWarning
|
|
}
|
|
return townAllowlistScopeWarning
|
|
}
|
|
|
|
func result[T any](page domain.Page[T], limit, offset int, dataSources []string, radius *float64, now func() time.Time, scopeWarning string, warnings []string) QueryResult[[]T] {
|
|
if page.Items == nil {
|
|
page.Items = []T{}
|
|
}
|
|
status := statusOK
|
|
if page.TotalCount == 0 {
|
|
status = statusNoResults
|
|
}
|
|
hasMore := int64(offset+len(page.Items)) < page.TotalCount
|
|
var nextOffset *int
|
|
if hasMore {
|
|
next := offset + len(page.Items)
|
|
nextOffset = &next
|
|
}
|
|
resultWarnings := []string{scopeWarning, invalidGeometryWarning}
|
|
resultWarnings = append(resultWarnings, warnings...)
|
|
return QueryResult[[]T]{
|
|
Status: status,
|
|
Data: page.Items,
|
|
Metadata: ResultMetadata{
|
|
GeneratedAt: now().UTC().Format(time.RFC3339Nano),
|
|
DataSources: slices.Clone(dataSources),
|
|
SpatialReference: "EPSG:4326",
|
|
ResultCount: len(page.Items),
|
|
TotalCount: page.TotalCount,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
HasMore: hasMore,
|
|
NextOffset: nextOffset,
|
|
SearchRadiusMeters: radius,
|
|
},
|
|
Warnings: resultWarnings,
|
|
}
|
|
}
|