@roll-agent/octopus-agent 0.0.4 → 0.0.6
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 +7 -4
- package/SKILL.md +4 -4
- package/bin/check-python.js +117 -0
- package/bin/start-roll-server.js +109 -0
- package/package.json +8 -5
- package/pyproject.toml +2 -2
- package/src/octopus_sponge_skill/__init__.py +1 -1
- package/src/octopus_sponge_skill/_version.py +1 -1
- package/src/octopus_sponge_skill/agent.py +236 -31
- package/src/octopus_sponge_skill/config.py +8 -1
- package/src/octopus_sponge_skill/prompt_builder.py +10 -1
- package/src/octopus_sponge_skill/roll_server.py +3 -3
- package/src/octopus_sponge_skill/schema_context_retriever.py +62 -2
- package/src/octopus_sponge_skill/sql_generator.py +359 -29
|
@@ -65,15 +65,12 @@ class RuleBasedSqlGenerator:
|
|
|
65
65
|
if _is_multi_metric_query(question):
|
|
66
66
|
raise SqlGenerationError("Multi-metric query requires schema-driven metric generation")
|
|
67
67
|
|
|
68
|
-
if ("品牌" in question or "brand" in lowered) and "brand" in table_names:
|
|
68
|
+
if ("品牌" in question or "brand" in lowered) and "brand" in table_names and _is_entity_lookup_query(question, "品牌"):
|
|
69
69
|
brand_filter = _extract_brand_filter(question)
|
|
70
70
|
brand_where = "b.is_delete = 0"
|
|
71
71
|
if brand_filter is not None:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
brand_where = f"b.is_delete = 0 AND b.name LIKE '%{_escape_sql_string(brand_name)}%'"
|
|
75
|
-
else:
|
|
76
|
-
brand_where = f"b.is_delete = 0 AND b.name = '{_escape_sql_string(brand_name)}'"
|
|
72
|
+
_operator, brand_name = brand_filter
|
|
73
|
+
brand_where = f"b.is_delete = 0 AND b.name LIKE '%{_escape_sql_string(brand_name)}%'"
|
|
77
74
|
sql = (
|
|
78
75
|
"SELECT b.id AS 品牌ID, b.name AS 品牌名称 "
|
|
79
76
|
"FROM brand b "
|
|
@@ -82,11 +79,15 @@ class RuleBasedSqlGenerator:
|
|
|
82
79
|
)
|
|
83
80
|
return GeneratedSql(sql, ["brand"], [])
|
|
84
81
|
|
|
85
|
-
if ("项目" in question or "project" in lowered) and "sponge_project" in table_names:
|
|
82
|
+
if ("项目" in question or "project" in lowered) and "sponge_project" in table_names and _is_entity_lookup_query(question, "项目"):
|
|
83
|
+
project_name = _extract_project_name(question)
|
|
84
|
+
project_where = "p.is_delete = 0"
|
|
85
|
+
if project_name is not None:
|
|
86
|
+
project_where = f"p.is_delete = 0 AND p.name LIKE '%{_escape_sql_string(project_name)}%'"
|
|
86
87
|
sql = (
|
|
87
88
|
"SELECT p.id AS 项目ID, p.name AS 项目名称 "
|
|
88
89
|
"FROM sponge_project p "
|
|
89
|
-
"WHERE
|
|
90
|
+
f"WHERE {project_where} "
|
|
90
91
|
f"LIMIT {self.default_limit}"
|
|
91
92
|
)
|
|
92
93
|
return GeneratedSql(sql, ["sponge_project"], [])
|
|
@@ -101,7 +102,7 @@ class RuleBasedSqlGenerator:
|
|
|
101
102
|
if location and _has_recruiting_location_tables(table_names):
|
|
102
103
|
escaped_location = _escape_sql_string(location)
|
|
103
104
|
from_and_where = _append_job_time_range_condition(
|
|
104
|
-
_recruiting_location_from_and_where(escaped_location),
|
|
105
|
+
_append_job_name_condition(_recruiting_location_from_and_where(escaped_location), question),
|
|
105
106
|
question,
|
|
106
107
|
schema,
|
|
107
108
|
)
|
|
@@ -123,7 +124,11 @@ class RuleBasedSqlGenerator:
|
|
|
123
124
|
)
|
|
124
125
|
|
|
125
126
|
if _is_recruiting_job_question(question) and _has_recruiting_tables(table_names):
|
|
126
|
-
from_and_where = _append_job_time_range_condition(
|
|
127
|
+
from_and_where = _append_job_time_range_condition(
|
|
128
|
+
_append_job_name_condition(_recruiting_from_and_where(), question),
|
|
129
|
+
question,
|
|
130
|
+
schema,
|
|
131
|
+
)
|
|
127
132
|
if _question_requests_count(question):
|
|
128
133
|
sql = f"SELECT COUNT(DISTINCT jbi.id) AS 在招岗位数量 {from_and_where} LIMIT {self.default_limit}"
|
|
129
134
|
else:
|
|
@@ -150,8 +155,11 @@ def enforce_sql_rules(sql: str, *, max_limit: int) -> None:
|
|
|
150
155
|
raise SqlGenerationError("SQL contains a forbidden keyword")
|
|
151
156
|
if re.search(r"\b(password|token|secret|credential|private_key)\b", normalized):
|
|
152
157
|
raise SqlGenerationError("SQL contains a sensitive field")
|
|
158
|
+
if _sql_selects_sensitive_field(sql):
|
|
159
|
+
raise SqlGenerationError("SQL contains a sensitive field")
|
|
153
160
|
if ":scope." in normalized:
|
|
154
161
|
raise SqlGenerationError("SQL must not contain scope placeholders")
|
|
162
|
+
_enforce_text_filters_use_like(sql)
|
|
155
163
|
_enforce_chinese_select_aliases(sql)
|
|
156
164
|
_enforce_no_null_placeholder_select_items(sql)
|
|
157
165
|
limit = re.search(r"\blimit\s+(\d+)\b", normalized)
|
|
@@ -161,11 +169,157 @@ def enforce_sql_rules(sql: str, *, max_limit: int) -> None:
|
|
|
161
169
|
raise SqlGenerationError(f"SQL LIMIT must not exceed {max_limit}")
|
|
162
170
|
|
|
163
171
|
|
|
172
|
+
SENSITIVE_FIELD_NAMES = {
|
|
173
|
+
"phone",
|
|
174
|
+
"mobile",
|
|
175
|
+
"tel",
|
|
176
|
+
"telephone",
|
|
177
|
+
"phone_number",
|
|
178
|
+
"mobile_number",
|
|
179
|
+
"mobile_phone",
|
|
180
|
+
"contact_phone",
|
|
181
|
+
"contact_mobile",
|
|
182
|
+
"contact_tel",
|
|
183
|
+
"id_card",
|
|
184
|
+
"idcard",
|
|
185
|
+
"identity_card",
|
|
186
|
+
"cert_no",
|
|
187
|
+
"certificate_no",
|
|
188
|
+
"password",
|
|
189
|
+
"token",
|
|
190
|
+
"secret",
|
|
191
|
+
"credential",
|
|
192
|
+
"private_key",
|
|
193
|
+
}
|
|
194
|
+
SENSITIVE_FIELD_SUFFIXES = ("_phone", "_mobile", "_tel")
|
|
195
|
+
SENSITIVE_FIELD_LABELS = (
|
|
196
|
+
"手机号",
|
|
197
|
+
"手机号码",
|
|
198
|
+
"电话",
|
|
199
|
+
"电话号码",
|
|
200
|
+
"联系方式",
|
|
201
|
+
"身份证",
|
|
202
|
+
"身份证号",
|
|
203
|
+
"身份证号码",
|
|
204
|
+
"密码",
|
|
205
|
+
"令牌",
|
|
206
|
+
"密钥",
|
|
207
|
+
"凭证",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def sensitive_terms_in_text(text: str) -> list[str]:
|
|
212
|
+
lowered = text.lower()
|
|
213
|
+
terms: list[str] = []
|
|
214
|
+
for label in SENSITIVE_FIELD_LABELS:
|
|
215
|
+
if label in text:
|
|
216
|
+
terms.append(label)
|
|
217
|
+
english_terms = {
|
|
218
|
+
"phone": "手机号",
|
|
219
|
+
"mobile": "手机号",
|
|
220
|
+
"tel": "电话",
|
|
221
|
+
"telephone": "电话",
|
|
222
|
+
"id_card": "身份证",
|
|
223
|
+
"idcard": "身份证",
|
|
224
|
+
"password": "密码",
|
|
225
|
+
"token": "令牌",
|
|
226
|
+
"secret": "密钥",
|
|
227
|
+
}
|
|
228
|
+
for term, label in english_terms.items():
|
|
229
|
+
if re.search(rf"\b{re.escape(term)}\b", lowered) and label not in terms:
|
|
230
|
+
terms.append(label)
|
|
231
|
+
return list(dict.fromkeys(terms))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _sql_selects_sensitive_field(sql: str) -> bool:
|
|
235
|
+
select_clause = _top_level_select_clause(sql)
|
|
236
|
+
if select_clause is None:
|
|
237
|
+
return False
|
|
238
|
+
return any(_select_item_contains_sensitive_field(item) for item in _split_select_items(select_clause))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _top_level_select_clause(sql: str) -> str | None:
|
|
242
|
+
match = re.search(r"\bselect\b(?P<select>.*?)\bfrom\b", sql, flags=re.IGNORECASE | re.DOTALL)
|
|
243
|
+
if match is None:
|
|
244
|
+
return None
|
|
245
|
+
return match.group("select")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _split_select_items(select_clause: str) -> list[str]:
|
|
249
|
+
items: list[str] = []
|
|
250
|
+
start = 0
|
|
251
|
+
depth = 0
|
|
252
|
+
quote: str | None = None
|
|
253
|
+
for index, char in enumerate(select_clause):
|
|
254
|
+
if quote is not None:
|
|
255
|
+
if char == quote:
|
|
256
|
+
quote = None
|
|
257
|
+
continue
|
|
258
|
+
if char in {"'", '"', "`"}:
|
|
259
|
+
quote = char
|
|
260
|
+
elif char == "(":
|
|
261
|
+
depth += 1
|
|
262
|
+
elif char == ")" and depth > 0:
|
|
263
|
+
depth -= 1
|
|
264
|
+
elif char == "," and depth == 0:
|
|
265
|
+
items.append(select_clause[start:index].strip())
|
|
266
|
+
start = index + 1
|
|
267
|
+
tail = select_clause[start:].strip()
|
|
268
|
+
if tail:
|
|
269
|
+
items.append(tail)
|
|
270
|
+
return items
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _select_item_contains_sensitive_field(item: str) -> bool:
|
|
274
|
+
if any(label in item for label in SENSITIVE_FIELD_LABELS):
|
|
275
|
+
return True
|
|
276
|
+
identifiers = re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]*\b", item.lower())
|
|
277
|
+
for identifier in identifiers:
|
|
278
|
+
if identifier in {"select", "case", "when", "then", "else", "end", "as", "if", "null", "coalesce"}:
|
|
279
|
+
continue
|
|
280
|
+
if identifier in SENSITIVE_FIELD_NAMES or identifier.endswith(SENSITIVE_FIELD_SUFFIXES):
|
|
281
|
+
return True
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _enforce_text_filters_use_like(sql: str) -> None:
|
|
286
|
+
for match in re.finditer(
|
|
287
|
+
r"\b(?:where|and|or|having)\s*\(?\s*(?:[a-zA-Z_][a-zA-Z0-9_]*\.)?"
|
|
288
|
+
r"(?P<column>[a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*'(?P<value>[^']*)'",
|
|
289
|
+
sql,
|
|
290
|
+
flags=re.IGNORECASE,
|
|
291
|
+
):
|
|
292
|
+
column = match.group("column").lower()
|
|
293
|
+
value = match.group("value").strip()
|
|
294
|
+
if _is_name_filter_column(column) or (_is_status_text_filter_column(column) and not _is_numeric_literal(value)):
|
|
295
|
+
raise SqlGenerationError("SQL text filters must use LIKE")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _is_name_filter_column(column: str) -> bool:
|
|
299
|
+
return column == "name" or column == "job_name" or column.endswith("_name")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _is_status_text_filter_column(column: str) -> bool:
|
|
303
|
+
return column in {"status", "state"} or column.endswith("_status") or column.endswith("_state")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _is_numeric_literal(value: str) -> bool:
|
|
307
|
+
return bool(re.fullmatch(r"-?\d+(?:\.\d+)?", value))
|
|
308
|
+
|
|
309
|
+
|
|
164
310
|
def is_low_quality_sql_for_question(question: str, sql: str) -> bool:
|
|
165
311
|
normalized = re.sub(r"\s+", " ", sql.strip().lower())
|
|
312
|
+
if _is_business_query(question) and _is_single_entity_lookup_sql(sql):
|
|
313
|
+
return True
|
|
166
314
|
brand_filter = _extract_brand_filter(question)
|
|
167
315
|
if brand_filter is not None and _sql_references_brand_table(sql) and not _sql_contains_brand_name_filter(sql, brand_filter[1]):
|
|
168
316
|
return True
|
|
317
|
+
project_name = _extract_project_name(question)
|
|
318
|
+
if project_name is not None and _sql_references_table(sql, "sponge_project") and not _sql_contains_name_filter(sql, project_name):
|
|
319
|
+
return True
|
|
320
|
+
job_name = _extract_job_name_filter(question)
|
|
321
|
+
if job_name is not None and _sql_references_table(sql, "job_basic_info") and not _sql_contains_job_name_filter(sql, job_name):
|
|
322
|
+
return True
|
|
169
323
|
if _is_work_order_detail_query(question):
|
|
170
324
|
if "mvp_applied_jobs_by_helped_account" not in normalized:
|
|
171
325
|
return True
|
|
@@ -352,12 +506,27 @@ def _generate_recruiting_detail_sql(question: str, schema: dict[str, Any], defau
|
|
|
352
506
|
raise SqlGenerationError("job_basic_info is required for recruiting detail query")
|
|
353
507
|
|
|
354
508
|
select_items = _recruiting_detail_select_items(question, schema)
|
|
355
|
-
|
|
509
|
+
city_brand_filter = _extract_city_brand_filter(question)
|
|
510
|
+
location = _extract_location_for_recruiting_job_question(question)
|
|
511
|
+
brand_name = _extract_brand_name(question)
|
|
512
|
+
city_only_location = False
|
|
513
|
+
if city_brand_filter is not None:
|
|
514
|
+
location = city_brand_filter[0]
|
|
515
|
+
city_only_location = True
|
|
516
|
+
if brand_name is None:
|
|
517
|
+
brand_name = city_brand_filter[1]
|
|
518
|
+
location = location if location and _has_recruiting_location_tables(table_names) else None
|
|
519
|
+
brand_name = brand_name if brand_name and "brand" in table_names else None
|
|
520
|
+
from_and_where = _append_job_time_range_condition(
|
|
521
|
+
_recruiting_detail_from_and_where(table_names, location, brand_name, city_only_location),
|
|
522
|
+
question,
|
|
523
|
+
schema,
|
|
524
|
+
)
|
|
356
525
|
order_by = _job_order_by(question, schema)
|
|
357
526
|
sql = f"SELECT DISTINCT {', '.join(select_items)} {from_and_where} {order_by} LIMIT {default_limit}"
|
|
358
527
|
tables = [
|
|
359
528
|
table
|
|
360
|
-
for table in ("job_basic_info", "job_store", "store", "job_salary", "job_hiring_requirement")
|
|
529
|
+
for table in ("job_basic_info", "job_address", "job_store", "store", "province", "city", "region", "brand", "job_salary", "job_hiring_requirement")
|
|
361
530
|
if table in table_names
|
|
362
531
|
]
|
|
363
532
|
return GeneratedSql(sql, tables, [])
|
|
@@ -374,8 +543,8 @@ def _generate_work_order_detail_sql(question: str, schema: dict[str, Any], defau
|
|
|
374
543
|
if not sql:
|
|
375
544
|
raise SqlGenerationError("Work order detail schema example has no SQL")
|
|
376
545
|
sql = re.sub(
|
|
377
|
-
r"b\.name\s
|
|
378
|
-
f"b.name
|
|
546
|
+
r"b\.name\s*(?:=|LIKE)\s*'%?[^']*%?'",
|
|
547
|
+
f"b.name LIKE '%{_escape_sql_string(brand_name)}%'",
|
|
379
548
|
sql,
|
|
380
549
|
count=1,
|
|
381
550
|
flags=re.IGNORECASE,
|
|
@@ -682,17 +851,37 @@ def _match_effect_match_metric_sql(
|
|
|
682
851
|
)
|
|
683
852
|
|
|
684
853
|
|
|
685
|
-
def _recruiting_detail_from_and_where(
|
|
854
|
+
def _recruiting_detail_from_and_where(
|
|
855
|
+
table_names: set[str],
|
|
856
|
+
location: str | None = None,
|
|
857
|
+
brand_name: str | None = None,
|
|
858
|
+
city_only_location: bool = False,
|
|
859
|
+
) -> str:
|
|
686
860
|
parts = ["FROM job_basic_info jbi"]
|
|
861
|
+
if location is not None and "job_address" in table_names:
|
|
862
|
+
parts.append("LEFT JOIN job_address ja ON ja.job_id = jbi.job_id AND ja.delete_at IS NULL")
|
|
687
863
|
if "job_store" in table_names:
|
|
688
864
|
parts.append("LEFT JOIN job_store js ON js.job_basic_info_id = jbi.id AND js.is_deleted = 0")
|
|
689
865
|
if {"job_store", "store"} <= table_names:
|
|
690
866
|
parts.append("LEFT JOIN store s ON s.id = js.store_id AND s.delete_at IS NULL")
|
|
867
|
+
if brand_name is not None and "brand" in table_names:
|
|
868
|
+
parts.append("LEFT JOIN brand b ON b.id = jbi.brand_id AND b.is_delete = 0")
|
|
691
869
|
if "job_salary" in table_names:
|
|
692
870
|
parts.append("LEFT JOIN job_salary jsa ON jsa.job_basic_info_id = jbi.id AND jsa.is_deleted = 0")
|
|
693
871
|
if "job_hiring_requirement" in table_names:
|
|
694
872
|
parts.append("LEFT JOIN job_hiring_requirement jhr ON jhr.job_basic_info_id = jbi.id AND jhr.is_deleted = 0")
|
|
695
|
-
|
|
873
|
+
where = "WHERE jbi.is_deleted = 0 AND jbi.status = 1"
|
|
874
|
+
if location is not None:
|
|
875
|
+
escaped_location = _escape_sql_string(location)
|
|
876
|
+
location_condition = (
|
|
877
|
+
_recruiting_city_condition(escaped_location)
|
|
878
|
+
if city_only_location
|
|
879
|
+
else _recruiting_location_condition(escaped_location)
|
|
880
|
+
)
|
|
881
|
+
where = f"{where} AND {location_condition}"
|
|
882
|
+
if brand_name is not None:
|
|
883
|
+
where = f"{where} AND b.name LIKE '%{_escape_sql_string(brand_name)}%'"
|
|
884
|
+
parts.append(where)
|
|
696
885
|
return " ".join(parts)
|
|
697
886
|
|
|
698
887
|
|
|
@@ -836,6 +1025,17 @@ def _recruiting_location_condition(location: str) -> str:
|
|
|
836
1025
|
)
|
|
837
1026
|
|
|
838
1027
|
|
|
1028
|
+
def _recruiting_city_condition(location: str) -> str:
|
|
1029
|
+
return (
|
|
1030
|
+
"("
|
|
1031
|
+
"EXISTS ("
|
|
1032
|
+
"SELECT 1 FROM city c "
|
|
1033
|
+
f"WHERE ({_city_name_matches(location)}) "
|
|
1034
|
+
"AND (ja.city_id = c.id OR s.city_id = c.id OR FIND_IN_SET(c.id, jbi.city_ids) > 0)"
|
|
1035
|
+
"))"
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
|
|
839
1039
|
def _append_job_time_range_condition(from_and_where: str, question: str, schema: dict[str, Any]) -> str:
|
|
840
1040
|
time_range = _extract_time_range(question, schema)
|
|
841
1041
|
if time_range is None:
|
|
@@ -846,6 +1046,13 @@ def _append_job_time_range_condition(from_and_where: str, question: str, schema:
|
|
|
846
1046
|
)
|
|
847
1047
|
|
|
848
1048
|
|
|
1049
|
+
def _append_job_name_condition(from_and_where: str, question: str) -> str:
|
|
1050
|
+
job_name = _extract_job_name_filter(question)
|
|
1051
|
+
if job_name is None:
|
|
1052
|
+
return from_and_where
|
|
1053
|
+
return f"{from_and_where} AND jbi.job_name LIKE '%{_escape_sql_string(job_name)}%'"
|
|
1054
|
+
|
|
1055
|
+
|
|
849
1056
|
def _extract_time_range(question: str, schema: dict[str, Any] | None = None) -> TimeRange | None:
|
|
850
1057
|
field = _resolve_job_time_field(question, schema or {})
|
|
851
1058
|
if field is None:
|
|
@@ -1073,6 +1280,7 @@ def _extract_location_for_recruiting_job_question(question: str) -> str | None:
|
|
|
1073
1280
|
r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:有)?(?:多少|几|数量|总数).*?(?:在招|招聘)(?:岗位|职位)",
|
|
1074
1281
|
r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:的)?(?:在招|招聘)(?:岗位|职位)",
|
|
1075
1282
|
r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:有|有哪些|有什么).*?(?:在招|招聘)(?:岗位|职位)",
|
|
1283
|
+
r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:的)?(?:岗位|职位)",
|
|
1076
1284
|
r"(?:在|位于)(?P<location>[\u4e00-\u9fff]{2,12}?)(?:的)?(?:岗位|职位)",
|
|
1077
1285
|
]
|
|
1078
1286
|
for pattern in patterns:
|
|
@@ -1099,6 +1307,63 @@ def _is_work_order_detail_query(question: str) -> bool:
|
|
|
1099
1307
|
)
|
|
1100
1308
|
|
|
1101
1309
|
|
|
1310
|
+
def _is_entity_lookup_query(question: str, entity: str) -> bool:
|
|
1311
|
+
if _is_business_query(question):
|
|
1312
|
+
return False
|
|
1313
|
+
if entity == "品牌" and _extract_brand_filter(question) is not None:
|
|
1314
|
+
return True
|
|
1315
|
+
if entity == "项目" and _extract_project_name(question) is not None:
|
|
1316
|
+
return True
|
|
1317
|
+
if re.search(r"(列表|清单|信息|有哪些|哪些|我能看到|可见|能看到|查询.*(?:品牌|项目)|查看.*(?:品牌|项目))", question):
|
|
1318
|
+
return True
|
|
1319
|
+
return bool(re.fullmatch(rf"\s*(?:查询|查看|列出)?\s*{entity}\s*", question))
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def _is_business_query(question: str) -> bool:
|
|
1323
|
+
return bool(
|
|
1324
|
+
re.search(
|
|
1325
|
+
r"(报名|工单|候选人|入职|上岗|在招|招聘|岗位|职位|门店|剩余|转化|漏斗|匹配|统计|多少|几|数量|总数|人数|数)",
|
|
1326
|
+
question,
|
|
1327
|
+
)
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def _is_single_entity_lookup_sql(sql: str) -> bool:
|
|
1332
|
+
normalized = re.sub(r"\s+", " ", sql.strip().lower())
|
|
1333
|
+
table_aliases = _sql_table_aliases(normalized)
|
|
1334
|
+
referenced_tables = set(table_aliases.values())
|
|
1335
|
+
entity_tables = {"brand", "sponge_project", "city", "province", "region", "store"}
|
|
1336
|
+
if len(referenced_tables) != 1 or not referenced_tables <= entity_tables:
|
|
1337
|
+
return False
|
|
1338
|
+
if " limit " not in f" {normalized} ":
|
|
1339
|
+
return False
|
|
1340
|
+
return bool(re.search(r"\b(?:id|name|job_name)\b", normalized))
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
_ENTITY_NAME_CHARS = r"[\u4e00-\u9fffA-Za-z0-9()()·_\-]{1,40}"
|
|
1344
|
+
_ENTITY_SEPARATOR = r"[\s::,,、;;]+"
|
|
1345
|
+
_DIRECT_MUNICIPALITIES = ("上海", "北京", "天津", "重庆")
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def _extract_city_brand_filter(question: str) -> tuple[str, str] | None:
|
|
1349
|
+
if not _is_recruiting_job_question(question):
|
|
1350
|
+
return None
|
|
1351
|
+
text = re.sub(r"(根据|我要|海绵数据|帮我查询|帮我查|帮我|查询|查看|看看|当前|目前|现在|,|,|。|\s)", "", question)
|
|
1352
|
+
patterns = [
|
|
1353
|
+
r"(?P<city>[\u4e00-\u9fff]{2,8}?(?:省|市|区|县))(?P<brand>[\u4e00-\u9fffA-Za-z0-9()()·_\-]{1,40}?)(?:当前|目前|现在)?(?:在招|招聘)(?:岗位|职位)",
|
|
1354
|
+
rf"(?P<city>{'|'.join(_DIRECT_MUNICIPALITIES)})(?P<brand>[\u4e00-\u9fffA-Za-z0-9()()·_\-]{{1,40}}?)(?:当前|目前|现在)?(?:在招|招聘)(?:岗位|职位)",
|
|
1355
|
+
]
|
|
1356
|
+
for pattern in patterns:
|
|
1357
|
+
match = re.search(pattern, text)
|
|
1358
|
+
if match is None:
|
|
1359
|
+
continue
|
|
1360
|
+
location = _clean_location_candidate(match.group("city"), require_location_signal=True)
|
|
1361
|
+
brand = _clean_brand_candidate(match.group("brand"))
|
|
1362
|
+
if location is not None and brand is not None:
|
|
1363
|
+
return location, brand
|
|
1364
|
+
return None
|
|
1365
|
+
|
|
1366
|
+
|
|
1102
1367
|
def _extract_brand_filter(question: str) -> tuple[str, str] | None:
|
|
1103
1368
|
contains_patterns = [
|
|
1104
1369
|
r"品牌(?:名称)?(?:包含|含有|模糊匹配|like)(?P<brand>.+?)(?:,|,|。|;|;|的品牌|品牌信息|包括|返回|$)",
|
|
@@ -1113,8 +1378,9 @@ def _extract_brand_filter(question: str) -> tuple[str, str] | None:
|
|
|
1113
1378
|
return "contains", brand
|
|
1114
1379
|
|
|
1115
1380
|
exact_patterns = [
|
|
1116
|
-
|
|
1117
|
-
|
|
1381
|
+
rf"品牌(?:名称)?(?:为|是|=|等于|:|:)(?:{_ENTITY_SEPARATOR})?(?P<brand>{_ENTITY_NAME_CHARS})",
|
|
1382
|
+
rf"品牌{_ENTITY_SEPARATOR}(?P<brand>{_ENTITY_NAME_CHARS})",
|
|
1383
|
+
rf"(?P<brand>{_ENTITY_NAME_CHARS})(?:{_ENTITY_SEPARATOR})?品牌(?:下|的)?",
|
|
1118
1384
|
]
|
|
1119
1385
|
for pattern in exact_patterns:
|
|
1120
1386
|
match = re.search(pattern, question)
|
|
@@ -1131,30 +1397,94 @@ def _extract_brand_name(question: str) -> str | None:
|
|
|
1131
1397
|
return brand_filter[1] if brand_filter is not None else None
|
|
1132
1398
|
|
|
1133
1399
|
|
|
1400
|
+
def _extract_project_name(question: str) -> str | None:
|
|
1401
|
+
patterns = [
|
|
1402
|
+
r"项目(?:名称)?(?:包含|含有|模糊匹配|like)(?P<name>.+?)(?:,|,|。|;|;|项目信息|包括|返回|$)",
|
|
1403
|
+
rf"项目(?:名称)?(?:为|是|=|等于|:|:)(?:{_ENTITY_SEPARATOR})?(?P<name>{_ENTITY_NAME_CHARS})",
|
|
1404
|
+
rf"项目{_ENTITY_SEPARATOR}(?P<name>{_ENTITY_NAME_CHARS})",
|
|
1405
|
+
rf"(?P<name>{_ENTITY_NAME_CHARS})(?:{_ENTITY_SEPARATOR})?项目(?:下|的)?",
|
|
1406
|
+
]
|
|
1407
|
+
for pattern in patterns:
|
|
1408
|
+
match = re.search(pattern, question, flags=re.IGNORECASE)
|
|
1409
|
+
if match is None:
|
|
1410
|
+
continue
|
|
1411
|
+
project = _clean_entity_candidate(match.group("name"), extra_forbidden=("品牌", "岗位", "职位", "报名"))
|
|
1412
|
+
if project is not None:
|
|
1413
|
+
return project
|
|
1414
|
+
return None
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _extract_job_name_filter(question: str) -> str | None:
|
|
1418
|
+
patterns = [
|
|
1419
|
+
r"(?:岗位|职位)(?:名称)?(?:包含|含有|模糊匹配|like)(?P<name>.+?)(?:,|,|。|;|;|岗位信息|职位信息|包括|返回|$)",
|
|
1420
|
+
rf"(?:岗位|职位)(?:名称)?(?:为|是|=|等于|:|:)(?:{_ENTITY_SEPARATOR})?(?P<name>{_ENTITY_NAME_CHARS})",
|
|
1421
|
+
rf"(?:招聘|在招|招|急招)(?:{_ENTITY_SEPARATOR})?(?P<name>{_ENTITY_NAME_CHARS})(?:岗位|职位)",
|
|
1422
|
+
]
|
|
1423
|
+
for pattern in patterns:
|
|
1424
|
+
match = re.search(pattern, question, flags=re.IGNORECASE)
|
|
1425
|
+
if match is None:
|
|
1426
|
+
continue
|
|
1427
|
+
job_name = _clean_entity_candidate(match.group("name"), extra_forbidden=("品牌", "项目", "门店", "报名"))
|
|
1428
|
+
if job_name is not None:
|
|
1429
|
+
return job_name
|
|
1430
|
+
return None
|
|
1431
|
+
|
|
1432
|
+
|
|
1134
1433
|
def _clean_brand_candidate(value: str) -> str | None:
|
|
1434
|
+
return _clean_entity_candidate(
|
|
1435
|
+
value,
|
|
1436
|
+
extra_forbidden=("岗位", "候选人", "报名", "入职", "在招", "招聘", "数量", "多少"),
|
|
1437
|
+
suffix_pattern=r"(的品牌信息|品牌信息|的品牌|品牌|的|下)$",
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def _clean_entity_candidate(
|
|
1442
|
+
value: str,
|
|
1443
|
+
*,
|
|
1444
|
+
extra_forbidden: tuple[str, ...] = (),
|
|
1445
|
+
suffix_pattern: str = r"(的|下)$",
|
|
1446
|
+
) -> str | None:
|
|
1135
1447
|
brand = re.sub(r"\s+", "", value)
|
|
1136
|
-
brand = brand.strip("\"'
|
|
1448
|
+
brand = brand.strip("\"'“”‘’%::,,、;;")
|
|
1137
1449
|
brand = re.sub(r"^(名称为|名称是|为|是|=|等于)", "", brand)
|
|
1138
|
-
brand = re.sub(
|
|
1139
|
-
brand = brand.strip("\"'
|
|
1140
|
-
|
|
1450
|
+
brand = re.sub(suffix_pattern, "", brand)
|
|
1451
|
+
brand = brand.strip("\"'“”‘’%::,,、;;")
|
|
1452
|
+
forbidden = ("查询", "查看", "给我", "帮我", "根据", "roll-core", "信息", "数据") + extra_forbidden
|
|
1453
|
+
if 2 <= len(brand) <= 40 and not any(term in brand for term in forbidden):
|
|
1141
1454
|
return brand
|
|
1142
1455
|
return None
|
|
1143
1456
|
|
|
1144
1457
|
|
|
1145
1458
|
def _sql_references_brand_table(sql: str) -> bool:
|
|
1146
|
-
return "brand"
|
|
1459
|
+
return _sql_references_table(sql, "brand")
|
|
1147
1460
|
|
|
1148
1461
|
|
|
1149
1462
|
def _sql_contains_brand_name_filter(sql: str, brand_name: str) -> bool:
|
|
1463
|
+
return _sql_contains_name_filter(sql, brand_name)
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def _sql_references_table(sql: str, table_name: str) -> bool:
|
|
1467
|
+
return table_name in _sql_table_aliases(sql.lower()).values()
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def _sql_contains_name_filter(sql: str, name: str) -> bool:
|
|
1150
1471
|
normalized = sql.lower()
|
|
1151
|
-
escaped = re.escape(_escape_sql_string(
|
|
1472
|
+
escaped = re.escape(_escape_sql_string(name).lower())
|
|
1152
1473
|
return bool(
|
|
1153
1474
|
re.search(rf"\b[a-zA-Z_][a-zA-Z0-9_]*\.name\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized)
|
|
1154
1475
|
or re.search(rf"\bname\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized)
|
|
1155
1476
|
)
|
|
1156
1477
|
|
|
1157
1478
|
|
|
1479
|
+
def _sql_contains_job_name_filter(sql: str, job_name: str) -> bool:
|
|
1480
|
+
normalized = sql.lower()
|
|
1481
|
+
escaped = re.escape(_escape_sql_string(job_name).lower())
|
|
1482
|
+
return bool(
|
|
1483
|
+
re.search(rf"\bjbi\.job_name\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized)
|
|
1484
|
+
or re.search(rf"\bjob_name\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized)
|
|
1485
|
+
)
|
|
1486
|
+
|
|
1487
|
+
|
|
1158
1488
|
def _is_monthly_recruiting_conversion_query(question: str) -> bool:
|
|
1159
1489
|
return bool(
|
|
1160
1490
|
re.search(r"(全年|年内|每月|月度)", question)
|
|
@@ -1203,11 +1533,11 @@ def _is_multi_metric_query(question: str) -> bool:
|
|
|
1203
1533
|
|
|
1204
1534
|
|
|
1205
1535
|
def _requires_recruiting_detail_joins(question: str) -> bool:
|
|
1206
|
-
if re.search(r"(
|
|
1536
|
+
if re.search(r"(明细|详情|信息|包含|需要|显示|带上|返回|字段)", question) is None:
|
|
1207
1537
|
return False
|
|
1208
1538
|
return bool(
|
|
1209
1539
|
re.search(
|
|
1210
|
-
r"(
|
|
1540
|
+
r"(门店|门店地址|详细地址|精确地址|地址|薪资|学历|经验|年龄|技能|用人要求|招聘人数|岗位类型|职能类别|工作年限|薪资范围|薪资类型|岗位描述)",
|
|
1211
1541
|
question,
|
|
1212
1542
|
)
|
|
1213
1543
|
)
|
|
@@ -1285,7 +1615,7 @@ def _escape_sql_string(value: str) -> str:
|
|
|
1285
1615
|
def _province_name_matches(location: str) -> str:
|
|
1286
1616
|
literal = f"'{location}'"
|
|
1287
1617
|
return (
|
|
1288
|
-
f"p.name
|
|
1618
|
+
f"p.name LIKE '%{location}%' OR "
|
|
1289
1619
|
"REPLACE(REPLACE(REPLACE(REPLACE(p.name, '特别行政区', ''), '自治区', ''), '省', ''), '市', '') = "
|
|
1290
1620
|
f"REPLACE(REPLACE(REPLACE(REPLACE({literal}, '特别行政区', ''), '自治区', ''), '省', ''), '市', '')"
|
|
1291
1621
|
)
|
|
@@ -1294,7 +1624,7 @@ def _province_name_matches(location: str) -> str:
|
|
|
1294
1624
|
def _city_name_matches(location: str) -> str:
|
|
1295
1625
|
literal = f"'{location}'"
|
|
1296
1626
|
return (
|
|
1297
|
-
f"c.name
|
|
1627
|
+
f"c.name LIKE '%{location}%' OR "
|
|
1298
1628
|
"REPLACE(REPLACE(REPLACE(c.name, '自治州', ''), '地区', ''), '市', '') = "
|
|
1299
1629
|
f"REPLACE(REPLACE(REPLACE({literal}, '自治州', ''), '地区', ''), '市', '')"
|
|
1300
1630
|
)
|
|
@@ -1303,7 +1633,7 @@ def _city_name_matches(location: str) -> str:
|
|
|
1303
1633
|
def _region_name_matches(location: str) -> str:
|
|
1304
1634
|
literal = f"'{location}'"
|
|
1305
1635
|
return (
|
|
1306
|
-
f"r.name
|
|
1636
|
+
f"r.name LIKE '%{location}%' OR "
|
|
1307
1637
|
"REPLACE(REPLACE(REPLACE(r.name, '自治县', ''), '区', ''), '县', '') = "
|
|
1308
1638
|
f"REPLACE(REPLACE(REPLACE({literal}, '自治县', ''), '区', ''), '县', '')"
|
|
1309
1639
|
)
|