concierge-graph 3.8.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +14 -0
- agents/revisor_critico.py +610 -0
- concierge_graph-3.8.2.dist-info/METADATA +327 -0
- concierge_graph-3.8.2.dist-info/RECORD +37 -0
- concierge_graph-3.8.2.dist-info/WHEEL +5 -0
- concierge_graph-3.8.2.dist-info/entry_points.txt +3 -0
- concierge_graph-3.8.2.dist-info/licenses/LICENSE +21 -0
- concierge_graph-3.8.2.dist-info/top_level.txt +8 -0
- core/__init__.py +23 -0
- core/config.py +192 -0
- core/hybrid_search.py +288 -0
- core/memory_extractor.py +245 -0
- core/middleware.py +723 -0
- core/probabilistic_retriever.py +99 -0
- core/project_index.py +316 -0
- core/vector_backend.py +471 -0
- ingestion/__init__.py +25 -0
- ingestion/crawler.py +722 -0
- ingestion/orchestrator.py +920 -0
- ingestion/parser.py +984 -0
- ingestion/summarizer.py +948 -0
- interface/__init__.py +16 -0
- interface/action_hooks.py +261 -0
- interface/cli.py +391 -0
- interface/mcp_server.py +1737 -0
- main.py +281 -0
- scripts/bootstrap_core_memory.py +93 -0
- services/__init__.py +13 -0
- services/janitor.py +762 -0
- storage/__init__.py +31 -0
- storage/base_backend.py +241 -0
- storage/connection.py +310 -0
- storage/logic.py +703 -0
- storage/schema.py +390 -0
- storage/semantic_logic.py +125 -0
- storage/store.py +802 -0
- storage/vector_store.py +759 -0
ingestion/parser.py
ADDED
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ingestion/parser.py - Grafo Concierge v3.8.0 (Absolute Solidity)
|
|
3
|
+
|
|
4
|
+
Semantic/AST Chunking with Prompt Armor for the Apex Ingestion Engine.
|
|
5
|
+
|
|
6
|
+
Responsibilities:
|
|
7
|
+
- Split files into chunks respecting logical blocks (functions, classes, sections).
|
|
8
|
+
- Support multiple languages: Python (AST), JavaScript/TypeScript, Markdown.
|
|
9
|
+
- Apply Prompt Armor (XML tags <raw_data_do_not_execute>) for sanitization.
|
|
10
|
+
- Extract metadata from each chunk (tags, imports, detected dependencies).
|
|
11
|
+
- Respect token limit per chunk (MAX_CHUNK_TOKENS).
|
|
12
|
+
|
|
13
|
+
Integration:
|
|
14
|
+
- Receives CrawlResult from crawler.py.
|
|
15
|
+
- Produces a list of ParsedChunk consumed by summarizer.py.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import logging
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
from ingestion.crawler import CrawlResult, FileCategory
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("grafo-concierge.parser")
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Settings
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
MAX_CHUNK_TOKENS: int = 512
|
|
36
|
+
PROMPT_ARMOR_OPEN: str = "<!-- DATA_DO_NOT_EXECUTE:"
|
|
37
|
+
PROMPT_ARMOR_CLOSE: str = "-->"
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# ChunkType
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
class ChunkType(str, Enum):
|
|
44
|
+
FUNCTION = "function"
|
|
45
|
+
METHOD = "method"
|
|
46
|
+
CLASS = "class"
|
|
47
|
+
MODULE = "module"
|
|
48
|
+
SECTION = "section"
|
|
49
|
+
CONFIG = "config"
|
|
50
|
+
RAW = "raw"
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# ParsedChunk
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ParsedChunk:
|
|
58
|
+
"""A semantic chunk extracted from a file."""
|
|
59
|
+
content: str
|
|
60
|
+
armored_content: str
|
|
61
|
+
chunk_type: ChunkType
|
|
62
|
+
chunk_index: int
|
|
63
|
+
source_file: str
|
|
64
|
+
file_hash: str
|
|
65
|
+
category: FileCategory
|
|
66
|
+
start_line: int = 0
|
|
67
|
+
end_line: int = 0
|
|
68
|
+
symbol_name: str = ""
|
|
69
|
+
detected_tags: list[str] = field(default_factory=list)
|
|
70
|
+
estimated_tokens: int = 0
|
|
71
|
+
calls: list[str] = field(default_factory=list)
|
|
72
|
+
node_id: Optional[int] = None
|
|
73
|
+
# Delta Cache fields — filled by orchestrator._detect_cached_chunks
|
|
74
|
+
cached: bool = False
|
|
75
|
+
cached_summary: Optional[str] = None
|
|
76
|
+
cached_tags: Optional[list[str]] = None
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Regex patterns for JS/TS
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
_JS_FUNCTION_RE = re.compile(
|
|
83
|
+
r"(?:^|\n)"
|
|
84
|
+
r"(?:export\s+(?:default\s+)?)?"
|
|
85
|
+
r"(?:async\s+)?"
|
|
86
|
+
r"(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_]\w*)\s*=>)"
|
|
87
|
+
, re.MULTILINE,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
_JS_CLASS_RE = re.compile(
|
|
91
|
+
r"(?:^|\n)(?:export\s+(?:default\s+)?)?class\s+(\w+)",
|
|
92
|
+
re.MULTILINE,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Tag detection patterns
|
|
96
|
+
_IMPORT_PY_RE = re.compile(r"^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))", re.MULTILINE)
|
|
97
|
+
_IMPORT_JS_RE = re.compile(r"""(?:import\s+.*?from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))""", re.MULTILINE)
|
|
98
|
+
|
|
99
|
+
# Framework/known library keywords
|
|
100
|
+
_FRAMEWORK_KEYWORDS: dict[str, str] = {
|
|
101
|
+
"fastapi": "fastapi", "flask": "flask", "django": "django",
|
|
102
|
+
"express": "express", "react": "react", "nextjs": "nextjs",
|
|
103
|
+
"next": "nextjs", "vue": "vue", "angular": "angular",
|
|
104
|
+
"pytorch": "pytorch", "torch": "pytorch", "tensorflow": "tensorflow",
|
|
105
|
+
"pandas": "pandas", "numpy": "numpy", "sqlalchemy": "sqlalchemy",
|
|
106
|
+
"prisma": "prisma", "sequelize": "sequelize", "mongoose": "mongoose",
|
|
107
|
+
"jwt": "jwt", "oauth": "oauth", "bcrypt": "bcrypt",
|
|
108
|
+
"celery": "celery", "redis": "redis", "rabbitmq": "rabbitmq",
|
|
109
|
+
"graphql": "graphql", "grpc": "grpc", "websocket": "websocket",
|
|
110
|
+
"docker": "docker", "kubernetes": "kubernetes", "terraform": "terraform",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# FileParser
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
class FileParser:
|
|
118
|
+
"""Semantic/AST Chunking engine with Prompt Armor.
|
|
119
|
+
|
|
120
|
+
Strategy by type:
|
|
121
|
+
- Python (.py): ast stdlib — functions, classes, module.
|
|
122
|
+
- JS/TS (.js/.ts/.tsx/.jsx): Regex — functions, classes, module.
|
|
123
|
+
- Markdown (.md): Headers (#) — hierarchical sections.
|
|
124
|
+
- Config (.json/.yaml/.toml): Single block or split by size.
|
|
125
|
+
- Others: Fixed size chunking (fallback RAW).
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
# Extensions using each parser
|
|
129
|
+
_PYTHON_EXTS = {".py"}
|
|
130
|
+
_JS_EXTS = {".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs"}
|
|
131
|
+
_MD_EXTS = {".md", ".mdx", ".rst", ".adoc", ".txt"}
|
|
132
|
+
_CONFIG_EXTS = {".json", ".yaml", ".yml", ".toml", ".env", ".ini", ".cfg", ".xml"}
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
max_chunk_tokens: int = MAX_CHUNK_TOKENS,
|
|
137
|
+
enable_prompt_armor: bool = True,
|
|
138
|
+
) -> None:
|
|
139
|
+
self._max_tokens = max_chunk_tokens
|
|
140
|
+
self._armor = enable_prompt_armor
|
|
141
|
+
|
|
142
|
+
def parse(self, crawl_result: CrawlResult) -> list[ParsedChunk]:
|
|
143
|
+
"""Processes a file and returns its semantic chunks."""
|
|
144
|
+
filepath = crawl_result.absolute_path
|
|
145
|
+
file_hash = crawl_result.file_hash
|
|
146
|
+
category = crawl_result.category
|
|
147
|
+
ext = crawl_result.extension.lower()
|
|
148
|
+
|
|
149
|
+
# Reading the content
|
|
150
|
+
try:
|
|
151
|
+
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
|
152
|
+
source = f.read()
|
|
153
|
+
except (OSError, IOError) as e:
|
|
154
|
+
logger.warning("Error reading %s: %s", filepath, e)
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
if not source.strip():
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
# Dispatch by extension
|
|
161
|
+
if ext in self._PYTHON_EXTS or ext in self._JS_EXTS or ext in {".go", ".rs"}:
|
|
162
|
+
chunks = self._parse_with_tree_sitter(source, crawl_result.relative_path, file_hash, ext)
|
|
163
|
+
elif ext in self._MD_EXTS:
|
|
164
|
+
chunks = self._parse_markdown(source, crawl_result.relative_path, file_hash)
|
|
165
|
+
elif ext in self._CONFIG_EXTS:
|
|
166
|
+
chunks = self._parse_config(source, crawl_result.relative_path, file_hash)
|
|
167
|
+
else:
|
|
168
|
+
chunks = self._parse_raw(source, crawl_result.relative_path, file_hash, category)
|
|
169
|
+
|
|
170
|
+
# Post-processing: assigns category, tags and armor
|
|
171
|
+
for chunk in chunks:
|
|
172
|
+
chunk.category = category
|
|
173
|
+
if not chunk.detected_tags:
|
|
174
|
+
chunk.detected_tags = self._detect_tags(chunk.content, category)
|
|
175
|
+
|
|
176
|
+
return chunks
|
|
177
|
+
|
|
178
|
+
def _get_tree_sitter_parser(self, ext: str) -> Optional[Any]:
|
|
179
|
+
try:
|
|
180
|
+
import tree_sitter
|
|
181
|
+
import ctypes
|
|
182
|
+
|
|
183
|
+
# Setup ctypes helper to get pointer from capsule if needed
|
|
184
|
+
try:
|
|
185
|
+
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
|
|
186
|
+
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = [ctypes.py_object, ctypes.c_char_p]
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
def _get_lang_ptr(capsule_or_ptr):
|
|
191
|
+
if isinstance(capsule_or_ptr, int):
|
|
192
|
+
return capsule_or_ptr
|
|
193
|
+
# If it's a PyCapsule, extract using ctypes
|
|
194
|
+
try:
|
|
195
|
+
return ctypes.pythonapi.PyCapsule_GetPointer(capsule_or_ptr, b"tree_sitter.Language")
|
|
196
|
+
except Exception:
|
|
197
|
+
try:
|
|
198
|
+
return ctypes.pythonapi.PyCapsule_GetPointer(capsule_or_ptr, None)
|
|
199
|
+
except Exception:
|
|
200
|
+
return capsule_or_ptr
|
|
201
|
+
|
|
202
|
+
def _create_language(raw_lang, name):
|
|
203
|
+
if isinstance(raw_lang, tree_sitter.Language):
|
|
204
|
+
return raw_lang
|
|
205
|
+
try:
|
|
206
|
+
# Tries to instantiate passing the object directly (modern API)
|
|
207
|
+
return tree_sitter.Language(raw_lang)
|
|
208
|
+
except TypeError:
|
|
209
|
+
ptr = _get_lang_ptr(raw_lang)
|
|
210
|
+
try:
|
|
211
|
+
# Fallback passing the pointer
|
|
212
|
+
return tree_sitter.Language(ptr)
|
|
213
|
+
except TypeError:
|
|
214
|
+
# Classic fallback (<=0.21)
|
|
215
|
+
return tree_sitter.Language(ptr, name)
|
|
216
|
+
|
|
217
|
+
if ext == ".py":
|
|
218
|
+
import tree_sitter_python
|
|
219
|
+
lang = _create_language(tree_sitter_python.language(), "python")
|
|
220
|
+
elif ext in (".js", ".jsx", ".mjs", ".cjs"):
|
|
221
|
+
import tree_sitter_javascript
|
|
222
|
+
lang = _create_language(tree_sitter_javascript.language(), "javascript")
|
|
223
|
+
elif ext == ".ts":
|
|
224
|
+
import tree_sitter_typescript
|
|
225
|
+
lang = _create_language(tree_sitter_typescript.language_typescript(), "typescript")
|
|
226
|
+
elif ext == ".tsx":
|
|
227
|
+
import tree_sitter_typescript
|
|
228
|
+
lang = _create_language(tree_sitter_typescript.language_tsx(), "tsx")
|
|
229
|
+
elif ext == ".go":
|
|
230
|
+
import tree_sitter_go
|
|
231
|
+
lang = _create_language(tree_sitter_go.language(), "go")
|
|
232
|
+
elif ext == ".rs":
|
|
233
|
+
import tree_sitter_rust
|
|
234
|
+
lang = _create_language(tree_sitter_rust.language(), "rust")
|
|
235
|
+
else:
|
|
236
|
+
return None
|
|
237
|
+
parser = tree_sitter.Parser()
|
|
238
|
+
try:
|
|
239
|
+
parser.set_language(lang)
|
|
240
|
+
except AttributeError:
|
|
241
|
+
parser.language = lang
|
|
242
|
+
return parser
|
|
243
|
+
except Exception as e:
|
|
244
|
+
logger.warning("Error loading tree-sitter parser for %s: %s", ext, e)
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
def _get_node_definition_type(self, node: Any, ext: str) -> Optional[tuple[str, str]]:
|
|
248
|
+
ntype = node.type
|
|
249
|
+
|
|
250
|
+
# Python
|
|
251
|
+
if ext == ".py":
|
|
252
|
+
if ntype == "class_definition":
|
|
253
|
+
name_node = node.child_by_field_name("name")
|
|
254
|
+
if name_node:
|
|
255
|
+
return "class", name_node.text.decode('utf-8', errors='replace')
|
|
256
|
+
elif ntype == "function_definition":
|
|
257
|
+
name_node = node.child_by_field_name("name")
|
|
258
|
+
if name_node:
|
|
259
|
+
is_method = False
|
|
260
|
+
parent = node.parent
|
|
261
|
+
while parent:
|
|
262
|
+
if parent.type == "class_definition":
|
|
263
|
+
is_method = True
|
|
264
|
+
break
|
|
265
|
+
parent = parent.parent
|
|
266
|
+
return "method" if is_method else "function", name_node.text.decode('utf-8', errors='replace')
|
|
267
|
+
|
|
268
|
+
# JS/TS
|
|
269
|
+
elif ext in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"):
|
|
270
|
+
if ntype in ("class_declaration", "class"):
|
|
271
|
+
name_node = node.child_by_field_name("name")
|
|
272
|
+
if name_node:
|
|
273
|
+
return "class", name_node.text.decode('utf-8', errors='replace')
|
|
274
|
+
elif ntype in ("function_declaration", "generator_function_declaration"):
|
|
275
|
+
name_node = node.child_by_field_name("name")
|
|
276
|
+
if name_node:
|
|
277
|
+
return "function", name_node.text.decode('utf-8', errors='replace')
|
|
278
|
+
elif ntype == "method_definition":
|
|
279
|
+
name_node = node.child_by_field_name("name")
|
|
280
|
+
if name_node:
|
|
281
|
+
return "method", name_node.text.decode('utf-8', errors='replace')
|
|
282
|
+
elif ntype == "variable_declarator":
|
|
283
|
+
init_node = node.child_by_field_name("value") or node.child_by_field_name("init")
|
|
284
|
+
if init_node and init_node.type == "arrow_function":
|
|
285
|
+
name_node = node.child_by_field_name("name") or node.child(0)
|
|
286
|
+
if name_node:
|
|
287
|
+
return "function", name_node.text.decode('utf-8', errors='replace')
|
|
288
|
+
|
|
289
|
+
# Go
|
|
290
|
+
elif ext == ".go":
|
|
291
|
+
if ntype == "function_declaration":
|
|
292
|
+
name_node = node.child_by_field_name("name")
|
|
293
|
+
if name_node:
|
|
294
|
+
return "function", name_node.text.decode('utf-8', errors='replace')
|
|
295
|
+
elif ntype == "method_declaration":
|
|
296
|
+
name_node = node.child_by_field_name("name")
|
|
297
|
+
if name_node:
|
|
298
|
+
return "method", name_node.text.decode('utf-8', errors='replace')
|
|
299
|
+
elif ntype == "type_spec":
|
|
300
|
+
type_node = node.child_by_field_name("type")
|
|
301
|
+
if type_node and type_node.type == "struct_type":
|
|
302
|
+
name_node = node.child_by_field_name("name")
|
|
303
|
+
if name_node:
|
|
304
|
+
return "class", name_node.text.decode('utf-8', errors='replace')
|
|
305
|
+
|
|
306
|
+
# Rust
|
|
307
|
+
elif ext == ".rs":
|
|
308
|
+
if ntype == "function_item":
|
|
309
|
+
name_node = node.child_by_field_name("name")
|
|
310
|
+
if name_node:
|
|
311
|
+
is_method = False
|
|
312
|
+
parent = node.parent
|
|
313
|
+
while parent:
|
|
314
|
+
if parent.type == "impl_item":
|
|
315
|
+
is_method = True
|
|
316
|
+
break
|
|
317
|
+
parent = parent.parent
|
|
318
|
+
return "method" if is_method else "function", name_node.text.decode('utf-8', errors='replace')
|
|
319
|
+
elif ntype in ("struct_item", "enum_item", "union_item", "trait_item"):
|
|
320
|
+
name_node = node.child_by_field_name("name")
|
|
321
|
+
if name_node:
|
|
322
|
+
return "class", name_node.text.decode('utf-8', errors='replace')
|
|
323
|
+
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
def _is_call_node(self, node: Any, ext: str) -> bool:
|
|
327
|
+
ntype = node.type
|
|
328
|
+
if ext == ".py" and ntype == "call":
|
|
329
|
+
return True
|
|
330
|
+
elif ext in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs") and ntype == "call_expression":
|
|
331
|
+
return True
|
|
332
|
+
elif ext == ".go" and ntype == "call_expression":
|
|
333
|
+
return True
|
|
334
|
+
elif ext == ".rs" and ntype in ("call_expression", "method_call_expression"):
|
|
335
|
+
return True
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
def _get_callable_name(self, node: Any) -> str:
|
|
339
|
+
if node.type == "identifier" or node.type == "variable_declarator":
|
|
340
|
+
return node.text.decode('utf-8', errors='replace')
|
|
341
|
+
|
|
342
|
+
if node.type == "attribute":
|
|
343
|
+
attr_node = node.child_by_field_name("attribute")
|
|
344
|
+
if attr_node:
|
|
345
|
+
return attr_node.text.decode('utf-8', errors='replace')
|
|
346
|
+
|
|
347
|
+
if node.type == "member_expression":
|
|
348
|
+
prop_node = node.child_by_field_name("property")
|
|
349
|
+
if prop_node:
|
|
350
|
+
return prop_node.text.decode('utf-8', errors='replace')
|
|
351
|
+
|
|
352
|
+
if node.type == "selector_expression":
|
|
353
|
+
field_node = node.child_by_field_name("field")
|
|
354
|
+
if field_node:
|
|
355
|
+
return field_node.text.decode('utf-8', errors='replace')
|
|
356
|
+
|
|
357
|
+
if node.children:
|
|
358
|
+
for child in reversed(node.children):
|
|
359
|
+
if "identifier" in child.type:
|
|
360
|
+
return child.text.decode('utf-8', errors='replace')
|
|
361
|
+
|
|
362
|
+
return node.text.decode('utf-8', errors='replace')
|
|
363
|
+
|
|
364
|
+
def _get_call_name(self, node: Any, ext: str) -> Optional[str]:
|
|
365
|
+
if ext == ".rs" and node.type == "method_call_expression":
|
|
366
|
+
method_node = node.child_by_field_name("method")
|
|
367
|
+
if method_node:
|
|
368
|
+
return method_node.text.decode('utf-8', errors='replace')
|
|
369
|
+
|
|
370
|
+
func_node = node.child_by_field_name("function")
|
|
371
|
+
if func_node:
|
|
372
|
+
return self._get_callable_name(func_node)
|
|
373
|
+
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
def _parse_with_tree_sitter(self, source: str, rel_path: str, file_hash: str, ext: str) -> list[ParsedChunk]:
|
|
377
|
+
parser = self._get_tree_sitter_parser(ext)
|
|
378
|
+
if not parser:
|
|
379
|
+
if ext == ".py":
|
|
380
|
+
return self._parse_python(source, rel_path, file_hash)
|
|
381
|
+
elif ext in self._JS_EXTS:
|
|
382
|
+
return self._parse_javascript(source, rel_path, file_hash)
|
|
383
|
+
return self._parse_raw(source, rel_path, file_hash, FileCategory.CODE)
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
source_bytes = source.encode('utf-8')
|
|
387
|
+
tree = parser.parse(source_bytes)
|
|
388
|
+
root = tree.root_node
|
|
389
|
+
|
|
390
|
+
if root.has_error:
|
|
391
|
+
logger.warning("Syntax error (AST has_error) in %s (RAW fallback)", rel_path)
|
|
392
|
+
return self._parse_raw(source, rel_path, file_hash, FileCategory.CODE)
|
|
393
|
+
|
|
394
|
+
definitions = []
|
|
395
|
+
stack = [(root, False)]
|
|
396
|
+
defn_stack = []
|
|
397
|
+
|
|
398
|
+
module_defn = {
|
|
399
|
+
"type": "module",
|
|
400
|
+
"name": "<module>",
|
|
401
|
+
"start_line": 1,
|
|
402
|
+
"end_line": source.count("\n") + 1,
|
|
403
|
+
"code": source,
|
|
404
|
+
"calls": set()
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
while stack:
|
|
408
|
+
node, visited = stack.pop()
|
|
409
|
+
if visited:
|
|
410
|
+
def_info = self._get_node_definition_type(node, ext)
|
|
411
|
+
if def_info:
|
|
412
|
+
if defn_stack:
|
|
413
|
+
popped = defn_stack.pop()
|
|
414
|
+
parent_class = None
|
|
415
|
+
for parent_defn in reversed(defn_stack):
|
|
416
|
+
if parent_defn["type"] == "class":
|
|
417
|
+
parent_class = parent_defn["name"]
|
|
418
|
+
break
|
|
419
|
+
if parent_class and popped["type"] == "method":
|
|
420
|
+
popped["name"] = f"{parent_class}.{popped['name']}"
|
|
421
|
+
definitions.append(popped)
|
|
422
|
+
else:
|
|
423
|
+
stack.append((node, True))
|
|
424
|
+
|
|
425
|
+
def_info = self._get_node_definition_type(node, ext)
|
|
426
|
+
if def_info:
|
|
427
|
+
dtype, dname = def_info
|
|
428
|
+
defn = {
|
|
429
|
+
"type": dtype,
|
|
430
|
+
"name": dname,
|
|
431
|
+
"start_line": node.start_point[0] + 1,
|
|
432
|
+
"end_line": node.end_point[0] + 1,
|
|
433
|
+
"code": node.text.decode('utf-8', errors='replace'),
|
|
434
|
+
"calls": set()
|
|
435
|
+
}
|
|
436
|
+
defn_stack.append(defn)
|
|
437
|
+
elif self._is_call_node(node, ext):
|
|
438
|
+
call_name = self._get_call_name(node, ext)
|
|
439
|
+
if call_name:
|
|
440
|
+
if defn_stack:
|
|
441
|
+
defn_stack[-1]["calls"].add(call_name)
|
|
442
|
+
else:
|
|
443
|
+
module_defn["calls"].add(call_name)
|
|
444
|
+
|
|
445
|
+
for child in reversed(node.children):
|
|
446
|
+
stack.append((child, False))
|
|
447
|
+
|
|
448
|
+
definitions.append(module_defn)
|
|
449
|
+
|
|
450
|
+
chunks = []
|
|
451
|
+
for i, defn in enumerate(definitions):
|
|
452
|
+
content = defn["code"]
|
|
453
|
+
armored = self._apply_prompt_armor(content)
|
|
454
|
+
tokens = self._estimate_tokens(content)
|
|
455
|
+
|
|
456
|
+
if defn["type"] == "class":
|
|
457
|
+
ctype = ChunkType.CLASS
|
|
458
|
+
elif defn["type"] == "method":
|
|
459
|
+
ctype = ChunkType.METHOD
|
|
460
|
+
elif defn["type"] == "function":
|
|
461
|
+
ctype = ChunkType.FUNCTION
|
|
462
|
+
elif defn["type"] == "module":
|
|
463
|
+
ctype = ChunkType.MODULE
|
|
464
|
+
else:
|
|
465
|
+
ctype = ChunkType.RAW
|
|
466
|
+
|
|
467
|
+
chunk = ParsedChunk(
|
|
468
|
+
content=content,
|
|
469
|
+
armored_content=armored,
|
|
470
|
+
chunk_type=ctype,
|
|
471
|
+
chunk_index=i,
|
|
472
|
+
source_file=rel_path,
|
|
473
|
+
file_hash=file_hash,
|
|
474
|
+
category=FileCategory.CODE,
|
|
475
|
+
start_line=defn["start_line"],
|
|
476
|
+
end_line=defn["end_line"],
|
|
477
|
+
symbol_name=defn["name"],
|
|
478
|
+
detected_tags=self._detect_tags(content, FileCategory.CODE),
|
|
479
|
+
estimated_tokens=tokens
|
|
480
|
+
)
|
|
481
|
+
chunk.calls = list(defn["calls"])
|
|
482
|
+
chunks.append(chunk)
|
|
483
|
+
|
|
484
|
+
except Exception as e:
|
|
485
|
+
logger.warning("Error processing AST with tree-sitter for %s (native/RAW fallback): %s", rel_path, e)
|
|
486
|
+
if ext == ".py":
|
|
487
|
+
return self._parse_python(source, rel_path, file_hash)
|
|
488
|
+
elif ext in self._JS_EXTS:
|
|
489
|
+
return self._parse_javascript(source, rel_path, file_hash)
|
|
490
|
+
return self._parse_raw(source, rel_path, file_hash, FileCategory.CODE)
|
|
491
|
+
|
|
492
|
+
return chunks
|
|
493
|
+
|
|
494
|
+
def parse_batch(self, crawl_results: list[CrawlResult]) -> list[ParsedChunk]:
|
|
495
|
+
"""Processes multiple files with Semantic Fallback."""
|
|
496
|
+
all_chunks: list[ParsedChunk] = []
|
|
497
|
+
for cr in crawl_results:
|
|
498
|
+
try:
|
|
499
|
+
chunks = self.parse(cr)
|
|
500
|
+
all_chunks.extend(chunks)
|
|
501
|
+
except Exception as e:
|
|
502
|
+
logger.error("Fallback: failure parsing %s: %s", cr.relative_path, e)
|
|
503
|
+
return all_chunks
|
|
504
|
+
|
|
505
|
+
# ===================================================================
|
|
506
|
+
# PYTHON — AST Chunking
|
|
507
|
+
# ===================================================================
|
|
508
|
+
|
|
509
|
+
def _parse_python(self, source: str, rel_path: str, file_hash: str) -> list[ParsedChunk]:
|
|
510
|
+
lines = source.splitlines(keepends=True)
|
|
511
|
+
chunks: list[ParsedChunk] = []
|
|
512
|
+
chunk_idx = 0
|
|
513
|
+
|
|
514
|
+
try:
|
|
515
|
+
tree = ast.parse(source, filename=rel_path)
|
|
516
|
+
except SyntaxError as e:
|
|
517
|
+
logger.warning("SyntaxError in %s (RAW fallback): %s", rel_path, e)
|
|
518
|
+
return self._parse_raw(source, rel_path, file_hash, FileCategory.CODE)
|
|
519
|
+
|
|
520
|
+
# Collect nodes (functions, classes, methods)
|
|
521
|
+
nodes_to_process: list[tuple[ChunkType, str, int, int, ast.AST]] = []
|
|
522
|
+
|
|
523
|
+
# Traverse AST child nodes
|
|
524
|
+
for node in ast.iter_child_nodes(tree):
|
|
525
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
526
|
+
start = node.lineno
|
|
527
|
+
end = node.end_lineno if hasattr(node, "end_lineno") and node.end_lineno else start
|
|
528
|
+
nodes_to_process.append((ChunkType.FUNCTION, node.name, start, end, node))
|
|
529
|
+
elif isinstance(node, ast.ClassDef):
|
|
530
|
+
start = node.lineno
|
|
531
|
+
end = node.end_lineno if hasattr(node, "end_lineno") and node.end_lineno else start
|
|
532
|
+
nodes_to_process.append((ChunkType.CLASS, node.name, start, end, node))
|
|
533
|
+
# Maps internal methods of the class
|
|
534
|
+
for subnode in ast.iter_child_nodes(node):
|
|
535
|
+
if isinstance(subnode, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
536
|
+
sub_start = subnode.lineno
|
|
537
|
+
sub_end = subnode.end_lineno if hasattr(subnode, "end_lineno") and subnode.end_lineno else sub_start
|
|
538
|
+
nodes_to_process.append((ChunkType.METHOD, f"{node.name}.{subnode.name}", sub_start, sub_end, subnode))
|
|
539
|
+
|
|
540
|
+
# Adds the module at the end to match the behavior of tree-sitter
|
|
541
|
+
nodes_to_process.append((ChunkType.MODULE, "<module>", 1, len(lines) if lines else 1, tree))
|
|
542
|
+
|
|
543
|
+
# Maps calls using definition scope
|
|
544
|
+
calls_map = {tree: set()}
|
|
545
|
+
|
|
546
|
+
def traverse_ast(n, current_defn):
|
|
547
|
+
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
548
|
+
calls_map[n] = set()
|
|
549
|
+
for child in ast.iter_child_nodes(n):
|
|
550
|
+
traverse_ast(child, n)
|
|
551
|
+
else:
|
|
552
|
+
if isinstance(n, ast.Call):
|
|
553
|
+
name = None
|
|
554
|
+
if isinstance(n.func, ast.Name):
|
|
555
|
+
name = n.func.id
|
|
556
|
+
elif isinstance(n.func, ast.Attribute):
|
|
557
|
+
name = n.func.attr
|
|
558
|
+
if name:
|
|
559
|
+
calls_map[current_defn].add(name)
|
|
560
|
+
for child in ast.iter_child_nodes(n):
|
|
561
|
+
traverse_ast(child, current_defn)
|
|
562
|
+
|
|
563
|
+
traverse_ast(tree, tree)
|
|
564
|
+
|
|
565
|
+
for ctype, name, start, end, ast_node in nodes_to_process:
|
|
566
|
+
if ctype == ChunkType.MODULE:
|
|
567
|
+
block_text = source.rstrip()
|
|
568
|
+
else:
|
|
569
|
+
block_text = "".join(lines[start - 1:end]).rstrip()
|
|
570
|
+
|
|
571
|
+
armored = self._apply_prompt_armor(block_text)
|
|
572
|
+
tokens = self._estimate_tokens(block_text)
|
|
573
|
+
|
|
574
|
+
# Obtém chamadas específicas deste escopo
|
|
575
|
+
calls = list(calls_map.get(ast_node, set()))
|
|
576
|
+
|
|
577
|
+
# If it exceeds max_tokens, subdivides (except module to simplify)
|
|
578
|
+
if tokens > self._max_tokens and ctype != ChunkType.MODULE:
|
|
579
|
+
sub_chunks = self._split_oversized(block_text, rel_path, file_hash, ctype, name, start, chunk_idx)
|
|
580
|
+
# Copia chamadas para sub-chunks
|
|
581
|
+
for sc in sub_chunks:
|
|
582
|
+
sc.calls = calls
|
|
583
|
+
chunks.extend(sub_chunks)
|
|
584
|
+
chunk_idx += len(sub_chunks)
|
|
585
|
+
else:
|
|
586
|
+
chunk = ParsedChunk(
|
|
587
|
+
content=block_text,
|
|
588
|
+
armored_content=armored,
|
|
589
|
+
chunk_type=ctype,
|
|
590
|
+
chunk_index=chunk_idx,
|
|
591
|
+
source_file=rel_path,
|
|
592
|
+
file_hash=file_hash,
|
|
593
|
+
category=FileCategory.CODE,
|
|
594
|
+
start_line=start,
|
|
595
|
+
end_line=end,
|
|
596
|
+
symbol_name=name,
|
|
597
|
+
detected_tags=self._detect_tags(block_text, FileCategory.CODE),
|
|
598
|
+
estimated_tokens=tokens,
|
|
599
|
+
)
|
|
600
|
+
chunk.calls = calls
|
|
601
|
+
chunks.append(chunk)
|
|
602
|
+
chunk_idx += 1
|
|
603
|
+
|
|
604
|
+
return chunks
|
|
605
|
+
|
|
606
|
+
# ===================================================================
|
|
607
|
+
# JS/TS — Regex Chunking
|
|
608
|
+
# ===================================================================
|
|
609
|
+
|
|
610
|
+
def _parse_javascript(self, source: str, rel_path: str, file_hash: str) -> list[ParsedChunk]:
|
|
611
|
+
lines = source.splitlines(keepends=True)
|
|
612
|
+
total_lines = len(lines)
|
|
613
|
+
chunks: list[ParsedChunk] = []
|
|
614
|
+
chunk_idx = 0
|
|
615
|
+
|
|
616
|
+
# Collects positions of functions and classes
|
|
617
|
+
boundaries: list[tuple[int, str, ChunkType]] = []
|
|
618
|
+
|
|
619
|
+
for m in _JS_CLASS_RE.finditer(source):
|
|
620
|
+
line_no = source[:m.start()].count("\n") + 1
|
|
621
|
+
name = m.group(1) or "AnonymousClass"
|
|
622
|
+
boundaries.append((line_no, name, ChunkType.CLASS))
|
|
623
|
+
|
|
624
|
+
for m in _JS_FUNCTION_RE.finditer(source):
|
|
625
|
+
line_no = source[:m.start()].count("\n") + 1
|
|
626
|
+
name = m.group(1) or m.group(2) or "anonymous"
|
|
627
|
+
boundaries.append((line_no, name, ChunkType.FUNCTION))
|
|
628
|
+
|
|
629
|
+
boundaries.sort(key=lambda b: b[0])
|
|
630
|
+
|
|
631
|
+
if not boundaries:
|
|
632
|
+
return self._parse_raw(source, rel_path, file_hash, FileCategory.CODE)
|
|
633
|
+
|
|
634
|
+
# MODULE block: imports at the top (before the first boundary)
|
|
635
|
+
first_boundary_line = boundaries[0][0]
|
|
636
|
+
if first_boundary_line > 1:
|
|
637
|
+
module_text = "".join(lines[:first_boundary_line - 1]).strip()
|
|
638
|
+
if module_text:
|
|
639
|
+
armored = self._apply_prompt_armor(module_text)
|
|
640
|
+
chunks.append(ParsedChunk(
|
|
641
|
+
content=module_text,
|
|
642
|
+
armored_content=armored,
|
|
643
|
+
chunk_type=ChunkType.MODULE,
|
|
644
|
+
chunk_index=chunk_idx,
|
|
645
|
+
source_file=rel_path,
|
|
646
|
+
file_hash=file_hash,
|
|
647
|
+
category=FileCategory.CODE,
|
|
648
|
+
start_line=1,
|
|
649
|
+
end_line=first_boundary_line - 1,
|
|
650
|
+
symbol_name="<module>",
|
|
651
|
+
detected_tags=self._detect_tags(module_text, FileCategory.CODE),
|
|
652
|
+
estimated_tokens=self._estimate_tokens(module_text),
|
|
653
|
+
))
|
|
654
|
+
chunk_idx += 1
|
|
655
|
+
|
|
656
|
+
# Chunks for each detected block
|
|
657
|
+
for i, (line_no, name, ctype) in enumerate(boundaries):
|
|
658
|
+
# Determines end of block: next boundary or EOF
|
|
659
|
+
if i + 1 < len(boundaries):
|
|
660
|
+
end_line = boundaries[i + 1][0] - 1
|
|
661
|
+
else:
|
|
662
|
+
end_line = total_lines
|
|
663
|
+
|
|
664
|
+
# Finds the actual end of the block via brace count
|
|
665
|
+
block_lines = lines[line_no - 1:end_line]
|
|
666
|
+
actual_end = self._find_block_end_braces(block_lines)
|
|
667
|
+
if actual_end > 0:
|
|
668
|
+
end_line = line_no - 1 + actual_end
|
|
669
|
+
|
|
670
|
+
block_text = "".join(lines[line_no - 1:end_line]).rstrip()
|
|
671
|
+
if not block_text.strip():
|
|
672
|
+
continue
|
|
673
|
+
|
|
674
|
+
armored = self._apply_prompt_armor(block_text)
|
|
675
|
+
tokens = self._estimate_tokens(block_text)
|
|
676
|
+
|
|
677
|
+
if tokens > self._max_tokens:
|
|
678
|
+
sub = self._split_oversized(block_text, rel_path, file_hash, ctype, name, line_no, chunk_idx)
|
|
679
|
+
chunks.extend(sub)
|
|
680
|
+
chunk_idx += len(sub)
|
|
681
|
+
else:
|
|
682
|
+
chunks.append(ParsedChunk(
|
|
683
|
+
content=block_text,
|
|
684
|
+
armored_content=armored,
|
|
685
|
+
chunk_type=ctype,
|
|
686
|
+
chunk_index=chunk_idx,
|
|
687
|
+
source_file=rel_path,
|
|
688
|
+
file_hash=file_hash,
|
|
689
|
+
category=FileCategory.CODE,
|
|
690
|
+
start_line=line_no,
|
|
691
|
+
end_line=end_line,
|
|
692
|
+
symbol_name=name,
|
|
693
|
+
detected_tags=self._detect_tags(block_text, FileCategory.CODE),
|
|
694
|
+
estimated_tokens=tokens,
|
|
695
|
+
))
|
|
696
|
+
chunk_idx += 1
|
|
697
|
+
|
|
698
|
+
return chunks if chunks else self._parse_raw(source, rel_path, file_hash, FileCategory.CODE)
|
|
699
|
+
|
|
700
|
+
@staticmethod
|
|
701
|
+
def _find_block_end_braces(block_lines: list[str]) -> int:
|
|
702
|
+
"""Finds the line where braces { } balance (1-indexed relative)."""
|
|
703
|
+
depth = 0
|
|
704
|
+
started = False
|
|
705
|
+
for i, line in enumerate(block_lines, 1):
|
|
706
|
+
for ch in line:
|
|
707
|
+
if ch == "{":
|
|
708
|
+
depth += 1
|
|
709
|
+
started = True
|
|
710
|
+
elif ch == "}":
|
|
711
|
+
depth -= 1
|
|
712
|
+
if started and depth <= 0:
|
|
713
|
+
return i
|
|
714
|
+
return 0
|
|
715
|
+
|
|
716
|
+
# ===================================================================
|
|
717
|
+
# MARKDOWN — Header Chunking
|
|
718
|
+
# ===================================================================
|
|
719
|
+
|
|
720
|
+
def _parse_markdown(self, source: str, rel_path: str, file_hash: str) -> list[ParsedChunk]:
|
|
721
|
+
lines = source.splitlines(keepends=True)
|
|
722
|
+
chunks: list[ParsedChunk] = []
|
|
723
|
+
chunk_idx = 0
|
|
724
|
+
|
|
725
|
+
# Finds headers
|
|
726
|
+
header_positions: list[tuple[int, str]] = [] # (line_no, header_text)
|
|
727
|
+
for i, line in enumerate(lines, 1):
|
|
728
|
+
stripped = line.strip()
|
|
729
|
+
if stripped.startswith("#") and " " in stripped:
|
|
730
|
+
header_positions.append((i, stripped))
|
|
731
|
+
|
|
732
|
+
if not header_positions:
|
|
733
|
+
# No headers — treats as a single block
|
|
734
|
+
armored = self._apply_prompt_armor(source)
|
|
735
|
+
return [ParsedChunk(
|
|
736
|
+
content=source.strip(),
|
|
737
|
+
armored_content=armored,
|
|
738
|
+
chunk_type=ChunkType.SECTION,
|
|
739
|
+
chunk_index=0,
|
|
740
|
+
source_file=rel_path,
|
|
741
|
+
file_hash=file_hash,
|
|
742
|
+
category=FileCategory.DOC,
|
|
743
|
+
start_line=1,
|
|
744
|
+
end_line=len(lines),
|
|
745
|
+
symbol_name="<document>",
|
|
746
|
+
detected_tags=self._detect_tags(source, FileCategory.DOC),
|
|
747
|
+
estimated_tokens=self._estimate_tokens(source),
|
|
748
|
+
)]
|
|
749
|
+
|
|
750
|
+
# Content before the first header
|
|
751
|
+
if header_positions[0][0] > 1:
|
|
752
|
+
pre_text = "".join(lines[:header_positions[0][0] - 1]).strip()
|
|
753
|
+
if pre_text:
|
|
754
|
+
armored = self._apply_prompt_armor(pre_text)
|
|
755
|
+
chunks.append(ParsedChunk(
|
|
756
|
+
content=pre_text,
|
|
757
|
+
armored_content=armored,
|
|
758
|
+
chunk_type=ChunkType.SECTION,
|
|
759
|
+
chunk_index=chunk_idx,
|
|
760
|
+
source_file=rel_path,
|
|
761
|
+
file_hash=file_hash,
|
|
762
|
+
category=FileCategory.DOC,
|
|
763
|
+
start_line=1,
|
|
764
|
+
end_line=header_positions[0][0] - 1,
|
|
765
|
+
symbol_name="<preamble>",
|
|
766
|
+
detected_tags=[],
|
|
767
|
+
estimated_tokens=self._estimate_tokens(pre_text),
|
|
768
|
+
))
|
|
769
|
+
chunk_idx += 1
|
|
770
|
+
|
|
771
|
+
# Each section
|
|
772
|
+
for i, (line_no, header) in enumerate(header_positions):
|
|
773
|
+
if i + 1 < len(header_positions):
|
|
774
|
+
end_line = header_positions[i + 1][0] - 1
|
|
775
|
+
else:
|
|
776
|
+
end_line = len(lines)
|
|
777
|
+
|
|
778
|
+
section_text = "".join(lines[line_no - 1:end_line]).rstrip()
|
|
779
|
+
if not section_text.strip():
|
|
780
|
+
continue
|
|
781
|
+
|
|
782
|
+
# Extracts section name (removes #)
|
|
783
|
+
section_name = header.lstrip("#").strip()
|
|
784
|
+
|
|
785
|
+
armored = self._apply_prompt_armor(section_text)
|
|
786
|
+
tokens = self._estimate_tokens(section_text)
|
|
787
|
+
|
|
788
|
+
if tokens > self._max_tokens:
|
|
789
|
+
sub = self._split_oversized(section_text, rel_path, file_hash, ChunkType.SECTION, section_name, line_no, chunk_idx)
|
|
790
|
+
chunks.extend(sub)
|
|
791
|
+
chunk_idx += len(sub)
|
|
792
|
+
else:
|
|
793
|
+
chunks.append(ParsedChunk(
|
|
794
|
+
content=section_text,
|
|
795
|
+
armored_content=armored,
|
|
796
|
+
chunk_type=ChunkType.SECTION,
|
|
797
|
+
chunk_index=chunk_idx,
|
|
798
|
+
source_file=rel_path,
|
|
799
|
+
file_hash=file_hash,
|
|
800
|
+
category=FileCategory.DOC,
|
|
801
|
+
start_line=line_no,
|
|
802
|
+
end_line=end_line,
|
|
803
|
+
symbol_name=section_name,
|
|
804
|
+
detected_tags=self._detect_tags(section_text, FileCategory.DOC),
|
|
805
|
+
estimated_tokens=tokens,
|
|
806
|
+
))
|
|
807
|
+
chunk_idx += 1
|
|
808
|
+
|
|
809
|
+
return chunks
|
|
810
|
+
|
|
811
|
+
# ===================================================================
|
|
812
|
+
# CONFIG — Single block or split
|
|
813
|
+
# ===================================================================
|
|
814
|
+
|
|
815
|
+
def _parse_config(self, source: str, rel_path: str, file_hash: str) -> list[ParsedChunk]:
|
|
816
|
+
tokens = self._estimate_tokens(source)
|
|
817
|
+
armored = self._apply_prompt_armor(source)
|
|
818
|
+
|
|
819
|
+
if tokens <= self._max_tokens:
|
|
820
|
+
return [ParsedChunk(
|
|
821
|
+
content=source.strip(),
|
|
822
|
+
armored_content=armored,
|
|
823
|
+
chunk_type=ChunkType.CONFIG,
|
|
824
|
+
chunk_index=0,
|
|
825
|
+
source_file=rel_path,
|
|
826
|
+
file_hash=file_hash,
|
|
827
|
+
category=FileCategory.CONFIG,
|
|
828
|
+
start_line=1,
|
|
829
|
+
end_line=source.count("\n") + 1,
|
|
830
|
+
symbol_name=Path(rel_path).name,
|
|
831
|
+
detected_tags=self._detect_tags(source, FileCategory.CONFIG),
|
|
832
|
+
estimated_tokens=tokens,
|
|
833
|
+
)]
|
|
834
|
+
|
|
835
|
+
# Large config — split by size
|
|
836
|
+
return self._split_oversized(source, rel_path, file_hash, ChunkType.CONFIG, Path(rel_path).name, 1, 0)
|
|
837
|
+
|
|
838
|
+
# ===================================================================
|
|
839
|
+
# RAW — Fallback
|
|
840
|
+
# ===================================================================
|
|
841
|
+
|
|
842
|
+
def _parse_raw(self, source: str, rel_path: str, file_hash: str, category: FileCategory) -> list[ParsedChunk]:
|
|
843
|
+
tokens = self._estimate_tokens(source)
|
|
844
|
+
armored = self._apply_prompt_armor(source)
|
|
845
|
+
|
|
846
|
+
if tokens <= self._max_tokens:
|
|
847
|
+
return [ParsedChunk(
|
|
848
|
+
content=source.strip(),
|
|
849
|
+
armored_content=armored,
|
|
850
|
+
chunk_type=ChunkType.RAW,
|
|
851
|
+
chunk_index=0,
|
|
852
|
+
source_file=rel_path,
|
|
853
|
+
file_hash=file_hash,
|
|
854
|
+
category=category,
|
|
855
|
+
start_line=1,
|
|
856
|
+
end_line=source.count("\n") + 1,
|
|
857
|
+
symbol_name=Path(rel_path).name,
|
|
858
|
+
detected_tags=self._detect_tags(source, category),
|
|
859
|
+
estimated_tokens=tokens,
|
|
860
|
+
)]
|
|
861
|
+
|
|
862
|
+
return self._split_oversized(source, rel_path, file_hash, ChunkType.RAW, Path(rel_path).name, 1, 0)
|
|
863
|
+
|
|
864
|
+
# ===================================================================
|
|
865
|
+
# SPLIT OVERSIZED — subdivides large chunks
|
|
866
|
+
# ===================================================================
|
|
867
|
+
|
|
868
|
+
def _split_oversized(
|
|
869
|
+
self, text: str, rel_path: str, file_hash: str,
|
|
870
|
+
chunk_type: ChunkType, base_name: str, base_line: int, start_idx: int,
|
|
871
|
+
) -> list[ParsedChunk]:
|
|
872
|
+
"""Subdivides a block that exceeds max_chunk_tokens into sub-chunks."""
|
|
873
|
+
lines = text.splitlines(keepends=True)
|
|
874
|
+
chunks: list[ParsedChunk] = []
|
|
875
|
+
chunk_idx = start_idx
|
|
876
|
+
max_chars = self._max_tokens * 4 # inverse heuristic
|
|
877
|
+
|
|
878
|
+
current_lines: list[str] = []
|
|
879
|
+
current_start = base_line
|
|
880
|
+
current_chars = 0
|
|
881
|
+
|
|
882
|
+
for i, line in enumerate(lines):
|
|
883
|
+
current_lines.append(line)
|
|
884
|
+
current_chars += len(line)
|
|
885
|
+
|
|
886
|
+
if current_chars >= max_chars:
|
|
887
|
+
block = "".join(current_lines).rstrip()
|
|
888
|
+
armored = self._apply_prompt_armor(block)
|
|
889
|
+
chunks.append(ParsedChunk(
|
|
890
|
+
content=block,
|
|
891
|
+
armored_content=armored,
|
|
892
|
+
chunk_type=chunk_type,
|
|
893
|
+
chunk_index=chunk_idx,
|
|
894
|
+
source_file=rel_path,
|
|
895
|
+
file_hash=file_hash,
|
|
896
|
+
category=FileCategory.CODE,
|
|
897
|
+
start_line=current_start,
|
|
898
|
+
end_line=current_start + len(current_lines) - 1,
|
|
899
|
+
symbol_name=f"{base_name}[{chunk_idx - start_idx}]",
|
|
900
|
+
detected_tags=self._detect_tags(block, FileCategory.CODE),
|
|
901
|
+
estimated_tokens=self._estimate_tokens(block),
|
|
902
|
+
))
|
|
903
|
+
chunk_idx += 1
|
|
904
|
+
current_start = base_line + i + 1
|
|
905
|
+
current_lines = []
|
|
906
|
+
current_chars = 0
|
|
907
|
+
|
|
908
|
+
# Rest
|
|
909
|
+
if current_lines:
|
|
910
|
+
block = "".join(current_lines).rstrip()
|
|
911
|
+
if block.strip():
|
|
912
|
+
armored = self._apply_prompt_armor(block)
|
|
913
|
+
chunks.append(ParsedChunk(
|
|
914
|
+
content=block,
|
|
915
|
+
armored_content=armored,
|
|
916
|
+
chunk_type=chunk_type,
|
|
917
|
+
chunk_index=chunk_idx,
|
|
918
|
+
source_file=rel_path,
|
|
919
|
+
file_hash=file_hash,
|
|
920
|
+
category=FileCategory.CODE,
|
|
921
|
+
start_line=current_start,
|
|
922
|
+
end_line=current_start + len(current_lines) - 1,
|
|
923
|
+
symbol_name=f"{base_name}[{chunk_idx - start_idx}]" if chunks else base_name,
|
|
924
|
+
detected_tags=self._detect_tags(block, FileCategory.CODE),
|
|
925
|
+
estimated_tokens=self._estimate_tokens(block),
|
|
926
|
+
))
|
|
927
|
+
|
|
928
|
+
return chunks
|
|
929
|
+
|
|
930
|
+
# ===================================================================
|
|
931
|
+
# PROMPT ARMOR
|
|
932
|
+
# ===================================================================
|
|
933
|
+
|
|
934
|
+
def _apply_prompt_armor(self, content: str) -> str:
|
|
935
|
+
"""Wraps content in Prompt Armor XML tags (structured XML comments with defensive escaping)."""
|
|
936
|
+
if not self._armor:
|
|
937
|
+
return content
|
|
938
|
+
escaped_content = content.replace("-->", "-- >")
|
|
939
|
+
return f"{PROMPT_ARMOR_OPEN}\n{escaped_content}\n{PROMPT_ARMOR_CLOSE}"
|
|
940
|
+
|
|
941
|
+
# ===================================================================
|
|
942
|
+
# TOKEN ESTIMATION
|
|
943
|
+
# ===================================================================
|
|
944
|
+
|
|
945
|
+
def _estimate_tokens(self, text: str) -> int:
|
|
946
|
+
"""Estimates tokens: ~4 characters per token."""
|
|
947
|
+
return max(1, len(text) // 4)
|
|
948
|
+
|
|
949
|
+
# ===================================================================
|
|
950
|
+
# TAG DETECTION
|
|
951
|
+
# ===================================================================
|
|
952
|
+
|
|
953
|
+
def _detect_tags(self, content: str, category: FileCategory) -> list[str]:
|
|
954
|
+
"""Detects automatic tags based on the content."""
|
|
955
|
+
tags: set[str] = set()
|
|
956
|
+
|
|
957
|
+
content_lower = content.lower()
|
|
958
|
+
|
|
959
|
+
if category == FileCategory.CODE:
|
|
960
|
+
# Extracts Python imports
|
|
961
|
+
for m in _IMPORT_PY_RE.finditer(content):
|
|
962
|
+
mod = (m.group(1) or m.group(2) or "").split(".")[0].lower()
|
|
963
|
+
if mod:
|
|
964
|
+
tags.add(mod)
|
|
965
|
+
# Extracts JS/TS imports
|
|
966
|
+
for m in _IMPORT_JS_RE.finditer(content):
|
|
967
|
+
mod = (m.group(1) or m.group(2) or "")
|
|
968
|
+
mod = mod.strip("./").split("/")[0].lower()
|
|
969
|
+
if mod and not mod.startswith("."):
|
|
970
|
+
tags.add(mod)
|
|
971
|
+
|
|
972
|
+
# Detects known frameworks
|
|
973
|
+
for keyword, tag in _FRAMEWORK_KEYWORDS.items():
|
|
974
|
+
if keyword in content_lower:
|
|
975
|
+
tags.add(tag)
|
|
976
|
+
|
|
977
|
+
# For docs: detects technical keywords
|
|
978
|
+
if category == FileCategory.DOC:
|
|
979
|
+
doc_keywords = ["api", "auth", "database", "deploy", "migration", "security", "testing"]
|
|
980
|
+
for kw in doc_keywords:
|
|
981
|
+
if kw in content_lower:
|
|
982
|
+
tags.add(kw)
|
|
983
|
+
|
|
984
|
+
return sorted(tags)
|