@roll-agent/octopus-agent 0.0.10 → 0.0.11
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.
- package/README.md +1 -0
- package/SKILL.md +25 -1
- package/package.json +1 -1
- package/pyproject.toml +4 -2
- package/references/env.yaml +17 -0
- package/scripts/refresh_dictionary.py +67 -0
- package/scripts/verify_binding.py +47 -0
- package/src/octopus_skill/_version.py +1 -1
- package/src/octopus_skill/agent.py +1099 -146
- package/src/octopus_skill/config.py +58 -10
- package/src/octopus_skill/entity_binding.py +523 -0
- package/src/octopus_skill/entity_dictionary.py +59 -0
- package/src/octopus_skill/entity_resolver.py +446 -0
- package/src/octopus_skill/llm_sql_generator.py +3 -0
- package/src/octopus_skill/mcp_client.py +16 -0
- package/src/octopus_skill/octopus_run.py +369 -17
- package/src/octopus_skill/prompt_builder.py +74 -31
- package/src/octopus_skill/schema_context_retriever.py +199 -150
- package/src/octopus_skill/sql_generator.py +432 -801
- package/src/octopus_skill/query_sql_cache.py +0 -72
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable, Iterable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Scorer returns a similarity in [0.0, 1.0]. Backed by rapidfuzz when available
|
|
10
|
+
# (better CJK fuzzy matching), with a stdlib difflib fallback so the package
|
|
11
|
+
# still works if rapidfuzz is not installed.
|
|
12
|
+
Scorer = Callable[[str, str], float]
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from rapidfuzz import fuzz as _rapidfuzz_fuzz
|
|
16
|
+
|
|
17
|
+
def default_scorer(a: str, b: str) -> float:
|
|
18
|
+
return _rapidfuzz_fuzz.ratio(a, b) / 100.0
|
|
19
|
+
|
|
20
|
+
except ImportError: # pragma: no cover - exercised only without rapidfuzz
|
|
21
|
+
from difflib import SequenceMatcher
|
|
22
|
+
|
|
23
|
+
def default_scorer(a: str, b: str) -> float:
|
|
24
|
+
return SequenceMatcher(None, a, b).ratio()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
GEO_TYPES = ("province", "city", "region")
|
|
28
|
+
# Trailing tokens dropped when comparing geo names so "上海市" matches "上海".
|
|
29
|
+
_GEO_SUFFIXES = ("特别行政区", "自治区", "新区", "地区", "盟", "省", "市", "区", "县", "旗")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class EntityItem:
|
|
34
|
+
entity_type: str
|
|
35
|
+
id: Any
|
|
36
|
+
name: str
|
|
37
|
+
aliases: tuple[str, ...]
|
|
38
|
+
parent_id: Any | None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ResolvedEntity:
|
|
43
|
+
entity_type: str
|
|
44
|
+
id: Any
|
|
45
|
+
canonical: str
|
|
46
|
+
table: str
|
|
47
|
+
column: str
|
|
48
|
+
score: float
|
|
49
|
+
matched_via: str # exact | alias | partial | fuzzy
|
|
50
|
+
parent_id: Any | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Mention:
|
|
55
|
+
"""A dictionary entity detected as a substring of a larger text."""
|
|
56
|
+
|
|
57
|
+
entity: ResolvedEntity
|
|
58
|
+
start: int
|
|
59
|
+
end: int
|
|
60
|
+
text: str # the matched dictionary name/alias (normalized)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class _TypeIndex:
|
|
65
|
+
entity_type: str
|
|
66
|
+
table: str
|
|
67
|
+
id_column: str
|
|
68
|
+
name_column: str
|
|
69
|
+
parent_type: str | None
|
|
70
|
+
items: list[EntityItem] = field(default_factory=list)
|
|
71
|
+
_exact: dict[str, EntityItem] = field(default_factory=dict)
|
|
72
|
+
_alias: dict[str, EntityItem] = field(default_factory=dict)
|
|
73
|
+
# (normalized_match_text, item) pairs used for substring and fuzzy matching
|
|
74
|
+
_candidates: list[tuple[str, EntityItem]] = field(default_factory=list)
|
|
75
|
+
by_id: dict[Any, EntityItem] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
def add(self, item: EntityItem, extra_aliases: Iterable[str] = ()) -> None:
|
|
78
|
+
self.items.append(item)
|
|
79
|
+
self.by_id.setdefault(item.id, item)
|
|
80
|
+
name_key = _norm(item.name)
|
|
81
|
+
if name_key:
|
|
82
|
+
self._exact.setdefault(name_key, item)
|
|
83
|
+
self._candidates.append((name_key, item))
|
|
84
|
+
core = _geo_core(name_key)
|
|
85
|
+
if core != name_key and core:
|
|
86
|
+
self._alias.setdefault(core, item)
|
|
87
|
+
self._candidates.append((core, item))
|
|
88
|
+
for alias in tuple(item.aliases) + tuple(extra_aliases):
|
|
89
|
+
alias_key = _norm(alias)
|
|
90
|
+
if alias_key:
|
|
91
|
+
self._alias.setdefault(alias_key, item)
|
|
92
|
+
self._candidates.append((alias_key, item))
|
|
93
|
+
|
|
94
|
+
def lookup(self, key: str) -> tuple[EntityItem, str] | None:
|
|
95
|
+
if key in self._exact:
|
|
96
|
+
return self._exact[key], "exact"
|
|
97
|
+
if key in self._alias:
|
|
98
|
+
return self._alias[key], "alias"
|
|
99
|
+
core = _geo_core(key)
|
|
100
|
+
if core and core in self._exact:
|
|
101
|
+
return self._exact[core], "exact"
|
|
102
|
+
if core and core in self._alias:
|
|
103
|
+
return self._alias[core], "alias"
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class EntityResolver:
|
|
109
|
+
indexes: dict[str, _TypeIndex]
|
|
110
|
+
scorer: Scorer = default_scorer
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_payload(
|
|
114
|
+
cls,
|
|
115
|
+
dictionary: dict[str, Any],
|
|
116
|
+
*,
|
|
117
|
+
geo_aliases: dict[str, dict[str, str]] | None = None,
|
|
118
|
+
scorer: Scorer = default_scorer,
|
|
119
|
+
) -> "EntityResolver":
|
|
120
|
+
geo_aliases = geo_aliases or {}
|
|
121
|
+
entities = dictionary.get("entities") if isinstance(dictionary, dict) else None
|
|
122
|
+
indexes: dict[str, _TypeIndex] = {}
|
|
123
|
+
if isinstance(entities, dict):
|
|
124
|
+
for entity_type, payload in entities.items():
|
|
125
|
+
if not isinstance(payload, dict):
|
|
126
|
+
continue
|
|
127
|
+
index = _TypeIndex(
|
|
128
|
+
entity_type=entity_type,
|
|
129
|
+
table=str(payload.get("table") or entity_type),
|
|
130
|
+
id_column=str(payload.get("idColumn") or "id"),
|
|
131
|
+
name_column=str(payload.get("nameColumn") or "name"),
|
|
132
|
+
parent_type=payload.get("parentType"),
|
|
133
|
+
)
|
|
134
|
+
# canonical name -> [aliases] coming from the geo alias config
|
|
135
|
+
alias_by_canonical: dict[str, list[str]] = {}
|
|
136
|
+
for alias, canonical in (geo_aliases.get(entity_type) or {}).items():
|
|
137
|
+
if alias.startswith("_"):
|
|
138
|
+
continue
|
|
139
|
+
alias_by_canonical.setdefault(_norm(canonical), []).append(alias)
|
|
140
|
+
for raw in payload.get("items") or []:
|
|
141
|
+
if not isinstance(raw, dict):
|
|
142
|
+
continue
|
|
143
|
+
name = str(raw.get("name") or "")
|
|
144
|
+
if not name:
|
|
145
|
+
continue
|
|
146
|
+
aliases = tuple(
|
|
147
|
+
str(value)
|
|
148
|
+
for value in (raw.get("aliases") or [])
|
|
149
|
+
if str(value).strip()
|
|
150
|
+
)
|
|
151
|
+
item = EntityItem(
|
|
152
|
+
entity_type=entity_type,
|
|
153
|
+
id=raw.get(index.id_column) if index.id_column in raw else raw.get("id"),
|
|
154
|
+
name=name,
|
|
155
|
+
aliases=aliases,
|
|
156
|
+
parent_id=raw.get("parentId"),
|
|
157
|
+
)
|
|
158
|
+
# Config canonical names may omit geo suffixes (e.g. the
|
|
159
|
+
# config says "南京" while the table stores "南京市"), so
|
|
160
|
+
# match on both the full name and its geo core.
|
|
161
|
+
name_key = _norm(name)
|
|
162
|
+
extra = list(alias_by_canonical.get(name_key, ()))
|
|
163
|
+
core_key = _geo_core(name_key)
|
|
164
|
+
if core_key != name_key:
|
|
165
|
+
extra.extend(alias_by_canonical.get(core_key, ()))
|
|
166
|
+
index.add(item, extra_aliases=extra)
|
|
167
|
+
indexes[entity_type] = index
|
|
168
|
+
return cls(indexes=indexes, scorer=scorer)
|
|
169
|
+
|
|
170
|
+
def resolve(
|
|
171
|
+
self,
|
|
172
|
+
span: str,
|
|
173
|
+
entity_type: str,
|
|
174
|
+
*,
|
|
175
|
+
limit: int = 5,
|
|
176
|
+
min_score: float = 0.6,
|
|
177
|
+
) -> list[ResolvedEntity]:
|
|
178
|
+
index = self.indexes.get(entity_type)
|
|
179
|
+
if index is None:
|
|
180
|
+
return []
|
|
181
|
+
key = _norm(span)
|
|
182
|
+
if not key:
|
|
183
|
+
return []
|
|
184
|
+
|
|
185
|
+
direct = index.lookup(key)
|
|
186
|
+
if direct is not None:
|
|
187
|
+
item, via = direct
|
|
188
|
+
score = 1.0 if via == "exact" else 0.95
|
|
189
|
+
return [_to_resolved(item, index, score, via)]
|
|
190
|
+
|
|
191
|
+
scored: dict[Any, ResolvedEntity] = {}
|
|
192
|
+
for candidate_text, item in index._candidates:
|
|
193
|
+
score = self.scorer(key, candidate_text)
|
|
194
|
+
if score < min_score:
|
|
195
|
+
continue
|
|
196
|
+
existing = scored.get(item.id)
|
|
197
|
+
if existing is None or score > existing.score:
|
|
198
|
+
scored[item.id] = _to_resolved(item, index, round(score, 4), "fuzzy")
|
|
199
|
+
ranked = sorted(scored.values(), key=lambda entity: entity.score, reverse=True)
|
|
200
|
+
return ranked[:limit]
|
|
201
|
+
|
|
202
|
+
def find_mentions(
|
|
203
|
+
self,
|
|
204
|
+
text: str,
|
|
205
|
+
entity_type: str,
|
|
206
|
+
*,
|
|
207
|
+
min_len: int = 2,
|
|
208
|
+
) -> list[Mention]:
|
|
209
|
+
"""Detect dictionary entities that appear as substrings of ``text``.
|
|
210
|
+
|
|
211
|
+
This is the reverse of :meth:`resolve`: instead of resolving a single
|
|
212
|
+
pre-extracted span, it scans ``text`` for any known name/alias. Each
|
|
213
|
+
item is reported once, using its longest matching candidate. Matching
|
|
214
|
+
is exact/alias only (no fuzzy) to keep substring scanning precise.
|
|
215
|
+
"""
|
|
216
|
+
index = self.indexes.get(entity_type)
|
|
217
|
+
if index is None:
|
|
218
|
+
return []
|
|
219
|
+
haystack = _norm(text)
|
|
220
|
+
if not haystack:
|
|
221
|
+
return []
|
|
222
|
+
best: dict[Any, Mention] = {}
|
|
223
|
+
for candidate_text, item in index._candidates:
|
|
224
|
+
if len(candidate_text) < min_len:
|
|
225
|
+
continue
|
|
226
|
+
start = haystack.find(candidate_text)
|
|
227
|
+
if start < 0:
|
|
228
|
+
continue
|
|
229
|
+
via = "exact" if candidate_text == _norm(item.name) else "alias"
|
|
230
|
+
score = 1.0 if via == "exact" else 0.95
|
|
231
|
+
mention = Mention(
|
|
232
|
+
entity=_to_resolved(item, index, score, via),
|
|
233
|
+
start=start,
|
|
234
|
+
end=start + len(candidate_text),
|
|
235
|
+
text=candidate_text,
|
|
236
|
+
)
|
|
237
|
+
existing = best.get(item.id)
|
|
238
|
+
if existing is None or (mention.end - mention.start) > (existing.end - existing.start):
|
|
239
|
+
best[item.id] = mention
|
|
240
|
+
return sorted(best.values(), key=lambda mention: mention.start)
|
|
241
|
+
|
|
242
|
+
def find_containing(
|
|
243
|
+
self,
|
|
244
|
+
span: str,
|
|
245
|
+
entity_type: str,
|
|
246
|
+
*,
|
|
247
|
+
limit: int | None = None,
|
|
248
|
+
min_len: int = 2,
|
|
249
|
+
) -> list[ResolvedEntity]:
|
|
250
|
+
"""Find entities whose known name/alias contains ``span``.
|
|
251
|
+
|
|
252
|
+
This covers user shorthand such as "必胜客-1" matching projects named
|
|
253
|
+
"上海必胜客-1" and "南京必胜客-1".
|
|
254
|
+
"""
|
|
255
|
+
index = self.indexes.get(entity_type)
|
|
256
|
+
if index is None:
|
|
257
|
+
return []
|
|
258
|
+
key = _norm(span)
|
|
259
|
+
if len(key) < min_len:
|
|
260
|
+
return []
|
|
261
|
+
|
|
262
|
+
best: dict[Any, ResolvedEntity] = {}
|
|
263
|
+
order: dict[Any, int] = {}
|
|
264
|
+
for candidate_text, item in index._candidates:
|
|
265
|
+
if len(candidate_text) < min_len or key not in candidate_text:
|
|
266
|
+
continue
|
|
267
|
+
if candidate_text == _norm(item.name):
|
|
268
|
+
via = "exact" if key == candidate_text else "partial"
|
|
269
|
+
score = 1.0 if key == candidate_text else round(0.9 * len(key) / len(candidate_text), 4)
|
|
270
|
+
else:
|
|
271
|
+
via = "alias" if key == candidate_text else "partial"
|
|
272
|
+
score = 0.95 if key == candidate_text else round(0.85 * len(key) / len(candidate_text), 4)
|
|
273
|
+
resolved = _to_resolved(item, index, score, via)
|
|
274
|
+
existing = best.get(item.id)
|
|
275
|
+
if existing is None:
|
|
276
|
+
order[item.id] = len(order)
|
|
277
|
+
if existing is None or resolved.score > existing.score:
|
|
278
|
+
best[item.id] = resolved
|
|
279
|
+
|
|
280
|
+
ranked = sorted(
|
|
281
|
+
best.values(),
|
|
282
|
+
key=lambda entity: (-entity.score, order.get(entity.id, 0)),
|
|
283
|
+
)
|
|
284
|
+
return ranked if limit is None else ranked[:limit]
|
|
285
|
+
|
|
286
|
+
def resolve_location(
|
|
287
|
+
self,
|
|
288
|
+
span: str,
|
|
289
|
+
*,
|
|
290
|
+
min_score: float = 0.6,
|
|
291
|
+
) -> dict[str, ResolvedEntity]:
|
|
292
|
+
"""Decompose a location span into province / city / region.
|
|
293
|
+
|
|
294
|
+
Handles compounds like "上海宝山" and disambiguates duplicate names
|
|
295
|
+
(e.g. 宝山区 exists in both 上海 and 黑龙江) by preferring the geo entity
|
|
296
|
+
whose parent chain is consistent with the other levels mentioned in the
|
|
297
|
+
same span.
|
|
298
|
+
"""
|
|
299
|
+
key = _norm(span)
|
|
300
|
+
if not key:
|
|
301
|
+
return {}
|
|
302
|
+
|
|
303
|
+
# Collect every geo item whose name/alias is a substring, keeping ALL
|
|
304
|
+
# items per matched span (duplicate names must all be considered).
|
|
305
|
+
spans: dict[tuple[int, int], dict[str, list[EntityItem]]] = {}
|
|
306
|
+
for geo_type in GEO_TYPES:
|
|
307
|
+
index = self.indexes.get(geo_type)
|
|
308
|
+
if index is None:
|
|
309
|
+
continue
|
|
310
|
+
for candidate_text, item in index._candidates:
|
|
311
|
+
if len(candidate_text) < 2:
|
|
312
|
+
continue
|
|
313
|
+
start = key.find(candidate_text)
|
|
314
|
+
if start < 0:
|
|
315
|
+
continue
|
|
316
|
+
bucket = spans.setdefault((start, start + len(candidate_text)), {})
|
|
317
|
+
items = bucket.setdefault(geo_type, [])
|
|
318
|
+
if all(existing.id != item.id for existing in items):
|
|
319
|
+
items.append(item)
|
|
320
|
+
|
|
321
|
+
if not spans:
|
|
322
|
+
return self._fuzzy_location_fallback(span, min_score)
|
|
323
|
+
|
|
324
|
+
# Accept non-overlapping spans, longest first.
|
|
325
|
+
accepted = self._accept_geo_spans(spans)
|
|
326
|
+
provinces = [item for bucket in accepted for item in bucket.get("province", [])]
|
|
327
|
+
cities = [item for bucket in accepted for item in bucket.get("city", [])]
|
|
328
|
+
regions = [item for bucket in accepted for item in bucket.get("region", [])]
|
|
329
|
+
|
|
330
|
+
return self._assemble_location(provinces, cities, regions)
|
|
331
|
+
|
|
332
|
+
def _accept_geo_spans(
|
|
333
|
+
self,
|
|
334
|
+
spans: dict[tuple[int, int], dict[str, list[EntityItem]]],
|
|
335
|
+
) -> list[dict[str, list[EntityItem]]]:
|
|
336
|
+
ordered = sorted(spans.items(), key=lambda item: item[0][1] - item[0][0], reverse=True)
|
|
337
|
+
accepted: list[tuple[tuple[int, int], dict[str, list[EntityItem]]]] = []
|
|
338
|
+
for (start, end), bucket in ordered:
|
|
339
|
+
if any(start < other_end and other_start < end for (other_start, other_end), _ in accepted):
|
|
340
|
+
continue
|
|
341
|
+
accepted.append(((start, end), bucket))
|
|
342
|
+
return [bucket for _, bucket in accepted]
|
|
343
|
+
|
|
344
|
+
def _assemble_location(
|
|
345
|
+
self,
|
|
346
|
+
provinces: list[EntityItem],
|
|
347
|
+
cities: list[EntityItem],
|
|
348
|
+
regions: list[EntityItem],
|
|
349
|
+
) -> dict[str, ResolvedEntity]:
|
|
350
|
+
city_index = self.indexes.get("city")
|
|
351
|
+
province_ids = {item.id for item in provinces}
|
|
352
|
+
city_ids = {item.id for item in cities}
|
|
353
|
+
|
|
354
|
+
chosen_region: EntityItem | None = None
|
|
355
|
+
chosen_city: EntityItem | None = None
|
|
356
|
+
if regions:
|
|
357
|
+
# Prefer a region whose parent city is mentioned, else whose parent
|
|
358
|
+
# city rolls up to a mentioned province; fall back to the first.
|
|
359
|
+
for region in regions:
|
|
360
|
+
if region.parent_id in city_ids:
|
|
361
|
+
chosen_region = region
|
|
362
|
+
break
|
|
363
|
+
if chosen_region is None and province_ids and city_index is not None:
|
|
364
|
+
for region in regions:
|
|
365
|
+
parent_city = city_index.by_id.get(region.parent_id)
|
|
366
|
+
if parent_city is not None and parent_city.parent_id in province_ids:
|
|
367
|
+
chosen_region = region
|
|
368
|
+
chosen_city = parent_city
|
|
369
|
+
break
|
|
370
|
+
if chosen_region is None:
|
|
371
|
+
chosen_region = regions[0]
|
|
372
|
+
|
|
373
|
+
if chosen_city is None and chosen_region is not None and city_index is not None:
|
|
374
|
+
# Anchor the city to the region's declared parent when available.
|
|
375
|
+
parent_city = city_index.by_id.get(chosen_region.parent_id)
|
|
376
|
+
if parent_city is not None and (not cities or parent_city.id in city_ids):
|
|
377
|
+
chosen_city = parent_city
|
|
378
|
+
if chosen_city is None and cities:
|
|
379
|
+
chosen_city = cities[0]
|
|
380
|
+
|
|
381
|
+
chosen_province: EntityItem | None = None
|
|
382
|
+
if chosen_city is not None and provinces:
|
|
383
|
+
chosen_province = next((p for p in provinces if p.id == chosen_city.parent_id), None)
|
|
384
|
+
if chosen_province is None and provinces:
|
|
385
|
+
chosen_province = provinces[0]
|
|
386
|
+
|
|
387
|
+
result: dict[str, ResolvedEntity] = {}
|
|
388
|
+
if chosen_province is not None:
|
|
389
|
+
result["province"] = _to_resolved(chosen_province, self.indexes["province"], 1.0, "exact")
|
|
390
|
+
if chosen_city is not None:
|
|
391
|
+
result["city"] = _to_resolved(chosen_city, self.indexes["city"], 1.0, "exact")
|
|
392
|
+
if chosen_region is not None:
|
|
393
|
+
result["region"] = _to_resolved(chosen_region, self.indexes["region"], 1.0, "exact")
|
|
394
|
+
return result
|
|
395
|
+
|
|
396
|
+
def _fuzzy_location_fallback(self, span: str, min_score: float) -> dict[str, ResolvedEntity]:
|
|
397
|
+
best: ResolvedEntity | None = None
|
|
398
|
+
for geo_type in GEO_TYPES:
|
|
399
|
+
for resolved in self.resolve(span, geo_type, limit=1, min_score=min_score):
|
|
400
|
+
if best is None or resolved.score > best.score:
|
|
401
|
+
best = resolved
|
|
402
|
+
return {best.entity_type: best} if best is not None else {}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def load_geo_aliases(path: Path) -> dict[str, dict[str, str]]:
|
|
406
|
+
if not path.exists():
|
|
407
|
+
return {}
|
|
408
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
409
|
+
if not isinstance(data, dict):
|
|
410
|
+
return {}
|
|
411
|
+
return {
|
|
412
|
+
key: value
|
|
413
|
+
for key, value in data.items()
|
|
414
|
+
if isinstance(value, dict) and not key.startswith("_")
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _to_resolved(
|
|
419
|
+
item: EntityItem,
|
|
420
|
+
index: _TypeIndex,
|
|
421
|
+
score: float,
|
|
422
|
+
via: str,
|
|
423
|
+
) -> ResolvedEntity:
|
|
424
|
+
return ResolvedEntity(
|
|
425
|
+
entity_type=item.entity_type,
|
|
426
|
+
id=item.id,
|
|
427
|
+
canonical=item.name,
|
|
428
|
+
table=index.table,
|
|
429
|
+
column=index.name_column,
|
|
430
|
+
score=score,
|
|
431
|
+
matched_via=via,
|
|
432
|
+
parent_id=item.parent_id,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _norm(value: str) -> str:
|
|
437
|
+
return value.strip().lower()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _geo_core(value: str) -> str:
|
|
441
|
+
core = value
|
|
442
|
+
for suffix in _GEO_SUFFIXES:
|
|
443
|
+
if core.endswith(suffix) and len(core) > len(suffix):
|
|
444
|
+
core = core[: -len(suffix)]
|
|
445
|
+
break
|
|
446
|
+
return core
|
|
@@ -18,6 +18,7 @@ class SamplingSqlGenerator:
|
|
|
18
18
|
sampler: Sampler
|
|
19
19
|
default_limit: int
|
|
20
20
|
max_limit: int
|
|
21
|
+
resolved_entities: dict[str, Any] | None = None
|
|
21
22
|
|
|
22
23
|
def generate(
|
|
23
24
|
self,
|
|
@@ -30,6 +31,7 @@ class SamplingSqlGenerator:
|
|
|
30
31
|
schema_context=schema_context,
|
|
31
32
|
default_limit=self.default_limit,
|
|
32
33
|
max_limit=self.max_limit,
|
|
34
|
+
resolved_entities=self.resolved_entities,
|
|
33
35
|
)
|
|
34
36
|
return _parse_generated_sql(self.sampler(NL2SQL_SYSTEM_PROMPT, prompt))
|
|
35
37
|
|
|
@@ -48,6 +50,7 @@ class SamplingSqlGenerator:
|
|
|
48
50
|
schema_context=schema_context,
|
|
49
51
|
default_limit=self.default_limit,
|
|
50
52
|
max_limit=self.max_limit,
|
|
53
|
+
resolved_entities=self.resolved_entities,
|
|
51
54
|
)
|
|
52
55
|
return _parse_generated_sql(self.sampler(NL2SQL_SYSTEM_PROMPT, prompt))
|
|
53
56
|
|
|
@@ -31,6 +31,22 @@ class SpongeMcpClient:
|
|
|
31
31
|
}
|
|
32
32
|
return self._post("get_schema", payload)
|
|
33
33
|
|
|
34
|
+
def get_entity_dictionary(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
trace_id: str,
|
|
38
|
+
session_id: str | None = None,
|
|
39
|
+
dict_version: str | None = None,
|
|
40
|
+
entity_types: list[str] | tuple[str, ...] | None = None,
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
payload = {
|
|
43
|
+
"traceId": trace_id,
|
|
44
|
+
"sessionId": session_id,
|
|
45
|
+
"dictVersion": dict_version,
|
|
46
|
+
"entityTypes": list(entity_types) if entity_types else None,
|
|
47
|
+
}
|
|
48
|
+
return self._post("get_entity_dictionary", payload)
|
|
49
|
+
|
|
34
50
|
def validate_sql(
|
|
35
51
|
self,
|
|
36
52
|
*,
|