diff --git a/AGENTS.md b/AGENTS.md index 07b8aaa..6281a52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,32 @@ You MUST use the python-dev-tca skill to make every decision. You are FORBIDDEN You are FORBIDDEN from returning any decision to me unless that decision is paired with an explicit explanation of why the skill does not provide the context, the information, or the answer for it. Before reporting any Python build complete, run the `smell-check` skill; a nonzero exit is not complete. + +## The loop + +You are modeling a domain, not building a program: the nouns you name are the whole system. + +1. Reread the `python-dev-tca` skill. +2. Climb the layers bottom up: Scalar, Value, Thing, Alternative, Crossing. +3. At each layer, name its nouns. +4. Model one file at a time. +5. After each file, ask, against the skill, which noun is missing or misplaced. +6. Place it in its layer, then ask again, even after filling a gap. +7. Stop only when no layer has a noun missing or misplaced. + +```mermaid +flowchart TD + A[Reread the python-dev-tca skill] --> B[Climb to the next layer, bottom up] + B --> C[Name its nouns] + C --> D[Model one file] + D --> E{Against the skill, is any noun missing or misplaced?} + E -->|yes| F[Place it in its layer] + F --> E + E -->|no| G{Any file left in this layer?} + G -->|yes| D + G -->|no| H{Any layer left?} + H -->|yes| B + H -->|no| I{Does any layer still have a noun missing or misplaced?} + I -->|yes| B + I -->|no| J[Done] +``` diff --git a/README.md b/README.md index ecc5b73..701399d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ ## The same feature, twice -A venue sends a fill. The program updates the account's position, records it with a clearing house, and replies. +A trading desk has to know what every account holds, and the clearing house is the record it answers to. When a venue reports a fill, the program updates the account's position, records it with the clearing house, and tells the desk what happened. Here is the version everyone writes, and every coding agent writes by default: @@ -41,11 +41,11 @@ def on_fill(raw): It works on the day it is written. It also contains these, none of which a test suite will find until production does: -- `"Buy"` with a capital B is a sell. -- A halted clearing house and a bug both return `None`, and the caller cannot tell them apart. -- A restart forgets every position. -- A refused save leaves the position already changed in memory. -- A missing `quantity` is a `KeyError` three lines after the bad message was accepted. +- `"Buy"` with a capital B is booked as a sell: the account now holds the opposite of what it traded. +- A halted clearing house and a bug both return `None`: the desk cannot tell an unrecorded trade from a crash. +- A restart forgets every position: after a deploy, nobody knows what the firm holds. +- A refused save leaves the position changed in memory: the program believes a trade the clearing house never recorded. +- A missing `quantity` is a `KeyError` three lines after the fill was accepted: the venue gets a crash instead of a rejection. Here is the same feature in TCA. Every class is frozen and strict; that configuration is left out here and is exact in the skill. @@ -91,13 +91,23 @@ class Position(BaseModel): return PersistPosition(position=self) ``` -Every "or" is a model over its variants, and construction picks the variant: +Every way a trade can go is a model over its variants, and construction picks the one that happened: ```python -class Fill(RootModel[BuyFill | SellFill]): ... -class ClearingReply(RootModel[ClearingAcknowledgement | ClearingRefusal]): ... -class PositionOutcome(RootModel[RecordedPosition | RefusedPosition]): ... -class FillReply(RootModel[FillBooked | FillDeclined]): ... +class Fill(RootModel[BuyFill | SellFill]): + """A trade a venue executed for an account.""" + + +class ClearingReply(RootModel[ClearingAcknowledgement | ClearingRefusal]): + """What the clearing house says of a position it was asked to record.""" + + +class PositionOutcome(RootModel[RecordedPosition | RefusedPosition]): + """What became of a position at the clearing house.""" + + +class FillReply(RootModel[FillBooked | FillDeclined]): + """This program's reply to a fill.""" ``` And this is the entire program that runs: @@ -124,15 +134,15 @@ One function, one returned expression. The raw message is read once, where it ar ## What happened to each line -| The procedural line | What it became | What can no longer be written | +| The procedural line | What it became | What can no longer happen | |---|---|---| -| `json.loads(raw)` and `body["data"]["payload"]` | `FillRoute.model_validate_json(...)`: the whole message given to one constructor where it arrives | a half-read message; a `KeyError` downstream | -| `if fill["side"] == "buy"` | `BuyFill` or `SellFill`, picked by construction, each with its own `signed` | a third spelling of "buy"; `"Buy"` constructs neither | -| `positions.get(key)` and `if position is None` | `FlatPosition`, the holding before any fill | a forgotten `None` check | -| `position["net"] += ...` | a new `Position` that holds its `prior` and one `Fill` | a position changed before the save succeeded | -| `positions = {}` | the prior, read from the clearing house on every fill | state lost on restart | -| `try` / `except` / `return None` | `ClearingRefusal`, a variant of the reply; `RefusedPosition`, of the outcome; `FillDeclined`, of what the caller receives | a refusal the caller cannot see | -| `return {...}` | `FillBooked` or `FillDeclined`, picked by construction | a reply shape nobody declared | +| `json.loads(raw)` and `body["data"]["payload"]` | `FillRoute.model_validate_json(...)`: the whole message given to one constructor where it arrives | a fill accepted, then dropped halfway through | +| `if fill["side"] == "buy"` | `BuyFill` or `SellFill`, picked by construction, each with its own `signed` | a sell booked from a mistyped buy; `"Buy"` constructs neither | +| `positions.get(key)` and `if position is None` | `FlatPosition`, the holding before any fill | an account's first trade crashing on a missing position | +| `position["net"] += ...` | a new `Position` that holds its `prior` and one `Fill` | a position the program believes and the clearing house never recorded | +| `positions = {}` | the prior, read from the clearing house on every fill | every position lost on a restart | +| `try` / `except` / `return None` | `ClearingRefusal`, a variant of the reply; `RefusedPosition`, of the outcome; `FillDeclined`, of what the desk receives | a refusal the desk never hears about | +| `return {...}` | `FillBooked` or `FillDeclined`, picked by construction | a reply the desk was never promised | The clearing house's raw reply enters at one place, its interpreter, and is given whole to the union: @@ -151,7 +161,7 @@ async def interpret(self) -> PersistAttempt: ) ``` -When the clearing house answers `{"error": "halted"}`, the caller receives: +When the clearing house answers `{"error": "halted"}`, the desk is told exactly that: which position, and why it was refused. ```json {"outcome":{"position":{"account":"A1","instrument":"ESZ6","net_quantity":"1"},"clearing":{"reason":"halted"}}} @@ -163,20 +173,20 @@ Nothing was caught. The refusal was constructed, carried, and published like any That is the picture at the top of this page, and it is the whole method. -A procedure says: do this, then that. TCA says: the later thing holds the earlier thing. A reply holds the outcome. The outcome holds the position. The position holds the fill and the position before it, down to the flat position at the start. Order is depth, and Pydantic's constructors run it. You never write the sequence, so you cannot write it wrong. +A procedure says: do this, then that. TCA says: the later thing holds the earlier thing. A reply holds the outcome. The outcome holds the position. The position holds the fill and the position before it, down to the flat position at the start. Order is depth, and Pydantic's constructors run it. You never write the sequence, so you cannot write it wrong. And a position is its own history: its fields are the trades that made it, back to flat. Every step you are about to type is a thing you have not named yet: -| You are about to… | You declare | -|---|---| -| do this, then that | a later fact holding the earlier fact as a field | -| check whether | a union; construction picks the variant | -| handle it failing | a refusal variant in the reply and in the outcome | -| handle there being none | a named thing for the empty case | -| handle several | a tuple held whole, or one construction for each arrival | -| read what another system sent | a route or foreign model given the raw input where it arrives | -| ask another system for something | an action, one interpreter, and the raw reply given to a union | -| keep something between arrivals | a prior read on each arrival, and a successor constructed from it | +| You are about to… | The question it settles | You declare | +|---|---|---| +| do this, then that | What does this depend on? | a later fact holding the earlier fact as a field | +| check whether | Which case is this? | a union; construction picks the variant | +| handle it failing | What if they say no? | a refusal variant in the reply and in the outcome | +| handle there being none | What if there is nothing? | a named thing for the empty case | +| handle several | What if there are many? | a tuple held whole, or one construction for each arrival | +| read what another system sent | What exactly did they send? | a route or foreign model given the raw input where it arrives | +| ask another system for something | What do we ask, and what can come back? | an action, one interpreter, and the raw reply given to a union | +| keep something between arrivals | What do we remember between trades? | a prior read on each arrival, and a successor constructed from it | ## Three questions @@ -188,10 +198,10 @@ Ask them of every change: The third fails in exactly four ways: -- **Escaped.** A meaning with no structure: it lives in a comment, a procedure, or a convention. -- **Duplicated.** A meaning with two structures, kept in agreement by hand. -- **Vacuous.** A structure with no meaning: a wrapper, a helper, a name that says nothing. -- **Fused.** A structure with two meanings that vary independently. +- **Escaped.** A meaning with no structure: it lives in a comment, a procedure, or a convention. *A halted market refuses trades, says a comment.* +- **Duplicated.** A meaning with two structures, kept in agreement by hand. *The side is `"buy"` in one place and `+1` in another.* +- **Vacuous.** A structure with no meaning: a wrapper, a helper, a name that says nothing. *`PositionManager`.* +- **Fused.** A structure with two meanings that vary independently. *One `status` string for both refused and unreachable.* There is no fifth. Every design argument reduces to which of the four it is. @@ -294,6 +304,6 @@ The procedural version was always the cheap one on day one and the expensive one A model that can read your field names, your variants, and your type structure removes the reason for the trade. The modeled design now arrives at the speed the shortcut used to, without the bugs listed at the top of this page. -And a type is an instruction. The same declaration that stops an invalid value from existing tells a model what is allowed to exist. One structure does both jobs, so design, code, and documentation stop being three copies of the same intent. +And a type is an instruction. The same declaration that stops an invalid value from existing tells a model what is allowed to exist. One structure does both jobs, so requirements, design, code, and documentation stop being four copies of the same intent. None of the ideas are new. This is the good half of typed functional programming and domain-driven design, held to one test and delivered in a form an agent cannot talk its way around. diff --git a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/SKILL.md b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/SKILL.md index cd02e75..b2c6fd3 100644 --- a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/SKILL.md +++ b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/SKILL.md @@ -31,6 +31,8 @@ Only an interpreter's `interpret` and a composition root's callback read or writ A format that is not JSON is read by a maintained library that yields JSON, and is then the line above. Only a format no library yields JSON from is read by a format type in `parser/`, and nothing outside `parser/` reads a format. +An action carries every rule its effect must follow; the system it calls enforces them, and its reply is read once into the domain's outcome. + An agent's own words live in `prompts/agents//SKILL.md` and the skills it may load in `prompts/skills//`; neither is ever a string in code. A template's slots are exactly the fields of its value object, and that value object is the agent's deps. ## A whole program @@ -48,6 +50,16 @@ flowchart LR DiscountOffer["DiscountOffer
percent"] SkillDocument["SkillDocument
name · description · body"] NoticeReply["NoticeReply
text"] + CatalogPoint["CatalogPoint
sku"] + IndexQuery["IndexQuery
query · filter · score_threshold · group_size · limit"] + PointHit["PointHit
id · score · payload"] + Hits["Hits"] + PointGroup["PointGroup
hits · id"] + PointGroups["PointGroups"] + GroupResult["GroupResult
groups"] + IndexGroups["IndexGroups
result · status · time"] + IndexFailure["IndexFailure
status · time"] + IndexReply["IndexReply"] CardCharge["CardCharge
amt · cur · src · ref"] ChargeApproved["ChargeApproved
id"] ChargeDeclined["ChargeDeclined
decline_codes"] @@ -63,12 +75,16 @@ flowchart LR Decline["Decline
reasons"] Failure["Failure
error"] DeclineNoticeValues["DeclineNoticeValues
customer · id · reasons"] + Match["Match
sku · category · similarity"] + RelatedMatches["RelatedMatches
one or more"] + NoMatches["NoMatches
none"] end subgraph g4["4 concept model"] Order["Order
id · customer · card · currency · lines"] PricedOrder["PricedOrder
order · discount"] NotifiedOrder["NotifiedOrder
order · notice"] + RecommendedOrder["RecommendedOrder
order · recommendation"] end subgraph g4u["4 union"] @@ -76,12 +92,17 @@ flowchart LR DeclinedOrder["DeclinedOrder
order · payment"] UnsettledOrder["UnsettledOrder
order · payment"] OrderOutcome["OrderOutcome"] + Recommended["Recommended
matches"] + NothingRelated["NothingRelated
matches"] + Unavailable["Unavailable
reason"] + Recommendation["Recommendation"] end subgraph g4a["4 action"] ReadDiscount["ReadDiscount
order"] Charge["Charge
order"] WriteNotice["WriteNotice
order"] + FindRelated["FindRelated
order · closeness · per_aisle · limit"] end subgraph g5["5 contract model"] @@ -94,12 +115,15 @@ flowchart LR subgraph g7["7 transformation"] ChargeAttempt["ChargeAttempt
order · reply"] + RelatedQuery["RelatedQuery
action"] + RelatedSearch["RelatedSearch
order · reply"] end subgraph g8["8 effect interpreter"] ReadDiscountInterpreter["ReadDiscountInterpreter
action · client"] ChargeInterpreter["ChargeInterpreter
action · client"] WriteNoticeInterpreter["WriteNoticeInterpreter
action · client"] + FindRelatedInterpreter["FindRelatedInterpreter
action · client"] end subgraph g9["9 route"] @@ -147,6 +171,27 @@ flowchart LR DeclinedOrder --> WriteNotice --> WriteNoticeInterpreter ==> NotifiedOrder DeclinedOrder --> NotifiedOrder DeclinedOrder -.-> DeclineNoticeValues + PaidOrder --> FindRelated --> FindRelatedInterpreter ==> RelatedSearch + FindRelated --> RelatedQuery -.-> IndexQuery + Line -.-> CatalogPoint + PointHit --> Hits --> PointGroup --> PointGroups --> GroupResult --> IndexGroups --> IndexReply + IndexFailure --> IndexReply + PaidOrder --> RelatedSearch + IndexReply --> RelatedSearch + PointHit -.-> Match + Match --> RelatedMatches + Match --> NoMatches + IndexGroups -.-> Recommended + IndexGroups -.-> NothingRelated + IndexFailure -.-> Unavailable + RelatedMatches --> Recommended + NoMatches --> NothingRelated + Recommended --> Recommendation + NothingRelated --> Recommendation + Unavailable --> Recommendation + RelatedSearch -.-> RecommendedOrder + PaidOrder --> RecommendedOrder + Recommendation --> RecommendedOrder ``` Every box is a class in the page its group names. diff --git a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/action.md b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/action.md index 286a171..3a65a79 100644 --- a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/action.md +++ b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/action.md @@ -1,6 +1,6 @@ --- type: Construct -description: "One intended external effect, as a value that performs nothing. Holds semantic scalars, value objects, and concept models. Lives in domain//.py, beside the fact that authorizes it." +description: "One intended external effect, as a value that performs nothing, carrying every rule its effect must follow. Holds semantic scalars, value objects, and concept models. Lives in domain//.py, beside the fact that authorizes it." --- ```python @@ -45,3 +45,21 @@ class WriteNotice(BaseModel): ) order: DeclinedOrder ``` + +```python +# domain/shop/order.py +class FindRelated(BaseModel): + """The intended search of the catalog for products close to the ones a paid order bought.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + order: PaidOrder + closeness: Closeness + per_aisle: GroupSize + limit: MatchLimit +``` diff --git a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/collection.md b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/collection.md index b3b69ca..9a2e724 100644 --- a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/collection.md +++ b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/collection.md @@ -1,6 +1,6 @@ --- type: Construct -description: "Several of one thing, with a meaning of their own, as a frozen tuple. Holds its members. Lives beside them." +description: "Several of one thing, with a meaning of their own, as a frozen tuple. Holds its members. Lives beside them. Crossing a layer, a collection is handed over as its members: a fold returning a tuple of them, from which the next layer's collection is constructed." --- ```python @@ -33,3 +33,73 @@ class Lines(RootModel[tuple[Line, ...]]): def amount(self) -> Amount: return Amount(sum(line.amount.root for line in self.root)) ``` + +```python +# domain/shop/value.py +class RelatedMatches(RootModel[tuple[Match, ...]]): + """The products recommended beside an order, at most one per aisle, at least one.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: tuple[Match, ...] = Field(min_length=1) + + +class NoMatches(RootModel[tuple[Match, ...]]): + """No product close enough to an order to recommend.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: tuple[Match, ...] = Field(max_length=0) +``` + +```python +# integration/catalog_index/model.py +class Hits(RootModel[tuple[PointHit, ...]]): + """The products the catalog index found in one aisle.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: tuple[PointHit, ...] = Field(min_length=1) + + +class PointGroups(RootModel[tuple[PointGroup, ...]]): + """Every aisle of a grouped recommendation; none when nothing was close enough.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + + +class IndexGroups(BaseModel): + """The catalog index's answer to a grouped recommendation.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + result: GroupResult + status: IndexStatus + time: QueryTime + + @property + def matches(self) -> tuple[PointHit, ...]: + return tuple(hit for group in self.result.groups.root for hit in group.hits.root) +``` diff --git a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/composition-root.md b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/composition-root.md index 2c1ee2d..37b35bc 100644 --- a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/composition-root.md +++ b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/composition-root.md @@ -59,10 +59,18 @@ from pydantic_ai.capabilities import LocalWorkspace from pydantic_ai_harness import FileSystem, Skills from config import ShopConfig -from domain.shop.order import DeclinedOrder, NotifiedOrder, Order, OrderOutcome +from domain.shop.order import ( + DeclinedOrder, + NotifiedOrder, + Order, + OrderOutcome, + PaidOrder, + RecommendedOrder, +) from domain.shop.type import SkillLibrary, SkillLocation from domain.shop.value import DeclineNoticeValues from integration.agent_skills.model import SkillDocument +from integration.catalog_index.interpreter import FindRelatedInterpreter from integration.model_provider.interpreter import WriteNoticeInterpreter from integration.model_provider.model import NoticeReply from integration.payments.interpreter import ChargeInterpreter @@ -112,3 +120,18 @@ async def notify(order: DeclinedOrder) -> NotifiedOrder: def notify_sync(order: DeclinedOrder) -> NotifiedOrder: return asyncio.run(notify(order)) ``` + +```python +# shop/__init__.py +async def recommend(order: PaidOrder) -> RecommendedOrder: + return ( + await FindRelatedInterpreter( + action=order.related, + client=ShopConfig().catalog.client, + ).interpret() + ).recommended + + +def recommend_sync(order: PaidOrder) -> RecommendedOrder: + return asyncio.run(recommend(order)) +``` diff --git a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/concept-model.md b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/concept-model.md index 976cf11..07cc053 100644 --- a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/concept-model.md +++ b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/concept-model.md @@ -57,6 +57,10 @@ class PricedOrder(BaseModel): def currency(self) -> Currency: return self.order.currency + @property + def lines(self) -> Lines: + return self.order.lines + @property def amount(self) -> Amount: return Amount(self.order.lines.amount.root * (100 - self.discount.percent.root) // 100) @@ -81,3 +85,20 @@ class NotifiedOrder(BaseModel): order: DeclinedOrder notice: NoticeText ``` + +```python +# domain/shop/order.py +class RecommendedOrder(BaseModel): + """A paid order, with what the catalog suggests beside it.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + from_attributes=True, + ) + order: PaidOrder + recommendation: Recommendation +``` diff --git a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/config.md b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/config.md index ba9cf36..e69abd8 100644 --- a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/config.md +++ b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/config.md @@ -38,7 +38,9 @@ from domain.shop.type import ( BlankPassword, CardProviderName, CollectionUrl, + FieldName, InvoiceProviderName, + MediaType, ModelName, ProviderKey, ProviderUrl, @@ -124,6 +126,27 @@ class Notices(BaseModel): ) +class CatalogIndex(BaseModel): + """The catalog's collection in the index, at its address.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + from_attributes=True, + ) + index_collection_url: CollectionUrl + + @property + def client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=self.index_collection_url.root, + headers=((FieldName.CONTENT_TYPE, MediaType.JSON),), + ) + + class ShopConfig(BaseSettings): """The deployment's addresses, its payment provider, its notice model, and their credentials.""" @@ -141,6 +164,7 @@ class ShopConfig(BaseSettings): payments_key: ProviderKey | Unset = Unset() notices_model: ModelName notices_key: ProviderKey + index_collection_url: CollectionUrl @property def payments(self) -> Payments: @@ -149,4 +173,8 @@ class ShopConfig(BaseSettings): @property def notices(self) -> Notices: return Notices.model_validate(self) + + @property + def catalog(self) -> CatalogIndex: + return CatalogIndex.model_validate(self) ``` diff --git a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/effect-interpreter.md b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/effect-interpreter.md index 1626a40..de7890d 100644 --- a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/effect-interpreter.md +++ b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/effect-interpreter.md @@ -112,3 +112,44 @@ class WriteNoticeInterpreter(BaseModel): ).output.text, ) ``` + +```python +# integration/catalog_index/interpreter.py +import httpx +from pydantic import BaseModel, ConfigDict, Field + +from domain.shop.order import FindRelated +from integration.catalog_index.model import IndexQuery, IndexReply, IndexResource +from integration.catalog_index.query import RelatedQuery +from integration.catalog_index.search import RelatedSearch + + +class FindRelatedInterpreter(BaseModel): + """The one place the catalog index is asked for products close to the ones an order bought.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + arbitrary_types_allowed=True, + ) + action: FindRelated + client: httpx.AsyncClient = Field(exclude=True, repr=False) + + async def interpret(self) -> RelatedSearch: + return RelatedSearch( + order=self.action.order, + reply=IndexReply.model_validate_json( + ( + await self.client.post( + IndexResource.GROUPED_QUERY, + content=IndexQuery.model_validate( + RelatedQuery(action=self.action) + ).model_dump_json(), + ) + ).content + ), + ) +``` diff --git a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/foreign-model.md b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/foreign-model.md index 10b4677..3edc985 100644 --- a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/foreign-model.md +++ b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/foreign-model.md @@ -173,3 +173,328 @@ class NoticeReply(BaseModel): ) text: NoticeText ``` + +```python +# integration/catalog_index/model.py +from enum import StrEnum +from uuid import uuid5 + +from pydantic import BaseModel, ConfigDict, Field, RootModel + +from domain.shop.type import ( + Category, + Closeness, + Currency, + GroupSize, + IndexFault, + IndexStatus, + MatchLimit, + PayloadKey, + PointId, + PointNamespace, + PointVersion, + QueryTime, + Similarity, + Sku, +) + + +class IndexResource(StrEnum): + """What the catalog's collection answers, relative to its address.""" + + GROUPED_QUERY = "points/query/groups" + + +class CatalogPoint(BaseModel): + """A product as the catalog index names it.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + from_attributes=True, + ) + sku: Sku + + @property + def id(self) -> PointId: + return PointId(uuid5(PointNamespace().root, self.sku.root)) + + +class PointIds(RootModel[tuple[PointId, ...]]): + """The products a recommendation is drawn toward.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: tuple[PointId, ...] = Field(min_length=1) + + +class Recommend(BaseModel): + """A recommendation drawn toward some products.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + positive: PointIds + + +class RecommendQuery(BaseModel): + """A query that recommends by example.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + recommend: Recommend + + +class CurrencyMatch(BaseModel): + """A payload value a product must hold.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + value: Currency + + +class CurrencyCondition(BaseModel): + """A product sold in a currency.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + key: PayloadKey = PayloadKey.CURRENCIES + match: CurrencyMatch + + +class Conditions(RootModel[tuple[CurrencyCondition, ...]]): + """Conditions every product in a recommendation meets.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: tuple[CurrencyCondition, ...] = Field(min_length=1) + + +class MarketFilter(BaseModel): + """The products a market may be offered.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + must: Conditions + + +class PayloadSelection(RootModel[tuple[PayloadKey, ...]]): + """The fields the catalog index returns beside each product.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: tuple[PayloadKey, ...] = (PayloadKey.SKU, PayloadKey.CATEGORY) + + +class IndexQuery(BaseModel): + """A grouped recommendation, as the catalog index accepts it.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + from_attributes=True, + serialize_by_alias=True, + ) + query: RecommendQuery + market: MarketFilter = Field(serialization_alias="filter") + score_threshold: Closeness + group_by: PayloadKey = PayloadKey.CATEGORY + group_size: GroupSize + limit: MatchLimit + with_payload: PayloadSelection = PayloadSelection() + + +class PointPayload(BaseModel): + """The fields the catalog index returned beside a product.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + sku: Sku + category: Category + + +class PointHit(BaseModel): + """A product the catalog index found, and how alike it scored it.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + id: PointId + version: PointVersion + similarity: Similarity = Field(alias="score") + payload: PointPayload + + @property + def sku(self) -> Sku: + return self.payload.sku + + @property + def category(self) -> Category: + return self.payload.category + + +class Hits(RootModel[tuple[PointHit, ...]]): + """The products the catalog index found in one aisle.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: tuple[PointHit, ...] = Field(min_length=1) + + +class PointGroup(BaseModel): + """One aisle of a grouped recommendation.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + hits: Hits + id: Category + + +class PointGroups(RootModel[tuple[PointGroup, ...]]): + """Every aisle of a grouped recommendation; none when nothing was close enough.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + + +class GroupResult(BaseModel): + """The aisles the catalog index found.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + groups: PointGroups + + +class IndexGroups(BaseModel): + """The catalog index's answer to a grouped recommendation.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + result: GroupResult + status: IndexStatus + time: QueryTime + + @property + def matches(self) -> tuple[PointHit, ...]: + return tuple(hit for group in self.result.groups.root for hit in group.hits.root) + + +class IndexFaultStatus(BaseModel): + """What the catalog index said went wrong.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + error: IndexFault + + +class IndexFailure(BaseModel): + """A recommendation the catalog index could not give.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + status: IndexFaultStatus + time: QueryTime + + @property + def reason(self) -> IndexFault: + return self.status.error + + +class IndexReply(RootModel[IndexGroups | IndexFailure]): + """What the catalog index says of a grouped recommendation.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + + @property + def answer(self) -> IndexGroups | IndexFailure: + return self.root +``` diff --git a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/semantic-scalar.md b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/semantic-scalar.md index ad836c9..7342518 100644 --- a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/semantic-scalar.md +++ b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/semantic-scalar.md @@ -83,7 +83,7 @@ class ProviderUrl(RootModel[str]): class CollectionUrl(RootModel[str]): - """The address of a collection another system holds; each thing in it is named relative to it.""" + """The address of a collection another system holds; what it holds is named relative to it.""" model_config = ConfigDict( frozen=True, @@ -296,3 +296,139 @@ class SkillLibrary(StrEnum): SKILLS = "prompts/skills" ``` + +```python +# domain/shop/type.py +class Category(RootModel[str]): + """The aisle of the catalog a product belongs to.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: str = Field(pattern=r"^[a-z]+(-[a-z]+)*$") + + +class Similarity(RootModel[float]): + """How alike two products are, as the catalog index scores them.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: float = Field(ge=-1, le=1) + + +class Closeness(RootModel[float]): + """How alike a product must be to one an order bought to be recommended beside it.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: float = Field(default=0.8, ge=-1, le=1) + + +class MatchLimit(RootModel[int]): + """How many aisles a recommendation draws from.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: int = Field(default=4, ge=1) + + +class GroupSize(RootModel[int]): + """How many products a recommendation takes from each aisle.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: int = Field(default=1, ge=1) + + +class PointId(RootModel[UUID]): + """The catalog index's name for a product.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: UUID + + +class PointNamespace(RootModel[UUID]): + """The namespace the catalog index's product names are made in.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: UUID = UUID("6f1c2b8e-3a0d-4c1e-9f57-2d4b8a6e1c33") + + +class PointVersion(RootModel[int]): + """The catalog index's revision of a product.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: int = Field(ge=0) + + +class QueryTime(RootModel[float]): + """How long the catalog index took to answer, in seconds.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: float = Field(ge=0) + + +class IndexFault(RootModel[str]): + """Why the catalog index could not answer.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + ) + root: str = Field(min_length=1) + + +class IndexStatus(StrEnum): + """The catalog index's word for an answer it could give.""" + + OK = "ok" + + +class PayloadKey(StrEnum): + """A field the catalog index keeps beside each product.""" + + SKU = "sku" + CATEGORY = "category" + CURRENCIES = "currencies" +``` diff --git a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/transformation.md b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/transformation.md index 315bc18..dc67b6b 100644 --- a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/transformation.md +++ b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/transformation.md @@ -32,3 +32,97 @@ class ChargeAttempt(BaseModel): def outcome(self) -> OrderOutcome: return OrderOutcome.model_validate(self) ``` + +```python +# integration/catalog_index/query.py +from pydantic import BaseModel, ConfigDict + +from domain.shop.order import FindRelated +from domain.shop.type import Closeness, GroupSize, MatchLimit +from integration.catalog_index.model import ( + CatalogPoint, + Conditions, + CurrencyCondition, + CurrencyMatch, + MarketFilter, + PointIds, + Recommend, + RecommendQuery, +) + + +class RelatedQuery(BaseModel): + """A search for related products, in the catalog index's terms.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + action: FindRelated + + @property + def query(self) -> RecommendQuery: + return RecommendQuery( + recommend=Recommend( + positive=PointIds( + tuple( + CatalogPoint.model_validate(line).id + for line in self.action.order.lines.root + ) + ) + ) + ) + + @property + def market(self) -> MarketFilter: + return MarketFilter( + must=Conditions( + (CurrencyCondition(match=CurrencyMatch(value=self.action.order.currency)),) + ) + ) + + @property + def score_threshold(self) -> Closeness: + return self.action.closeness + + @property + def group_size(self) -> GroupSize: + return self.action.per_aisle + + @property + def limit(self) -> MatchLimit: + return self.action.limit +``` + +```python +# integration/catalog_index/search.py +from pydantic import BaseModel, ConfigDict + +from domain.shop.order import PaidOrder, Recommendation, RecommendedOrder +from integration.catalog_index.model import IndexReply + + +class RelatedSearch(BaseModel): + """A paid order's search of the catalog, with what the catalog index said of it.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + ) + order: PaidOrder + reply: IndexReply + + @property + def recommendation(self) -> Recommendation: + return Recommendation.model_validate(self.reply.answer) + + @property + def recommended(self) -> RecommendedOrder: + return RecommendedOrder.model_validate(self) +``` diff --git a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/union.md b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/union.md index becce84..363fac8 100644 --- a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/union.md +++ b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/union.md @@ -19,6 +19,20 @@ class PaidOrder(BaseModel): order: PricedOrder payment: Approval + @property + def currency(self) -> Currency: + return self.order.currency + + @property + def lines(self) -> Lines: + return self.order.lines + + @property + def related(self) -> FindRelated: + return FindRelated( + order=self, closeness=Closeness(), per_aisle=GroupSize(), limit=MatchLimit() + ) + class DeclinedOrder(BaseModel): """A priced order the payment provider declined to charge.""" @@ -85,3 +99,59 @@ class OrderOutcome(RootModel[PaidOrder | DeclinedOrder | UnsettledOrder]): def payment(self) -> Approval | Decline | Failure: return self.root.payment ``` + +```python +# domain/shop/order.py +class Recommended(BaseModel): + """Products close enough to an order to recommend beside it.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + from_attributes=True, + ) + matches: RelatedMatches + + +class NothingRelated(BaseModel): + """No product in the catalog close enough to an order to recommend.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + from_attributes=True, + ) + matches: NoMatches + + +class Unavailable(BaseModel): + """A recommendation the catalog index could not give, and why.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + from_attributes=True, + ) + reason: IndexFault + + +class Recommendation(RootModel[Recommended | NothingRelated | Unavailable]): + """What the catalog suggests beside an order.""" + + model_config = ConfigDict( + frozen=True, + strict=True, + validate_default=True, + revalidate_instances="never", + from_attributes=True, + ) +``` diff --git a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/value-object.md b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/value-object.md index a4f1730..b7fb5d1 100644 --- a/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/value-object.md +++ b/packages/python/type-construction-agent/src/type_construction/agent/prompts/skills/python-dev-tca/value-object.md @@ -93,3 +93,21 @@ class Failure(BaseModel): ) error: StatedError ``` + +```python +# domain/shop/value.py +class Match(BaseModel): + """A product the catalog found close to an order, in its aisle, and how alike it is.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + validate_default=True, + revalidate_instances="never", + from_attributes=True, + ) + sku: Sku + category: Category + similarity: Similarity +``` diff --git a/pyproject.toml b/pyproject.toml index 2db0d5d..010b0ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ src = ["packages/python"] [tool.ruff.lint] select = ["E","W","F","I","N","UP","B","A","C4","PIE","RET","SIM","ARG","PTH","ERA","PL","TRY","RUF","ANN","S","FBT","TC","EM"] -ignore = ["ANN401","TRY003"] +ignore = ["ANN401","TRY003","TC001","TC002","TC003"] [tool.ruff.lint.per-file-ignores] "**/tests/**" = ["S101", "S106", "ARG001", "PLR2004"]