@roll-agent/octopus-agent 0.0.10 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/SKILL.md +25 -1
- package/package.json +1 -1
- package/pyproject.toml +4 -2
- package/references/env.yaml +17 -0
- package/scripts/refresh_dictionary.py +67 -0
- package/scripts/verify_binding.py +47 -0
- package/src/octopus_skill/_version.py +1 -1
- package/src/octopus_skill/agent.py +1099 -146
- package/src/octopus_skill/config.py +58 -10
- package/src/octopus_skill/entity_binding.py +523 -0
- package/src/octopus_skill/entity_dictionary.py +59 -0
- package/src/octopus_skill/entity_resolver.py +446 -0
- package/src/octopus_skill/llm_sql_generator.py +3 -0
- package/src/octopus_skill/mcp_client.py +16 -0
- package/src/octopus_skill/octopus_run.py +369 -17
- package/src/octopus_skill/prompt_builder.py +74 -31
- package/src/octopus_skill/schema_context_retriever.py +199 -150
- package/src/octopus_skill/sql_generator.py +432 -801
- package/src/octopus_skill/query_sql_cache.py +0 -72
|
@@ -40,109 +40,10 @@ class SchemaValidationIssue:
|
|
|
40
40
|
message: str
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
@dataclass
|
|
44
|
-
class
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
287
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
319
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 = (
|
|
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
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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:
|