@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.
@@ -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