@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.
@@ -40,109 +40,10 @@ class SchemaValidationIssue:
40
40
  message: str
41
41
 
42
42
 
43
- @dataclass
44
- class RuleBasedSqlGenerator:
45
- default_limit: int
46
- max_limit: int
47
-
48
- def generate(
49
- self,
50
- *,
51
- question: str,
52
- schema: dict[str, Any],
53
- ) -> GeneratedSql:
54
- table_names = _table_names(schema)
55
- lowered = question.lower()
56
-
57
- if _is_monthly_recruiting_conversion_query(question):
58
- return _generate_monthly_recruiting_conversion_sql(question, schema, self.default_limit)
59
- if is_recruiting_match_effect_query(question):
60
- return _generate_recruiting_match_effect_sql(question, schema, self.default_limit)
61
- if _is_recruiting_supply_multi_metric_query(question):
62
- return _generate_recruiting_supply_metric_sql(question, schema, self.default_limit)
63
- if _is_work_order_detail_query(question):
64
- return _generate_work_order_detail_sql(question, schema, self.default_limit)
65
- if _is_multi_metric_query(question):
66
- raise SqlGenerationError("Multi-metric query requires schema-driven metric generation")
67
-
68
- if ("品牌" in question or "brand" in lowered) and "brand" in table_names and _is_entity_lookup_query(question, "品牌"):
69
- brand_filter = _extract_brand_filter(question)
70
- brand_where = "b.is_delete = 0"
71
- if brand_filter is not None:
72
- _operator, brand_name = brand_filter
73
- brand_where = f"b.is_delete = 0 AND b.name LIKE '%{_escape_sql_string(brand_name)}%'"
74
- sql = (
75
- "SELECT b.id AS 品牌ID, b.name AS 品牌名称 "
76
- "FROM brand b "
77
- f"WHERE {brand_where} "
78
- f"LIMIT {self.default_limit}"
79
- )
80
- return GeneratedSql(sql, ["brand"], [])
81
-
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)}%'"
87
- sql = (
88
- "SELECT p.id AS 项目ID, p.name AS 项目名称 "
89
- "FROM sponge_project p "
90
- f"WHERE {project_where} "
91
- f"LIMIT {self.default_limit}"
92
- )
93
- return GeneratedSql(sql, ["sponge_project"], [])
94
-
95
- if _is_recruiting_job_question(question) and _requires_recruiting_detail_joins(question):
96
- return _generate_recruiting_detail_sql(question, schema, self.default_limit)
97
-
98
- if _is_recruiting_remaining_signup_query(question):
99
- raise SqlGenerationError("Remaining signup metrics require schema-driven metric generation")
100
-
101
- location = _extract_location_for_recruiting_job_question(question)
102
- if location and _has_recruiting_location_tables(table_names):
103
- escaped_location = _escape_sql_string(location)
104
- from_and_where = _append_job_time_range_condition(
105
- _append_job_name_condition(_recruiting_location_from_and_where(escaped_location), question),
106
- question,
107
- schema,
108
- )
109
- if _question_requests_count(question):
110
- sql = f"SELECT COUNT(DISTINCT jbi.id) AS 在招岗位数量 {from_and_where} LIMIT {self.default_limit}"
111
- else:
112
- select_items = _job_basic_select_items(question, schema)
113
- order_by = _job_order_by(question, schema)
114
- sql = (
115
- f"SELECT DISTINCT {select_items} "
116
- f"{from_and_where} "
117
- f"{order_by} "
118
- f"LIMIT {self.default_limit}"
119
- )
120
- return GeneratedSql(
121
- sql,
122
- ["job_basic_info", "job_address", "job_store", "store", "province", "city", "region"],
123
- [],
124
- )
125
-
126
- if _is_recruiting_job_question(question) and _has_recruiting_tables(table_names):
127
- from_and_where = _append_job_time_range_condition(
128
- _append_job_name_condition(_recruiting_from_and_where(), question),
129
- question,
130
- schema,
131
- )
132
- if _question_requests_count(question):
133
- sql = f"SELECT COUNT(DISTINCT jbi.id) AS 在招岗位数量 {from_and_where} LIMIT {self.default_limit}"
134
- else:
135
- select_items = _job_basic_select_items(question, schema)
136
- order_by = _job_order_by(question, schema)
137
- sql = (
138
- f"SELECT DISTINCT {select_items} "
139
- f"{from_and_where} "
140
- f"{order_by} "
141
- f"LIMIT {self.default_limit}"
142
- )
143
- return GeneratedSql(sql, ["job_basic_info"], [])
144
-
145
- raise SqlGenerationError("No safe SQL template matched the question")
43
+ @dataclass(frozen=True)
44
+ class LowQualitySqlIssue:
45
+ code: str
46
+ message: str
146
47
 
147
48
 
148
49
  def enforce_sql_rules(sql: str, *, max_limit: int) -> None:
@@ -282,16 +183,40 @@ def _select_item_contains_sensitive_field(item: str) -> bool:
282
183
  return False
283
184
 
284
185
 
186
+ _TEXT_EQUALITY_FILTER_RE = re.compile(
187
+ r"(?P<prefix>\b(?:WHERE|AND|OR|HAVING)\s+\(?\s*)"
188
+ r"(?P<column>(?:[a-zA-Z_][A-Za-z0-9_]*\.)?[a-zA-Z_][A-Za-z0-9_]*)"
189
+ r"\s*=\s*'(?P<value>[^']*)'",
190
+ flags=re.IGNORECASE,
191
+ )
192
+
193
+
194
+ def normalize_text_filters_to_like(sql: str) -> str:
195
+ """Rewrite name/status text equality filters to LIKE before rule enforcement."""
196
+
197
+ def replace(match: re.Match[str]) -> str:
198
+ column = match.group("column")
199
+ column_name = column.rsplit(".", 1)[-1].lower()
200
+ value = match.group("value").strip()
201
+ if not _should_normalize_text_filter_to_like(column_name, value):
202
+ return match.group(0)
203
+ like_value = value if value.startswith("%") and value.endswith("%") else f"%{value.strip('%')}%"
204
+ return f"{match.group('prefix')}{column} LIKE '{like_value}'"
205
+
206
+ return _TEXT_EQUALITY_FILTER_RE.sub(replace, sql)
207
+
208
+
209
+ def _should_normalize_text_filter_to_like(column_name: str, value: str) -> bool:
210
+ if not value or _is_numeric_literal(value):
211
+ return False
212
+ return _is_name_filter_column(column_name) or _is_status_text_filter_column(column_name)
213
+
214
+
285
215
  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()
216
+ for match in _TEXT_EQUALITY_FILTER_RE.finditer(sql):
217
+ column = match.group("column").rsplit(".", 1)[-1].lower()
293
218
  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)):
219
+ if _should_normalize_text_filter_to_like(column, value):
295
220
  raise SqlGenerationError("SQL text filters must use LIKE")
296
221
 
297
222
 
@@ -307,31 +232,330 @@ def _is_numeric_literal(value: str) -> bool:
307
232
  return bool(re.fullmatch(r"-?\d+(?:\.\d+)?", value))
308
233
 
309
234
 
310
- def is_low_quality_sql_for_question(question: str, sql: str) -> bool:
235
+ def is_low_quality_sql_for_question(question: str, sql: str, *, schema: dict[str, Any] | None = None) -> bool:
236
+ return low_quality_sql_issue_for_question(question, sql, schema=schema) is not None
237
+
238
+
239
+ def low_quality_sql_issue_for_question(question: str, sql: str, *, schema: dict[str, Any] | None = None) -> LowQualitySqlIssue | None:
311
240
  normalized = re.sub(r"\s+", " ", sql.strip().lower())
312
241
  if _is_business_query(question) and _is_single_entity_lookup_sql(sql):
313
- return True
242
+ return LowQualitySqlIssue(
243
+ code="BUSINESS_QUERY_SINGLE_ENTITY_LOOKUP",
244
+ message="本次查询未执行:这是业务数据查询,但生成的 SQL 退化成了品牌、项目、城市、门店或岗位的单表列表,不能回答当前问题。",
245
+ )
246
+ if _job_status_filter_mismatches_question(question, sql, schema):
247
+ return LowQualitySqlIssue(
248
+ code="JOB_STATUS_ENUM_MISMATCH",
249
+ message="本次查询未执行:你问的是岗位状态相关数据,但生成的 SQL 没有使用 schema 枚举中对应的岗位状态值,可能把未发布、已发布、已下架等状态混在一起。",
250
+ )
251
+ if _plain_job_total_question_has_unrequested_status_filter(question, sql):
252
+ return LowQualitySqlIssue(
253
+ code="JOB_TOTAL_SHOULD_NOT_FILTER_STATUS",
254
+ message="本次查询未执行:你问的是岗位总数,但生成的 SQL 加了岗位状态过滤,会把全部岗位总数误算成特定状态的岗位数量。",
255
+ )
256
+ if _generic_job_count_sql_mismatches_question(question, sql, schema):
257
+ return LowQualitySqlIssue(
258
+ code="JOB_COUNT_ALIAS_MISMATCH",
259
+ message="本次查询未执行:你问的是岗位总数,但生成的 SQL 输出字段是“在招岗位数/已发布岗位数”等状态口径,指标名称和查询口径不一致。",
260
+ )
314
261
  brand_filter = _extract_brand_filter(question)
315
262
  if brand_filter is not None and _sql_references_brand_table(sql) and not _sql_contains_brand_name_filter(sql, brand_filter[1]):
316
- return True
263
+ return LowQualitySqlIssue(
264
+ code="MISSING_BRAND_FILTER",
265
+ message=f"本次查询未执行:你明确指定了品牌“{brand_filter[1]}”,但生成的 SQL 没有按该品牌名称过滤。",
266
+ )
317
267
  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
268
+ if (
269
+ project_name is not None
270
+ and _sql_references_table(sql, "sponge_project")
271
+ and not _sql_contains_name_filter(sql, project_name)
272
+ and not _sql_contains_project_id_filter(sql)
273
+ ):
274
+ return LowQualitySqlIssue(
275
+ code="MISSING_PROJECT_FILTER",
276
+ message=f"本次查询未执行:你明确指定了项目“{project_name}”,但生成的 SQL 没有按该项目名称过滤。",
277
+ )
320
278
  job_name = _extract_job_name_filter(question)
321
279
  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
280
+ return LowQualitySqlIssue(
281
+ code="MISSING_JOB_NAME_FILTER",
282
+ message=f"本次查询未执行:你明确指定了岗位“{job_name}”,但生成的 SQL 没有按该岗位名称过滤。",
283
+ )
284
+ store_name = _extract_store_name_filter(question)
285
+ if store_name is not None and _sql_references_table(sql, "store") and not _sql_contains_store_name_filter(sql, store_name):
286
+ return LowQualitySqlIssue(
287
+ code="MISSING_STORE_FILTER",
288
+ message=f"本次查询未执行:你明确指定了门店“{store_name}”,但生成的 SQL 没有按该门店名称过滤。",
289
+ )
323
290
  if _is_work_order_detail_query(question):
324
291
  if "mvp_applied_jobs_by_helped_account" not in normalized:
325
- return True
292
+ return LowQualitySqlIssue(
293
+ code="MISSING_WORK_ORDER_TABLE",
294
+ message="本次查询未执行:你问的是报名/工单明细,但生成的 SQL 没有使用报名工单表。",
295
+ )
326
296
  if re.search(r"\bfrom\s+brand\b", normalized) and "mvp_applied_jobs_by_helped_account" not in normalized:
327
- return True
297
+ return LowQualitySqlIssue(
298
+ code="WORK_ORDER_QUERY_BRAND_ONLY",
299
+ message="本次查询未执行:你问的是报名/工单明细,但生成的 SQL 只查询了品牌列表。",
300
+ )
328
301
  if _has_work_order_job_id_to_job_pk_join(normalized):
329
- return True
302
+ return LowQualitySqlIssue(
303
+ code="WORK_ORDER_JOB_JOIN_MISMATCH",
304
+ message="本次查询未执行:报名工单和岗位表的关联方式不符合当前 schema 声明。",
305
+ )
330
306
  if "面试成功" in question and "interview_pass_status" in normalized:
331
- return True
307
+ return LowQualitySqlIssue(
308
+ code="INTERVIEW_STATUS_FIELD_MISMATCH",
309
+ message="本次查询未执行:你问的是候选人面试成功状态,但生成的 SQL 使用了不符合当前口径的面试状态字段。",
310
+ )
311
+ return None
312
+
313
+
314
+ def _job_status_filter_mismatches_question(question: str, sql: str, schema: dict[str, Any] | None) -> bool:
315
+ requested_values = _requested_job_status_values(question, schema)
316
+ if requested_values is None:
317
+ return False
318
+ table_aliases = _sql_table_aliases(sql)
319
+ job_aliases = {alias for alias, table in table_aliases.items() if table == "job_basic_info"}
320
+ if not job_aliases:
321
+ return False
322
+ actual_values = _sql_status_filter_values(sql, job_aliases)
323
+ if actual_values is None:
324
+ return True
325
+ return actual_values != requested_values
326
+
327
+
328
+ def _plain_job_total_question_has_unrequested_status_filter(question: str, sql: str) -> bool:
329
+ if not _is_job_count_intent_question(question):
330
+ return False
331
+ if _user_requests_recruiting_job_count(question):
332
+ return False
333
+ if re.search(r"(未发布|已下架|状态包括|状态为|岗位状态)", question):
334
+ return False
335
+ table_aliases = _sql_table_aliases(sql)
336
+ job_aliases = {alias for alias, table in table_aliases.items() if table == "job_basic_info"}
337
+ if not job_aliases:
338
+ return False
339
+ return _sql_status_filter_values(sql, job_aliases) is not None
340
+
341
+
342
+ def _is_job_count_intent_question(question: str) -> bool:
343
+ if re.search(
344
+ r"(岗位|职位).{0,8}(总数|数量|数)|(?:总数|数量|多少|几个).{0,8}(岗位|职位)",
345
+ question,
346
+ ):
347
+ return True
348
+ if re.search(r"(?:查|查询|统计|看).{0,6}总数", question):
349
+ return True
350
+ return bool(re.fullmatch(r"(?:查询|统计)?总数", question.strip()))
351
+
352
+
353
+ def _user_requests_recruiting_job_count(question: str, schema: dict[str, Any] | None = None) -> bool:
354
+ if re.search(r"在招岗位数|在招|已发布|已上架", question):
355
+ return True
356
+ requested_values = _requested_job_status_values(question, schema)
357
+ if requested_values is None:
358
+ return False
359
+ if schema is None:
360
+ return True
361
+ status_enum = _status_enum(schema, "job_basic_info.status")
362
+ if not status_enum:
363
+ return True
364
+ recruiting_values: set[str] = set()
365
+ for item in status_enum:
366
+ value = item.get("value")
367
+ labels = _enum_label_terms(str(item.get("label") or ""))
368
+ if value is None:
369
+ continue
370
+ if any(label in {"在招", "已发布", "已上架"} or "在招" in label for label in labels):
371
+ recruiting_values.add(str(value))
372
+ return bool(requested_values & recruiting_values)
373
+
374
+
375
+ def _preferred_count_alias(question: str) -> str | None:
376
+ if not _is_job_count_intent_question(question):
377
+ return None
378
+ if "在招岗位数" in question:
379
+ return "在招岗位数"
380
+ if re.search(r"岗位总数", question):
381
+ return "岗位总数"
382
+ if re.search(r"职位总数", question):
383
+ return "职位总数"
384
+ if re.search(r"岗位数", question):
385
+ return "岗位数"
386
+ if re.search(r"职位数", question):
387
+ return "职位数"
388
+ if "总数" in question:
389
+ return "总数"
390
+ if re.search(r"(岗位|职位).{0,8}(总数|数量|数)|(?:总数|数量|多少|几个).{0,8}(岗位|职位)", question):
391
+ return "岗位总数"
392
+ return None
393
+
394
+
395
+ def _sql_select_metric_aliases(sql: str) -> list[str]:
396
+ select_list = _top_level_select_list(sql)
397
+ if select_list is None:
398
+ return []
399
+ aliases: list[str] = []
400
+ for item in _split_top_level_select_items(select_list):
401
+ alias_match = re.search(
402
+ r"\s+as\s+(`[^`]+`|\"[^\"]+\"|'[^']+'|[\u4e00-\u9fff][\w\u4e00-\u9fff]*)\s*$",
403
+ item.strip(),
404
+ flags=re.IGNORECASE,
405
+ )
406
+ if alias_match is not None:
407
+ aliases.append(alias_match.group(1).strip("`\"'"))
408
+ return aliases
409
+
410
+
411
+ def _generic_job_count_sql_mismatches_question(
412
+ question: str,
413
+ sql: str,
414
+ schema: dict[str, Any] | None,
415
+ ) -> bool:
416
+ if not _is_job_count_intent_question(question) or not _sql_counts_job_basic_info(sql):
417
+ return False
418
+ preferred_alias = _preferred_count_alias(question)
419
+ aliases = _sql_select_metric_aliases(sql)
420
+ if preferred_alias and aliases and preferred_alias not in aliases:
421
+ if any(alias in {"在招岗位数", "在招岗位", "已发布岗位数"} for alias in aliases):
422
+ return not _user_requests_recruiting_job_count(question, schema)
423
+ if preferred_alias == "总数" and any("在招" in alias for alias in aliases):
424
+ return not _user_requests_recruiting_job_count(question, schema)
425
+ if _user_requests_recruiting_job_count(question, schema):
426
+ return False
427
+ if any("在招" in alias for alias in aliases):
428
+ return True
332
429
  return False
333
430
 
334
431
 
432
+ def _sql_counts_job_basic_info(sql: str) -> bool:
433
+ normalized = re.sub(r"\s+", " ", sql.strip().lower())
434
+ return "count(" in normalized and "job_basic_info" in normalized
435
+
436
+
437
+ def _strip_job_status_filters(sql: str) -> str:
438
+ table_aliases = _sql_table_aliases(sql)
439
+ job_aliases = {alias for alias, table in table_aliases.items() if table == "job_basic_info"}
440
+ if not job_aliases:
441
+ return sql
442
+ alias_pattern = "|".join(re.escape(alias) for alias in sorted(job_aliases, key=len, reverse=True))
443
+ columns = [r"(?<!\.)\bstatus"]
444
+ if alias_pattern:
445
+ columns.append(rf"\b(?:{alias_pattern})\.status")
446
+ cleaned = sql
447
+ for column in columns:
448
+ cleaned = re.sub(
449
+ rf"\s+and\s+{column}\s*=\s*-?\d+\b",
450
+ "",
451
+ cleaned,
452
+ flags=re.IGNORECASE,
453
+ )
454
+ cleaned = re.sub(
455
+ rf"\s+and\s+{column}\s+in\s*\([^)]*\)",
456
+ "",
457
+ cleaned,
458
+ flags=re.IGNORECASE,
459
+ )
460
+ return cleaned
461
+
462
+
463
+ def _replace_primary_metric_alias(sql: str, alias: str) -> str:
464
+ select_list = _top_level_select_list(sql)
465
+ if select_list is None:
466
+ return sql
467
+ items = _split_top_level_select_items(select_list)
468
+ if not items:
469
+ return sql
470
+ last_item = items[-1].strip()
471
+ updated_last = re.sub(
472
+ r"\s+as\s+(`[^`]+`|\"[^\"]+\"|'[^']+'|[\u4e00-\u9fff][\w\u4e00-\u9fff]*)\s*$",
473
+ f" AS {alias}",
474
+ last_item,
475
+ count=1,
476
+ flags=re.IGNORECASE,
477
+ )
478
+ if updated_last == last_item:
479
+ return sql
480
+ items[-1] = updated_last
481
+ rebuilt_select = ", ".join(items)
482
+ return re.sub(
483
+ r"(?is)\bselect\b(.*?)\bfrom\b",
484
+ lambda match: f"SELECT {rebuilt_select} FROM",
485
+ sql,
486
+ count=1,
487
+ )
488
+
489
+
490
+ def normalize_job_count_sql(sql: str, question: str, *, schema: dict[str, Any] | None = None) -> str:
491
+ if not _is_job_count_intent_question(question) or not _sql_counts_job_basic_info(sql):
492
+ return sql
493
+ preferred_alias = _preferred_count_alias(question)
494
+ normalized = sql
495
+ if not _user_requests_recruiting_job_count(question, schema):
496
+ normalized = _strip_job_status_filters(normalized)
497
+ if preferred_alias:
498
+ normalized = _replace_primary_metric_alias(normalized, preferred_alias)
499
+ elif any("在招" in alias for alias in _sql_select_metric_aliases(normalized)):
500
+ normalized = _replace_primary_metric_alias(normalized, "总数")
501
+ elif preferred_alias:
502
+ normalized = _replace_primary_metric_alias(normalized, preferred_alias)
503
+ return normalized
504
+
505
+
506
+ def _requested_job_status_values(question: str, schema: dict[str, Any] | None) -> set[str] | None:
507
+ if not schema:
508
+ return None
509
+ status_enum = _status_enum(schema, "job_basic_info.status")
510
+ if status_enum is None:
511
+ return None
512
+ matched: set[str] = set()
513
+ for item in status_enum:
514
+ value = item.get("value")
515
+ labels = _enum_label_terms(str(item.get("label") or ""))
516
+ if value is None or not labels:
517
+ continue
518
+ if any(label and label in question for label in labels):
519
+ matched.add(str(value))
520
+ return matched or None
521
+
522
+
523
+ def _status_enum(schema: dict[str, Any], field: str) -> list[dict[str, Any]] | None:
524
+ enums = schema.get("enums")
525
+ if not isinstance(enums, list):
526
+ return None
527
+ for enum in enums:
528
+ if not isinstance(enum, dict):
529
+ continue
530
+ if str(enum.get("field") or "").lower() != field.lower():
531
+ continue
532
+ values = enum.get("values")
533
+ if isinstance(values, list):
534
+ return [value for value in values if isinstance(value, dict)]
535
+ return None
536
+
537
+
538
+ def _enum_label_terms(label: str) -> set[str]:
539
+ terms = {label.strip()}
540
+ terms.update(part.strip() for part in re.split(r"[/、,,;;\s]+", label) if part.strip())
541
+ return {term for term in terms if term}
542
+
543
+
544
+ def _sql_status_filter_values(sql: str, job_aliases: set[str]) -> set[str] | None:
545
+ values: set[str] = set()
546
+ alias_pattern = "|".join(re.escape(alias) for alias in sorted(job_aliases, key=len, reverse=True))
547
+ columns = [r"(?<!\.)\bstatus"]
548
+ if alias_pattern:
549
+ columns.append(rf"\b(?:{alias_pattern})\.status")
550
+ for column in columns:
551
+ for match in re.finditer(rf"{column}\s*=\s*(?P<value>-?\d+)\b", sql, flags=re.IGNORECASE):
552
+ values.add(match.group("value"))
553
+ for match in re.finditer(rf"{column}\s+in\s*\((?P<values>[^)]*)\)", sql, flags=re.IGNORECASE):
554
+ parsed = re.findall(r"-?\d+", match.group("values"))
555
+ values.update(parsed)
556
+ return values or None
557
+
558
+
335
559
  def validate_sql_against_schema(sql: str, schema: dict[str, Any]) -> list[SchemaValidationIssue]:
336
560
  issues: list[SchemaValidationIssue] = []
337
561
  table_aliases = _sql_table_aliases(sql)
@@ -500,458 +724,6 @@ def _table_columns(schema: dict[str, Any], table_name: str) -> list[dict[str, An
500
724
  return []
501
725
 
502
726
 
503
- def _generate_recruiting_detail_sql(question: str, schema: dict[str, Any], default_limit: int) -> GeneratedSql:
504
- table_names = _table_names(schema)
505
- if "job_basic_info" not in table_names:
506
- raise SqlGenerationError("job_basic_info is required for recruiting detail query")
507
-
508
- select_items = _recruiting_detail_select_items(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
- )
525
- order_by = _job_order_by(question, schema)
526
- sql = f"SELECT DISTINCT {', '.join(select_items)} {from_and_where} {order_by} LIMIT {default_limit}"
527
- tables = [
528
- table
529
- for table in ("job_basic_info", "job_address", "job_store", "store", "province", "city", "region", "brand", "job_salary", "job_hiring_requirement")
530
- if table in table_names
531
- ]
532
- return GeneratedSql(sql, tables, [])
533
-
534
-
535
- def _generate_work_order_detail_sql(question: str, schema: dict[str, Any], default_limit: int) -> GeneratedSql:
536
- example = _find_work_order_detail_example(schema)
537
- if example is None:
538
- raise SqlGenerationError("Work order detail query requires a schema example")
539
- brand_name = _extract_brand_name(question)
540
- if brand_name is None:
541
- raise SqlGenerationError("Work order detail query requires a brand name")
542
- sql = str(example.get("sql") or "")
543
- if not sql:
544
- raise SqlGenerationError("Work order detail schema example has no SQL")
545
- sql = re.sub(
546
- r"b\.name\s*(?:=|LIKE)\s*'%?[^']*%?'",
547
- f"b.name LIKE '%{_escape_sql_string(brand_name)}%'",
548
- sql,
549
- count=1,
550
- flags=re.IGNORECASE,
551
- )
552
- tables = [str(table) for table in example.get("tables", []) if table]
553
- return GeneratedSql(sql, tables, [])
554
-
555
-
556
- def _find_work_order_detail_example(schema: dict[str, Any]) -> dict[str, Any] | None:
557
- examples = schema.get("examples", [])
558
- if not isinstance(examples, list):
559
- return None
560
- for example in examples:
561
- if not isinstance(example, dict):
562
- continue
563
- text = f"{example.get('question') or ''} {example.get('sql') or ''}"
564
- if (
565
- "mvp_applied_jobs_by_helped_account" in text
566
- and "account_name" in text
567
- and "work_order_status" in text
568
- and "报名" in text
569
- and "候选人状态" in text
570
- ):
571
- return example
572
- return None
573
-
574
-
575
- def _generate_recruiting_supply_metric_sql(question: str, schema: dict[str, Any], default_limit: int) -> GeneratedSql:
576
- table_names = _table_names(schema)
577
- required = {"job_basic_info", "job_store", "store"}
578
- if not required <= table_names:
579
- raise SqlGenerationError("Recruiting supply metrics require job_basic_info, job_store and store")
580
-
581
- select_items: list[str] = []
582
- group_by: list[str] = []
583
- joins = [
584
- "FROM job_basic_info jbi",
585
- "LEFT JOIN job_store js ON js.job_basic_info_id = jbi.id AND js.is_deleted = 0",
586
- "LEFT JOIN store s ON s.id = js.store_id AND s.delete_at IS NULL",
587
- ]
588
- tables = ["job_basic_info", "job_store", "store"]
589
-
590
- if "品牌" in question and "brand" in table_names and _has_column(schema, "job_basic_info", "brand_id"):
591
- joins.append("LEFT JOIN brand b ON b.id = jbi.brand_id")
592
- select_items.extend(["b.id AS 品牌ID", "b.name AS 品牌名称"])
593
- group_by.extend(["b.id", "b.name"])
594
- tables.append("brand")
595
- if "项目" in question and "sponge_project" in table_names and _has_column(schema, "job_basic_info", "organization_id"):
596
- joins.append("LEFT JOIN sponge_project p ON p.id = jbi.organization_id")
597
- select_items.extend(["p.id AS 项目ID", "p.name AS 项目名称"])
598
- group_by.extend(["p.id", "p.name"])
599
- tables.append("sponge_project")
600
- if "城市" in question and "city" in table_names and _has_column(schema, "store", "city_id"):
601
- joins.append("LEFT JOIN city c ON c.id = s.city_id")
602
- select_items.extend(["c.id AS 城市ID", "c.name AS 城市名称"])
603
- group_by.extend(["c.id", "c.name"])
604
- tables.append("city")
605
- if "区域" in question and "region" in table_names and _has_column(schema, "store", "region_id"):
606
- joins.append("LEFT JOIN region r ON r.id = s.region_id")
607
- select_items.extend(["r.id AS 区域ID", "r.name AS 区域名称"])
608
- group_by.extend(["r.id", "r.name"])
609
- tables.append("region")
610
-
611
- if not select_items:
612
- raise SqlGenerationError("Recruiting supply metric query requires at least one grouping dimension")
613
- select_items.extend(
614
- [
615
- "COUNT(DISTINCT jbi.id) AS 在招岗位数",
616
- "SUM(COALESCE(js.requirement_num, 0)) AS 招聘人数",
617
- "COUNT(DISTINCT js.store_id) AS 招聘门店数",
618
- ]
619
- )
620
- from_and_where = _append_job_time_range_condition(" ".join(joins) + " WHERE jbi.is_deleted = 0 AND jbi.status = 1", question, schema)
621
- sql = (
622
- f"SELECT {', '.join(select_items)} {from_and_where} "
623
- f"GROUP BY {', '.join(group_by)} "
624
- "ORDER BY 在招岗位数 DESC "
625
- f"LIMIT {default_limit}"
626
- )
627
- return GeneratedSql(sql, list(dict.fromkeys(tables)), [])
628
-
629
-
630
- def _generate_monthly_recruiting_conversion_sql(question: str, schema: dict[str, Any], default_limit: int) -> GeneratedSql:
631
- table_names = _table_names(schema)
632
- required = {"mvp_applied_jobs_by_helped_account", "job_basic_info", "brand", "sponge_project"}
633
- if not required <= table_names:
634
- raise SqlGenerationError("Monthly recruiting conversion requires work order, job, brand and project tables")
635
- year_bounds = _extract_year_bounds(question)
636
- if year_bounds is None:
637
- raise SqlGenerationError("Monthly recruiting conversion query requires a year range")
638
- start, end = year_bounds
639
- has_store = "store" in table_names
640
- store_join = "LEFT JOIN store st ON st.id = wo.store_id AND st.delete_at IS NULL" if has_store else ""
641
- brand_expr = "COALESCE(jbi.brand_id, st.brand_id)" if has_store else "jbi.brand_id"
642
- project_expr = "COALESCE(jbi.organization_id, st.organization_id)" if has_store else "jbi.organization_id"
643
- tables = ["mvp_applied_jobs_by_helped_account", "job_basic_info", "brand", "sponge_project"]
644
- if has_store:
645
- tables.append("store")
646
-
647
- signup_sql = _monthly_conversion_event_sql(
648
- start=start,
649
- end=end,
650
- date_field="wo.sign_up_time",
651
- brand_expr=brand_expr,
652
- project_expr=project_expr,
653
- store_join=store_join,
654
- signup_value=1,
655
- onboard_value=0,
656
- extra_condition="",
657
- )
658
- onboard_sql = _monthly_conversion_event_sql(
659
- start=start,
660
- end=end,
661
- date_field="wo.on_work_time",
662
- brand_expr=brand_expr,
663
- project_expr=project_expr,
664
- store_join=store_join,
665
- signup_value=0,
666
- onboard_value=1,
667
- extra_condition="AND wo.on_work_status = 1",
668
- )
669
- sql = (
670
- "SELECT monthly.月份 AS 月份, b.id AS 品牌ID, b.name AS 品牌名称, "
671
- "p.id AS 项目ID, p.name AS 项目名称, "
672
- "SUM(monthly.报名人数) AS 报名人数, "
673
- "SUM(monthly.入职人数) AS 入职人数, "
674
- "ROUND(SUM(monthly.入职人数) / NULLIF(SUM(monthly.报名人数), 0), 4) AS 报名转化率, "
675
- "ROUND(SUM(monthly.入职人数) / NULLIF(SUM(monthly.报名人数), 0), 4) AS 入职转化率 "
676
- f"FROM (({signup_sql}) UNION ALL ({onboard_sql})) monthly "
677
- "LEFT JOIN brand b ON b.id = monthly.brand_id "
678
- "LEFT JOIN sponge_project p ON p.id = monthly.project_id "
679
- "GROUP BY monthly.月份, b.id, b.name, p.id, p.name "
680
- "ORDER BY monthly.月份 ASC, 报名人数 DESC, 入职人数 DESC "
681
- f"LIMIT {default_limit}"
682
- )
683
- return GeneratedSql(sql, tables, [])
684
-
685
-
686
- def _monthly_conversion_event_sql(
687
- *,
688
- start: str,
689
- end: str,
690
- date_field: str,
691
- brand_expr: str,
692
- project_expr: str,
693
- store_join: str,
694
- signup_value: int,
695
- onboard_value: int,
696
- extra_condition: str,
697
- ) -> str:
698
- return (
699
- f"SELECT DATE_FORMAT({date_field}, '%Y-%m') AS 月份, "
700
- f"{brand_expr} AS brand_id, {project_expr} AS project_id, "
701
- f"COUNT(DISTINCT wo.id) * {signup_value} AS 报名人数, "
702
- f"COUNT(DISTINCT wo.id) * {onboard_value} AS 入职人数 "
703
- "FROM mvp_applied_jobs_by_helped_account wo "
704
- "JOIN job_basic_info jbi ON jbi.job_id = wo.job_id AND jbi.is_deleted = 0 "
705
- f"{store_join} "
706
- f"WHERE wo.delete_at IS NULL AND {date_field} >= '{start}' AND {date_field} < '{end}' "
707
- f"{extra_condition} "
708
- f"GROUP BY DATE_FORMAT({date_field}, '%Y-%m'), {brand_expr}, {project_expr}"
709
- )
710
-
711
-
712
- def _generate_recruiting_match_effect_sql(question: str, schema: dict[str, Any], default_limit: int) -> GeneratedSql:
713
- table_names = _table_names(schema)
714
- required = {
715
- "job_basic_info",
716
- "job_store",
717
- "store",
718
- "brand",
719
- "sponge_project",
720
- "city",
721
- "mvp_applied_jobs_by_helped_account",
722
- }
723
- if not required <= table_names:
724
- raise SqlGenerationError("Recruiting match effect requires job, store, brand, project, city and work order tables")
725
-
726
- time_bounds = _extract_time_bounds(question)
727
- if time_bounds is None:
728
- raise SqlGenerationError("Recruiting match effect query requires a time range")
729
- start, end = time_bounds
730
- candidate_expr = "wo.c_helped_account_id"
731
- signup_join = ""
732
- signup_city = "st.city_id"
733
- tables = [
734
- "job_basic_info",
735
- "job_store",
736
- "store",
737
- "brand",
738
- "sponge_project",
739
- "city",
740
- "mvp_applied_jobs_by_helped_account",
741
- ]
742
- if "sign_up" in table_names:
743
- signup_join = "LEFT JOIN sign_up su ON su.id = wo.sign_up_id AND su.delete_at IS NULL"
744
- candidate_expr = "COALESCE(wo.c_helped_account_id, su.resume_id)"
745
- signup_city = "COALESCE(st.city_id, su.city_id)"
746
- tables.append("sign_up")
747
-
748
- supply_dimension_sql = _match_effect_supply_dimension_sql(start, end)
749
- match_dimension_sql = _match_effect_match_dimension_sql(start, end, signup_join, signup_city)
750
- supply_metric_sql = _match_effect_supply_metric_sql(start, end)
751
- match_metric_sql = _match_effect_match_metric_sql(start, end, signup_join, signup_city, candidate_expr)
752
-
753
- sql = (
754
- "SELECT b.id AS 品牌ID, b.name AS 品牌名称, p.id AS 项目ID, p.name AS 项目名称, "
755
- "c.id AS 城市ID, c.name AS 城市名称, "
756
- "COALESCE(supply.在招岗位数, 0) AS 在招岗位数, "
757
- "COALESCE(supply.招聘人数, 0) AS 招聘人数, "
758
- "COALESCE(supply.招聘门店数, 0) AS 招聘门店数, "
759
- "COALESCE(match_data.报名人数, 0) AS 报名人数, "
760
- "COALESCE(match_data.入职人数, 0) AS 入职人数, "
761
- "COALESCE(match_data.候选人数, 0) AS 候选人数, "
762
- "COALESCE(match_data.报名人数, 0) - COALESCE(match_data.入职人数, 0) AS 报名入职差, "
763
- "ROUND(COALESCE(match_data.报名人数, 0) / NULLIF(COALESCE(supply.在招岗位数, 0), 0), 2) AS 岗位报名比, "
764
- "ROUND(COALESCE(match_data.候选人数, 0) / NULLIF(COALESCE(supply.在招岗位数, 0), 0), 2) AS 候选岗位比, "
765
- "COALESCE(NULLIF(CONCAT_WS('、', "
766
- "IF(COALESCE(match_data.报名人数, 0) > COALESCE(match_data.入职人数, 0), '报名多但入职少', NULL), "
767
- "IF(COALESCE(supply.在招岗位数, 0) > COALESCE(match_data.报名人数, 0), '在招多但报名少', NULL), "
768
- "IF(COALESCE(match_data.候选人数, 0) > COALESCE(supply.在招岗位数, 0), '候选人多但岗位少', NULL)"
769
- "), ''), '正常') AS 问题类型 "
770
- f"FROM (({supply_dimension_sql}) UNION ({match_dimension_sql})) dimension_base "
771
- "LEFT JOIN brand b ON b.id = dimension_base.brand_id "
772
- "LEFT JOIN sponge_project p ON p.id = dimension_base.project_id "
773
- "LEFT JOIN city c ON c.id = dimension_base.city_id "
774
- f"LEFT JOIN ({supply_metric_sql}) supply ON supply.brand_id <=> dimension_base.brand_id "
775
- "AND supply.project_id <=> dimension_base.project_id AND supply.city_id <=> dimension_base.city_id "
776
- f"LEFT JOIN ({match_metric_sql}) match_data ON match_data.brand_id <=> dimension_base.brand_id "
777
- "AND match_data.project_id <=> dimension_base.project_id AND match_data.city_id <=> dimension_base.city_id "
778
- "WHERE COALESCE(match_data.报名人数, 0) > COALESCE(match_data.入职人数, 0) "
779
- "OR COALESCE(supply.在招岗位数, 0) > COALESCE(match_data.报名人数, 0) "
780
- "OR COALESCE(match_data.候选人数, 0) > COALESCE(supply.在招岗位数, 0) "
781
- "ORDER BY 报名入职差 DESC, 在招岗位数 DESC, 候选岗位比 DESC "
782
- f"LIMIT {default_limit}"
783
- )
784
- return GeneratedSql(sql, list(dict.fromkeys(tables)), [])
785
-
786
-
787
- def _match_effect_supply_dimension_sql(start: str, end: str) -> str:
788
- return (
789
- "SELECT jbi.brand_id AS brand_id, jbi.organization_id AS project_id, st.city_id AS city_id "
790
- "FROM job_basic_info jbi "
791
- "LEFT JOIN job_store js ON js.job_basic_info_id = jbi.id AND js.is_deleted = 0 "
792
- "LEFT JOIN store st ON st.id = js.store_id AND st.delete_at IS NULL "
793
- "WHERE jbi.is_deleted = 0 AND jbi.status = 1 "
794
- f"AND jbi.create_at >= '{start}' AND jbi.create_at < '{end}' "
795
- "GROUP BY jbi.brand_id, jbi.organization_id, st.city_id"
796
- )
797
-
798
-
799
- def _match_effect_match_dimension_sql(start: str, end: str, signup_join: str, signup_city: str) -> str:
800
- return (
801
- f"SELECT jbi.brand_id AS brand_id, jbi.organization_id AS project_id, {signup_city} AS city_id "
802
- "FROM mvp_applied_jobs_by_helped_account wo "
803
- "JOIN job_basic_info jbi ON jbi.job_id = wo.job_id AND jbi.is_deleted = 0 "
804
- f"{signup_join} "
805
- "LEFT JOIN store st ON st.id = wo.store_id AND st.delete_at IS NULL "
806
- "WHERE wo.delete_at IS NULL AND ("
807
- f"(wo.sign_up_time >= '{start}' AND wo.sign_up_time < '{end}') "
808
- f"OR (wo.on_work_time >= '{start}' AND wo.on_work_time < '{end}')"
809
- ") "
810
- f"GROUP BY jbi.brand_id, jbi.organization_id, {signup_city}"
811
- )
812
-
813
-
814
- def _match_effect_supply_metric_sql(start: str, end: str) -> str:
815
- return (
816
- "SELECT jbi.brand_id AS brand_id, jbi.organization_id AS project_id, st.city_id AS city_id, "
817
- "COUNT(DISTINCT jbi.id) AS 在招岗位数, "
818
- "SUM(COALESCE(js.requirement_num, 0)) AS 招聘人数, "
819
- "COUNT(DISTINCT js.store_id) AS 招聘门店数 "
820
- "FROM job_basic_info jbi "
821
- "LEFT JOIN job_store js ON js.job_basic_info_id = jbi.id AND js.is_deleted = 0 "
822
- "LEFT JOIN store st ON st.id = js.store_id AND st.delete_at IS NULL "
823
- "WHERE jbi.is_deleted = 0 AND jbi.status = 1 "
824
- f"AND jbi.create_at >= '{start}' AND jbi.create_at < '{end}' "
825
- "GROUP BY jbi.brand_id, jbi.organization_id, st.city_id"
826
- )
827
-
828
-
829
- def _match_effect_match_metric_sql(
830
- start: str,
831
- end: str,
832
- signup_join: str,
833
- signup_city: str,
834
- candidate_expr: str,
835
- ) -> str:
836
- return (
837
- f"SELECT jbi.brand_id AS brand_id, jbi.organization_id AS project_id, {signup_city} AS city_id, "
838
- f"COUNT(DISTINCT CASE WHEN wo.sign_up_time >= '{start}' AND wo.sign_up_time < '{end}' THEN wo.id END) AS 报名人数, "
839
- "COUNT(DISTINCT CASE WHEN wo.on_work_status = 1 "
840
- f"AND wo.on_work_time >= '{start}' AND wo.on_work_time < '{end}' THEN wo.id END) AS 入职人数, "
841
- f"COUNT(DISTINCT CASE WHEN wo.sign_up_time >= '{start}' AND wo.sign_up_time < '{end}' THEN {candidate_expr} END) AS 候选人数 "
842
- "FROM mvp_applied_jobs_by_helped_account wo "
843
- "JOIN job_basic_info jbi ON jbi.job_id = wo.job_id AND jbi.is_deleted = 0 "
844
- f"{signup_join} "
845
- "LEFT JOIN store st ON st.id = wo.store_id AND st.delete_at IS NULL "
846
- "WHERE wo.delete_at IS NULL AND ("
847
- f"(wo.sign_up_time >= '{start}' AND wo.sign_up_time < '{end}') "
848
- f"OR (wo.on_work_time >= '{start}' AND wo.on_work_time < '{end}')"
849
- ") "
850
- f"GROUP BY jbi.brand_id, jbi.organization_id, {signup_city}"
851
- )
852
-
853
-
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:
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")
863
- if "job_store" in table_names:
864
- parts.append("LEFT JOIN job_store js ON js.job_basic_info_id = jbi.id AND js.is_deleted = 0")
865
- if {"job_store", "store"} <= table_names:
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")
869
- if "job_salary" in table_names:
870
- parts.append("LEFT JOIN job_salary jsa ON jsa.job_basic_info_id = jbi.id AND jsa.is_deleted = 0")
871
- if "job_hiring_requirement" in table_names:
872
- parts.append("LEFT JOIN job_hiring_requirement jhr ON jhr.job_basic_info_id = jbi.id AND jhr.is_deleted = 0")
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)
885
- return " ".join(parts)
886
-
887
-
888
- def _recruiting_detail_select_items(question: str, schema: dict[str, Any]) -> list[str]:
889
- items = [
890
- "jbi.id AS 岗位ID",
891
- "jbi.job_name AS 岗位名称",
892
- ]
893
- for item in (
894
- _select_field_item(schema, "job_basic_info", "jbi", ("岗位类别", "岗位类型", "职能类别"), "岗位类型", ("job_type",)),
895
- _select_field_item(schema, "job_store", "js", ("招聘人数",), "招聘人数", ("requirement_num",)),
896
- _select_field_item(schema, "job_basic_info", "jbi", ("状态", "岗位状态"), "岗位状态", ("status",)),
897
- ):
898
- if item is not None:
899
- items.append(item)
900
- time_range = _extract_time_range(question, schema)
901
- if time_range is not None:
902
- items.append(f"jbi.{time_range.field} AS {time_range.alias}")
903
- if "门店" in question:
904
- for item in (
905
- _select_field_item(schema, "store", "s", ("门店名称",), "门店名称", ("name",)),
906
- _select_field_item(schema, "store", "s", ("门店地址", "详细地址", "精确地址"), "门店地址", ("address", "exact_address")),
907
- _select_field_item(schema, "store", "s", ("门店ID",), "门店ID", ("id",)),
908
- ):
909
- if item is not None:
910
- items.append(item)
911
- if "薪资" in question:
912
- for item in (
913
- _select_field_item(schema, "job_salary", "jsa", ("最低综合薪资", "最低薪资", "薪资范围最低"), "最低薪资", ("min_comprehensive_salary",)),
914
- _select_field_item(schema, "job_salary", "jsa", ("最高综合薪资", "最高薪资", "薪资范围最高"), "最高薪资", ("max_comprehensive_salary",)),
915
- _select_field_item(schema, "job_salary", "jsa", ("薪资类型", "类型", "薪资结构"), "薪资类型", ("type", "salary_period")),
916
- ):
917
- if item is not None:
918
- items.append(item)
919
- if re.search(r"(用人要求|学历|经验|年龄|技能)", question):
920
- for item in (
921
- _select_field_item(schema, "job_hiring_requirement", "jhr", ("学历", "最低学历"), "学历要求", ("education_id",)),
922
- _select_field_item(schema, "job_hiring_requirement", "jhr", ("最少工作时长", "工作经验", "工作年限"), "经验要求", ("min_work_time",)),
923
- _select_age_requirement_item(schema),
924
- _select_field_item(schema, "job_hiring_requirement", "jhr", ("软性技能", "技能要求"), "技能要求", ("soft_skill",)),
925
- ):
926
- if item is not None:
927
- items.append(item)
928
- if re.search(r"(岗位描述|工作内容|技能要求)", question):
929
- item = _select_field_item(schema, "job_basic_info", "jbi", ("工作内容", "岗位描述"), "岗位描述", ("job_content",))
930
- if item is not None:
931
- items.append(item)
932
- return list(dict.fromkeys(items))
933
-
934
-
935
- def _select_age_requirement_item(schema: dict[str, Any]) -> str | None:
936
- if _has_column(schema, "job_hiring_requirement", "min_age") and _has_column(schema, "job_hiring_requirement", "max_age"):
937
- return "CONCAT(jhr.min_age, '-', jhr.max_age) AS 年龄要求"
938
- return _select_field_item(schema, "job_hiring_requirement", "jhr", ("年龄",), "年龄要求", ("min_age", "max_age"))
939
-
940
-
941
- def _select_field_item(
942
- schema: dict[str, Any],
943
- table_name: str,
944
- table_alias: str,
945
- terms: tuple[str, ...],
946
- output_alias: str,
947
- fallback_names: tuple[str, ...],
948
- ) -> str | None:
949
- column = _select_column(schema, table_name, terms, fallback_names)
950
- if column is None:
951
- return None
952
- return f"{table_alias}.{column} AS {output_alias}"
953
-
954
-
955
727
  def _select_column(
956
728
  schema: dict[str, Any],
957
729
  table_name: str,
@@ -983,76 +755,6 @@ def _has_column(schema: dict[str, Any], table_name: str, column_name: str) -> bo
983
755
  return any((column.get("columnName") or column.get("name")) == column_name for column in _table_columns(schema, table_name))
984
756
 
985
757
 
986
- def _has_recruiting_location_tables(table_names: set[str]) -> bool:
987
- required = {"job_basic_info", "job_address", "job_store", "store", "province", "city", "region"}
988
- return required <= table_names
989
-
990
-
991
- def _has_recruiting_tables(table_names: set[str]) -> bool:
992
- return "job_basic_info" in table_names
993
-
994
-
995
- def _recruiting_from_and_where() -> str:
996
- return "FROM job_basic_info jbi WHERE jbi.is_deleted = 0 AND jbi.status = 1"
997
-
998
-
999
- def _recruiting_location_from_and_where(location: str) -> str:
1000
- return (
1001
- "FROM job_basic_info jbi "
1002
- "LEFT JOIN job_address ja ON ja.job_id = jbi.job_id AND ja.delete_at IS NULL "
1003
- "LEFT JOIN job_store js ON js.job_basic_info_id = jbi.id AND js.is_deleted = 0 "
1004
- "LEFT JOIN store s ON s.id = js.store_id AND s.delete_at IS NULL "
1005
- f"WHERE jbi.is_deleted = 0 AND jbi.status = 1 AND {_recruiting_location_condition(location)}"
1006
- )
1007
-
1008
-
1009
- def _recruiting_location_condition(location: str) -> str:
1010
- return (
1011
- "("
1012
- "EXISTS ("
1013
- "SELECT 1 FROM province p JOIN city c ON c.province_id = p.id "
1014
- f"WHERE ({_province_name_matches(location)}) "
1015
- "AND (ja.city_id = c.id OR s.city_id = c.id OR FIND_IN_SET(c.id, jbi.city_ids) > 0)"
1016
- ") OR EXISTS ("
1017
- "SELECT 1 FROM city c "
1018
- f"WHERE ({_city_name_matches(location)}) "
1019
- "AND (ja.city_id = c.id OR s.city_id = c.id OR FIND_IN_SET(c.id, jbi.city_ids) > 0)"
1020
- ") OR EXISTS ("
1021
- "SELECT 1 FROM region r "
1022
- f"WHERE ({_region_name_matches(location)}) "
1023
- "AND (ja.region_id = r.id OR s.region_id = r.id)"
1024
- "))"
1025
- )
1026
-
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
-
1039
- def _append_job_time_range_condition(from_and_where: str, question: str, schema: dict[str, Any]) -> str:
1040
- time_range = _extract_time_range(question, schema)
1041
- if time_range is None:
1042
- return from_and_where
1043
- return (
1044
- f"{from_and_where} AND jbi.{time_range.field} >= '{time_range.start}' "
1045
- f"AND jbi.{time_range.field} < '{time_range.end}'"
1046
- )
1047
-
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
-
1056
758
  def _extract_time_range(question: str, schema: dict[str, Any] | None = None) -> TimeRange | None:
1057
759
  field = _resolve_job_time_field(question, schema or {})
1058
760
  if field is None:
@@ -1063,29 +765,6 @@ def _extract_time_range(question: str, schema: dict[str, Any] | None = None) ->
1063
765
  return TimeRange(field=field.name, alias=field.alias, start=bounds[0], end=bounds[1])
1064
766
 
1065
767
 
1066
- def _job_basic_select_items(question: str, schema: dict[str, Any]) -> str:
1067
- items = [
1068
- "jbi.id AS 岗位ID",
1069
- "jbi.job_name AS 岗位名称",
1070
- "jbi.job_content AS 工作内容",
1071
- ]
1072
- time_range = _extract_time_range(question, schema)
1073
- if time_range is not None:
1074
- items.append(f"jbi.{time_range.field} AS {time_range.alias}")
1075
- else:
1076
- items.append("jbi.publish_at AS 发布时间")
1077
- items.append("jbi.status AS 状态")
1078
- return ", ".join(items)
1079
-
1080
-
1081
- def _job_order_by(question: str, schema: dict[str, Any]) -> str:
1082
- time_range = _extract_time_range(question, schema)
1083
- if time_range is not None:
1084
- direction = "ASC" if re.search(r"(正序|升序|从早到晚)", question) else "DESC"
1085
- return f"ORDER BY jbi.{time_range.field} {direction}"
1086
- return "ORDER BY jbi.publish_at DESC"
1087
-
1088
-
1089
768
  def _resolve_job_time_field(question: str, schema: dict[str, Any]) -> FieldRef | None:
1090
769
  columns = _table_columns(schema, "job_basic_info")
1091
770
  question_text = _normalize_semantic_text(question)
@@ -1202,23 +881,6 @@ def _extract_time_bounds(question: str) -> tuple[str, str] | None:
1202
881
  return _month_bounds(year, month)
1203
882
 
1204
883
 
1205
- def _extract_year_bounds(question: str) -> tuple[str, str] | None:
1206
- relative_year_match = re.search(r"(?P<relative_year>今年|去年|前年|明年|后年)(?:全年|年内|全年内)?", question)
1207
- if relative_year_match is not None:
1208
- year = _today().year + _relative_year_offset(relative_year_match.group("relative_year"))
1209
- return _year_bounds(year)
1210
- if re.search(r"(全年|年内|全年内)", question):
1211
- return _year_bounds(_today().year)
1212
- year_match = re.search(r"(?P<year>20\d{2})\s*年(?:全年|年内|全年内)?", question)
1213
- if year_match is None:
1214
- return None
1215
- return _year_bounds(int(year_match.group("year")))
1216
-
1217
-
1218
- def _year_bounds(year: int) -> tuple[str, str]:
1219
- return _format_datetime(date(year, 1, 1)), _format_datetime(date(year + 1, 1, 1))
1220
-
1221
-
1222
884
  def _relative_year_offset(relative_year: str) -> int:
1223
885
  offsets = {
1224
886
  "今年": 0,
@@ -1294,10 +956,6 @@ def _is_recruiting_job_question(question: str) -> bool:
1294
956
  return bool(re.search(r"(岗位|职位|招聘|在招)", question))
1295
957
 
1296
958
 
1297
- def _is_recruiting_remaining_signup_query(question: str) -> bool:
1298
- return bool("岗位" in question and "报名" in question and re.search(r"(剩余|还剩|余量|可报名)", question))
1299
-
1300
-
1301
959
  def _is_work_order_detail_query(question: str) -> bool:
1302
960
  return bool(
1303
961
  "品牌" in question
@@ -1307,18 +965,6 @@ def _is_work_order_detail_query(question: str) -> bool:
1307
965
  )
1308
966
 
1309
967
 
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
968
  def _is_business_query(question: str) -> bool:
1323
969
  return bool(
1324
970
  re.search(
@@ -1342,28 +988,13 @@ def _is_single_entity_lookup_sql(sql: str) -> bool:
1342
988
 
1343
989
  _ENTITY_NAME_CHARS = r"[\u4e00-\u9fffA-Za-z0-9()()·_\-]{1,40}"
1344
990
  _ENTITY_SEPARATOR = r"[\s::,,、;;]+"
991
+ _FIELD_LABEL_BOUNDARY = (
992
+ r"(?:\s+(?=门店|品牌|项目|岗位|职位|城市|区域|有哪些|有什么|多少|包括|返回|状态)"
993
+ r"|(?=在(?!招)|位于|有哪些|有什么|多少|包括|返回|状态|岗位|职位|门店|品牌|项目|$|[,,。;;??]))"
994
+ )
1345
995
  _DIRECT_MUNICIPALITIES = ("上海", "北京", "天津", "重庆")
1346
996
 
1347
997
 
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
-
1367
998
  def _extract_brand_filter(question: str) -> tuple[str, str] | None:
1368
999
  contains_patterns = [
1369
1000
  r"品牌(?:名称)?(?:包含|含有|模糊匹配|like)(?P<brand>.+?)(?:,|,|。|;|;|的品牌|品牌信息|包括|返回|$)",
@@ -1380,6 +1011,7 @@ def _extract_brand_filter(question: str) -> tuple[str, str] | None:
1380
1011
  exact_patterns = [
1381
1012
  rf"品牌(?:名称)?(?:为|是|=|等于|:|:)(?:{_ENTITY_SEPARATOR})?(?P<brand>{_ENTITY_NAME_CHARS})",
1382
1013
  rf"品牌{_ENTITY_SEPARATOR}(?P<brand>{_ENTITY_NAME_CHARS})",
1014
+ rf"品牌(?P<brand>{_ENTITY_NAME_CHARS}?)(?={_FIELD_LABEL_BOUNDARY})",
1383
1015
  rf"(?P<brand>{_ENTITY_NAME_CHARS})(?:{_ENTITY_SEPARATOR})?品牌(?:下|的)?",
1384
1016
  ]
1385
1017
  for pattern in exact_patterns:
@@ -1403,17 +1035,27 @@ def _extract_project_name(question: str) -> str | None:
1403
1035
  rf"项目(?:名称)?(?:为|是|=|等于|:|:)(?:{_ENTITY_SEPARATOR})?(?P<name>{_ENTITY_NAME_CHARS})",
1404
1036
  rf"项目{_ENTITY_SEPARATOR}(?P<name>{_ENTITY_NAME_CHARS})",
1405
1037
  rf"(?P<name>{_ENTITY_NAME_CHARS})(?:{_ENTITY_SEPARATOR})?项目(?:下|的)?",
1038
+ rf"项目(?P<name>{_ENTITY_NAME_CHARS})(?={_FIELD_LABEL_BOUNDARY})",
1406
1039
  ]
1407
1040
  for pattern in patterns:
1408
1041
  match = re.search(pattern, question, flags=re.IGNORECASE)
1409
1042
  if match is None:
1410
1043
  continue
1411
- project = _clean_entity_candidate(match.group("name"), extra_forbidden=("品牌", "岗位", "职位", "报名"))
1044
+ project = _clean_project_candidate(match.group("name"))
1412
1045
  if project is not None:
1413
1046
  return project
1414
1047
  return None
1415
1048
 
1416
1049
 
1050
+ def _clean_project_candidate(value: str) -> str | None:
1051
+ return _clean_entity_candidate(
1052
+ value,
1053
+ extra_forbidden=("品牌", "岗位", "职位", "报名"),
1054
+ suffix_pattern=r"((?:在|位于)[\u4e00-\u9fff]{1,20}(?:市|省|区|县)?.*$|(?:的)?(?:岗位|职位).*$|的项目信息|项目信息|的项目|项目|的|下)$",
1055
+ reject_location_like=True,
1056
+ )
1057
+
1058
+
1417
1059
  def _extract_job_name_filter(question: str) -> str | None:
1418
1060
  patterns = [
1419
1061
  r"(?:岗位|职位)(?:名称)?(?:包含|含有|模糊匹配|like)(?P<name>.+?)(?:,|,|。|;|;|岗位信息|职位信息|包括|返回|$)",
@@ -1430,11 +1072,33 @@ def _extract_job_name_filter(question: str) -> str | None:
1430
1072
  return None
1431
1073
 
1432
1074
 
1075
+ def _extract_store_name_filter(question: str) -> str | None:
1076
+ patterns = [
1077
+ r"门店(?:名称)?(?:包含|含有|模糊匹配|like)(?P<name>.+?)(?:,|,|。|;|;|门店信息|包括|返回|$)",
1078
+ rf"门店(?:名称)?(?:为|是|=|等于|:|:)(?:{_ENTITY_SEPARATOR})?(?P<name>{_ENTITY_NAME_CHARS})",
1079
+ rf"(?<!所属)门店(?P<name>{_ENTITY_NAME_CHARS})(?=[,,。;;??\s]|有哪些|有什么|多少|岗位|职位|在招|招聘|$)",
1080
+ rf"(?P<name>{_ENTITY_NAME_CHARS})(?:{_ENTITY_SEPARATOR})?的门店",
1081
+ ]
1082
+ for pattern in patterns:
1083
+ match = re.search(pattern, question, flags=re.IGNORECASE)
1084
+ if match is None:
1085
+ continue
1086
+ store_name = _clean_entity_candidate(
1087
+ match.group("name"),
1088
+ extra_forbidden=("品牌", "项目", "岗位", "职位", "报名", "薪资", "学历", "学历要求", "年龄要求"),
1089
+ suffix_pattern=r"((?:的)?(?:岗位|职位).*$|门店信息|的门店|门店|的|下)$",
1090
+ reject_location_like=True,
1091
+ )
1092
+ if store_name is not None:
1093
+ return store_name
1094
+ return None
1095
+
1096
+
1433
1097
  def _clean_brand_candidate(value: str) -> str | None:
1434
1098
  return _clean_entity_candidate(
1435
1099
  value,
1436
1100
  extra_forbidden=("岗位", "候选人", "报名", "入职", "在招", "招聘", "数量", "多少"),
1437
- suffix_pattern=r"(的品牌信息|品牌信息|的品牌|品牌|的|下)$",
1101
+ suffix_pattern=r"((?:的)?(?:岗位|职位).*$|的品牌信息|品牌信息|的品牌|品牌|的|下)$",
1438
1102
  )
1439
1103
 
1440
1104
 
@@ -1443,18 +1107,45 @@ def _clean_entity_candidate(
1443
1107
  *,
1444
1108
  extra_forbidden: tuple[str, ...] = (),
1445
1109
  suffix_pattern: str = r"(的|下)$",
1110
+ reject_location_like: bool = False,
1446
1111
  ) -> str | None:
1447
1112
  brand = re.sub(r"\s+", "", value)
1448
1113
  brand = brand.strip("\"'“”‘’%::,,、;;")
1449
1114
  brand = re.sub(r"^(名称为|名称是|为|是|=|等于)", "", brand)
1450
1115
  brand = re.sub(suffix_pattern, "", brand)
1451
1116
  brand = brand.strip("\"'“”‘’%::,,、;;")
1452
- forbidden = ("查询", "查看", "给我", "帮我", "根据", "roll-core", "信息", "数据") + extra_forbidden
1117
+ forbidden = (
1118
+ "查询",
1119
+ "查看",
1120
+ "给我",
1121
+ "帮我",
1122
+ "根据",
1123
+ "roll-core",
1124
+ "信息",
1125
+ "数据",
1126
+ "列表",
1127
+ "明细",
1128
+ "情况",
1129
+ "哪些",
1130
+ "所有",
1131
+ "全部",
1132
+ "我能看到",
1133
+ ) + extra_forbidden
1134
+ if reject_location_like and _is_location_like_entity_name(brand):
1135
+ return None
1453
1136
  if 2 <= len(brand) <= 40 and not any(term in brand for term in forbidden):
1454
1137
  return brand
1455
1138
  return None
1456
1139
 
1457
1140
 
1141
+ def _is_location_like_entity_name(value: str) -> bool:
1142
+ return bool(
1143
+ re.fullmatch(r"在[\u4e00-\u9fff]{1,20}(?:市|省|区|县)?", value)
1144
+ or re.fullmatch(r"位于[\u4e00-\u9fff]{1,20}(?:市|省|区|县)?", value)
1145
+ or value.startswith(("在", "位于"))
1146
+ )
1147
+
1148
+
1458
1149
  def _sql_references_brand_table(sql: str) -> bool:
1459
1150
  return _sql_references_table(sql, "brand")
1460
1151
 
@@ -1476,6 +1167,16 @@ def _sql_contains_name_filter(sql: str, name: str) -> bool:
1476
1167
  )
1477
1168
 
1478
1169
 
1170
+ def _sql_contains_project_id_filter(sql: str) -> bool:
1171
+ normalized = sql.lower()
1172
+ table_aliases = _sql_table_aliases(normalized)
1173
+ project_aliases = [alias for alias, table in table_aliases.items() if table == "sponge_project"]
1174
+ if not project_aliases:
1175
+ return False
1176
+ alias_pattern = "|".join(re.escape(alias) for alias in sorted(set(project_aliases), key=len, reverse=True))
1177
+ return bool(re.search(rf"\b(?:{alias_pattern})\.id\s*=\s*\d+\b", normalized))
1178
+
1179
+
1479
1180
  def _sql_contains_job_name_filter(sql: str, job_name: str) -> bool:
1480
1181
  normalized = sql.lower()
1481
1182
  escaped = re.escape(_escape_sql_string(job_name).lower())
@@ -1485,15 +1186,16 @@ def _sql_contains_job_name_filter(sql: str, job_name: str) -> bool:
1485
1186
  )
1486
1187
 
1487
1188
 
1488
- def _is_monthly_recruiting_conversion_query(question: str) -> bool:
1489
- return bool(
1490
- re.search(r"(全年|年内|每月|月度)", question)
1491
- and re.search(r"(报名人数|报名数)", question)
1492
- and re.search(r"(入职人数|入职数|上岗人数|上岗数)", question)
1493
- and re.search(r"(转化率|转化)", question)
1494
- and "品牌" in question
1495
- and "项目" in question
1496
- )
1189
+ def _sql_contains_store_name_filter(sql: str, store_name: str) -> bool:
1190
+ normalized = sql.lower()
1191
+ escaped = re.escape(_escape_sql_string(store_name).lower())
1192
+ table_aliases = _sql_table_aliases(normalized)
1193
+ store_aliases = [alias for alias, table in table_aliases.items() if table == "store"]
1194
+ if store_aliases:
1195
+ alias_pattern = "|".join(re.escape(alias) for alias in sorted(set(store_aliases), key=len, reverse=True))
1196
+ if re.search(rf"\b(?:{alias_pattern})\.name\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized):
1197
+ return True
1198
+ return bool(re.search(rf"\bstore_name\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized))
1497
1199
 
1498
1200
 
1499
1201
  def is_recruiting_match_effect_query(question: str) -> bool:
@@ -1505,50 +1207,6 @@ def is_recruiting_match_effect_query(question: str) -> bool:
1505
1207
  )
1506
1208
 
1507
1209
 
1508
- def _is_recruiting_supply_multi_metric_query(question: str) -> bool:
1509
- return bool(
1510
- _is_multi_metric_query(question)
1511
- and re.search(r"(在招岗位数|岗位供给|招聘人数|招聘门店数)", question)
1512
- and re.search(r"(品牌|项目|城市|区域)", question)
1513
- )
1514
-
1515
-
1516
- def _is_multi_metric_query(question: str) -> bool:
1517
- metric_terms = (
1518
- "岗位供给数量",
1519
- "候选人供给数量",
1520
- "报名人数",
1521
- "入职人数",
1522
- "报名转化率",
1523
- "入职转化率",
1524
- "在招岗位数",
1525
- "招聘人数",
1526
- "招聘门店数",
1527
- )
1528
- dimension_terms = ("品牌", "项目", "城市", "区域")
1529
- metric_count = sum(1 for term in metric_terms if term in question)
1530
- dimension_count = sum(1 for term in dimension_terms if term in question)
1531
- has_list_separator = bool(re.search(r"[、,,]", question))
1532
- return (metric_count >= 2) or (metric_count >= 1 and dimension_count >= 2 and has_list_separator)
1533
-
1534
-
1535
- def _requires_recruiting_detail_joins(question: str) -> bool:
1536
- if re.search(r"(明细|详情|信息|包含|需要|显示|带上|返回|字段)", question) is None:
1537
- return False
1538
- return bool(
1539
- re.search(
1540
- r"(门店|门店地址|详细地址|精确地址|地址|薪资|学历|经验|年龄|技能|用人要求|招聘人数|岗位类型|职能类别|工作年限|薪资范围|薪资类型|岗位描述)",
1541
- question,
1542
- )
1543
- )
1544
-
1545
-
1546
- def _question_requests_count(question: str) -> bool:
1547
- if re.search(r"(每个|各个|分别|列表|明细|详情|有哪些|有什么|剩余|还剩|所有.*(?:岗位|职位)|全部.*(?:岗位|职位))", question):
1548
- return False
1549
- return bool(re.search(r"(多少|几|数量|总数|count|统计)", question, flags=re.IGNORECASE))
1550
-
1551
-
1552
1210
  def _clean_location_candidate(candidate: str, *, require_location_signal: bool = False) -> str | None:
1553
1211
  cleaned = re.sub(r"[,,。;;??\s]", "", candidate)
1554
1212
  cleaned = re.sub(r"(有哪些|有什么|所有|全部|每个|各个|多少|数量|列表|明细|信息|数据|情况|都|的|有)$", "", cleaned)
@@ -1612,33 +1270,6 @@ def _escape_sql_string(value: str) -> str:
1612
1270
  return value.replace("\\", "\\\\").replace("'", "''")
1613
1271
 
1614
1272
 
1615
- def _province_name_matches(location: str) -> str:
1616
- literal = f"'{location}'"
1617
- return (
1618
- f"p.name LIKE '%{location}%' OR "
1619
- "REPLACE(REPLACE(REPLACE(REPLACE(p.name, '特别行政区', ''), '自治区', ''), '省', ''), '市', '') = "
1620
- f"REPLACE(REPLACE(REPLACE(REPLACE({literal}, '特别行政区', ''), '自治区', ''), '省', ''), '市', '')"
1621
- )
1622
-
1623
-
1624
- def _city_name_matches(location: str) -> str:
1625
- literal = f"'{location}'"
1626
- return (
1627
- f"c.name LIKE '%{location}%' OR "
1628
- "REPLACE(REPLACE(REPLACE(c.name, '自治州', ''), '地区', ''), '市', '') = "
1629
- f"REPLACE(REPLACE(REPLACE({literal}, '自治州', ''), '地区', ''), '市', '')"
1630
- )
1631
-
1632
-
1633
- def _region_name_matches(location: str) -> str:
1634
- literal = f"'{location}'"
1635
- return (
1636
- f"r.name LIKE '%{location}%' OR "
1637
- "REPLACE(REPLACE(REPLACE(r.name, '自治县', ''), '区', ''), '县', '') = "
1638
- f"REPLACE(REPLACE(REPLACE({literal}, '自治县', ''), '区', ''), '县', '')"
1639
- )
1640
-
1641
-
1642
1273
  def _enforce_chinese_select_aliases(sql: str) -> None:
1643
1274
  select_list = _top_level_select_list(sql)
1644
1275
  if select_list is None: