Spaces:
Sleeping
Sleeping
Deploy GitHub main cc65f516c16c41a528eec2ab762e0118fa11605c
Browse files- .deploy/customer-ai-free-model +3 -1
- deployment-evidence/hp-public-story-latest.json +8 -4
- deployment-evidence/live-story-latest.json +6 -4
- edge/src/worker.js +93 -103
- runtime/conversation.py +8 -0
- runtime/schemas.py +22 -0
- runtime/service.py +44 -1
.deploy/customer-ai-free-model
CHANGED
|
@@ -5,6 +5,8 @@ full-master-live-story-suite=blocked-without-notion-token
|
|
| 5 |
hp-public-live-story-suite=100
|
| 6 |
hp-public-bundled-pages=23
|
| 7 |
hp-public-source-sha256=8c2de4259b00a4c64dc175bb76ed7187387db1c127e2f3de66fc21278490d8f5
|
| 8 |
-
trigger=2026-
|
| 9 |
evidence=deployment-evidence/live-story-latest.json,deployment-evidence/hp-public-story-latest.json
|
| 10 |
mode=private-space-internal-signed-routes
|
|
|
|
|
|
|
|
|
| 5 |
hp-public-live-story-suite=100
|
| 6 |
hp-public-bundled-pages=23
|
| 7 |
hp-public-source-sha256=8c2de4259b00a4c64dc175bb76ed7187387db1c127e2f3de66fc21278490d8f5
|
| 8 |
+
trigger=2026-08-10T13:29:00+09:00
|
| 9 |
evidence=deployment-evidence/live-story-latest.json,deployment-evidence/hp-public-story-latest.json
|
| 10 |
mode=private-space-internal-signed-routes
|
| 11 |
+
public-routing=response_mode,mode_source,current_path
|
| 12 |
+
conversation-lifecycle=preserve,minimize,new,delete
|
deployment-evidence/hp-public-story-latest.json
CHANGED
|
@@ -5,9 +5,13 @@
|
|
| 5 |
"failed": 0,
|
| 6 |
"score_percent": 0.0,
|
| 7 |
"free_model_invoked": false,
|
| 8 |
-
"failure_stage": "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"workflow_status": "failure",
|
| 10 |
-
"workflow_run_id": "
|
| 11 |
-
"workflow_sha": "
|
| 12 |
-
"evidence_persisted_at": "2026-07-
|
| 13 |
}
|
|
|
|
| 5 |
"failed": 0,
|
| 6 |
"score_percent": 0.0,
|
| 7 |
"free_model_invoked": false,
|
| 8 |
+
"failure_stage": "hp_public_private_space_self_test_transport",
|
| 9 |
+
"error": "ReadError:[Errno 104] Connection reset by peer",
|
| 10 |
+
"space_id": "G-ACE/astera-customerAI",
|
| 11 |
+
"github_sha": "4d1e86831ecdedb419a1420985356e9c55082c4d",
|
| 12 |
+
"finished_at": "2026-07-29T14:04:15.831626+00:00",
|
| 13 |
"workflow_status": "failure",
|
| 14 |
+
"workflow_run_id": "30458269214",
|
| 15 |
+
"workflow_sha": "4d1e86831ecdedb419a1420985356e9c55082c4d",
|
| 16 |
+
"evidence_persisted_at": "2026-07-29T14:04:16.687657+00:00"
|
| 17 |
}
|
deployment-evidence/live-story-latest.json
CHANGED
|
@@ -5,9 +5,11 @@
|
|
| 5 |
"failed": 0,
|
| 6 |
"score_percent": 0.0,
|
| 7 |
"free_model_invoked": false,
|
| 8 |
-
"failure_stage": "
|
|
|
|
|
|
|
| 9 |
"workflow_status": "failure",
|
| 10 |
-
"workflow_run_id": "
|
| 11 |
-
"workflow_sha": "
|
| 12 |
-
"evidence_persisted_at": "2026-07-
|
| 13 |
}
|
|
|
|
| 5 |
"failed": 0,
|
| 6 |
"score_percent": 0.0,
|
| 7 |
"free_model_invoked": false,
|
| 8 |
+
"failure_stage": "full_master_notion_token_missing",
|
| 9 |
+
"verification_scope": "full_customerai_master_v2",
|
| 10 |
+
"finished_at": "2026-07-29T13:59:41.149535+00:00",
|
| 11 |
"workflow_status": "failure",
|
| 12 |
+
"workflow_run_id": "30458269214",
|
| 13 |
+
"workflow_sha": "4d1e86831ecdedb419a1420985356e9c55082c4d",
|
| 14 |
+
"evidence_persisted_at": "2026-07-29T14:04:16.687552+00:00"
|
| 15 |
}
|
edge/src/worker.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
|
|
|
|
|
|
| 2 |
|
| 3 |
function json(body, status = 200, extra = {}) {
|
| 4 |
return new Response(JSON.stringify(body), { status, headers: { ...JSON_HEADERS, ...extra } });
|
|
@@ -14,7 +16,7 @@ function corsHeaders(request, env) {
|
|
| 14 |
if (!allowedOrigins(env).has(origin)) return {};
|
| 15 |
return {
|
| 16 |
"access-control-allow-origin": origin,
|
| 17 |
-
"access-control-allow-methods": "GET,POST,OPTIONS",
|
| 18 |
"access-control-allow-headers": "authorization,content-type,x-turnstile-token,x-astera-source,x-request-id",
|
| 19 |
"access-control-max-age": "86400",
|
| 20 |
vary: "Origin"
|
|
@@ -33,6 +35,11 @@ function isId(value, prefix) {
|
|
| 33 |
&& value.length <= 160;
|
| 34 |
}
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
function textSafeEqual(left, right) {
|
| 37 |
const a = new TextEncoder().encode(String(left));
|
| 38 |
const b = new TextEncoder().encode(String(right));
|
|
@@ -55,10 +62,7 @@ function externalApiAuthorization(request, env) {
|
|
| 55 |
if (!authorization) return { attempted: false, authorized: false };
|
| 56 |
const token = bearerToken(request);
|
| 57 |
const expected = String(env.CUSTOMER_AI_EXTERNAL_API_TOKEN || "");
|
| 58 |
-
return {
|
| 59 |
-
attempted: true,
|
| 60 |
-
authorized: Boolean(token && expected && textSafeEqual(token, expected))
|
| 61 |
-
};
|
| 62 |
}
|
| 63 |
|
| 64 |
async function bodyJson(request, maxBytes = 32768) {
|
|
@@ -87,9 +91,7 @@ async function verifyTurnstile(request, env, remoteIp) {
|
|
| 87 |
form.set("idempotency_key", crypto.randomUUID());
|
| 88 |
if (remoteIp) form.set("remoteip", remoteIp);
|
| 89 |
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
| 90 |
-
method: "POST",
|
| 91 |
-
body: form,
|
| 92 |
-
signal: AbortSignal.timeout(5000)
|
| 93 |
});
|
| 94 |
if (!response.ok) return false;
|
| 95 |
const result = await response.json();
|
|
@@ -102,6 +104,15 @@ async function verifyTurnstile(request, env, remoteIp) {
|
|
| 102 |
return true;
|
| 103 |
}
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
function decodeSecret(secret) {
|
| 106 |
if (!secret) return new Uint8Array();
|
| 107 |
if (!secret.startsWith("base64:")) return new TextEncoder().encode(secret);
|
|
@@ -122,15 +133,8 @@ async function verifyStandardWebhook(request, rawBody, secret, toleranceSeconds
|
|
| 122 |
const seconds = Number(timestamp);
|
| 123 |
if (!eventId || !Number.isInteger(seconds) || !signature || !secret) return false;
|
| 124 |
if (Math.abs(Math.floor(Date.now() / 1000) - seconds) > toleranceSeconds) return false;
|
| 125 |
-
const key = await crypto.subtle.importKey(
|
| 126 |
-
|
| 127 |
-
decodeSecret(secret),
|
| 128 |
-
{ name: "HMAC", hash: "SHA-256" },
|
| 129 |
-
false,
|
| 130 |
-
["sign"]
|
| 131 |
-
);
|
| 132 |
-
const signed = `${eventId}.${timestamp}.${rawBody}`;
|
| 133 |
-
const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signed));
|
| 134 |
const expected = base64(digest);
|
| 135 |
return signature.split(/\s+/).some((candidate) => {
|
| 136 |
const [version, encoded] = candidate.split(",", 2);
|
|
@@ -138,107 +142,97 @@ async function verifyStandardWebhook(request, rawBody, secret, toleranceSeconds
|
|
| 138 |
});
|
| 139 |
}
|
| 140 |
|
| 141 |
-
async function
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
const apiAuthorization = externalApiAuthorization(request, env);
|
| 147 |
-
if (apiAuthorization.attempted && !apiAuthorization.authorized) {
|
| 148 |
-
return json({ error: "invalid_api_token" }, 401, { "www-authenticate": "Bearer" });
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
const remoteIp = request.headers.get("cf-connecting-ip") || "";
|
| 152 |
-
if (!apiAuthorization.authorized && !(await verifyTurnstile(request, env, remoteIp))) {
|
| 153 |
-
return json({ error: "turnstile_failed" }, 403);
|
| 154 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
|
|
|
|
|
|
|
|
|
| 156 |
const input = await bodyJson(request, Number(env.MAX_INPUT_BYTES || 32768));
|
| 157 |
const message = String(input.message || "").trim();
|
| 158 |
if (!message) return json({ error: "message_required" }, 422);
|
| 159 |
-
if (message.length > Number(env.MAX_MESSAGE_CHARS || 20000)) {
|
| 160 |
-
return json({ error: "message_too_large" }, 413);
|
| 161 |
-
}
|
| 162 |
|
| 163 |
-
const
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
const sessionId = isId(input.session_id, "session") ? input.session_id : id("session");
|
| 167 |
const messageId = isId(input.message_id, "message") ? input.message_id : id("message");
|
| 168 |
const jobId = isId(input.job_id, "job") ? input.job_id : id("job");
|
| 169 |
const eventId = `event_${messageId}`;
|
| 170 |
-
const
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
eventId,
|
| 183 |
-
eventType: "customer.ai.message.requested",
|
| 184 |
-
sourceId: "cloudflare-customer-ai-edge",
|
| 185 |
-
destinationId,
|
| 186 |
-
subject: `job/${jobId}`,
|
| 187 |
-
data: {
|
| 188 |
-
job_id: jobId,
|
| 189 |
-
message: {
|
| 190 |
-
session_id: sessionId,
|
| 191 |
-
message_id: messageId,
|
| 192 |
-
message,
|
| 193 |
-
locale: String(input.locale || "ja-JP"),
|
| 194 |
-
source
|
| 195 |
-
}
|
| 196 |
}
|
| 197 |
-
}
|
| 198 |
});
|
| 199 |
-
|
| 200 |
-
if (!
|
| 201 |
-
return json({ error: "gateway_rejected", gateway: gatewayPayload }, 502);
|
| 202 |
-
}
|
| 203 |
|
| 204 |
await env.CUSTOMER_AI_RESULTS.put(jobId, JSON.stringify({
|
| 205 |
-
job_id: jobId,
|
| 206 |
-
|
| 207 |
-
message_id: messageId,
|
| 208 |
-
status: "accepted",
|
| 209 |
created_at: new Date().toISOString()
|
| 210 |
}), { expirationTtl: Number(env.RESULT_TTL_SECONDS || 3600) });
|
| 211 |
|
| 212 |
return json({
|
| 213 |
-
ok: true,
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
message_id: messageId,
|
| 217 |
-
status: "accepted",
|
| 218 |
-
gateway_event_id: gatewayPayload.eventId
|
| 219 |
}, 202);
|
| 220 |
}
|
| 221 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
async function receiveEvent(request, env) {
|
| 223 |
const raw = await request.text();
|
| 224 |
-
if (!(await verifyStandardWebhook(request, raw, env.RESULT_WEBHOOK_SECRET))) {
|
| 225 |
-
return json({ error: "invalid_standard_webhook_signature" }, 401);
|
| 226 |
-
}
|
| 227 |
const event = JSON.parse(raw);
|
| 228 |
const data = event && typeof event.data === "object" ? event.data : {};
|
| 229 |
const jobId = String(data.job_id || event.subject || "").replace(/^job\//, "");
|
| 230 |
if (!isId(jobId, "job")) return json({ error: "job_id_invalid" }, 422);
|
| 231 |
const existing = await env.CUSTOMER_AI_RESULTS.get(jobId, "json");
|
| 232 |
-
const value = {
|
| 233 |
-
|
| 234 |
-
...data,
|
| 235 |
-
job_id: jobId,
|
| 236 |
-
event_type: event.type,
|
| 237 |
-
updated_at: new Date().toISOString()
|
| 238 |
-
};
|
| 239 |
-
await env.CUSTOMER_AI_RESULTS.put(jobId, JSON.stringify(value), {
|
| 240 |
-
expirationTtl: Number(env.RESULT_TTL_SECONDS || 3600)
|
| 241 |
-
});
|
| 242 |
return json({ ok: true, job_id: jobId }, 202);
|
| 243 |
}
|
| 244 |
|
|
@@ -254,17 +248,17 @@ export default {
|
|
| 254 |
async fetch(request, env) {
|
| 255 |
const url = new URL(request.url);
|
| 256 |
const cors = corsHeaders(request, env);
|
| 257 |
-
if (request.method === "OPTIONS") {
|
| 258 |
-
return new Response(null, { status: 204, headers: cors });
|
| 259 |
-
}
|
| 260 |
const origin = request.headers.get("origin");
|
| 261 |
-
if (origin && !Object.keys(cors).length && url.pathname !== "/v1/customer-ai/events") {
|
| 262 |
-
return json({ error: "origin_not_allowed" }, 403);
|
| 263 |
-
}
|
| 264 |
try {
|
| 265 |
let response;
|
| 266 |
-
if (request.method === "
|
|
|
|
|
|
|
| 267 |
response = await submitMessage(request, env);
|
|
|
|
|
|
|
| 268 |
} else if (request.method === "POST" && url.pathname === "/v1/customer-ai/events") {
|
| 269 |
response = await receiveEvent(request, env);
|
| 270 |
} else if (request.method === "GET" && url.pathname.startsWith("/v1/customer-ai/jobs/")) {
|
|
@@ -279,11 +273,7 @@ export default {
|
|
| 279 |
return new Response(response.body, { status: response.status, headers });
|
| 280 |
} catch (error) {
|
| 281 |
const code = error instanceof Error ? error.message : "internal_error";
|
| 282 |
-
return json(
|
| 283 |
-
{ error: code === "BODY_TOO_LARGE" ? "body_too_large" : "internal_error" },
|
| 284 |
-
code === "BODY_TOO_LARGE" ? 413 : 500,
|
| 285 |
-
cors
|
| 286 |
-
);
|
| 287 |
}
|
| 288 |
}
|
| 289 |
-
};
|
|
|
|
| 1 |
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
| 2 |
+
const RESPONSE_MODES = new Set(["general", "operation", "billing", "technical", "investor", "support", "trouble", "auto"]);
|
| 3 |
+
const MODE_SOURCES = new Set(["selected", "auto", "confirmed"]);
|
| 4 |
|
| 5 |
function json(body, status = 200, extra = {}) {
|
| 6 |
return new Response(JSON.stringify(body), { status, headers: { ...JSON_HEADERS, ...extra } });
|
|
|
|
| 16 |
if (!allowedOrigins(env).has(origin)) return {};
|
| 17 |
return {
|
| 18 |
"access-control-allow-origin": origin,
|
| 19 |
+
"access-control-allow-methods": "GET,POST,DELETE,OPTIONS",
|
| 20 |
"access-control-allow-headers": "authorization,content-type,x-turnstile-token,x-astera-source,x-request-id",
|
| 21 |
"access-control-max-age": "86400",
|
| 22 |
vary: "Origin"
|
|
|
|
| 35 |
&& value.length <= 160;
|
| 36 |
}
|
| 37 |
|
| 38 |
+
function normalizePath(value) {
|
| 39 |
+
const path = String(value || "/").trim().split("?", 1)[0].split("#", 1)[0];
|
| 40 |
+
return path.startsWith("/") && !path.includes("://") ? path.slice(0, 512) || "/" : "/";
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
function textSafeEqual(left, right) {
|
| 44 |
const a = new TextEncoder().encode(String(left));
|
| 45 |
const b = new TextEncoder().encode(String(right));
|
|
|
|
| 62 |
if (!authorization) return { attempted: false, authorized: false };
|
| 63 |
const token = bearerToken(request);
|
| 64 |
const expected = String(env.CUSTOMER_AI_EXTERNAL_API_TOKEN || "");
|
| 65 |
+
return { attempted: true, authorized: Boolean(token && expected && textSafeEqual(token, expected)) };
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
|
| 68 |
async function bodyJson(request, maxBytes = 32768) {
|
|
|
|
| 91 |
form.set("idempotency_key", crypto.randomUUID());
|
| 92 |
if (remoteIp) form.set("remoteip", remoteIp);
|
| 93 |
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
| 94 |
+
method: "POST", body: form, signal: AbortSignal.timeout(5000)
|
|
|
|
|
|
|
| 95 |
});
|
| 96 |
if (!response.ok) return false;
|
| 97 |
const result = await response.json();
|
|
|
|
| 104 |
return true;
|
| 105 |
}
|
| 106 |
|
| 107 |
+
async function authorizePublicAction(request, env) {
|
| 108 |
+
if (!(await checkPublicRateLimit(request, env))) return { error: json({ error: "rate_limited", retry_after_seconds: 60 }, 429, { "retry-after": "60" }) };
|
| 109 |
+
const apiAuthorization = externalApiAuthorization(request, env);
|
| 110 |
+
if (apiAuthorization.attempted && !apiAuthorization.authorized) return { error: json({ error: "invalid_api_token" }, 401, { "www-authenticate": "Bearer" }) };
|
| 111 |
+
const remoteIp = request.headers.get("cf-connecting-ip") || "";
|
| 112 |
+
if (!apiAuthorization.authorized && !(await verifyTurnstile(request, env, remoteIp))) return { error: json({ error: "turnstile_failed" }, 403) };
|
| 113 |
+
return { apiAuthorization };
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
function decodeSecret(secret) {
|
| 117 |
if (!secret) return new Uint8Array();
|
| 118 |
if (!secret.startsWith("base64:")) return new TextEncoder().encode(secret);
|
|
|
|
| 133 |
const seconds = Number(timestamp);
|
| 134 |
if (!eventId || !Number.isInteger(seconds) || !signature || !secret) return false;
|
| 135 |
if (Math.abs(Math.floor(Date.now() / 1000) - seconds) > toleranceSeconds) return false;
|
| 136 |
+
const key = await crypto.subtle.importKey("raw", decodeSecret(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
| 137 |
+
const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${eventId}.${timestamp}.${rawBody}`));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
const expected = base64(digest);
|
| 139 |
return signature.split(/\s+/).some((candidate) => {
|
| 140 |
const [version, encoded] = candidate.split(",", 2);
|
|
|
|
| 142 |
});
|
| 143 |
}
|
| 144 |
|
| 145 |
+
async function dispatchGateway(env, payload) {
|
| 146 |
+
const destinationId = String(env.CUSTOMER_AI_RUNTIME_DESTINATION_ID || "");
|
| 147 |
+
if (!env.WEBHOOK_INTERNAL_API_URL || !env.WEBHOOK_INTERNAL_API_TOKEN || !destinationId) {
|
| 148 |
+
return { response: null, payload: { error: "customer_ai_edge_not_configured" } };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
}
|
| 150 |
+
const response = await fetch(env.WEBHOOK_INTERNAL_API_URL, {
|
| 151 |
+
method: "POST",
|
| 152 |
+
headers: { authorization: `Bearer ${env.WEBHOOK_INTERNAL_API_TOKEN}`, "content-type": "application/json" },
|
| 153 |
+
body: JSON.stringify({ ...payload, destinationId })
|
| 154 |
+
});
|
| 155 |
+
const body = await response.json().catch(() => ({}));
|
| 156 |
+
return { response, payload: body };
|
| 157 |
+
}
|
| 158 |
|
| 159 |
+
async function submitMessage(request, env) {
|
| 160 |
+
const authorization = await authorizePublicAction(request, env);
|
| 161 |
+
if (authorization.error) return authorization.error;
|
| 162 |
const input = await bodyJson(request, Number(env.MAX_INPUT_BYTES || 32768));
|
| 163 |
const message = String(input.message || "").trim();
|
| 164 |
if (!message) return json({ error: "message_required" }, 422);
|
| 165 |
+
if (message.length > Number(env.MAX_MESSAGE_CHARS || 20000)) return json({ error: "message_too_large" }, 413);
|
|
|
|
|
|
|
| 166 |
|
| 167 |
+
const responseMode = String(input.response_mode || "auto");
|
| 168 |
+
const modeSource = String(input.mode_source || (responseMode === "auto" ? "auto" : "selected"));
|
| 169 |
+
if (!RESPONSE_MODES.has(responseMode)) return json({ error: "response_mode_invalid" }, 422);
|
| 170 |
+
if (!MODE_SOURCES.has(modeSource)) return json({ error: "mode_source_invalid" }, 422);
|
| 171 |
+
|
| 172 |
+
const apiAuthorization = authorization.apiAuthorization;
|
| 173 |
+
const source = apiAuthorization.authorized ? "astera-api" : input.source === "astera-app" ? "astera-app" : "astera-hp";
|
| 174 |
const sessionId = isId(input.session_id, "session") ? input.session_id : id("session");
|
| 175 |
const messageId = isId(input.message_id, "message") ? input.message_id : id("message");
|
| 176 |
const jobId = isId(input.job_id, "job") ? input.job_id : id("job");
|
| 177 |
const eventId = `event_${messageId}`;
|
| 178 |
+
const currentPath = normalizePath(input.current_path);
|
| 179 |
+
const gateway = await dispatchGateway(env, {
|
| 180 |
+
eventId,
|
| 181 |
+
eventType: "customer.ai.message.requested",
|
| 182 |
+
sourceId: "cloudflare-customer-ai-edge",
|
| 183 |
+
subject: `job/${jobId}`,
|
| 184 |
+
data: {
|
| 185 |
+
job_id: jobId,
|
| 186 |
+
message: {
|
| 187 |
+
session_id: sessionId, message_id: messageId, message,
|
| 188 |
+
locale: String(input.locale || "ja-JP"), source,
|
| 189 |
+
response_mode: responseMode, mode_source: modeSource, current_path: currentPath
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
}
|
| 191 |
+
}
|
| 192 |
});
|
| 193 |
+
if (!gateway.response) return json(gateway.payload, 503);
|
| 194 |
+
if (!gateway.response.ok) return json({ error: "gateway_rejected", gateway: gateway.payload }, 502);
|
|
|
|
|
|
|
| 195 |
|
| 196 |
await env.CUSTOMER_AI_RESULTS.put(jobId, JSON.stringify({
|
| 197 |
+
job_id: jobId, session_id: sessionId, message_id: messageId, status: "accepted",
|
| 198 |
+
response_mode: responseMode, mode_source: modeSource, current_path: currentPath,
|
|
|
|
|
|
|
| 199 |
created_at: new Date().toISOString()
|
| 200 |
}), { expirationTtl: Number(env.RESULT_TTL_SECONDS || 3600) });
|
| 201 |
|
| 202 |
return json({
|
| 203 |
+
ok: true, job_id: jobId, session_id: sessionId, message_id: messageId, status: "accepted",
|
| 204 |
+
response_mode: responseMode, mode_source: modeSource, current_path: currentPath,
|
| 205 |
+
gateway_event_id: gateway.payload.eventId
|
|
|
|
|
|
|
|
|
|
| 206 |
}, 202);
|
| 207 |
}
|
| 208 |
|
| 209 |
+
async function deleteSession(request, env, sessionId) {
|
| 210 |
+
if (!isId(sessionId, "session")) return json({ error: "session_id_invalid" }, 422);
|
| 211 |
+
const authorization = await authorizePublicAction(request, env);
|
| 212 |
+
if (authorization.error) return authorization.error;
|
| 213 |
+
const eventId = id("event");
|
| 214 |
+
const gateway = await dispatchGateway(env, {
|
| 215 |
+
eventId,
|
| 216 |
+
eventType: "customer.ai.session.delete.requested",
|
| 217 |
+
sourceId: "cloudflare-customer-ai-edge",
|
| 218 |
+
subject: `session/${sessionId}`,
|
| 219 |
+
data: { session_id: sessionId, source: "astera-hp" }
|
| 220 |
+
});
|
| 221 |
+
if (!gateway.response) return json(gateway.payload, 503);
|
| 222 |
+
if (!gateway.response.ok) return json({ error: "gateway_rejected", gateway: gateway.payload }, 502);
|
| 223 |
+
return json({ ok: true, session_id: sessionId, status: "delete_requested", gateway_event_id: gateway.payload.eventId }, 202);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
async function receiveEvent(request, env) {
|
| 227 |
const raw = await request.text();
|
| 228 |
+
if (!(await verifyStandardWebhook(request, raw, env.RESULT_WEBHOOK_SECRET))) return json({ error: "invalid_standard_webhook_signature" }, 401);
|
|
|
|
|
|
|
| 229 |
const event = JSON.parse(raw);
|
| 230 |
const data = event && typeof event.data === "object" ? event.data : {};
|
| 231 |
const jobId = String(data.job_id || event.subject || "").replace(/^job\//, "");
|
| 232 |
if (!isId(jobId, "job")) return json({ error: "job_id_invalid" }, 422);
|
| 233 |
const existing = await env.CUSTOMER_AI_RESULTS.get(jobId, "json");
|
| 234 |
+
const value = { ...(existing && typeof existing === "object" ? existing : {}), ...data, job_id: jobId, event_type: event.type, updated_at: new Date().toISOString() };
|
| 235 |
+
await env.CUSTOMER_AI_RESULTS.put(jobId, JSON.stringify(value), { expirationTtl: Number(env.RESULT_TTL_SECONDS || 3600) });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
return json({ ok: true, job_id: jobId }, 202);
|
| 237 |
}
|
| 238 |
|
|
|
|
| 248 |
async fetch(request, env) {
|
| 249 |
const url = new URL(request.url);
|
| 250 |
const cors = corsHeaders(request, env);
|
| 251 |
+
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
|
|
|
|
|
|
|
| 252 |
const origin = request.headers.get("origin");
|
| 253 |
+
if (origin && !Object.keys(cors).length && url.pathname !== "/v1/customer-ai/events") return json({ error: "origin_not_allowed" }, 403);
|
|
|
|
|
|
|
| 254 |
try {
|
| 255 |
let response;
|
| 256 |
+
if (request.method === "GET" && url.pathname === "/v1/customer-ai/config") {
|
| 257 |
+
response = json({ turnstile_site_key: String(env.TURNSTILE_SITE_KEY || "") });
|
| 258 |
+
} else if (request.method === "POST" && url.pathname === "/v1/customer-ai/messages") {
|
| 259 |
response = await submitMessage(request, env);
|
| 260 |
+
} else if (request.method === "DELETE" && url.pathname.startsWith("/v1/customer-ai/sessions/")) {
|
| 261 |
+
response = await deleteSession(request, env, decodeURIComponent(url.pathname.split("/").pop()));
|
| 262 |
} else if (request.method === "POST" && url.pathname === "/v1/customer-ai/events") {
|
| 263 |
response = await receiveEvent(request, env);
|
| 264 |
} else if (request.method === "GET" && url.pathname.startsWith("/v1/customer-ai/jobs/")) {
|
|
|
|
| 273 |
return new Response(response.body, { status: response.status, headers });
|
| 274 |
} catch (error) {
|
| 275 |
const code = error instanceof Error ? error.message : "internal_error";
|
| 276 |
+
return json({ error: code === "BODY_TOO_LARGE" ? "body_too_large" : "internal_error" }, code === "BODY_TOO_LARGE" ? 413 : 500, cors);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
}
|
| 278 |
}
|
| 279 |
+
};
|
runtime/conversation.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import copy
|
|
|
|
| 4 |
import time
|
| 5 |
from collections import OrderedDict
|
| 6 |
from datetime import UTC, datetime
|
|
@@ -56,6 +57,13 @@ class ConversationCache:
|
|
| 56 |
self.store.put_json(self._path(trimmed.session_id), trimmed.model_dump(mode="json"))
|
| 57 |
self._remember(trimmed)
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
def append_turns(
|
| 60 |
self,
|
| 61 |
context: SessionContext,
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import copy
|
| 4 |
+
import shutil
|
| 5 |
import time
|
| 6 |
from collections import OrderedDict
|
| 7 |
from datetime import UTC, datetime
|
|
|
|
| 57 |
self.store.put_json(self._path(trimmed.session_id), trimmed.model_dump(mode="json"))
|
| 58 |
self._remember(trimmed)
|
| 59 |
|
| 60 |
+
def delete(self, session_id: str) -> bool:
|
| 61 |
+
"""Delete one bounded session from memory and persistent runtime state."""
|
| 62 |
+
existed = session_id in self._memory or (self.root / session_id).exists()
|
| 63 |
+
self._memory.pop(session_id, None)
|
| 64 |
+
shutil.rmtree(self.root / session_id, ignore_errors=True)
|
| 65 |
+
return existed
|
| 66 |
+
|
| 67 |
def append_turns(
|
| 68 |
self,
|
| 69 |
context: SessionContext,
|
runtime/schemas.py
CHANGED
|
@@ -19,6 +19,17 @@ JobStatus = Literal[
|
|
| 19 |
"completed",
|
| 20 |
"failed",
|
| 21 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
class MessagePayload(BaseModel):
|
|
@@ -27,12 +38,23 @@ class MessagePayload(BaseModel):
|
|
| 27 |
message: str = Field(min_length=1, max_length=20000)
|
| 28 |
locale: Literal["ja-JP", "en"] = "ja-JP"
|
| 29 |
source: Literal["astera-hp", "astera-app", "astera-api"]
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
@field_validator("session_id", "message_id")
|
| 32 |
@classmethod
|
| 33 |
def safe_identifier(cls, value: str) -> str:
|
| 34 |
return validate_identifier(value)
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
class ConversationTurn(BaseModel):
|
| 38 |
role: Literal["user", "assistant"]
|
|
|
|
| 19 |
"completed",
|
| 20 |
"failed",
|
| 21 |
]
|
| 22 |
+
ResponseMode = Literal[
|
| 23 |
+
"general",
|
| 24 |
+
"operation",
|
| 25 |
+
"billing",
|
| 26 |
+
"technical",
|
| 27 |
+
"investor",
|
| 28 |
+
"support",
|
| 29 |
+
"trouble",
|
| 30 |
+
"auto",
|
| 31 |
+
]
|
| 32 |
+
ModeSource = Literal["selected", "auto", "confirmed"]
|
| 33 |
|
| 34 |
|
| 35 |
class MessagePayload(BaseModel):
|
|
|
|
| 38 |
message: str = Field(min_length=1, max_length=20000)
|
| 39 |
locale: Literal["ja-JP", "en"] = "ja-JP"
|
| 40 |
source: Literal["astera-hp", "astera-app", "astera-api"]
|
| 41 |
+
response_mode: ResponseMode = "auto"
|
| 42 |
+
mode_source: ModeSource = "auto"
|
| 43 |
+
current_path: str = Field(default="/", max_length=512)
|
| 44 |
|
| 45 |
@field_validator("session_id", "message_id")
|
| 46 |
@classmethod
|
| 47 |
def safe_identifier(cls, value: str) -> str:
|
| 48 |
return validate_identifier(value)
|
| 49 |
|
| 50 |
+
@field_validator("current_path")
|
| 51 |
+
@classmethod
|
| 52 |
+
def safe_current_path(cls, value: str) -> str:
|
| 53 |
+
path = str(value or "/").strip()
|
| 54 |
+
if not path.startswith("/") or "://" in path:
|
| 55 |
+
return "/"
|
| 56 |
+
return path.split("?", 1)[0].split("#", 1)[0][:512] or "/"
|
| 57 |
+
|
| 58 |
|
| 59 |
class ConversationTurn(BaseModel):
|
| 60 |
role: Literal["user", "assistant"]
|
runtime/service.py
CHANGED
|
@@ -24,6 +24,15 @@ from .v8 import V8Supervisor
|
|
| 24 |
|
| 25 |
LOGGER = logging.getLogger(__name__)
|
| 26 |
SUPPORT_PIPELINE = "astera-customerai-master-v2-kb-only"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
class InternalEventApiClient:
|
|
@@ -170,6 +179,17 @@ class CustomerAIService:
|
|
| 170 |
return {"recovered": recovered, "count": len(recovered)}
|
| 171 |
|
| 172 |
async def accept(self, event: CloudEvent) -> tuple[dict[str, Any], bool]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
if event.type != "customer.ai.message.requested":
|
| 174 |
raise ValueError("unsupported_event_type")
|
| 175 |
payload = MessagePayload.model_validate(event.data["message"])
|
|
@@ -177,7 +197,6 @@ class CustomerAIService:
|
|
| 177 |
raise ValueError("message_too_large")
|
| 178 |
redacted = redact_text(payload.message)
|
| 179 |
payload = payload.model_copy(update={"message": redacted.text})
|
| 180 |
-
validate_identifier(event.id, field="event_id")
|
| 181 |
job_id = str(
|
| 182 |
event.data.get("job_id")
|
| 183 |
or "job_" + hashlib.sha256(event.id.encode()).hexdigest()[:32]
|
|
@@ -193,6 +212,9 @@ class CustomerAIService:
|
|
| 193 |
"job_id": job_id,
|
| 194 |
"created": created,
|
| 195 |
"redaction_kinds": redacted.kinds,
|
|
|
|
|
|
|
|
|
|
| 196 |
},
|
| 197 |
)
|
| 198 |
return record.model_dump(mode="json"), created
|
|
@@ -241,6 +263,21 @@ class CustomerAIService:
|
|
| 241 |
async def _run_pipeline(
|
| 242 |
self, job_id: str, request: MessagePayload
|
| 243 |
) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
outcome = await self.core.execute(request=request)
|
| 245 |
return JobResult(
|
| 246 |
job_id=job_id,
|
|
@@ -261,6 +298,12 @@ class CustomerAIService:
|
|
| 261 |
"repair_attempted": outcome.repair_attempted,
|
| 262 |
"feedback_candidate_id": outcome.feedback_candidate_id,
|
| 263 |
"execution": outcome.execution,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
}
|
| 265 |
|
| 266 |
def readiness(self) -> dict[str, Any]:
|
|
|
|
| 24 |
|
| 25 |
LOGGER = logging.getLogger(__name__)
|
| 26 |
SUPPORT_PIPELINE = "astera-customerai-master-v2-kb-only"
|
| 27 |
+
RESPONSE_MODE_TOPICS = {
|
| 28 |
+
"general": "astera",
|
| 29 |
+
"operation": "operation",
|
| 30 |
+
"billing": "billing",
|
| 31 |
+
"technical": "technical",
|
| 32 |
+
"investor": "investor",
|
| 33 |
+
"support": "support",
|
| 34 |
+
"trouble": "troubleshooting",
|
| 35 |
+
}
|
| 36 |
|
| 37 |
|
| 38 |
class InternalEventApiClient:
|
|
|
|
| 179 |
return {"recovered": recovered, "count": len(recovered)}
|
| 180 |
|
| 181 |
async def accept(self, event: CloudEvent) -> tuple[dict[str, Any], bool]:
|
| 182 |
+
validate_identifier(event.id, field="event_id")
|
| 183 |
+
if event.type == "customer.ai.session.delete.requested":
|
| 184 |
+
session_id = validate_identifier(
|
| 185 |
+
str(event.data.get("session_id") or ""), field="session_id"
|
| 186 |
+
)
|
| 187 |
+
deleted = self.conversations.delete(session_id)
|
| 188 |
+
result = {"session_id": session_id, "deleted": deleted}
|
| 189 |
+
await self.events.emit(
|
| 190 |
+
"customer.ai.session.deleted", f"session/{session_id}", result
|
| 191 |
+
)
|
| 192 |
+
return result, True
|
| 193 |
if event.type != "customer.ai.message.requested":
|
| 194 |
raise ValueError("unsupported_event_type")
|
| 195 |
payload = MessagePayload.model_validate(event.data["message"])
|
|
|
|
| 197 |
raise ValueError("message_too_large")
|
| 198 |
redacted = redact_text(payload.message)
|
| 199 |
payload = payload.model_copy(update={"message": redacted.text})
|
|
|
|
| 200 |
job_id = str(
|
| 201 |
event.data.get("job_id")
|
| 202 |
or "job_" + hashlib.sha256(event.id.encode()).hexdigest()[:32]
|
|
|
|
| 212 |
"job_id": job_id,
|
| 213 |
"created": created,
|
| 214 |
"redaction_kinds": redacted.kinds,
|
| 215 |
+
"response_mode": payload.response_mode,
|
| 216 |
+
"mode_source": payload.mode_source,
|
| 217 |
+
"current_path": payload.current_path,
|
| 218 |
},
|
| 219 |
)
|
| 220 |
return record.model_dump(mode="json"), created
|
|
|
|
| 263 |
async def _run_pipeline(
|
| 264 |
self, job_id: str, request: MessagePayload
|
| 265 |
) -> dict[str, Any]:
|
| 266 |
+
context = self.conversations.get(request.session_id)
|
| 267 |
+
details = dict(context.confirmed_details)
|
| 268 |
+
details.update(
|
| 269 |
+
{
|
| 270 |
+
"response_mode": request.response_mode,
|
| 271 |
+
"mode_source": request.mode_source,
|
| 272 |
+
"current_path": request.current_path,
|
| 273 |
+
}
|
| 274 |
+
)
|
| 275 |
+
updates: dict[str, Any] = {"confirmed_details": details}
|
| 276 |
+
routed_topic = RESPONSE_MODE_TOPICS.get(request.response_mode)
|
| 277 |
+
if routed_topic:
|
| 278 |
+
updates["active_topic"] = routed_topic
|
| 279 |
+
self.conversations.save(context.model_copy(update=updates))
|
| 280 |
+
|
| 281 |
outcome = await self.core.execute(request=request)
|
| 282 |
return JobResult(
|
| 283 |
job_id=job_id,
|
|
|
|
| 298 |
"repair_attempted": outcome.repair_attempted,
|
| 299 |
"feedback_candidate_id": outcome.feedback_candidate_id,
|
| 300 |
"execution": outcome.execution,
|
| 301 |
+
"routing": {
|
| 302 |
+
"response_mode": request.response_mode,
|
| 303 |
+
"mode_source": request.mode_source,
|
| 304 |
+
"current_path": request.current_path,
|
| 305 |
+
"active_topic": routed_topic or context.active_topic,
|
| 306 |
+
},
|
| 307 |
}
|
| 308 |
|
| 309 |
def readiness(self) -> dict[str, Any]:
|