File size: 6,558 Bytes
c8d30bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
"""
柱 1:情境工程 — AGENTS.md 解析器 + 任務路由引擎
================================================

漸進式情境披露(Progressive Disclosure)的核心元件。
根據任務描述的關鍵字,匹配 AGENTS.md 中的路由段落,
回傳最相關的文件引用列表。

層級邊界:L1(最底層)— 不可引用 harness.constraints 或 harness.entropy
"""

import re
import logging
from pathlib import Path
from dataclasses import dataclass, field

logger = logging.getLogger("threathunter.harness.context")


@dataclass
class DocReference:
    """文件引用"""
    path: str           # 檔案相對路徑
    section: str        # 所屬段落標題
    description: str    # 用途說明
    relevance: float    # 關鍵字重疊度(0.0 ~ 1.0)


@dataclass
class RouteSection:
    """AGENTS.md 中的一個路由段落"""
    title: str                          # 段落標題(例如「修復測試失敗」)
    keywords: list[str] = field(default_factory=list)  # 關鍵字
    references: list[str] = field(default_factory=list)  # 文件引用路徑
    description: str = ""               # 段落描述


class AgentMap:
    """
    AGENTS.md 解析器 + 任務路由引擎

    將 AGENTS.md 解析為結構化路由表,
    根據任務描述的關鍵字重疊度匹配,
    回傳最相關的文件引用。

    用法:
        agent_map = AgentMap(project_root)
        agent_map.load()
        refs = agent_map.query_context("修復 memory_tool 的測試失敗")
    """

    def __init__(self, project_root: Path):
        self.project_root = project_root
        self.agents_md_path = project_root / "AGENTS.md"
        self.sections: list[RouteSection] = []
        self.global_rules: list[str] = []
        self._loaded = False

    def load(self) -> bool:
        """
        載入並解析 AGENTS.md

        Returns:
            是否成功載入
        """
        if not self.agents_md_path.exists():
            logger.warning(f"AGENTS.md 不存在:{self.agents_md_path}")
            return False

        try:
            content = self.agents_md_path.read_text(encoding="utf-8")
            self._parse(content)
            self._loaded = True
            logger.info(
                f"✅ AGENTS.md 已載入:"
                f"{len(self.sections)} 個路由段落,"
                f"{len(self.global_rules)} 條全域規則"
            )
            return True
        except Exception as e:
            logger.error(f"AGENTS.md 解析失敗:{e}")
            return False

    def _parse(self, content: str) -> None:
        """解析 AGENTS.md 的 Markdown 結構"""
        self.sections = []
        self.global_rules = []

        current_section: RouteSection | None = None
        in_rules_block = False

        for line in content.split("\n"):
            stripped = line.strip()

            # 偵測 ### 段落標題(任務路由段落)
            if stripped.startswith("### "):
                if current_section:
                    self.sections.append(current_section)
                title = stripped[4:].strip()
                # 從「如果你的任務是「XXX」」格式提取關鍵字
                keywords = re.findall(r"[「「](.+?)[」」]", title)
                current_section = RouteSection(
                    title=title,
                    keywords=keywords,
                )
                continue

            # 收集文件引用(→ 格式)
            if current_section and ("→" in stripped or "->" in stripped):
                # 提取路徑引用(反引號包裹的路徑)
                paths = re.findall(r"`([^`]+)`", stripped)
                for path in paths:
                    if "/" in path or path.endswith((".py", ".md", ".toml", ".json")):
                        current_section.references.append(path)
                # 提取描述
                desc = re.sub(r"`[^`]+`", "", stripped).strip("→-> ").strip()
                if desc:
                    current_section.description += desc + " "
                continue

            # 收集全域規則(- 開頭的列表)
            if not current_section and stripped.startswith("- "):
                self.global_rules.append(stripped[2:])

        # 最後一個段落
        if current_section:
            self.sections.append(current_section)

    def query_context(self, task: str, top_k: int = 5) -> list[DocReference]:
        """
        漸進披露的核心方法:根據任務描述匹配文件引用

        使用關鍵字重疊度進行匹配:
        overlap = len(task_tokens ∩ section_keywords) / len(section_keywords)

        Args:
            task: 使用者的任務描述
            top_k: 回傳最相關的前 N 個引用

        Returns:
            按相關性排序的 DocReference 列表
        """
        if not self._loaded:
            self.load()

        task_tokens = set(self._tokenize(task))
        results: list[DocReference] = []

        for section in self.sections:
            section_tokens = set()
            for kw in section.keywords:
                section_tokens.update(self._tokenize(kw))
            # 加上標題的 token
            section_tokens.update(self._tokenize(section.title))

            if not section_tokens:
                continue

            # 計算重疊度
            overlap = len(task_tokens & section_tokens)
            relevance = overlap / max(len(section_tokens), 1)

            if relevance > 0:
                for ref_path in section.references:
                    results.append(DocReference(
                        path=ref_path,
                        section=section.title,
                        description=section.description.strip(),
                        relevance=relevance,
                    ))

        # 按相關性排序,取 top_k
        results.sort(key=lambda r: r.relevance, reverse=True)
        return results[:top_k]

    @staticmethod
    def _tokenize(text: str) -> list[str]:
        """
        簡易中英文分詞

        英文:按空白和標點拆分,轉小寫
        中文:逐字拆分(每個漢字作為獨立 token)
        """
        tokens = []
        # 英文 token
        english_tokens = re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", text)
        tokens.extend(t.lower() for t in english_tokens)
        # 中文 token(逐字)
        chinese_chars = re.findall(r"[\u4e00-\u9fff]", text)
        tokens.extend(chinese_chars)
        return tokens