Spaces:
Sleeping
Sleeping
Deploy direct private KB runtime
Browse files- README.md +1 -0
- app.py +20 -5
- config/quality.yaml +26 -2
- deployment-manifest.json +7 -0
- pyproject.toml +2 -2
- requirements.txt +1 -0
- runtime/__pycache__/__init__.cpython-313.pyc +0 -0
- runtime/__pycache__/answer_quality.cpython-313.pyc +0 -0
- runtime/__pycache__/bootstrap.cpython-313.pyc +0 -0
- runtime/__pycache__/contracts.cpython-313.pyc +0 -0
- runtime/__pycache__/hf_client.cpython-313.pyc +0 -0
- runtime/__pycache__/integration.cpython-313.pyc +0 -0
- runtime/__pycache__/internal_core.cpython-313.pyc +0 -0
- runtime/__pycache__/japanese_skills.cpython-313.pyc +0 -0
- runtime/__pycache__/kagrra_bridge.cpython-313.pyc +0 -0
- runtime/__pycache__/kb_harvest.cpython-313.pyc +0 -0
- runtime/__pycache__/kb_search.cpython-313.pyc +0 -0
- runtime/__pycache__/knowledge.cpython-313.pyc +0 -0
- runtime/__pycache__/live_state.cpython-313.pyc +0 -0
- runtime/__pycache__/model.cpython-313.pyc +0 -0
- runtime/__pycache__/observability.cpython-313.pyc +0 -0
- runtime/__pycache__/quality.cpython-313.pyc +0 -0
- runtime/__pycache__/roles.cpython-313.pyc +0 -0
- runtime/__pycache__/runtime_factory.cpython-313.pyc +0 -0
- runtime/__pycache__/schemas.cpython-313.pyc +0 -0
- runtime/__pycache__/search_planner.cpython-313.pyc +0 -0
- runtime/__pycache__/security.cpython-313.pyc +0 -0
- runtime/__pycache__/service.cpython-313.pyc +0 -0
- runtime/__pycache__/shared_head.cpython-313.pyc +0 -0
- runtime/__pycache__/skill_runtime.cpython-313.pyc +0 -0
- runtime/__pycache__/startup.cpython-313.pyc +0 -0
- runtime/__pycache__/state.cpython-313.pyc +0 -0
- runtime/__pycache__/task_decomposition.cpython-313.pyc +0 -0
- runtime/__pycache__/v8_bridge.cpython-313.pyc +0 -0
- runtime/__pycache__/writing_skills.cpython-313.pyc +0 -0
- runtime/internal_core.py +83 -19
- runtime/kb_bucket.py +216 -0
- runtime/security.py +35 -3
- runtime/startup.py +69 -6
- runtime/task_decomposition.py +42 -0
README.md
CHANGED
|
@@ -3,6 +3,7 @@ title: Astera Customer AI
|
|
| 3 |
sdk: docker
|
| 4 |
app_port: 7860
|
| 5 |
---
|
|
|
|
| 6 |
# Astera Customer AI
|
| 7 |
|
| 8 |
Implementation repository for the current Customer AI runtime defined in the Astera Notion canon.
|
|
|
|
| 3 |
sdk: docker
|
| 4 |
app_port: 7860
|
| 5 |
---
|
| 6 |
+
|
| 7 |
# Astera Customer AI
|
| 8 |
|
| 9 |
Implementation repository for the current Customer AI runtime defined in the Astera Notion canon.
|
app.py
CHANGED
|
@@ -66,11 +66,26 @@ async def lifespan(_: FastAPI):
|
|
| 66 |
|
| 67 |
|
| 68 |
app = FastAPI(title="Astera Customer AI", version="0.0.0", lifespan=lifespan)
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
app.add_middleware(
|
| 75 |
CORSMiddleware,
|
| 76 |
allow_origins=_origins,
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
app = FastAPI(title="Astera Customer AI", version="0.0.0", lifespan=lifespan)
|
| 69 |
+
_DEFAULT_ALLOWED_ORIGINS = (
|
| 70 |
+
"https://asterav8.jp",
|
| 71 |
+
"https://staging.asterav8.jp",
|
| 72 |
+
"https://open.asterav8.jp",
|
| 73 |
+
"https://localhost",
|
| 74 |
+
"capacitor://localhost",
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _merge_allowed_origins(configured: str) -> list[str]:
|
| 79 |
+
extra_origins = [
|
| 80 |
+
origin.strip()
|
| 81 |
+
for origin in configured.split(",")
|
| 82 |
+
if origin.strip()
|
| 83 |
+
]
|
| 84 |
+
return list(dict.fromkeys([*_DEFAULT_ALLOWED_ORIGINS, *extra_origins]))
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
_configured_origins = os.environ.get("CUSTOMER_AI_ALLOWED_ORIGINS", "").strip()
|
| 88 |
+
_origins = _merge_allowed_origins(_configured_origins)
|
| 89 |
app.add_middleware(
|
| 90 |
CORSMiddleware,
|
| 91 |
allow_origins=_origins,
|
config/quality.yaml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
quality:
|
| 2 |
-
|
| 3 |
answer_satisfaction_min: 0.98
|
| 4 |
satisfaction_confidence_lower_bound_min: 0.98
|
| 5 |
evaluation_min_unseen_scenarios: 200
|
|
@@ -9,9 +9,33 @@ quality:
|
|
| 9 |
evaluation_min_multiturn: 20
|
| 10 |
evaluation_min_false_premise: 20
|
| 11 |
production_model_self_judge_allowed: false
|
| 12 |
-
|
|
|
|
| 13 |
false_premise_correction_min: 1.0
|
| 14 |
unsupported_hallucination_max: 0
|
| 15 |
legacy_mixing_max: 0
|
| 16 |
secret_leak_max: 0
|
| 17 |
unexecuted_completion_claim_max: 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
quality:
|
| 2 |
+
completion_primary_metric: answer_satisfaction
|
| 3 |
answer_satisfaction_min: 0.98
|
| 4 |
satisfaction_confidence_lower_bound_min: 0.98
|
| 5 |
evaluation_min_unseen_scenarios: 200
|
|
|
|
| 9 |
evaluation_min_multiturn: 20
|
| 10 |
evaluation_min_false_premise: 20
|
| 11 |
production_model_self_judge_allowed: false
|
| 12 |
+
user_need_resolution_role: diagnostic_only
|
| 13 |
+
critical_satisfaction_min: 0.99
|
| 14 |
false_premise_correction_min: 1.0
|
| 15 |
unsupported_hallucination_max: 0
|
| 16 |
legacy_mixing_max: 0
|
| 17 |
secret_leak_max: 0
|
| 18 |
unexecuted_completion_claim_max: 0
|
| 19 |
+
satisfaction_required_dimensions:
|
| 20 |
+
- purpose_fulfilled
|
| 21 |
+
- preflight_correct
|
| 22 |
+
- intent_correct
|
| 23 |
+
- all_major_needs_covered
|
| 24 |
+
- required_depth_met
|
| 25 |
+
- factual
|
| 26 |
+
- evidence_complete
|
| 27 |
+
- constraints_respected
|
| 28 |
+
- conditions_exceptions_covered
|
| 29 |
+
- current_status_covered_when_required
|
| 30 |
+
- next_action_covered_when_required
|
| 31 |
+
- relevant
|
| 32 |
+
- direct
|
| 33 |
+
- clear
|
| 34 |
+
- appropriately_concise
|
| 35 |
+
- actionable_when_required
|
| 36 |
+
- context_consistent
|
| 37 |
+
- clarification_efficient
|
| 38 |
+
- resolution_mode_correct
|
| 39 |
+
- self_contained
|
| 40 |
+
same_revision_evidence_required: true
|
| 41 |
+
evaluation_input_contract: scenario_plus_runtime_evidence
|
deployment-manifest.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": 5,
|
| 3 |
+
"kb_storage": "huggingface_private_bucket_direct_runtime",
|
| 4 |
+
"kb_bucket_id": "G-ACE/astera-customerai-kb",
|
| 5 |
+
"kb_build_id": "kb-20260814T042741+0900",
|
| 6 |
+
"kb_embedded_in_space": false
|
| 7 |
+
}
|
pyproject.toml
CHANGED
|
@@ -2,11 +2,11 @@
|
|
| 2 |
name = "astera-customer-ai"
|
| 3 |
version = "0.0.0"
|
| 4 |
requires-python = ">=3.11"
|
| 5 |
-
dependencies = ["fastapi>=0.128,<1", "uvicorn>=0.48,<1", "pydantic>=2.12,<3", "httpx>=0.28,<1", "rapidfuzz==3.14.3", "pyyaml>=6,<7"]
|
| 6 |
|
| 7 |
[project.optional-dependencies]
|
| 8 |
training = ["datasets", "transformers", "peft", "trl"]
|
| 9 |
-
test = ["pytest==9.0.2", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"]
|
| 10 |
|
| 11 |
[tool.pytest.ini_options]
|
| 12 |
asyncio_mode = "auto"
|
|
|
|
| 2 |
name = "astera-customer-ai"
|
| 3 |
version = "0.0.0"
|
| 4 |
requires-python = ">=3.11"
|
| 5 |
+
dependencies = ["fastapi>=0.128,<1", "uvicorn>=0.48,<1", "pydantic>=2.12,<3", "httpx>=0.28,<1", "rapidfuzz==3.14.3", "pyyaml>=6,<7", "huggingface_hub>=1.24,<2"]
|
| 6 |
|
| 7 |
[project.optional-dependencies]
|
| 8 |
training = ["datasets", "transformers", "peft", "trl"]
|
| 9 |
+
test = ["pytest==9.0.2", "pytest-asyncio>=1,<2", "ruff>=0.12,<1", "huggingface_hub>=1.24,<2"]
|
| 10 |
|
| 11 |
[tool.pytest.ini_options]
|
| 12 |
asyncio_mode = "auto"
|
requirements.txt
CHANGED
|
@@ -4,3 +4,4 @@ pydantic>=2.12,<3
|
|
| 4 |
httpx>=0.28,<1
|
| 5 |
rapidfuzz==3.14.3
|
| 6 |
pyyaml>=6,<7
|
|
|
|
|
|
| 4 |
httpx>=0.28,<1
|
| 5 |
rapidfuzz==3.14.3
|
| 6 |
pyyaml>=6,<7
|
| 7 |
+
huggingface_hub>=1.24,<2
|
runtime/__pycache__/__init__.cpython-313.pyc
DELETED
|
Binary file (361 Bytes)
|
|
|
runtime/__pycache__/answer_quality.cpython-313.pyc
DELETED
|
Binary file (6.88 kB)
|
|
|
runtime/__pycache__/bootstrap.cpython-313.pyc
DELETED
|
Binary file (3.1 kB)
|
|
|
runtime/__pycache__/contracts.cpython-313.pyc
DELETED
|
Binary file (4.87 kB)
|
|
|
runtime/__pycache__/hf_client.cpython-313.pyc
DELETED
|
Binary file (3.66 kB)
|
|
|
runtime/__pycache__/integration.cpython-313.pyc
DELETED
|
Binary file (4.01 kB)
|
|
|
runtime/__pycache__/internal_core.cpython-313.pyc
DELETED
|
Binary file (18.5 kB)
|
|
|
runtime/__pycache__/japanese_skills.cpython-313.pyc
DELETED
|
Binary file (12.5 kB)
|
|
|
runtime/__pycache__/kagrra_bridge.cpython-313.pyc
DELETED
|
Binary file (2.17 kB)
|
|
|
runtime/__pycache__/kb_harvest.cpython-313.pyc
DELETED
|
Binary file (2.63 kB)
|
|
|
runtime/__pycache__/kb_search.cpython-313.pyc
DELETED
|
Binary file (23.9 kB)
|
|
|
runtime/__pycache__/knowledge.cpython-313.pyc
DELETED
|
Binary file (7.24 kB)
|
|
|
runtime/__pycache__/live_state.cpython-313.pyc
DELETED
|
Binary file (2.16 kB)
|
|
|
runtime/__pycache__/model.cpython-313.pyc
DELETED
|
Binary file (6.35 kB)
|
|
|
runtime/__pycache__/observability.cpython-313.pyc
DELETED
|
Binary file (2.88 kB)
|
|
|
runtime/__pycache__/quality.cpython-313.pyc
DELETED
|
Binary file (2.97 kB)
|
|
|
runtime/__pycache__/roles.cpython-313.pyc
DELETED
|
Binary file (1.21 kB)
|
|
|
runtime/__pycache__/runtime_factory.cpython-313.pyc
DELETED
|
Binary file (4.54 kB)
|
|
|
runtime/__pycache__/schemas.cpython-313.pyc
DELETED
|
Binary file (7.6 kB)
|
|
|
runtime/__pycache__/search_planner.cpython-313.pyc
DELETED
|
Binary file (3.77 kB)
|
|
|
runtime/__pycache__/security.cpython-313.pyc
DELETED
|
Binary file (1.87 kB)
|
|
|
runtime/__pycache__/service.cpython-313.pyc
DELETED
|
Binary file (1.28 kB)
|
|
|
runtime/__pycache__/shared_head.cpython-313.pyc
DELETED
|
Binary file (10 kB)
|
|
|
runtime/__pycache__/skill_runtime.cpython-313.pyc
DELETED
|
Binary file (4.23 kB)
|
|
|
runtime/__pycache__/startup.cpython-313.pyc
DELETED
|
Binary file (5.09 kB)
|
|
|
runtime/__pycache__/state.cpython-313.pyc
DELETED
|
Binary file (18.3 kB)
|
|
|
runtime/__pycache__/task_decomposition.cpython-313.pyc
DELETED
|
Binary file (6.81 kB)
|
|
|
runtime/__pycache__/v8_bridge.cpython-313.pyc
DELETED
|
Binary file (2.74 kB)
|
|
|
runtime/__pycache__/writing_skills.cpython-313.pyc
DELETED
|
Binary file (5.82 kB)
|
|
|
runtime/internal_core.py
CHANGED
|
@@ -86,9 +86,11 @@ class CustomerAIInternalCore:
|
|
| 86 |
}
|
| 87 |
contract = None
|
| 88 |
try:
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
| 92 |
bound_tasks = self.state.bind_tasks(session_id, contract.need_tasks, follow_up_kind)
|
| 93 |
contract = contract.model_copy(update={"need_tasks": bound_tasks})
|
| 94 |
state = self.state.get(session_id)
|
|
@@ -104,25 +106,43 @@ class CustomerAIInternalCore:
|
|
| 104 |
user_conditions=dict(state.user_conditions),
|
| 105 |
)
|
| 106 |
except GroundingConflictError:
|
|
|
|
| 107 |
if contract is not None:
|
| 108 |
self.state.complete_turn(
|
| 109 |
session_id,
|
| 110 |
contract.need_tasks,
|
| 111 |
resolved_task_ids=set(),
|
| 112 |
-
unresolved_task_ids=
|
| 113 |
satisfaction_blockers={"grounding_conflict"},
|
| 114 |
)
|
| 115 |
-
return self._failure(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
except Exception:
|
|
|
|
| 117 |
if contract is not None:
|
| 118 |
self.state.complete_turn(
|
| 119 |
session_id,
|
| 120 |
contract.need_tasks,
|
| 121 |
resolved_task_ids=set(),
|
| 122 |
-
unresolved_task_ids=
|
| 123 |
satisfaction_blockers={"preflight_runtime_failure"},
|
| 124 |
)
|
| 125 |
-
return self._failure(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
language = "ja" if any("\u3040" <= ch <= "\u30ff" or "\u4e00" <= ch <= "\u9fff" for ch in normalized_text) else "en"
|
| 128 |
audience = self._audience(normalized_text)
|
|
@@ -159,14 +179,23 @@ class CustomerAIInternalCore:
|
|
| 159 |
results = await self.roles.run_all(packet, capsules)
|
| 160 |
integrated = self.integrator.integrate(results)
|
| 161 |
except Exception:
|
|
|
|
| 162 |
self.state.complete_turn(
|
| 163 |
session_id,
|
| 164 |
contract.need_tasks,
|
| 165 |
resolved_task_ids=set(),
|
| 166 |
-
unresolved_task_ids=
|
| 167 |
satisfaction_blockers={"role_runtime_failure"},
|
| 168 |
)
|
| 169 |
-
return self._failure(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
external = self.audit.check(packet, results)
|
| 172 |
quality = self.gate.evaluate(packet, integrated, external_violations=external)
|
|
@@ -204,7 +233,12 @@ class CustomerAIInternalCore:
|
|
| 204 |
composed = self.composer.compose(plan)
|
| 205 |
answer = self.refiner.refine(composed.answer or "") if composed.answer else ""
|
| 206 |
terminology = self.japanese.terminology_violations(answer) if answer else []
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
major = [t for t in contract.need_tasks if t.priority == "primary"]
|
| 209 |
resolved = set(composed.resolved_task_ids)
|
| 210 |
all_major = all(t.task_id in resolved for t in major)
|
|
@@ -241,10 +275,31 @@ class CustomerAIInternalCore:
|
|
| 241 |
unresolved_task_ids = set(composed.unresolved_task_ids)
|
| 242 |
if not passed:
|
| 243 |
unresolved_task_ids.update(t.task_id for t in contract.need_tasks if t.task_id not in resolved)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
self.state.complete_turn(
|
| 245 |
session_id,
|
| 246 |
contract.need_tasks,
|
| 247 |
-
resolved_task_ids=
|
| 248 |
unresolved_task_ids=unresolved_task_ids,
|
| 249 |
evidence_gaps=set(quality.missing_evidence_task_ids),
|
| 250 |
satisfaction_blockers=set(violations),
|
|
@@ -254,37 +309,46 @@ class CustomerAIInternalCore:
|
|
| 254 |
if not passed:
|
| 255 |
if "grounding_conflict" in violations:
|
| 256 |
failure_class = "grounding_conflict"
|
|
|
|
|
|
|
| 257 |
elif {"major_need_missing", "evidence_incomplete", "conversation_not_resolved"}.intersection(violations):
|
| 258 |
failure_class = "coverage_defect"
|
| 259 |
-
elif not security.passed:
|
| 260 |
-
failure_class = "safety_rejection"
|
| 261 |
else:
|
| 262 |
failure_class = "runtime_failure"
|
| 263 |
return FinalResponse(
|
| 264 |
request_id=request_id,
|
| 265 |
session_id=session_id,
|
| 266 |
turn_id=turn_id,
|
| 267 |
-
answer=
|
| 268 |
-
answered_task_ids=
|
| 269 |
-
unresolved_task_ids=
|
| 270 |
evidence_ids=list(integrated.evidence_ids),
|
| 271 |
resolution_score=quality.resolution_score,
|
| 272 |
passed=passed,
|
| 273 |
-
resolution_mode=
|
| 274 |
clarification_questions=list(composed.clarification_questions),
|
| 275 |
failure_class=failure_class,
|
| 276 |
violations=violations,
|
| 277 |
)
|
| 278 |
|
| 279 |
@staticmethod
|
| 280 |
-
def _failure(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
return FinalResponse(
|
| 282 |
request_id=request_id,
|
| 283 |
session_id=session_id,
|
| 284 |
turn_id=turn_id,
|
| 285 |
answer=None,
|
| 286 |
answered_task_ids=[],
|
| 287 |
-
unresolved_task_ids=
|
| 288 |
evidence_ids=[],
|
| 289 |
resolution_score=0.0,
|
| 290 |
passed=False,
|
|
|
|
| 86 |
}
|
| 87 |
contract = None
|
| 88 |
try:
|
| 89 |
+
seed_contract = self.decomposer.decompose(normalized_text, context)
|
| 90 |
+
contract = seed_contract
|
| 91 |
+
if self.decomposer.requires_semantic_expansion(seed_contract) and hasattr(self.roles, "semantic_decompose"):
|
| 92 |
+
semantic_contract = await self.roles.semantic_decompose(normalized_text, seed_contract)
|
| 93 |
+
contract = self.decomposer.protect_semantic_expansion(seed_contract, semantic_contract)
|
| 94 |
bound_tasks = self.state.bind_tasks(session_id, contract.need_tasks, follow_up_kind)
|
| 95 |
contract = contract.model_copy(update={"need_tasks": bound_tasks})
|
| 96 |
state = self.state.get(session_id)
|
|
|
|
| 106 |
user_conditions=dict(state.user_conditions),
|
| 107 |
)
|
| 108 |
except GroundingConflictError:
|
| 109 |
+
unresolved = [t.task_id for t in contract.need_tasks] if contract is not None else []
|
| 110 |
if contract is not None:
|
| 111 |
self.state.complete_turn(
|
| 112 |
session_id,
|
| 113 |
contract.need_tasks,
|
| 114 |
resolved_task_ids=set(),
|
| 115 |
+
unresolved_task_ids=set(unresolved),
|
| 116 |
satisfaction_blockers={"grounding_conflict"},
|
| 117 |
)
|
| 118 |
+
return self._failure(
|
| 119 |
+
request_id,
|
| 120 |
+
session_id,
|
| 121 |
+
turn_id,
|
| 122 |
+
ResolutionMode.BLOCKED_CURRENT_FACT,
|
| 123 |
+
"grounding_conflict",
|
| 124 |
+
["grounding_conflict"],
|
| 125 |
+
unresolved_task_ids=unresolved,
|
| 126 |
+
)
|
| 127 |
except Exception:
|
| 128 |
+
unresolved = [t.task_id for t in contract.need_tasks] if contract is not None else []
|
| 129 |
if contract is not None:
|
| 130 |
self.state.complete_turn(
|
| 131 |
session_id,
|
| 132 |
contract.need_tasks,
|
| 133 |
resolved_task_ids=set(),
|
| 134 |
+
unresolved_task_ids=set(unresolved),
|
| 135 |
satisfaction_blockers={"preflight_runtime_failure"},
|
| 136 |
)
|
| 137 |
+
return self._failure(
|
| 138 |
+
request_id,
|
| 139 |
+
session_id,
|
| 140 |
+
turn_id,
|
| 141 |
+
ResolutionMode.RUNTIME_FAILURE,
|
| 142 |
+
"runtime_failure",
|
| 143 |
+
["preflight_runtime_failure"],
|
| 144 |
+
unresolved_task_ids=unresolved,
|
| 145 |
+
)
|
| 146 |
|
| 147 |
language = "ja" if any("\u3040" <= ch <= "\u30ff" or "\u4e00" <= ch <= "\u9fff" for ch in normalized_text) else "en"
|
| 148 |
audience = self._audience(normalized_text)
|
|
|
|
| 179 |
results = await self.roles.run_all(packet, capsules)
|
| 180 |
integrated = self.integrator.integrate(results)
|
| 181 |
except Exception:
|
| 182 |
+
unresolved = [t.task_id for t in contract.need_tasks]
|
| 183 |
self.state.complete_turn(
|
| 184 |
session_id,
|
| 185 |
contract.need_tasks,
|
| 186 |
resolved_task_ids=set(),
|
| 187 |
+
unresolved_task_ids=set(unresolved),
|
| 188 |
satisfaction_blockers={"role_runtime_failure"},
|
| 189 |
)
|
| 190 |
+
return self._failure(
|
| 191 |
+
request_id,
|
| 192 |
+
session_id,
|
| 193 |
+
turn_id,
|
| 194 |
+
ResolutionMode.RUNTIME_FAILURE,
|
| 195 |
+
"runtime_failure",
|
| 196 |
+
["role_runtime_failure"],
|
| 197 |
+
unresolved_task_ids=unresolved,
|
| 198 |
+
)
|
| 199 |
|
| 200 |
external = self.audit.check(packet, results)
|
| 201 |
quality = self.gate.evaluate(packet, integrated, external_violations=external)
|
|
|
|
| 233 |
composed = self.composer.compose(plan)
|
| 234 |
answer = self.refiner.refine(composed.answer or "") if composed.answer else ""
|
| 235 |
terminology = self.japanese.terminology_violations(answer) if answer else []
|
| 236 |
+
unexecuted_claim = self.security.detect_unexecuted_completion_claim(answer) if answer else False
|
| 237 |
+
security = self.security.check_output(
|
| 238 |
+
answer=answer,
|
| 239 |
+
forbidden_literals=packet.forbidden_claims,
|
| 240 |
+
unexecuted_completion_claim=unexecuted_claim,
|
| 241 |
+
)
|
| 242 |
major = [t for t in contract.need_tasks if t.priority == "primary"]
|
| 243 |
resolved = set(composed.resolved_task_ids)
|
| 244 |
all_major = all(t.task_id in resolved for t in major)
|
|
|
|
| 275 |
unresolved_task_ids = set(composed.unresolved_task_ids)
|
| 276 |
if not passed:
|
| 277 |
unresolved_task_ids.update(t.task_id for t in contract.need_tasks if t.task_id not in resolved)
|
| 278 |
+
|
| 279 |
+
zero_tolerance = {"unsupported_claim", "forbidden_literal_exposed", "unexecuted_completion_claim"}
|
| 280 |
+
public_blocked = bool(zero_tolerance.intersection(violations))
|
| 281 |
+
public_answer = answer or composed.answer
|
| 282 |
+
public_mode = composed.mode
|
| 283 |
+
answered_task_ids = set(composed.resolved_task_ids)
|
| 284 |
+
state_resolved = set(resolved)
|
| 285 |
+
if public_blocked:
|
| 286 |
+
public_answer = None
|
| 287 |
+
public_mode = ResolutionMode.SAFETY_BLOCKED
|
| 288 |
+
answered_task_ids.clear()
|
| 289 |
+
unresolved_task_ids.update(t.task_id for t in contract.need_tasks)
|
| 290 |
+
state_resolved.clear()
|
| 291 |
+
elif not passed and composed.mode == ResolutionMode.RESOLVED:
|
| 292 |
+
# A fully composed answer that failed a non-zero-tolerance quality gate
|
| 293 |
+
# must not be advertised or persisted as resolved. Keep the text as a
|
| 294 |
+
# safe partial response, but keep the need open for repair/follow-up.
|
| 295 |
+
public_mode = ResolutionMode.SAFE_PARTIAL
|
| 296 |
+
unresolved_task_ids.update(t.task_id for t in contract.need_tasks)
|
| 297 |
+
state_resolved.clear()
|
| 298 |
+
|
| 299 |
self.state.complete_turn(
|
| 300 |
session_id,
|
| 301 |
contract.need_tasks,
|
| 302 |
+
resolved_task_ids=state_resolved,
|
| 303 |
unresolved_task_ids=unresolved_task_ids,
|
| 304 |
evidence_gaps=set(quality.missing_evidence_task_ids),
|
| 305 |
satisfaction_blockers=set(violations),
|
|
|
|
| 309 |
if not passed:
|
| 310 |
if "grounding_conflict" in violations:
|
| 311 |
failure_class = "grounding_conflict"
|
| 312 |
+
elif public_blocked or not security.passed:
|
| 313 |
+
failure_class = "safety_rejection"
|
| 314 |
elif {"major_need_missing", "evidence_incomplete", "conversation_not_resolved"}.intersection(violations):
|
| 315 |
failure_class = "coverage_defect"
|
|
|
|
|
|
|
| 316 |
else:
|
| 317 |
failure_class = "runtime_failure"
|
| 318 |
return FinalResponse(
|
| 319 |
request_id=request_id,
|
| 320 |
session_id=session_id,
|
| 321 |
turn_id=turn_id,
|
| 322 |
+
answer=public_answer,
|
| 323 |
+
answered_task_ids=sorted(answered_task_ids),
|
| 324 |
+
unresolved_task_ids=sorted(unresolved_task_ids),
|
| 325 |
evidence_ids=list(integrated.evidence_ids),
|
| 326 |
resolution_score=quality.resolution_score,
|
| 327 |
passed=passed,
|
| 328 |
+
resolution_mode=public_mode,
|
| 329 |
clarification_questions=list(composed.clarification_questions),
|
| 330 |
failure_class=failure_class,
|
| 331 |
violations=violations,
|
| 332 |
)
|
| 333 |
|
| 334 |
@staticmethod
|
| 335 |
+
def _failure(
|
| 336 |
+
request_id,
|
| 337 |
+
session_id,
|
| 338 |
+
turn_id,
|
| 339 |
+
mode,
|
| 340 |
+
failure_class,
|
| 341 |
+
violations,
|
| 342 |
+
*,
|
| 343 |
+
unresolved_task_ids=None,
|
| 344 |
+
):
|
| 345 |
return FinalResponse(
|
| 346 |
request_id=request_id,
|
| 347 |
session_id=session_id,
|
| 348 |
turn_id=turn_id,
|
| 349 |
answer=None,
|
| 350 |
answered_task_ids=[],
|
| 351 |
+
unresolved_task_ids=list(unresolved_task_ids or ()),
|
| 352 |
evidence_ids=[],
|
| 353 |
resolution_score=0.0,
|
| 354 |
passed=False,
|
runtime/kb_bucket.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import tempfile
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from pathlib import Path, PurePosixPath
|
| 8 |
+
|
| 9 |
+
from huggingface_hub import HfFileSystem
|
| 10 |
+
|
| 11 |
+
HF_KB_BUCKET_DEFAULT = "G-ACE/astera-customerai-kb"
|
| 12 |
+
HF_KB_MOUNT_DEFAULT = "/data/customer-ai"
|
| 13 |
+
HF_KB_ACTIVE_POINTER_DEFAULT = "active.json"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(frozen=True)
|
| 17 |
+
class MountedKBRelease:
|
| 18 |
+
build_id: str
|
| 19 |
+
canonical_path: Path
|
| 20 |
+
current_facts_path: Path | None
|
| 21 |
+
aliases_path: Path | None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _resolve_file_inside_mount(mount: Path, relative_path: str, code: str) -> Path:
|
| 25 |
+
relative = relative_path.strip()
|
| 26 |
+
if not relative:
|
| 27 |
+
raise ValueError(code)
|
| 28 |
+
mount_resolved = mount.resolve()
|
| 29 |
+
candidate = (mount_resolved / relative).resolve()
|
| 30 |
+
if candidate != mount_resolved and mount_resolved not in candidate.parents:
|
| 31 |
+
raise ValueError("kb_pointer_path_escape")
|
| 32 |
+
if not candidate.is_file() or candidate.stat().st_size == 0:
|
| 33 |
+
raise ValueError(code)
|
| 34 |
+
return candidate
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _safe_relative(value: str, code: str) -> str:
|
| 38 |
+
relative = value.strip()
|
| 39 |
+
if not relative:
|
| 40 |
+
raise ValueError(code)
|
| 41 |
+
path = PurePosixPath(relative)
|
| 42 |
+
if path.is_absolute() or ".." in path.parts:
|
| 43 |
+
raise ValueError("kb_pointer_path_escape")
|
| 44 |
+
return path.as_posix()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _validate_pointer(payload: object, expected_build_id: str) -> tuple[str, str, str | None, str | None, str]:
|
| 48 |
+
if not isinstance(payload, dict):
|
| 49 |
+
raise ValueError("kb_active_pointer_invalid")
|
| 50 |
+
|
| 51 |
+
build_id = str(payload.get("build_id") or "").strip()
|
| 52 |
+
expected = expected_build_id.strip()
|
| 53 |
+
if not build_id:
|
| 54 |
+
raise ValueError("kb_active_build_id_missing")
|
| 55 |
+
if not expected:
|
| 56 |
+
raise ValueError("kb_expected_build_id_missing")
|
| 57 |
+
if build_id != expected:
|
| 58 |
+
raise ValueError("kb_active_build_id_mismatch")
|
| 59 |
+
|
| 60 |
+
release_prefix = f"releases/{expected}/"
|
| 61 |
+
canonical = _safe_relative(str(payload.get("canonical_path") or ""), "kb_active_canonical_missing")
|
| 62 |
+
manifest = _safe_relative(str(payload.get("manifest_path") or ""), "kb_active_manifest_missing")
|
| 63 |
+
if not canonical.startswith(release_prefix) or not manifest.startswith(release_prefix):
|
| 64 |
+
raise ValueError("kb_pointer_release_path_mismatch")
|
| 65 |
+
|
| 66 |
+
current_raw = str(payload.get("current_facts_path") or "").strip()
|
| 67 |
+
current = _safe_relative(current_raw, "kb_active_current_missing") if current_raw else None
|
| 68 |
+
if current is not None and not current.startswith(release_prefix):
|
| 69 |
+
raise ValueError("kb_pointer_release_path_mismatch")
|
| 70 |
+
|
| 71 |
+
aliases_raw = str(payload.get("aliases_path") or "").strip()
|
| 72 |
+
aliases = _safe_relative(aliases_raw, "kb_active_aliases_missing") if aliases_raw else None
|
| 73 |
+
if aliases is not None and not aliases.startswith(release_prefix):
|
| 74 |
+
raise ValueError("kb_pointer_release_path_mismatch")
|
| 75 |
+
|
| 76 |
+
return build_id, canonical, current, aliases, manifest
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def load_mounted_kb_release(
|
| 80 |
+
*,
|
| 81 |
+
mount_path: str,
|
| 82 |
+
expected_build_id: str,
|
| 83 |
+
pointer_name: str = HF_KB_ACTIVE_POINTER_DEFAULT,
|
| 84 |
+
) -> MountedKBRelease:
|
| 85 |
+
mount = Path(mount_path).expanduser()
|
| 86 |
+
if not mount.is_dir():
|
| 87 |
+
raise ValueError("kb_bucket_mount_missing")
|
| 88 |
+
|
| 89 |
+
pointer = _resolve_file_inside_mount(mount, pointer_name, "kb_active_pointer_missing")
|
| 90 |
+
try:
|
| 91 |
+
payload = json.loads(pointer.read_text(encoding="utf-8"))
|
| 92 |
+
except (OSError, json.JSONDecodeError) as exc:
|
| 93 |
+
raise ValueError("kb_active_pointer_invalid") from exc
|
| 94 |
+
|
| 95 |
+
build_id, canonical_value, current_value, aliases_value, manifest_value = _validate_pointer(
|
| 96 |
+
payload,
|
| 97 |
+
expected_build_id,
|
| 98 |
+
)
|
| 99 |
+
_resolve_file_inside_mount(mount, manifest_value, "kb_active_manifest_missing")
|
| 100 |
+
|
| 101 |
+
canonical_path = _resolve_file_inside_mount(mount, canonical_value, "kb_active_canonical_missing")
|
| 102 |
+
current_facts_path = (
|
| 103 |
+
_resolve_file_inside_mount(mount, current_value, "kb_active_current_missing")
|
| 104 |
+
if current_value
|
| 105 |
+
else None
|
| 106 |
+
)
|
| 107 |
+
aliases_path = (
|
| 108 |
+
_resolve_file_inside_mount(mount, aliases_value, "kb_active_aliases_missing")
|
| 109 |
+
if aliases_value
|
| 110 |
+
else None
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
return MountedKBRelease(
|
| 114 |
+
build_id=build_id,
|
| 115 |
+
canonical_path=canonical_path,
|
| 116 |
+
current_facts_path=current_facts_path,
|
| 117 |
+
aliases_path=aliases_path,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def load_remote_kb_release(
|
| 122 |
+
*,
|
| 123 |
+
bucket_id: str,
|
| 124 |
+
token: str,
|
| 125 |
+
expected_build_id: str,
|
| 126 |
+
pointer_name: str = HF_KB_ACTIVE_POINTER_DEFAULT,
|
| 127 |
+
cache_root: str | None = None,
|
| 128 |
+
) -> MountedKBRelease:
|
| 129 |
+
bucket = bucket_id.strip()
|
| 130 |
+
auth = token.strip()
|
| 131 |
+
if not bucket:
|
| 132 |
+
raise ValueError("kb_bucket_id_missing")
|
| 133 |
+
if not auth:
|
| 134 |
+
raise ValueError("hf_token_missing")
|
| 135 |
+
|
| 136 |
+
fs = HfFileSystem(token=auth)
|
| 137 |
+
base = f"hf://buckets/{bucket}"
|
| 138 |
+
|
| 139 |
+
def read_remote(relative_path: str, code: str) -> bytes:
|
| 140 |
+
relative = _safe_relative(relative_path, code)
|
| 141 |
+
try:
|
| 142 |
+
with fs.open(f"{base}/{relative}", "rb") as handle:
|
| 143 |
+
raw = handle.read()
|
| 144 |
+
except Exception as exc:
|
| 145 |
+
raise ValueError(code) from exc
|
| 146 |
+
if not raw:
|
| 147 |
+
raise ValueError(code)
|
| 148 |
+
return raw
|
| 149 |
+
|
| 150 |
+
pointer_name = _safe_relative(pointer_name, "kb_active_pointer_missing")
|
| 151 |
+
try:
|
| 152 |
+
pointer_payload = json.loads(read_remote(pointer_name, "kb_active_pointer_missing").decode("utf-8"))
|
| 153 |
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
| 154 |
+
raise ValueError("kb_active_pointer_invalid") from exc
|
| 155 |
+
|
| 156 |
+
build_id, canonical_value, current_value, aliases_value, manifest_value = _validate_pointer(
|
| 157 |
+
pointer_payload,
|
| 158 |
+
expected_build_id,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
try:
|
| 162 |
+
manifest_payload = json.loads(read_remote(manifest_value, "kb_active_manifest_missing").decode("utf-8"))
|
| 163 |
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
| 164 |
+
raise ValueError("kb_active_manifest_invalid") from exc
|
| 165 |
+
if not isinstance(manifest_payload, dict) or str(manifest_payload.get("build_id") or "") != build_id:
|
| 166 |
+
raise ValueError("kb_active_manifest_invalid")
|
| 167 |
+
|
| 168 |
+
manifest_files: dict[str, tuple[int, str]] = {}
|
| 169 |
+
for item in manifest_payload.get("files") or []:
|
| 170 |
+
if not isinstance(item, dict):
|
| 171 |
+
continue
|
| 172 |
+
name = str(item.get("path") or "").strip()
|
| 173 |
+
sha256 = str(item.get("sha256") or "").strip()
|
| 174 |
+
try:
|
| 175 |
+
size = int(item.get("bytes"))
|
| 176 |
+
except (TypeError, ValueError):
|
| 177 |
+
continue
|
| 178 |
+
if name and sha256:
|
| 179 |
+
manifest_files[name] = (size, sha256)
|
| 180 |
+
|
| 181 |
+
cache = Path(cache_root).expanduser() if cache_root else Path(tempfile.gettempdir()) / "astera-customerai-kb"
|
| 182 |
+
release_cache = cache / build_id
|
| 183 |
+
release_cache.mkdir(parents=True, exist_ok=True)
|
| 184 |
+
|
| 185 |
+
def materialize(relative_path: str, code: str) -> Path:
|
| 186 |
+
relative = _safe_relative(relative_path, code)
|
| 187 |
+
name = PurePosixPath(relative).name
|
| 188 |
+
expected = manifest_files.get(name)
|
| 189 |
+
if expected is None:
|
| 190 |
+
raise ValueError("kb_manifest_file_missing")
|
| 191 |
+
expected_size, expected_sha = expected
|
| 192 |
+
destination = release_cache / name
|
| 193 |
+
|
| 194 |
+
if destination.is_file():
|
| 195 |
+
cached = destination.read_bytes()
|
| 196 |
+
if len(cached) == expected_size and hashlib.sha256(cached).hexdigest() == expected_sha:
|
| 197 |
+
return destination
|
| 198 |
+
|
| 199 |
+
raw = read_remote(relative, code)
|
| 200 |
+
if len(raw) != expected_size or hashlib.sha256(raw).hexdigest() != expected_sha:
|
| 201 |
+
raise ValueError("kb_remote_integrity_mismatch")
|
| 202 |
+
temporary = destination.with_suffix(destination.suffix + ".tmp")
|
| 203 |
+
temporary.write_bytes(raw)
|
| 204 |
+
temporary.replace(destination)
|
| 205 |
+
return destination
|
| 206 |
+
|
| 207 |
+
canonical_path = materialize(canonical_value, "kb_active_canonical_missing")
|
| 208 |
+
current_facts_path = materialize(current_value, "kb_active_current_missing") if current_value else None
|
| 209 |
+
aliases_path = materialize(aliases_value, "kb_active_aliases_missing") if aliases_value else None
|
| 210 |
+
|
| 211 |
+
return MountedKBRelease(
|
| 212 |
+
build_id=build_id,
|
| 213 |
+
canonical_path=canonical_path,
|
| 214 |
+
current_facts_path=current_facts_path,
|
| 215 |
+
aliases_path=aliases_path,
|
| 216 |
+
)
|
runtime/security.py
CHANGED
|
@@ -1,11 +1,23 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from dataclasses import dataclass
|
| 4 |
from typing import Iterable
|
| 5 |
|
| 6 |
from .schemas import GroundedFact
|
| 7 |
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
@dataclass(frozen=True)
|
| 10 |
class SecurityCheck:
|
| 11 |
passed: bool
|
|
@@ -16,11 +28,31 @@ class PublicBoundary:
|
|
| 16 |
def filter_facts(self, facts: Iterable[GroundedFact]) -> list[GroundedFact]:
|
| 17 |
return [f for f in facts if f.public and not f.legacy and not f.undecided]
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
violations: list[str] = []
|
| 21 |
for literal in forbidden_literals:
|
| 22 |
if literal and literal in answer:
|
| 23 |
-
violations.append("forbidden_literal_exposed")
|
| 24 |
-
|
|
|
|
| 25 |
violations.append("unexecuted_completion_claim")
|
| 26 |
return SecurityCheck(passed=not violations, violations=violations)
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import re
|
| 4 |
from dataclasses import dataclass
|
| 5 |
from typing import Iterable
|
| 6 |
|
| 7 |
from .schemas import GroundedFact
|
| 8 |
|
| 9 |
|
| 10 |
+
_UNEXECUTED_JA = re.compile(
|
| 11 |
+
r"(?:^|[。!?!?\n])\s*(?:[-*・]\s*)?(?:(?:こちら|当方)で\s*|私(?:が|は)?\s*)?"
|
| 12 |
+
r"(?:(?:実行|変更|更新|削除|作成|送信|登録|設定|反映|デプロイ|公開|保存|修正|コミット|プッシュ|処理)(?:を)?(?:し|いたし)(?:ました|ておきました|てあります)|完了しました)"
|
| 13 |
+
)
|
| 14 |
+
_UNEXECUTED_EN = re.compile(
|
| 15 |
+
r"(?:^|[.!?\n])\s*(?:[-*]\s*)?(?:i|we)\s+(?:have\s+)?"
|
| 16 |
+
r"(?:executed|changed|updated|deleted|created|sent|registered|configured|deployed|published|saved|fixed|committed|pushed|completed)\b",
|
| 17 |
+
re.IGNORECASE,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
@dataclass(frozen=True)
|
| 22 |
class SecurityCheck:
|
| 23 |
passed: bool
|
|
|
|
| 28 |
def filter_facts(self, facts: Iterable[GroundedFact]) -> list[GroundedFact]:
|
| 29 |
return [f for f in facts if f.public and not f.legacy and not f.undecided]
|
| 30 |
|
| 31 |
+
@staticmethod
|
| 32 |
+
def detect_unexecuted_completion_claim(answer: str) -> bool:
|
| 33 |
+
"""Detect assistant-style claims that an external mutation was already executed.
|
| 34 |
+
|
| 35 |
+
Customer AI has no write/deploy authority. A role prompt is not sufficient as
|
| 36 |
+
an enforcement boundary, so suspicious first-person/subject-omitted completion
|
| 37 |
+
language is rejected deterministically before public output.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
if not answer.strip():
|
| 41 |
+
return False
|
| 42 |
+
return bool(_UNEXECUTED_JA.search(answer) or _UNEXECUTED_EN.search(answer))
|
| 43 |
+
|
| 44 |
+
def check_output(
|
| 45 |
+
self,
|
| 46 |
+
*,
|
| 47 |
+
answer: str,
|
| 48 |
+
forbidden_literals: Iterable[str],
|
| 49 |
+
unexecuted_completion_claim: bool,
|
| 50 |
+
) -> SecurityCheck:
|
| 51 |
violations: list[str] = []
|
| 52 |
for literal in forbidden_literals:
|
| 53 |
if literal and literal in answer:
|
| 54 |
+
violations.append("forbidden_literal_exposed")
|
| 55 |
+
break
|
| 56 |
+
if unexecuted_completion_claim or self.detect_unexecuted_completion_claim(answer):
|
| 57 |
violations.append("unexecuted_completion_claim")
|
| 58 |
return SecurityCheck(passed=not violations, violations=violations)
|
runtime/startup.py
CHANGED
|
@@ -7,6 +7,13 @@ from pathlib import Path
|
|
| 7 |
|
| 8 |
from .bootstrap import RuntimeDependencies, build_work
|
| 9 |
from .hf_client import HF_CHAT_API
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from .live_state import EmptyLiveStateProvider, HybridLiveStateProvider
|
| 11 |
from .service import CustomerAIWork
|
| 12 |
|
|
@@ -24,6 +31,13 @@ def _required_file(value: str, code: str) -> Path:
|
|
| 24 |
return path
|
| 25 |
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
def _load_alias_registry(path_value: str) -> Mapping[str, Iterable[str]]:
|
| 28 |
if not path_value.strip():
|
| 29 |
return {}
|
|
@@ -42,18 +56,67 @@ def _load_alias_registry(path_value: str) -> Mapping[str, Iterable[str]]:
|
|
| 42 |
return normalized
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
def create_work_from_environment(
|
| 46 |
env: Mapping[str, str] | None = None,
|
| 47 |
*,
|
| 48 |
role_pool: object | None = None,
|
| 49 |
) -> CustomerAIWork:
|
| 50 |
values = os.environ if env is None else env
|
| 51 |
-
kb_path = _required_file(values.get("CUSTOMER_AI_KB_SNAPSHOT_PATH", "").strip(), "kb_snapshot_missing")
|
| 52 |
-
generation_id = values.get("CUSTOMER_AI_KB_GENERATION_ID", "").strip() or kb_path.stem
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
live_provider = HybridLiveStateProvider.from_jsonl(
|
| 58 |
current_path,
|
| 59 |
generation_id=f"{generation_id}:current",
|
|
@@ -73,7 +136,7 @@ def create_work_from_environment(
|
|
| 73 |
return build_work(
|
| 74 |
RuntimeDependencies(
|
| 75 |
live_state_provider=live_provider,
|
| 76 |
-
japanese_alias_registry=_load_alias_registry(
|
| 77 |
japanese_fuzzy_threshold=fuzzy_threshold,
|
| 78 |
kb_snapshot_path=str(kb_path),
|
| 79 |
kb_generation_id=generation_id,
|
|
|
|
| 7 |
|
| 8 |
from .bootstrap import RuntimeDependencies, build_work
|
| 9 |
from .hf_client import HF_CHAT_API
|
| 10 |
+
from .kb_bucket import (
|
| 11 |
+
HF_KB_ACTIVE_POINTER_DEFAULT,
|
| 12 |
+
HF_KB_BUCKET_DEFAULT,
|
| 13 |
+
HF_KB_MOUNT_DEFAULT,
|
| 14 |
+
load_mounted_kb_release,
|
| 15 |
+
load_remote_kb_release,
|
| 16 |
+
)
|
| 17 |
from .live_state import EmptyLiveStateProvider, HybridLiveStateProvider
|
| 18 |
from .service import CustomerAIWork
|
| 19 |
|
|
|
|
| 31 |
return path
|
| 32 |
|
| 33 |
|
| 34 |
+
def _required_value(values: Mapping[str, str], key: str, code: str) -> str:
|
| 35 |
+
value = values.get(key, "").strip()
|
| 36 |
+
if not value:
|
| 37 |
+
raise RuntimeNotReady(code)
|
| 38 |
+
return value
|
| 39 |
+
|
| 40 |
+
|
| 41 |
def _load_alias_registry(path_value: str) -> Mapping[str, Iterable[str]]:
|
| 42 |
if not path_value.strip():
|
| 43 |
return {}
|
|
|
|
| 56 |
return normalized
|
| 57 |
|
| 58 |
|
| 59 |
+
def _production_kb_files(values: Mapping[str, str]) -> tuple[Path, Path | None, Path | None, str]:
|
| 60 |
+
build_id = _required_value(values, "CUSTOMER_AI_KB_BUILD_ID", "kb_build_id_missing")
|
| 61 |
+
mount_path = values.get("CUSTOMER_AI_KB_MOUNT_PATH", "").strip() or HF_KB_MOUNT_DEFAULT
|
| 62 |
+
pointer_name = (
|
| 63 |
+
values.get("CUSTOMER_AI_KB_ACTIVE_POINTER", "").strip()
|
| 64 |
+
or HF_KB_ACTIVE_POINTER_DEFAULT
|
| 65 |
+
)
|
| 66 |
+
bucket_id = values.get("CUSTOMER_AI_KB_BUCKET_ID", "").strip() or HF_KB_BUCKET_DEFAULT
|
| 67 |
+
token = (values.get("HF_TOKEN", "") or values.get("HF_KEY", "")).strip()
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
release = load_mounted_kb_release(
|
| 71 |
+
mount_path=mount_path,
|
| 72 |
+
expected_build_id=build_id,
|
| 73 |
+
pointer_name=pointer_name,
|
| 74 |
+
)
|
| 75 |
+
except ValueError as exc:
|
| 76 |
+
if str(exc) != "kb_bucket_mount_missing":
|
| 77 |
+
raise RuntimeNotReady(str(exc)) from exc
|
| 78 |
+
if not token:
|
| 79 |
+
raise RuntimeNotReady("hf_token_missing") from exc
|
| 80 |
+
try:
|
| 81 |
+
release = load_remote_kb_release(
|
| 82 |
+
bucket_id=bucket_id,
|
| 83 |
+
token=token,
|
| 84 |
+
expected_build_id=build_id,
|
| 85 |
+
pointer_name=pointer_name,
|
| 86 |
+
)
|
| 87 |
+
except ValueError as remote_exc:
|
| 88 |
+
raise RuntimeNotReady(str(remote_exc)) from remote_exc
|
| 89 |
+
|
| 90 |
+
return (
|
| 91 |
+
release.canonical_path,
|
| 92 |
+
release.current_facts_path,
|
| 93 |
+
release.aliases_path,
|
| 94 |
+
release.build_id,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
def create_work_from_environment(
|
| 99 |
env: Mapping[str, str] | None = None,
|
| 100 |
*,
|
| 101 |
role_pool: object | None = None,
|
| 102 |
) -> CustomerAIWork:
|
| 103 |
values = os.environ if env is None else env
|
|
|
|
|
|
|
| 104 |
|
| 105 |
+
if role_pool is None:
|
| 106 |
+
kb_path, current_path, alias_path, build_id = _production_kb_files(values)
|
| 107 |
+
generation_id = values.get("CUSTOMER_AI_KB_GENERATION_ID", "").strip() or build_id
|
| 108 |
+
else:
|
| 109 |
+
kb_path = _required_file(
|
| 110 |
+
values.get("CUSTOMER_AI_KB_SNAPSHOT_PATH", "").strip(),
|
| 111 |
+
"kb_snapshot_missing",
|
| 112 |
+
)
|
| 113 |
+
generation_id = values.get("CUSTOMER_AI_KB_GENERATION_ID", "").strip() or kb_path.stem
|
| 114 |
+
current_value = values.get("CUSTOMER_AI_CURRENT_FACTS_PATH", "").strip()
|
| 115 |
+
current_path = _required_file(current_value, "current_facts_missing") if current_value else None
|
| 116 |
+
alias_value = values.get("CUSTOMER_AI_ALIAS_REGISTRY_PATH", "").strip()
|
| 117 |
+
alias_path = _required_file(alias_value, "alias_registry_missing") if alias_value else None
|
| 118 |
+
|
| 119 |
+
if current_path is not None:
|
| 120 |
live_provider = HybridLiveStateProvider.from_jsonl(
|
| 121 |
current_path,
|
| 122 |
generation_id=f"{generation_id}:current",
|
|
|
|
| 136 |
return build_work(
|
| 137 |
RuntimeDependencies(
|
| 138 |
live_state_provider=live_provider,
|
| 139 |
+
japanese_alias_registry=_load_alias_registry(str(alias_path) if alias_path else ""),
|
| 140 |
japanese_fuzzy_threshold=fuzzy_threshold,
|
| 141 |
kb_snapshot_path=str(kb_path),
|
| 142 |
kb_generation_id=generation_id,
|
runtime/task_decomposition.py
CHANGED
|
@@ -7,6 +7,8 @@ from .contracts import TaskContract
|
|
| 7 |
from .schemas import NeedTask
|
| 8 |
|
| 9 |
_SENTENCE_SPLIT = re.compile(r"(?:\n{2,}|(?<=[。!?!?])\s*)")
|
|
|
|
|
|
|
| 10 |
_COMPARISON = ("比較", "違い", "どちら", "どっち", "vs", "対して")
|
| 11 |
_PROCEDURE = ("方法", "手順", "やり方", "どうや", "設定", "登録", "作成", "実装")
|
| 12 |
_TROUBLE = ("エラー", "不具合", "動か", "失敗", "直ら", "できない", "困")
|
|
@@ -41,6 +43,19 @@ class TaskDecomposer:
|
|
| 41 |
folded = text.casefold()
|
| 42 |
return not any(k in folded for k in ("ありがとう", "thanks", "thank you", "こんにちは", "hello"))
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
def _make_task(self, text: str, idx: int, *, priority: str = "primary") -> NeedTask:
|
| 45 |
shape = self._shape(text)
|
| 46 |
return NeedTask(
|
|
@@ -83,3 +98,30 @@ class TaskDecomposer:
|
|
| 83 |
|
| 84 |
def requires_semantic_expansion(self, contract: TaskContract) -> bool:
|
| 85 |
return "semantic_decomposition_required" in contract.constraints
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from .schemas import NeedTask
|
| 8 |
|
| 9 |
_SENTENCE_SPLIT = re.compile(r"(?:\n{2,}|(?<=[。!?!?])\s*)")
|
| 10 |
+
_COVERAGE_SPLIT = re.compile(r"(?:\n+|[。!?!?、,]+|(?:さらに|加えて|また|なお|かつ|一方で))")
|
| 11 |
+
_COVERAGE_NOISE = re.compile(r"[\s\u3000・::;;()()「」『』【】\[\]\"'`]+")
|
| 12 |
_COMPARISON = ("比較", "違い", "どちら", "どっち", "vs", "対して")
|
| 13 |
_PROCEDURE = ("方法", "手順", "やり方", "どうや", "設定", "登録", "作成", "実装")
|
| 14 |
_TROUBLE = ("エラー", "不具合", "動か", "失敗", "直ら", "できない", "困")
|
|
|
|
| 43 |
folded = text.casefold()
|
| 44 |
return not any(k in folded for k in ("ありがとう", "thanks", "thank you", "こんにちは", "hello"))
|
| 45 |
|
| 46 |
+
@staticmethod
|
| 47 |
+
def _coverage_normalize(text: str) -> str:
|
| 48 |
+
return _COVERAGE_NOISE.sub("", text.casefold())
|
| 49 |
+
|
| 50 |
+
@classmethod
|
| 51 |
+
def _coverage_fragments(cls, text: str) -> tuple[str, ...]:
|
| 52 |
+
fragments: list[str] = []
|
| 53 |
+
for part in _COVERAGE_SPLIT.split(text):
|
| 54 |
+
normalized = cls._coverage_normalize(part)
|
| 55 |
+
if len(normalized) >= 2:
|
| 56 |
+
fragments.append(normalized)
|
| 57 |
+
return tuple(dict.fromkeys(fragments))
|
| 58 |
+
|
| 59 |
def _make_task(self, text: str, idx: int, *, priority: str = "primary") -> NeedTask:
|
| 60 |
shape = self._shape(text)
|
| 61 |
return NeedTask(
|
|
|
|
| 98 |
|
| 99 |
def requires_semantic_expansion(self, contract: TaskContract) -> bool:
|
| 100 |
return "semantic_decomposition_required" in contract.constraints
|
| 101 |
+
|
| 102 |
+
def protect_semantic_expansion(self, seed: TaskContract, candidate: TaskContract) -> TaskContract:
|
| 103 |
+
"""Accept semantic expansion only when preservation of the original need is provable.
|
| 104 |
+
|
| 105 |
+
The semantic model may paraphrase freely, but Customer AI must never silently
|
| 106 |
+
drop a user clause. If deterministic coverage cannot be proven, fall back to
|
| 107 |
+
the seed's full-request task rather than trusting the model decomposition.
|
| 108 |
+
"""
|
| 109 |
+
|
| 110 |
+
if not candidate.need_tasks:
|
| 111 |
+
return seed
|
| 112 |
+
coverage_text = " ".join(task.text for task in candidate.need_tasks)
|
| 113 |
+
coverage = self._coverage_normalize(coverage_text)
|
| 114 |
+
required = self._coverage_fragments(seed.target)
|
| 115 |
+
if required and not all(fragment in coverage for fragment in required):
|
| 116 |
+
return seed.model_copy(
|
| 117 |
+
update={
|
| 118 |
+
"constraints": [item for item in seed.constraints if item != "semantic_decomposition_required"]
|
| 119 |
+
}
|
| 120 |
+
)
|
| 121 |
+
return candidate.model_copy(
|
| 122 |
+
update={
|
| 123 |
+
"target": seed.target,
|
| 124 |
+
"conditions": list(dict.fromkeys([*seed.conditions, *candidate.conditions])),
|
| 125 |
+
"constraints": [item for item in candidate.constraints if item != "semantic_decomposition_required"],
|
| 126 |
+
}
|
| 127 |
+
)
|