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.
- adforge/__init__.py +3 -0
- adforge/models/__init__.py +24 -0
- adforge/models/common.py +23 -0
- adforge/models/cost.py +42 -0
- adforge/models/dag.py +48 -0
- adforge/models/problem.py +21 -0
- adforge/models/solution.py +49 -0
- adforge/models/sub_problem.py +28 -0
- adforge/persistence/__init__.py +20 -0
- adforge/persistence/db.py +84 -0
- adforge/persistence/migrations/001_initial.sql +103 -0
- adforge/persistence/repositories/__init__.py +1 -0
- adforge/persistence/repositories/cost_repository.py +172 -0
- adforge/persistence/repositories/dag_repository.py +110 -0
- adforge/persistence/repositories/problem_repository.py +72 -0
- adforge/persistence/repositories/solution_repository.py +98 -0
- adforge/persistence/repositories/sub_problem_repository.py +99 -0
- adforge/pricing/__init__.py +20 -0
- adforge/pricing/aws.py +151 -0
- adforge/pricing/azure.py +95 -0
- adforge/pricing/base.py +39 -0
- adforge/pricing/databricks.py +52 -0
- adforge/pricing/gcp.py +53 -0
- adforge/pricing/manager.py +40 -0
- adforge/pricing/roles.py +79 -0
- adforge/prompts/__init__.py +40 -0
- adforge/prompts/problem_decomposition.py +41 -0
- adforge/resources/__init__.py +40 -0
- adforge/server.py +88 -0
- adforge/tools/__init__.py +40 -0
- adforge/tools/cost.py +101 -0
- adforge/tools/decompose.py +153 -0
- adforge/tools/problem.py +101 -0
- adforge/tools/solution.py +169 -0
- adforge/tools/solution_cost.py +125 -0
- adforge/utils/__init__.py +1 -0
- adforge/utils/logging.py +10 -0
- adforge-0.1.0.dist-info/METADATA +161 -0
- adforge-0.1.0.dist-info/RECORD +41 -0
- adforge-0.1.0.dist-info/WHEEL +4 -0
- adforge-0.1.0.dist-info/entry_points.txt +2 -0
adforge/__init__.py
ADDED
|
@@ -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
|
+
]
|
adforge/models/common.py
ADDED
|
@@ -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()
|