@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.
@@ -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
- operator, brand_name = brand_filter
73
- if operator == "contains":
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 p.is_delete = 0 "
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(_recruiting_from_and_where(), question, schema)
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
- from_and_where = _append_job_time_range_condition(_recruiting_detail_from_and_where(table_names), question, schema)
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*=\s*'[^']*'",
378
- f"b.name = '{_escape_sql_string(brand_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(table_names: set[str]) -> str:
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
- parts.append("WHERE jbi.is_deleted = 0 AND jbi.status = 1")
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
- r"品牌(?:名称)?(?:为|是|=|等于)(?P<brand>.+?)(?:,|,|。|;|;|候选人|工单|报名|提供|输出|通过|包括|返回|$)",
1117
- r"(?P<brand>[\u4e00-\u9fffA-Za-z0-9()()·_\-]{2,40})品牌(?:下|的)?",
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(r"(的品牌信息|品牌信息|的品牌|品牌|的|下)$", "", brand)
1139
- brand = brand.strip("\"'“”‘’%")
1140
- if 2 <= len(brand) <= 40 and re.search(r"(查询|查看|给我|帮我|根据|roll-core|岗位|候选人|报名|入职|在招|招聘|数量|多少)", brand) is None:
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" in _sql_table_aliases(sql.lower()).values()
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(brand_name).lower())
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"(明细|详情|包含|需要|显示|带上|返回)", question) is None:
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 = {literal} OR "
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 = {literal} OR "
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 = {literal} OR "
1636
+ f"r.name LIKE '%{location}%' OR "
1307
1637
  "REPLACE(REPLACE(REPLACE(r.name, '自治县', ''), '区', ''), '县', '') = "
1308
1638
  f"REPLACE(REPLACE(REPLACE({literal}, '自治县', ''), '区', ''), '县', '')"
1309
1639
  )