G-ACE commited on
Commit
aaa4456
·
verified ·
1 Parent(s): 9e95393

Deploy direct private KB runtime

Browse files
Files changed (40) hide show
  1. README.md +1 -0
  2. app.py +20 -5
  3. config/quality.yaml +26 -2
  4. deployment-manifest.json +7 -0
  5. pyproject.toml +2 -2
  6. requirements.txt +1 -0
  7. runtime/__pycache__/__init__.cpython-313.pyc +0 -0
  8. runtime/__pycache__/answer_quality.cpython-313.pyc +0 -0
  9. runtime/__pycache__/bootstrap.cpython-313.pyc +0 -0
  10. runtime/__pycache__/contracts.cpython-313.pyc +0 -0
  11. runtime/__pycache__/hf_client.cpython-313.pyc +0 -0
  12. runtime/__pycache__/integration.cpython-313.pyc +0 -0
  13. runtime/__pycache__/internal_core.cpython-313.pyc +0 -0
  14. runtime/__pycache__/japanese_skills.cpython-313.pyc +0 -0
  15. runtime/__pycache__/kagrra_bridge.cpython-313.pyc +0 -0
  16. runtime/__pycache__/kb_harvest.cpython-313.pyc +0 -0
  17. runtime/__pycache__/kb_search.cpython-313.pyc +0 -0
  18. runtime/__pycache__/knowledge.cpython-313.pyc +0 -0
  19. runtime/__pycache__/live_state.cpython-313.pyc +0 -0
  20. runtime/__pycache__/model.cpython-313.pyc +0 -0
  21. runtime/__pycache__/observability.cpython-313.pyc +0 -0
  22. runtime/__pycache__/quality.cpython-313.pyc +0 -0
  23. runtime/__pycache__/roles.cpython-313.pyc +0 -0
  24. runtime/__pycache__/runtime_factory.cpython-313.pyc +0 -0
  25. runtime/__pycache__/schemas.cpython-313.pyc +0 -0
  26. runtime/__pycache__/search_planner.cpython-313.pyc +0 -0
  27. runtime/__pycache__/security.cpython-313.pyc +0 -0
  28. runtime/__pycache__/service.cpython-313.pyc +0 -0
  29. runtime/__pycache__/shared_head.cpython-313.pyc +0 -0
  30. runtime/__pycache__/skill_runtime.cpython-313.pyc +0 -0
  31. runtime/__pycache__/startup.cpython-313.pyc +0 -0
  32. runtime/__pycache__/state.cpython-313.pyc +0 -0
  33. runtime/__pycache__/task_decomposition.cpython-313.pyc +0 -0
  34. runtime/__pycache__/v8_bridge.cpython-313.pyc +0 -0
  35. runtime/__pycache__/writing_skills.cpython-313.pyc +0 -0
  36. runtime/internal_core.py +83 -19
  37. runtime/kb_bucket.py +216 -0
  38. runtime/security.py +35 -3
  39. runtime/startup.py +69 -6
  40. 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
- _origins = [
70
- origin.strip()
71
- for origin in os.environ.get("CUSTOMER_AI_ALLOWED_ORIGINS", "https://asterav8.jp").split(",")
72
- if origin.strip()
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
- user_need_resolution_min: 0.98
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
- critical_resolution_min: 0.99
 
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
- contract = self.decomposer.decompose(normalized_text, context)
90
- if self.decomposer.requires_semantic_expansion(contract) and hasattr(self.roles, "semantic_decompose"):
91
- contract = await self.roles.semantic_decompose(normalized_text, contract)
 
 
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={t.task_id for t in contract.need_tasks},
113
  satisfaction_blockers={"grounding_conflict"},
114
  )
115
- return self._failure(request_id, session_id, turn_id, ResolutionMode.BLOCKED_CURRENT_FACT, "grounding_conflict", ["grounding_conflict"])
 
 
 
 
 
 
 
 
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={t.task_id for t in contract.need_tasks},
123
  satisfaction_blockers={"preflight_runtime_failure"},
124
  )
125
- return self._failure(request_id, session_id, turn_id, ResolutionMode.RUNTIME_FAILURE, "runtime_failure", ["preflight_runtime_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={t.task_id for t in contract.need_tasks},
167
  satisfaction_blockers={"role_runtime_failure"},
168
  )
169
- return self._failure(request_id, session_id, turn_id, ResolutionMode.RUNTIME_FAILURE, "runtime_failure", ["role_runtime_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
- security = self.security.check_output(answer=answer, forbidden_literals=packet.forbidden_claims, unexecuted_completion_claim=False)
 
 
 
 
 
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=resolved,
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=answer or composed.answer,
268
- answered_task_ids=list(composed.resolved_task_ids),
269
- unresolved_task_ids=list(composed.unresolved_task_ids),
270
  evidence_ids=list(integrated.evidence_ids),
271
  resolution_score=quality.resolution_score,
272
  passed=passed,
273
- resolution_mode=composed.mode,
274
  clarification_questions=list(composed.clarification_questions),
275
  failure_class=failure_class,
276
  violations=violations,
277
  )
278
 
279
  @staticmethod
280
- def _failure(request_id, session_id, turn_id, mode, failure_class, violations):
 
 
 
 
 
 
 
 
 
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
- def check_output(self, *, answer: str, forbidden_literals: Iterable[str], unexecuted_completion_claim: bool) -> SecurityCheck:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  violations: list[str] = []
21
  for literal in forbidden_literals:
22
  if literal and literal in answer:
23
- violations.append("forbidden_literal_exposed"); break
24
- if unexecuted_completion_claim:
 
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
- current_value = values.get("CUSTOMER_AI_CURRENT_FACTS_PATH", "").strip()
55
- if current_value:
56
- current_path = _required_file(current_value, "current_facts_missing")
 
 
 
 
 
 
 
 
 
 
 
 
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(values.get("CUSTOMER_AI_ALIAS_REGISTRY_PATH", "")),
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
+ )