astera-customerAI / runtime /task_decomposition.py
G-ACE's picture
Deploy direct private KB runtime
aaa4456 verified
Raw History Blame Contribute Delete
5.87 kB
from __future__ import annotations
import re
from collections.abc import Mapping
from .contracts import TaskContract
from .schemas import NeedTask
_SENTENCE_SPLIT = re.compile(r"(?:\n{2,}|(?<=[。!?!?])\s*)")
_COVERAGE_SPLIT = re.compile(r"(?:\n+|[。!?!?、,]+|(?:さらに|加えて|また|なお|かつ|一方で))")
_COVERAGE_NOISE = re.compile(r"[\s\u3000・::;;()()「」『』【】\[\]\"'`]+")
_COMPARISON = ("比較", "違い", "どちら", "どっち", "vs", "対して")
_PROCEDURE = ("方法", "手順", "やり方", "どうや", "設定", "登録", "作成", "実装")
_TROUBLE = ("エラー", "不具合", "動か", "失敗", "直ら", "できない", "困")
_COMPOUND = ("それぞれ", "全部", "すべて", "加えて", "さらに", "なお", "一方", "かつ")
class TaskDecomposer:
"""Astera由来Task Contractの軽量Preflight。複雑な場合だけShared Headへ意味分解を委譲する。"""
def __init__(self, *, max_fast_tasks: int = 4):
if max_fast_tasks < 1:
raise ValueError("max_fast_tasks_invalid")
self.max_fast_tasks = max_fast_tasks
@staticmethod
def _shape(text: str) -> str:
folded = text.casefold()
if any(k in folded for k in _TROUBLE):
return "troubleshooting"
if any(k in folded for k in _COMPARISON):
return "comparison"
if any(k in folded for k in _PROCEDURE):
return "procedure"
return "direct"
@staticmethod
def _intent(shape: str) -> str:
return {"troubleshooting": "troubleshooting", "comparison": "comparison", "procedure": "procedure"}.get(shape, "general")
@staticmethod
def _grounding_required(text: str) -> bool:
folded = text.casefold()
return not any(k in folded for k in ("ありがとう", "thanks", "thank you", "こんにちは", "hello"))
@staticmethod
def _coverage_normalize(text: str) -> str:
return _COVERAGE_NOISE.sub("", text.casefold())
@classmethod
def _coverage_fragments(cls, text: str) -> tuple[str, ...]:
fragments: list[str] = []
for part in _COVERAGE_SPLIT.split(text):
normalized = cls._coverage_normalize(part)
if len(normalized) >= 2:
fragments.append(normalized)
return tuple(dict.fromkeys(fragments))
def _make_task(self, text: str, idx: int, *, priority: str = "primary") -> NeedTask:
shape = self._shape(text)
return NeedTask(
task_id=f"t{idx}",
text=text,
intent=self._intent(shape),
required_facts=["evidence_required"] if self._grounding_required(text) else [],
completion_condition=f"resolve:{idx}",
priority=priority,
response_shape=shape,
actionability_required=shape in {"procedure", "troubleshooting"},
)
def decompose(self, message: str, context: Mapping[str, object] | None = None) -> TaskContract:
text = message.strip()
if not text:
raise ValueError("empty_request")
pieces = [part.strip() for part in _SENTENCE_SPLIT.split(text) if part.strip()] or [text]
semantic_required = len(pieces) > self.max_fast_tasks or len(text) > 220 or any(m in text for m in _COMPOUND)
# Needを切り捨てない。Fast上限を超えたら全文1 Taskを保持し、必要時だけ意味分解する。
if semantic_required:
tasks = [self._make_task(text, 1)]
else:
tasks = [self._make_task(part, idx, priority="primary" if idx == 1 else "secondary") for idx, part in enumerate(pieces, 1)]
conditions: list[str] = []
if context:
conditions.extend(f"{key}={value}" for key, value in dict(context.get("user_conditions") or {}).items())
constraints = ["semantic_decomposition_required"] if semantic_required else []
return TaskContract(
purpose="resolve_customer_need",
target=text,
conditions=conditions,
constraints=constraints,
premises=[],
missing_information=[],
success_criteria=[f"{task.task_id}:{task.completion_condition}" for task in tasks],
need_tasks=tasks,
completion_conditions=[task.completion_condition for task in tasks],
)
def requires_semantic_expansion(self, contract: TaskContract) -> bool:
return "semantic_decomposition_required" in contract.constraints
def protect_semantic_expansion(self, seed: TaskContract, candidate: TaskContract) -> TaskContract:
"""Accept semantic expansion only when preservation of the original need is provable.
The semantic model may paraphrase freely, but Customer AI must never silently
drop a user clause. If deterministic coverage cannot be proven, fall back to
the seed's full-request task rather than trusting the model decomposition.
"""
if not candidate.need_tasks:
return seed
coverage_text = " ".join(task.text for task in candidate.need_tasks)
coverage = self._coverage_normalize(coverage_text)
required = self._coverage_fragments(seed.target)
if required and not all(fragment in coverage for fragment in required):
return seed.model_copy(
update={
"constraints": [item for item in seed.constraints if item != "semantic_decomposition_required"]
}
)
return candidate.model_copy(
update={
"target": seed.target,
"conditions": list(dict.fromkeys([*seed.conditions, *candidate.conditions])),
"constraints": [item for item in candidate.constraints if item != "semantic_decomposition_required"],
}
)