BuyingAgent/backend/migrations/versions/0002_domain_schema.py

234 lines
12 KiB
Python
Raw Permalink Normal View History

"""Global product/price schema for v0.1. No user or workspace columns.
Revision ID: 0002_domain_schema
Revises: 0001_deploy_skeleton
Create Date: 2026-10-03
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0002_domain_schema"
down_revision: str | None = "0001_deploy_skeleton"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"attribute_definitions",
sa.Column("key", sa.String(128), primary_key=True),
sa.Column("value_type", sa.String(32), nullable=False),
sa.Column("canonical_unit", sa.String(32)),
sa.Column("minimum", sa.Numeric(12, 4)),
sa.Column("maximum", sa.Numeric(12, 4)),
sa.Column("description", sa.Text()),
sa.CheckConstraint(
"value_type IN ('number', 'string', 'boolean', 'string_list')",
name="ck_attribute_definitions_value_type",
),
)
op.create_table(
"sources",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("adapter_type", sa.String(64), nullable=False),
)
op.create_table(
"scoring_models",
sa.Column("version", sa.String(64), primary_key=True),
sa.Column("algorithm_id", sa.String(128), nullable=False),
sa.Column("definition", postgresql.JSONB, nullable=False),
sa.Column("definition_hash", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_table(
"product_profiles",
sa.Column("id", sa.String(128), primary_key=True),
sa.Column("version", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(256), nullable=False),
sa.Column("category", sa.String(128), nullable=False),
sa.Column("scoring_version", sa.String(64), sa.ForeignKey("scoring_models.version"), nullable=False),
sa.Column("definition", postgresql.JSONB, nullable=False),
sa.Column("definition_hash", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint("version > 0", name="ck_product_profiles_version"),
)
op.create_table(
"products",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("category", sa.String(128), nullable=False),
sa.Column("brand", sa.String(128), nullable=False),
sa.Column("name", sa.String(256), nullable=False),
sa.Column("model", sa.String(128)),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_table(
"product_variants",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("product_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("products.id"), nullable=False),
sa.Column("name", sa.String(256), nullable=False),
sa.Column("manufacturer_sku", sa.String(128)),
sa.Column("identifiers", postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_product_variants_product_id", "product_variants", ["product_id"])
op.create_table(
"variant_attributes",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("variant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_variants.id"), nullable=False),
sa.Column("attribute_key", sa.String(128), sa.ForeignKey("attribute_definitions.key"), nullable=False),
sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False),
sa.Column("raw_value", postgresql.JSONB, nullable=False),
sa.Column("normalized_value", postgresql.JSONB),
sa.Column("unit", sa.String(32)),
sa.Column("normalization_status", sa.String(32), nullable=False),
sa.Column("normalizer_version", sa.String(64), nullable=False),
sa.Column("confidence", sa.Numeric(4, 3), nullable=False),
sa.Column("source_reference", sa.String(256), nullable=False),
sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ingested_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("normalization_error", sa.Text()),
sa.CheckConstraint("confidence >= 0 AND confidence <= 1", name="ck_variant_attributes_confidence"),
sa.CheckConstraint(
"normalization_status IN ('normalized', 'unknown')",
name="ck_variant_attributes_status",
),
)
op.execute(
"CREATE INDEX ix_variant_attributes_lookup ON variant_attributes "
"(variant_id, attribute_key, observed_at DESC, ingested_at DESC, id DESC)"
)
op.create_table(
"offers",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("variant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_variants.id"), nullable=False),
sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False),
sa.Column("external_id", sa.String(256), nullable=False),
sa.Column("seller", sa.String(256), nullable=False),
sa.Column("url", sa.Text()),
sa.Column("market", sa.String(16), nullable=False),
sa.Column("currency", sa.String(3), nullable=False),
sa.Column("condition", sa.String(32), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("source_id", "external_id", name="uq_offers_source_external"),
sa.CheckConstraint("currency ~ '^[A-Z]{3}$'", name="ck_offers_currency"),
sa.CheckConstraint("condition IN ('new', 'refurbished', 'used')", name="ck_offers_condition"),
)
op.create_index("ix_offers_variant_id", "offers", ["variant_id"])
op.create_table(
"price_observations",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("offer_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("offers.id"), nullable=False),
sa.Column("source_id", sa.String(64), sa.ForeignKey("sources.id"), nullable=False),
sa.Column("external_observation_id", sa.String(256), nullable=False),
sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ingested_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("item_price", sa.Numeric(12, 2), nullable=False),
sa.Column("shipping_price", sa.Numeric(12, 2)),
sa.Column("currency", sa.String(3), nullable=False),
sa.Column("availability", sa.String(32), nullable=False),
sa.Column("raw_payload", postgresql.JSONB, nullable=False),
sa.Column("confidence", sa.Numeric(4, 3), nullable=False),
sa.UniqueConstraint("offer_id", "external_observation_id", name="uq_price_obs_offer_external"),
sa.CheckConstraint("item_price > 0", name="ck_price_obs_item_price"),
sa.CheckConstraint("shipping_price IS NULL OR shipping_price >= 0", name="ck_price_obs_shipping"),
sa.CheckConstraint("confidence >= 0 AND confidence <= 1", name="ck_price_obs_confidence"),
sa.CheckConstraint(
"availability IN ('in_stock', 'out_of_stock', 'unknown')",
name="ck_price_obs_availability",
),
sa.CheckConstraint("currency ~ '^[A-Z]{3}$'", name="ck_price_obs_currency"),
)
op.execute(
"CREATE INDEX ix_price_observations_lookup ON price_observations "
"(offer_id, observed_at DESC, ingested_at DESC, id DESC)"
)
op.create_table(
"evaluations",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("variant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_variants.id"), nullable=False),
sa.Column("offer_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("offers.id"), nullable=False),
sa.Column("profile_id", sa.String(128), nullable=False),
sa.Column("profile_version", sa.Integer(), nullable=False),
sa.Column("scoring_version", sa.String(64), sa.ForeignKey("scoring_models.version"), nullable=False),
sa.Column("as_of", sa.DateTime(timezone=True), nullable=False),
sa.Column("snapshot_cutoff", sa.DateTime(timezone=True), nullable=False),
sa.Column("scoring_hash", sa.String(64), nullable=False),
sa.Column("engine_build", sa.String(64), nullable=False),
sa.Column("product_eligibility", sa.String(16), nullable=False),
sa.Column("offer_eligibility", sa.String(16), nullable=False),
sa.Column("overall_eligibility", sa.String(16), nullable=False),
sa.Column("product_score", sa.Numeric(6, 2)),
sa.Column("price_score", sa.Numeric(6, 2)),
sa.Column("deal_score", sa.Numeric(6, 2)),
sa.Column("classification", sa.String(32), nullable=False),
sa.Column("price_reference_scope", sa.String(32), nullable=False),
sa.Column("input_snapshot", postgresql.JSONB, nullable=False),
sa.Column("result_snapshot", postgresql.JSONB, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["profile_id", "profile_version"],
["product_profiles.id", "product_profiles.version"],
name="fk_evaluations_profile",
),
sa.CheckConstraint(
"product_eligibility IN ('ELIGIBLE', 'INELIGIBLE', 'UNKNOWN')",
name="ck_eval_product_eligibility",
),
sa.CheckConstraint(
"offer_eligibility IN ('ELIGIBLE', 'INELIGIBLE', 'UNKNOWN')",
name="ck_eval_offer_eligibility",
),
sa.CheckConstraint(
"overall_eligibility IN ('ELIGIBLE', 'INELIGIBLE', 'UNKNOWN')",
name="ck_eval_overall_eligibility",
),
sa.CheckConstraint(
"product_score IS NULL OR (product_score >= 0 AND product_score <= 100)",
name="ck_eval_product_score",
),
sa.CheckConstraint(
"price_score IS NULL OR (price_score >= 0 AND price_score <= 100)",
name="ck_eval_price_score",
),
sa.CheckConstraint(
"deal_score IS NULL OR (deal_score >= 0 AND deal_score <= 100)",
name="ck_eval_deal_score",
),
)
op.create_index("ix_evaluations_variant_id", "evaluations", ["variant_id"])
op.execute(
"CREATE INDEX ix_evaluations_profile_created ON evaluations "
"(profile_id, profile_version, created_at DESC)"
)
op.create_table(
"evaluation_components",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("evaluation_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("evaluations.id"), nullable=False),
sa.Column("kind", sa.String(64), nullable=False),
sa.Column("component_key", sa.String(128), nullable=False),
sa.Column("raw_score", sa.Numeric(12, 6)),
sa.Column("weight", sa.Numeric(12, 6)),
sa.Column("contribution", sa.Numeric(12, 6)),
sa.Column("details", postgresql.JSONB, nullable=False),
sa.UniqueConstraint("evaluation_id", "kind", "component_key", name="uq_eval_component"),
)
def downgrade() -> None:
op.drop_table("evaluation_components")
op.drop_table("evaluations")
op.drop_table("price_observations")
op.drop_table("offers")
op.drop_table("variant_attributes")
op.drop_table("product_variants")
op.drop_table("products")
op.drop_table("product_profiles")
op.drop_table("scoring_models")
op.drop_table("sources")
op.drop_table("attribute_definitions")