File size: 5,865 Bytes
a1e4405
 
 
 
 
 
 
 
 
aaa4456
 
a1e4405
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aaa4456
 
 
 
 
 
 
 
 
 
 
 
 
a1e4405
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aaa4456
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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"],
            }
        )