Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/platform/forecast/forecast.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/platform/forecast/forecast.go |
| Original SHA-256 | 0a04c4c29c7a74792462a3123acec9b8161718bdcd213db42273c024ffb0f3fd |
| Git blob SHA | 212b38616c705a5e4f5ec23db96cf3873e84faa0 |
| Reading-copy SHA-256 | 0a04c4c29c7a74792462a3123acec9b8161718bdcd213db42273c024ffb0f3fd |
Every recorded occurrence
These 33 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / internal/platform/forecast/forecast.go | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | per-tenant-routing | 9bde0f9a74b89d01980fa658e5253acda7528d0c | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | collision-deconfliction | 4ee5a09972608ed89f8377c6665542d5f450f0a6 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | ws6-retrieval | 6c56e29231ec05735927fa265c97d517499dfeaf | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | workstream-a-shared-core | e0de03ac3a1ec826bd15d0e048e3b02baa23eeff | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | m5-price-to-win | 41000ad30fff4129fdaf5ec84e15ba14c27d4890 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | workstream-c-slatematch | 5cd60c918301b46ecd6ed3e03e20c395da0062e2 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | ws1-ontology-sync | b596f449a32a1016fbd403c36505cc00a1ac9ee6 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | master | 1e9249840a4302ec82e123b9c9cb6f75badea3e4 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | ws8-teaching-loop | c823f0fb6bb2aba0f39001eb6de96f4000d9321f | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | w3-ai-staffing-recommendations | 43d56387270ef4da4cabd794d97ca6a49909bc7d | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | mag-substrate | b82c7100a492dec67dfd1eda6b3f53def859be35 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | ws3-clearance | 4b8e7ce594c068621867f9c9deed0ec64ae6c553 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | workstream-d-command-center | 255e7d1d420abf5e277d0cd8c9b14f6f72b6acb9 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | phase2-eval-fairness | d361af254701975fe239927656ababb56374655e | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | workstream-e-dataflow-deploy | 433815a042f9741411e60fa553fe72143e9b10db | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | ruthless-debug-sweep | b7d742f8f032984be3817efffa3eb8b13b651c1f | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | time-to-fill-survival | b66e1e505863d4f17741a4b9ede73509e45bd6c6 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | ai-intelligence-layer | 5d5ddff70393a197c5f54a45280bb5dd42b24755 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | graphrag-foundation | b0ddeb9f633fd138d09309d1a17b7d13bc3a67f7 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | deploy-and-ops | bdacd9d0b4d5998d25fc74bf4007f4c3ec538c7a | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | ws9-bench-ingestion | f585a9ef32fa554663ce1fba9a414a54b6e49cf0 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | ws2-alias-relationship | e4dada4601c6eb101830f9d223a9655dfa120616 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | presentation-readiness | eb574acaa29d71ce4d0f852572e87c275c79fdb7 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | vendor-intelligence | fe9796a19c261951d3f2565206c0c1b5ac294465 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | workstream-b-screenmatch | 09015496d565a172bcaa15cfe9a8e4b072b13df0 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | ws7-federal-signal | 6a9f6ce9cfb13345ffb47770d4960e7c2d9c17c2 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | workstream-e-commercial-stubs | 4437da660539284a400ee380b9b427a300c369ee | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | ai-brain-live | aac32fc49c878cc5bca099459307611562d0bac1 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | workstream-d-missionmatch | 3be46ba89edb737182075a2b320509b42dc916f8 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | phase2-connectors | e038d4c687aec16f8bd9bc4d907eea14d91f8704 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
| Janady13/missionmatch / internal/platform/forecast/forecast.go | capture-readiness | bae6af7cb2997e819174a9e9cfff5e16067a1975 | Open original |
Recovered source transcript
Download complete source text ↓248 lines · 7.7 KB
// Package forecast normalizes agency contract forecasts (planned, not yet posted opportunities) from
// CSV exports into a flat record. Most agencies, and the governmentwide GSA Forecast of Contracting
// Opportunities, publish forecasts as a CSV with their own column names and no clean API, so the parser
// is mapping driven: each source supplies a Mapping from the normalized fields to its candidate CSV
// header names, and the same parser handles every source without new code. Forecasts are planning data
// subject to change, never a posted solicitation. Money and dates are parsed only from a real single
// value, never fabricated from a range or a category.
package forecast
import (
"encoding/csv"
"errors"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"time"
"github.com/shopspring/decimal"
)
// Record is one normalized forecast row. Optional money and dates are pointers (nil = absent). Raw keeps
// the original row keyed by header for provenance.
type Record struct {
SourceID string
Title string
Agency string
Organization string
NAICS string
PlaceState string
SetAside string // acquisition strategy or set aside status
ContractType string
AwardStatus string
EstimatedValue *decimal.Decimal
EstimatedAward *time.Time
Raw map[string]string
}
// Field is a normalized forecast field the parser maps a source column onto.
type Field string
const (
FieldSourceID Field = "source_id"
FieldTitle Field = "title"
FieldAgency Field = "agency"
FieldOrganization Field = "organization"
FieldNAICS Field = "naics"
FieldPlaceState Field = "place_state"
FieldSetAside Field = "set_aside"
FieldContractType Field = "contract_type"
FieldAwardStatus Field = "award_status"
FieldEstimatedValue Field = "estimated_value"
FieldEstimatedAward Field = "estimated_award_date"
)
// Mapping gives, for each field, the candidate CSV header names to look for. Matching is case
// insensitive and the first candidate that exists wins, so one source definition tolerates header drift
// between exports.
type Mapping map[Field][]string
// Parse reads a CSV and maps each row to a Record using the mapping. The first row is the header. A row
// with no identity (no source id and no title) is skipped, and a malformed row is skipped rather than
// fatal, so one bad line never loses the whole export. The count of skipped rows is returned for the
// caller to log.
func Parse(r io.Reader, m Mapping) (records []Record, skipped int, err error) {
cr := csv.NewReader(r)
cr.FieldsPerRecord = -1 // tolerate ragged rows from a hand exported CSV
cr.TrimLeadingSpace = true
header, err := cr.Read()
if err != nil {
return nil, 0, fmt.Errorf("forecast: read header: %w", err)
}
col := resolveColumns(header, m)
for {
row, e := cr.Read()
if errors.Is(e, io.EOF) {
break
}
if e != nil {
skipped++
continue
}
rec, ok := mapRow(row, header, col)
if !ok {
skipped++
continue
}
records = append(records, rec)
}
return records, skipped, nil
}
// resolveColumns maps each field to a column index using the first matching candidate header, or minus
// one when no candidate header is present in this export.
func resolveColumns(header []string, m Mapping) map[Field]int {
pos := make(map[string]int, len(header))
for i, h := range header {
pos[normHeader(h)] = i
}
out := make(map[Field]int, len(m))
for field, candidates := range m {
idx := -1
for _, c := range candidates {
if i, ok := pos[normHeader(c)]; ok {
idx = i
break
}
}
out[field] = idx
}
return out
}
func mapRow(row, header []string, col map[Field]int) (Record, bool) {
get := func(f Field) string {
i := col[f]
if i < 0 || i >= len(row) {
return ""
}
return strings.TrimSpace(row[i])
}
rec := Record{
SourceID: get(FieldSourceID),
Title: get(FieldTitle),
Agency: get(FieldAgency),
Organization: get(FieldOrganization),
NAICS: get(FieldNAICS),
PlaceState: get(FieldPlaceState),
SetAside: get(FieldSetAside),
ContractType: get(FieldContractType),
AwardStatus: get(FieldAwardStatus),
EstimatedValue: parseValue(get(FieldEstimatedValue)),
EstimatedAward: parseDate(get(FieldEstimatedAward)),
}
if rec.SourceID == "" && rec.Title == "" {
return Record{}, false
}
rec.Raw = make(map[string]string, len(header))
for i, h := range header {
if i < len(row) {
rec.Raw[h] = strings.TrimSpace(row[i])
}
}
return rec, true
}
func normHeader(h string) string {
return strings.ToLower(strings.TrimSpace(h))
}
var (
quarterRe = regexp.MustCompile(`Q([1-4])`)
ordinalQRe = regexp.MustCompile(`([1-4])(?:ST|ND|RD|TH)?\s+QUARTER`)
yearRe = regexp.MustCompile(`(20\d\d)`)
// numberRe is anchored to the whole trimmed field so a value is only read when the entire field is a
// single dollar amount. Without the anchors, descriptive text like "Approx. 500K" would still yield
// one match and parseValue would read a figure the forecast never stated.
numberRe = regexp.MustCompile(`^\$?\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*(K|M|B|THOUSAND|MILLION|BILLION)?$`)
)
// parseDate normalizes the estimated award date. It accepts an explicit date in several layouts, a month
// and year, and a federal fiscal quarter (mapped to the quarter's first month, since a forecast states a
// quarter not a day). TBD and similar placeholders, and anything unrecognized, return nil rather than a
// fabricated date.
func parseDate(s string) *time.Time {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
up := strings.ToUpper(s)
switch up {
case "TBD", "N/A", "NA", "UNKNOWN", "PENDING":
return nil
}
layouts := []string{
"2006-01-02", "01/02/2006", "1/2/2006", time.RFC3339,
"January 2, 2006", "Jan 2, 2006", "2006-01", "01/2006", "1/2006",
"January 2006", "Jan 2006", "Jan-2006",
}
for _, l := range layouts {
if t, err := time.Parse(l, s); err == nil {
d := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
return &d
}
}
return parseFiscalQuarter(up)
}
// parseFiscalQuarter maps a stated federal fiscal quarter to the quarter's first calendar month, the
// honest sortable approximation of a quarter (the original text is retained in the record's Raw). The
// federal fiscal year starts in October, so FY Q1 is October of the prior calendar year.
func parseFiscalQuarter(up string) *time.Time {
q := 0
if mm := quarterRe.FindStringSubmatch(up); mm != nil {
q, _ = strconv.Atoi(mm[1])
} else if mm := ordinalQRe.FindStringSubmatch(up); mm != nil {
q, _ = strconv.Atoi(mm[1])
}
ym := yearRe.FindStringSubmatch(up)
if q == 0 || ym == nil {
return nil
}
fy, _ := strconv.Atoi(ym[1])
var month int
var year int
switch q {
case 1:
month, year = 10, fy-1
case 2:
month, year = 1, fy
case 3:
month, year = 4, fy
case 4:
month, year = 7, fy
}
d := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
return &d
}
// parseValue parses the estimated value only when the field is a single clean dollar amount (optionally
// with a K, M, or B suffix). A range, a category, or two numbers yields nil, because representing a range
// as one point would fabricate a figure the forecast does not state. The raw text stays in the record.
func parseValue(s string) *decimal.Decimal {
up := strings.ToUpper(strings.TrimSpace(s))
if up == "" {
return nil
}
matches := numberRe.FindAllStringSubmatch(up, -1)
if len(matches) != 1 {
return nil
}
digits := strings.ReplaceAll(matches[0][1], ",", "")
d, err := decimal.NewFromString(digits)
if err != nil {
return nil
}
switch matches[0][2] {
case "K", "THOUSAND":
d = d.Mul(decimal.NewFromInt(1000))
case "M", "MILLION":
d = d.Mul(decimal.NewFromInt(1000000))
case "B", "BILLION":
d = d.Mul(decimal.NewFromInt(1000000000))
}
return &d
}