@nikeandocean/carbon-factor-matcher 0.2.0 → 0.2.1
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 +236 -252
- package/index.js +20 -1
- package/package.json +1 -2
- package/src/carbon_factor_matcher/__init__.py +0 -3
- package/src/carbon_factor_matcher/__main__.py +0 -5
- package/src/carbon_factor_matcher/adapters/__init__.py +0 -6
- package/src/carbon_factor_matcher/adapters/base.py +0 -22
- package/src/carbon_factor_matcher/adapters/ecoinvent.py +0 -292
- package/src/carbon_factor_matcher/api_client.py +0 -94
- package/src/carbon_factor_matcher/config.py +0 -14
- package/src/carbon_factor_matcher/factor_db.py +0 -220
- package/src/carbon_factor_matcher/license.py +0 -80
- package/src/carbon_factor_matcher/matcher.py +0 -795
- package/src/carbon_factor_matcher/models.py +0 -71
- package/src/carbon_factor_matcher/server.py +0 -232
- package/src/carbon_factor_matcher/usage_tracker.py +0 -68
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
from dataclasses import dataclass, field
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
@dataclass
|
|
5
|
-
class Factor:
|
|
6
|
-
"""An emission factor from an LCA database."""
|
|
7
|
-
|
|
8
|
-
id: str
|
|
9
|
-
name: str
|
|
10
|
-
category: str
|
|
11
|
-
value: float
|
|
12
|
-
unit: str
|
|
13
|
-
geography: dict[str, str]
|
|
14
|
-
applicability: str
|
|
15
|
-
source: str
|
|
16
|
-
source_year: int
|
|
17
|
-
scope: int | None = None
|
|
18
|
-
uncertainty: str | None = None
|
|
19
|
-
notes: str | None = None
|
|
20
|
-
quality_technical: float | None = None
|
|
21
|
-
quality_source: float | None = None
|
|
22
|
-
quality_geographical: float | None = None
|
|
23
|
-
quality_time: float | None = None
|
|
24
|
-
quality_fairness: float | None = None
|
|
25
|
-
raw_data: dict | None = field(default=None, repr=False)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@dataclass
|
|
29
|
-
class Activity:
|
|
30
|
-
"""User activity data for emission factor matching.
|
|
31
|
-
|
|
32
|
-
Example:
|
|
33
|
-
Activity(
|
|
34
|
-
name="工业用电",
|
|
35
|
-
location="上海市",
|
|
36
|
-
time="2024",
|
|
37
|
-
unit="kWh",
|
|
38
|
-
amount=1000,
|
|
39
|
-
technology="火力发电",
|
|
40
|
-
)
|
|
41
|
-
"""
|
|
42
|
-
|
|
43
|
-
name: str # 活动名称 (e.g., "工业用电")
|
|
44
|
-
unit: str # 单位 (e.g., "kWh")
|
|
45
|
-
location: str = "" # 地点 (e.g., "上海市")
|
|
46
|
-
time: str = "" # 时间 (e.g., "2024")
|
|
47
|
-
amount: float = 0.0 # 数量
|
|
48
|
-
technology: str = "" # 工艺/技术 (e.g., "火力发电")
|
|
49
|
-
category: str = "" # 行业分类 (e.g., "制造业")
|
|
50
|
-
|
|
51
|
-
def to_query(self) -> str:
|
|
52
|
-
"""Build search query string from activity fields."""
|
|
53
|
-
parts = []
|
|
54
|
-
if self.location:
|
|
55
|
-
parts.append(self.location)
|
|
56
|
-
if self.technology:
|
|
57
|
-
parts.append(self.technology)
|
|
58
|
-
parts.append(self.name)
|
|
59
|
-
if self.category:
|
|
60
|
-
parts.append(self.category)
|
|
61
|
-
return " ".join(parts)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
@dataclass
|
|
65
|
-
class MatchResult:
|
|
66
|
-
"""Result of a factor matching operation."""
|
|
67
|
-
|
|
68
|
-
factor: Factor
|
|
69
|
-
reason: str
|
|
70
|
-
confidence: float
|
|
71
|
-
candidates: list[Factor]
|
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
"""Carbon Factor Matcher MCP server.
|
|
2
|
-
|
|
3
|
-
Uses remote API for factor data, local LLM for Pro matching.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import os
|
|
7
|
-
import json
|
|
8
|
-
from mcp.server.fastmcp import FastMCP
|
|
9
|
-
|
|
10
|
-
from carbon_factor_matcher.api_client import FactorAPIClient
|
|
11
|
-
from carbon_factor_matcher.matcher import EmissionFactorMatcher
|
|
12
|
-
from carbon_factor_matcher.config import LLMConfig
|
|
13
|
-
from carbon_factor_matcher.license import LicenseManager
|
|
14
|
-
from carbon_factor_matcher.usage_tracker import UsageTracker
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def create_server(
|
|
18
|
-
api_url: str | None = None,
|
|
19
|
-
api_client: FactorAPIClient | None = None,
|
|
20
|
-
llm_api_key: str | None = None,
|
|
21
|
-
llm_base_url: str | None = None,
|
|
22
|
-
llm_model: str | None = None,
|
|
23
|
-
embedding_model: str | None = None,
|
|
24
|
-
license_key: str | None = None,
|
|
25
|
-
usage_db_path: str | None = None,
|
|
26
|
-
) -> FastMCP:
|
|
27
|
-
"""Create and configure the MCP server."""
|
|
28
|
-
|
|
29
|
-
# Resolve config from env vars or defaults
|
|
30
|
-
_default_db = os.path.join(os.path.expanduser("~"), ".carbon-factor-matcher", "usage.db")
|
|
31
|
-
config = LLMConfig(
|
|
32
|
-
api_key=llm_api_key or os.environ.get("LLM_API_KEY", ""),
|
|
33
|
-
base_url=llm_base_url or os.environ.get("LLM_BASE_URL", "https://api.deepseek.com"),
|
|
34
|
-
model=llm_model or os.environ.get("LLM_MODEL", "deepseek-chat"),
|
|
35
|
-
embedding_model=embedding_model or os.environ.get(
|
|
36
|
-
"EMBEDDING_MODEL", "shibing624/text2vec-base-chinese"
|
|
37
|
-
),
|
|
38
|
-
license_key=license_key or os.environ.get("CARBON_FACTOR_LICENSE_KEY", ""),
|
|
39
|
-
usage_db_path=usage_db_path or os.environ.get(
|
|
40
|
-
"CARBON_FACTOR_USAGE_DB", _default_db
|
|
41
|
-
),
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
# Initialize components
|
|
45
|
-
if api_client is None:
|
|
46
|
-
api_url = api_url or os.environ.get(
|
|
47
|
-
"CARBON_FACTOR_API_URL",
|
|
48
|
-
"https://paddle-webhook.nikeandocean.workers.dev/api",
|
|
49
|
-
)
|
|
50
|
-
api_client = FactorAPIClient(base_url=api_url)
|
|
51
|
-
license_mgr = LicenseManager(license_key=config.license_key)
|
|
52
|
-
usage_tracker = UsageTracker(db_path=config.usage_db_path)
|
|
53
|
-
user_id = _hash_key(config.license_key)
|
|
54
|
-
|
|
55
|
-
# Create MCP server
|
|
56
|
-
mcp = FastMCP("carbon-factor-matcher")
|
|
57
|
-
|
|
58
|
-
def _check_limit(tool_name: str) -> str | None:
|
|
59
|
-
"""Check daily query limit. Returns error if exceeded, None if OK."""
|
|
60
|
-
limit = license_mgr.daily_limit
|
|
61
|
-
if limit is not None:
|
|
62
|
-
daily = usage_tracker.get_daily_usage(user_id)
|
|
63
|
-
if daily >= limit:
|
|
64
|
-
return json.dumps({
|
|
65
|
-
"error": f"Daily free limit reached ({limit}/day). Upgrade to Pro for unlimited.",
|
|
66
|
-
"purchase": "https://nikeandocean.github.io/carbon-factor-matcher",
|
|
67
|
-
}, ensure_ascii=False)
|
|
68
|
-
usage_tracker.track_call(user_id, tool_name)
|
|
69
|
-
return None
|
|
70
|
-
|
|
71
|
-
def _with_hint(data: dict) -> str:
|
|
72
|
-
"""Serialize result and append upgrade hint for free tier."""
|
|
73
|
-
output = json.dumps(data, ensure_ascii=False, indent=2)
|
|
74
|
-
if license_mgr.daily_limit is not None:
|
|
75
|
-
daily = usage_tracker.get_daily_usage(user_id)
|
|
76
|
-
limit = license_mgr.daily_limit
|
|
77
|
-
remaining = max(0, limit - daily)
|
|
78
|
-
if remaining <= 20:
|
|
79
|
-
output += (
|
|
80
|
-
f"\n\n---\n{remaining} free queries remaining today. "
|
|
81
|
-
f"Upgrade to Pro ($5) for unlimited: "
|
|
82
|
-
f"https://nikeandocean.github.io/carbon-factor-matcher"
|
|
83
|
-
)
|
|
84
|
-
return output
|
|
85
|
-
|
|
86
|
-
@mcp.tool()
|
|
87
|
-
def factor_match(activity_data: str, top_k: int = 10) -> str:
|
|
88
|
-
"""Match activity data to the best emission factor using semantic search.
|
|
89
|
-
|
|
90
|
-
Uses two-stage matching:
|
|
91
|
-
1. Embedding-based rough filtering to find candidate factors
|
|
92
|
-
2. LLM-based fine-ranking to select the best match with reasoning
|
|
93
|
-
|
|
94
|
-
Args:
|
|
95
|
-
activity_data: Description of the activity (e.g., "Factory in Shenzhen,
|
|
96
|
-
10kV industrial electricity, 2024, semiconductor fab")
|
|
97
|
-
top_k: Number of candidates to consider in rough filter (default: 10)
|
|
98
|
-
|
|
99
|
-
Returns:
|
|
100
|
-
JSON with best factor, match reason, confidence score, and alternatives
|
|
101
|
-
"""
|
|
102
|
-
limit_err = _check_limit("factor_match")
|
|
103
|
-
if limit_err:
|
|
104
|
-
return limit_err
|
|
105
|
-
|
|
106
|
-
# Search via API
|
|
107
|
-
factors = api_client.search(activity_data, limit=20)
|
|
108
|
-
|
|
109
|
-
if not factors:
|
|
110
|
-
return json.dumps({"error": "No matching factors found"}, ensure_ascii=False)
|
|
111
|
-
|
|
112
|
-
if not license_mgr.can_use_advanced_matching():
|
|
113
|
-
# Free tier: return keyword search results
|
|
114
|
-
return _with_hint({
|
|
115
|
-
"factors": [
|
|
116
|
-
{"id": f.id, "name": f.name, "value": f.value, "unit": f.unit}
|
|
117
|
-
for f in factors[:10]
|
|
118
|
-
],
|
|
119
|
-
"note": "Basic keyword search. Pro unlocks hybrid matching + quality rating.",
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
# Pro: use LLM matcher on API results
|
|
123
|
-
matcher = EmissionFactorMatcher(factors=factors, config=config, skip_embedding=False)
|
|
124
|
-
result = matcher.match(activity_data, top_k=top_k)
|
|
125
|
-
|
|
126
|
-
if result is None:
|
|
127
|
-
return json.dumps({"error": "No matching factors found"}, ensure_ascii=False)
|
|
128
|
-
|
|
129
|
-
return _with_hint({
|
|
130
|
-
"selected_factor": {
|
|
131
|
-
"id": result.factor.id,
|
|
132
|
-
"name": result.factor.name,
|
|
133
|
-
"value": result.factor.value,
|
|
134
|
-
"unit": result.factor.unit,
|
|
135
|
-
"category": result.factor.category,
|
|
136
|
-
"geography": result.factor.geography,
|
|
137
|
-
"applicability": result.factor.applicability,
|
|
138
|
-
"source": result.factor.source,
|
|
139
|
-
"source_year": result.factor.source_year,
|
|
140
|
-
},
|
|
141
|
-
"confidence": result.confidence,
|
|
142
|
-
"reason": result.reason,
|
|
143
|
-
"alternatives": [
|
|
144
|
-
{"id": f.id, "name": f.name, "value": f.value, "unit": f.unit}
|
|
145
|
-
for f in result.candidates[1:4]
|
|
146
|
-
],
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
@mcp.tool()
|
|
150
|
-
def factor_search(query: str, category: str | None = None, limit: int = 10) -> str:
|
|
151
|
-
"""Search emission factors by keyword.
|
|
152
|
-
|
|
153
|
-
Args:
|
|
154
|
-
query: Search keyword (e.g., "electricity", "diesel", "transport")
|
|
155
|
-
category: Optional category filter (e.g., "electricity", "fuel")
|
|
156
|
-
limit: Maximum number of results (default: 10)
|
|
157
|
-
|
|
158
|
-
Returns:
|
|
159
|
-
JSON list of matching factors
|
|
160
|
-
"""
|
|
161
|
-
limit_err = _check_limit("factor_search")
|
|
162
|
-
if limit_err:
|
|
163
|
-
return limit_err
|
|
164
|
-
|
|
165
|
-
factors = api_client.search(query, limit=limit, category=category)
|
|
166
|
-
|
|
167
|
-
return _with_hint({
|
|
168
|
-
"count": len(factors),
|
|
169
|
-
"factors": [
|
|
170
|
-
{
|
|
171
|
-
"id": f.id,
|
|
172
|
-
"name": f.name,
|
|
173
|
-
"value": f.value,
|
|
174
|
-
"unit": f.unit,
|
|
175
|
-
"category": f.category,
|
|
176
|
-
"geography": f.geography,
|
|
177
|
-
"source": f.source,
|
|
178
|
-
"source_year": f.source_year,
|
|
179
|
-
}
|
|
180
|
-
for f in factors
|
|
181
|
-
],
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
@mcp.tool()
|
|
185
|
-
def factor_detail(factor_id: str) -> str:
|
|
186
|
-
"""Get full metadata for a specific emission factor.
|
|
187
|
-
|
|
188
|
-
Args:
|
|
189
|
-
factor_id: The unique identifier of the factor
|
|
190
|
-
|
|
191
|
-
Returns:
|
|
192
|
-
JSON with complete factor metadata
|
|
193
|
-
"""
|
|
194
|
-
limit_err = _check_limit("factor_detail")
|
|
195
|
-
if limit_err:
|
|
196
|
-
return limit_err
|
|
197
|
-
|
|
198
|
-
factor = api_client.get_factor(factor_id)
|
|
199
|
-
if factor is None:
|
|
200
|
-
return json.dumps({"error": f"Factor not found: {factor_id}"}, ensure_ascii=False)
|
|
201
|
-
|
|
202
|
-
return _with_hint({
|
|
203
|
-
"id": factor.id,
|
|
204
|
-
"name": factor.name,
|
|
205
|
-
"category": factor.category,
|
|
206
|
-
"value": factor.value,
|
|
207
|
-
"unit": factor.unit,
|
|
208
|
-
"geography": factor.geography,
|
|
209
|
-
"applicability": factor.applicability,
|
|
210
|
-
"source": factor.source,
|
|
211
|
-
"source_year": factor.source_year,
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
return mcp
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def _hash_key(key: str) -> str:
|
|
218
|
-
"""Create a short hash of license key for user tracking."""
|
|
219
|
-
import hashlib
|
|
220
|
-
if not key:
|
|
221
|
-
return "anonymous"
|
|
222
|
-
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def main():
|
|
226
|
-
"""Entry point for the MCP server."""
|
|
227
|
-
mcp = create_server()
|
|
228
|
-
mcp.run()
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if __name__ == "__main__":
|
|
232
|
-
main()
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
"""Usage tracking for API calls."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import sqlite3
|
|
5
|
-
from datetime import date
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class UsageTracker:
|
|
9
|
-
"""Track user API call counts.
|
|
10
|
-
|
|
11
|
-
Stores usage data in a local SQLite database.
|
|
12
|
-
Each call is recorded with user_id, tool name, and date.
|
|
13
|
-
|
|
14
|
-
Args:
|
|
15
|
-
db_path: Path to SQLite database file. Use ":memory:" for testing.
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
def __init__(self, db_path: str = "usage.db"):
|
|
19
|
-
self.db_path = db_path
|
|
20
|
-
if db_path != ":memory:":
|
|
21
|
-
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
|
|
22
|
-
self._conn = sqlite3.connect(db_path)
|
|
23
|
-
self._init_db()
|
|
24
|
-
|
|
25
|
-
def _init_db(self):
|
|
26
|
-
"""Initialize database tables."""
|
|
27
|
-
self._conn.execute("""
|
|
28
|
-
CREATE TABLE IF NOT EXISTS usage (
|
|
29
|
-
user_id TEXT NOT NULL,
|
|
30
|
-
tool TEXT NOT NULL,
|
|
31
|
-
date TEXT NOT NULL,
|
|
32
|
-
count INTEGER DEFAULT 0,
|
|
33
|
-
PRIMARY KEY (user_id, tool, date)
|
|
34
|
-
)
|
|
35
|
-
""")
|
|
36
|
-
self._conn.commit()
|
|
37
|
-
|
|
38
|
-
def track_call(self, user_id: str, tool: str):
|
|
39
|
-
"""Record one API call.
|
|
40
|
-
|
|
41
|
-
Args:
|
|
42
|
-
user_id: User identifier (e.g., license key hash).
|
|
43
|
-
tool: Tool name (e.g., "factor_match").
|
|
44
|
-
"""
|
|
45
|
-
today = date.today().isoformat()
|
|
46
|
-
self._conn.execute("""
|
|
47
|
-
INSERT INTO usage (user_id, tool, date, count)
|
|
48
|
-
VALUES (?, ?, ?, 1)
|
|
49
|
-
ON CONFLICT(user_id, tool, date)
|
|
50
|
-
DO UPDATE SET count = count + 1
|
|
51
|
-
""", (user_id, tool, today))
|
|
52
|
-
self._conn.commit()
|
|
53
|
-
|
|
54
|
-
def get_daily_usage(self, user_id: str) -> int:
|
|
55
|
-
"""Get total call count for user today.
|
|
56
|
-
|
|
57
|
-
Args:
|
|
58
|
-
user_id: User identifier.
|
|
59
|
-
|
|
60
|
-
Returns:
|
|
61
|
-
Total calls today across all tools.
|
|
62
|
-
"""
|
|
63
|
-
today = date.today().isoformat()
|
|
64
|
-
result = self._conn.execute(
|
|
65
|
-
"SELECT COALESCE(SUM(count), 0) FROM usage WHERE user_id = ? AND date = ?",
|
|
66
|
-
(user_id, today),
|
|
67
|
-
).fetchone()
|
|
68
|
-
return result[0]
|