@nikeandocean/carbon-factor-matcher 0.2.0
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.
- package/README.md +252 -0
- package/index.js +28 -0
- package/package.json +41 -0
- package/src/carbon_factor_matcher/__init__.py +3 -0
- package/src/carbon_factor_matcher/__main__.py +5 -0
- package/src/carbon_factor_matcher/adapters/__init__.py +6 -0
- package/src/carbon_factor_matcher/adapters/base.py +22 -0
- package/src/carbon_factor_matcher/adapters/ecoinvent.py +292 -0
- package/src/carbon_factor_matcher/api_client.py +94 -0
- package/src/carbon_factor_matcher/config.py +14 -0
- package/src/carbon_factor_matcher/factor_db.py +220 -0
- package/src/carbon_factor_matcher/license.py +80 -0
- package/src/carbon_factor_matcher/matcher.py +795 -0
- package/src/carbon_factor_matcher/models.py +71 -0
- package/src/carbon_factor_matcher/server.py +232 -0
- package/src/carbon_factor_matcher/usage_tracker.py +68 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""HTTP client for the Carbon Factor Matcher remote API."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .models import Factor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_API_URL = "https://paddle-webhook.nikeandocean.workers.dev/api"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FactorAPIClient:
|
|
15
|
+
"""Client for the remote factor data API.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
base_url: API base URL (default: Cloudflare Worker).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, base_url: str = DEFAULT_API_URL):
|
|
22
|
+
self.base_url = base_url.rstrip("/")
|
|
23
|
+
|
|
24
|
+
def _get(self, path: str, params: dict | None = None) -> dict:
|
|
25
|
+
"""Make a GET request to the API."""
|
|
26
|
+
url = f"{self.base_url}{path}"
|
|
27
|
+
if params:
|
|
28
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
29
|
+
|
|
30
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
31
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
32
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
33
|
+
|
|
34
|
+
def search(self, query: str, limit: int = 10, category: str | None = None) -> list[Factor]:
|
|
35
|
+
"""Search for emission factors by keyword.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
query: Search query (e.g., "electricity", "diesel").
|
|
39
|
+
limit: Maximum number of results (default: 10).
|
|
40
|
+
category: Optional category filter.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
List of matching Factor objects.
|
|
44
|
+
"""
|
|
45
|
+
params = {"q": query, "limit": str(limit)}
|
|
46
|
+
if category:
|
|
47
|
+
params["category"] = category
|
|
48
|
+
|
|
49
|
+
data = self._get("/search", params)
|
|
50
|
+
factors_data = data.get("factors", [])
|
|
51
|
+
if not isinstance(factors_data, list):
|
|
52
|
+
return []
|
|
53
|
+
return [_dict_to_factor(f) for f in factors_data]
|
|
54
|
+
|
|
55
|
+
def get_factor(self, factor_id: str) -> Optional[Factor]:
|
|
56
|
+
"""Get a factor by its ID.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
factor_id: The unique factor identifier.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Factor object or None if not found.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
data = self._get(f"/factor/{factor_id}")
|
|
66
|
+
if "error" in data:
|
|
67
|
+
return None
|
|
68
|
+
return _dict_to_factor(data)
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def stats(self) -> dict:
|
|
73
|
+
"""Get database statistics."""
|
|
74
|
+
return self._get("/stats")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _dict_to_factor(d: dict) -> Factor:
|
|
78
|
+
"""Convert API response dict to Factor object."""
|
|
79
|
+
return Factor(
|
|
80
|
+
id=d.get("id", ""),
|
|
81
|
+
name=d.get("name", ""),
|
|
82
|
+
category=d.get("category", ""),
|
|
83
|
+
value=d.get("value", 0.0),
|
|
84
|
+
unit=d.get("unit", ""),
|
|
85
|
+
geography=d.get("geography", {}),
|
|
86
|
+
applicability=d.get("applicability", ""),
|
|
87
|
+
source=d.get("source", ""),
|
|
88
|
+
source_year=d.get("source_year", 0),
|
|
89
|
+
quality_technical=d.get("quality_technical"),
|
|
90
|
+
quality_source=d.get("quality_source"),
|
|
91
|
+
quality_geographical=d.get("quality_geographical"),
|
|
92
|
+
quality_time=d.get("quality_time"),
|
|
93
|
+
quality_fairness=d.get("quality_fairness"),
|
|
94
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class LLMConfig:
|
|
6
|
+
"""Configuration for the LLM used in factor matching."""
|
|
7
|
+
|
|
8
|
+
api_key: str = ""
|
|
9
|
+
base_url: str = "https://api.deepseek.com"
|
|
10
|
+
model: str = "deepseek-chat"
|
|
11
|
+
max_tokens: int = 2048
|
|
12
|
+
embedding_model: str = "shibing624/text2vec-base-chinese"
|
|
13
|
+
license_key: str = ""
|
|
14
|
+
usage_db_path: str = "usage.db"
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from carbon_factor_matcher.models import Factor
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FactorDatabase:
|
|
11
|
+
"""Loads and queries emission factors from multiple data sources.
|
|
12
|
+
|
|
13
|
+
Supports loading from:
|
|
14
|
+
- JSON-LD ILCD files (ELCD format) via data_dir
|
|
15
|
+
- Custom adapters (ecoinvent Excel, etc.) via load_adapter()
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, data_dir: str = "data/factors"):
|
|
19
|
+
self._data_dir = Path(data_dir)
|
|
20
|
+
self._factors: list[Factor] = []
|
|
21
|
+
self._by_id: dict[str, Factor] = {}
|
|
22
|
+
self._load()
|
|
23
|
+
|
|
24
|
+
def load_adapter(self, adapter) -> None:
|
|
25
|
+
"""Load factors from a FactorAdapter.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
adapter: A FactorAdapter instance (e.g., EcoinventExcelAdapter).
|
|
29
|
+
"""
|
|
30
|
+
factors = adapter.load()
|
|
31
|
+
for f in factors:
|
|
32
|
+
if f.id in self._by_id:
|
|
33
|
+
logger.debug(f"Duplicate factor ID, skipping: {f.id}")
|
|
34
|
+
continue
|
|
35
|
+
self._factors.append(f)
|
|
36
|
+
self._by_id[f.id] = f
|
|
37
|
+
logger.info(f"Adapter loaded {len(factors)} factors, total now: {len(self._factors)}")
|
|
38
|
+
|
|
39
|
+
def _load(self) -> None:
|
|
40
|
+
"""Load all JSON-LD ILCD files from the data directory."""
|
|
41
|
+
for json_file in self._data_dir.glob("**/*.json"):
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(json_file.read_text(encoding="utf-8"))
|
|
44
|
+
if isinstance(data, list):
|
|
45
|
+
for item in data:
|
|
46
|
+
self._try_add_process(item)
|
|
47
|
+
elif isinstance(data, dict):
|
|
48
|
+
self._try_add_process(data)
|
|
49
|
+
except (json.JSONDecodeError, KeyError):
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
def _try_add_process(self, data: dict) -> None:
|
|
53
|
+
"""Try to parse a JSON-LD process into a Factor."""
|
|
54
|
+
if data.get("@type") != "Process":
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
factor = self._parse_process(data)
|
|
58
|
+
if factor:
|
|
59
|
+
self._factors.append(factor)
|
|
60
|
+
self._by_id[factor.id] = factor
|
|
61
|
+
|
|
62
|
+
def _parse_process(self, data: dict) -> Factor | None:
|
|
63
|
+
"""Parse a JSON-LD ILCD Process into a Factor."""
|
|
64
|
+
process_id = data.get("@id", "")
|
|
65
|
+
name = data.get("name", "")
|
|
66
|
+
if not process_id or not name:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
# Extract geography - handle both formats
|
|
70
|
+
geography = {}
|
|
71
|
+
# Format 1: location at top level (ELCD 3.x)
|
|
72
|
+
if "location" in data and isinstance(data["location"], dict):
|
|
73
|
+
loc_name = data["location"].get("name", "")
|
|
74
|
+
if loc_name:
|
|
75
|
+
geography["location"] = loc_name
|
|
76
|
+
# Format 2: geography field
|
|
77
|
+
geo_data = data.get("geography", {})
|
|
78
|
+
if isinstance(geo_data, dict):
|
|
79
|
+
location = geo_data.get("location", "")
|
|
80
|
+
description = geo_data.get("description", "")
|
|
81
|
+
if location:
|
|
82
|
+
geography["location"] = location
|
|
83
|
+
if description:
|
|
84
|
+
geography["description"] = description
|
|
85
|
+
|
|
86
|
+
# Extract time - handle both formats
|
|
87
|
+
source_year = 0
|
|
88
|
+
# Format 1: processDocumentation.validFrom (ELCD 3.x)
|
|
89
|
+
doc = data.get("processDocumentation", {})
|
|
90
|
+
if isinstance(doc, dict):
|
|
91
|
+
valid_from = doc.get("validFrom", "")
|
|
92
|
+
if valid_from and len(valid_from) >= 4:
|
|
93
|
+
try:
|
|
94
|
+
source_year = int(valid_from[:4])
|
|
95
|
+
except ValueError:
|
|
96
|
+
pass
|
|
97
|
+
# Fallback to timeDescription
|
|
98
|
+
if source_year == 0:
|
|
99
|
+
time_desc = doc.get("timeDescription", "")
|
|
100
|
+
if time_desc and len(time_desc) >= 4:
|
|
101
|
+
try:
|
|
102
|
+
source_year = int(time_desc[:4])
|
|
103
|
+
except ValueError:
|
|
104
|
+
pass
|
|
105
|
+
# Format 2: time.startDate
|
|
106
|
+
time_data = data.get("time", {})
|
|
107
|
+
if isinstance(time_data, dict):
|
|
108
|
+
start = time_data.get("startDate", "")
|
|
109
|
+
if start and len(start) >= 4:
|
|
110
|
+
try:
|
|
111
|
+
source_year = int(start[:4])
|
|
112
|
+
except ValueError:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
# Extract category
|
|
116
|
+
category = data.get("category", "")
|
|
117
|
+
|
|
118
|
+
# Extract the reference exchange (main output/emission)
|
|
119
|
+
exchanges = data.get("exchanges", [])
|
|
120
|
+
value, unit = self._extract_reference_value(exchanges)
|
|
121
|
+
|
|
122
|
+
if value == 0:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
# Determine source from metadata
|
|
126
|
+
source = self._extract_source(data)
|
|
127
|
+
|
|
128
|
+
# Build applicability description
|
|
129
|
+
applicability = self._build_applicability(data)
|
|
130
|
+
|
|
131
|
+
return Factor(
|
|
132
|
+
id=process_id,
|
|
133
|
+
name=name,
|
|
134
|
+
category=category,
|
|
135
|
+
value=value,
|
|
136
|
+
unit=unit,
|
|
137
|
+
geography=geography,
|
|
138
|
+
applicability=applicability,
|
|
139
|
+
source=source,
|
|
140
|
+
source_year=source_year,
|
|
141
|
+
raw_data=data,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _extract_reference_value(self, exchanges: list[dict]) -> tuple[float, str]:
|
|
145
|
+
"""Extract the reference exchange value and unit."""
|
|
146
|
+
for ex in exchanges:
|
|
147
|
+
# Skip input exchanges
|
|
148
|
+
is_input = ex.get("isInput", ex.get("input", True))
|
|
149
|
+
if is_input:
|
|
150
|
+
continue
|
|
151
|
+
# Try different amount field names
|
|
152
|
+
result = ex.get("amount", ex.get("resultingAmount", 0))
|
|
153
|
+
if result > 0:
|
|
154
|
+
unit_ref = ex.get("unit", {})
|
|
155
|
+
unit = unit_ref.get("name", "") if isinstance(unit_ref, dict) else ""
|
|
156
|
+
return float(result), unit
|
|
157
|
+
return 0.0, ""
|
|
158
|
+
|
|
159
|
+
def _extract_source(self, data: dict) -> str:
|
|
160
|
+
"""Extract data source information."""
|
|
161
|
+
# Check processDocumentation first (ELCD 3.x format)
|
|
162
|
+
doc = data.get("processDocumentation", {})
|
|
163
|
+
if isinstance(doc, dict):
|
|
164
|
+
for key in ("dataGenerator", "publication", "dataSetOwner"):
|
|
165
|
+
if key in doc:
|
|
166
|
+
ref = doc[key]
|
|
167
|
+
if isinstance(ref, dict) and "name" in ref:
|
|
168
|
+
return ref["name"]
|
|
169
|
+
if isinstance(ref, str):
|
|
170
|
+
return ref
|
|
171
|
+
# Fallback to top-level fields
|
|
172
|
+
for key in ("dataGenerator", "publication", "commissioner"):
|
|
173
|
+
if key in data:
|
|
174
|
+
ref = data[key]
|
|
175
|
+
if isinstance(ref, dict) and "name" in ref:
|
|
176
|
+
return ref["name"]
|
|
177
|
+
if isinstance(ref, str):
|
|
178
|
+
return ref
|
|
179
|
+
return "Unknown"
|
|
180
|
+
|
|
181
|
+
def _build_applicability(self, data: dict) -> str:
|
|
182
|
+
"""Build an applicability description from process metadata."""
|
|
183
|
+
parts = []
|
|
184
|
+
# Check processDocumentation first (ELCD 3.x format)
|
|
185
|
+
doc = data.get("processDocumentation", {})
|
|
186
|
+
if isinstance(doc, dict):
|
|
187
|
+
tech_desc = doc.get("technologyDescription", "")
|
|
188
|
+
if tech_desc:
|
|
189
|
+
parts.append(tech_desc)
|
|
190
|
+
geo_desc = doc.get("geographyDescription", "")
|
|
191
|
+
if geo_desc:
|
|
192
|
+
parts.append(geo_desc)
|
|
193
|
+
# Fallback to top-level fields
|
|
194
|
+
if "technology" in data:
|
|
195
|
+
tech = data["technology"]
|
|
196
|
+
if isinstance(tech, dict) and "description" in tech:
|
|
197
|
+
parts.append(tech["description"])
|
|
198
|
+
if "geography" in data:
|
|
199
|
+
geo = data["geography"]
|
|
200
|
+
if isinstance(geo, dict) and "description" in geo:
|
|
201
|
+
parts.append(geo["description"])
|
|
202
|
+
return "; ".join(parts) if parts else data.get("name", "")
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def factors(self) -> list[Factor]:
|
|
206
|
+
return list(self._factors)
|
|
207
|
+
|
|
208
|
+
def search(self, query: str) -> list[Factor]:
|
|
209
|
+
"""Simple keyword search across factor name, category, applicability."""
|
|
210
|
+
query_lower = query.lower()
|
|
211
|
+
return [
|
|
212
|
+
f for f in self._factors
|
|
213
|
+
if query_lower in f.name.lower()
|
|
214
|
+
or query_lower in f.category.lower()
|
|
215
|
+
or query_lower in f.applicability.lower()
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
def get_by_id(self, factor_id: str) -> Factor | None:
|
|
219
|
+
"""Get a factor by its ID."""
|
|
220
|
+
return self._by_id.get(factor_id)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""License management for Carbon Factor Matcher MCP server."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LicenseTier(Enum):
|
|
8
|
+
"""License tier levels."""
|
|
9
|
+
|
|
10
|
+
FREE = "free"
|
|
11
|
+
PRO = "pro"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Daily query limits per tier (None = unlimited)
|
|
15
|
+
TIER_DAILY_LIMIT: dict[LicenseTier, Optional[int]] = {
|
|
16
|
+
LicenseTier.FREE: 300,
|
|
17
|
+
LicenseTier.PRO: None,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
UPGRADE_HINT = (
|
|
21
|
+
"\n\n---\n"
|
|
22
|
+
"Daily free query limit reached (300/day). "
|
|
23
|
+
"Upgrade to Pro ($5 one-time) for unlimited queries + ecoinvent + hybrid matching.\n"
|
|
24
|
+
"Purchase: https://nikeandocean.github.io/carbon-factor-matcher"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LicenseManager:
|
|
29
|
+
"""Manage user license and feature access.
|
|
30
|
+
|
|
31
|
+
One-time purchase model (no expiration):
|
|
32
|
+
- free: ELCD only, basic search, 300 queries/day
|
|
33
|
+
- pro: ELCD + ecoinvent, hybrid matching, unlimited queries
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
license_key: License key string. Empty = free tier (no key needed).
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If key format is invalid (looks like a key but isn't PRO-*).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, license_key: str = ""):
|
|
43
|
+
self.key = license_key
|
|
44
|
+
self.tier = self._parse_tier(license_key)
|
|
45
|
+
|
|
46
|
+
def _parse_tier(self, key: str) -> LicenseTier:
|
|
47
|
+
"""Parse tier from license key prefix.
|
|
48
|
+
|
|
49
|
+
Key format: {TIER}-{RANDOM}-{TIMESTAMP}
|
|
50
|
+
Examples:
|
|
51
|
+
- PRO-KEY-123 -> PRO
|
|
52
|
+
- "" -> FREE
|
|
53
|
+
- (no key) -> FREE
|
|
54
|
+
|
|
55
|
+
Raises ValueError for malformed keys (e.g., "XXX-abc", "garbage-123").
|
|
56
|
+
"""
|
|
57
|
+
if not key:
|
|
58
|
+
return LicenseTier.FREE
|
|
59
|
+
if "-" not in key:
|
|
60
|
+
return LicenseTier.FREE
|
|
61
|
+
prefix = key.split("-")[0].upper()
|
|
62
|
+
if prefix == "PRO":
|
|
63
|
+
return LicenseTier.PRO
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"Invalid license key prefix '{prefix}'. "
|
|
66
|
+
f"Expected 'PRO-*'. Get a key at https://nikeandocean.github.io/carbon-factor-matcher"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def daily_limit(self) -> Optional[int]:
|
|
71
|
+
"""Return max daily queries. None = unlimited."""
|
|
72
|
+
return TIER_DAILY_LIMIT[self.tier]
|
|
73
|
+
|
|
74
|
+
def can_use_ecoinvent(self) -> bool:
|
|
75
|
+
"""Check if current tier can use ecoinvent data."""
|
|
76
|
+
return self.tier == LicenseTier.PRO
|
|
77
|
+
|
|
78
|
+
def can_use_advanced_matching(self) -> bool:
|
|
79
|
+
"""Check if current tier can use hybrid matching + quality rating."""
|
|
80
|
+
return self.tier == LicenseTier.PRO
|