G-ACE commited on
Commit
ff76bd2
·
verified ·
1 Parent(s): 95f2c96

Deploy GitHub main bbdacdb09d2a0c17d49fd8a49c8159317f3ec5b7

Browse files
.env.example ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CUSTOMER_AI_DATA_ROOT=/data/customer-ai
2
+ # Inbound signature used by the generic gateway destination when delivering to /internal/customer-ai/accept.
3
+ CUSTOMER_AI_HMAC_SECRET=base64:replace_with_private_runtime_outbound_secret
4
+
5
+ # Generic Webhook Gateway internal event API used for accepted/completed/recovery events.
6
+ INTERNAL_EVENT_API_URL=https://webhooks.asterav8.jp/internal/events
7
+ INTERNAL_EVENT_API_TOKEN=replace_with_long_random_internal_api_token
8
+ INTERNAL_EVENT_SOURCE_ID=hf-private-runtime
9
+ # Deployment-registered generic destination that returns events to the Cloudflare result receiver.
10
+ INTERNAL_EVENT_RESULT_DESTINATION_ID=app-receiver
11
+ INTERNAL_EVENT_API_TIMEOUT_SECONDS=10
12
+
13
+ NOTION_TOKEN=
14
+ NOTION_DATA_SOURCE_ID=2a2e5dd3-8492-45b9-a450-b362d02794b4
15
+ CUSTOMER_AI_MODEL_ID=Qwen/Qwen3-4B-Instruct-2507
16
+ CUSTOMER_AI_MODEL_REVISION=cdbee75f17c01a7cc42f958dc650907174af0554
17
+ CUSTOMER_AI_ENABLE_MODEL=0
18
+ CUSTOMER_AI_GPU_DAILY_BUDGET_SECONDS=2100
19
+ CUSTOMER_AI_NODE_BINARY=node
20
+ CUSTOMER_AI_NODE_SOCKET=/tmp/customer-ai-v8.sock
21
+ CUSTOMER_AI_NODE_MEMORY_MB=384
22
+ CUSTOMER_AI_JOB_LEASE_SECONDS=90
23
+ CUSTOMER_AI_SESSION_LEASE_SECONDS=90
24
+ CUSTOMER_AI_MAX_INPUT_CHARS=20000
25
+ CUSTOMER_AI_PROCESS_CONCURRENCY=2
26
+ CUSTOMER_AI_SESSION_CACHE_TTL_SECONDS=1800
27
+ CUSTOMER_AI_SESSION_CACHE_MAX_SESSIONS=256
28
+ CUSTOMER_AI_SESSION_CACHE_MAX_TURNS=12
29
+ CUSTOMER_AI_KB_CACHE_TTL_SECONDS=120
30
+ CUSTOMER_AI_KB_CACHE_MAX_ENTRIES=256
.gitignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ .pytest_cache/
3
+ .coverage
4
+ htmlcov/
5
+ .venv/
6
+ .env
7
+ *.pyc
8
+ *.pyo
9
+ *.sqlite
10
+ *.sqlite-shm
11
+ *.sqlite-wal
12
+ node_modules/
13
+ /data/
14
+ .runtime/
README.md CHANGED
@@ -1,13 +1,170 @@
1
  ---
2
- title: Astera CustomerAI
3
- emoji: 😻
4
- colorFrom: pink
5
  colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Astera Customer AI
3
+ emoji: ✨
4
+ colorFrom: gray
5
  colorTo: yellow
6
  sdk: gradio
7
+ sdk_version: 6.5.1
 
8
  app_file: app.py
9
  pinned: false
10
+ license: other
11
+ short_description: Private Astera-derived customer-response runtime
12
  ---
13
 
14
+ # Astera Customer AI
15
+
16
+ Private Hugging Face Space runtime for answering questions about Astera and the owner's development products.
17
+
18
+ ## Fixed purpose
19
+
20
+ This is not a generic AI, multi-AI coordinator, FAQ wall, or standalone lightweight-model agent.
21
+
22
+ It is a specialized response system that must:
23
+
24
+ - understand Japanese first questions and follow-up questions;
25
+ - preserve the user's goal, active topic, confirmed details, answered questions, and unresolved questions;
26
+ - decompose documents and multi-question messages into explicit tasks;
27
+ - search the approved KB for each task;
28
+ - bind evidence to the task it answers;
29
+ - construct an answer blueprint before any model call;
30
+ - use a lightweight model only when natural composition is necessary;
31
+ - verify coverage, evidence, topic, unsafe claims, and private information;
32
+ - allow at most one violation-targeted repair;
33
+ - create anonymized, deduplicated, review-only KB feedback candidates.
34
+
35
+ Astera itself is not executed. The runtime adapts the useful Astera/KAGRRA engineering logic for document analysis, task decomposition, search planning, evidence organization, answer integration, bounded state, and output verification.
36
+
37
+ ## Processing path
38
+
39
+ ```text
40
+ Current message
41
+ -> normalize
42
+ -> bounded session context
43
+ -> document/question decomposition
44
+ -> task classification
45
+ -> per-task KB search planning
46
+ -> Notion retrieval
47
+ -> evidence binding
48
+ -> answer blueprint
49
+ -> deterministic answer or optional model composition
50
+ -> deterministic verification
51
+ -> one bounded repair
52
+ -> generic Webhook Gateway reply event
53
+ -> app/HP receiver
54
+ ```
55
+
56
+ ## Runtime topology
57
+
58
+ ```text
59
+ Cloudflare Customer AI Edge
60
+ -> Universal Webhook Gateway generic POST /internal/events
61
+ -> Private Hugging Face Space
62
+ -> Private Hugging Face Bucket mounted at /data/customer-ai
63
+ -> Notion KB
64
+ -> Universal Webhook Gateway generic POST /internal/events
65
+ -> Cloudflare receiver
66
+ ```
67
+
68
+ The browser must not call this private Space directly.
69
+
70
+ ## Generic internal event contract
71
+
72
+ Ingress event:
73
+
74
+ - `event_type`: `customer_ai.requested`
75
+ - `source_id`: Cloudflare source identity
76
+ - `destination_ids`: includes the HF private-runtime destination
77
+ - `payload`: normalized customer message, locale, channel, session ID, and optional document
78
+
79
+ Result event:
80
+
81
+ - `event_type`: `customer_ai.completed`
82
+ - `source_id`: `hf-private-runtime`
83
+ - `destination_ids`: includes the app receiver
84
+ - `payload`: answer, citations, verification report, session state summary, and deduplicated feedback candidate references
85
+
86
+ No Customer-AI-specific route is added to the universal Gateway.
87
+
88
+ ## Runtime endpoints
89
+
90
+ - `GET /healthz`
91
+ - `GET /readyz`
92
+ - `POST /internal/events`
93
+ - `POST /internal/customer-ai`
94
+
95
+ The direct customer endpoint exists for private runtime verification and controlled internal callers. The production integration uses the generic event endpoint.
96
+
97
+ ## Authentication
98
+
99
+ The Space verifies the same generic Gateway HMAC headers used by the repository adapter:
100
+
101
+ - `X-Webhook-Timestamp`
102
+ - `X-Webhook-Nonce`
103
+ - `X-Webhook-Signature`
104
+
105
+ The signing secret is injected as `CUSTOMER_AI_HMAC_SECRET`.
106
+
107
+ ## Runtime persistence
108
+
109
+ All writable runtime state lives under `CUSTOMER_AI_DATA_ROOT`.
110
+
111
+ The deployment provisions a private HF Bucket named `G-ACE/astera-customerai-data` and mounts it at:
112
+
113
+ ```text
114
+ /data/customer-ai
115
+ ```
116
+
117
+ Persisted files include:
118
+
119
+ - session state;
120
+ - KB search cache;
121
+ - feedback candidates;
122
+ - feedback deduplication index;
123
+ - owner-review feedback JSONL.
124
+
125
+ ## Optional model
126
+
127
+ The default runtime mode is `CUSTOMER_AI_ENABLE_MODEL=0`.
128
+
129
+ The deterministic path remains operational without loading a model. When enabled, the model is pinned to:
130
+
131
+ ```text
132
+ Qwen/Qwen3-4B-Instruct-2507
133
+ revision: cdbee75f17c01a7cc42f958dc650907174af0554
134
+ ```
135
+
136
+ The model may compose language from the answer blueprint. It does not own task decomposition, KB retrieval, evidence binding, verification, or feedback approval.
137
+
138
+ ## Important environment variables
139
+
140
+ | Name | Purpose |
141
+ |---|---|
142
+ | `CUSTOMER_AI_HMAC_SECRET` | Generic Gateway request authentication |
143
+ | `CUSTOMER_AI_DATA_ROOT` | Mounted persistent data root |
144
+ | `NOTION_TOKEN` | Approved KB access |
145
+ | `NOTION_DATA_SOURCE_ID` | Approved KB data source |
146
+ | `INTERNAL_EVENT_API_URL` | Generic Gateway internal event endpoint |
147
+ | `INTERNAL_EVENT_API_TOKEN` | Gateway internal bearer token |
148
+ | `CUSTOMER_AI_ENABLE_MODEL` | Enables optional lightweight model |
149
+ | `CUSTOMER_AI_MODEL_ID` | Pinned model ID |
150
+ | `CUSTOMER_AI_MODEL_REVISION` | Pinned model revision |
151
+ | `CUSTOMER_AI_GPU_DAILY_BUDGET_SECONDS` | Internal daily GPU budget guard |
152
+ | `INTERNAL_EVENT_SOURCE_ID` | Source ID for result events |
153
+ | `INTERNAL_EVENT_RESULT_DESTINATION_ID` | Result receiver destination ID |
154
+
155
+ ## Deployment
156
+
157
+ The deployment workflow:
158
+
159
+ 1. checks out `main`;
160
+ 2. runs the complete repository verification suite;
161
+ 3. creates or reuses the private Bucket;
162
+ 4. creates or reuses the private Space;
163
+ 5. attaches the Bucket at `/data/customer-ai`;
164
+ 6. configures Space secrets and variables;
165
+ 7. uploads the current `main` source;
166
+ 8. restarts the Space;
167
+ 9. verifies Bucket write/read/delete;
168
+ 10. waits for authenticated `/healthz` and `/readyz` responses.
169
+
170
+ The deployment uses the registered Hugging Face access secret from GitHub Actions and never places GitHub in the production request path.
app.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from contextlib import asynccontextmanager
7
+
8
+ import gradio as gr
9
+ import uvicorn
10
+ from fastapi import FastAPI, HTTPException, Request
11
+ from fastapi.responses import JSONResponse
12
+
13
+ from runtime import CustomerAIService
14
+ from runtime.schemas import CloudEvent
15
+ from runtime.security import verify_hmac, verify_standard_webhook
16
+ from runtime.storage import ConflictError, NotFoundError
17
+
18
+ MAX_INTERNAL_BODY_BYTES = 10 * 1024 * 1024
19
+
20
+ logging.basicConfig(level=logging.INFO)
21
+ service = CustomerAIService()
22
+
23
+
24
+ @asynccontextmanager
25
+ async def lifespan(_: FastAPI):
26
+ await service.startup()
27
+ yield
28
+ await service.shutdown()
29
+
30
+
31
+ api = FastAPI(title="Astera Customer AI", version="1.1.0", lifespan=lifespan, docs_url=None, redoc_url=None)
32
+
33
+
34
+ @api.get("/healthz")
35
+ async def healthz() -> dict[str, str]:
36
+ return {"status": "ok"}
37
+
38
+
39
+ @api.get("/readyz")
40
+ async def readyz() -> JSONResponse:
41
+ checks = service.readiness()
42
+ ready = checks["data_root"] and checks["v8"]
43
+ return JSONResponse(status_code=200 if ready else 503, content={"ready": ready, "checks": checks})
44
+
45
+
46
+ @api.post("/internal/customer-ai/accept")
47
+ async def accept(request: Request) -> JSONResponse:
48
+ if request.headers.get("content-type", "").split(";", 1)[0] not in {"application/json", "application/cloudevents+json"}:
49
+ raise HTTPException(status_code=415, detail="unsupported_content_type")
50
+ declared = int(request.headers.get("content-length", "0") or 0)
51
+ if declared > service.settings.max_input_chars * 4:
52
+ raise HTTPException(status_code=413, detail="payload_too_large")
53
+ raw = await request.body()
54
+ if len(raw) > service.settings.max_input_chars * 4:
55
+ raise HTTPException(status_code=413, detail="payload_too_large")
56
+ webhook_id = request.headers.get("webhook-id", "")
57
+ timestamp = request.headers.get("webhook-timestamp", "")
58
+ signature = request.headers.get("webhook-signature", "")
59
+ if not verify_standard_webhook(
60
+ raw,
61
+ webhook_id,
62
+ timestamp,
63
+ signature,
64
+ service.settings.hmac_secret,
65
+ ):
66
+ raise HTTPException(status_code=401, detail="invalid_standard_webhook_signature")
67
+ try:
68
+ event = CloudEvent.model_validate_json(raw)
69
+ record, created = await service.accept(event)
70
+ except ConflictError as exc:
71
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
72
+ except (ValueError, KeyError) as exc:
73
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
74
+ return JSONResponse(status_code=202, content={"accepted": True, "created": created, "job": record})
75
+
76
+
77
+ @api.get("/internal/customer-ai/jobs/{job_id}")
78
+ async def job_status(job_id: str) -> dict:
79
+ try:
80
+ record = service.jobs.get_job(job_id)
81
+ except NotFoundError as exc:
82
+ raise HTTPException(status_code=404, detail="job_not_found") from exc
83
+ result = service.jobs.get_result(job_id)
84
+ return {"job": record.model_dump(mode="json"), "result": result}
85
+
86
+
87
+ @api.post("/internal/kb/sync")
88
+ async def kb_sync(request: Request) -> JSONResponse:
89
+ declared = int(request.headers.get("content-length", "0") or 0)
90
+ if declared > MAX_INTERNAL_BODY_BYTES:
91
+ raise HTTPException(status_code=413, detail="payload_too_large")
92
+ raw = await request.body()
93
+ if len(raw) > MAX_INTERNAL_BODY_BYTES:
94
+ raise HTTPException(status_code=413, detail="payload_too_large")
95
+ if not verify_hmac(
96
+ raw,
97
+ request.headers.get("x-webhook-timestamp", ""),
98
+ request.headers.get("x-webhook-signature", ""),
99
+ service.settings.hmac_secret,
100
+ ):
101
+ raise HTTPException(status_code=401, detail="invalid_signature")
102
+ payload = json.loads(raw)
103
+ pages = payload.get("pages")
104
+ version = str(payload.get("version", ""))
105
+ if not version:
106
+ raise HTTPException(status_code=422, detail="version_required")
107
+ if pages is None:
108
+ result = await service.sync_notion_kb(version)
109
+ elif isinstance(pages, list):
110
+ info = await asyncio.to_thread(service.kb.build_snapshot, version=version, pages=pages)
111
+ service.kb.open()
112
+ result = {"version": info.version, "path": str(info.path), "source_pages": len(pages)}
113
+ else:
114
+ raise HTTPException(status_code=422, detail="pages_must_be_list")
115
+ return JSONResponse(status_code=202, content=result)
116
+
117
+
118
+ @api.post("/internal/recovery/run")
119
+ async def recovery(request: Request) -> dict:
120
+ raw = await request.body()
121
+ if not verify_hmac(
122
+ raw,
123
+ request.headers.get("x-webhook-timestamp", ""),
124
+ request.headers.get("x-webhook-signature", ""),
125
+ service.settings.hmac_secret,
126
+ ):
127
+ raise HTTPException(status_code=401, detail="invalid_signature")
128
+ result = await service.recover_once()
129
+ return {"accepted": True, **result}
130
+
131
+
132
+ async def process_job(job_id: str) -> dict:
133
+ return await service.process_job(job_id)
134
+
135
+
136
+ with gr.Blocks(title="Astera Customer AI") as demo:
137
+ gr.Markdown("# Astera Customer AI\nPrivate runtime. Use the Astera HP or app.")
138
+ job_id = gr.Textbox(label="Job ID", visible=False)
139
+ output = gr.JSON(visible=False)
140
+ trigger = gr.Button("Process", visible=False)
141
+ trigger.click(
142
+ fn=process_job,
143
+ inputs=job_id,
144
+ outputs=output,
145
+ api_name="customer_ai_process",
146
+ api_description="Process one durably accepted Customer AI job by ID.",
147
+ queue=True,
148
+ concurrency_limit=2,
149
+ api_visibility="private",
150
+ )
151
+
152
+ app = gr.mount_gradio_app(api, demo, path="/")
153
+
154
+ if __name__ == "__main__":
155
+ uvicorn.run(app, host="0.0.0.0", port=7860, log_level="info")
package.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "astera-customer-ai-v8-runtime",
3
+ "version": "1.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "engines": {"node": ">=22.0.0"},
7
+ "scripts": {
8
+ "test": "node --test v8/test/*.test.mjs",
9
+ "test:edge": "node --test edge/test/*.test.mjs",
10
+ "check:edge": "node --check edge/src/worker.js"
11
+ }
12
+ }
packages.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ git
2
+ xz-utils
3
+ ca-certificates
pyproject.toml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "astera-customer-ai"
3
+ version = "1.1.0"
4
+ requires-python = ">=3.11"
5
+
6
+ [tool.pytest.ini_options]
7
+ addopts = "-q --strict-markers"
8
+ testpaths = ["tests"]
9
+ asyncio_mode = "auto"
10
+
11
+ [tool.ruff]
12
+ line-length = 120
13
+ target-version = "py311"
14
+
15
+ [tool.ruff.lint]
16
+ select = ["E", "F", "I", "B", "UP", "SIM"]
17
+ ignore = ["E501"]
18
+
19
+ [tool.mypy]
20
+ python_version = "3.11"
21
+ warn_unused_configs = true
22
+ check_untyped_defs = true
23
+ no_implicit_optional = true
requirements-dev.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ -r requirements.txt
2
+ pytest==9.0.2
3
+ pytest-asyncio>=1.0,<2
4
+ ruff>=0.12,<1
5
+ mypy>=1.17,<2
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio==6.5.1
2
+ fastapi==0.128.2
3
+ pydantic==2.13.4
4
+ uvicorn==0.48.0
5
+ httpx>=0.28,<1
6
+ huggingface_hub==1.16.1
7
+ spaces>=0.41,<1
8
+ transformers>=4.57,<6
9
+ safetensors>=0.5,<1
10
+ torch>=2.8,<3
runtime/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .service import CustomerAIService
2
+
3
+ __all__ = ["CustomerAIService"]
runtime/config.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ def _int(name: str, default: int) -> int:
9
+ value = int(os.getenv(name, str(default)))
10
+ if value <= 0:
11
+ raise ValueError(f"{name} must be positive")
12
+ return value
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class Settings:
17
+ data_root: Path
18
+ hmac_secret: str
19
+ internal_event_api_url: str
20
+ internal_event_api_token: str
21
+ internal_event_source_id: str
22
+ internal_event_result_destination_id: str
23
+ notion_token: str
24
+ notion_data_source_id: str
25
+ model_id: str
26
+ model_revision: str
27
+ enable_model: bool
28
+ gpu_daily_budget_seconds: int
29
+ node_binary: str
30
+ node_socket: Path
31
+ node_memory_mb: int
32
+ job_lease_seconds: int
33
+ session_lease_seconds: int
34
+ max_input_chars: int
35
+ process_concurrency: int
36
+ internal_event_api_timeout_seconds: int
37
+ session_cache_ttl_seconds: int
38
+ session_cache_max_sessions: int
39
+ session_cache_max_turns: int
40
+ kb_cache_ttl_seconds: int
41
+ kb_cache_max_entries: int
42
+
43
+ @classmethod
44
+ def load(cls) -> "Settings":
45
+ root = Path(os.getenv("CUSTOMER_AI_DATA_ROOT", "/data/customer-ai")).resolve()
46
+ return cls(
47
+ data_root=root,
48
+ hmac_secret=os.getenv("CUSTOMER_AI_HMAC_SECRET", ""),
49
+ internal_event_api_url=os.getenv("INTERNAL_EVENT_API_URL", ""),
50
+ internal_event_api_token=os.getenv("INTERNAL_EVENT_API_TOKEN", ""),
51
+ internal_event_source_id=os.getenv("INTERNAL_EVENT_SOURCE_ID", "hf-private-runtime"),
52
+ internal_event_result_destination_id=os.getenv(
53
+ "INTERNAL_EVENT_RESULT_DESTINATION_ID", ""
54
+ ),
55
+ notion_token=os.getenv("NOTION_TOKEN", ""),
56
+ notion_data_source_id=os.getenv("NOTION_DATA_SOURCE_ID", ""),
57
+ model_id=os.getenv("CUSTOMER_AI_MODEL_ID", "Qwen/Qwen3-4B-Instruct-2507"),
58
+ model_revision=os.getenv("CUSTOMER_AI_MODEL_REVISION", ""),
59
+ enable_model=os.getenv("CUSTOMER_AI_ENABLE_MODEL", "0") == "1",
60
+ gpu_daily_budget_seconds=_int("CUSTOMER_AI_GPU_DAILY_BUDGET_SECONDS", 2100),
61
+ node_binary=os.getenv("CUSTOMER_AI_NODE_BINARY", "node"),
62
+ node_socket=Path(os.getenv("CUSTOMER_AI_NODE_SOCKET", "/tmp/customer-ai-v8.sock")),
63
+ node_memory_mb=_int("CUSTOMER_AI_NODE_MEMORY_MB", 384),
64
+ job_lease_seconds=_int("CUSTOMER_AI_JOB_LEASE_SECONDS", 90),
65
+ session_lease_seconds=_int("CUSTOMER_AI_SESSION_LEASE_SECONDS", 90),
66
+ max_input_chars=_int("CUSTOMER_AI_MAX_INPUT_CHARS", 20000),
67
+ process_concurrency=_int("CUSTOMER_AI_PROCESS_CONCURRENCY", 2),
68
+ internal_event_api_timeout_seconds=_int("INTERNAL_EVENT_API_TIMEOUT_SECONDS", 10),
69
+ session_cache_ttl_seconds=_int("CUSTOMER_AI_SESSION_CACHE_TTL_SECONDS", 1800),
70
+ session_cache_max_sessions=_int("CUSTOMER_AI_SESSION_CACHE_MAX_SESSIONS", 256),
71
+ session_cache_max_turns=_int("CUSTOMER_AI_SESSION_CACHE_MAX_TURNS", 12),
72
+ kb_cache_ttl_seconds=_int("CUSTOMER_AI_KB_CACHE_TTL_SECONDS", 120),
73
+ kb_cache_max_entries=_int("CUSTOMER_AI_KB_CACHE_MAX_ENTRIES", 256),
74
+ )
75
+
76
+ def ensure_directories(self) -> None:
77
+ for name in ("jobs", "sessions", "kb/snapshots", "runtime", "temporary"):
78
+ (self.data_root / name).mkdir(parents=True, exist_ok=True)
runtime/control.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from typing import Any, Callable
6
+
7
+ from .conversation import ConversationCache
8
+ from .schemas import SessionContext
9
+ from .security import contains_internal_implementation, redact_text
10
+ from .support import FeedbackStore, PreparedSupport, SupportRuntime, validate_response
11
+ from .v8 import V8Unavailable
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class ConversationOutcome:
16
+ status: str
17
+ answer: str
18
+ kb_ids: list[str]
19
+ facts: list[str]
20
+ engine_invoked: bool
21
+ clarification: str | None
22
+ context_used: bool
23
+ analysis: dict[str, Any]
24
+ violations: list[str]
25
+ processing_grade: str
26
+ question_tasks: list[dict[str, Any]]
27
+ blueprint: dict[str, Any]
28
+ repair_attempted: bool
29
+ feedback_candidate_id: str | None
30
+ execution: dict[str, Any]
31
+
32
+
33
+ class ConversationCore:
34
+ """Astera-derived support pipeline specialized for Customer AI response handling."""
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ v8: Any,
40
+ engine: Any,
41
+ cache: ConversationCache,
42
+ search: Callable[..., list[Any]],
43
+ feedback_store: FeedbackStore | None = None,
44
+ ) -> None:
45
+ self.v8 = v8
46
+ self.engine = engine
47
+ self.cache = cache
48
+ self.search = search
49
+ self.support = SupportRuntime(search=search)
50
+ self.feedback_store = feedback_store
51
+
52
+ async def execute(self, *, request: Any) -> ConversationOutcome:
53
+ context = self.cache.get(request.session_id)
54
+ compact_context = self.cache.compact(context)
55
+ analysis = await self._analyze(request.message, compact_context, request.source)
56
+ prepared = await self.support.prepare(
57
+ message=request.message,
58
+ locale=request.locale,
59
+ source=request.source,
60
+ context=compact_context,
61
+ analysis=analysis,
62
+ )
63
+
64
+ answer, answered_task_ids, unresolved_task_ids, used_evidence_ids = self._deterministic_state(prepared)
65
+ returned_goal = str(analysis.get("user_goal") or context.user_goal or request.message).strip()
66
+ returned_topic = str(analysis.get("active_topic") or context.active_topic or "general").strip()
67
+ engine_invoked = False
68
+ repair_attempted = False
69
+ first_attempt_violations: list[str] = []
70
+
71
+ packet = self._engine_packet(
72
+ request=request,
73
+ compact_context=compact_context,
74
+ prepared=prepared,
75
+ analysis=analysis,
76
+ )
77
+ if prepared.model_required and prepared.evidence and self.engine.available():
78
+ generated = await self._execute_engine(packet)
79
+ if generated is not None:
80
+ answer, returned_goal, returned_topic, answered_task_ids, unresolved_task_ids, used_evidence_ids = self._apply_engine_output(
81
+ generated=generated,
82
+ fallback_answer=answer,
83
+ returned_goal=returned_goal,
84
+ returned_topic=returned_topic,
85
+ default_answered=answered_task_ids,
86
+ default_unresolved=unresolved_task_ids,
87
+ default_evidence=used_evidence_ids,
88
+ )
89
+ engine_invoked = True
90
+
91
+ verification = await self._verify_response(
92
+ answer=answer,
93
+ analysis=analysis,
94
+ prepared=prepared,
95
+ returned_topic=returned_topic,
96
+ answered_task_ids=answered_task_ids,
97
+ unresolved_task_ids=unresolved_task_ids,
98
+ used_evidence_ids=used_evidence_ids,
99
+ )
100
+ validation = validate_response(
101
+ answer=answer,
102
+ prepared=prepared,
103
+ answered_task_ids=answered_task_ids,
104
+ unresolved_task_ids=unresolved_task_ids,
105
+ used_evidence_ids=used_evidence_ids,
106
+ external_violations=verification.get("violations") or [],
107
+ )
108
+
109
+ if not validation.passed and engine_invoked:
110
+ first_attempt_violations = list(validation.violations)
111
+ repair_attempted = True
112
+ repair_packet = {
113
+ **packet,
114
+ "repair": {
115
+ "attempt": 1,
116
+ "previous_answer": answer[:12000],
117
+ "violations": validation.violations,
118
+ "required_answered_task_ids": [item.task_id for item in prepared.tasks if item.task_id not in prepared.blueprint.get("unresolved_task_ids", [])],
119
+ "required_unresolved_task_ids": list(prepared.blueprint.get("unresolved_task_ids", [])),
120
+ "allowed_evidence_ids": [item.evidence_id for item in prepared.evidence],
121
+ },
122
+ }
123
+ repaired = await self._execute_engine(repair_packet)
124
+ if repaired is not None:
125
+ answer, returned_goal, returned_topic, answered_task_ids, unresolved_task_ids, used_evidence_ids = self._apply_engine_output(
126
+ generated=repaired,
127
+ fallback_answer=prepared.blueprint["deterministic_answer"],
128
+ returned_goal=returned_goal,
129
+ returned_topic=returned_topic,
130
+ default_answered=[section["task_id"] for section in prepared.blueprint["sections"] if section["resolved"]],
131
+ default_unresolved=list(prepared.blueprint.get("unresolved_task_ids", [])),
132
+ default_evidence=list(prepared.blueprint.get("evidence_ids", [])),
133
+ )
134
+ verification = await self._verify_response(
135
+ answer=answer,
136
+ analysis=analysis,
137
+ prepared=prepared,
138
+ returned_topic=returned_topic,
139
+ answered_task_ids=answered_task_ids,
140
+ unresolved_task_ids=unresolved_task_ids,
141
+ used_evidence_ids=used_evidence_ids,
142
+ )
143
+ validation = validate_response(
144
+ answer=answer,
145
+ prepared=prepared,
146
+ answered_task_ids=answered_task_ids,
147
+ unresolved_task_ids=unresolved_task_ids,
148
+ used_evidence_ids=used_evidence_ids,
149
+ external_violations=verification.get("violations") or [],
150
+ )
151
+
152
+ if not validation.passed:
153
+ answer, answered_task_ids, unresolved_task_ids, used_evidence_ids = self._deterministic_state(prepared)
154
+ verification = await self._verify_response(
155
+ answer=answer,
156
+ analysis=analysis,
157
+ prepared=prepared,
158
+ returned_topic=str(analysis.get("active_topic") or returned_topic),
159
+ answered_task_ids=answered_task_ids,
160
+ unresolved_task_ids=unresolved_task_ids,
161
+ used_evidence_ids=used_evidence_ids,
162
+ )
163
+ validation = validate_response(
164
+ answer=answer,
165
+ prepared=prepared,
166
+ answered_task_ids=answered_task_ids,
167
+ unresolved_task_ids=unresolved_task_ids,
168
+ used_evidence_ids=used_evidence_ids,
169
+ external_violations=verification.get("violations") or [],
170
+ )
171
+ returned_topic = str(analysis.get("active_topic") or returned_topic)
172
+ engine_invoked = False
173
+
174
+ answer = redact_text(answer).text.strip()
175
+ if contains_internal_implementation(answer):
176
+ answer = (
177
+ "内部実装の非公開部分ではなく、利用方法、仕様、問題解決に必要な範囲で具体的に説明します。"
178
+ if request.locale == "ja-JP"
179
+ else "I can explain supported use, specifications, and resolution steps without exposing private implementation details."
180
+ )
181
+ validation.violations.append("internal_implementation")
182
+ unresolved_task_ids = list(dict.fromkeys([*unresolved_task_ids, *[item.task_id for item in prepared.tasks]]))
183
+ answered_task_ids = []
184
+
185
+ clarification = self._clarification(prepared, unresolved_task_ids)
186
+ status = "awaiting_clarification" if unresolved_task_ids else "completed"
187
+ question_ledger = self._updated_ledger(context, request.message_id, prepared, answered_task_ids, unresolved_task_ids)
188
+ evidence_cache = self._updated_evidence_cache(context, prepared)
189
+ answered_ledger_ids = [
190
+ f"{request.message_id}:{task_id}" for task_id in answered_task_ids
191
+ ]
192
+ updated = SessionContext(
193
+ session_id=request.session_id,
194
+ user_goal=returned_goal[:1000],
195
+ active_topic=returned_topic[:160],
196
+ confirmed_details=dict(analysis.get("confirmed_details") or context.confirmed_details),
197
+ unresolved_questions=[
198
+ item.text for item in prepared.tasks if item.task_id in unresolved_task_ids
199
+ ],
200
+ last_kb_ids=[item.kb_id for item in prepared.evidence],
201
+ turns=context.turns,
202
+ answered_question_ids=list(dict.fromkeys([*context.answered_question_ids, *answered_ledger_ids])),
203
+ question_ledger=question_ledger,
204
+ evidence_cache=evidence_cache,
205
+ last_blueprint=prepared.blueprint,
206
+ )
207
+ updated = self.cache.append_turns(
208
+ updated,
209
+ user_text=request.message,
210
+ assistant_text=answer,
211
+ message_id=request.message_id,
212
+ kb_ids=[item.kb_id for item in prepared.evidence],
213
+ )
214
+ self.cache.save(updated)
215
+
216
+ candidate_id = None
217
+ if self.feedback_store is not None:
218
+ candidate_id = self.feedback_store.record(
219
+ session_id=request.session_id,
220
+ message=request.message,
221
+ prepared=prepared,
222
+ validation=validation,
223
+ status=status,
224
+ )
225
+
226
+ final_violations = list(dict.fromkeys(validation.violations))
227
+ execution = {
228
+ "pipeline": "astera-derived-document-task-search-evidence-blueprint",
229
+ "processing_grade": prepared.processing_grade,
230
+ "question_count": len(prepared.tasks),
231
+ "search_task_count": len(prepared.search_tasks),
232
+ "evidence_count": len(prepared.evidence),
233
+ "engine_required": prepared.model_required,
234
+ "engine_invoked": engine_invoked,
235
+ "repair_attempted": repair_attempted,
236
+ "repair_limit": 1,
237
+ "first_attempt_violations": first_attempt_violations,
238
+ "answered_task_ids": answered_task_ids,
239
+ "unresolved_task_ids": unresolved_task_ids,
240
+ "used_evidence_ids": used_evidence_ids,
241
+ "feedback_candidate_id": candidate_id,
242
+ }
243
+ return ConversationOutcome(
244
+ status=status,
245
+ answer=answer,
246
+ kb_ids=[item.kb_id for item in prepared.evidence],
247
+ facts=[item.short_answer for item in prepared.evidence],
248
+ engine_invoked=engine_invoked,
249
+ clarification=clarification,
250
+ context_used=bool(analysis.get("context_used")),
251
+ analysis=analysis | {"analysis_dictionary": prepared.analysis_dictionary},
252
+ violations=final_violations,
253
+ processing_grade=prepared.processing_grade,
254
+ question_tasks=[item.as_dict() for item in prepared.tasks],
255
+ blueprint=prepared.blueprint,
256
+ repair_attempted=repair_attempted,
257
+ feedback_candidate_id=candidate_id,
258
+ execution=execution,
259
+ )
260
+
261
+ async def _analyze(self, message: str, context: dict[str, Any], source: str) -> dict[str, Any]:
262
+ try:
263
+ return await self.v8.request("analyze_turn", {"message": message, "context": context, "source": source})
264
+ except V8Unavailable:
265
+ active_topic = str(context.get("active_topic") or "general")
266
+ goal = str(context.get("user_goal") or message)
267
+ query = " ".join(item for item in (message, goal, active_topic) if item and item != "general")
268
+ return {
269
+ "message": message,
270
+ "follow_up": bool(context.get("user_goal")),
271
+ "context_used": bool(context.get("user_goal") or context.get("turns")),
272
+ "active_topic": active_topic,
273
+ "user_goal": goal,
274
+ "confirmed_details": dict(context.get("confirmed_details") or {}),
275
+ "retrieval_query": query,
276
+ }
277
+
278
+ async def _execute_engine(self, packet: dict[str, Any]) -> dict[str, Any] | None:
279
+ try:
280
+ return await asyncio.to_thread(self.engine.execute, packet)
281
+ except Exception:
282
+ return None
283
+
284
+ async def _verify_response(
285
+ self,
286
+ *,
287
+ answer: str,
288
+ analysis: dict[str, Any],
289
+ prepared: PreparedSupport,
290
+ returned_topic: str,
291
+ answered_task_ids: list[str],
292
+ unresolved_task_ids: list[str],
293
+ used_evidence_ids: list[str],
294
+ ) -> dict[str, Any]:
295
+ payload = {
296
+ "answer": answer,
297
+ "analysis": analysis,
298
+ "returned_topic": returned_topic,
299
+ "question_task_ids": [item.task_id for item in prepared.tasks],
300
+ "answered_task_ids": answered_task_ids,
301
+ "unresolved_task_ids": unresolved_task_ids,
302
+ "used_evidence_ids": used_evidence_ids,
303
+ "available_evidence_ids": [item.evidence_id for item in prepared.evidence],
304
+ }
305
+ try:
306
+ return await self.v8.request("verify_turn", payload)
307
+ except V8Unavailable:
308
+ return {"answer": answer, "passed": bool(answer.strip()), "violations": [] if answer.strip() else ["empty_answer"]}
309
+
310
+ @staticmethod
311
+ def _engine_packet(
312
+ *,
313
+ request: Any,
314
+ compact_context: dict[str, Any],
315
+ prepared: PreparedSupport,
316
+ analysis: dict[str, Any],
317
+ ) -> dict[str, Any]:
318
+ return {
319
+ "message": prepared.normalized_message[:8000],
320
+ "conversation": compact_context,
321
+ "support_packet": prepared.as_packet(),
322
+ "analysis": analysis,
323
+ "response_rules": {
324
+ "answer_only_from_supplied_evidence": True,
325
+ "answer_each_question_task": True,
326
+ "preserve_user_goal_and_topic": True,
327
+ "do_not_repeat_answered_questions": True,
328
+ "ask_only_for_specific_missing_information": True,
329
+ "do_not_claim_unexecuted_actions": True,
330
+ "do_not_expose_private_implementation": True,
331
+ "locale": request.locale,
332
+ },
333
+ }
334
+
335
+ @staticmethod
336
+ def _apply_engine_output(
337
+ *,
338
+ generated: dict[str, Any],
339
+ fallback_answer: str,
340
+ returned_goal: str,
341
+ returned_topic: str,
342
+ default_answered: list[str],
343
+ default_unresolved: list[str],
344
+ default_evidence: list[str],
345
+ ) -> tuple[str, str, str, list[str], list[str], list[str]]:
346
+ answer = str(generated.get("answer") or fallback_answer).strip()
347
+ goal = str(generated.get("user_goal") or returned_goal).strip()
348
+ topic = str(generated.get("active_topic") or returned_topic).strip()
349
+ answered = [str(item) for item in generated.get("answered_task_ids", default_answered) if str(item)]
350
+ unresolved = [str(item) for item in generated.get("unresolved_task_ids", default_unresolved) if str(item)]
351
+ evidence = [str(item) for item in generated.get("used_evidence_ids", default_evidence) if str(item)]
352
+ return answer, goal, topic, answered, unresolved, evidence
353
+
354
+ @staticmethod
355
+ def _deterministic_state(prepared: PreparedSupport) -> tuple[str, list[str], list[str], list[str]]:
356
+ answered = [section["task_id"] for section in prepared.blueprint["sections"] if section["resolved"]]
357
+ unresolved = list(prepared.blueprint.get("unresolved_task_ids", []))
358
+ return (
359
+ str(prepared.blueprint.get("deterministic_answer") or "").strip(),
360
+ answered,
361
+ unresolved,
362
+ list(prepared.blueprint.get("evidence_ids", [])),
363
+ )
364
+
365
+ @staticmethod
366
+ def _clarification(prepared: PreparedSupport, unresolved_task_ids: list[str]) -> str | None:
367
+ if not unresolved_task_ids:
368
+ return None
369
+ sections = [
370
+ section["body"]
371
+ for section in prepared.blueprint.get("sections", [])
372
+ if section.get("task_id") in unresolved_task_ids and section.get("body")
373
+ ]
374
+ return "\n\n".join(dict.fromkeys(sections)) or None
375
+
376
+ @staticmethod
377
+ def _updated_ledger(
378
+ context: SessionContext,
379
+ message_id: str,
380
+ prepared: PreparedSupport,
381
+ answered_task_ids: list[str],
382
+ unresolved_task_ids: list[str],
383
+ ) -> list[dict[str, Any]]:
384
+ new_rows = []
385
+ for task in prepared.tasks:
386
+ new_rows.append(
387
+ {
388
+ "ledger_id": f"{message_id}:{task.task_id}",
389
+ "message_id": message_id,
390
+ "task": task.as_dict(),
391
+ "status": "answered" if task.task_id in answered_task_ids else "unresolved" if task.task_id in unresolved_task_ids else "pending",
392
+ "evidence_ids": [item.evidence_id for item in prepared.evidence if task.task_id in item.task_ids],
393
+ }
394
+ )
395
+ return [*context.question_ledger, *new_rows][-24:]
396
+
397
+ @staticmethod
398
+ def _updated_evidence_cache(context: SessionContext, prepared: PreparedSupport) -> dict[str, dict[str, Any]]:
399
+ cache = dict(context.evidence_cache)
400
+ for item in prepared.evidence:
401
+ cache[item.evidence_id] = {
402
+ "kb_id": item.kb_id,
403
+ "question": item.question[:600],
404
+ "short_answer": item.short_answer[:1200],
405
+ "answer_boundary": item.answer_boundary[:600],
406
+ "task_ids": item.task_ids,
407
+ }
408
+ return dict(list(cache.items())[-16:])
409
+
410
+
411
+ ControlledExecutionCore = ConversationCore
412
+ ControlOutcome = ConversationOutcome
runtime/conversation.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import time
5
+ from collections import OrderedDict
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .schemas import ConversationTurn, SessionContext
11
+ from .storage import AtomicStore
12
+
13
+
14
+ class ConversationCache:
15
+ """Bounded in-memory LRU backed by one persistent session context file."""
16
+
17
+ def __init__(self, root: Path, *, ttl_seconds: int, max_sessions: int, max_turns: int):
18
+ self.root = root / "sessions"
19
+ self.store = AtomicStore(root)
20
+ self.ttl_seconds = ttl_seconds
21
+ self.max_sessions = max_sessions
22
+ self.max_turns = max_turns
23
+ self._memory: OrderedDict[str, tuple[float, SessionContext]] = OrderedDict()
24
+
25
+ def get(self, session_id: str) -> SessionContext:
26
+ now = time.monotonic()
27
+ cached = self._memory.get(session_id)
28
+ if cached and cached[0] > now:
29
+ self._memory.move_to_end(session_id)
30
+ return SessionContext.model_validate(copy.deepcopy(cached[1].model_dump(mode="json")))
31
+ if cached:
32
+ self._memory.pop(session_id, None)
33
+
34
+ path = self._path(session_id)
35
+ if path.exists():
36
+ context = SessionContext.model_validate(self.store.get_json(path))
37
+ else:
38
+ context = SessionContext(session_id=session_id)
39
+ self._remember(context)
40
+ return SessionContext.model_validate(copy.deepcopy(context.model_dump(mode="json")))
41
+
42
+ def save(self, context: SessionContext) -> None:
43
+ evidence_items = list(context.evidence_cache.items())[-16:]
44
+ trimmed = context.model_copy(
45
+ update={
46
+ "turns": context.turns[-self.max_turns :],
47
+ "unresolved_questions": context.unresolved_questions[-16:],
48
+ "last_kb_ids": context.last_kb_ids[-16:],
49
+ "answered_question_ids": context.answered_question_ids[-32:],
50
+ "question_ledger": context.question_ledger[-24:],
51
+ "evidence_cache": dict(evidence_items),
52
+ "last_blueprint": _bound_structure(context.last_blueprint, max_chars=16000),
53
+ "updated_at": datetime.now(UTC),
54
+ }
55
+ )
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,
62
+ *,
63
+ user_text: str,
64
+ assistant_text: str,
65
+ message_id: str,
66
+ kb_ids: list[str],
67
+ ) -> SessionContext:
68
+ turns = [
69
+ *context.turns,
70
+ ConversationTurn(role="user", text=user_text[:8000], message_id=message_id),
71
+ ConversationTurn(role="assistant", text=assistant_text[:8000], message_id=message_id, kb_ids=kb_ids),
72
+ ]
73
+ return context.model_copy(update={"turns": turns[-self.max_turns :]})
74
+
75
+ def compact(self, context: SessionContext, *, last_turns: int = 6) -> dict[str, Any]:
76
+ turns = []
77
+ for turn in context.turns[-last_turns:]:
78
+ turns.append(
79
+ {
80
+ "role": turn.role,
81
+ "text": turn.text[:1200],
82
+ "message_id": turn.message_id,
83
+ "kb_ids": turn.kb_ids[-4:],
84
+ }
85
+ )
86
+ evidence = {
87
+ key: _bound_structure(value, max_chars=1600)
88
+ for key, value in list(context.evidence_cache.items())[-8:]
89
+ }
90
+ return {
91
+ "user_goal": context.user_goal[:1000],
92
+ "active_topic": context.active_topic[:160],
93
+ "confirmed_details": _bound_structure(context.confirmed_details, max_chars=4000),
94
+ "unresolved_questions": [item[:500] for item in context.unresolved_questions[-8:]],
95
+ "last_kb_ids": context.last_kb_ids[-8:],
96
+ "answered_question_ids": context.answered_question_ids[-16:],
97
+ "question_ledger": [_bound_structure(item, max_chars=1000) for item in context.question_ledger[-12:]],
98
+ "evidence_cache": evidence,
99
+ "last_blueprint": _bound_structure(context.last_blueprint, max_chars=5000),
100
+ "turns": turns,
101
+ }
102
+
103
+ def status(self) -> dict[str, int]:
104
+ return {
105
+ "memory_sessions": len(self._memory),
106
+ "max_sessions": self.max_sessions,
107
+ "max_turns": self.max_turns,
108
+ }
109
+
110
+ def _path(self, session_id: str) -> Path:
111
+ return self.root / session_id / "context.json"
112
+
113
+ def _remember(self, context: SessionContext) -> None:
114
+ self._memory[context.session_id] = (time.monotonic() + self.ttl_seconds, context)
115
+ self._memory.move_to_end(context.session_id)
116
+ while len(self._memory) > self.max_sessions:
117
+ self._memory.popitem(last=False)
118
+
119
+
120
+ def _bound_structure(value: Any, *, max_chars: int) -> Any:
121
+ """Bound persistent helper state without converting it into an unbounded transcript."""
122
+ if isinstance(value, str):
123
+ return value[:max_chars]
124
+ if isinstance(value, list):
125
+ return [_bound_structure(item, max_chars=max(128, max_chars // max(1, min(len(value), 16)))) for item in value[-32:]]
126
+ if isinstance(value, dict):
127
+ bounded: dict[str, Any] = {}
128
+ per_item = max(128, max_chars // max(1, min(len(value), 32)))
129
+ for key, item in list(value.items())[-32:]:
130
+ bounded[str(key)[:160]] = _bound_structure(item, max_chars=per_item)
131
+ return bounded
132
+ return value
runtime/kb.py ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ import tempfile
7
+ import threading
8
+ import time
9
+ from collections import OrderedDict
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Iterable
13
+
14
+ from .schemas import KBHit
15
+ from .security import contains_internal_implementation, redact_text
16
+
17
+
18
+ SCHEMA = """
19
+ CREATE TABLE IF NOT EXISTS kb_pages (
20
+ kb_id TEXT PRIMARY KEY,
21
+ question TEXT NOT NULL,
22
+ short_answer TEXT NOT NULL,
23
+ body TEXT NOT NULL,
24
+ search_terms TEXT NOT NULL DEFAULT '',
25
+ answer_boundary TEXT NOT NULL DEFAULT '',
26
+ target TEXT NOT NULL DEFAULT '',
27
+ public_status TEXT NOT NULL,
28
+ implementation_status TEXT NOT NULL,
29
+ needs_review INTEGER NOT NULL DEFAULT 0,
30
+ source_url TEXT NOT NULL DEFAULT '',
31
+ verified_at TEXT NOT NULL DEFAULT ''
32
+ );
33
+ CREATE VIRTUAL TABLE IF NOT EXISTS kb_fts USING fts5(
34
+ kb_id UNINDEXED,
35
+ question,
36
+ short_answer,
37
+ body,
38
+ search_terms,
39
+ tokenize='unicode61'
40
+ );
41
+ """
42
+ MIN_EVIDENCE_SCORE = 5.0
43
+ QUERY_ALIASES: dict[str, tuple[str, ...]] = {
44
+ "credit": ("クレジット", "残高", "付与", "決済"),
45
+ "billing": ("料金", "価格", "費用", "プラン"),
46
+ "account": ("アカウント", "ログイン", "認証", "退会", "削除"),
47
+ "webhook-gateway": ("Webhook Gateway", "webhook", "配送", "再送", "復旧"),
48
+ "astera": ("Astera", "アステラ", "判断材料", "主役AI"),
49
+ "api": ("API", "APIキー", "連携", "エンドポイント"),
50
+ "procedure": ("方法", "手順", "確認"),
51
+ "troubleshooting": ("不具合", "エラー", "反映されない", "確認"),
52
+ "pricing": ("料金", "価格", "いくら", "費用"),
53
+ "comparison": ("違い", "比較"),
54
+ }
55
+ QUERY_STOP_TERMS = {
56
+ "すか",
57
+ "ですか",
58
+ "ますか",
59
+ "現在",
60
+ "system",
61
+ "prompt",
62
+ "internal",
63
+ "admin",
64
+ "env",
65
+ "内容",
66
+ "全部",
67
+ "全部出して",
68
+ "教えて",
69
+ "ください",
70
+ }
71
+
72
+
73
+ @dataclass(slots=True)
74
+ class SnapshotInfo:
75
+ version: str
76
+ path: Path
77
+
78
+
79
+ class KBIndex:
80
+ def __init__(self, root: Path, *, cache_ttl_seconds: int = 120, cache_max_entries: int = 256):
81
+ self.root = root / "kb"
82
+ self.root.mkdir(parents=True, exist_ok=True)
83
+ self._connection: sqlite3.Connection | None = None
84
+ self._version: str | None = None
85
+ self._cache_ttl_seconds = cache_ttl_seconds
86
+ self._cache_max_entries = cache_max_entries
87
+ self._query_cache: OrderedDict[tuple[str, str, int], tuple[float, list[KBHit]]] = OrderedDict()
88
+ self._lock = threading.RLock()
89
+
90
+ def _manifest_path(self) -> Path:
91
+ return self.root / "manifest.json"
92
+
93
+ def current(self) -> SnapshotInfo | None:
94
+ with self._lock:
95
+ path = self._manifest_path()
96
+ if not path.exists():
97
+ return None
98
+ manifest = json.loads(path.read_text(encoding="utf-8"))
99
+ snapshot = self.root / "snapshots" / manifest["version"] / "kb.sqlite"
100
+ return SnapshotInfo(version=manifest["version"], path=snapshot)
101
+
102
+ def open(self) -> bool:
103
+ with self._lock:
104
+ info = self.current()
105
+ if not info or not info.path.exists():
106
+ return False
107
+ if self._connection is not None and self._version == info.version:
108
+ return True
109
+ new = sqlite3.connect(f"file:{info.path}?mode=ro&immutable=1", uri=True, check_same_thread=False)
110
+ new.row_factory = sqlite3.Row
111
+ old = self._connection
112
+ self._connection = new
113
+ self._version = info.version
114
+ self._query_cache.clear()
115
+ if old is not None:
116
+ old.close()
117
+ return True
118
+
119
+ def search(self, query: str, *, limit: int = 5) -> list[KBHit]:
120
+ with self._lock:
121
+ if not self.open() or self._connection is None or not self._version:
122
+ return []
123
+ normalized_query = " ".join(query.split())[:1000]
124
+ expanded_terms = self._expand_terms(normalized_query)
125
+ expanded_query = " ".join(expanded_terms)
126
+ cache_key = (self._version, expanded_query, limit)
127
+ now = time.monotonic()
128
+ cached = self._query_cache.get(cache_key)
129
+ if cached and cached[0] > now:
130
+ self._query_cache.move_to_end(cache_key)
131
+ return [KBHit.model_validate(item.model_dump()) for item in cached[1]]
132
+ if cached:
133
+ self._query_cache.pop(cache_key, None)
134
+
135
+ terms = [part.strip().replace("%", "").replace("_", "") for part in expanded_terms if part.strip()][:20]
136
+ if not terms:
137
+ return []
138
+ score_parts: list[str] = []
139
+ where_parts: list[str] = []
140
+ score_params: list[Any] = []
141
+ where_params: list[Any] = []
142
+ for term in terms:
143
+ pattern = f"%{term}%"
144
+ score_parts.append(
145
+ "(CASE WHEN question LIKE ? THEN 12 ELSE 0 END + "
146
+ "CASE WHEN search_terms LIKE ? THEN 10 ELSE 0 END + "
147
+ "CASE WHEN short_answer LIKE ? THEN 5 ELSE 0 END + "
148
+ "CASE WHEN body LIKE ? THEN 2 ELSE 0 END)"
149
+ )
150
+ score_params.extend([pattern, pattern, pattern, pattern])
151
+ where_parts.append("(question LIKE ? OR search_terms LIKE ? OR short_answer LIKE ? OR body LIKE ?)")
152
+ where_params.extend([pattern, pattern, pattern, pattern])
153
+ sql = (
154
+ "SELECT *, (" + " + ".join(score_parts) + ") AS score FROM kb_pages WHERE "
155
+ + " OR ".join(where_parts)
156
+ + " ORDER BY score DESC, kb_id ASC LIMIT ?"
157
+ )
158
+ params = [*score_params, *where_params, limit]
159
+ rows = self._connection.execute(sql, params).fetchall()
160
+ hits: list[KBHit] = []
161
+ for row in rows:
162
+ score = float(row["score"] or 0.0)
163
+ if score < MIN_EVIDENCE_SCORE:
164
+ continue
165
+ hits.append(
166
+ KBHit(
167
+ kb_id=row["kb_id"],
168
+ question=row["question"],
169
+ short_answer=row["short_answer"],
170
+ body=row["body"],
171
+ score=score,
172
+ answer_boundary=row["answer_boundary"],
173
+ target=row["target"],
174
+ )
175
+ )
176
+ self._query_cache[cache_key] = (now + self._cache_ttl_seconds, hits)
177
+ self._query_cache.move_to_end(cache_key)
178
+ while len(self._query_cache) > self._cache_max_entries:
179
+ self._query_cache.popitem(last=False)
180
+ return [KBHit.model_validate(item.model_dump()) for item in hits]
181
+
182
+ def cache_status(self) -> dict[str, int]:
183
+ with self._lock:
184
+ return {"entries": len(self._query_cache), "max_entries": self._cache_max_entries}
185
+
186
+ def build_snapshot(self, *, version: str, pages: Iterable[dict[str, Any]]) -> SnapshotInfo:
187
+ with self._lock:
188
+ snapshot_dir = self.root / "snapshots" / version
189
+ snapshot_dir.mkdir(parents=True, exist_ok=True)
190
+ fd, temp_name = tempfile.mkstemp(suffix=".sqlite", dir=snapshot_dir)
191
+ os.close(fd)
192
+ temp = Path(temp_name)
193
+ connection = sqlite3.connect(temp)
194
+ try:
195
+ connection.executescript(SCHEMA)
196
+ accepted = 0
197
+ for page in pages:
198
+ normalized = normalize_page(page)
199
+ if normalized is None:
200
+ continue
201
+ connection.execute("INSERT INTO kb_pages VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", normalized)
202
+ connection.execute(
203
+ "INSERT INTO kb_fts VALUES (?, ?, ?, ?, ?)",
204
+ (normalized[0], normalized[1], normalized[2], normalized[3], normalized[4]),
205
+ )
206
+ accepted += 1
207
+ connection.commit()
208
+ integrity = connection.execute("PRAGMA integrity_check").fetchone()[0]
209
+ if integrity != "ok":
210
+ raise RuntimeError(f"SQLite integrity check failed: {integrity}")
211
+ if accepted == 0:
212
+ raise RuntimeError("snapshot contains no publishable KB pages")
213
+ finally:
214
+ connection.close()
215
+ final = snapshot_dir / "kb.sqlite"
216
+ os.replace(temp, final)
217
+ manifest_tmp = self.root / ".manifest.tmp"
218
+ manifest_tmp.write_text(json.dumps({"version": version, "page_count": accepted}), encoding="utf-8")
219
+ os.replace(manifest_tmp, self._manifest_path())
220
+ self._query_cache.clear()
221
+ return SnapshotInfo(version=version, path=final)
222
+
223
+ @staticmethod
224
+ def _expand_terms(query: str) -> list[str]:
225
+ raw = [part for part in query.split() if part][:12]
226
+ expanded: list[str] = []
227
+ for term in raw:
228
+ normalized = term.strip()
229
+ key = normalized.lower()
230
+ if not normalized or key in QUERY_STOP_TERMS:
231
+ continue
232
+ if normalized not in expanded:
233
+ expanded.append(normalized)
234
+ for alias in QUERY_ALIASES.get(key, ()):
235
+ if alias not in expanded:
236
+ expanded.append(alias)
237
+ return expanded[:20]
238
+
239
+
240
+ def _lines(value: Any) -> list[str]:
241
+ if value is None:
242
+ return []
243
+ if isinstance(value, str):
244
+ return [line.strip() for line in value.splitlines() if line.strip()]
245
+ if isinstance(value, list):
246
+ result: list[str] = []
247
+ for item in value:
248
+ result.extend(_lines(item))
249
+ return result
250
+ if isinstance(value, dict):
251
+ return [f"{key}: {item}" for key, item in value.items() if str(item).strip()]
252
+ text = str(value).strip()
253
+ return [text] if text else []
254
+
255
+
256
+ def _section(title: str, value: Any, *, numbered: bool = False) -> str:
257
+ values = _lines(value)
258
+ if not values:
259
+ return ""
260
+ if numbered:
261
+ content = "\n".join(f"{index}. {item}" for index, item in enumerate(values, start=1))
262
+ else:
263
+ content = "\n".join(f"- {item}" for item in values)
264
+ return f"## {title}\n\n{content}"
265
+
266
+
267
+ def compose_structured_body(page: dict[str, Any]) -> str:
268
+ sections = [
269
+ _section("目的・概要", page.get("目的")),
270
+ _section("理由", page.get("理由")),
271
+ _section("前提条件", page.get("前提条件")),
272
+ _section("利用条件", page.get("利用条件")),
273
+ _section("手順", page.get("手順"), numbered=True),
274
+ _section("条件", page.get("条件")),
275
+ _section("例外", page.get("例外")),
276
+ _section("障害症状", page.get("障害症状")),
277
+ _section("考えられる原因", page.get("原因")),
278
+ _section("確認方法", page.get("確認方法"), numbered=True),
279
+ _section("解決手順", page.get("解決手順"), numbered=True),
280
+ _section("完了確認", page.get("完了確認")),
281
+ _section("追加質問への対応", page.get("追加質問")),
282
+ _section("関連KB", page.get("関連KB")),
283
+ ]
284
+ return "\n\n".join(section for section in sections if section).strip()
285
+
286
+
287
+ def _search_terms(page: dict[str, Any]) -> str:
288
+ values: list[str] = []
289
+ for key in ("製品", "システム", "カテゴリ", "質問種別", "検索語", "同義語", "追加質問"):
290
+ values.extend(_lines(page.get(key)))
291
+ return " ".join(dict.fromkeys(values))
292
+
293
+
294
+ def _answer_boundary(page: dict[str, Any]) -> str:
295
+ values = [*_lines(page.get("回答境界")), *_lines(page.get("禁止回答"))]
296
+ return "\n".join(dict.fromkeys(values))
297
+
298
+
299
+ def normalize_page(page: dict[str, Any]) -> tuple[Any, ...] | None:
300
+ if page.get("公開状態") != "公開":
301
+ return None
302
+ if page.get("実装状態") not in {"実装済み", "文書確認済み"}:
303
+ return None
304
+ if page.get("要再確認") in (True, "__YES__", 1):
305
+ return None
306
+ question = redact_text(str(page.get("質問", ""))).text.strip()
307
+ short = redact_text(str(page.get("短い回答", ""))).text.strip()
308
+ raw_body = str(page.get("本文", "")).strip() or compose_structured_body(page)
309
+ body = redact_text(raw_body).text.strip()
310
+ boundary = redact_text(_answer_boundary(page)).text.strip()
311
+ if not question or not short or not body:
312
+ return None
313
+ if contains_internal_implementation("\n".join((question, short, body, boundary))):
314
+ return None
315
+ return (
316
+ str(page.get("id") or page.get("url") or question),
317
+ question,
318
+ short,
319
+ body,
320
+ _search_terms(page),
321
+ boundary,
322
+ ",".join(page.get("対象", [])) if isinstance(page.get("対象"), list) else str(page.get("対象", "")),
323
+ str(page.get("公開状態")),
324
+ str(page.get("実装状態")),
325
+ 0,
326
+ str(page.get("url", "")),
327
+ str(page.get("確認日", "")),
328
+ )
runtime/model.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+ try:
10
+ import spaces
11
+ except ImportError:
12
+ spaces = None
13
+
14
+ from .config import Settings
15
+
16
+
17
+ def _gpu_decorator(function: Callable[..., str]) -> Callable[..., str]:
18
+ if spaces is None:
19
+ return function
20
+ return spaces.GPU(duration=45)(function)
21
+
22
+
23
+ _MODEL_LOCK = threading.Lock()
24
+ _MODEL: Any = None
25
+ _TOKENIZER: Any = None
26
+
27
+
28
+ @_gpu_decorator
29
+ def _generate_gpu(model_id: str, revision: str, packet: str, max_new_tokens: int) -> str:
30
+ global _MODEL, _TOKENIZER
31
+ with _MODEL_LOCK:
32
+ if _MODEL is None or _TOKENIZER is None:
33
+ from transformers import AutoModelForCausalLM, AutoTokenizer
34
+
35
+ _TOKENIZER = AutoTokenizer.from_pretrained(model_id, revision=revision, trust_remote_code=False)
36
+ _MODEL = AutoModelForCausalLM.from_pretrained(
37
+ model_id,
38
+ revision=revision,
39
+ trust_remote_code=False,
40
+ torch_dtype="auto",
41
+ device_map="auto",
42
+ )
43
+ instruction = (
44
+ "You are only the Japanese response-composition component inside Astera Customer AI. "
45
+ "You do not decide product facts, routing, search, task scope, actions, or completion. "
46
+ "The support_packet already contains decomposed question tasks, search plans, verified KB evidence, and an answer blueprint. "
47
+ "Compose a specific and polite answer from that material only. Answer every question task that has evidence. "
48
+ "For a task without evidence, keep it in unresolved_task_ids and ask only the task-specific missing information supplied by the blueprint. "
49
+ "Preserve the cached user goal and active topic. Do not repeat answered questions. "
50
+ "Never invent product facts, reveal private implementation, identify the model/provider, or claim an action was executed. "
51
+ "When repair is present, correct only the listed violations and do not broaden the response. "
52
+ "Return JSON only with keys: answer, user_goal, active_topic, answered_task_ids, unresolved_task_ids, used_evidence_ids, needs_clarification."
53
+ )
54
+ messages = [{"role": "user", "content": instruction + "\n" + packet}]
55
+ text = _TOKENIZER.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
56
+ inputs = _TOKENIZER(text, return_tensors="pt").to(_MODEL.device)
57
+ outputs = _MODEL.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
58
+ generated = outputs[0][inputs["input_ids"].shape[1] :]
59
+ return _TOKENIZER.decode(generated, skip_special_tokens=True)
60
+
61
+
62
+ class GPUUsageLedger:
63
+ def __init__(self, root: Path, budget_seconds: int):
64
+ self.path = root / "runtime" / "gpu-usage.jsonl"
65
+ self.path.parent.mkdir(parents=True, exist_ok=True)
66
+ self.budget_seconds = budget_seconds
67
+
68
+ def used_seconds(self) -> float:
69
+ if not self.path.exists():
70
+ return 0.0
71
+ cutoff = time.time() - 86400
72
+ total = 0.0
73
+ for line in self.path.read_text(encoding="utf-8").splitlines():
74
+ try:
75
+ row = json.loads(line)
76
+ if float(row["timestamp"]) >= cutoff:
77
+ total += float(row["seconds"])
78
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError):
79
+ continue
80
+ return total
81
+
82
+ def can_use(self, reserve_seconds: int = 45) -> bool:
83
+ return self.used_seconds() + reserve_seconds <= self.budget_seconds
84
+
85
+ def record(self, seconds: float) -> None:
86
+ with self.path.open("a", encoding="utf-8") as handle:
87
+ handle.write(json.dumps({"timestamp": time.time(), "seconds": seconds}) + "\n")
88
+
89
+
90
+ class ConversationLanguageEngine:
91
+ REQUIRED_PACKET_KEYS = {"message", "conversation", "support_packet", "response_rules"}
92
+
93
+ def __init__(self, settings: Settings):
94
+ self.settings = settings
95
+ self.ledger = GPUUsageLedger(settings.data_root, settings.gpu_daily_budget_seconds)
96
+
97
+ def available(self) -> bool:
98
+ return bool(self.settings.enable_model and self.settings.model_revision and self.ledger.can_use())
99
+
100
+ def execute(self, packet: dict[str, Any]) -> dict[str, Any]:
101
+ missing = sorted(self.REQUIRED_PACKET_KEYS.difference(packet))
102
+ if missing:
103
+ raise ValueError("support_packet_missing:" + ",".join(missing))
104
+ if not self.available():
105
+ raise RuntimeError("model_unavailable_or_budget_exhausted")
106
+ support_packet = packet.get("support_packet")
107
+ if not isinstance(support_packet, dict):
108
+ raise ValueError("support_packet_invalid")
109
+ question_tasks = support_packet.get("question_tasks")
110
+ evidence = support_packet.get("evidence")
111
+ blueprint = support_packet.get("blueprint")
112
+ if not isinstance(question_tasks, list) or not question_tasks:
113
+ raise ValueError("support_packet_invalid:question_tasks")
114
+ if not isinstance(evidence, list) or not isinstance(blueprint, dict):
115
+ raise ValueError("support_packet_invalid:evidence_or_blueprint")
116
+
117
+ serialized = json.dumps(packet, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
118
+ started = time.monotonic()
119
+ raw = _generate_gpu(self.settings.model_id, self.settings.model_revision, serialized, 900)
120
+ self.ledger.record(time.monotonic() - started)
121
+ parsed = self._parse_json(raw)
122
+ self._validate_output(parsed)
123
+ return parsed
124
+
125
+ @staticmethod
126
+ def _validate_output(parsed: dict[str, Any]) -> None:
127
+ if not isinstance(parsed.get("answer"), str) or not parsed["answer"].strip():
128
+ raise ValueError("model_schema_invalid:answer")
129
+ for key in ("user_goal", "active_topic"):
130
+ if not isinstance(parsed.get(key), str):
131
+ raise ValueError(f"model_schema_invalid:{key}")
132
+ for key in ("answered_task_ids", "unresolved_task_ids", "used_evidence_ids"):
133
+ if not isinstance(parsed.get(key), list) or not all(isinstance(item, str) for item in parsed[key]):
134
+ raise ValueError(f"model_schema_invalid:{key}")
135
+ if not isinstance(parsed.get("needs_clarification"), bool):
136
+ raise ValueError("model_schema_invalid:needs_clarification")
137
+
138
+ @staticmethod
139
+ def _parse_json(raw: str) -> dict[str, Any]:
140
+ text = raw.strip()
141
+ if text.startswith("```"):
142
+ text = text.split("\n", 1)[-1]
143
+ if text.endswith("```"):
144
+ text = text[:-3]
145
+ parsed = json.loads(text.strip())
146
+ if not isinstance(parsed, dict):
147
+ raise ValueError("model_schema_invalid:root")
148
+ return parsed
149
+
150
+
151
+ ControlledLanguageEngine = ConversationLanguageEngine
152
+ DialogueModel = ConversationLanguageEngine
runtime/notion.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+
9
+ class NotionSyncError(RuntimeError):
10
+ pass
11
+
12
+
13
+ def _rich_text(value: Any) -> str:
14
+ if not isinstance(value, list):
15
+ return ""
16
+ return "".join(str(item.get("plain_text", "")) for item in value if isinstance(item, dict))
17
+
18
+
19
+ def _property_value(prop: dict[str, Any]) -> Any:
20
+ kind = prop.get("type")
21
+ value = prop.get(kind, {}) if kind else None
22
+ if kind in {"title", "rich_text"}:
23
+ return _rich_text(value)
24
+ if kind == "select":
25
+ return (value or {}).get("name", "")
26
+ if kind == "multi_select":
27
+ return [item.get("name", "") for item in (value or [])]
28
+ if kind == "checkbox":
29
+ return bool(value)
30
+ if kind == "date":
31
+ return (value or {}).get("start", "")
32
+ if kind == "url":
33
+ return value or ""
34
+ if kind == "status":
35
+ return (value or {}).get("name", "")
36
+ if kind == "number":
37
+ return value
38
+ if kind == "formula":
39
+ formula_type = (value or {}).get("type")
40
+ return (value or {}).get(formula_type) if formula_type else None
41
+ return value
42
+
43
+
44
+ def _block_text(block: dict[str, Any]) -> str:
45
+ kind = block.get("type")
46
+ payload = block.get(kind, {}) if kind else {}
47
+ text = _rich_text(payload.get("rich_text", []))
48
+ if kind in {"bulleted_list_item", "numbered_list_item", "to_do"} and text:
49
+ return f"- {text}"
50
+ if kind and kind.startswith("heading_") and text:
51
+ return f"## {text}"
52
+ if kind == "code" and text:
53
+ language = payload.get("language", "text")
54
+ return f"```{language}\n{text}\n```"
55
+ return text
56
+
57
+
58
+ class NotionClient:
59
+ def __init__(self, token: str, data_source_id: str, *, timeout_seconds: int = 20):
60
+ self.token = token
61
+ self.data_source_id = data_source_id
62
+ self.timeout_seconds = timeout_seconds
63
+ self.headers = {
64
+ "authorization": f"Bearer {token}",
65
+ "notion-version": "2025-09-03",
66
+ "content-type": "application/json",
67
+ }
68
+
69
+ async def fetch_pages(self) -> list[dict[str, Any]]:
70
+ if not self.token or not self.data_source_id:
71
+ raise NotionSyncError("notion_not_configured")
72
+ async with httpx.AsyncClient(timeout=self.timeout_seconds, headers=self.headers) as client:
73
+ rows = await self._query_all(client)
74
+ semaphore = asyncio.Semaphore(3)
75
+
76
+ async def hydrate(row: dict[str, Any]) -> dict[str, Any]:
77
+ async with semaphore:
78
+ content = await self._fetch_blocks(client, row["id"])
79
+ properties = {
80
+ name: _property_value(value)
81
+ for name, value in row.get("properties", {}).items()
82
+ if isinstance(value, dict)
83
+ }
84
+ properties.update(
85
+ {
86
+ "id": row["id"],
87
+ "url": row.get("url", ""),
88
+ "本文": content,
89
+ }
90
+ )
91
+ return properties
92
+
93
+ return await asyncio.gather(*(hydrate(row) for row in rows))
94
+
95
+ async def _query_all(self, client: httpx.AsyncClient) -> list[dict[str, Any]]:
96
+ results: list[dict[str, Any]] = []
97
+ cursor: str | None = None
98
+ while True:
99
+ payload: dict[str, Any] = {"page_size": 100}
100
+ if cursor:
101
+ payload["start_cursor"] = cursor
102
+ response = await self._request_with_retry(
103
+ client,
104
+ "POST",
105
+ f"https://api.notion.com/v1/data_sources/{self.data_source_id}/query",
106
+ json=payload,
107
+ )
108
+ body = response.json()
109
+ results.extend(body.get("results", []))
110
+ if not body.get("has_more"):
111
+ return results
112
+ cursor = body.get("next_cursor")
113
+ if not cursor:
114
+ raise NotionSyncError("notion_missing_cursor")
115
+
116
+ async def _fetch_blocks(self, client: httpx.AsyncClient, block_id: str) -> str:
117
+ lines: list[str] = []
118
+ cursor: str | None = None
119
+ while True:
120
+ params: dict[str, Any] = {"page_size": 100}
121
+ if cursor:
122
+ params["start_cursor"] = cursor
123
+ response = await self._request_with_retry(
124
+ client,
125
+ "GET",
126
+ f"https://api.notion.com/v1/blocks/{block_id}/children",
127
+ params=params,
128
+ )
129
+ body = response.json()
130
+ for block in body.get("results", []):
131
+ text = _block_text(block)
132
+ if text:
133
+ lines.append(text)
134
+ if block.get("has_children"):
135
+ child = await self._fetch_blocks(client, block["id"])
136
+ if child:
137
+ lines.append(child)
138
+ if not body.get("has_more"):
139
+ break
140
+ cursor = body.get("next_cursor")
141
+ if not cursor:
142
+ break
143
+ return "\n".join(lines).strip()
144
+
145
+ async def _request_with_retry(self, client: httpx.AsyncClient, method: str, url: str, **kwargs: Any) -> httpx.Response:
146
+ delay = 1.0
147
+ last_error: Exception | None = None
148
+ for _ in range(5):
149
+ try:
150
+ response = await client.request(method, url, **kwargs)
151
+ if response.status_code == 429:
152
+ await asyncio.sleep(float(response.headers.get("retry-after", delay)))
153
+ delay = min(delay * 2, 16)
154
+ continue
155
+ if response.status_code >= 500:
156
+ await asyncio.sleep(delay)
157
+ delay = min(delay * 2, 16)
158
+ continue
159
+ response.raise_for_status()
160
+ return response
161
+ except (httpx.TimeoutException, httpx.NetworkError, httpx.HTTPStatusError) as exc:
162
+ last_error = exc
163
+ await asyncio.sleep(delay)
164
+ delay = min(delay * 2, 16)
165
+ raise NotionSyncError(f"notion_request_failed: {last_error}")
runtime/schemas.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+ from .security import validate_identifier
9
+
10
+
11
+ JobStatus = Literal[
12
+ "received",
13
+ "accepted",
14
+ "queued_processing",
15
+ "processing",
16
+ "awaiting_clarification",
17
+ "retrying",
18
+ "degraded",
19
+ "completed",
20
+ "failed",
21
+ ]
22
+
23
+
24
+ class MessagePayload(BaseModel):
25
+ session_id: str = Field(min_length=8, max_length=160)
26
+ message_id: str = Field(min_length=8, max_length=160)
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"]
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"]
39
+ text: str = Field(min_length=1, max_length=8000)
40
+ message_id: str = ""
41
+ kb_ids: list[str] = Field(default_factory=list)
42
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
43
+
44
+
45
+ class SessionContext(BaseModel):
46
+ session_id: str
47
+ user_goal: str = ""
48
+ active_topic: str = ""
49
+ confirmed_details: dict[str, Any] = Field(default_factory=dict)
50
+ unresolved_questions: list[str] = Field(default_factory=list)
51
+ last_kb_ids: list[str] = Field(default_factory=list)
52
+ turns: list[ConversationTurn] = Field(default_factory=list)
53
+ answered_question_ids: list[str] = Field(default_factory=list)
54
+ question_ledger: list[dict[str, Any]] = Field(default_factory=list)
55
+ evidence_cache: dict[str, dict[str, Any]] = Field(default_factory=dict)
56
+ last_blueprint: dict[str, Any] = Field(default_factory=dict)
57
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
58
+
59
+
60
+ class CloudEvent(BaseModel):
61
+ specversion: Literal["1.0"] = "1.0"
62
+ id: str = Field(min_length=8, max_length=160)
63
+ source: str = Field(min_length=3, max_length=300)
64
+ type: str = Field(min_length=3, max_length=160)
65
+ subject: str = Field(min_length=3, max_length=300)
66
+ time: datetime
67
+ datacontenttype: Literal["application/json"] = "application/json"
68
+ data: dict[str, Any]
69
+
70
+
71
+ class JobRecord(BaseModel):
72
+ job_id: str
73
+ event_id: str
74
+ session_id: str
75
+ message_id: str
76
+ status: JobStatus
77
+ stage: str
78
+ request_hash: str
79
+ created_at: datetime
80
+ updated_at: datetime
81
+ attempts: int = 0
82
+ error_code: str | None = None
83
+ queue_event_id: str | None = None
84
+
85
+ @classmethod
86
+ def new(cls, *, job_id: str, event_id: str, payload: MessagePayload, request_hash: str) -> "JobRecord":
87
+ now = datetime.now(UTC)
88
+ return cls(
89
+ job_id=job_id,
90
+ event_id=event_id,
91
+ session_id=payload.session_id,
92
+ message_id=payload.message_id,
93
+ status="accepted",
94
+ stage="durable_accept",
95
+ request_hash=request_hash,
96
+ created_at=now,
97
+ updated_at=now,
98
+ )
99
+
100
+
101
+ class JobResult(BaseModel):
102
+ job_id: str
103
+ session_id: str
104
+ status: Literal["completed", "awaiting_clarification", "failed"]
105
+ answer: str
106
+ kb_ids: list[str] = Field(default_factory=list)
107
+ ai_invoked: bool = False
108
+ clarification: str | None = None
109
+ facts: list[str] = Field(default_factory=list)
110
+ context_used: bool = False
111
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
112
+
113
+
114
+ class KBHit(BaseModel):
115
+ kb_id: str
116
+ question: str
117
+ short_answer: str
118
+ body: str
119
+ score: float
120
+ answer_boundary: str = ""
121
+ target: str = ""
122
+
123
+
124
+ class NodeResponse(BaseModel):
125
+ request_id: str
126
+ ok: bool
127
+ result: dict[str, Any] = Field(default_factory=dict)
128
+ error_code: str | None = None
129
+ duration_ms: int = 0
runtime/security.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import re
8
+ import time
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ EMAIL = re.compile(r"(?<![\w.+-])[\w.+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
13
+ PHONE = re.compile(r"(?<!\d)(?:\+?81[- ]?|0)\d{1,4}[- ]?\d{1,4}[- ]?\d{3,4}(?!\d)")
14
+ CARD = re.compile(r"(?<!\d)(?:\d[ -]?){13,19}(?!\d)")
15
+ JWT = re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b")
16
+ TOKEN = re.compile(r"\b(?:sk|hf|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{16,}\b", re.I)
17
+ PRIVATE_KEY = re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")
18
+ INTERNAL_PATH = re.compile(r"(?:/internal/|src/(?:system|component|feature|part)/|\.env\b)", re.I)
19
+ IDENTIFIER = re.compile(r"^[A-Za-z0-9_.:-]{8,160}$")
20
+ PROMPT_INJECTION = re.compile(r"(?:ignore (?:all|previous) instructions|system prompt|developer message|前の指示を無視|システムプロンプト)", re.I)
21
+ HIGH_ENTROPY = re.compile(r"\b[A-Za-z0-9_+/=-]{40,}\b")
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class RedactionResult:
26
+ text: str
27
+ kinds: list[str]
28
+
29
+
30
+ def _decode_secret(secret: str) -> bytes:
31
+ if secret.startswith("base64:"):
32
+ try:
33
+ return base64.b64decode(secret.removeprefix("base64:"), validate=True)
34
+ except ValueError:
35
+ return b""
36
+ return secret.encode("utf-8")
37
+
38
+
39
+ def verify_standard_webhook(
40
+ raw_body: bytes,
41
+ event_id: str,
42
+ timestamp: str,
43
+ signature: str,
44
+ secret: str,
45
+ *,
46
+ tolerance_seconds: int = 300,
47
+ ) -> bool:
48
+ if not secret or not event_id or not timestamp or not signature:
49
+ return False
50
+ try:
51
+ ts = int(timestamp)
52
+ body_text = raw_body.decode("utf-8")
53
+ except (ValueError, UnicodeDecodeError):
54
+ return False
55
+ if tolerance_seconds and abs(int(time.time()) - ts) > tolerance_seconds:
56
+ return False
57
+ key = _decode_secret(secret)
58
+ if not key:
59
+ return False
60
+ expected = hmac.new(key, f"{event_id}.{timestamp}.{body_text}".encode("utf-8"), hashlib.sha256).digest()
61
+ candidates: list[bytes] = []
62
+ for item in signature.split():
63
+ version, separator, encoded = item.partition(",")
64
+ if version != "v1" or not separator or not encoded:
65
+ continue
66
+ try:
67
+ candidates.append(base64.b64decode(encoded, validate=True))
68
+ except ValueError:
69
+ continue
70
+ return any(hmac.compare_digest(expected, candidate) for candidate in candidates)
71
+
72
+
73
+ def sign_standard_webhook(raw_body: bytes, event_id: str, timestamp: str, secret: str) -> str:
74
+ key = _decode_secret(secret)
75
+ if not key:
76
+ raise ValueError("standard_webhook_secret_invalid")
77
+ body_text = raw_body.decode("utf-8")
78
+ digest = hmac.new(key, f"{event_id}.{timestamp}.{body_text}".encode("utf-8"), hashlib.sha256).digest()
79
+ return "v1," + base64.b64encode(digest).decode("ascii")
80
+
81
+
82
+ def verify_hmac(raw_body: bytes, timestamp: str, signature: str, secret: str, *, tolerance_seconds: int = 300) -> bool:
83
+ if not secret or not timestamp or not signature:
84
+ return False
85
+ try:
86
+ ts = int(timestamp)
87
+ except ValueError:
88
+ return False
89
+ if abs(int(time.time()) - ts) > tolerance_seconds:
90
+ return False
91
+ expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
92
+ provided = signature.removeprefix("sha256=")
93
+ return hmac.compare_digest(expected, provided)
94
+
95
+
96
+ def sign_hmac(raw_body: bytes, timestamp: str, secret: str) -> str:
97
+ return "sha256=" + hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
98
+
99
+
100
+ def canonical_json(value: Any) -> bytes:
101
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str).encode()
102
+
103
+
104
+ def redact_text(value: str) -> RedactionResult:
105
+ text = value
106
+ kinds: list[str] = []
107
+ for kind, pattern, replacement in (
108
+ ("private_key", PRIVATE_KEY, "<REDACTED_PRIVATE_KEY>"),
109
+ ("jwt", JWT, "<REDACTED_JWT>"),
110
+ ("token", TOKEN, "<REDACTED_TOKEN>"),
111
+ ("high_entropy", HIGH_ENTROPY, "<REDACTED_HIGH_ENTROPY>"),
112
+ ("email", EMAIL, "<EMAIL>"),
113
+ ("phone", PHONE, "<PHONE>"),
114
+ ("card", CARD, "<PAYMENT_NUMBER>"),
115
+ ):
116
+ text, count = pattern.subn(replacement, text)
117
+ if count:
118
+ kinds.append(kind)
119
+ return RedactionResult(text=text, kinds=sorted(set(kinds)))
120
+
121
+
122
+ def sanitize_structure(value: Any) -> Any:
123
+ if isinstance(value, str):
124
+ return redact_text(value).text
125
+ if isinstance(value, list):
126
+ return [sanitize_structure(item) for item in value]
127
+ if isinstance(value, dict):
128
+ result: dict[str, Any] = {}
129
+ for key, item in value.items():
130
+ lowered = str(key).lower()
131
+ if any(word in lowered for word in ("password", "secret", "token", "authorization", "card")):
132
+ result[str(key)] = "<REDACTED>"
133
+ else:
134
+ result[str(key)] = sanitize_structure(item)
135
+ return result
136
+ return value
137
+
138
+
139
+ def contains_internal_implementation(text: str) -> bool:
140
+ return bool(INTERNAL_PATH.search(text))
141
+
142
+
143
+ def validate_identifier(value: str, *, field: str = "identifier") -> str:
144
+ if not IDENTIFIER.fullmatch(value):
145
+ raise ValueError(f"{field} contains unsupported characters")
146
+ return value
147
+
148
+
149
+ def safe_candidate_phrase(value: str) -> bool:
150
+ clean = " ".join(value.split())
151
+ if not 3 <= len(clean) <= 120:
152
+ return False
153
+ if PROMPT_INJECTION.search(clean) or contains_internal_implementation(clean):
154
+ return False
155
+ if PRIVATE_KEY.search(clean) or JWT.search(clean) or TOKEN.search(clean) or HIGH_ENTROPY.search(clean):
156
+ return False
157
+ return True
runtime/service.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from .config import Settings
12
+ from .control import ConversationCore
13
+ from .conversation import ConversationCache
14
+ from .kb import KBIndex
15
+ from .model import ConversationLanguageEngine
16
+ from .notion import NotionClient
17
+ from .schemas import CloudEvent, JobRecord, JobResult, MessagePayload
18
+ from .security import canonical_json, redact_text, sanitize_structure, validate_identifier
19
+ from .storage import ConflictError, JobStore
20
+ from .support import FeedbackStore
21
+ from .v8 import V8Supervisor
22
+
23
+
24
+ class InternalEventApiClient:
25
+ def __init__(self, settings: Settings):
26
+ self.settings = settings
27
+
28
+ async def emit(self, event_type: str, subject: str, data: dict[str, Any]) -> None:
29
+ if not (
30
+ self.settings.internal_event_api_url
31
+ and self.settings.internal_event_api_token
32
+ and self.settings.internal_event_result_destination_id
33
+ ):
34
+ return
35
+ sanitized = sanitize_structure(data)
36
+ event_id = "evt_" + hashlib.sha256(
37
+ f"{event_type}:{subject}:{canonical_json(sanitized).hex()}".encode()
38
+ ).hexdigest()[:32]
39
+ payload = {
40
+ "eventId": event_id,
41
+ "eventType": event_type,
42
+ "sourceId": self.settings.internal_event_source_id,
43
+ "destinationId": self.settings.internal_event_result_destination_id,
44
+ "subject": subject,
45
+ "time": datetime.now(UTC).isoformat(),
46
+ "data": sanitized,
47
+ }
48
+ headers = {
49
+ "authorization": f"Bearer {self.settings.internal_event_api_token}",
50
+ "content-type": "application/json",
51
+ }
52
+ async with httpx.AsyncClient(
53
+ timeout=self.settings.internal_event_api_timeout_seconds
54
+ ) as client:
55
+ response = await client.post(
56
+ self.settings.internal_event_api_url,
57
+ json=payload,
58
+ headers=headers,
59
+ )
60
+ response.raise_for_status()
61
+
62
+
63
+ class CustomerAIService:
64
+ def __init__(self, settings: Settings | None = None):
65
+ self.settings = settings or Settings.load()
66
+ self.settings.ensure_directories()
67
+ self.jobs = JobStore(self.settings.data_root)
68
+ self.kb = KBIndex(
69
+ self.settings.data_root,
70
+ cache_ttl_seconds=self.settings.kb_cache_ttl_seconds,
71
+ cache_max_entries=self.settings.kb_cache_max_entries,
72
+ )
73
+ self.v8 = V8Supervisor(self.settings)
74
+ self.engine = ConversationLanguageEngine(self.settings)
75
+ self.conversations = ConversationCache(
76
+ self.settings.data_root,
77
+ ttl_seconds=self.settings.session_cache_ttl_seconds,
78
+ max_sessions=self.settings.session_cache_max_sessions,
79
+ max_turns=self.settings.session_cache_max_turns,
80
+ )
81
+ self.feedback = FeedbackStore(self.settings.data_root)
82
+ self.core = ConversationCore(
83
+ v8=self.v8,
84
+ engine=self.engine,
85
+ cache=self.conversations,
86
+ search=self.kb.search,
87
+ feedback_store=self.feedback,
88
+ )
89
+ self.events = InternalEventApiClient(self.settings)
90
+ self.notion = NotionClient(
91
+ self.settings.notion_token, self.settings.notion_data_source_id
92
+ )
93
+ self._process_semaphore = asyncio.Semaphore(self.settings.process_concurrency)
94
+
95
+ async def startup(self) -> None:
96
+ try:
97
+ await self.v8.start()
98
+ except Exception:
99
+ pass
100
+ self.kb.open()
101
+
102
+ async def shutdown(self) -> None:
103
+ await self.v8.stop()
104
+
105
+ async def sync_notion_kb(self, version: str) -> dict[str, Any]:
106
+ pages = await self.notion.fetch_pages()
107
+ info = await asyncio.to_thread(
108
+ self.kb.build_snapshot, version=version, pages=pages
109
+ )
110
+ self.kb.open()
111
+ result = {
112
+ "version": info.version,
113
+ "path": str(info.path),
114
+ "source_pages": len(pages),
115
+ }
116
+ await self.events.emit(
117
+ "customer.ai.kb.update.applied", f"kb/{version}", result
118
+ )
119
+ return result
120
+
121
+ async def recover_once(self) -> dict[str, Any]:
122
+ recovered: list[str] = []
123
+ now = datetime.now(UTC)
124
+ for status_path in self.settings.data_root.glob("jobs/*/*/status.json"):
125
+ try:
126
+ record = JobRecord.model_validate_json(
127
+ status_path.read_text(encoding="utf-8")
128
+ )
129
+ except Exception:
130
+ continue
131
+ age = (now - record.updated_at).total_seconds()
132
+ should_requeue = (
133
+ record.status in {"accepted", "queued_processing", "retrying"}
134
+ and age > 60
135
+ )
136
+ if record.status == "processing":
137
+ lease_path = status_path.parent / "lease.json"
138
+ if not lease_path.exists():
139
+ should_requeue = age > self.settings.job_lease_seconds
140
+ else:
141
+ try:
142
+ lease = self.jobs.store.get_json(lease_path)
143
+ should_requeue = datetime.fromisoformat(lease["expires_at"]) <= now
144
+ except Exception:
145
+ should_requeue = True
146
+ if not should_requeue:
147
+ continue
148
+ self.jobs.update_job(
149
+ record.job_id, status="retrying", stage="recovery_requeue"
150
+ )
151
+ await self.events.emit(
152
+ "customer.ai.job.requeue.requested",
153
+ f"job/{record.job_id}",
154
+ {"job_id": record.job_id},
155
+ )
156
+ recovered.append(record.job_id)
157
+ return {"recovered": recovered, "count": len(recovered)}
158
+
159
+ async def accept(self, event: CloudEvent) -> tuple[dict[str, Any], bool]:
160
+ if event.type != "customer.ai.message.requested":
161
+ raise ValueError("unsupported_event_type")
162
+ payload = MessagePayload.model_validate(event.data["message"])
163
+ if len(payload.message) > self.settings.max_input_chars:
164
+ raise ValueError("message_too_large")
165
+ redacted = redact_text(payload.message)
166
+ payload = payload.model_copy(update={"message": redacted.text})
167
+ validate_identifier(event.id, field="event_id")
168
+ job_id = str(
169
+ event.data.get("job_id")
170
+ or "job_" + hashlib.sha256(event.id.encode()).hexdigest()[:32]
171
+ )
172
+ validate_identifier(job_id, field="job_id")
173
+ record, created = self.jobs.accept(
174
+ job_id=job_id, event_id=event.id, payload=payload
175
+ )
176
+ await self.events.emit(
177
+ "customer.ai.job.accepted",
178
+ f"job/{job_id}",
179
+ {
180
+ "job_id": job_id,
181
+ "created": created,
182
+ "redaction_kinds": redacted.kinds,
183
+ },
184
+ )
185
+ return record.model_dump(mode="json"), created
186
+
187
+ async def process_job(self, job_id: str) -> dict[str, Any]:
188
+ existing = self.jobs.get_result(job_id)
189
+ if existing:
190
+ return existing
191
+ async with self._process_semaphore:
192
+ with self.jobs.store.lease(
193
+ self.jobs.job_dir(job_id) / "lease.json",
194
+ f"processor:{job_id}",
195
+ self.settings.job_lease_seconds,
196
+ ):
197
+ existing = self.jobs.get_result(job_id)
198
+ if existing:
199
+ return existing
200
+ request = self.jobs.get_request(job_id)
201
+ self.jobs.update_job(
202
+ job_id, status="processing", stage="support_preparation"
203
+ )
204
+ session_lease_path = (
205
+ self.jobs.sessions_root / request.session_id / "lease.json"
206
+ )
207
+ try:
208
+ with self.jobs.store.lease(
209
+ session_lease_path,
210
+ job_id,
211
+ self.settings.session_lease_seconds,
212
+ ):
213
+ result = await self._run_pipeline(job_id, request)
214
+ except ConflictError:
215
+ self.jobs.update_job(
216
+ job_id, status="retrying", stage="session_busy"
217
+ )
218
+ return {"job_id": job_id, "status": "retrying", "retry_after": 2}
219
+ self.jobs.save_result(job_id, result)
220
+ self.jobs.update_job(
221
+ job_id, status=result["status"], stage=result["status"]
222
+ )
223
+ await self.events.emit(
224
+ "customer.ai.response.completed", f"job/{job_id}", result
225
+ )
226
+ return result
227
+
228
+ async def _run_pipeline(
229
+ self, job_id: str, request: MessagePayload
230
+ ) -> dict[str, Any]:
231
+ outcome = await self.core.execute(request=request)
232
+ return JobResult(
233
+ job_id=job_id,
234
+ session_id=request.session_id,
235
+ status=outcome.status,
236
+ answer=outcome.answer,
237
+ kb_ids=outcome.kb_ids,
238
+ ai_invoked=outcome.engine_invoked,
239
+ clarification=outcome.clarification,
240
+ facts=outcome.facts,
241
+ context_used=outcome.context_used,
242
+ ).model_dump(mode="json") | {
243
+ "analysis": outcome.analysis,
244
+ "violations": outcome.violations,
245
+ "processing_grade": outcome.processing_grade,
246
+ "question_tasks": outcome.question_tasks,
247
+ "blueprint": outcome.blueprint,
248
+ "repair_attempted": outcome.repair_attempted,
249
+ "feedback_candidate_id": outcome.feedback_candidate_id,
250
+ "execution": outcome.execution,
251
+ }
252
+
253
+ def readiness(self) -> dict[str, Any]:
254
+ return {
255
+ "data_root": self.settings.data_root.exists()
256
+ and os_access(self.settings.data_root),
257
+ "v8": bool(self.v8.process and self.v8.process.returncode is None),
258
+ "kb": self.kb.current() is not None,
259
+ "model_enabled": self.settings.enable_model,
260
+ "model_revision_pinned": bool(self.settings.model_revision),
261
+ "conversation_cache": self.conversations.status(),
262
+ "kb_cache": self.kb.cache_status(),
263
+ "feedback_store": self.feedback.root.exists(),
264
+ "internal_event_api": {
265
+ "configured": bool(
266
+ self.settings.internal_event_api_url
267
+ and self.settings.internal_event_api_token
268
+ and self.settings.internal_event_result_destination_id
269
+ ),
270
+ "source_id": self.settings.internal_event_source_id,
271
+ "result_destination_id": self.settings.internal_event_result_destination_id,
272
+ },
273
+ "support_pipeline": "astera-derived-document-task-search-evidence-blueprint",
274
+ }
275
+
276
+
277
+ def os_access(path: Path) -> bool:
278
+ try:
279
+ probe = path / "temporary" / ".ready-probe"
280
+ probe.parent.mkdir(parents=True, exist_ok=True)
281
+ probe.write_text("ok", encoding="utf-8")
282
+ probe.unlink()
283
+ return True
284
+ except OSError:
285
+ return False
runtime/storage.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import tempfile
7
+ import time
8
+ from contextlib import contextmanager
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime, timedelta
11
+ from pathlib import Path
12
+ from typing import Any, Iterator
13
+
14
+ from .schemas import JobRecord, MessagePayload
15
+ from .security import canonical_json
16
+
17
+
18
+ class ConflictError(RuntimeError):
19
+ pass
20
+
21
+
22
+ class NotFoundError(RuntimeError):
23
+ pass
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class Lease:
28
+ path: Path
29
+ owner: str
30
+ expires_at: datetime
31
+
32
+
33
+ class AtomicStore:
34
+ def __init__(self, root: Path):
35
+ self.root = root
36
+ self.root.mkdir(parents=True, exist_ok=True)
37
+
38
+ @staticmethod
39
+ def _write_atomic(path: Path, data: bytes) -> None:
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
42
+ try:
43
+ with os.fdopen(fd, "wb") as handle:
44
+ handle.write(data)
45
+ handle.flush()
46
+ os.fsync(handle.fileno())
47
+ os.replace(temp_name, path)
48
+ finally:
49
+ if os.path.exists(temp_name):
50
+ os.unlink(temp_name)
51
+
52
+ def create_once(self, path: Path, data: bytes) -> bool:
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ try:
55
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
56
+ except FileExistsError:
57
+ return False
58
+ with os.fdopen(fd, "wb") as handle:
59
+ handle.write(data)
60
+ handle.flush()
61
+ os.fsync(handle.fileno())
62
+ return True
63
+
64
+ def put_json(self, path: Path, value: Any) -> None:
65
+ self._write_atomic(path, canonical_json(value))
66
+
67
+ def get_json(self, path: Path) -> Any:
68
+ try:
69
+ return json.loads(path.read_text(encoding="utf-8"))
70
+ except FileNotFoundError as exc:
71
+ raise NotFoundError(str(path)) from exc
72
+
73
+ @contextmanager
74
+ def lease(self, path: Path, owner: str, ttl_seconds: int) -> Iterator[Lease]:
75
+ now = datetime.now(UTC)
76
+ expires = now + timedelta(seconds=ttl_seconds)
77
+ payload = {"owner": owner, "heartbeat_at": now.isoformat(), "expires_at": expires.isoformat()}
78
+ created = self.create_once(path, canonical_json(payload))
79
+ if not created:
80
+ current = self.get_json(path)
81
+ current_expiry = datetime.fromisoformat(current["expires_at"])
82
+ if current_expiry > now:
83
+ raise ConflictError(f"lease owned by {current.get('owner')}")
84
+ stale = path.with_suffix(f".stale.{int(time.time())}.json")
85
+ try:
86
+ os.replace(path, stale)
87
+ except FileNotFoundError:
88
+ pass
89
+ if not self.create_once(path, canonical_json(payload)):
90
+ raise ConflictError("lease was concurrently recovered")
91
+ lease = Lease(path=path, owner=owner, expires_at=expires)
92
+ try:
93
+ yield lease
94
+ finally:
95
+ try:
96
+ current = self.get_json(path)
97
+ if current.get("owner") == owner:
98
+ path.unlink(missing_ok=True)
99
+ except NotFoundError:
100
+ pass
101
+
102
+ def heartbeat(self, lease: Lease, ttl_seconds: int) -> None:
103
+ current = self.get_json(lease.path)
104
+ if current.get("owner") != lease.owner:
105
+ raise ConflictError("lease ownership changed")
106
+ now = datetime.now(UTC)
107
+ expires = now + timedelta(seconds=ttl_seconds)
108
+ self.put_json(
109
+ lease.path,
110
+ {"owner": lease.owner, "heartbeat_at": now.isoformat(), "expires_at": expires.isoformat()},
111
+ )
112
+ lease.expires_at = expires
113
+
114
+
115
+ class JobStore:
116
+ def __init__(self, root: Path):
117
+ self.store = AtomicStore(root)
118
+ self.jobs_root = root / "jobs"
119
+ self.sessions_root = root / "sessions"
120
+
121
+ def job_dir(self, job_id: str) -> Path:
122
+ return self.jobs_root / job_id[:2] / job_id
123
+
124
+ def accept(self, *, job_id: str, event_id: str, payload: MessagePayload) -> tuple[JobRecord, bool]:
125
+ job_dir = self.job_dir(job_id)
126
+ request_data = payload.model_dump(mode="json")
127
+ request_hash = hashlib.sha256(canonical_json(request_data)).hexdigest()
128
+ created = self.store.create_once(job_dir / "request.json", canonical_json(request_data))
129
+ if not created:
130
+ existing_hash = hashlib.sha256((job_dir / "request.json").read_bytes()).hexdigest()
131
+ if existing_hash != request_hash:
132
+ raise ConflictError("job id reused with different payload")
133
+ return JobRecord.model_validate(self.store.get_json(job_dir / "status.json")), False
134
+ record = JobRecord.new(job_id=job_id, event_id=event_id, payload=payload, request_hash=request_hash)
135
+ self.store.put_json(job_dir / "status.json", record.model_dump(mode="json"))
136
+ self.store.create_once(
137
+ self.sessions_root / payload.session_id / "events" / f"{payload.message_id}.json",
138
+ canonical_json({"type": "message", "job_id": job_id, **request_data}),
139
+ )
140
+ return record, True
141
+
142
+ def get_job(self, job_id: str) -> JobRecord:
143
+ return JobRecord.model_validate(self.store.get_json(self.job_dir(job_id) / "status.json"))
144
+
145
+ def get_request(self, job_id: str) -> MessagePayload:
146
+ return MessagePayload.model_validate(self.store.get_json(self.job_dir(job_id) / "request.json"))
147
+
148
+ def update_job(self, job_id: str, **changes: Any) -> JobRecord:
149
+ record = self.get_job(job_id)
150
+ data = record.model_dump()
151
+ data.update(changes)
152
+ data["updated_at"] = datetime.now(UTC)
153
+ updated = JobRecord.model_validate(data)
154
+ self.store.put_json(self.job_dir(job_id) / "status.json", updated.model_dump(mode="json"))
155
+ return updated
156
+
157
+ def save_result(self, job_id: str, result: dict[str, Any]) -> None:
158
+ self.store.put_json(self.job_dir(job_id) / "result.json", result)
159
+
160
+ def get_result(self, job_id: str) -> dict[str, Any] | None:
161
+ path = self.job_dir(job_id) / "result.json"
162
+ return self.store.get_json(path) if path.exists() else None
163
+
164
+ def save_insight(self, job_id: str, insight: dict[str, Any]) -> None:
165
+ self.store.put_json(self.job_dir(job_id) / "insight.json", insight)
166
+
167
+ def append_session_state(self, session_id: str, job_id: str, state: dict[str, Any]) -> None:
168
+ session_dir = self.sessions_root / session_id
169
+ self.store.create_once(
170
+ session_dir / "events" / f"state-{job_id}.json",
171
+ canonical_json({"type": "state", "job_id": job_id, "state": state}),
172
+ )
173
+ self.store.put_json(session_dir / "state.json", state)
174
+
175
+ def get_session_state(self, session_id: str) -> dict[str, Any]:
176
+ path = self.sessions_root / session_id / "state.json"
177
+ if not path.exists():
178
+ return {}
179
+ return self.store.get_json(path)
runtime/support.py ADDED
@@ -0,0 +1,622 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import threading
8
+ import unicodedata
9
+ from dataclasses import asdict, dataclass, field
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Iterable
13
+
14
+ from .security import contains_internal_implementation, redact_text, sanitize_structure
15
+
16
+
17
+ KNOWN_SUBJECTS: tuple[tuple[str, re.Pattern[str]], ...] = (
18
+ ("astera-app", re.compile(r"Astera[- ]?app|アステラアプリ|アプリ", re.I)),
19
+ ("webhook-gateway", re.compile(r"Webhook\s*Gateway|webhook|配送|再送|リプレイ", re.I)),
20
+ ("astera", re.compile(r"Astera|アステラ", re.I)),
21
+ ("api", re.compile(r"API|APIキー|エンドポイント|連携", re.I)),
22
+ ("billing", re.compile(r"料金|価格|支払|決済|請求|クレジット|残高|プラン", re.I)),
23
+ ("account", re.compile(r"アカウント|ログイン|認証|パスワード|退会|解約|削除", re.I)),
24
+ ("support", re.compile(r"問い合わせ|質問|回答|サポート|Customer\s*AI", re.I)),
25
+ )
26
+
27
+ INTENTS: tuple[tuple[str, re.Pattern[str]], ...] = (
28
+ ("comparison", re.compile(r"違い|比較|どちら|何が異なる|vs\b", re.I)),
29
+ ("troubleshooting", re.compile(r"エラー|動かない|できない|反映されない|届かない|失敗|不具合|直し|確認すれば", re.I)),
30
+ ("procedure", re.compile(r"方法|手順|どうやって|どこから|どこを|何を確認|確認方法|設定|使い方|始め方|導入", re.I)),
31
+ ("pricing", re.compile(r"料金|価格|いくら|費用|課金|クレジット", re.I)),
32
+ ("contract", re.compile(r"契約|解約|退会|返金|更新|支払", re.I)),
33
+ ("availability", re.compile(r"使える|使えます|使えない|利用でき|対応|可能|できる|未実装|提供", re.I)),
34
+ ("limitation", re.compile(r"制限|上限|できない|禁止|対象外|条件", re.I)),
35
+ ("definition", re.compile(r"とは|何ですか|何なの|意味|概要", re.I)),
36
+ )
37
+
38
+ ANSWER_SHAPES = {
39
+ "comparison": "comparison",
40
+ "troubleshooting": "resolution_steps",
41
+ "procedure": "ordered_steps",
42
+ "pricing": "current_fact",
43
+ "contract": "conditions_and_steps",
44
+ "availability": "yes_no_with_conditions",
45
+ "limitation": "boundary",
46
+ "definition": "conclusion_and_detail",
47
+ "general": "conclusion_and_detail",
48
+ }
49
+
50
+ REQUIRED_EVIDENCE = {
51
+ "comparison": ("definition", "responsibility", "limitations"),
52
+ "troubleshooting": ("symptom", "cause", "check", "resolution", "completion_check"),
53
+ "procedure": ("prerequisites", "ordered_steps", "completion_check"),
54
+ "pricing": ("current_price", "conditions", "effective_date"),
55
+ "contract": ("current_terms", "conditions", "ordered_steps"),
56
+ "availability": ("implementation_status", "conditions", "limitations"),
57
+ "limitation": ("answer_boundary", "conditions", "exceptions"),
58
+ "definition": ("definition", "purpose", "scope"),
59
+ "general": ("confirmed_answer", "conditions"),
60
+ }
61
+
62
+ STOP_WORDS = {
63
+ "これ", "それ", "その", "この", "あれ", "あと", "また", "さらに", "について", "教えて", "ください",
64
+ "です", "ます", "したい", "できる", "できます", "どう", "どこ", "何", "なぜ", "場合", "もの", "こと",
65
+ "the", "a", "an", "is", "are", "what", "how", "can", "does", "please",
66
+ }
67
+
68
+
69
+ @dataclass(slots=True)
70
+ class QuestionTask:
71
+ task_id: str
72
+ text: str
73
+ subject: str
74
+ intent: str
75
+ audience: str
76
+ answer_shape: str
77
+ search_terms: list[str]
78
+ required_evidence: list[str]
79
+ depends_on: list[str] = field(default_factory=list)
80
+
81
+ def as_dict(self) -> dict[str, Any]:
82
+ return asdict(self)
83
+
84
+
85
+ @dataclass(slots=True)
86
+ class SearchTask:
87
+ task_id: str
88
+ query: str
89
+ terms: list[str]
90
+ required_evidence: list[str]
91
+ priority_sources: list[str]
92
+ comparison_conditions: list[str]
93
+ verification_conditions: list[str]
94
+
95
+ def as_dict(self) -> dict[str, Any]:
96
+ return asdict(self)
97
+
98
+
99
+ @dataclass(slots=True)
100
+ class EvidenceItem:
101
+ evidence_id: str
102
+ kb_id: str
103
+ task_ids: list[str]
104
+ question: str
105
+ short_answer: str
106
+ body: str
107
+ answer_boundary: str
108
+ target: str
109
+ score: float
110
+ source: str = "kb_search"
111
+
112
+ def as_dict(self) -> dict[str, Any]:
113
+ return asdict(self)
114
+
115
+
116
+ @dataclass(slots=True)
117
+ class PreparedSupport:
118
+ normalized_message: str
119
+ analysis_dictionary: dict[str, Any]
120
+ tasks: list[QuestionTask]
121
+ search_tasks: list[SearchTask]
122
+ evidence: list[EvidenceItem]
123
+ blueprint: dict[str, Any]
124
+ processing_grade: str
125
+ model_required: bool
126
+
127
+ def as_packet(self) -> dict[str, Any]:
128
+ return {
129
+ "normalized_message": self.normalized_message,
130
+ "analysis_dictionary": self.analysis_dictionary,
131
+ "question_tasks": [item.as_dict() for item in self.tasks],
132
+ "search_tasks": [item.as_dict() for item in self.search_tasks],
133
+ "evidence": [item.as_dict() for item in self.evidence],
134
+ "blueprint": self.blueprint,
135
+ "processing_grade": self.processing_grade,
136
+ }
137
+
138
+
139
+ @dataclass(slots=True)
140
+ class ResponseValidation:
141
+ passed: bool
142
+ violations: list[str]
143
+ answered_task_ids: list[str]
144
+ unresolved_task_ids: list[str]
145
+ used_evidence_ids: list[str]
146
+
147
+ def as_dict(self) -> dict[str, Any]:
148
+ return asdict(self)
149
+
150
+
151
+ def normalize_japanese(text: str) -> str:
152
+ normalized = unicodedata.normalize("NFKC", str(text or ""))
153
+ normalized = re.sub(r"[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]", "", normalized)
154
+ normalized = normalized.replace("。", "。").replace("、", "、")
155
+ normalized = re.sub(r"[ \t]+", " ", normalized)
156
+ normalized = re.sub(r"\n{3,}", "\n\n", normalized)
157
+ return normalized.strip()
158
+
159
+
160
+ def detect_subject(text: str, previous: str = "") -> str:
161
+ for subject, pattern in KNOWN_SUBJECTS:
162
+ if pattern.search(text):
163
+ return subject
164
+ return previous or "general"
165
+
166
+
167
+ def detect_intent(text: str) -> str:
168
+ for intent, pattern in INTENTS:
169
+ if pattern.search(text):
170
+ return intent
171
+ return "general"
172
+
173
+
174
+ def detect_audience(text: str, source: str) -> str:
175
+ if re.search(r"開発者|実装|コード|SDK|API|Webhook|JSON|HTTP|GitHub", text, re.I):
176
+ return "developer"
177
+ if re.search(r"法人|企業|スポンサー|投資|提携", text, re.I):
178
+ return "business"
179
+ return "registered_user" if source == "astera-app" else "general_user"
180
+
181
+
182
+ def _split_document(text: str) -> list[str]:
183
+ lines = [re.sub(r"^\s*(?:[-*・]|\d+[.).、]|[①-⑳])\s*", "", line).strip() for line in text.splitlines()]
184
+ lines = [line for line in lines if line] or [text]
185
+ candidates: list[tuple[str, bool]] = []
186
+ for line in lines:
187
+ for raw_part in re.split(r"(?<=[??!!。])\s*", line):
188
+ for raw_item in re.split(r"\s*(?:それと|さらに|加えて|あと、|また、)\s*", raw_part):
189
+ item = raw_item.strip()
190
+ if not item:
191
+ continue
192
+ question_mark = item.endswith(("?", "?"))
193
+ cleaned = item.strip(" \t。??")
194
+ if not cleaned:
195
+ continue
196
+ actionable = question_mark or detect_intent(cleaned) != "general" or bool(re.search(r"教えて|知りたい|確認したい", cleaned))
197
+ candidates.append((cleaned, actionable))
198
+ selected = [item for item, actionable in candidates if actionable]
199
+ if not selected:
200
+ selected = [item for item, _ in candidates]
201
+ deduped: list[str] = []
202
+ for item in selected:
203
+ if item not in deduped:
204
+ deduped.append(item)
205
+ return deduped[:8] or [text]
206
+
207
+
208
+ def extract_search_terms(text: str, *, subject: str, intent: str, active_topic: str = "") -> list[str]:
209
+ working = normalize_japanese(text)
210
+ working = re.sub(r"(?:について|という|できますか|できるか|教えてください|教えて|とは|何ですか|どうですか)", " ", working)
211
+ working = re.sub(r"[??!!。、,/::()()\[\]「」『』\-]+", " ", working)
212
+ working = re.sub(r"(?:は|が|を|に|で|と|や|へ|から|まで|より|の)", " ", working)
213
+ candidates = re.findall(r"[A-Za-z][A-Za-z0-9_.-]{1,40}|[一-龯ぁ-んァ-ヶー]{2,24}", working)
214
+ seeded = [subject, active_topic, intent, *candidates]
215
+ terms: list[str] = []
216
+ for term in seeded:
217
+ value = str(term or "").strip().lower()
218
+ if not value or value == "general" or value in STOP_WORDS or value in terms:
219
+ continue
220
+ terms.append(value)
221
+ return terms[:12]
222
+
223
+
224
+ def build_analysis_dictionary(
225
+ *, message: str, context: dict[str, Any], analysis: dict[str, Any], tasks: list[QuestionTask]
226
+ ) -> dict[str, Any]:
227
+ missing: list[str] = []
228
+ details = dict(analysis.get("confirmed_details") or context.get("confirmed_details") or {})
229
+ if any(item.intent == "troubleshooting" for item in tasks):
230
+ if not details.get("error_code"):
231
+ missing.append("shown_error_or_symptom")
232
+ if not details.get("relative_time"):
233
+ missing.append("occurrence_timing")
234
+ premises = [str(key) for key in details]
235
+ if context.get("user_goal"):
236
+ premises.append("existing_user_goal")
237
+ return {
238
+ "purpose": str(analysis.get("user_goal") or context.get("user_goal") or message),
239
+ "targets": sorted({item.subject for item in tasks}),
240
+ "conditions": details,
241
+ "constraints": [
242
+ "use_confirmed_kb_only_for_product_facts",
243
+ "do_not_claim_unexecuted_actions",
244
+ "do_not_expose_private_implementation",
245
+ "answer_every_detected_question_or_mark_it_unresolved",
246
+ ],
247
+ "missing_information": list(dict.fromkeys(missing)),
248
+ "premises": list(dict.fromkeys(premises)),
249
+ "uncertainty": [] if tasks else ["question_decomposition_failed"],
250
+ }
251
+
252
+
253
+ def _task_from_dict(item: dict[str, Any], index: int, *, source: str, previous_subject: str, active_topic: str) -> QuestionTask:
254
+ text = normalize_japanese(str(item.get("text") or item.get("question") or ""))
255
+ subject = str(item.get("subject") or detect_subject(text, previous_subject))
256
+ intent = str(item.get("intent") or detect_intent(text))
257
+ terms = [str(value).strip() for value in item.get("search_terms", []) if str(value).strip()]
258
+ if not terms:
259
+ terms = extract_search_terms(text, subject=subject, intent=intent, active_topic=active_topic)
260
+ required = item.get("required_evidence") or REQUIRED_EVIDENCE.get(intent, REQUIRED_EVIDENCE["general"])
261
+ return QuestionTask(
262
+ task_id=str(item.get("task_id") or f"q{index + 1}"),
263
+ text=text,
264
+ subject=subject,
265
+ intent=intent,
266
+ audience=str(item.get("audience") or detect_audience(text, source)),
267
+ answer_shape=str(item.get("answer_shape") or ANSWER_SHAPES.get(intent, "conclusion_and_detail")),
268
+ search_terms=terms,
269
+ required_evidence=[str(value) for value in required],
270
+ depends_on=[str(value) for value in item.get("depends_on", [])],
271
+ )
272
+
273
+
274
+ def decompose_questions(
275
+ *, message: str, source: str, context: dict[str, Any], analysis: dict[str, Any]
276
+ ) -> list[QuestionTask]:
277
+ previous_subject = str(context.get("active_topic") or analysis.get("active_topic") or "")
278
+ active_topic = str(analysis.get("active_topic") or previous_subject)
279
+ supplied = analysis.get("question_tasks")
280
+ if isinstance(supplied, list) and supplied:
281
+ tasks = [
282
+ _task_from_dict(item, index, source=source, previous_subject=previous_subject, active_topic=active_topic)
283
+ for index, item in enumerate(supplied)
284
+ if isinstance(item, dict)
285
+ ]
286
+ if tasks:
287
+ return tasks[:8]
288
+ return [
289
+ _task_from_dict({"text": part}, index, source=source, previous_subject=previous_subject, active_topic=active_topic)
290
+ for index, part in enumerate(_split_document(message))
291
+ ]
292
+
293
+
294
+ def plan_search(tasks: Iterable[QuestionTask], *, user_goal: str, active_topic: str) -> list[SearchTask]:
295
+ del user_goal
296
+ plans: list[SearchTask] = []
297
+ for task in tasks:
298
+ terms = list(task.search_terms)
299
+ for value in (task.subject, task.intent, active_topic):
300
+ value = str(value or "").strip().lower()
301
+ if value and value != "general" and value not in terms:
302
+ terms.append(value)
303
+ plans.append(
304
+ SearchTask(
305
+ task_id=task.task_id,
306
+ query=(" ".join(terms[:12]) or task.text)[:1000],
307
+ terms=terms[:12],
308
+ required_evidence=task.required_evidence,
309
+ priority_sources=["approved_notion_kb", "runtime_kb_snapshot", "bounded_session_evidence"],
310
+ comparison_conditions=["same_product_scope"] if task.intent == "comparison" else [],
311
+ verification_conditions=["public_status", "implementation_status", "answer_boundary", "evidence_path"],
312
+ )
313
+ )
314
+ return plans
315
+
316
+
317
+ class EvidenceRetriever:
318
+ def __init__(self, search: Callable[..., list[Any]], *, max_parallel: int = 4, limit_per_task: int = 5):
319
+ self.search = search
320
+ self.max_parallel = max(1, min(max_parallel, 8))
321
+ self.limit_per_task = max(1, min(limit_per_task, 8))
322
+
323
+ async def retrieve(self, plans: list[SearchTask]) -> list[EvidenceItem]:
324
+ semaphore = asyncio.Semaphore(self.max_parallel)
325
+
326
+ async def run(plan: SearchTask) -> tuple[SearchTask, list[Any]]:
327
+ async with semaphore:
328
+ hits = await asyncio.to_thread(self.search, plan.query, limit=self.limit_per_task)
329
+ return plan, hits
330
+
331
+ results = await asyncio.gather(*(run(plan) for plan in plans))
332
+ merged: dict[str, EvidenceItem] = {}
333
+ for plan, hits in results:
334
+ for hit in hits:
335
+ kb_id = str(getattr(hit, "kb_id", ""))
336
+ if not kb_id:
337
+ continue
338
+ item = merged.get(kb_id)
339
+ if item is None:
340
+ item = EvidenceItem(
341
+ evidence_id=f"kb:{kb_id}", kb_id=kb_id, task_ids=[],
342
+ question=str(getattr(hit, "question", ""))[:1000],
343
+ short_answer=str(getattr(hit, "short_answer", ""))[:2000],
344
+ body=str(getattr(hit, "body", ""))[:5000],
345
+ answer_boundary=str(getattr(hit, "answer_boundary", ""))[:1000],
346
+ target=str(getattr(hit, "target", ""))[:300],
347
+ score=float(getattr(hit, "score", 0.0)), source="kb_search",
348
+ )
349
+ merged[kb_id] = item
350
+ if plan.task_id not in item.task_ids:
351
+ item.task_ids.append(plan.task_id)
352
+ return sorted(merged.values(), key=lambda item: (-item.score, item.kb_id))
353
+
354
+
355
+ def _cached_evidence(context: dict[str, Any], tasks: list[QuestionTask]) -> list[EvidenceItem]:
356
+ cached = context.get("evidence_cache")
357
+ if not isinstance(cached, dict):
358
+ return []
359
+ result: list[EvidenceItem] = []
360
+ for evidence_id, raw in list(cached.items())[-16:]:
361
+ if not isinstance(raw, dict):
362
+ continue
363
+ combined = normalize_japanese(" ".join(str(raw.get(key) or "") for key in ("question", "short_answer", "answer_boundary"))).lower()
364
+ task_ids: list[str] = []
365
+ for task in tasks:
366
+ terms = [task.subject.lower(), *[term.lower() for term in task.search_terms]]
367
+ if any(term and term != "general" and term in combined for term in terms):
368
+ task_ids.append(task.task_id)
369
+ if not task_ids:
370
+ continue
371
+ kb_id = str(raw.get("kb_id") or str(evidence_id).removeprefix("kb:"))
372
+ result.append(
373
+ EvidenceItem(
374
+ evidence_id=str(evidence_id), kb_id=kb_id, task_ids=task_ids,
375
+ question=str(raw.get("question") or "")[:1000],
376
+ short_answer=str(raw.get("short_answer") or "")[:2000],
377
+ body="", answer_boundary=str(raw.get("answer_boundary") or "")[:1000],
378
+ target="", score=0.1, source="session_evidence_cache",
379
+ )
380
+ )
381
+ return result
382
+
383
+
384
+ def _merge_evidence(fresh: list[EvidenceItem], cached: list[EvidenceItem]) -> list[EvidenceItem]:
385
+ merged = {item.evidence_id: item for item in fresh}
386
+ for item in cached:
387
+ existing = merged.get(item.evidence_id)
388
+ if existing is None:
389
+ merged[item.evidence_id] = item
390
+ continue
391
+ existing.task_ids = list(dict.fromkeys([*existing.task_ids, *item.task_ids]))
392
+ return sorted(merged.values(), key=lambda item: (-item.score, item.evidence_id))
393
+
394
+
395
+ def _clarification_for(task: QuestionTask) -> str:
396
+ if task.intent == "troubleshooting":
397
+ return "表示されている症状またはエラー、現在の画面、直前に行った操作を確認すると原因と解決手順を絞れます。"
398
+ if task.intent in {"pricing", "contract"}:
399
+ return "料金・契約条件は現在の正本情報で確認する必要があるため、対象プランまたは契約状態を特定します。"
400
+ if task.intent == "comparison":
401
+ return "比較対象と、重視する条件を特定すると正確に違いを整理できます。"
402
+ if task.intent == "procedure":
403
+ return "対象の機能、現在の画面、実行したい操作を確認すると正しい順序で案内できます。"
404
+ return "対象の製品または機能と、確認したい点を特定すると正確に回答できます。"
405
+
406
+
407
+ def _render_task_section(task: QuestionTask, evidence: list[EvidenceItem]) -> tuple[str, list[str], list[dict[str, Any]]]:
408
+ matched = [item for item in evidence if task.task_id in item.task_ids][:4]
409
+ if not matched:
410
+ return _clarification_for(task), [], []
411
+ blocks: list[str] = []
412
+ evidence_blocks: list[dict[str, Any]] = []
413
+ used: list[str] = []
414
+ for item in matched:
415
+ conclusion = item.short_answer.strip()
416
+ detail = item.body.strip()
417
+ text = conclusion
418
+ if detail and detail not in conclusion:
419
+ text += "\n\n" + detail
420
+ if task.answer_shape == "comparison" and len(matched) > 1:
421
+ text = f"#### {item.question or item.kb_id}\n\n{text}"
422
+ if text and text not in blocks:
423
+ blocks.append(text)
424
+ used.append(item.evidence_id)
425
+ evidence_blocks.append(
426
+ {
427
+ "evidence_id": item.evidence_id,
428
+ "question": item.question,
429
+ "conclusion": conclusion,
430
+ "detail": detail,
431
+ "answer_boundary": item.answer_boundary,
432
+ "source": item.source,
433
+ }
434
+ )
435
+ return "\n\n".join(blocks), list(dict.fromkeys(used)), evidence_blocks
436
+
437
+
438
+ def _selected_runtime_skills(tasks: list[QuestionTask], context_used: bool) -> list[str]:
439
+ selected = ["japanese_normalization", "question_task_decomposition", "evidence_binding", "answer_coverage_validation"]
440
+ if context_used:
441
+ selected.append("conversation_reference_resolution")
442
+ if len(tasks) > 1:
443
+ selected.extend(["multi_question_management", "sectioned_document_composition"])
444
+ for task in tasks:
445
+ selected.append(f"audience_{task.audience}")
446
+ selected.append(f"shape_{task.answer_shape}")
447
+ return list(dict.fromkeys(selected))
448
+
449
+
450
+ def build_blueprint(
451
+ *, tasks: list[QuestionTask], evidence: list[EvidenceItem], user_goal: str,
452
+ active_topic: str, locale: str, context_used: bool = False
453
+ ) -> dict[str, Any]:
454
+ sections: list[dict[str, Any]] = []
455
+ deterministic: list[str] = []
456
+ unresolved: list[str] = []
457
+ all_used: list[str] = []
458
+ for index, task in enumerate(tasks, start=1):
459
+ body, used, evidence_blocks = _render_task_section(task, evidence)
460
+ if not used:
461
+ unresolved.append(task.task_id)
462
+ all_used.extend(used)
463
+ sections.append(
464
+ {
465
+ "task_id": task.task_id, "heading": task.text, "answer_shape": task.answer_shape,
466
+ "body": body, "evidence_ids": used, "evidence_blocks": evidence_blocks,
467
+ "resolved": bool(used), "audience": task.audience,
468
+ }
469
+ )
470
+ deterministic.append(body if len(tasks) == 1 else f"### {index}. {task.text}\n\n{body}")
471
+ intents = {item.intent for item in tasks}
472
+ model_required = len(tasks) > 1 or "comparison" in intents or len(evidence) > 3
473
+ return {
474
+ "user_goal": user_goal, "active_topic": active_topic, "locale": locale,
475
+ "sections": sections, "unresolved_task_ids": unresolved,
476
+ "selected_runtime_skills": _selected_runtime_skills(tasks, context_used),
477
+ "prohibited_claims": [
478
+ "facts_not_present_in_evidence", "unexecuted_action_completion",
479
+ "private_implementation_details", "model_or_provider_identity",
480
+ ],
481
+ "required_style": ["conclusion_first", "specific", "polite", "resolve_to_next_step", "no_repeated_questions"],
482
+ "deterministic_answer": "\n\n".join(deterministic).strip(),
483
+ "evidence_ids": list(dict.fromkeys(all_used)), "model_required": model_required,
484
+ }
485
+
486
+
487
+ def classify_processing_grade(tasks: list[QuestionTask], evidence: list[EvidenceItem], blueprint: dict[str, Any]) -> str:
488
+ if blueprint.get("unresolved_task_ids"):
489
+ return "L3_CONTEXT_REQUIRED"
490
+ if len(tasks) > 1:
491
+ return "L2_MULTI_TASK_COMPOSE"
492
+ if len(evidence) > 1 or tasks[0].intent in {"comparison", "troubleshooting", "procedure"}:
493
+ return "L1_STRUCTURED_COMPOSE"
494
+ return "L0_DETERMINISTIC_EXACT"
495
+
496
+
497
+ class SupportRuntime:
498
+ def __init__(self, *, search: Callable[..., list[Any]], max_parallel_search: int = 4):
499
+ self.retriever = EvidenceRetriever(search, max_parallel=max_parallel_search)
500
+
501
+ async def prepare(
502
+ self, *, message: str, locale: str, source: str,
503
+ context: dict[str, Any], analysis: dict[str, Any]
504
+ ) -> PreparedSupport:
505
+ normalized = normalize_japanese(message)
506
+ tasks = decompose_questions(message=normalized, source=source, context=context, analysis=analysis)
507
+ user_goal = str(analysis.get("user_goal") or context.get("user_goal") or normalized)
508
+ active_topic = str(analysis.get("active_topic") or context.get("active_topic") or detect_subject(normalized))
509
+ dictionary = build_analysis_dictionary(message=normalized, context=context, analysis=analysis, tasks=tasks)
510
+ plans = plan_search(tasks, user_goal=user_goal, active_topic=active_topic)
511
+ fresh = await self.retriever.retrieve(plans)
512
+ evidence = _merge_evidence(fresh, _cached_evidence(context, tasks))
513
+ blueprint = build_blueprint(
514
+ tasks=tasks, evidence=evidence, user_goal=user_goal, active_topic=active_topic,
515
+ locale=locale, context_used=bool(context.get("user_goal") or context.get("turns")),
516
+ )
517
+ grade = classify_processing_grade(tasks, evidence, blueprint)
518
+ return PreparedSupport(
519
+ normalized_message=normalized, analysis_dictionary=dictionary,
520
+ tasks=tasks, search_tasks=plans, evidence=evidence, blueprint=blueprint,
521
+ processing_grade=grade, model_required=bool(blueprint.get("model_required")),
522
+ )
523
+
524
+
525
+ def validate_response(
526
+ *, answer: str, prepared: PreparedSupport, answered_task_ids: Iterable[str],
527
+ unresolved_task_ids: Iterable[str], used_evidence_ids: Iterable[str],
528
+ external_violations: Iterable[str] = ()
529
+ ) -> ResponseValidation:
530
+ text = str(answer or "").strip()
531
+ answered = list(dict.fromkeys(str(value) for value in answered_task_ids if str(value)))
532
+ unresolved = list(dict.fromkeys(str(value) for value in unresolved_task_ids if str(value)))
533
+ used = list(dict.fromkeys(str(value) for value in used_evidence_ids if str(value)))
534
+ task_ids = {item.task_id for item in prepared.tasks}
535
+ evidence_ids = {item.evidence_id for item in prepared.evidence}
536
+ violations = list(external_violations)
537
+ if not text:
538
+ violations.append("empty_answer")
539
+ if not set(answered).issubset(task_ids) or not set(unresolved).issubset(task_ids):
540
+ violations.append("unknown_task_reference")
541
+ if task_ids.difference(answered).difference(unresolved):
542
+ violations.append("question_coverage_missing")
543
+ if not set(used).issubset(evidence_ids):
544
+ violations.append("unknown_evidence_reference")
545
+ if evidence_ids and answered and not used:
546
+ violations.append("evidence_grounding_missing")
547
+ if re.search(r"(?:情報がありません|回答できません|お問い合わせください|I (?:cannot|can't) answer)", text, re.I):
548
+ violations.append("generic_non_answer")
549
+ if re.search(r"(?:返金しました|削除しました|解約しました|処理しました|設定しました|refunded|deleted|cancelled)", text, re.I):
550
+ violations.append("unverified_action_claim")
551
+ if contains_internal_implementation(text):
552
+ violations.append("internal_implementation")
553
+ return ResponseValidation(
554
+ passed=not violations, violations=list(dict.fromkeys(violations)),
555
+ answered_task_ids=answered, unresolved_task_ids=unresolved, used_evidence_ids=used,
556
+ )
557
+
558
+
559
+ class FeedbackStore:
560
+ """Stores anonymized review candidates; it never publishes to Notion automatically."""
561
+
562
+ def __init__(self, root: Path):
563
+ self.root = root / "feedback"
564
+ self.root.mkdir(parents=True, exist_ok=True)
565
+ self.index_path = self.root / "candidate-index.json"
566
+ self._lock = threading.Lock()
567
+
568
+ def record(
569
+ self, *, session_id: str, message: str, prepared: PreparedSupport,
570
+ validation: ResponseValidation, status: str
571
+ ) -> str | None:
572
+ safe_message = redact_text(normalize_japanese(message)).text[:1000]
573
+ safe_tasks = sanitize_structure([item.as_dict() for item in prepared.tasks])
574
+ safe_search_tasks = sanitize_structure([item.as_dict() for item in prepared.search_tasks])
575
+ safe_kb_ids = [redact_text(item.kb_id).text[:160] for item in prepared.evidence]
576
+ fingerprint_source = json.dumps(
577
+ {"message": safe_message, "tasks": safe_tasks, "kb_ids": sorted(safe_kb_ids)},
578
+ ensure_ascii=False, sort_keys=True,
579
+ )
580
+ fingerprint = hashlib.sha256(fingerprint_source.encode("utf-8")).hexdigest()
581
+ candidate_id = "qic_" + fingerprint[:24]
582
+ with self._lock:
583
+ index = self._read_index()
584
+ if fingerprint in index:
585
+ return None
586
+ gap_types: list[str] = []
587
+ if prepared.blueprint.get("unresolved_task_ids"):
588
+ gap_types.append("kb_content_gap")
589
+ if any(plan.query and not any(plan.task_id in item.task_ids for item in prepared.evidence) for plan in prepared.search_tasks):
590
+ gap_types.append("search_term_or_content_gap")
591
+ if validation.violations:
592
+ gap_types.append("answer_quality_gap")
593
+ row = {
594
+ "candidate_id": candidate_id, "fingerprint": fingerprint,
595
+ "created_at": datetime.now(UTC).isoformat(),
596
+ "session_hash": hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:24],
597
+ "normalized_question": safe_message,
598
+ "question_tasks": safe_tasks, "search_tasks": safe_search_tasks,
599
+ "matched_kb_ids": sorted(safe_kb_ids),
600
+ "gap_types": list(dict.fromkeys(gap_types)), "status": status,
601
+ "validation_violations": sanitize_structure(validation.violations),
602
+ "approval_required": True, "auto_publish": False,
603
+ }
604
+ daily = self.root / (datetime.now(UTC).date().isoformat() + ".jsonl")
605
+ with daily.open("a", encoding="utf-8") as handle:
606
+ handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
607
+ index[fingerprint] = candidate_id
608
+ if len(index) > 10000:
609
+ index = dict(list(index.items())[-10000:])
610
+ temporary = self.index_path.with_suffix(".tmp")
611
+ temporary.write_text(json.dumps(index, ensure_ascii=False, sort_keys=True), encoding="utf-8")
612
+ temporary.replace(self.index_path)
613
+ return candidate_id
614
+
615
+ def _read_index(self) -> dict[str, str]:
616
+ if not self.index_path.exists():
617
+ return {}
618
+ try:
619
+ value = json.loads(self.index_path.read_text(encoding="utf-8"))
620
+ return value if isinstance(value, dict) else {}
621
+ except (OSError, json.JSONDecodeError):
622
+ return {}
runtime/v8.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import subprocess
10
+ import tarfile
11
+ import time
12
+ import urllib.request
13
+ import uuid
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .config import Settings
18
+ from .schemas import NodeResponse
19
+
20
+
21
+ class V8Unavailable(RuntimeError):
22
+ pass
23
+
24
+
25
+ NODE_VERSION = "22.16.0"
26
+ NODE_DISTRIBUTIONS = {
27
+ "x86_64": ("linux-x64", "f4cb75bb036f0d0eddf6b79d9596df1aaab9ddccd6a20bf489be5abe9467e84e"),
28
+ "amd64": ("linux-x64", "f4cb75bb036f0d0eddf6b79d9596df1aaab9ddccd6a20bf489be5abe9467e84e"),
29
+ "aarch64": ("linux-arm64", "eab80cb88f8fda1e65f5e8d0420c9809bdb320b03fd34976ab7161b6e703b910"),
30
+ "arm64": ("linux-arm64", "eab80cb88f8fda1e65f5e8d0420c9809bdb320b03fd34976ab7161b6e703b910"),
31
+ }
32
+
33
+
34
+ def _node_major(binary: str) -> int:
35
+ try:
36
+ version = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=5, check=True).stdout.strip()
37
+ return int(version.removeprefix("v").split(".")[0])
38
+ except (OSError, ValueError, subprocess.SubprocessError):
39
+ return 0
40
+
41
+
42
+ def _safe_socket_path(configured: Path) -> Path:
43
+ """Unix sockets have a short platform path limit; use a stable /tmp path when needed."""
44
+ encoded = os.fsencode(str(configured))
45
+ if len(encoded) <= 96:
46
+ return configured
47
+ digest = hashlib.sha256(encoded).hexdigest()[:20]
48
+ return Path("/tmp") / f"customer-ai-v8-{digest}.sock"
49
+
50
+
51
+ class NodeBootstrap:
52
+ def __init__(self, settings: Settings):
53
+ self.settings = settings
54
+
55
+ def resolve(self) -> str:
56
+ if _node_major(self.settings.node_binary) >= 22:
57
+ return self.settings.node_binary
58
+ machine = platform.machine().lower()
59
+ if machine not in NODE_DISTRIBUTIONS:
60
+ raise V8Unavailable(f"unsupported Node.js platform: {machine}")
61
+ flavor, expected_sha = NODE_DISTRIBUTIONS[machine]
62
+ filename = f"node-v{NODE_VERSION}-{flavor}.tar.xz"
63
+ cache_dir = self.settings.data_root / "runtime" / "node"
64
+ cache_dir.mkdir(parents=True, exist_ok=True)
65
+ archive = cache_dir / filename
66
+ if not archive.exists() or self._sha256(archive) != expected_sha:
67
+ temp_archive = archive.with_suffix(".download")
68
+ temp_archive.unlink(missing_ok=True)
69
+ url = f"https://nodejs.org/dist/v{NODE_VERSION}/{filename}"
70
+ with urllib.request.urlopen(url, timeout=120) as response, temp_archive.open("wb") as handle:
71
+ shutil.copyfileobj(response, handle)
72
+ if self._sha256(temp_archive) != expected_sha:
73
+ temp_archive.unlink(missing_ok=True)
74
+ raise V8Unavailable("downloaded Node.js archive checksum mismatch")
75
+ os.replace(temp_archive, archive)
76
+ local_root = Path("/tmp") / f"customer-ai-node-v{NODE_VERSION}-{flavor}"
77
+ binary = local_root / "bin" / "node"
78
+ if _node_major(str(binary)) >= 22:
79
+ return str(binary)
80
+ temp_root = local_root.with_name(local_root.name + f".tmp-{os.getpid()}")
81
+ shutil.rmtree(temp_root, ignore_errors=True)
82
+ temp_root.mkdir(parents=True, exist_ok=True)
83
+ with tarfile.open(archive, mode="r:xz") as package:
84
+ members = package.getmembers()
85
+ prefix = f"node-v{NODE_VERSION}-{flavor}/"
86
+ for member in members:
87
+ if not member.name.startswith(prefix) or ".." in Path(member.name).parts:
88
+ raise V8Unavailable("unsafe Node.js archive path")
89
+ package.extractall(temp_root, filter="data")
90
+ extracted = temp_root / f"node-v{NODE_VERSION}-{flavor}"
91
+ shutil.rmtree(local_root, ignore_errors=True)
92
+ os.replace(extracted, local_root)
93
+ shutil.rmtree(temp_root, ignore_errors=True)
94
+ binary.chmod(0o755)
95
+ if _node_major(str(binary)) < 22:
96
+ raise V8Unavailable("bootstrapped Node.js runtime failed validation")
97
+ return str(binary)
98
+
99
+ @staticmethod
100
+ def _sha256(path: Path) -> str:
101
+ digest = hashlib.sha256()
102
+ with path.open("rb") as handle:
103
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
104
+ digest.update(chunk)
105
+ return digest.hexdigest()
106
+
107
+
108
+ class V8Supervisor:
109
+ def __init__(self, settings: Settings):
110
+ self.settings = settings
111
+ self.process: asyncio.subprocess.Process | None = None
112
+ self._lock = asyncio.Lock()
113
+ self.node_binary = settings.node_binary
114
+ self.socket_path = _safe_socket_path(settings.node_socket)
115
+
116
+ async def start(self) -> None:
117
+ async with self._lock:
118
+ if self.process and self.process.returncode is None:
119
+ return
120
+ if self.process is not None:
121
+ try:
122
+ await self.process.wait()
123
+ except (ProcessLookupError, ChildProcessError):
124
+ pass
125
+ self.process = None
126
+ self.node_binary = await asyncio.to_thread(NodeBootstrap(self.settings).resolve)
127
+ self.socket_path.unlink(missing_ok=True)
128
+ script = Path(__file__).resolve().parent.parent / "v8" / "server.mjs"
129
+ env = os.environ.copy()
130
+ env["CUSTOMER_AI_NODE_SOCKET"] = str(self.socket_path)
131
+ self.process = await asyncio.create_subprocess_exec(
132
+ self.node_binary,
133
+ f"--max-old-space-size={self.settings.node_memory_mb}",
134
+ str(script),
135
+ env=env,
136
+ stdout=asyncio.subprocess.PIPE,
137
+ stderr=asyncio.subprocess.PIPE,
138
+ )
139
+ for _ in range(50):
140
+ if self.socket_path.exists():
141
+ return
142
+ if self.process.returncode is not None:
143
+ stderr = b""
144
+ if self.process.stderr is not None:
145
+ stderr = await self.process.stderr.read()
146
+ message = stderr.decode("utf-8", errors="replace").strip()
147
+ raise V8Unavailable(message or "Node V8 process exited before socket readiness")
148
+ await asyncio.sleep(0.1)
149
+ await self.stop()
150
+ raise V8Unavailable("Node V8 socket did not become ready")
151
+
152
+ async def stop(self) -> None:
153
+ process = self.process
154
+ self.process = None
155
+ try:
156
+ if process is None:
157
+ return
158
+ if process.returncode is None:
159
+ try:
160
+ process.terminate()
161
+ except ProcessLookupError:
162
+ pass
163
+ try:
164
+ await asyncio.wait_for(process.wait(), timeout=5)
165
+ except TimeoutError:
166
+ try:
167
+ process.kill()
168
+ except ProcessLookupError:
169
+ pass
170
+ try:
171
+ await process.wait()
172
+ except (ProcessLookupError, ChildProcessError):
173
+ pass
174
+ else:
175
+ try:
176
+ await process.wait()
177
+ except (ProcessLookupError, ChildProcessError):
178
+ pass
179
+ finally:
180
+ self.socket_path.unlink(missing_ok=True)
181
+
182
+ async def request(self, phase: str, payload: dict[str, Any], *, timeout: float = 15.0) -> dict[str, Any]:
183
+ if not self.process or self.process.returncode is not None:
184
+ await self.start()
185
+ request_id = str(uuid.uuid4())
186
+ request = {
187
+ "request_id": request_id,
188
+ "phase": phase,
189
+ "deadline_at": int((time.time() + timeout) * 1000),
190
+ "payload": payload,
191
+ }
192
+ try:
193
+ reader, writer = await asyncio.open_unix_connection(str(self.socket_path))
194
+ writer.write((json.dumps(request, ensure_ascii=False) + "\n").encode())
195
+ await writer.drain()
196
+ raw = await asyncio.wait_for(reader.readline(), timeout=timeout)
197
+ writer.close()
198
+ await writer.wait_closed()
199
+ response = NodeResponse.model_validate_json(raw)
200
+ if not response.ok:
201
+ raise V8Unavailable(response.error_code or "node_error")
202
+ return response.result
203
+ except (ConnectionError, OSError, TimeoutError, ValueError) as exc:
204
+ await self.stop()
205
+ raise V8Unavailable(str(exc)) from exc
scripts/provision_hf.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = ["huggingface_hub>=1.24.0", "httpx>=0.28,<1"]
4
+ # ///
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import tempfile
10
+ import time
11
+ from pathlib import Path
12
+
13
+ import httpx
14
+ from huggingface_hub import HfApi, Volume
15
+
16
+ # Deployment preserves existing HF runtime secrets when GitHub values are absent.
17
+ ROOT = Path(__file__).resolve().parents[1]
18
+ TOKEN = os.environ.get("HF_TOKEN", "")
19
+ NAMESPACE = os.environ.get("HF_NAMESPACE", "G-ACE")
20
+ SPACE_NAME = os.environ.get("HF_SPACE_NAME", "astera-customerAI")
21
+ BUCKET_NAME = os.environ.get("HF_BUCKET_NAME", "astera-customerai-data")
22
+ SPACE_ID = f"{NAMESPACE}/{SPACE_NAME}"
23
+ BUCKET_ID = f"{NAMESPACE}/{BUCKET_NAME}"
24
+ MODEL_REVISION = os.environ.get(
25
+ "CUSTOMER_AI_MODEL_REVISION",
26
+ "cdbee75f17c01a7cc42f958dc650907174af0554",
27
+ )
28
+
29
+ RUNTIME_SECRETS = (
30
+ "CUSTOMER_AI_HMAC_SECRET",
31
+ "INTERNAL_EVENT_API_URL",
32
+ "INTERNAL_EVENT_API_TOKEN",
33
+ "NOTION_TOKEN",
34
+ "NOTION_DATA_SOURCE_ID",
35
+ )
36
+
37
+
38
+ def runtime_environment() -> dict[str, str]:
39
+ if not TOKEN:
40
+ raise SystemExit("HF_TOKEN_MISSING")
41
+
42
+ values: dict[str, str] = {}
43
+ missing: list[str] = []
44
+ for name in RUNTIME_SECRETS:
45
+ value = os.environ.get(name, "").strip()
46
+ if value:
47
+ values[name] = value
48
+ else:
49
+ missing.append(name)
50
+
51
+ if missing:
52
+ # upload_folder and Space configuration do not delete existing HF secrets.
53
+ # Missing GitHub secrets are therefore left untouched so an existing Space
54
+ # can retain its current runtime credentials during a code-only deployment.
55
+ print("HF_RUNTIME_SECRETS_PRESERVED=" + ",".join(missing))
56
+ return values
57
+
58
+
59
+ def verify_bucket(api: HfApi) -> None:
60
+ payload = json.dumps(
61
+ {
62
+ "github_sha": os.environ.get("GITHUB_SHA", "manual"),
63
+ "verified_at": time.time(),
64
+ },
65
+ sort_keys=True,
66
+ ).encode("utf-8")
67
+ probe_path = f"runtime-verification/deploy-{os.environ.get('GITHUB_RUN_ID', 'manual')}.json"
68
+ with tempfile.TemporaryDirectory(prefix="hf-customer-ai-") as temporary:
69
+ downloaded = Path(temporary) / "probe.json"
70
+ try:
71
+ api.batch_bucket_files(BUCKET_ID, add=[(payload, probe_path)])
72
+ api.download_bucket_files(BUCKET_ID, files=[(probe_path, str(downloaded))])
73
+ if downloaded.read_bytes() != payload:
74
+ raise RuntimeError("HF_BUCKET_READ_AFTER_WRITE_MISMATCH")
75
+ print("HF_BUCKET_WRITE_READ_OK")
76
+ finally:
77
+ api.batch_bucket_files(BUCKET_ID, delete=[probe_path])
78
+ print("HF_BUCKET_PROBE_REMOVED")
79
+
80
+
81
+ def wait_for_space(api: HfApi) -> None:
82
+ last = ""
83
+ headers = {"Authorization": f"Bearer {TOKEN}"}
84
+ for attempt in range(30):
85
+ info = api.space_info(
86
+ SPACE_ID,
87
+ expand=["runtime", "private", "sha", "sdk", "subdomain"],
88
+ )
89
+ if not info.private:
90
+ raise RuntimeError("HF_SPACE_NOT_PRIVATE")
91
+ if info.subdomain:
92
+ health_url = f"https://{info.subdomain}.hf.space/healthz"
93
+ ready_url = f"https://{info.subdomain}.hf.space/readyz"
94
+ try:
95
+ response = httpx.get(
96
+ health_url,
97
+ headers=headers,
98
+ timeout=30,
99
+ follow_redirects=True,
100
+ )
101
+ last = f"{response.status_code}:{response.text[:300]}"
102
+ if response.status_code == 200 and response.json().get("status") == "ok":
103
+ print("HF_SPACE_HEALTH_OK")
104
+ ready = httpx.get(
105
+ ready_url,
106
+ headers=headers,
107
+ timeout=30,
108
+ follow_redirects=True,
109
+ )
110
+ print(f"HF_SPACE_READY_STATUS={ready.status_code}")
111
+ print(ready.text[:2000])
112
+ print("HF_PRIVATE_RUNTIME_DEPLOYED")
113
+ return
114
+ except Exception as error: # build and cold-start transition
115
+ last = repr(error)
116
+ if attempt == 29:
117
+ break
118
+ time.sleep(20)
119
+ raise RuntimeError(f"HF_SPACE_HEALTH_TIMEOUT:{last}")
120
+
121
+
122
+ def main() -> None:
123
+ runtime_secrets = runtime_environment()
124
+ api = HfApi(token=TOKEN)
125
+ identity = api.whoami()
126
+ print(f"HF_AUTHENTICATED_AS={identity.get('name') or identity.get('fullname')}")
127
+
128
+ bucket = api.create_bucket(BUCKET_ID, private=True, exist_ok=True)
129
+ if not bucket:
130
+ raise RuntimeError("HF_BUCKET_CREATE_FAILED")
131
+ print(f"HF_PRIVATE_BUCKET_READY={BUCKET_ID}")
132
+
133
+ volume = Volume(
134
+ type="bucket",
135
+ source=BUCKET_ID,
136
+ mount_path="/data/customer-ai",
137
+ read_only=False,
138
+ )
139
+ api.create_repo(
140
+ repo_id=SPACE_ID,
141
+ repo_type="space",
142
+ private=True,
143
+ exist_ok=True,
144
+ space_sdk="gradio",
145
+ space_volumes=[volume],
146
+ )
147
+ api.update_repo_settings(repo_id=SPACE_ID, repo_type="space", private=True)
148
+ api.set_space_volumes(SPACE_ID, volumes=[volume])
149
+ print(f"HF_PRIVATE_SPACE_READY={SPACE_ID}")
150
+ print("HF_BUCKET_MOUNT=/data/customer-ai")
151
+
152
+ for key, value in runtime_secrets.items():
153
+ api.add_space_secret(SPACE_ID, key=key, value=value)
154
+
155
+ variables = {
156
+ "CUSTOMER_AI_DATA_ROOT": "/data/customer-ai",
157
+ "CUSTOMER_AI_MODEL_ID": os.environ.get(
158
+ "CUSTOMER_AI_MODEL_ID", "Qwen/Qwen3-4B-Instruct-2507"
159
+ ),
160
+ "CUSTOMER_AI_MODEL_REVISION": MODEL_REVISION,
161
+ "CUSTOMER_AI_ENABLE_MODEL": os.environ.get("CUSTOMER_AI_ENABLE_MODEL", "0"),
162
+ "CUSTOMER_AI_GPU_DAILY_BUDGET_SECONDS": "2100",
163
+ "CUSTOMER_AI_NODE_BINARY": "node",
164
+ "CUSTOMER_AI_NODE_SOCKET": "/tmp/customer-ai-v8.sock",
165
+ "CUSTOMER_AI_NODE_MEMORY_MB": "384",
166
+ "CUSTOMER_AI_SESSION_CACHE_TTL_SECONDS": "1800",
167
+ "CUSTOMER_AI_SESSION_CACHE_MAX_SESSIONS": "256",
168
+ "CUSTOMER_AI_SESSION_CACHE_MAX_TURNS": "12",
169
+ "CUSTOMER_AI_KB_CACHE_TTL_SECONDS": "120",
170
+ "CUSTOMER_AI_KB_CACHE_MAX_ENTRIES": "256",
171
+ "INTERNAL_EVENT_SOURCE_ID": os.environ.get(
172
+ "INTERNAL_EVENT_SOURCE_ID", "hf-private-runtime"
173
+ ),
174
+ "INTERNAL_EVENT_RESULT_DESTINATION_ID": os.environ.get(
175
+ "INTERNAL_EVENT_RESULT_DESTINATION_ID", "app-receiver"
176
+ ),
177
+ "DEPLOYED_GITHUB_COMMIT": os.environ.get("GITHUB_SHA", "manual"),
178
+ }
179
+ for key, value in variables.items():
180
+ api.add_space_variable(SPACE_ID, key=key, value=value)
181
+ print(f"HF_SPACE_SECRETS_CONFIGURED={len(runtime_secrets)}")
182
+ print(f"HF_SPACE_VARIABLES_CONFIGURED={len(variables)}")
183
+
184
+ api.upload_folder(
185
+ repo_id=SPACE_ID,
186
+ repo_type="space",
187
+ folder_path=str(ROOT),
188
+ commit_message=os.environ.get(
189
+ "HF_COMMIT_MESSAGE", "Deploy Customer AI from GitHub main"
190
+ ),
191
+ ignore_patterns=[
192
+ ".git/**",
193
+ ".github/**",
194
+ ".pytest_cache/**",
195
+ ".pytest-*/**",
196
+ ".ruff_cache/**",
197
+ "__pycache__/**",
198
+ "*.pyc",
199
+ ".env",
200
+ "docs/**",
201
+ "tests/**",
202
+ "edge/**",
203
+ "test-results/**",
204
+ "state.md",
205
+ ],
206
+ )
207
+ api.restart_space(SPACE_ID)
208
+ print("HF_SPACE_UPLOAD_COMPLETE")
209
+
210
+ verify_bucket(api)
211
+ wait_for_space(api)
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()
scripts/review.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import ast
5
+ import json
6
+ from pathlib import Path
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+
10
+ REVIEWS = {
11
+ "conversation": {
12
+ "prompt": "Review whether first answers and multiple follow-up turns preserve the same user goal, active topic, confirmed details, answered and unresolved questions, recent turns, evidence, and the last answer blueprint.",
13
+ "checks": [
14
+ "ConversationCache",
15
+ "user_goal",
16
+ "active_topic",
17
+ "unresolved_questions",
18
+ "answered_question_ids",
19
+ "question_ledger",
20
+ "evidence_cache",
21
+ "last_blueprint",
22
+ "context_used",
23
+ ],
24
+ },
25
+ "support_preparation": {
26
+ "prompt": "Review whether documents are normalized, decomposed into question tasks, converted into bounded search tasks, bound to verified KB evidence, and assembled into a support blueprint before any language-model call.",
27
+ "checks": [
28
+ "normalize_japanese",
29
+ "QuestionTask",
30
+ "SearchTask",
31
+ "EvidenceRetriever",
32
+ "analysis_dictionary",
33
+ "build_blueprint",
34
+ "processing_grade",
35
+ "support_packet",
36
+ ],
37
+ },
38
+ "engine_boundary": {
39
+ "prompt": "Review whether the lightweight model is optional, receives only a prepared support packet, cannot own facts/routing/actions/completion, and is limited to one initial call plus at most one violation-targeted repair.",
40
+ "checks": [
41
+ "ConversationLanguageEngine",
42
+ "answer_only_from_supplied_evidence",
43
+ "repair_limit",
44
+ "repair_attempted",
45
+ "model_required",
46
+ "deterministic_answer",
47
+ ],
48
+ },
49
+ "security_and_feedback": {
50
+ "prompt": "Review PII and secret redaction, internal implementation leakage, task/evidence coverage, unverified action claims, and anonymized deduplicated KB feedback candidates that require approval and never auto-publish.",
51
+ "checks": [
52
+ "verify_hmac",
53
+ "redact_text",
54
+ "sanitize_structure",
55
+ "unknown_evidence_reference",
56
+ "question_coverage_missing",
57
+ "unverified_action_claim",
58
+ "FeedbackStore",
59
+ "approval_required",
60
+ "auto_publish",
61
+ ],
62
+ },
63
+ }
64
+
65
+ FORBIDDEN_RUNTIME_PATTERNS = {
66
+ "kagura-engine.js": "Astera/Kagura engine must not be invoked",
67
+ "CUSTOMER_AI_ASTERA_": "Astera engine runtime variables must not exist",
68
+ "class AsteraBootstrap": "Astera engine bootstrap must not exist",
69
+ "class SkillRegistry": "a generic all-purpose skill registry is not the dedicated Customer AI runtime",
70
+ "class RoutineBotSupervisor": "multiple generic routine bots are outside the focused maintenance design",
71
+ "class WorkerPool": "an unbounded generic worker pool is unnecessary",
72
+ "auto_publish\": True": "user questions must never auto-publish to the approved KB",
73
+ }
74
+
75
+
76
+ def collect_text() -> str:
77
+ return "\n".join(
78
+ path.read_text(encoding="utf-8")
79
+ for path in ROOT.rglob("*")
80
+ if path.is_file() and path.suffix in {".py", ".mjs", ".md", ".txt", ".toml", ".example"}
81
+ )
82
+
83
+
84
+ def check_python_syntax() -> list[str]:
85
+ errors = []
86
+ for path in ROOT.rglob("*.py"):
87
+ try:
88
+ ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
89
+ except SyntaxError as exc:
90
+ errors.append(f"{path}: {exc}")
91
+ return errors
92
+
93
+
94
+ def check_forbidden_runtime() -> list[str]:
95
+ errors: list[str] = []
96
+ runtime_paths = [ROOT / "runtime", ROOT / "v8", ROOT / ".env.example", ROOT / "scripts" / "provision_hf.py"]
97
+ for base in runtime_paths:
98
+ paths = [base] if base.is_file() else list(base.rglob("*")) if base.exists() else []
99
+ for path in paths:
100
+ if not path.is_file() or path.suffix not in {".py", ".mjs", ".md", ".example"}:
101
+ continue
102
+ text = path.read_text(encoding="utf-8")
103
+ for pattern, reason in FORBIDDEN_RUNTIME_PATTERNS.items():
104
+ if pattern in text:
105
+ errors.append(f"{path.relative_to(ROOT)}: {reason}: {pattern}")
106
+ return errors
107
+
108
+
109
+ def run_review(name: str, text: str) -> dict:
110
+ spec = REVIEWS[name]
111
+ missing = [item for item in spec["checks"] if item.lower() not in text.lower()]
112
+ return {"name": name, "prompt": spec["prompt"], "passed": not missing, "missing": missing}
113
+
114
+
115
+ def main() -> int:
116
+ parser = argparse.ArgumentParser()
117
+ parser.add_argument("--all", action="store_true")
118
+ parser.add_argument("--review", choices=REVIEWS)
119
+ args = parser.parse_args()
120
+ text = collect_text()
121
+ names = list(REVIEWS) if args.all or not args.review else [args.review]
122
+ results = [run_review(name, text) for name in names]
123
+ syntax = check_python_syntax()
124
+ forbidden = check_forbidden_runtime()
125
+ output = {"reviews": results, "python_syntax_errors": syntax, "forbidden_runtime_errors": forbidden}
126
+ print(json.dumps(output, ensure_ascii=False, indent=2))
127
+ return 0 if all(item["passed"] for item in results) and not syntax and not forbidden else 1
128
+
129
+
130
+ if __name__ == "__main__":
131
+ raise SystemExit(main())
templates/customer-ai-kb-template.json ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "template_version": "1.0",
3
+ "purpose": "Astera Customer AIが一次質問、追加質問、比較、手順、障害対応まで回答するためのKBレコード作成テンプレート",
4
+ "rules": {
5
+ "one_record_one_answer_scope": "1レコードは1つの主質問と、その質問から自然に派生する追加質問の範囲に限定する",
6
+ "facts_only": "実装済みまたは文書確認済みの事実だけを書く。予定、推測、未確認情報を公開しない",
7
+ "body_generation": "本文を空にした場合、目的・理由・前提条件・手順・条件・例外・障害・完了確認・追加質問からRuntimeが本文を構成する",
8
+ "publish_gate": "公開状態=公開、実装状態=実装済みまたは文書確認済み、要再確認=false のレコードだけがRuntime KBへ入る",
9
+ "answer_boundary": "アカウント固有状態、決済結果、実行結果などKBだけで断定できない範囲を明記する",
10
+ "evidence": "根拠Pathまたは根拠URLと確認日を必ず保持する。公開回答へ内部Pathを直接出さない"
11
+ },
12
+ "required_fields": [
13
+ "id",
14
+ "製品",
15
+ "カテゴリ",
16
+ "質問種別",
17
+ "質問",
18
+ "短い回答",
19
+ "対象",
20
+ "検索語",
21
+ "回答境界",
22
+ "公開状態",
23
+ "実装状態",
24
+ "要再確認",
25
+ "確認日"
26
+ ],
27
+ "pages": [
28
+ {
29
+ "id": "product-category-topic-001",
30
+ "製品": "Astera | Astera-app | Webhook Gateway | その他の対象製品",
31
+ "システム": "対象となるSystemまたは機能名",
32
+ "カテゴリ": "概要 | 導入 | 利用方法 | 仕様 | 料金 | 契約 | API | 障害 | 運用 | セキュリティ | 法人・支援",
33
+ "質問種別": "definition | availability | procedure | comparison | pricing | contract | troubleshooting | limitation",
34
+ "質問": "利用者が実際に尋ねる代表質問を1文で記入",
35
+ "短い回答": "最初に返す結論を、条件を含めて具体的に記入",
36
+ "本文": "",
37
+ "目的": [
38
+ "この回答で利用者が理解または完了できる状態"
39
+ ],
40
+ "理由": [
41
+ "なぜその回答になるのか",
42
+ "設計・仕様・運用上の根拠"
43
+ ],
44
+ "前提条件": [
45
+ "回答が成立する利用者状態、プラン、画面、機能、実装状態"
46
+ ],
47
+ "対象": [
48
+ "一般利用者",
49
+ "開発者",
50
+ "法人",
51
+ "投資家・スポンサー"
52
+ ],
53
+ "利用条件": [
54
+ "利用可能な条件",
55
+ "必要な登録・権限・環境"
56
+ ],
57
+ "手順": [
58
+ "最初に行う操作",
59
+ "次に行う操作",
60
+ "最後に確認する内容"
61
+ ],
62
+ "条件": [
63
+ "通常ケースで変わる条件"
64
+ ],
65
+ "例外": [
66
+ "通常手順が当てはまらないケース",
67
+ "例外時の正しい扱い"
68
+ ],
69
+ "障害症状": [
70
+ "利用者が目にする症状、表示、エラー、未反映状態"
71
+ ],
72
+ "原因": [
73
+ "確認済みの原因候補。推測は書かない"
74
+ ],
75
+ "確認方法": [
76
+ "原因を切り分ける確認順序"
77
+ ],
78
+ "解決手順": [
79
+ "解決のための具体的な操作順序"
80
+ ],
81
+ "完了確認": [
82
+ "解決または設定完了を判断する基準"
83
+ ],
84
+ "追加質問": [
85
+ "この回答の後に聞かれやすい質問",
86
+ "省略表現や口語での聞かれ方",
87
+ "前の回答を前提にした追加質問"
88
+ ],
89
+ "検索語": "製品名 機能名 正式用語 口語 誤表記 症状 手順",
90
+ "同義語": [
91
+ "表記揺れ",
92
+ "略称",
93
+ "利用者が使う別の言い方"
94
+ ],
95
+ "回答境界": [
96
+ "KBだけで断定できる範囲",
97
+ "アカウント・決済・実処理の正本確認が必要な範囲"
98
+ ],
99
+ "禁止回答": [
100
+ "未実装機能を利用可能と書かない",
101
+ "実行していない操作を完了と書かない",
102
+ "未確定料金や契約条件を作らない",
103
+ "非公開の内部実装・Secret・個人情報を出さない"
104
+ ],
105
+ "関連KB": [
106
+ "関連するKB ID"
107
+ ],
108
+ "根拠Path": [
109
+ "GitHub上の根拠ファイルPathまたはNotion正本の参照位置"
110
+ ],
111
+ "根拠種別": "実装Code | 公開仕様 | 利用規約 | 運用手順 | 確認済み文書",
112
+ "公開状態": "下書き",
113
+ "実装状態": "未確認",
114
+ "要再確認": true,
115
+ "確認日": "YYYY-MM-DD",
116
+ "url": "根拠URLまたはNotionページURL"
117
+ }
118
+ ]
119
+ }
v8/server.mjs ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import net from "node:net";
2
+ import fs from "node:fs";
3
+
4
+ const socketPath = process.env.CUSTOMER_AI_NODE_SOCKET || "/tmp/customer-ai-v8.sock";
5
+ try { fs.unlinkSync(socketPath); } catch (error) { if (error.code !== "ENOENT") throw error; }
6
+
7
+ const TOPICS = [
8
+ ["credit", /クレジット|残高|credit|付与|反映/i],
9
+ ["account", /アカウント|ログイン|パスワード|認証|account|login|退会|解約/i],
10
+ ["billing", /料金|支払|決済|プラン|請求|billing|payment|price/i],
11
+ ["webhook-gateway", /webhook|配送|再送|リプレイ|届か/i],
12
+ ["astera-app", /astera[- ]?app|アステラアプリ/i],
13
+ ["astera", /astera|アステラ/i],
14
+ ["api", /api|キー|key|エンドポイント|連携/i],
15
+ ["corporate", /法人|スポンサー|投資|提携|enterprise|sponsor/i],
16
+ ];
17
+
18
+ const INTENTS = [
19
+ ["comparison", /違い|比較|どちら|何が異なる|\bvs\b/i],
20
+ ["troubleshooting", /エラー|動かない|できない|反映されない|届かない|失敗|不具合|直し/i],
21
+ ["procedure", /方法|手順|どうやって|どこから|どこを|何を確認|確認すれば|確認方法|設定|使い方|始め方|導入/i],
22
+ ["pricing", /料金|価格|いくら|費用|課金|クレジット/i],
23
+ ["contract", /契約|解約|退会|返金|更新|支払/i],
24
+ ["availability", /使える|使えます|使えない|利用でき|対応|可能|できる|未実装|提供/i],
25
+ ["limitation", /制限|上限|できない|禁止|対象外|条件/i],
26
+ ["definition", /とは|何ですか|何なの|意味|概要/i],
27
+ ];
28
+
29
+ const EVIDENCE_BY_INTENT = {
30
+ comparison: ["definition", "responsibility", "limitations"],
31
+ troubleshooting: ["symptom", "cause", "check", "resolution", "completion_check"],
32
+ procedure: ["prerequisites", "ordered_steps", "completion_check"],
33
+ pricing: ["current_price", "conditions", "effective_date"],
34
+ contract: ["current_terms", "conditions", "ordered_steps"],
35
+ availability: ["implementation_status", "conditions", "limitations"],
36
+ limitation: ["answer_boundary", "conditions", "exceptions"],
37
+ definition: ["definition", "purpose", "scope"],
38
+ general: ["confirmed_answer", "conditions"],
39
+ };
40
+
41
+ const SHAPE_BY_INTENT = {
42
+ comparison: "comparison",
43
+ troubleshooting: "resolution_steps",
44
+ procedure: "ordered_steps",
45
+ pricing: "current_fact",
46
+ contract: "conditions_and_steps",
47
+ availability: "yes_no_with_conditions",
48
+ limitation: "boundary",
49
+ definition: "conclusion_and_detail",
50
+ general: "conclusion_and_detail",
51
+ };
52
+
53
+ const STOP_WORDS = new Set([
54
+ "これ", "それ", "その", "この", "あれ", "あと", "また", "さらに", "について", "教えて", "ください",
55
+ "です", "ます", "したい", "できる", "できます", "どう", "どこ", "何", "なぜ", "場合", "もの", "こと",
56
+ ]);
57
+
58
+ function normalize(text) {
59
+ return String(text || "")
60
+ .normalize("NFKC")
61
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
62
+ .replace(/[\t ]+/g, " ")
63
+ .replace(/\n{3,}/g, "\n\n")
64
+ .trim();
65
+ }
66
+
67
+ function detectTopic(message) {
68
+ for (const [topic, pattern] of TOPICS) if (pattern.test(message)) return topic;
69
+ return "";
70
+ }
71
+
72
+ function detectIntent(message) {
73
+ for (const [intent, pattern] of INTENTS) if (pattern.test(message)) return intent;
74
+ return "general";
75
+ }
76
+
77
+ function detectAudience(message, source) {
78
+ if (/開発者|実装|コード|sdk|api|webhook|json|http|github/i.test(message)) return "developer";
79
+ if (/法人|企業|スポンサー|投資|提携/i.test(message)) return "business";
80
+ return source === "astera-app" ? "registered_user" : "general_user";
81
+ }
82
+
83
+ function extractDetails(message) {
84
+ const details = {};
85
+ const hour = message.match(/(?:午前|午後)?\s*(\d{1,2})\s*時/);
86
+ if (hour) details.approximate_hour = Number(hour[1]);
87
+ const errorCode = message.match(/(?:エラー|error)\s*[::#-]?\s*([A-Z0-9_-]{3,40})/i);
88
+ if (errorCode) details.error_code = errorCode[1];
89
+ const timing = message.match(/今日|昨日|一昨日|今朝|昨夜|さっき|先ほど/);
90
+ if (timing) details.relative_time = timing[0];
91
+ return details;
92
+ }
93
+
94
+ function splitDocument(message) {
95
+ const lines = normalize(message)
96
+ .split(/\n+/)
97
+ .map((line) => line.replace(/^\s*(?:[-*・]|\d+[.).、]|[①-⑳])\s*/, "").trim())
98
+ .filter(Boolean);
99
+ const source = lines.length ? lines : [normalize(message)];
100
+ const candidates = [];
101
+ for (const line of source) {
102
+ const sentences = line.split(/(?<=[??!!。])\s*/).filter(Boolean);
103
+ for (const sentence of sentences) {
104
+ for (const part of sentence.split(/\s*(?:それと|さらに|加えて|あと、|また、)\s*/)) {
105
+ const raw = part.trim();
106
+ if (!raw) continue;
107
+ const questionMark = /[??]$/.test(raw);
108
+ const cleaned = raw.replace(/[。??\s]+$/g, "").trim();
109
+ if (!cleaned) continue;
110
+ const actionable = questionMark || detectIntent(cleaned) !== "general" || /教えて|知りたい|確認したい/.test(cleaned);
111
+ candidates.push({ text: cleaned, actionable });
112
+ }
113
+ }
114
+ }
115
+ const actionable = candidates.filter((item) => item.actionable).map((item) => item.text);
116
+ const selected = actionable.length ? actionable : candidates.map((item) => item.text);
117
+ return [...new Set(selected)].slice(0, 8).length ? [...new Set(selected)].slice(0, 8) : [normalize(message)];
118
+ }
119
+
120
+ function searchTerms(text, subject, intent, activeTopic) {
121
+ const working = normalize(text)
122
+ .replace(/(?:について|という|できますか|できるか|教えてください|教えて|とは|何ですか|どうですか)/g, " ")
123
+ .replace(/[??!!。、,/::()()[\]「」『』-]+/g, " ")
124
+ .replace(/(?:は|が|を|に|で|と|や|へ|から|まで|より|の)/g, " ");
125
+ const candidates = working.match(/[A-Za-z][A-Za-z0-9_.-]{1,40}|[一-龯ぁ-んァ-ヶー]{2,24}/g) || [];
126
+ const seeded = [subject, activeTopic, intent, ...candidates];
127
+ const result = [];
128
+ for (const raw of seeded) {
129
+ const value = normalize(raw).toLowerCase();
130
+ if (!value || value === "general" || STOP_WORDS.has(value) || result.includes(value)) continue;
131
+ result.push(value);
132
+ }
133
+ return result.slice(0, 12);
134
+ }
135
+
136
+ function questionTasks(message, context, source, activeTopic) {
137
+ const previousTopic = context.active_topic || activeTopic || "";
138
+ return splitDocument(message).map((text, index) => {
139
+ const subject = detectTopic(text) || previousTopic || "general";
140
+ const intent = detectIntent(text);
141
+ return {
142
+ task_id: `q${index + 1}`,
143
+ text,
144
+ subject,
145
+ intent,
146
+ audience: detectAudience(text, source),
147
+ answer_shape: SHAPE_BY_INTENT[intent] || SHAPE_BY_INTENT.general,
148
+ search_terms: searchTerms(text, subject, intent, activeTopic),
149
+ required_evidence: EVIDENCE_BY_INTENT[intent] || EVIDENCE_BY_INTENT.general,
150
+ depends_on: [],
151
+ };
152
+ });
153
+ }
154
+
155
+ function analyzeTurn(payload) {
156
+ const message = normalize(payload.message);
157
+ const context = payload.context || {};
158
+ const source = payload.source || "astera-hp";
159
+ const explicitTopic = detectTopic(message);
160
+ const followUp = /^(それ|その|これ|では|じゃあ|あと|他|ちなみに|で、|なぜ|何で|どう|いつ|どこ|さっき|先ほど)/.test(message)
161
+ || (!explicitTopic && Boolean(context.active_topic || context.user_goal));
162
+ const activeTopic = explicitTopic || context.active_topic || "general";
163
+ const newTopic = Boolean(explicitTopic && context.active_topic && explicitTopic !== context.active_topic && !followUp);
164
+ const userGoal = newTopic || !context.user_goal ? message : context.user_goal;
165
+ const unresolved = Array.isArray(context.unresolved_questions) ? context.unresolved_questions.slice(-5) : [];
166
+ const tasks = questionTasks(message, context, source, activeTopic);
167
+ const retrievalParts = tasks.flatMap((task) => task.search_terms);
168
+ if (followUp && userGoal && userGoal !== message) retrievalParts.push(userGoal);
169
+ if (activeTopic && activeTopic !== "general") retrievalParts.push(activeTopic);
170
+ retrievalParts.push(...unresolved);
171
+ const retrievalQuery = [...new Set(retrievalParts.map(normalize).filter(Boolean))].join(" ").slice(0, 1200);
172
+ const conditions = { ...(context.confirmed_details || {}), ...extractDetails(message) };
173
+ return {
174
+ message,
175
+ follow_up: followUp,
176
+ context_used: Boolean(context.user_goal || (context.turns || []).length),
177
+ explicit_topic: explicitTopic,
178
+ active_topic: activeTopic,
179
+ user_goal: userGoal,
180
+ new_topic: newTopic,
181
+ confirmed_details: conditions,
182
+ retrieval_query: retrievalQuery,
183
+ question_count: tasks.length,
184
+ question_tasks: tasks,
185
+ analysis_dictionary: {
186
+ purpose: userGoal,
187
+ targets: [...new Set(tasks.map((task) => task.subject))],
188
+ conditions,
189
+ constraints: [
190
+ "use_confirmed_kb_only_for_product_facts",
191
+ "do_not_claim_unexecuted_actions",
192
+ "answer_every_detected_question_or_mark_it_unresolved",
193
+ ],
194
+ missing_information: [],
195
+ premises: Object.keys(context.confirmed_details || {}),
196
+ uncertainty: [],
197
+ },
198
+ };
199
+ }
200
+
201
+ function verifyTurn(payload) {
202
+ const answer = normalize(payload.answer);
203
+ const violations = [];
204
+ const availableEvidenceIds = new Set(payload.available_evidence_ids || []);
205
+ const questionTaskIds = new Set(payload.question_task_ids || []);
206
+ const answeredTaskIds = new Set(payload.answered_task_ids || []);
207
+ const unresolvedTaskIds = new Set(payload.unresolved_task_ids || []);
208
+ const usedEvidenceIds = new Set(payload.used_evidence_ids || []);
209
+
210
+ if (!answer) violations.push("empty_answer");
211
+ for (const evidenceId of usedEvidenceIds) {
212
+ if (!availableEvidenceIds.has(evidenceId)) violations.push("unknown_evidence_reference");
213
+ }
214
+ for (const taskId of [...answeredTaskIds, ...unresolvedTaskIds]) {
215
+ if (!questionTaskIds.has(taskId)) violations.push("unknown_task_reference");
216
+ }
217
+ for (const taskId of questionTaskIds) {
218
+ if (!answeredTaskIds.has(taskId) && !unresolvedTaskIds.has(taskId)) violations.push("question_coverage_missing");
219
+ }
220
+ if (availableEvidenceIds.size && answeredTaskIds.size && !usedEvidenceIds.size) violations.push("evidence_grounding_missing");
221
+ if (/情報がありません|回答できません|お問い合わせください|I (?:cannot|can't) answer/i.test(answer)) violations.push("generic_non_answer");
222
+ if (/\b(?:as an ai|as a language model|qwen|hugging face|model provider)\b/i.test(answer)) violations.push("engine_identity");
223
+ if (/(?:\/internal\/|src\/(?:system|component|feature|part)\/|\.env\b)/i.test(answer)) violations.push("internal_implementation");
224
+ if (/(?:返金しました|削除しました|解約しました|処理しました|設定しました|refunded|deleted|cancelled)/i.test(answer)) violations.push("unverified_action_claim");
225
+ const expectedTopic = payload.analysis?.active_topic || "";
226
+ const returnedTopic = payload.returned_topic || expectedTopic;
227
+ if (payload.analysis?.follow_up && expectedTopic && returnedTopic && expectedTopic !== returnedTopic) violations.push("conversation_topic_drift");
228
+ return {
229
+ answer,
230
+ passed: violations.length === 0,
231
+ violations: [...new Set(violations)],
232
+ coverage: {
233
+ question_task_ids: [...questionTaskIds],
234
+ answered_task_ids: [...answeredTaskIds],
235
+ unresolved_task_ids: [...unresolvedTaskIds],
236
+ used_evidence_ids: [...usedEvidenceIds],
237
+ },
238
+ };
239
+ }
240
+
241
+ async function handle(request) {
242
+ const started = Date.now();
243
+ const { request_id: requestId, phase, payload, deadline_at: deadlineAt } = request;
244
+ if (!requestId || !phase || payload === undefined) throw new Error("invalid_request");
245
+ if (Number(deadlineAt || 0) < Date.now()) throw new Error("deadline_exceeded");
246
+ let result;
247
+ if (phase === "analyze_turn") result = analyzeTurn(payload);
248
+ else if (phase === "verify_turn") result = verifyTurn(payload);
249
+ else if (phase === "ping") result = { pong: true, node: process.version };
250
+ else throw new Error("unsupported_phase");
251
+ return { request_id: requestId, ok: true, result, error_code: null, duration_ms: Date.now() - started };
252
+ }
253
+
254
+ const server = net.createServer((connection) => {
255
+ let buffer = "";
256
+ connection.setEncoding("utf8");
257
+ connection.on("data", async (chunk) => {
258
+ buffer += chunk;
259
+ if (buffer.length > 1_000_000) {
260
+ connection.destroy(new Error("request_too_large"));
261
+ return;
262
+ }
263
+ const newline = buffer.indexOf("\n");
264
+ if (newline < 0) return;
265
+ const raw = buffer.slice(0, newline);
266
+ buffer = buffer.slice(newline + 1);
267
+ try {
268
+ connection.end(JSON.stringify(await handle(JSON.parse(raw))) + "\n");
269
+ } catch (error) {
270
+ const requestId = (() => { try { return JSON.parse(raw).request_id || "unknown"; } catch { return "unknown"; } })();
271
+ connection.end(JSON.stringify({ request_id: requestId, ok: false, result: {}, error_code: String(error?.message || error), duration_ms: 0 }) + "\n");
272
+ }
273
+ });
274
+ });
275
+
276
+ server.listen(socketPath, () => fs.chmodSync(socketPath, 0o600));
277
+
278
+ function shutdown() {
279
+ server.close();
280
+ try { fs.unlinkSync(socketPath); } catch {}
281
+ process.exit(0);
282
+ }
283
+ for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, shutdown);
v8/test/server.test.mjs ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import net from "node:net";
4
+ import { spawn } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const here = path.dirname(fileURLToPath(import.meta.url));
10
+ const root = path.resolve(here, "../..");
11
+ const socket = `/tmp/customer-ai-test-${process.pid}.sock`;
12
+ let child;
13
+
14
+ test.before(async () => {
15
+ child = spawn(process.execPath, [path.join(root, "v8/server.mjs")], { env: { ...process.env, CUSTOMER_AI_NODE_SOCKET: socket } });
16
+ for (let index = 0; index < 100 && !fs.existsSync(socket); index += 1) await new Promise((resolve) => setTimeout(resolve, 20));
17
+ assert.equal(fs.existsSync(socket), true);
18
+ });
19
+
20
+ test.after(() => {
21
+ child?.kill("SIGTERM");
22
+ try { fs.unlinkSync(socket); } catch {}
23
+ });
24
+
25
+ function request(phase, payload) {
26
+ return new Promise((resolve, reject) => {
27
+ const client = net.createConnection(socket);
28
+ let data = "";
29
+ client.on("connect", () => client.write(JSON.stringify({ request_id: "req-1", phase, deadline_at: Date.now() + 5000, payload }) + "\n"));
30
+ client.on("data", (chunk) => { data += chunk; });
31
+ client.on("end", () => resolve(JSON.parse(data)));
32
+ client.on("error", reject);
33
+ });
34
+ }
35
+
36
+ test("follow-up analysis reuses cached goal and topic", async () => {
37
+ const response = await request("analyze_turn", {
38
+ message: "昨日の夜です。どこを確認すればいい?",
39
+ source: "astera-app",
40
+ context: {
41
+ user_goal: "購入したクレジットが反映されない問題を解決する",
42
+ active_topic: "credit",
43
+ confirmed_details: {},
44
+ unresolved_questions: [],
45
+ turns: [{ role: "user", text: "購入したクレジットが反映されません" }],
46
+ },
47
+ });
48
+ assert.equal(response.ok, true);
49
+ assert.equal(response.result.follow_up, true);
50
+ assert.equal(response.result.context_used, true);
51
+ assert.equal(response.result.active_topic, "credit");
52
+ assert.match(response.result.retrieval_query, /credit/);
53
+ assert.equal(response.result.confirmed_details.relative_time, "昨日");
54
+ assert.ok(response.result.question_tasks.some((item) => item.intent === "procedure"));
55
+ assert.equal(response.result.question_tasks.some((item) => item.text === "昨日の夜です"), false);
56
+ });
57
+
58
+ test("document analysis decomposes multiple questions into search tasks", async () => {
59
+ const response = await request("analyze_turn", {
60
+ message: "Asteraとは何ですか?Webhook Gatewayとの違いは?APIなしでも使えますか?",
61
+ source: "astera-hp",
62
+ context: { user_goal: "", active_topic: "", confirmed_details: {}, turns: [] },
63
+ });
64
+ assert.equal(response.ok, true);
65
+ assert.equal(response.result.question_count, 3);
66
+ assert.deepEqual(response.result.question_tasks.map((item) => item.task_id), ["q1", "q2", "q3"]);
67
+ assert.equal(response.result.question_tasks[0].intent, "definition");
68
+ assert.equal(response.result.question_tasks[1].intent, "comparison");
69
+ assert.equal(response.result.question_tasks[2].intent, "availability");
70
+ assert.ok(response.result.question_tasks[1].required_evidence.includes("responsibility"));
71
+ assert.ok(response.result.analysis_dictionary.targets.includes("astera"));
72
+ });
73
+
74
+ test("new explicit topic can replace the previous topic", async () => {
75
+ const response = await request("analyze_turn", {
76
+ message: "次はアカウント削除について教えて",
77
+ source: "astera-app",
78
+ context: { user_goal: "クレジット未反映を解決する", active_topic: "credit", turns: [] },
79
+ });
80
+ assert.equal(response.ok, true);
81
+ assert.equal(response.result.active_topic, "account");
82
+ assert.equal(response.result.new_topic, true);
83
+ });
84
+
85
+ test("verification rejects missing coverage unknown evidence and generic non-answer", async () => {
86
+ const response = await request("verify_turn", {
87
+ answer: "お問い合わせください。",
88
+ analysis: { follow_up: false, active_topic: "credit" },
89
+ returned_topic: "credit",
90
+ question_task_ids: ["q1", "q2"],
91
+ answered_task_ids: ["q1"],
92
+ unresolved_task_ids: [],
93
+ used_evidence_ids: ["kb:unknown"],
94
+ available_evidence_ids: ["kb:credit"],
95
+ });
96
+ assert.equal(response.ok, true);
97
+ assert.equal(response.result.passed, false);
98
+ assert.ok(response.result.violations.includes("question_coverage_missing"));
99
+ assert.ok(response.result.violations.includes("unknown_evidence_reference"));
100
+ assert.ok(response.result.violations.includes("generic_non_answer"));
101
+ });
102
+
103
+ test("verification rejects topic drift private paths and unsafe action claims", async () => {
104
+ const response = await request("verify_turn", {
105
+ answer: "As an AI, /internal/admin で削除しました",
106
+ analysis: { follow_up: true, active_topic: "credit" },
107
+ returned_topic: "account",
108
+ question_task_ids: ["q1"],
109
+ answered_task_ids: ["q1"],
110
+ unresolved_task_ids: [],
111
+ used_evidence_ids: [],
112
+ available_evidence_ids: [],
113
+ });
114
+ assert.equal(response.ok, true);
115
+ assert.equal(response.result.passed, false);
116
+ assert.ok(response.result.violations.includes("conversation_topic_drift"));
117
+ assert.ok(response.result.violations.includes("internal_implementation"));
118
+ assert.ok(response.result.violations.includes("unverified_action_claim"));
119
+ });