@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.
@@ -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
  *,