adforge 0.1.0__py3-none-any.whl

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.
Files changed (41) hide show
  1. adforge/__init__.py +3 -0
  2. adforge/models/__init__.py +24 -0
  3. adforge/models/common.py +23 -0
  4. adforge/models/cost.py +42 -0
  5. adforge/models/dag.py +48 -0
  6. adforge/models/problem.py +21 -0
  7. adforge/models/solution.py +49 -0
  8. adforge/models/sub_problem.py +28 -0
  9. adforge/persistence/__init__.py +20 -0
  10. adforge/persistence/db.py +84 -0
  11. adforge/persistence/migrations/001_initial.sql +103 -0
  12. adforge/persistence/repositories/__init__.py +1 -0
  13. adforge/persistence/repositories/cost_repository.py +172 -0
  14. adforge/persistence/repositories/dag_repository.py +110 -0
  15. adforge/persistence/repositories/problem_repository.py +72 -0
  16. adforge/persistence/repositories/solution_repository.py +98 -0
  17. adforge/persistence/repositories/sub_problem_repository.py +99 -0
  18. adforge/pricing/__init__.py +20 -0
  19. adforge/pricing/aws.py +151 -0
  20. adforge/pricing/azure.py +95 -0
  21. adforge/pricing/base.py +39 -0
  22. adforge/pricing/databricks.py +52 -0
  23. adforge/pricing/gcp.py +53 -0
  24. adforge/pricing/manager.py +40 -0
  25. adforge/pricing/roles.py +79 -0
  26. adforge/prompts/__init__.py +40 -0
  27. adforge/prompts/problem_decomposition.py +41 -0
  28. adforge/resources/__init__.py +40 -0
  29. adforge/server.py +88 -0
  30. adforge/tools/__init__.py +40 -0
  31. adforge/tools/cost.py +101 -0
  32. adforge/tools/decompose.py +153 -0
  33. adforge/tools/problem.py +101 -0
  34. adforge/tools/solution.py +169 -0
  35. adforge/tools/solution_cost.py +125 -0
  36. adforge/utils/__init__.py +1 -0
  37. adforge/utils/logging.py +10 -0
  38. adforge-0.1.0.dist-info/METADATA +161 -0
  39. adforge-0.1.0.dist-info/RECORD +41 -0
  40. adforge-0.1.0.dist-info/WHEEL +4 -0
  41. adforge-0.1.0.dist-info/entry_points.txt +2 -0
adforge/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Solution Architect MCP Server package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,24 @@
1
+ """Core Pydantic models for solution architecture data."""
2
+
3
+ from adforge.models.common import Complexity, Status
4
+ from adforge.models.cost import CostLineItem, CostSummary
5
+ from adforge.models.dag import EdgeType, NodeType, SolutionEdge, SolutionNode
6
+ from adforge.models.problem import Problem
7
+ from adforge.models.solution import Comment, Review, Solution
8
+ from adforge.models.sub_problem import SubProblem
9
+
10
+ __all__ = [
11
+ "Status",
12
+ "Complexity",
13
+ "Problem",
14
+ "Solution",
15
+ "Comment",
16
+ "Review",
17
+ "SubProblem",
18
+ "SolutionNode",
19
+ "SolutionEdge",
20
+ "NodeType",
21
+ "EdgeType",
22
+ "CostLineItem",
23
+ "CostSummary",
24
+ ]
@@ -0,0 +1,23 @@
1
+ """Shared enumerations and primitives."""
2
+
3
+ from enum import IntEnum, StrEnum
4
+
5
+
6
+ class Status(StrEnum):
7
+ """Generic lifecycle status."""
8
+
9
+ DRAFT = "draft"
10
+ OPEN = "open"
11
+ IN_PROGRESS = "in_progress"
12
+ APPROVED = "approved"
13
+ REJECTED = "rejected"
14
+ CLOSED = "closed"
15
+
16
+
17
+ class Complexity(IntEnum):
18
+ """Rough complexity scoring for sub-problems."""
19
+
20
+ LOW = 1
21
+ MEDIUM = 3
22
+ HIGH = 5
23
+ EXTREME = 8
adforge/models/cost.py ADDED
@@ -0,0 +1,42 @@
1
+ """Cost/price line items and summaries."""
2
+
3
+ from datetime import UTC, datetime
4
+ from decimal import Decimal
5
+ from typing import Any
6
+ from uuid import uuid4
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class CostLineItem(BaseModel):
12
+ """A single priced component of a solution."""
13
+
14
+ id: str = Field(default_factory=lambda: uuid4().hex)
15
+ solution_id: str
16
+ sub_problem_id: str | None = None
17
+ provider: str
18
+ service: str
19
+ region: str = ""
20
+ parameters: dict[str, Any] = Field(default_factory=dict)
21
+ unit_price: Decimal
22
+ quantity: Decimal = Decimal("1")
23
+ unit: str = ""
24
+ source_url: str = ""
25
+ source_fetched_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
26
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
27
+
28
+ @property
29
+ def total(self) -> Decimal:
30
+ """Total cost for this line item."""
31
+ return self.unit_price * self.quantity
32
+
33
+
34
+ class CostSummary(BaseModel):
35
+ """Aggregated cost view attached to a solution document."""
36
+
37
+ currency: str = "USD"
38
+ total_monthly: Decimal = Decimal("0")
39
+ total_hourly: Decimal | None = None
40
+ line_item_count: int = 0
41
+ providers: list[str] = Field(default_factory=list)
42
+ note: str = ""
adforge/models/dag.py ADDED
@@ -0,0 +1,48 @@
1
+ """DAG representation of a solution approach."""
2
+
3
+ from enum import StrEnum
4
+ from typing import Any
5
+ from uuid import uuid4
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class NodeType(StrEnum):
11
+ """Kind of node in a solution DAG."""
12
+
13
+ SUB_PROBLEM = "sub_problem"
14
+ DECISION = "decision"
15
+ DELIVERABLE = "deliverable"
16
+ RISK = "risk"
17
+
18
+
19
+ class EdgeType(StrEnum):
20
+ """Kind of edge in a solution DAG."""
21
+
22
+ DEPENDS_ON = "depends_on"
23
+ ALTERNATIVE_TO = "alternative_to"
24
+ PRODUCES = "produces"
25
+ BLOCKS = "blocks"
26
+
27
+
28
+ class SolutionNode(BaseModel):
29
+ """A node in a solution approach DAG."""
30
+
31
+ id: str = Field(default_factory=lambda: uuid4().hex)
32
+ solution_id: str
33
+ sub_problem_id: str | None = None
34
+ node_type: NodeType = NodeType.SUB_PROBLEM
35
+ title: str = ""
36
+ description: str = ""
37
+ data: dict[str, Any] = Field(default_factory=dict)
38
+
39
+
40
+ class SolutionEdge(BaseModel):
41
+ """A directed edge between two nodes in a solution approach DAG."""
42
+
43
+ id: str = Field(default_factory=lambda: uuid4().hex)
44
+ solution_id: str
45
+ from_node_id: str
46
+ to_node_id: str
47
+ edge_type: EdgeType = EdgeType.DEPENDS_ON
48
+ data: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,21 @@
1
+ """Problem statement model."""
2
+
3
+ from datetime import UTC, datetime
4
+ from uuid import uuid4
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class Problem(BaseModel):
10
+ """A problem to be solved by one or more solution approaches."""
11
+
12
+ id: str = Field(default_factory=lambda: uuid4().hex)
13
+ title: str
14
+ description: str = ""
15
+ tags: list[str] = Field(default_factory=list)
16
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
17
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
18
+
19
+ def touch(self) -> None:
20
+ """Update the updated_at timestamp."""
21
+ self.updated_at = datetime.now(UTC)
@@ -0,0 +1,49 @@
1
+ """Solution document model, shaped like a GitHub PR/issue."""
2
+
3
+ from datetime import UTC, datetime
4
+ from uuid import uuid4
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from adforge.models.common import Status
9
+ from adforge.models.cost import CostSummary
10
+
11
+
12
+ class Comment(BaseModel):
13
+ """A comment on a solution, like a GitHub issue comment."""
14
+
15
+ id: str = Field(default_factory=lambda: uuid4().hex)
16
+ author: str = ""
17
+ body: str = ""
18
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
19
+
20
+
21
+ class Review(BaseModel):
22
+ """A review/approval record on a solution, like a GitHub PR review."""
23
+
24
+ id: str = Field(default_factory=lambda: uuid4().hex)
25
+ author: str = ""
26
+ state: str = "commented" # commented, approved, changes_requested
27
+ body: str = ""
28
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
29
+
30
+
31
+ class Solution(BaseModel):
32
+ """A solution approach for a problem, persisted as a PR/issue-shaped document."""
33
+
34
+ id: str = Field(default_factory=lambda: uuid4().hex)
35
+ problem_id: str
36
+ title: str
37
+ status: Status = Status.DRAFT
38
+ body: str = ""
39
+ comments: list[Comment] = Field(default_factory=list)
40
+ reviews: list[Review] = Field(default_factory=list)
41
+ labels: list[str] = Field(default_factory=list)
42
+ assignees: list[str] = Field(default_factory=list)
43
+ cost_summary: CostSummary | None = None
44
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
45
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
46
+
47
+ def touch(self) -> None:
48
+ """Update the updated_at timestamp."""
49
+ self.updated_at = datetime.now(UTC)
@@ -0,0 +1,28 @@
1
+ """Sub-problem model for divide-and-conquer decomposition."""
2
+
3
+ from datetime import UTC, datetime
4
+ from uuid import uuid4
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from adforge.models.common import Complexity, Status
9
+
10
+
11
+ class SubProblem(BaseModel):
12
+ """A sub-problem belonging to a problem and optionally a solution."""
13
+
14
+ id: str = Field(default_factory=lambda: uuid4().hex)
15
+ problem_id: str
16
+ solution_id: str | None = None
17
+ parent_id: str | None = None
18
+ title: str
19
+ description: str = ""
20
+ tags: list[str] = Field(default_factory=list)
21
+ complexity: Complexity = Complexity.MEDIUM
22
+ status: Status = Status.DRAFT
23
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
24
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
25
+
26
+ def touch(self) -> None:
27
+ """Update the updated_at timestamp."""
28
+ self.updated_at = datetime.now(UTC)
@@ -0,0 +1,20 @@
1
+ """Persistence layer: SQLite-backed storage for problems, solutions, and costs."""
2
+
3
+ from adforge.persistence.db import get_connection, migrate
4
+ from adforge.persistence.repositories.cost_repository import CostRepository
5
+ from adforge.persistence.repositories.dag_repository import DagRepository
6
+ from adforge.persistence.repositories.problem_repository import ProblemRepository
7
+ from adforge.persistence.repositories.solution_repository import SolutionRepository
8
+ from adforge.persistence.repositories.sub_problem_repository import (
9
+ SubProblemRepository,
10
+ )
11
+
12
+ __all__ = [
13
+ "get_connection",
14
+ "migrate",
15
+ "ProblemRepository",
16
+ "SolutionRepository",
17
+ "SubProblemRepository",
18
+ "DagRepository",
19
+ "CostRepository",
20
+ ]
@@ -0,0 +1,84 @@
1
+ """SQLite connection and migration utilities."""
2
+
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from adforge.utils.logging import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+ DEFAULT_DB_NAME = "adforge.db"
14
+
15
+
16
+ def _db_path() -> Path:
17
+ """Resolve the SQLite database path from env or project root."""
18
+ env_path = os.environ.get("ADFORGE_DB_PATH")
19
+ if env_path:
20
+ return Path(env_path)
21
+
22
+ # Fall back to project root, discovered from this file's location.
23
+ project_root = Path(__file__).resolve().parents[4]
24
+ return project_root / DEFAULT_DB_NAME
25
+
26
+
27
+ def get_connection() -> sqlite3.Connection:
28
+ """Return a SQLite connection with JSON-friendly row factory."""
29
+ path = _db_path()
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ conn = sqlite3.connect(path)
32
+ conn.row_factory = sqlite3.Row
33
+ conn.execute("PRAGMA foreign_keys = ON")
34
+ return conn
35
+
36
+
37
+ def _load_migrations() -> list[tuple[int, str]]:
38
+ """Load migration SQL files in order."""
39
+ migrations_dir = Path(__file__).with_name("migrations")
40
+ files = sorted(migrations_dir.glob("*.sql"))
41
+ migrations = []
42
+ for file in files:
43
+ try:
44
+ number = int(file.stem.split("_", 1)[0])
45
+ except ValueError:
46
+ logger.warning("Skipping migration file without numeric prefix", file=file.name)
47
+ continue
48
+ migrations.append((number, file.read_text()))
49
+ migrations.sort(key=lambda x: x[0])
50
+ return migrations
51
+
52
+
53
+ def migrate() -> None:
54
+ """Run pending migrations."""
55
+ with get_connection() as conn:
56
+ conn.execute(
57
+ """
58
+ CREATE TABLE IF NOT EXISTS migrations (
59
+ version INTEGER PRIMARY KEY
60
+ )
61
+ """
62
+ )
63
+ cur = conn.execute("SELECT version FROM migrations")
64
+ applied = {row["version"] for row in cur.fetchall()}
65
+
66
+ for version, sql in _load_migrations():
67
+ if version in applied:
68
+ continue
69
+ logger.info("Applying migration", version=version)
70
+ conn.executescript(sql)
71
+ conn.execute("INSERT INTO migrations (version) VALUES (?)", (version,))
72
+ conn.commit()
73
+
74
+
75
+ def to_json_blob(value: Any) -> str:
76
+ """Serialize a value to a JSON string for SQLite storage."""
77
+ return json.dumps(value, default=str)
78
+
79
+
80
+ def from_json_blob(text: str | None) -> Any:
81
+ """Deserialize a JSON string from SQLite storage."""
82
+ if text is None:
83
+ return None
84
+ return json.loads(text)
@@ -0,0 +1,103 @@
1
+ -- Initial schema for solution-architect-spl
2
+
3
+ CREATE TABLE IF NOT EXISTS problems (
4
+ id TEXT PRIMARY KEY,
5
+ title TEXT NOT NULL,
6
+ description TEXT NOT NULL DEFAULT '',
7
+ tags TEXT NOT NULL DEFAULT '[]',
8
+ created_at TEXT NOT NULL,
9
+ updated_at TEXT NOT NULL
10
+ );
11
+
12
+ CREATE TABLE IF NOT EXISTS solutions (
13
+ id TEXT PRIMARY KEY,
14
+ problem_id TEXT NOT NULL,
15
+ title TEXT NOT NULL,
16
+ status TEXT NOT NULL DEFAULT 'draft',
17
+ body TEXT NOT NULL DEFAULT '',
18
+ comments TEXT NOT NULL DEFAULT '[]',
19
+ reviews TEXT NOT NULL DEFAULT '[]',
20
+ labels TEXT NOT NULL DEFAULT '[]',
21
+ assignees TEXT NOT NULL DEFAULT '[]',
22
+ cost_summary TEXT,
23
+ created_at TEXT NOT NULL,
24
+ updated_at TEXT NOT NULL,
25
+ FOREIGN KEY (problem_id) REFERENCES problems(id) ON DELETE CASCADE
26
+ );
27
+ CREATE INDEX IF NOT EXISTS idx_solutions_problem_id ON solutions(problem_id);
28
+
29
+ CREATE TABLE IF NOT EXISTS sub_problems (
30
+ id TEXT PRIMARY KEY,
31
+ problem_id TEXT NOT NULL,
32
+ solution_id TEXT,
33
+ parent_id TEXT,
34
+ title TEXT NOT NULL,
35
+ description TEXT NOT NULL DEFAULT '',
36
+ tags TEXT NOT NULL DEFAULT '[]',
37
+ complexity INTEGER NOT NULL DEFAULT 3,
38
+ status TEXT NOT NULL DEFAULT 'draft',
39
+ created_at TEXT NOT NULL,
40
+ updated_at TEXT NOT NULL,
41
+ FOREIGN KEY (problem_id) REFERENCES problems(id) ON DELETE CASCADE,
42
+ FOREIGN KEY (solution_id) REFERENCES solutions(id) ON DELETE SET NULL,
43
+ FOREIGN KEY (parent_id) REFERENCES sub_problems(id) ON DELETE SET NULL
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_sub_problems_problem_id ON sub_problems(problem_id);
46
+ CREATE INDEX IF NOT EXISTS idx_sub_problems_solution_id ON sub_problems(solution_id);
47
+
48
+ CREATE TABLE IF NOT EXISTS solution_nodes (
49
+ id TEXT PRIMARY KEY,
50
+ solution_id TEXT NOT NULL,
51
+ sub_problem_id TEXT,
52
+ node_type TEXT NOT NULL DEFAULT 'sub_problem',
53
+ title TEXT NOT NULL DEFAULT '',
54
+ description TEXT NOT NULL DEFAULT '',
55
+ data TEXT NOT NULL DEFAULT '{}',
56
+ FOREIGN KEY (solution_id) REFERENCES solutions(id) ON DELETE CASCADE,
57
+ FOREIGN KEY (sub_problem_id) REFERENCES sub_problems(id) ON DELETE SET NULL
58
+ );
59
+ CREATE INDEX IF NOT EXISTS idx_solution_nodes_solution_id ON solution_nodes(solution_id);
60
+
61
+ CREATE TABLE IF NOT EXISTS solution_edges (
62
+ id TEXT PRIMARY KEY,
63
+ solution_id TEXT NOT NULL,
64
+ from_node_id TEXT NOT NULL,
65
+ to_node_id TEXT NOT NULL,
66
+ edge_type TEXT NOT NULL DEFAULT 'depends_on',
67
+ data TEXT NOT NULL DEFAULT '{}',
68
+ FOREIGN KEY (solution_id) REFERENCES solutions(id) ON DELETE CASCADE,
69
+ FOREIGN KEY (from_node_id) REFERENCES solution_nodes(id) ON DELETE CASCADE,
70
+ FOREIGN KEY (to_node_id) REFERENCES solution_nodes(id) ON DELETE CASCADE
71
+ );
72
+ CREATE INDEX IF NOT EXISTS idx_solution_edges_solution_id ON solution_edges(solution_id);
73
+
74
+ CREATE TABLE IF NOT EXISTS cost_line_items (
75
+ id TEXT PRIMARY KEY,
76
+ solution_id TEXT NOT NULL,
77
+ sub_problem_id TEXT,
78
+ provider TEXT NOT NULL,
79
+ service TEXT NOT NULL,
80
+ region TEXT NOT NULL DEFAULT '',
81
+ parameters TEXT NOT NULL DEFAULT '{}',
82
+ unit_price TEXT NOT NULL,
83
+ quantity TEXT NOT NULL DEFAULT '1',
84
+ unit TEXT NOT NULL DEFAULT '',
85
+ source_url TEXT NOT NULL DEFAULT '',
86
+ source_fetched_at TEXT NOT NULL,
87
+ created_at TEXT NOT NULL,
88
+ FOREIGN KEY (solution_id) REFERENCES solutions(id) ON DELETE CASCADE,
89
+ FOREIGN KEY (sub_problem_id) REFERENCES sub_problems(id) ON DELETE SET NULL
90
+ );
91
+ CREATE INDEX IF NOT EXISTS idx_cost_line_items_solution_id ON cost_line_items(solution_id);
92
+
93
+ CREATE TABLE IF NOT EXISTS pricing_cache (
94
+ key TEXT PRIMARY KEY,
95
+ provider TEXT NOT NULL,
96
+ service TEXT NOT NULL,
97
+ region TEXT NOT NULL DEFAULT '',
98
+ parameters_hash TEXT NOT NULL DEFAULT '',
99
+ response TEXT NOT NULL,
100
+ fetched_at TEXT NOT NULL,
101
+ expires_at TEXT NOT NULL
102
+ );
103
+ CREATE INDEX IF NOT EXISTS idx_pricing_cache_expires ON pricing_cache(expires_at);
@@ -0,0 +1 @@
1
+ """SQLite repositories for domain models."""
@@ -0,0 +1,172 @@
1
+ """Cost line item repository and pricing cache."""
2
+
3
+ import hashlib
4
+ import json
5
+ import sqlite3
6
+ from datetime import UTC, datetime, timedelta
7
+ from decimal import Decimal
8
+ from typing import Any
9
+
10
+ from adforge.models import CostLineItem
11
+ from adforge.persistence.db import from_json_blob, get_connection, to_json_blob
12
+
13
+
14
+ class CostRepository:
15
+ """CRUD for cost line items."""
16
+
17
+ def __init__(self, conn: sqlite3.Connection | None = None) -> None:
18
+ self._conn = conn or get_connection()
19
+ self._own_conn = conn is None
20
+
21
+ def save(self, item: CostLineItem) -> CostLineItem:
22
+ self._conn.execute(
23
+ """
24
+ INSERT INTO cost_line_items (
25
+ id, solution_id, sub_problem_id, provider, service, region,
26
+ parameters, unit_price, quantity, unit, source_url, source_fetched_at, created_at
27
+ )
28
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
29
+ ON CONFLICT(id) DO UPDATE SET
30
+ solution_id = excluded.solution_id,
31
+ sub_problem_id = excluded.sub_problem_id,
32
+ provider = excluded.provider,
33
+ service = excluded.service,
34
+ region = excluded.region,
35
+ parameters = excluded.parameters,
36
+ unit_price = excluded.unit_price,
37
+ quantity = excluded.quantity,
38
+ unit = excluded.unit,
39
+ source_url = excluded.source_url,
40
+ source_fetched_at = excluded.source_fetched_at
41
+ """,
42
+ (
43
+ item.id,
44
+ item.solution_id,
45
+ item.sub_problem_id,
46
+ item.provider,
47
+ item.service,
48
+ item.region,
49
+ to_json_blob(item.parameters),
50
+ str(item.unit_price),
51
+ str(item.quantity),
52
+ item.unit,
53
+ item.source_url,
54
+ item.source_fetched_at.isoformat(),
55
+ item.created_at.isoformat(),
56
+ ),
57
+ )
58
+ self._conn.commit()
59
+ return item
60
+
61
+ def list_for_solution(self, solution_id: str) -> list[CostLineItem]:
62
+ rows = self._conn.execute(
63
+ "SELECT * FROM cost_line_items WHERE solution_id = ? ORDER BY created_at",
64
+ (solution_id,),
65
+ ).fetchall()
66
+ return [self._row_to_item(row) for row in rows]
67
+
68
+ def delete(self, item_id: str) -> bool:
69
+ cur = self._conn.execute("DELETE FROM cost_line_items WHERE id = ?", (item_id,))
70
+ self._conn.commit()
71
+ return cur.rowcount > 0
72
+
73
+ @staticmethod
74
+ def _row_to_item(row: sqlite3.Row) -> CostLineItem:
75
+ return CostLineItem(
76
+ id=row["id"],
77
+ solution_id=row["solution_id"],
78
+ sub_problem_id=row["sub_problem_id"],
79
+ provider=row["provider"],
80
+ service=row["service"],
81
+ region=row["region"],
82
+ parameters=from_json_blob(row["parameters"]) or {},
83
+ unit_price=Decimal(row["unit_price"]),
84
+ quantity=Decimal(row["quantity"]),
85
+ unit=row["unit"],
86
+ source_url=row["source_url"],
87
+ source_fetched_at=datetime.fromisoformat(row["source_fetched_at"]),
88
+ created_at=datetime.fromisoformat(row["created_at"]),
89
+ )
90
+
91
+ def __del__(self) -> None:
92
+ if self._own_conn:
93
+ self._conn.close()
94
+
95
+
96
+ class PricingCache:
97
+ """SQLite-backed TTL cache for provider pricing responses."""
98
+
99
+ def __init__(self, conn: sqlite3.Connection | None = None, ttl_seconds: int = 86400) -> None:
100
+ self._conn = conn or get_connection()
101
+ self._own_conn = conn is None
102
+ self._ttl_seconds = ttl_seconds
103
+
104
+ def _make_key(
105
+ self, provider: str, service: str, region: str, parameters: dict[str, Any]
106
+ ) -> str:
107
+ payload = json.dumps(
108
+ {"provider": provider, "service": service, "region": region, "params": parameters},
109
+ sort_keys=True,
110
+ )
111
+ return hashlib.sha256(payload.encode()).hexdigest()
112
+
113
+ def get(
114
+ self, provider: str, service: str, region: str, parameters: dict[str, Any]
115
+ ) -> dict[str, Any] | None:
116
+ key = self._make_key(provider, service, region, parameters)
117
+ now = datetime.now(UTC).isoformat()
118
+ row = self._conn.execute(
119
+ "SELECT response FROM pricing_cache WHERE key = ? AND expires_at > ?",
120
+ (key, now),
121
+ ).fetchone()
122
+ if row is None:
123
+ return None
124
+ response = from_json_blob(row["response"])
125
+ if response is None:
126
+ return None
127
+ return response # type: ignore[no-any-return]
128
+
129
+ def set(
130
+ self,
131
+ provider: str,
132
+ service: str,
133
+ region: str,
134
+ parameters: dict[str, Any],
135
+ response: dict[str, Any],
136
+ ) -> None:
137
+ key = self._make_key(provider, service, region, parameters)
138
+ now = datetime.now(UTC)
139
+ expires = now + timedelta(seconds=self._ttl_seconds)
140
+ params_hash = key[:16]
141
+ self._conn.execute(
142
+ """
143
+ INSERT INTO pricing_cache (
144
+ key, provider, service, region, parameters_hash, response, fetched_at, expires_at
145
+ )
146
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
147
+ ON CONFLICT(key) DO UPDATE SET
148
+ response = excluded.response,
149
+ fetched_at = excluded.fetched_at,
150
+ expires_at = excluded.expires_at
151
+ """,
152
+ (
153
+ key,
154
+ provider,
155
+ service,
156
+ region,
157
+ params_hash,
158
+ to_json_blob(response),
159
+ now.isoformat(),
160
+ expires.isoformat(),
161
+ ),
162
+ )
163
+ self._conn.commit()
164
+
165
+ def clear_expired(self) -> None:
166
+ now = datetime.now(UTC).isoformat()
167
+ self._conn.execute("DELETE FROM pricing_cache WHERE expires_at <= ?", (now,))
168
+ self._conn.commit()
169
+
170
+ def __del__(self) -> None:
171
+ if self._own_conn:
172
+ self._conn.close()