G-ACE commited on
Commit
7769c4b
·
verified ·
1 Parent(s): eeec301

Deploy GitHub main cc65f516c16c41a528eec2ab762e0118fa11605c

Browse files
.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-07-29T22:55: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
 
 
 
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": "before_hp_public_live_story_report",
 
 
 
 
9
  "workflow_status": "failure",
10
- "workflow_run_id": "30457165765",
11
- "workflow_sha": "32f7375882072d69812b38ba0c8ef616b18ff8e2",
12
- "evidence_persisted_at": "2026-07-29T13:43:00.138648+00:00"
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": "before_baseline_live_story_report",
 
 
9
  "workflow_status": "failure",
10
- "workflow_run_id": "30457165765",
11
- "workflow_sha": "32f7375882072d69812b38ba0c8ef616b18ff8e2",
12
- "evidence_persisted_at": "2026-07-29T13:43:00.138608+00:00"
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
- "raw",
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 submitMessage(request, env) {
142
- if (!(await checkPublicRateLimit(request, env))) {
143
- return json({ error: "rate_limited", retry_after_seconds: 60 }, 429, { "retry-after": "60" });
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 source = apiAuthorization.authorized
164
- ? "astera-api"
165
- : input.source === "astera-app" ? "astera-app" : "astera-hp";
 
 
 
 
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 destinationId = String(env.CUSTOMER_AI_RUNTIME_DESTINATION_ID || "");
171
- if (!env.WEBHOOK_INTERNAL_API_URL || !env.WEBHOOK_INTERNAL_API_TOKEN || !destinationId) {
172
- return json({ error: "customer_ai_edge_not_configured" }, 503);
173
- }
174
-
175
- const gatewayResponse = await fetch(env.WEBHOOK_INTERNAL_API_URL, {
176
- method: "POST",
177
- headers: {
178
- authorization: `Bearer ${env.WEBHOOK_INTERNAL_API_TOKEN}`,
179
- "content-type": "application/json"
180
- },
181
- body: JSON.stringify({
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
- const gatewayPayload = await gatewayResponse.json().catch(() => ({}));
200
- if (!gatewayResponse.ok) {
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
- session_id: sessionId,
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
- job_id: jobId,
215
- session_id: sessionId,
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
- ...(existing && typeof existing === "object" ? existing : {}),
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 === "POST" && url.pathname === "/v1/customer-ai/messages") {
 
 
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]: