product-spec-mcp 0.3.21 → 0.3.26
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 +14 -0
- package/dist/index.cjs +1325 -29
- package/docs/online-pm-gate.md +58 -0
- package/package.json +6 -2
- package/workers/pm-intent-gate.mjs +383 -0
- package/workers/schema.sql +24 -0
- package/workers/wrangler.toml.example +19 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Online PM Gate
|
|
2
|
+
|
|
3
|
+
P0 online gate is an HTTP classifier for low-confidence or conflicting local PM Gate decisions. It does not generate long specs. It only helps choose the gate and returns short JSON for the local MCP package to validate and merge.
|
|
4
|
+
|
|
5
|
+
## Local MCP Environment
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
PRODUCT_SPEC_REMOTE_GATE_URL=https://gate.example.com/v1/pm-intent
|
|
9
|
+
PRODUCT_SPEC_REMOTE_GATE_TOKEN=replace-with-token
|
|
10
|
+
PRODUCT_SPEC_REMOTE_GATE_TIMEOUT_MS=2500
|
|
11
|
+
PRODUCT_SPEC_REMOTE_GATE_MODE=auto
|
|
12
|
+
PRODUCT_SPEC_TELEMETRY=off
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Modes:
|
|
16
|
+
|
|
17
|
+
- `auto`: only call remote when local gate is low confidence, unknown, or internally conflicting.
|
|
18
|
+
- `off`: never call remote.
|
|
19
|
+
- `force`: call remote for debugging.
|
|
20
|
+
|
|
21
|
+
Telemetry:
|
|
22
|
+
|
|
23
|
+
- `off`: do not store prompt samples.
|
|
24
|
+
- `minimal`: store hashes and decisions only.
|
|
25
|
+
- `sample`: store redacted prompt samples.
|
|
26
|
+
|
|
27
|
+
## Cloudflare Worker
|
|
28
|
+
|
|
29
|
+
Files:
|
|
30
|
+
|
|
31
|
+
- `workers/pm-intent-gate.mjs`
|
|
32
|
+
- `workers/schema.sql`
|
|
33
|
+
- `workers/wrangler.toml.example`
|
|
34
|
+
|
|
35
|
+
Setup outline:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
cd workers
|
|
39
|
+
cp wrangler.toml.example wrangler.toml
|
|
40
|
+
wrangler kv namespace create PROMPT_CACHE
|
|
41
|
+
wrangler d1 create product-spec-prompt-samples
|
|
42
|
+
wrangler d1 execute product-spec-prompt-samples --file schema.sql
|
|
43
|
+
wrangler secret put GATE_TOKEN
|
|
44
|
+
wrangler secret put DEEPSEEK_API_KEY
|
|
45
|
+
wrangler secret put RATE_LIMIT_SALT
|
|
46
|
+
wrangler deploy
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Runtime behavior:
|
|
50
|
+
|
|
51
|
+
- Prompt cache key: `cache:{model}:{promptHash}:pm-gate-v1`
|
|
52
|
+
- Cache TTL: 7 days
|
|
53
|
+
- LLM quota: 3 non-cached LLM decisions per IP per Shanghai calendar day
|
|
54
|
+
- User message sent to LLM: max 500 characters
|
|
55
|
+
- LLM max output tokens: 600
|
|
56
|
+
- LLM temperature: 0.1
|
|
57
|
+
|
|
58
|
+
If the Worker is unreachable, rate-limited, returns invalid JSON, or returns invalid enum fields, the local MCP falls back to the local PM Gate decision.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "product-spec-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.26",
|
|
4
4
|
"description": "MCP Server for product specification - requirement interrogation, architecture decision, UI translation, debug guidance, and acceptance generation",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist/index.cjs",
|
|
12
|
-
"README.md"
|
|
12
|
+
"README.md",
|
|
13
|
+
"docs/online-pm-gate.md",
|
|
14
|
+
"workers/pm-intent-gate.mjs",
|
|
15
|
+
"workers/schema.sql",
|
|
16
|
+
"workers/wrangler.toml.example"
|
|
13
17
|
],
|
|
14
18
|
"engines": {
|
|
15
19
|
"node": ">=18"
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
const GATE_SCHEMA_VERSION = "pm-gate-v1";
|
|
2
|
+
const DEFAULT_MODEL = "deepseek-chat";
|
|
3
|
+
const DAILY_LIMIT = 3;
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
async fetch(request, env) {
|
|
7
|
+
const url = new URL(request.url);
|
|
8
|
+
if (request.method === "GET" && url.pathname === "/health") {
|
|
9
|
+
return json({ ok: true, gateSchemaVersion: GATE_SCHEMA_VERSION });
|
|
10
|
+
}
|
|
11
|
+
if (request.method !== "POST" || url.pathname !== "/v1/pm-intent") {
|
|
12
|
+
return json({ error: "not_found" }, 404);
|
|
13
|
+
}
|
|
14
|
+
if (!isAuthorized(request, env)) {
|
|
15
|
+
return json({ error: "unauthorized" }, 401);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let body;
|
|
19
|
+
try {
|
|
20
|
+
body = await request.json();
|
|
21
|
+
} catch {
|
|
22
|
+
return json({ error: "invalid_json" }, 400);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const telemetryMode = normalizeTelemetry(request.headers.get("x-product-spec-telemetry") || "off");
|
|
26
|
+
const message = String(body.message || "").slice(0, 500);
|
|
27
|
+
const messageHash = body.messageHash || await sha256(normalizeText(message));
|
|
28
|
+
const model = env.DEEPSEEK_MODEL || DEFAULT_MODEL;
|
|
29
|
+
const cacheKey = `cache:${model}:${messageHash}:${GATE_SCHEMA_VERSION}`;
|
|
30
|
+
const cached = await env.PROMPT_CACHE?.get(cacheKey, "json");
|
|
31
|
+
const ipKey = await rateLimitKey(request, env);
|
|
32
|
+
const resetAt = nextShanghaiMidnightIso();
|
|
33
|
+
|
|
34
|
+
if (cached?.decision) {
|
|
35
|
+
await maybeStoreSample(env, telemetryMode, body, cached.decision, cached.decision, {
|
|
36
|
+
llmUsed: 0,
|
|
37
|
+
cacheHit: 1,
|
|
38
|
+
rateLimitStatus: "cache_hit",
|
|
39
|
+
});
|
|
40
|
+
return json({
|
|
41
|
+
decision: cached.decision,
|
|
42
|
+
llmGate: {
|
|
43
|
+
used: false,
|
|
44
|
+
provider: "deepseek",
|
|
45
|
+
model,
|
|
46
|
+
promptTokensApprox: cached.promptTokensApprox || 0,
|
|
47
|
+
completionTokensApprox: cached.completionTokensApprox || 0,
|
|
48
|
+
cacheHit: true,
|
|
49
|
+
},
|
|
50
|
+
rateLimit: {
|
|
51
|
+
limit: DAILY_LIMIT,
|
|
52
|
+
remaining: await remainingForKey(env, ipKey),
|
|
53
|
+
resetAt,
|
|
54
|
+
},
|
|
55
|
+
privacy: privacyResult(telemetryMode),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const limit = await consumeLimit(env, ipKey, resetAt);
|
|
60
|
+
if (!limit.allowed) {
|
|
61
|
+
await maybeStoreSample(env, telemetryMode, body, null, body.ruleDecision || {}, {
|
|
62
|
+
llmUsed: 0,
|
|
63
|
+
cacheHit: 0,
|
|
64
|
+
rateLimitStatus: "limited",
|
|
65
|
+
fallbackReason: "rate_limited",
|
|
66
|
+
});
|
|
67
|
+
return json({
|
|
68
|
+
decision: fallbackDecision(body.ruleDecision),
|
|
69
|
+
llmGate: { used: false, provider: "deepseek", model, cacheHit: false },
|
|
70
|
+
rateLimit: { limit: DAILY_LIMIT, remaining: 0, resetAt },
|
|
71
|
+
privacy: privacyResult(telemetryMode),
|
|
72
|
+
}, 429);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const prompt = buildGatePrompt(message, body.ruleDecision || {}, body.choices || {});
|
|
76
|
+
const promptTokensApprox = approxTokens(prompt);
|
|
77
|
+
|
|
78
|
+
let llmDecision;
|
|
79
|
+
let completionTokensApprox = 0;
|
|
80
|
+
let fallbackReason = "";
|
|
81
|
+
try {
|
|
82
|
+
const llmText = await callDeepSeek(env, model, prompt);
|
|
83
|
+
completionTokensApprox = approxTokens(llmText);
|
|
84
|
+
llmDecision = sanitizeDecision(extractJson(llmText));
|
|
85
|
+
if (!llmDecision) fallbackReason = "invalid_llm_schema";
|
|
86
|
+
} catch (error) {
|
|
87
|
+
fallbackReason = error instanceof Error ? error.message : "llm_error";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const finalDecision = llmDecision || fallbackDecision(body.ruleDecision);
|
|
91
|
+
if (llmDecision && env.PROMPT_CACHE) {
|
|
92
|
+
await env.PROMPT_CACHE.put(cacheKey, JSON.stringify({
|
|
93
|
+
decision: finalDecision,
|
|
94
|
+
promptTokensApprox,
|
|
95
|
+
completionTokensApprox,
|
|
96
|
+
}), { expirationTtl: 7 * 24 * 60 * 60 });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await maybeStoreSample(env, telemetryMode, body, llmDecision, finalDecision, {
|
|
100
|
+
llmUsed: llmDecision ? 1 : 0,
|
|
101
|
+
cacheHit: 0,
|
|
102
|
+
promptTokensApprox,
|
|
103
|
+
completionTokensApprox,
|
|
104
|
+
rateLimitStatus: "allowed",
|
|
105
|
+
fallbackReason,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return json({
|
|
109
|
+
decision: finalDecision,
|
|
110
|
+
llmGate: {
|
|
111
|
+
used: Boolean(llmDecision),
|
|
112
|
+
provider: "deepseek",
|
|
113
|
+
model,
|
|
114
|
+
promptTokensApprox,
|
|
115
|
+
completionTokensApprox,
|
|
116
|
+
cacheHit: false,
|
|
117
|
+
...(fallbackReason ? { fallbackReason } : {}),
|
|
118
|
+
},
|
|
119
|
+
rateLimit: {
|
|
120
|
+
limit: DAILY_LIMIT,
|
|
121
|
+
remaining: limit.remaining,
|
|
122
|
+
resetAt,
|
|
123
|
+
},
|
|
124
|
+
privacy: privacyResult(telemetryMode),
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
function isAuthorized(request, env) {
|
|
130
|
+
if (!env.GATE_TOKEN) return false;
|
|
131
|
+
return request.headers.get("authorization") === `Bearer ${env.GATE_TOKEN}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildGatePrompt(message, rule, choices) {
|
|
135
|
+
return JSON.stringify({
|
|
136
|
+
task: "Choose the best PM gate only. Return strict JSON, no markdown.",
|
|
137
|
+
output: {
|
|
138
|
+
bestGate: "one needType enum",
|
|
139
|
+
usageScope: "one usageScope enum",
|
|
140
|
+
maintenanceMode: "one maintenanceMode enum",
|
|
141
|
+
accessTopology: "one accessTopology enum",
|
|
142
|
+
confidence: "high|medium|low",
|
|
143
|
+
strongSignals: ["short strings"],
|
|
144
|
+
weakSignals: ["short strings"],
|
|
145
|
+
coreObjects: ["short strings"],
|
|
146
|
+
states: ["short strings"],
|
|
147
|
+
actions: ["short strings"],
|
|
148
|
+
mustNotUse: ["short ids"],
|
|
149
|
+
boundaryQuestionIds: ["short ids"],
|
|
150
|
+
},
|
|
151
|
+
msg: message,
|
|
152
|
+
rule: {
|
|
153
|
+
strong: rule.strongSignals || [],
|
|
154
|
+
weak: rule.weakSignals || [],
|
|
155
|
+
shape: rule.technicalShape || rule.shape || "unknown",
|
|
156
|
+
conflict: Boolean(rule.conflict),
|
|
157
|
+
},
|
|
158
|
+
choices,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function callDeepSeek(env, model, prompt) {
|
|
163
|
+
if (!env.DEEPSEEK_API_KEY) throw new Error("missing_deepseek_api_key");
|
|
164
|
+
const response = await fetch("https://api.deepseek.com/chat/completions", {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: {
|
|
167
|
+
"content-type": "application/json",
|
|
168
|
+
authorization: `Bearer ${env.DEEPSEEK_API_KEY}`,
|
|
169
|
+
},
|
|
170
|
+
body: JSON.stringify({
|
|
171
|
+
model,
|
|
172
|
+
temperature: 0.1,
|
|
173
|
+
max_tokens: 600,
|
|
174
|
+
messages: [
|
|
175
|
+
{ role: "system", content: "You are a terse product intent classifier. Output JSON only." },
|
|
176
|
+
{ role: "user", content: prompt },
|
|
177
|
+
],
|
|
178
|
+
}),
|
|
179
|
+
});
|
|
180
|
+
if (!response.ok) throw new Error(`deepseek_http_${response.status}`);
|
|
181
|
+
const data = await response.json();
|
|
182
|
+
const content = data?.choices?.[0]?.message?.content;
|
|
183
|
+
if (typeof content !== "string" || !content.trim()) throw new Error("deepseek_empty_content");
|
|
184
|
+
return content;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function extractJson(text) {
|
|
188
|
+
try {
|
|
189
|
+
return JSON.parse(text);
|
|
190
|
+
} catch {
|
|
191
|
+
const start = text.indexOf("{");
|
|
192
|
+
const end = text.lastIndexOf("}");
|
|
193
|
+
if (start < 0 || end <= start) return null;
|
|
194
|
+
try {
|
|
195
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function sanitizeDecision(raw) {
|
|
203
|
+
if (!raw || typeof raw !== "object") return null;
|
|
204
|
+
const bestGate = raw.bestGate || raw.needType;
|
|
205
|
+
if (!needTypes.includes(bestGate)) return null;
|
|
206
|
+
const decision = { bestGate };
|
|
207
|
+
copyEnum(raw, decision, "usageScope", usageScopes);
|
|
208
|
+
copyEnum(raw, decision, "maintenanceMode", maintenanceModes);
|
|
209
|
+
copyEnum(raw, decision, "accessTopology", accessTopologies);
|
|
210
|
+
copyEnum(raw, decision, "confidence", confidences);
|
|
211
|
+
copyStringArray(raw, decision, "strongSignals");
|
|
212
|
+
copyStringArray(raw, decision, "weakSignals");
|
|
213
|
+
copyStringArray(raw, decision, "coreObjects");
|
|
214
|
+
copyStringArray(raw, decision, "states");
|
|
215
|
+
copyStringArray(raw, decision, "actions");
|
|
216
|
+
copyStringArray(raw, decision, "mustNotUse");
|
|
217
|
+
copyStringArray(raw, decision, "boundaryQuestionIds");
|
|
218
|
+
return decision;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function fallbackDecision(ruleDecision) {
|
|
222
|
+
return {
|
|
223
|
+
bestGate: ruleDecision?.needType || "unknown",
|
|
224
|
+
usageScope: ruleDecision?.usageScope || "unknown",
|
|
225
|
+
maintenanceMode: ruleDecision?.maintenanceMode || "unknown",
|
|
226
|
+
accessTopology: ruleDecision?.accessTopology || "unknown",
|
|
227
|
+
confidence: "low",
|
|
228
|
+
strongSignals: ruleDecision?.strongSignals || [],
|
|
229
|
+
weakSignals: ruleDecision?.weakSignals || [],
|
|
230
|
+
coreObjects: [],
|
|
231
|
+
states: [],
|
|
232
|
+
actions: [],
|
|
233
|
+
mustNotUse: ruleDecision?.mustNotUse || [],
|
|
234
|
+
boundaryQuestionIds: ruleDecision?.boundaryQuestionIds || ["usage_scope", "maintenance_mode", "data_flow"],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function consumeLimit(env, key, resetAt) {
|
|
239
|
+
if (!env.PROMPT_CACHE) return { allowed: true, remaining: DAILY_LIMIT - 1 };
|
|
240
|
+
const current = Number(await env.PROMPT_CACHE.get(key) || "0");
|
|
241
|
+
if (current >= DAILY_LIMIT) return { allowed: false, remaining: 0 };
|
|
242
|
+
const next = current + 1;
|
|
243
|
+
const resetSeconds = Math.max(60, Math.floor((new Date(resetAt).getTime() - Date.now()) / 1000));
|
|
244
|
+
await env.PROMPT_CACHE.put(key, String(next), { expirationTtl: resetSeconds });
|
|
245
|
+
return { allowed: true, remaining: Math.max(0, DAILY_LIMIT - next) };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function remainingForKey(env, key) {
|
|
249
|
+
if (!env.PROMPT_CACHE) return DAILY_LIMIT;
|
|
250
|
+
const current = Number(await env.PROMPT_CACHE.get(key) || "0");
|
|
251
|
+
return Math.max(0, DAILY_LIMIT - current);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function rateLimitKey(request, env) {
|
|
255
|
+
const ip = request.headers.get("cf-connecting-ip") || request.headers.get("x-forwarded-for") || "unknown";
|
|
256
|
+
const day = shanghaiDateKey();
|
|
257
|
+
const salt = env.RATE_LIMIT_SALT || "product-spec";
|
|
258
|
+
return `rate:${day}:${await sha256(`${salt}:${ip}`)}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function maybeStoreSample(env, telemetryMode, body, llmDecision, finalDecision, meta) {
|
|
262
|
+
if (!env.PROMPT_SAMPLES || telemetryMode === "off") return;
|
|
263
|
+
const id = crypto.randomUUID();
|
|
264
|
+
const message = String(body.message || "").slice(0, 500);
|
|
265
|
+
const messageHash = body.messageHash || await sha256(normalizeText(message));
|
|
266
|
+
const sample = telemetryMode === "sample" ? redact(message) : null;
|
|
267
|
+
await env.PROMPT_SAMPLES.prepare(
|
|
268
|
+
`INSERT INTO prompt_samples (
|
|
269
|
+
id, created_at, package_version, client, telemetry_mode, message_hash, message_sample,
|
|
270
|
+
rule_decision_json, llm_decision_json, final_decision_json, llm_used, cache_hit,
|
|
271
|
+
prompt_tokens_approx, completion_tokens_approx, rate_limit_status, fallback_reason
|
|
272
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
273
|
+
).bind(
|
|
274
|
+
id,
|
|
275
|
+
new Date().toISOString(),
|
|
276
|
+
body.packageVersion || null,
|
|
277
|
+
body.client || null,
|
|
278
|
+
telemetryMode,
|
|
279
|
+
messageHash,
|
|
280
|
+
sample,
|
|
281
|
+
JSON.stringify(body.ruleDecision || {}),
|
|
282
|
+
llmDecision ? JSON.stringify(llmDecision) : null,
|
|
283
|
+
JSON.stringify(finalDecision || {}),
|
|
284
|
+
meta.llmUsed,
|
|
285
|
+
meta.cacheHit,
|
|
286
|
+
meta.promptTokensApprox || null,
|
|
287
|
+
meta.completionTokensApprox || null,
|
|
288
|
+
meta.rateLimitStatus || null,
|
|
289
|
+
meta.fallbackReason || null
|
|
290
|
+
).run();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function normalizeTelemetry(value) {
|
|
294
|
+
return ["off", "minimal", "sample"].includes(value) ? value : "off";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function privacyResult(telemetryMode) {
|
|
298
|
+
return {
|
|
299
|
+
stored: telemetryMode !== "off",
|
|
300
|
+
mode: telemetryMode,
|
|
301
|
+
redacted: telemetryMode === "sample",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function redact(text) {
|
|
306
|
+
return text
|
|
307
|
+
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[EMAIL]")
|
|
308
|
+
.replace(/(?<!\d)1[3-9]\d{9}(?!\d)/g, "[PHONE]")
|
|
309
|
+
.replace(/\b\d{15,19}\b/g, "[SENSITIVE_NUMBER]")
|
|
310
|
+
.replace(/\b(?:sk|pk|api|token|secret)[-_]?[A-Za-z0-9]{16,}\b/gi, "[SECRET]")
|
|
311
|
+
.replace(/([?&](?:token|key|secret|access_token)=)[^&\s]+/gi, "$1[SECRET]");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function shanghaiDateKey() {
|
|
315
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
316
|
+
timeZone: "Asia/Shanghai",
|
|
317
|
+
year: "numeric",
|
|
318
|
+
month: "2-digit",
|
|
319
|
+
day: "2-digit",
|
|
320
|
+
}).format(new Date());
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function nextShanghaiMidnightIso() {
|
|
324
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
325
|
+
timeZone: "Asia/Shanghai",
|
|
326
|
+
year: "numeric",
|
|
327
|
+
month: "2-digit",
|
|
328
|
+
day: "2-digit",
|
|
329
|
+
}).formatToParts(new Date());
|
|
330
|
+
const year = Number(parts.find((p) => p.type === "year").value);
|
|
331
|
+
const month = Number(parts.find((p) => p.type === "month").value);
|
|
332
|
+
const day = Number(parts.find((p) => p.type === "day").value);
|
|
333
|
+
const next = new Date(Date.UTC(year, month - 1, day + 1));
|
|
334
|
+
const y = next.getUTCFullYear();
|
|
335
|
+
const m = String(next.getUTCMonth() + 1).padStart(2, "0");
|
|
336
|
+
const d = String(next.getUTCDate()).padStart(2, "0");
|
|
337
|
+
return `${y}-${m}-${d}T00:00:00+08:00`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function sha256(text) {
|
|
341
|
+
const bytes = new TextEncoder().encode(text);
|
|
342
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
343
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function normalizeText(text) {
|
|
347
|
+
return text.trim().replace(/\s+/g, " ");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function approxTokens(text) {
|
|
351
|
+
return Math.ceil(String(text || "").length / 4);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function copyEnum(raw, decision, key, allowed) {
|
|
355
|
+
if (raw[key] !== undefined && allowed.includes(raw[key])) decision[key] = raw[key];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function copyStringArray(raw, decision, key) {
|
|
359
|
+
if (Array.isArray(raw[key])) decision[key] = raw[key].filter((item) => typeof item === "string").slice(0, 12);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function json(payload, status = 200) {
|
|
363
|
+
return new Response(JSON.stringify(payload), {
|
|
364
|
+
status,
|
|
365
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const needTypes = [
|
|
370
|
+
"static_display",
|
|
371
|
+
"personal_local_tool",
|
|
372
|
+
"multi_user_collaboration",
|
|
373
|
+
"content_marketing_site",
|
|
374
|
+
"data_visualization_site",
|
|
375
|
+
"transaction_workflow",
|
|
376
|
+
"content_knowledge",
|
|
377
|
+
"ai_automation",
|
|
378
|
+
"unknown",
|
|
379
|
+
];
|
|
380
|
+
const usageScopes = ["self", "fixed_group", "public_audience", "unknown"];
|
|
381
|
+
const maintenanceModes = ["agent_assisted", "manual_files", "web_admin", "visitor_submission", "runtime_collaboration", "unknown"];
|
|
382
|
+
const accessTopologies = ["single_device", "lan_only", "internet_ip", "public_domain", "unknown"];
|
|
383
|
+
const confidences = ["high", "medium", "low"];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS prompt_samples (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
created_at TEXT NOT NULL,
|
|
4
|
+
package_version TEXT,
|
|
5
|
+
client TEXT,
|
|
6
|
+
telemetry_mode TEXT NOT NULL,
|
|
7
|
+
message_hash TEXT NOT NULL,
|
|
8
|
+
message_sample TEXT,
|
|
9
|
+
rule_decision_json TEXT NOT NULL,
|
|
10
|
+
llm_decision_json TEXT,
|
|
11
|
+
final_decision_json TEXT NOT NULL,
|
|
12
|
+
llm_used INTEGER NOT NULL,
|
|
13
|
+
cache_hit INTEGER NOT NULL,
|
|
14
|
+
prompt_tokens_approx INTEGER,
|
|
15
|
+
completion_tokens_approx INTEGER,
|
|
16
|
+
rate_limit_status TEXT,
|
|
17
|
+
fallback_reason TEXT
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_prompt_samples_created_at
|
|
21
|
+
ON prompt_samples(created_at);
|
|
22
|
+
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_prompt_samples_message_hash
|
|
24
|
+
ON prompt_samples(message_hash);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name = "product-spec-pm-intent-gate"
|
|
2
|
+
main = "pm-intent-gate.mjs"
|
|
3
|
+
compatibility_date = "2026-06-23"
|
|
4
|
+
|
|
5
|
+
kv_namespaces = [
|
|
6
|
+
{ binding = "PROMPT_CACHE", id = "replace-with-kv-namespace-id" }
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
d1_databases = [
|
|
10
|
+
{ binding = "PROMPT_SAMPLES", database_name = "product-spec-prompt-samples", database_id = "replace-with-d1-database-id" }
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[vars]
|
|
14
|
+
DEEPSEEK_MODEL = "deepseek-chat"
|
|
15
|
+
|
|
16
|
+
# Secrets to set with wrangler:
|
|
17
|
+
# wrangler secret put GATE_TOKEN
|
|
18
|
+
# wrangler secret put DEEPSEEK_API_KEY
|
|
19
|
+
# wrangler secret put RATE_LIMIT_SALT
|