Spaces:
Sleeping
Sleeping
Deploy GitHub main bbdacdb09d2a0c17d49fd8a49c8159317f3ec5b7
Browse files- .env.example +30 -0
- .gitignore +14 -0
- README.md +163 -6
- app.py +155 -0
- package.json +12 -0
- packages.txt +3 -0
- pyproject.toml +23 -0
- requirements-dev.txt +5 -0
- requirements.txt +10 -0
- runtime/__init__.py +3 -0
- runtime/config.py +78 -0
- runtime/control.py +412 -0
- runtime/conversation.py +132 -0
- runtime/kb.py +328 -0
- runtime/model.py +152 -0
- runtime/notion.py +165 -0
- runtime/schemas.py +129 -0
- runtime/security.py +157 -0
- runtime/service.py +285 -0
- runtime/storage.py +179 -0
- runtime/support.py +622 -0
- runtime/v8.py +205 -0
- scripts/provision_hf.py +215 -0
- scripts/review.py +131 -0
- templates/customer-ai-kb-template.json +119 -0
- v8/server.mjs +283 -0
- v8/test/server.test.mjs +119 -0
.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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
});
|