@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,71 @@
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]
@@ -0,0 +1,232 @@
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()
@@ -0,0 +1,68 @@
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]