package adminstore import ( "context" "encoding/json" "math" "time" "github.com/jackc/pgx/v5" ) // Quality weights mirror ingestion/opengoods/etl/quality.py. const ( wCompleteness = 0.4 wSourceTrust = 0.3 wAgreement = 0.2 wFreshness = 0.1 ) // queryer is satisfied by both *pgxpool.Pool and pgx.Tx. type queryer interface { QueryRow(ctx context.Context, sql string, args ...any) pgx.Row } func agreementFromSources(n int) float64 { switch { case n <= 1: return 0.5 case n == 2: return 0.8 default: return 1.0 } } func freshnessFromAge(ageDays *float64) float64 { if ageDays == nil { return 0.5 } d := *ageDays switch { case d <= 30: return 1.0 case d <= 180: return 0.8 case d <= 365: return 0.6 case d <= 730: return 0.4 default: return 0.2 } } func (s *Store) computeQuality(ctx context.Context, q queryer, productID string) (float64, error) { var name, country *string var gtin *string var brandID, categoryID *string var netCanonical *float64 var ingredients *string var hasNutri, hasImage bool var kind string var attributes []byte err := q.QueryRow(ctx, ` SELECT p.name, p.gtin, p.brand_id, p.category_id, p.net_content_canonical, p.country_of_origin, COALESCE(c.archive_kind, 'generic'), p.attributes, f.ingredients_text, (f.nutriments IS NOT NULL AND f.nutriments::text <> '{}'), EXISTS (SELECT 1 FROM product_image pi WHERE pi.product_id = p.id) FROM product p LEFT JOIN category c ON c.id = p.category_id LEFT JOIN food_detail f ON f.product_id = p.id WHERE p.id = $1`, productID).Scan( &name, >in, &brandID, &categoryID, &netCanonical, &country, &kind, &attributes, &ingredients, &hasNutri, &hasImage) if err != nil { return 0, err } attrs := map[string]any{} if len(attributes) > 0 { _ = json.Unmarshal(attributes, &attrs) } qualified, err := s.kindQualifiedKeys(ctx, s.pool) if err != nil { return 0, err } qkeys := qualified[kind] known := map[string]bool{ "name": name != nil && *name != "", "gtin": gtin != nil && *gtin != "", "brand": brandID != nil, "category": categoryID != nil, "net_content": netCanonical != nil, "country_of_origin": country != nil && *country != "", "nutriments": hasNutri, "ingredients": ingredients != nil && *ingredients != "", "image": hasImage, } for _, k := range qkeys { known[k] = attrPresent(attrs, k) } keys := completenessKeys(kind, qkeys) present := 0 for _, k := range keys { if known[k] { present++ } } completeness := float64(present) / float64(len(keys)) var sourceCount int var sourceTrust *float64 var lastFetched *time.Time err = q.QueryRow(ctx, ` SELECT count(DISTINCT ps.source_id), COALESCE(max(s.trust_weight),0), max(ps.fetched_at) FROM product_source ps LEFT JOIN source s ON s.id = ps.source_id WHERE ps.product_id = $1`, productID).Scan(&sourceCount, &sourceTrust, &lastFetched) if err != nil { return 0, err } trust := 0.0 if sourceTrust != nil { trust = *sourceTrust } var ageDays *float64 if lastFetched != nil { d := time.Since(*lastFetched).Hours() / 24.0 if d < 0 { d = 0 } ageDays = &d } raw := wCompleteness*completeness + wSourceTrust*trust + wAgreement*agreementFromSources(sourceCount) + wFreshness*freshnessFromAge(ageDays) raw = math.Max(0, math.Min(1, raw)) return math.Round(raw*1000) / 1000, nil } func (s *Store) recomputeQualityTx(ctx context.Context, tx pgx.Tx, productID string) (float64, error) { v, err := s.computeQuality(ctx, tx, productID) if err != nil { return 0, err } _, err = tx.Exec(ctx, "UPDATE product SET quality_score=$1 WHERE id=$2", v, productID) return v, err } func (s *Store) recomputeQuality(ctx context.Context, productID string) (float64, error) { v, err := s.computeQuality(ctx, s.pool, productID) if err != nil { return 0, err } _, err = s.pool.Exec(ctx, "UPDATE product SET quality_score=$1 WHERE id=$2", v, productID) return v, err }