keble-data-infra-contract 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keble_data_infra_contract-0.1.0/.gitignore +42 -0
- keble_data_infra_contract-0.1.0/PKG-INFO +55 -0
- keble_data_infra_contract-0.1.0/README.md +45 -0
- keble_data_infra_contract-0.1.0/pyproject.toml +36 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/__init__.py +13 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/errors.py +60 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/manifests.py +84 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/__init__.py +0 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/base.py +23 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/creator.py +54 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/creator_link.py +56 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/identity.py +121 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/listing.py +57 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/metric.py +222 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/offer.py +68 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/product.py +42 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/provenance.py +68 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/store.py +29 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/video.py +106 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/models/video_enrichment.py +414 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/operations.py +60 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/protocols/__init__.py +2 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/protocols/creator_provider.py +42 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/protocols/provider.py +58 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/protocols/relation_provider.py +47 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/protocols/seller_provider.py +34 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/protocols/video_enrichment.py +152 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/protocols/video_provider.py +41 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/schemas/__init__.py +2 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/schemas/query.py +397 -0
- keble_data_infra_contract-0.1.0/src/keble_data_infra_contract/schemas/result.py +157 -0
- keble_data_infra_contract-0.1.0/tests/conftest.py +17 -0
- keble_data_infra_contract-0.1.0/tests/contract/test_creator_ranking.py +214 -0
- keble_data_infra_contract-0.1.0/tests/contract/test_dependency_direction.py +41 -0
- keble_data_infra_contract-0.1.0/tests/contract/test_manifests.py +74 -0
- keble_data_infra_contract-0.1.0/tests/contract/test_queries.py +39 -0
- keble_data_infra_contract-0.1.0/tests/contract/test_ranking.py +282 -0
- keble_data_infra_contract-0.1.0/tests/unit/test_contract_model.py +34 -0
- keble_data_infra_contract-0.1.0/tests/unit/test_identity.py +152 -0
- keble_data_infra_contract-0.1.0/tests/unit/test_money_identity.py +73 -0
- keble_data_infra_contract-0.1.0/tests/unit/test_product.py +65 -0
- keble_data_infra_contract-0.1.0/tests/unit/test_provenance.py +58 -0
- keble_data_infra_contract-0.1.0/tests/unit/test_video.py +184 -0
- keble_data_infra_contract-0.1.0/tests/unit/test_video_enrichment.py +192 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Generated by Cargo
|
|
2
|
+
# will have compiled files and executables
|
|
3
|
+
debug/
|
|
4
|
+
target/
|
|
5
|
+
cert/
|
|
6
|
+
|
|
7
|
+
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
|
8
|
+
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
|
9
|
+
Cargo.lock
|
|
10
|
+
|
|
11
|
+
# These are backup files generated by rustfmt
|
|
12
|
+
**/*.rs.bk
|
|
13
|
+
|
|
14
|
+
# MSVC Windows builds of rustc generate these, which store debugging information
|
|
15
|
+
*.pdb
|
|
16
|
+
*__ignored__*
|
|
17
|
+
|
|
18
|
+
*.env
|
|
19
|
+
**/.env.docker
|
|
20
|
+
*.DS_Store
|
|
21
|
+
**/.DS_Store
|
|
22
|
+
|
|
23
|
+
.vscode
|
|
24
|
+
.idea
|
|
25
|
+
|
|
26
|
+
*.crt
|
|
27
|
+
|
|
28
|
+
uploads/
|
|
29
|
+
!strapi-v5/public/uploads/
|
|
30
|
+
!strapi-v5/public/uploads/.gitkeep
|
|
31
|
+
test/
|
|
32
|
+
resources/
|
|
33
|
+
Brewfile
|
|
34
|
+
qdrant/
|
|
35
|
+
*.pyc
|
|
36
|
+
**.pyc.*
|
|
37
|
+
*/traefik/certs
|
|
38
|
+
*/traefik/certs/*
|
|
39
|
+
gcp.json
|
|
40
|
+
.history
|
|
41
|
+
|
|
42
|
+
prod.env.*
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keble-data-infra-contract
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Framework-free raw commerce contracts shared by Keble data providers and the raw API.
|
|
5
|
+
Author-email: zhenhao-ma <bob0103779@gmail.com>
|
|
6
|
+
Requires-Python: <3.14,>=3.13
|
|
7
|
+
Requires-Dist: keble-helpers<2,>=1.50.0
|
|
8
|
+
Requires-Dist: pydantic<3,>=2.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# keble-data-infra-contract
|
|
12
|
+
|
|
13
|
+
Install the provider-neutral public contract with
|
|
14
|
+
`pip install "keble-data-infra-contract>=0.1.0,<1"`.
|
|
15
|
+
|
|
16
|
+
Framework-free, provider-neutral raw-commerce contracts. This distribution owns
|
|
17
|
+
validated request/response models, provider protocols, operation keys, manifests,
|
|
18
|
+
and typed domain errors. It intentionally has no FastAPI, database, HTTP client,
|
|
19
|
+
provider, platform, or idea dependency.
|
|
20
|
+
|
|
21
|
+
The distribution declares only registry-resolvable runtime dependencies. Do not
|
|
22
|
+
add workspace-relative `tool.uv.sources` here: downstream Git/subdirectory installs
|
|
23
|
+
must build this contract without inheriting paths outside the repository checkout.
|
|
24
|
+
|
|
25
|
+
Provider packages own implementations and native DTOs. The raw API consumes
|
|
26
|
+
provider manifests and constructs HTTP paths from their typed operation keys.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv run --package keble-data-infra-contract pytest -q keble-data-infra-contract/tests
|
|
30
|
+
uv run --package keble-data-infra-contract npx --yes pyright keble-data-infra-contract
|
|
31
|
+
uv build --package keble-data-infra-contract
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Contract values inherit `ContractModel`, which accepts snake-case Python names
|
|
35
|
+
and camel-case wire aliases, rejects undeclared fields, and is frozen. Persisted
|
|
36
|
+
Mongo shapes do not belong here; provider/API packages own `SchemaBase` /
|
|
37
|
+
`MongoObjectBase` aggregates when durability is required.
|
|
38
|
+
`ContractModel` and the deliberately producer-tolerant Job/Result projection
|
|
39
|
+
both configure Pydantic through `keble-helpers.PydanticModelConfig`; a local
|
|
40
|
+
`ConfigDict` convention would split alias and validation ownership again.
|
|
41
|
+
|
|
42
|
+
`Money`, `Currency`, and `AmazonMarketplace` are imported from
|
|
43
|
+
`keble-helpers`. Provider-native response details remain inside the provider
|
|
44
|
+
package instead of crossing this boundary through an untyped extension bag.
|
|
45
|
+
|
|
46
|
+
Video enrichment uses the same framework-free boundary: immutable asset,
|
|
47
|
+
submission, job, completion-event, result, and normalized-projection models plus
|
|
48
|
+
one `VideoEnrichmentGateway` Protocol. HTTP submission and Mongo correlation
|
|
49
|
+
remain implementations owned by `keble-data-infra-api`.
|
|
50
|
+
|
|
51
|
+
Authorized provider transcripts use one canonical `VideoTranscript`/
|
|
52
|
+
`VideoTranscriptSegment` shape across submission, authoritative result reads, and
|
|
53
|
+
the local projection. Reuse evidence binds the normalized transcript checksum to
|
|
54
|
+
the data-infra source reference and media SHA-256; a title/description such as
|
|
55
|
+
`ShortVideo.caption` is not a spoken transcript and cannot bypass ASR.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# keble-data-infra-contract
|
|
2
|
+
|
|
3
|
+
Install the provider-neutral public contract with
|
|
4
|
+
`pip install "keble-data-infra-contract>=0.1.0,<1"`.
|
|
5
|
+
|
|
6
|
+
Framework-free, provider-neutral raw-commerce contracts. This distribution owns
|
|
7
|
+
validated request/response models, provider protocols, operation keys, manifests,
|
|
8
|
+
and typed domain errors. It intentionally has no FastAPI, database, HTTP client,
|
|
9
|
+
provider, platform, or idea dependency.
|
|
10
|
+
|
|
11
|
+
The distribution declares only registry-resolvable runtime dependencies. Do not
|
|
12
|
+
add workspace-relative `tool.uv.sources` here: downstream Git/subdirectory installs
|
|
13
|
+
must build this contract without inheriting paths outside the repository checkout.
|
|
14
|
+
|
|
15
|
+
Provider packages own implementations and native DTOs. The raw API consumes
|
|
16
|
+
provider manifests and constructs HTTP paths from their typed operation keys.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv run --package keble-data-infra-contract pytest -q keble-data-infra-contract/tests
|
|
20
|
+
uv run --package keble-data-infra-contract npx --yes pyright keble-data-infra-contract
|
|
21
|
+
uv build --package keble-data-infra-contract
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Contract values inherit `ContractModel`, which accepts snake-case Python names
|
|
25
|
+
and camel-case wire aliases, rejects undeclared fields, and is frozen. Persisted
|
|
26
|
+
Mongo shapes do not belong here; provider/API packages own `SchemaBase` /
|
|
27
|
+
`MongoObjectBase` aggregates when durability is required.
|
|
28
|
+
`ContractModel` and the deliberately producer-tolerant Job/Result projection
|
|
29
|
+
both configure Pydantic through `keble-helpers.PydanticModelConfig`; a local
|
|
30
|
+
`ConfigDict` convention would split alias and validation ownership again.
|
|
31
|
+
|
|
32
|
+
`Money`, `Currency`, and `AmazonMarketplace` are imported from
|
|
33
|
+
`keble-helpers`. Provider-native response details remain inside the provider
|
|
34
|
+
package instead of crossing this boundary through an untyped extension bag.
|
|
35
|
+
|
|
36
|
+
Video enrichment uses the same framework-free boundary: immutable asset,
|
|
37
|
+
submission, job, completion-event, result, and normalized-projection models plus
|
|
38
|
+
one `VideoEnrichmentGateway` Protocol. HTTP submission and Mongo correlation
|
|
39
|
+
remain implementations owned by `keble-data-infra-api`.
|
|
40
|
+
|
|
41
|
+
Authorized provider transcripts use one canonical `VideoTranscript`/
|
|
42
|
+
`VideoTranscriptSegment` shape across submission, authoritative result reads, and
|
|
43
|
+
the local projection. Reuse evidence binds the normalized transcript checksum to
|
|
44
|
+
the data-infra source reference and media SHA-256; a title/description such as
|
|
45
|
+
`ShortVideo.caption` is not a spoken transcript and cannot bypass ASR.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "keble-data-infra-contract"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Framework-free raw commerce contracts shared by Keble data providers and the raw API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13,<3.14"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"keble-helpers>=1.50.0,<2",
|
|
13
|
+
"pydantic>=2.10,<3",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[[project.authors]]
|
|
17
|
+
name = "zhenhao-ma"
|
|
18
|
+
email = "bob0103779@gmail.com"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
test = [
|
|
22
|
+
"pytest>=8.2,<9",
|
|
23
|
+
"pytest-asyncio>=1.4,<2",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[tool.uv]
|
|
27
|
+
default-groups = ["test"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
addopts = "--strict-markers"
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
asyncio_mode = "auto"
|
|
33
|
+
markers = [
|
|
34
|
+
"unit: fast deterministic tests with no network",
|
|
35
|
+
"contract: recorded or fixture-based external contract tests",
|
|
36
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Public framework-free contract surface for Keble raw data providers."""
|
|
2
|
+
|
|
3
|
+
from .manifests import ProviderManifest, ServiceOperation
|
|
4
|
+
from .operations import CommerceAction, CommerceResource, RouteKey
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CommerceAction",
|
|
8
|
+
"CommerceResource",
|
|
9
|
+
"ProviderManifest",
|
|
10
|
+
"RouteKey",
|
|
11
|
+
"ServiceOperation",
|
|
12
|
+
]
|
|
13
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Typed errors shared by provider services and the raw API boundary."""
|
|
2
|
+
|
|
3
|
+
from .operations import RouteKey
|
|
4
|
+
from .models.identity import Channel, ProviderId
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DataInfraError(Exception):
|
|
8
|
+
"""Base class for expected raw-data contract and provider failures."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DuplicateOperationError(DataInfraError):
|
|
12
|
+
"""Raised when two manifests declare the same provider operation key."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *, key: RouteKey) -> None:
|
|
15
|
+
super().__init__(f"duplicate provider operation: {key.operation_id()}")
|
|
16
|
+
self.key = key
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OperationNotSupportedError(DataInfraError):
|
|
20
|
+
"""Raised when a provider does not declare the requested capability."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, *, key: RouteKey) -> None:
|
|
23
|
+
super().__init__(f"provider operation is not supported: {key.operation_id()}")
|
|
24
|
+
self.key = key
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProviderNotFoundError(DataInfraError):
|
|
28
|
+
"""Raised when provider discovery targets an unregistered identity."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, *, channel: Channel, provider_id: ProviderId) -> None:
|
|
31
|
+
super().__init__(
|
|
32
|
+
f"provider is not registered: channel={channel.value}, provider={provider_id.root}"
|
|
33
|
+
)
|
|
34
|
+
self.channel = channel
|
|
35
|
+
self.provider_id = provider_id
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProviderRequestValidationError(DataInfraError):
|
|
39
|
+
"""Raised when a provider capability requires a missing or invalid field."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, *, field: str, message: str) -> None:
|
|
42
|
+
super().__init__(f"invalid provider request field {field}: {message}")
|
|
43
|
+
self.field = field
|
|
44
|
+
self.message = message
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ProviderUnavailableError(DataInfraError):
|
|
48
|
+
"""Raised when a declared provider cannot complete an upstream request."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ProviderAuthenticationError(DataInfraError):
|
|
52
|
+
"""Raised when provider credentials are absent or rejected."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ProviderRateLimitError(DataInfraError):
|
|
56
|
+
"""Raised when an upstream provider rejects work because of a rate limit."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ProviderTimeoutError(DataInfraError):
|
|
60
|
+
"""Raised when an upstream provider exceeds its configured timeout."""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Strongly typed provider operation manifests without HTTP dependencies."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Generic, TypeVar, cast
|
|
6
|
+
|
|
7
|
+
from .errors import DuplicateOperationError
|
|
8
|
+
from .models.base import ContractModel
|
|
9
|
+
from .models.identity import Channel, ProviderId
|
|
10
|
+
from .operations import RouteKey
|
|
11
|
+
|
|
12
|
+
RequestT = TypeVar("RequestT", bound=ContractModel)
|
|
13
|
+
ResponseT = TypeVar("ResponseT", bound=ContractModel)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ServiceOperation(Generic[RequestT, ResponseT]):
|
|
18
|
+
"""Bind one provider-owned service method to a framework-free operation key.
|
|
19
|
+
|
|
20
|
+
Side effects if changes:
|
|
21
|
+
- provider packages construct these bindings in `provider_manifest`;
|
|
22
|
+
- the API registry validates requests and responses through them;
|
|
23
|
+
- route generation uses their request and response model classes.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
key: RouteKey
|
|
27
|
+
request_model: type[RequestT]
|
|
28
|
+
response_model: type[ResponseT]
|
|
29
|
+
handler: Callable[[RequestT], Awaitable[ResponseT]]
|
|
30
|
+
|
|
31
|
+
async def invoke(self, request: ContractModel) -> ContractModel:
|
|
32
|
+
"""Validate the erased registry input, invoke, and verify output type."""
|
|
33
|
+
|
|
34
|
+
typed_request = self.request_model.model_validate(request)
|
|
35
|
+
response = await self.handler(typed_request)
|
|
36
|
+
return self.response_model.model_validate(response)
|
|
37
|
+
|
|
38
|
+
def erase(self) -> "ServiceOperation[ContractModel, ContractModel]":
|
|
39
|
+
"""Erase generic parameters only at the heterogeneous registry boundary."""
|
|
40
|
+
|
|
41
|
+
return cast("ServiceOperation[ContractModel, ContractModel]", self)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ProviderManifest:
|
|
46
|
+
"""Complete, explicit capability set exported by one provider service.
|
|
47
|
+
|
|
48
|
+
Steps:
|
|
49
|
+
1. validate every operation belongs to the manifest channel/provider;
|
|
50
|
+
2. reject duplicate route keys before API startup;
|
|
51
|
+
3. expose only registered operations as provider capabilities.
|
|
52
|
+
|
|
53
|
+
Side effects if changes:
|
|
54
|
+
- raw API startup and provider discovery;
|
|
55
|
+
- Amazon and TikTok service exports;
|
|
56
|
+
- duplicate route and OpenAPI guards.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
channel: Channel
|
|
60
|
+
provider_id: ProviderId
|
|
61
|
+
operations: tuple[ServiceOperation[ContractModel, ContractModel], ...]
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def build(
|
|
65
|
+
cls,
|
|
66
|
+
*,
|
|
67
|
+
channel: Channel,
|
|
68
|
+
provider_id: ProviderId,
|
|
69
|
+
operations: Iterable[ServiceOperation[ContractModel, ContractModel]],
|
|
70
|
+
) -> "ProviderManifest":
|
|
71
|
+
"""Validate ownership and uniqueness while freezing one manifest."""
|
|
72
|
+
|
|
73
|
+
materialized = tuple(operations)
|
|
74
|
+
seen: set[RouteKey] = set()
|
|
75
|
+
for operation in materialized:
|
|
76
|
+
if operation.key.channel is not channel:
|
|
77
|
+
raise ValueError("manifest operation channel does not match manifest owner")
|
|
78
|
+
if operation.key.provider_id != provider_id:
|
|
79
|
+
raise ValueError("manifest operation provider does not match manifest owner")
|
|
80
|
+
if operation.key in seen:
|
|
81
|
+
raise DuplicateOperationError(key=operation.key)
|
|
82
|
+
seen.add(operation.key)
|
|
83
|
+
return cls(channel=channel, provider_id=provider_id, operations=materialized)
|
|
84
|
+
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared model inheritance for framework-free data-infra contracts."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from keble_helpers.typings import PydanticModelConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ContractModel(BaseModel):
|
|
9
|
+
"""Base for immutable camel-case raw-data contracts.
|
|
10
|
+
|
|
11
|
+
Steps:
|
|
12
|
+
1. accept Python snake_case and HTTP camelCase field names;
|
|
13
|
+
2. reject undeclared provider payload fields;
|
|
14
|
+
3. keep validated contract values immutable across adapters.
|
|
15
|
+
|
|
16
|
+
Side effects if changes:
|
|
17
|
+
- every provider manifest request and response;
|
|
18
|
+
- `keble-data-infra-api` OpenAPI generation;
|
|
19
|
+
- the Stand Out raw HTTP adapter fixtures.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
model_config = PydanticModelConfig.default(extra="forbid", frozen=True)
|
|
23
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""CommerceCreator — universal channel creator entity.
|
|
2
|
+
|
|
3
|
+
A CommerceCreator is a content creator / influencer as they appear on a
|
|
4
|
+
specific commerce channel (TikTok today; other creator-carrying channels
|
|
5
|
+
later). It is the ranked entity of CreatorRankingProvider.rank_creators.
|
|
6
|
+
|
|
7
|
+
Design rules (mirrors CommerceListing):
|
|
8
|
+
- Provider-native creator details remain internal to the provider package.
|
|
9
|
+
- ``metrics`` is a list of MetricObservation (entity_scope=CREATOR), not a
|
|
10
|
+
KV bag. Follower counts, follower growth, and attributed GMV all flow
|
|
11
|
+
through MetricObservation.
|
|
12
|
+
- ``identities`` preserves the provider-native creator ID alongside the
|
|
13
|
+
channel creator ID for deduplication and provenance.
|
|
14
|
+
- Only the cross-channel universal core lives here (id, handle, display
|
|
15
|
+
name, avatar, market, metrics). Channel-specific scores/flags remain in
|
|
16
|
+
the provider package.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from keble_data_infra_contract.models.base import ContractModel
|
|
22
|
+
|
|
23
|
+
from pydantic import Field
|
|
24
|
+
|
|
25
|
+
from keble_data_infra_contract.models.identity import Channel, ExternalIdentity
|
|
26
|
+
from keble_data_infra_contract.models.metric import MetricObservation
|
|
27
|
+
from keble_data_infra_contract.models.provenance import Provenance
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CommerceCreator(ContractModel):
|
|
31
|
+
"""A creator on a specific commerce channel in a specific market.
|
|
32
|
+
|
|
33
|
+
FastMoss / TikTok mapping (Phase 2):
|
|
34
|
+
channel ← Channel.TIKTOK
|
|
35
|
+
market ← item.region (e.g., "US")
|
|
36
|
+
channel_creator_id ← creator.uid
|
|
37
|
+
handle ← creator.unique_id (the @handle)
|
|
38
|
+
display_name ← creator.nickname
|
|
39
|
+
avatar_url ← creator.avatar
|
|
40
|
+
identities ← [ExternalIdentity(provider=fastmoss, namespace="tiktok_creator_uid", …)]
|
|
41
|
+
metrics ← [MetricObservation(follower_count/follower_growth/gmv, CREATOR scope), …]
|
|
42
|
+
provenance ← Provenance(provider=fastmoss, captured_at=…)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
channel: Channel
|
|
47
|
+
market: str | None = None
|
|
48
|
+
channel_creator_id: str
|
|
49
|
+
handle: str | None = None
|
|
50
|
+
display_name: str | None = None
|
|
51
|
+
avatar_url: str | None = None
|
|
52
|
+
identities: list[ExternalIdentity] = Field(default_factory=list)
|
|
53
|
+
metrics: list[MetricObservation] = Field(default_factory=list)
|
|
54
|
+
provenance: Provenance | None = None
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""CreatorProductLink — universal creator↔product promotion relation (Phase 3).
|
|
2
|
+
|
|
3
|
+
Represents the "creator promotes product" edge in the Product↔Creator
|
|
4
|
+
relation graph. A single relation type (PROMOTES) is used for both
|
|
5
|
+
directions of the query (product→creators via R7, creator→products via R8).
|
|
6
|
+
|
|
7
|
+
Design rules (mirrors CommerceListing / CommerceCreator):
|
|
8
|
+
- ``creator`` and ``listing`` hold the universal commerce entities.
|
|
9
|
+
- ``metrics`` carries ONLY the per-pair attributed metrics (units_sold and
|
|
10
|
+
gmv attributed to THIS creator for THIS product specifically), NOT the
|
|
11
|
+
creator's all-product totals or the product's all-creator totals.
|
|
12
|
+
- ``entity_scope=LISTING`` is used for attributed sales/gmv metrics on a
|
|
13
|
+
listing; ``entity_scope=CREATOR`` is used for creator-scoped metrics.
|
|
14
|
+
- ``provenance`` records which provider supplied this relation edge.
|
|
15
|
+
|
|
16
|
+
Source semantics (carried transparently from the channel layer):
|
|
17
|
+
- R7 (/product/v1/creatorList): snapshot relation, no ``window_days``.
|
|
18
|
+
- R8 (/creator/v1/productList): rolling ``window_days`` ∈ {0,7,14,28,90}.
|
|
19
|
+
``window_days=None`` means the channel layer did not supply a window
|
|
20
|
+
(snapshot or unknown).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from keble_data_infra_contract.models.base import ContractModel
|
|
26
|
+
|
|
27
|
+
from typing import Literal
|
|
28
|
+
|
|
29
|
+
from pydantic import Field
|
|
30
|
+
|
|
31
|
+
from keble_data_infra_contract.models.creator import CommerceCreator
|
|
32
|
+
from keble_data_infra_contract.models.listing import CommerceListing
|
|
33
|
+
from keble_data_infra_contract.models.metric import MetricObservation
|
|
34
|
+
from keble_data_infra_contract.models.provenance import Provenance
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CreatorProductLink(ContractModel):
|
|
38
|
+
"""A creator↔product promotion relation with attributed metrics.
|
|
39
|
+
|
|
40
|
+
``relation`` is always "PROMOTES" (the only currently verified relation
|
|
41
|
+
type; other types may be introduced in later phases without breaking this
|
|
42
|
+
model since it is typed as a Literal).
|
|
43
|
+
``metrics`` contains attributed MetricObservation objects only (units_sold
|
|
44
|
+
and gmv for this specific pair).
|
|
45
|
+
``window_days`` is the original R8 ``days`` parameter; None for R7 snapshots.
|
|
46
|
+
Provider-specific link attribution that lacks a normalized representation
|
|
47
|
+
stays inside the provider package and is not exposed as an escape hatch.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
creator: CommerceCreator
|
|
52
|
+
listing: CommerceListing
|
|
53
|
+
relation: Literal["PROMOTES"] = "PROMOTES"
|
|
54
|
+
metrics: list[MetricObservation] = Field(default_factory=list)
|
|
55
|
+
window_days: int | None = None
|
|
56
|
+
provenance: Provenance | None = None
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Universal identity types for the Commerce layer.
|
|
2
|
+
|
|
3
|
+
ProviderId — validated string key for a data provider (open set: fastmoss, echotik, …).
|
|
4
|
+
Channel — closed enum of buyer-facing commerce channels (TikTok, Amazon, Shopify).
|
|
5
|
+
ExternalIdentity — provider-native identity tuple for a commerce entity.
|
|
6
|
+
|
|
7
|
+
Design rules:
|
|
8
|
+
- Channel is a closed enum: channels are structurally significant cross-channel keys.
|
|
9
|
+
- ProviderId is an open RootModel[str]: new providers do not require an enum change.
|
|
10
|
+
- ExternalIdentity.identity_key() is deterministic — equal inputs always produce
|
|
11
|
+
the same string, making it safe to use as a deduplication / merge key.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from keble_data_infra_contract.models.base import ContractModel
|
|
17
|
+
|
|
18
|
+
from enum import Enum
|
|
19
|
+
|
|
20
|
+
from pydantic import Field, RootModel, field_validator
|
|
21
|
+
from keble_helpers.typings import PydanticModelConfig
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# ProviderId
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProviderId(RootModel[str]):
|
|
30
|
+
"""Validated string identity for a commerce data provider.
|
|
31
|
+
|
|
32
|
+
Rules: lowercase letters, digits, and underscores only.
|
|
33
|
+
The set of providers is open — new providers do not need an enum change.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
model_config = PydanticModelConfig.default(frozen=True)
|
|
37
|
+
|
|
38
|
+
@field_validator("root")
|
|
39
|
+
@classmethod
|
|
40
|
+
def _validate(cls, value: str) -> str:
|
|
41
|
+
normalized = value.strip().lower()
|
|
42
|
+
if not normalized or not normalized.replace("_", "").isalnum():
|
|
43
|
+
raise ValueError(
|
|
44
|
+
"ProviderId must contain only lowercase letters, digits, or underscores"
|
|
45
|
+
)
|
|
46
|
+
return normalized
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
return self.root
|
|
50
|
+
|
|
51
|
+
def __repr__(self) -> str:
|
|
52
|
+
return f"ProviderId({self.root!r})"
|
|
53
|
+
|
|
54
|
+
def __eq__(self, other: object) -> bool:
|
|
55
|
+
if isinstance(other, ProviderId):
|
|
56
|
+
return self.root == other.root
|
|
57
|
+
if isinstance(other, str):
|
|
58
|
+
return self.root == other
|
|
59
|
+
return NotImplemented
|
|
60
|
+
|
|
61
|
+
def __hash__(self) -> int:
|
|
62
|
+
return hash(self.root)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Well-known provider constants — the set remains open for extension.
|
|
66
|
+
PROVIDER_FASTMOSS = ProviderId("fastmoss")
|
|
67
|
+
PROVIDER_ECHOTIK = ProviderId("echotik")
|
|
68
|
+
PROVIDER_KEEPA = ProviderId("keepa")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Channel
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Channel(str, Enum):
|
|
77
|
+
"""Closed enum of buyer-facing commerce channels.
|
|
78
|
+
|
|
79
|
+
Channels are structural keys in the Universal model (e.g., routing,
|
|
80
|
+
cross-channel deduplication), so they must be enumerated and compile-time
|
|
81
|
+
checkable — unlike providers, which are open.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
TIKTOK = "tiktok"
|
|
85
|
+
AMAZON = "amazon"
|
|
86
|
+
SHOPIFY = "shopify"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# ExternalIdentity
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ExternalIdentity(ContractModel):
|
|
95
|
+
"""Provider-native identity for a single commerce entity.
|
|
96
|
+
|
|
97
|
+
The four-tuple (provider, namespace, value, sorted scope) forms a stable,
|
|
98
|
+
deterministic key used for deduplication and merge operations.
|
|
99
|
+
|
|
100
|
+
Rules:
|
|
101
|
+
- ``value`` must be the actual external ID string — never a derived or
|
|
102
|
+
fabricated value.
|
|
103
|
+
- ``namespace`` distinguishes identity types within a provider
|
|
104
|
+
(e.g., "tiktok_product_id" vs "tiktok_seller_id").
|
|
105
|
+
- ``scope`` captures contextual qualifiers such as region; it is optional
|
|
106
|
+
but must not be used to carry business metrics.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
provider: ProviderId
|
|
111
|
+
namespace: str
|
|
112
|
+
value: str
|
|
113
|
+
scope: dict[str, str] = Field(default_factory=dict)
|
|
114
|
+
|
|
115
|
+
def identity_key(self) -> str:
|
|
116
|
+
"""Stable, deterministic key from all four components.
|
|
117
|
+
|
|
118
|
+
Suitable for use as a deduplication or merge key across sources.
|
|
119
|
+
"""
|
|
120
|
+
sorted_scope = "|".join(f"{k}={v}" for k, v in sorted(self.scope.items()))
|
|
121
|
+
return f"{self.provider}:{self.namespace}:{self.value}:{sorted_scope}"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""CommerceListing — universal channel listing entity.
|
|
2
|
+
|
|
3
|
+
A CommerceListing is a product as it appears in a specific channel and market.
|
|
4
|
+
It is the primary cross-channel unit of comparison.
|
|
5
|
+
|
|
6
|
+
Design rules (§4.3):
|
|
7
|
+
- Provider-native details remain inside the provider package and do not cross
|
|
8
|
+
the normalized public contract.
|
|
9
|
+
- ``metrics`` is a list of MetricObservation, not a KV bag.
|
|
10
|
+
- ``identities`` preserves the provider-native ID alongside the channel
|
|
11
|
+
product ID for deduplication and provenance.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from keble_data_infra_contract.models.base import ContractModel
|
|
17
|
+
|
|
18
|
+
from pydantic import Field
|
|
19
|
+
|
|
20
|
+
from keble_data_infra_contract.models.identity import Channel, ExternalIdentity
|
|
21
|
+
from keble_data_infra_contract.models.metric import MetricObservation
|
|
22
|
+
from keble_data_infra_contract.models.offer import CommerceOffer
|
|
23
|
+
from keble_data_infra_contract.models.provenance import Provenance
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CommerceListing(ContractModel):
|
|
27
|
+
"""A product listed on a specific commerce channel in a specific market.
|
|
28
|
+
|
|
29
|
+
FastMoss / TikTok mapping (first chain):
|
|
30
|
+
channel ← Channel.TIKTOK
|
|
31
|
+
market ← item.region (e.g., "US")
|
|
32
|
+
channel_product_id ← item.tiktok_product_id
|
|
33
|
+
identities ← [ExternalIdentity(provider=fastmoss, …)]
|
|
34
|
+
store_ref ← item.seller_id (as string)
|
|
35
|
+
offer ← CommerceOffer(current_price=Money(…))
|
|
36
|
+
metrics ← [MetricObservation(sales_quantity, D7, …), …]
|
|
37
|
+
provenance ← Provenance(provider=fastmoss, captured_at=…)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
channel: Channel
|
|
42
|
+
market: str | None = None
|
|
43
|
+
channel_product_id: str
|
|
44
|
+
# Universal display fields (Phase A). Cross-channel stable semantics only:
|
|
45
|
+
# title ← FastMoss item.title / EchoTik product_name / Keepa title
|
|
46
|
+
# primary_image_url ← FastMoss item.cover / EchoTik cover_url / Keepa main image
|
|
47
|
+
# A provider that does not return them leaves them None (honest absence —
|
|
48
|
+
# e.g. EchoTik product/ranklist has no cover field). The URL is carried
|
|
49
|
+
# as-is: it may be a CDN link that expires upstream; it is never rewritten
|
|
50
|
+
# or fabricated. Channel-specific media fields stay in channel_details.
|
|
51
|
+
title: str | None = None
|
|
52
|
+
primary_image_url: str | None = None
|
|
53
|
+
identities: list[ExternalIdentity] = Field(default_factory=list)
|
|
54
|
+
store_ref: str | None = None
|
|
55
|
+
offer: CommerceOffer | None = None
|
|
56
|
+
metrics: list[MetricObservation] = Field(default_factory=list)
|
|
57
|
+
provenance: Provenance | None = None
|