@roll-agent/octopus-agent 0.0.1 → 0.0.2
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 +18 -8
- package/SKILL.md +20 -5
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/octopus_sponge_skill/_version.py +1 -1
- package/src/octopus_sponge_skill/agent.py +370 -82
- package/src/octopus_sponge_skill/config.py +5 -1
- package/src/octopus_sponge_skill/context.py +0 -3
- package/src/octopus_sponge_skill/device_info.py +7 -0
- package/src/octopus_sponge_skill/llm_sql_generator.py +23 -6
- package/src/octopus_sponge_skill/main.py +0 -4
- package/src/octopus_sponge_skill/mcp_client.py +7 -25
- package/src/octopus_sponge_skill/prompt_builder.py +42 -15
- package/src/octopus_sponge_skill/query_sql_cache.py +72 -0
- package/src/octopus_sponge_skill/result_renderer.py +118 -2
- package/src/octopus_sponge_skill/roll_server.py +40 -25
- package/src/octopus_sponge_skill/schema_cache.py +6 -0
- package/src/octopus_sponge_skill/schema_context_retriever.py +112 -7
- package/src/octopus_sponge_skill/sql_generator.py +1356 -16
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from datetime import date, timedelta
|
|
3
4
|
import re
|
|
4
5
|
from dataclasses import dataclass
|
|
5
6
|
from typing import Any
|
|
@@ -14,6 +15,29 @@ class GeneratedSql:
|
|
|
14
15
|
sql: str
|
|
15
16
|
tables: list[str]
|
|
16
17
|
placeholders: list[str]
|
|
18
|
+
used_columns: list[str] | None = None
|
|
19
|
+
used_joins: list[str] | None = None
|
|
20
|
+
metric_refs: list[str] | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TimeRange:
|
|
25
|
+
field: str
|
|
26
|
+
alias: str
|
|
27
|
+
start: str
|
|
28
|
+
end: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class FieldRef:
|
|
33
|
+
name: str
|
|
34
|
+
alias: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class SchemaValidationIssue:
|
|
39
|
+
code: str
|
|
40
|
+
message: str
|
|
17
41
|
|
|
18
42
|
|
|
19
43
|
@dataclass
|
|
@@ -26,30 +50,92 @@ class RuleBasedSqlGenerator:
|
|
|
26
50
|
*,
|
|
27
51
|
question: str,
|
|
28
52
|
schema: dict[str, Any],
|
|
29
|
-
scope_hints: dict[str, Any],
|
|
30
53
|
) -> GeneratedSql:
|
|
31
54
|
table_names = _table_names(schema)
|
|
32
55
|
lowered = question.lower()
|
|
33
56
|
|
|
34
|
-
if (
|
|
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:
|
|
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
|
+
if operator == "contains":
|
|
74
|
+
brand_where = f"b.is_delete = 0 AND b.name LIKE '%{_escape_sql_string(brand_name)}%'"
|
|
75
|
+
else:
|
|
76
|
+
brand_where = f"b.is_delete = 0 AND b.name = '{_escape_sql_string(brand_name)}'"
|
|
35
77
|
sql = (
|
|
36
|
-
"SELECT b.id AS
|
|
78
|
+
"SELECT b.id AS 品牌ID, b.name AS 品牌名称 "
|
|
37
79
|
"FROM brand b "
|
|
38
|
-
"
|
|
39
|
-
"WHERE bp.project_id IN (:scope.project_ids) "
|
|
40
|
-
"AND b.id IN (:scope.brand_ids) "
|
|
80
|
+
f"WHERE {brand_where} "
|
|
41
81
|
f"LIMIT {self.default_limit}"
|
|
42
82
|
)
|
|
43
|
-
return GeneratedSql(sql, ["brand",
|
|
83
|
+
return GeneratedSql(sql, ["brand"], [])
|
|
44
84
|
|
|
45
85
|
if ("项目" in question or "project" in lowered) and "sponge_project" in table_names:
|
|
46
86
|
sql = (
|
|
47
|
-
"SELECT p.id AS
|
|
87
|
+
"SELECT p.id AS 项目ID, p.name AS 项目名称 "
|
|
48
88
|
"FROM sponge_project p "
|
|
49
|
-
"WHERE p.
|
|
89
|
+
"WHERE p.is_delete = 0 "
|
|
50
90
|
f"LIMIT {self.default_limit}"
|
|
51
91
|
)
|
|
52
|
-
return GeneratedSql(sql, ["sponge_project"], [
|
|
92
|
+
return GeneratedSql(sql, ["sponge_project"], [])
|
|
93
|
+
|
|
94
|
+
if _is_recruiting_job_question(question) and _requires_recruiting_detail_joins(question):
|
|
95
|
+
return _generate_recruiting_detail_sql(question, schema, self.default_limit)
|
|
96
|
+
|
|
97
|
+
if _is_recruiting_remaining_signup_query(question):
|
|
98
|
+
raise SqlGenerationError("Remaining signup metrics require schema-driven metric generation")
|
|
99
|
+
|
|
100
|
+
location = _extract_location_for_recruiting_job_question(question)
|
|
101
|
+
if location and _has_recruiting_location_tables(table_names):
|
|
102
|
+
escaped_location = _escape_sql_string(location)
|
|
103
|
+
from_and_where = _append_job_time_range_condition(
|
|
104
|
+
_recruiting_location_from_and_where(escaped_location),
|
|
105
|
+
question,
|
|
106
|
+
schema,
|
|
107
|
+
)
|
|
108
|
+
if _question_requests_count(question):
|
|
109
|
+
sql = f"SELECT COUNT(DISTINCT jbi.id) AS 在招岗位数量 {from_and_where} LIMIT {self.default_limit}"
|
|
110
|
+
else:
|
|
111
|
+
select_items = _job_basic_select_items(question, schema)
|
|
112
|
+
order_by = _job_order_by(question, schema)
|
|
113
|
+
sql = (
|
|
114
|
+
f"SELECT DISTINCT {select_items} "
|
|
115
|
+
f"{from_and_where} "
|
|
116
|
+
f"{order_by} "
|
|
117
|
+
f"LIMIT {self.default_limit}"
|
|
118
|
+
)
|
|
119
|
+
return GeneratedSql(
|
|
120
|
+
sql,
|
|
121
|
+
["job_basic_info", "job_address", "job_store", "store", "province", "city", "region"],
|
|
122
|
+
[],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if _is_recruiting_job_question(question) and _has_recruiting_tables(table_names):
|
|
126
|
+
from_and_where = _append_job_time_range_condition(_recruiting_from_and_where(), question, schema)
|
|
127
|
+
if _question_requests_count(question):
|
|
128
|
+
sql = f"SELECT COUNT(DISTINCT jbi.id) AS 在招岗位数量 {from_and_where} LIMIT {self.default_limit}"
|
|
129
|
+
else:
|
|
130
|
+
select_items = _job_basic_select_items(question, schema)
|
|
131
|
+
order_by = _job_order_by(question, schema)
|
|
132
|
+
sql = (
|
|
133
|
+
f"SELECT DISTINCT {select_items} "
|
|
134
|
+
f"{from_and_where} "
|
|
135
|
+
f"{order_by} "
|
|
136
|
+
f"LIMIT {self.default_limit}"
|
|
137
|
+
)
|
|
138
|
+
return GeneratedSql(sql, ["job_basic_info"], [])
|
|
53
139
|
|
|
54
140
|
raise SqlGenerationError("No safe SQL template matched the question")
|
|
55
141
|
|
|
@@ -64,6 +150,10 @@ def enforce_sql_rules(sql: str, *, max_limit: int) -> None:
|
|
|
64
150
|
raise SqlGenerationError("SQL contains a forbidden keyword")
|
|
65
151
|
if re.search(r"\b(password|token|secret|credential|private_key)\b", normalized):
|
|
66
152
|
raise SqlGenerationError("SQL contains a sensitive field")
|
|
153
|
+
if ":scope." in normalized:
|
|
154
|
+
raise SqlGenerationError("SQL must not contain scope placeholders")
|
|
155
|
+
_enforce_chinese_select_aliases(sql)
|
|
156
|
+
_enforce_no_null_placeholder_select_items(sql)
|
|
67
157
|
limit = re.search(r"\blimit\s+(\d+)\b", normalized)
|
|
68
158
|
if limit is None:
|
|
69
159
|
raise SqlGenerationError("SQL must contain LIMIT")
|
|
@@ -71,6 +161,164 @@ def enforce_sql_rules(sql: str, *, max_limit: int) -> None:
|
|
|
71
161
|
raise SqlGenerationError(f"SQL LIMIT must not exceed {max_limit}")
|
|
72
162
|
|
|
73
163
|
|
|
164
|
+
def is_low_quality_sql_for_question(question: str, sql: str) -> bool:
|
|
165
|
+
normalized = re.sub(r"\s+", " ", sql.strip().lower())
|
|
166
|
+
brand_filter = _extract_brand_filter(question)
|
|
167
|
+
if brand_filter is not None and _sql_references_brand_table(sql) and not _sql_contains_brand_name_filter(sql, brand_filter[1]):
|
|
168
|
+
return True
|
|
169
|
+
if _is_work_order_detail_query(question):
|
|
170
|
+
if "mvp_applied_jobs_by_helped_account" not in normalized:
|
|
171
|
+
return True
|
|
172
|
+
if re.search(r"\bfrom\s+brand\b", normalized) and "mvp_applied_jobs_by_helped_account" not in normalized:
|
|
173
|
+
return True
|
|
174
|
+
if _has_work_order_job_id_to_job_pk_join(normalized):
|
|
175
|
+
return True
|
|
176
|
+
if "面试成功" in question and "interview_pass_status" in normalized:
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def validate_sql_against_schema(sql: str, schema: dict[str, Any]) -> list[SchemaValidationIssue]:
|
|
182
|
+
issues: list[SchemaValidationIssue] = []
|
|
183
|
+
table_aliases = _sql_table_aliases(sql)
|
|
184
|
+
schema_tables = _table_names(schema)
|
|
185
|
+
for table in table_aliases.values():
|
|
186
|
+
if table not in schema_tables:
|
|
187
|
+
issues.append(
|
|
188
|
+
SchemaValidationIssue(
|
|
189
|
+
code="UNKNOWN_TABLE",
|
|
190
|
+
message=f"SQL 使用了 schema 中不存在的表:{table}",
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if {"mvp_applied_jobs_by_helped_account", "job_basic_info"} <= set(table_aliases.values()):
|
|
195
|
+
join_pairs = _sql_equality_pairs(sql, table_aliases)
|
|
196
|
+
allowed_pairs = _schema_allowed_join_pairs(schema)
|
|
197
|
+
work_order_job_pairs = {
|
|
198
|
+
pair
|
|
199
|
+
for pair in join_pairs
|
|
200
|
+
if {pair[0][0], pair[1][0]} == {"mvp_applied_jobs_by_helped_account", "job_basic_info"}
|
|
201
|
+
and (pair[0][1] == "job_id" or pair[1][1] == "job_id")
|
|
202
|
+
}
|
|
203
|
+
if not any(pair in allowed_pairs for pair in work_order_job_pairs):
|
|
204
|
+
issues.append(
|
|
205
|
+
SchemaValidationIssue(
|
|
206
|
+
code="JOIN_NOT_IN_SCHEMA",
|
|
207
|
+
message="报名工单和岗位表必须使用 schema 声明的 mvp_applied_jobs_by_helped_account.job_id = job_basic_info.job_id 关联。",
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
return issues
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _has_work_order_job_id_to_job_pk_join(normalized_sql: str) -> bool:
|
|
214
|
+
table_aliases = _sql_table_aliases(normalized_sql)
|
|
215
|
+
pairs = _sql_equality_pairs(normalized_sql, table_aliases)
|
|
216
|
+
return any(
|
|
217
|
+
{left[0], right[0]} == {"mvp_applied_jobs_by_helped_account", "job_basic_info"}
|
|
218
|
+
and (
|
|
219
|
+
(left[0] == "mvp_applied_jobs_by_helped_account" and left[1] == "job_id" and right[1] == "id")
|
|
220
|
+
or (right[0] == "mvp_applied_jobs_by_helped_account" and right[1] == "job_id" and left[1] == "id")
|
|
221
|
+
)
|
|
222
|
+
for left, right in pairs
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _sql_table_aliases(sql: str) -> dict[str, str]:
|
|
227
|
+
aliases: dict[str, str] = {}
|
|
228
|
+
for match in re.finditer(
|
|
229
|
+
r"\b(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\s+(?:as\s+)?([a-zA-Z_][a-zA-Z0-9_]*))?",
|
|
230
|
+
sql,
|
|
231
|
+
flags=re.IGNORECASE,
|
|
232
|
+
):
|
|
233
|
+
table = match.group(1).lower()
|
|
234
|
+
alias = (match.group(2) or table).lower()
|
|
235
|
+
if alias in {"on", "where", "left", "right", "inner", "outer", "join"}:
|
|
236
|
+
alias = table
|
|
237
|
+
aliases[alias] = table
|
|
238
|
+
aliases[table] = table
|
|
239
|
+
return aliases
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _sql_equality_pairs(sql: str, table_aliases: dict[str, str]) -> set[tuple[tuple[str, str], tuple[str, str]]]:
|
|
243
|
+
pairs: set[tuple[tuple[str, str], tuple[str, str]]] = set()
|
|
244
|
+
for match in re.finditer(
|
|
245
|
+
r"\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b",
|
|
246
|
+
sql,
|
|
247
|
+
flags=re.IGNORECASE,
|
|
248
|
+
):
|
|
249
|
+
left_alias, left_column, right_alias, right_column = (part.lower() for part in match.groups())
|
|
250
|
+
left_table = table_aliases.get(left_alias)
|
|
251
|
+
right_table = table_aliases.get(right_alias)
|
|
252
|
+
if left_table is None or right_table is None:
|
|
253
|
+
continue
|
|
254
|
+
pairs.add(_canonical_join_pair((left_table, left_column), (right_table, right_column)))
|
|
255
|
+
return pairs
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _schema_allowed_join_pairs(schema: dict[str, Any]) -> set[tuple[tuple[str, str], tuple[str, str]]]:
|
|
259
|
+
pairs: set[tuple[tuple[str, str], tuple[str, str]]] = set()
|
|
260
|
+
joins = schema.get("joins", [])
|
|
261
|
+
if isinstance(joins, list):
|
|
262
|
+
for join in joins:
|
|
263
|
+
if not isinstance(join, dict):
|
|
264
|
+
continue
|
|
265
|
+
left_table = str(join.get("leftTable") or join.get("from") or "").lower()
|
|
266
|
+
left_column = str(join.get("leftColumn") or join.get("fromColumn") or "").lower()
|
|
267
|
+
right_table = str(join.get("rightTable") or join.get("to") or "").lower()
|
|
268
|
+
right_column = str(join.get("rightColumn") or join.get("toColumn") or "").lower()
|
|
269
|
+
if left_table and left_column and right_table and right_column:
|
|
270
|
+
pairs.add(_canonical_join_pair((left_table, left_column), (right_table, right_column)))
|
|
271
|
+
for section_name in ("metrics", "glossary", "examples"):
|
|
272
|
+
section = schema.get(section_name, [])
|
|
273
|
+
if isinstance(section, list):
|
|
274
|
+
for item in section:
|
|
275
|
+
if isinstance(item, dict):
|
|
276
|
+
pairs.update(_join_pairs_from_text(str(item)))
|
|
277
|
+
return pairs
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _join_pairs_from_text(text: str) -> set[tuple[tuple[str, str], tuple[str, str]]]:
|
|
281
|
+
pairs: set[tuple[tuple[str, str], tuple[str, str]]] = set()
|
|
282
|
+
for match in re.finditer(
|
|
283
|
+
r"\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b",
|
|
284
|
+
text,
|
|
285
|
+
flags=re.IGNORECASE,
|
|
286
|
+
):
|
|
287
|
+
left_table, left_column, right_table, right_column = (part.lower() for part in match.groups())
|
|
288
|
+
pairs.add(_canonical_join_pair((left_table, left_column), (right_table, right_column)))
|
|
289
|
+
return pairs
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _canonical_join_pair(
|
|
293
|
+
left: tuple[str, str],
|
|
294
|
+
right: tuple[str, str],
|
|
295
|
+
) -> tuple[tuple[str, str], tuple[str, str]]:
|
|
296
|
+
return tuple(sorted((left, right))) # type: ignore[return-value]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def question_has_time_range(question: str) -> bool:
|
|
300
|
+
return _extract_time_range(question) is not None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def sql_satisfies_time_range(sql: str, question: str, schema: dict[str, Any] | None = None) -> bool:
|
|
304
|
+
time_range = _extract_time_range(question, schema)
|
|
305
|
+
if time_range is None:
|
|
306
|
+
return True
|
|
307
|
+
normalized = sql.lower()
|
|
308
|
+
field_pattern = rf"(?:\b\w+\.)?{re.escape(time_range.field.lower())}\b"
|
|
309
|
+
if re.search(field_pattern, normalized) is None:
|
|
310
|
+
return False
|
|
311
|
+
if time_range.start[:10] in sql and time_range.end[:10] in sql:
|
|
312
|
+
return True
|
|
313
|
+
year = time_range.start[:4]
|
|
314
|
+
month = str(int(time_range.start[5:7]))
|
|
315
|
+
padded_month = time_range.start[5:7]
|
|
316
|
+
return bool(
|
|
317
|
+
re.search(rf"\byear\s*\([^)]*{re.escape(time_range.field)}[^)]*\)\s*=\s*{year}\b", normalized)
|
|
318
|
+
and re.search(rf"\bmonth\s*\([^)]*{re.escape(time_range.field)}[^)]*\)\s*=\s*(?:{month}|{padded_month})\b", normalized)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
74
322
|
def _table_names(schema: dict[str, Any]) -> set[str]:
|
|
75
323
|
tables = schema.get("tables", [])
|
|
76
324
|
if not isinstance(tables, list):
|
|
@@ -85,9 +333,1101 @@ def _table_names(schema: dict[str, Any]) -> set[str]:
|
|
|
85
333
|
return names
|
|
86
334
|
|
|
87
335
|
|
|
88
|
-
def
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
336
|
+
def _table_columns(schema: dict[str, Any], table_name: str) -> list[dict[str, Any]]:
|
|
337
|
+
tables = schema.get("tables", [])
|
|
338
|
+
if not isinstance(tables, list):
|
|
339
|
+
return []
|
|
340
|
+
for table in tables:
|
|
341
|
+
if not isinstance(table, dict):
|
|
342
|
+
continue
|
|
343
|
+
name = table.get("name") or table.get("tableName")
|
|
344
|
+
if name == table_name and isinstance(table.get("columns"), list):
|
|
345
|
+
return [column for column in table["columns"] if isinstance(column, dict)]
|
|
346
|
+
return []
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _generate_recruiting_detail_sql(question: str, schema: dict[str, Any], default_limit: int) -> GeneratedSql:
|
|
350
|
+
table_names = _table_names(schema)
|
|
351
|
+
if "job_basic_info" not in table_names:
|
|
352
|
+
raise SqlGenerationError("job_basic_info is required for recruiting detail query")
|
|
353
|
+
|
|
354
|
+
select_items = _recruiting_detail_select_items(question, schema)
|
|
355
|
+
from_and_where = _append_job_time_range_condition(_recruiting_detail_from_and_where(table_names), question, schema)
|
|
356
|
+
order_by = _job_order_by(question, schema)
|
|
357
|
+
sql = f"SELECT DISTINCT {', '.join(select_items)} {from_and_where} {order_by} LIMIT {default_limit}"
|
|
358
|
+
tables = [
|
|
359
|
+
table
|
|
360
|
+
for table in ("job_basic_info", "job_store", "store", "job_salary", "job_hiring_requirement")
|
|
361
|
+
if table in table_names
|
|
362
|
+
]
|
|
363
|
+
return GeneratedSql(sql, tables, [])
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _generate_work_order_detail_sql(question: str, schema: dict[str, Any], default_limit: int) -> GeneratedSql:
|
|
367
|
+
example = _find_work_order_detail_example(schema)
|
|
368
|
+
if example is None:
|
|
369
|
+
raise SqlGenerationError("Work order detail query requires a schema example")
|
|
370
|
+
brand_name = _extract_brand_name(question)
|
|
371
|
+
if brand_name is None:
|
|
372
|
+
raise SqlGenerationError("Work order detail query requires a brand name")
|
|
373
|
+
sql = str(example.get("sql") or "")
|
|
374
|
+
if not sql:
|
|
375
|
+
raise SqlGenerationError("Work order detail schema example has no SQL")
|
|
376
|
+
sql = re.sub(
|
|
377
|
+
r"b\.name\s*=\s*'[^']*'",
|
|
378
|
+
f"b.name = '{_escape_sql_string(brand_name)}'",
|
|
379
|
+
sql,
|
|
380
|
+
count=1,
|
|
381
|
+
flags=re.IGNORECASE,
|
|
382
|
+
)
|
|
383
|
+
tables = [str(table) for table in example.get("tables", []) if table]
|
|
384
|
+
return GeneratedSql(sql, tables, [])
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _find_work_order_detail_example(schema: dict[str, Any]) -> dict[str, Any] | None:
|
|
388
|
+
examples = schema.get("examples", [])
|
|
389
|
+
if not isinstance(examples, list):
|
|
390
|
+
return None
|
|
391
|
+
for example in examples:
|
|
392
|
+
if not isinstance(example, dict):
|
|
393
|
+
continue
|
|
394
|
+
text = f"{example.get('question') or ''} {example.get('sql') or ''}"
|
|
395
|
+
if (
|
|
396
|
+
"mvp_applied_jobs_by_helped_account" in text
|
|
397
|
+
and "account_name" in text
|
|
398
|
+
and "work_order_status" in text
|
|
399
|
+
and "报名" in text
|
|
400
|
+
and "候选人状态" in text
|
|
401
|
+
):
|
|
402
|
+
return example
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _generate_recruiting_supply_metric_sql(question: str, schema: dict[str, Any], default_limit: int) -> GeneratedSql:
|
|
407
|
+
table_names = _table_names(schema)
|
|
408
|
+
required = {"job_basic_info", "job_store", "store"}
|
|
409
|
+
if not required <= table_names:
|
|
410
|
+
raise SqlGenerationError("Recruiting supply metrics require job_basic_info, job_store and store")
|
|
411
|
+
|
|
412
|
+
select_items: list[str] = []
|
|
413
|
+
group_by: list[str] = []
|
|
414
|
+
joins = [
|
|
415
|
+
"FROM job_basic_info jbi",
|
|
416
|
+
"LEFT JOIN job_store js ON js.job_basic_info_id = jbi.id AND js.is_deleted = 0",
|
|
417
|
+
"LEFT JOIN store s ON s.id = js.store_id AND s.delete_at IS NULL",
|
|
418
|
+
]
|
|
419
|
+
tables = ["job_basic_info", "job_store", "store"]
|
|
420
|
+
|
|
421
|
+
if "品牌" in question and "brand" in table_names and _has_column(schema, "job_basic_info", "brand_id"):
|
|
422
|
+
joins.append("LEFT JOIN brand b ON b.id = jbi.brand_id")
|
|
423
|
+
select_items.extend(["b.id AS 品牌ID", "b.name AS 品牌名称"])
|
|
424
|
+
group_by.extend(["b.id", "b.name"])
|
|
425
|
+
tables.append("brand")
|
|
426
|
+
if "项目" in question and "sponge_project" in table_names and _has_column(schema, "job_basic_info", "organization_id"):
|
|
427
|
+
joins.append("LEFT JOIN sponge_project p ON p.id = jbi.organization_id")
|
|
428
|
+
select_items.extend(["p.id AS 项目ID", "p.name AS 项目名称"])
|
|
429
|
+
group_by.extend(["p.id", "p.name"])
|
|
430
|
+
tables.append("sponge_project")
|
|
431
|
+
if "城市" in question and "city" in table_names and _has_column(schema, "store", "city_id"):
|
|
432
|
+
joins.append("LEFT JOIN city c ON c.id = s.city_id")
|
|
433
|
+
select_items.extend(["c.id AS 城市ID", "c.name AS 城市名称"])
|
|
434
|
+
group_by.extend(["c.id", "c.name"])
|
|
435
|
+
tables.append("city")
|
|
436
|
+
if "区域" in question and "region" in table_names and _has_column(schema, "store", "region_id"):
|
|
437
|
+
joins.append("LEFT JOIN region r ON r.id = s.region_id")
|
|
438
|
+
select_items.extend(["r.id AS 区域ID", "r.name AS 区域名称"])
|
|
439
|
+
group_by.extend(["r.id", "r.name"])
|
|
440
|
+
tables.append("region")
|
|
441
|
+
|
|
442
|
+
if not select_items:
|
|
443
|
+
raise SqlGenerationError("Recruiting supply metric query requires at least one grouping dimension")
|
|
444
|
+
select_items.extend(
|
|
445
|
+
[
|
|
446
|
+
"COUNT(DISTINCT jbi.id) AS 在招岗位数",
|
|
447
|
+
"SUM(COALESCE(js.requirement_num, 0)) AS 招聘人数",
|
|
448
|
+
"COUNT(DISTINCT js.store_id) AS 招聘门店数",
|
|
449
|
+
]
|
|
450
|
+
)
|
|
451
|
+
from_and_where = _append_job_time_range_condition(" ".join(joins) + " WHERE jbi.is_deleted = 0 AND jbi.status = 1", question, schema)
|
|
452
|
+
sql = (
|
|
453
|
+
f"SELECT {', '.join(select_items)} {from_and_where} "
|
|
454
|
+
f"GROUP BY {', '.join(group_by)} "
|
|
455
|
+
"ORDER BY 在招岗位数 DESC "
|
|
456
|
+
f"LIMIT {default_limit}"
|
|
457
|
+
)
|
|
458
|
+
return GeneratedSql(sql, list(dict.fromkeys(tables)), [])
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _generate_monthly_recruiting_conversion_sql(question: str, schema: dict[str, Any], default_limit: int) -> GeneratedSql:
|
|
462
|
+
table_names = _table_names(schema)
|
|
463
|
+
required = {"mvp_applied_jobs_by_helped_account", "job_basic_info", "brand", "sponge_project"}
|
|
464
|
+
if not required <= table_names:
|
|
465
|
+
raise SqlGenerationError("Monthly recruiting conversion requires work order, job, brand and project tables")
|
|
466
|
+
year_bounds = _extract_year_bounds(question)
|
|
467
|
+
if year_bounds is None:
|
|
468
|
+
raise SqlGenerationError("Monthly recruiting conversion query requires a year range")
|
|
469
|
+
start, end = year_bounds
|
|
470
|
+
has_store = "store" in table_names
|
|
471
|
+
store_join = "LEFT JOIN store st ON st.id = wo.store_id AND st.delete_at IS NULL" if has_store else ""
|
|
472
|
+
brand_expr = "COALESCE(jbi.brand_id, st.brand_id)" if has_store else "jbi.brand_id"
|
|
473
|
+
project_expr = "COALESCE(jbi.organization_id, st.organization_id)" if has_store else "jbi.organization_id"
|
|
474
|
+
tables = ["mvp_applied_jobs_by_helped_account", "job_basic_info", "brand", "sponge_project"]
|
|
475
|
+
if has_store:
|
|
476
|
+
tables.append("store")
|
|
477
|
+
|
|
478
|
+
signup_sql = _monthly_conversion_event_sql(
|
|
479
|
+
start=start,
|
|
480
|
+
end=end,
|
|
481
|
+
date_field="wo.sign_up_time",
|
|
482
|
+
brand_expr=brand_expr,
|
|
483
|
+
project_expr=project_expr,
|
|
484
|
+
store_join=store_join,
|
|
485
|
+
signup_value=1,
|
|
486
|
+
onboard_value=0,
|
|
487
|
+
extra_condition="",
|
|
488
|
+
)
|
|
489
|
+
onboard_sql = _monthly_conversion_event_sql(
|
|
490
|
+
start=start,
|
|
491
|
+
end=end,
|
|
492
|
+
date_field="wo.on_work_time",
|
|
493
|
+
brand_expr=brand_expr,
|
|
494
|
+
project_expr=project_expr,
|
|
495
|
+
store_join=store_join,
|
|
496
|
+
signup_value=0,
|
|
497
|
+
onboard_value=1,
|
|
498
|
+
extra_condition="AND wo.on_work_status = 1",
|
|
499
|
+
)
|
|
500
|
+
sql = (
|
|
501
|
+
"SELECT monthly.月份 AS 月份, b.id AS 品牌ID, b.name AS 品牌名称, "
|
|
502
|
+
"p.id AS 项目ID, p.name AS 项目名称, "
|
|
503
|
+
"SUM(monthly.报名人数) AS 报名人数, "
|
|
504
|
+
"SUM(monthly.入职人数) AS 入职人数, "
|
|
505
|
+
"ROUND(SUM(monthly.入职人数) / NULLIF(SUM(monthly.报名人数), 0), 4) AS 报名转化率, "
|
|
506
|
+
"ROUND(SUM(monthly.入职人数) / NULLIF(SUM(monthly.报名人数), 0), 4) AS 入职转化率 "
|
|
507
|
+
f"FROM (({signup_sql}) UNION ALL ({onboard_sql})) monthly "
|
|
508
|
+
"LEFT JOIN brand b ON b.id = monthly.brand_id "
|
|
509
|
+
"LEFT JOIN sponge_project p ON p.id = monthly.project_id "
|
|
510
|
+
"GROUP BY monthly.月份, b.id, b.name, p.id, p.name "
|
|
511
|
+
"ORDER BY monthly.月份 ASC, 报名人数 DESC, 入职人数 DESC "
|
|
512
|
+
f"LIMIT {default_limit}"
|
|
513
|
+
)
|
|
514
|
+
return GeneratedSql(sql, tables, [])
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _monthly_conversion_event_sql(
|
|
518
|
+
*,
|
|
519
|
+
start: str,
|
|
520
|
+
end: str,
|
|
521
|
+
date_field: str,
|
|
522
|
+
brand_expr: str,
|
|
523
|
+
project_expr: str,
|
|
524
|
+
store_join: str,
|
|
525
|
+
signup_value: int,
|
|
526
|
+
onboard_value: int,
|
|
527
|
+
extra_condition: str,
|
|
528
|
+
) -> str:
|
|
529
|
+
return (
|
|
530
|
+
f"SELECT DATE_FORMAT({date_field}, '%Y-%m') AS 月份, "
|
|
531
|
+
f"{brand_expr} AS brand_id, {project_expr} AS project_id, "
|
|
532
|
+
f"COUNT(DISTINCT wo.id) * {signup_value} AS 报名人数, "
|
|
533
|
+
f"COUNT(DISTINCT wo.id) * {onboard_value} AS 入职人数 "
|
|
534
|
+
"FROM mvp_applied_jobs_by_helped_account wo "
|
|
535
|
+
"JOIN job_basic_info jbi ON jbi.job_id = wo.job_id AND jbi.is_deleted = 0 "
|
|
536
|
+
f"{store_join} "
|
|
537
|
+
f"WHERE wo.delete_at IS NULL AND {date_field} >= '{start}' AND {date_field} < '{end}' "
|
|
538
|
+
f"{extra_condition} "
|
|
539
|
+
f"GROUP BY DATE_FORMAT({date_field}, '%Y-%m'), {brand_expr}, {project_expr}"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _generate_recruiting_match_effect_sql(question: str, schema: dict[str, Any], default_limit: int) -> GeneratedSql:
|
|
544
|
+
table_names = _table_names(schema)
|
|
545
|
+
required = {
|
|
546
|
+
"job_basic_info",
|
|
547
|
+
"job_store",
|
|
548
|
+
"store",
|
|
549
|
+
"brand",
|
|
550
|
+
"sponge_project",
|
|
551
|
+
"city",
|
|
552
|
+
"mvp_applied_jobs_by_helped_account",
|
|
553
|
+
}
|
|
554
|
+
if not required <= table_names:
|
|
555
|
+
raise SqlGenerationError("Recruiting match effect requires job, store, brand, project, city and work order tables")
|
|
556
|
+
|
|
557
|
+
time_bounds = _extract_time_bounds(question)
|
|
558
|
+
if time_bounds is None:
|
|
559
|
+
raise SqlGenerationError("Recruiting match effect query requires a time range")
|
|
560
|
+
start, end = time_bounds
|
|
561
|
+
candidate_expr = "wo.c_helped_account_id"
|
|
562
|
+
signup_join = ""
|
|
563
|
+
signup_city = "st.city_id"
|
|
564
|
+
tables = [
|
|
565
|
+
"job_basic_info",
|
|
566
|
+
"job_store",
|
|
567
|
+
"store",
|
|
568
|
+
"brand",
|
|
569
|
+
"sponge_project",
|
|
570
|
+
"city",
|
|
571
|
+
"mvp_applied_jobs_by_helped_account",
|
|
572
|
+
]
|
|
573
|
+
if "sign_up" in table_names:
|
|
574
|
+
signup_join = "LEFT JOIN sign_up su ON su.id = wo.sign_up_id AND su.delete_at IS NULL"
|
|
575
|
+
candidate_expr = "COALESCE(wo.c_helped_account_id, su.resume_id)"
|
|
576
|
+
signup_city = "COALESCE(st.city_id, su.city_id)"
|
|
577
|
+
tables.append("sign_up")
|
|
578
|
+
|
|
579
|
+
supply_dimension_sql = _match_effect_supply_dimension_sql(start, end)
|
|
580
|
+
match_dimension_sql = _match_effect_match_dimension_sql(start, end, signup_join, signup_city)
|
|
581
|
+
supply_metric_sql = _match_effect_supply_metric_sql(start, end)
|
|
582
|
+
match_metric_sql = _match_effect_match_metric_sql(start, end, signup_join, signup_city, candidate_expr)
|
|
583
|
+
|
|
584
|
+
sql = (
|
|
585
|
+
"SELECT b.id AS 品牌ID, b.name AS 品牌名称, p.id AS 项目ID, p.name AS 项目名称, "
|
|
586
|
+
"c.id AS 城市ID, c.name AS 城市名称, "
|
|
587
|
+
"COALESCE(supply.在招岗位数, 0) AS 在招岗位数, "
|
|
588
|
+
"COALESCE(supply.招聘人数, 0) AS 招聘人数, "
|
|
589
|
+
"COALESCE(supply.招聘门店数, 0) AS 招聘门店数, "
|
|
590
|
+
"COALESCE(match_data.报名人数, 0) AS 报名人数, "
|
|
591
|
+
"COALESCE(match_data.入职人数, 0) AS 入职人数, "
|
|
592
|
+
"COALESCE(match_data.候选人数, 0) AS 候选人数, "
|
|
593
|
+
"COALESCE(match_data.报名人数, 0) - COALESCE(match_data.入职人数, 0) AS 报名入职差, "
|
|
594
|
+
"ROUND(COALESCE(match_data.报名人数, 0) / NULLIF(COALESCE(supply.在招岗位数, 0), 0), 2) AS 岗位报名比, "
|
|
595
|
+
"ROUND(COALESCE(match_data.候选人数, 0) / NULLIF(COALESCE(supply.在招岗位数, 0), 0), 2) AS 候选岗位比, "
|
|
596
|
+
"COALESCE(NULLIF(CONCAT_WS('、', "
|
|
597
|
+
"IF(COALESCE(match_data.报名人数, 0) > COALESCE(match_data.入职人数, 0), '报名多但入职少', NULL), "
|
|
598
|
+
"IF(COALESCE(supply.在招岗位数, 0) > COALESCE(match_data.报名人数, 0), '在招多但报名少', NULL), "
|
|
599
|
+
"IF(COALESCE(match_data.候选人数, 0) > COALESCE(supply.在招岗位数, 0), '候选人多但岗位少', NULL)"
|
|
600
|
+
"), ''), '正常') AS 问题类型 "
|
|
601
|
+
f"FROM (({supply_dimension_sql}) UNION ({match_dimension_sql})) dimension_base "
|
|
602
|
+
"LEFT JOIN brand b ON b.id = dimension_base.brand_id "
|
|
603
|
+
"LEFT JOIN sponge_project p ON p.id = dimension_base.project_id "
|
|
604
|
+
"LEFT JOIN city c ON c.id = dimension_base.city_id "
|
|
605
|
+
f"LEFT JOIN ({supply_metric_sql}) supply ON supply.brand_id <=> dimension_base.brand_id "
|
|
606
|
+
"AND supply.project_id <=> dimension_base.project_id AND supply.city_id <=> dimension_base.city_id "
|
|
607
|
+
f"LEFT JOIN ({match_metric_sql}) match_data ON match_data.brand_id <=> dimension_base.brand_id "
|
|
608
|
+
"AND match_data.project_id <=> dimension_base.project_id AND match_data.city_id <=> dimension_base.city_id "
|
|
609
|
+
"WHERE COALESCE(match_data.报名人数, 0) > COALESCE(match_data.入职人数, 0) "
|
|
610
|
+
"OR COALESCE(supply.在招岗位数, 0) > COALESCE(match_data.报名人数, 0) "
|
|
611
|
+
"OR COALESCE(match_data.候选人数, 0) > COALESCE(supply.在招岗位数, 0) "
|
|
612
|
+
"ORDER BY 报名入职差 DESC, 在招岗位数 DESC, 候选岗位比 DESC "
|
|
613
|
+
f"LIMIT {default_limit}"
|
|
614
|
+
)
|
|
615
|
+
return GeneratedSql(sql, list(dict.fromkeys(tables)), [])
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _match_effect_supply_dimension_sql(start: str, end: str) -> str:
|
|
619
|
+
return (
|
|
620
|
+
"SELECT jbi.brand_id AS brand_id, jbi.organization_id AS project_id, st.city_id AS city_id "
|
|
621
|
+
"FROM job_basic_info jbi "
|
|
622
|
+
"LEFT JOIN job_store js ON js.job_basic_info_id = jbi.id AND js.is_deleted = 0 "
|
|
623
|
+
"LEFT JOIN store st ON st.id = js.store_id AND st.delete_at IS NULL "
|
|
624
|
+
"WHERE jbi.is_deleted = 0 AND jbi.status = 1 "
|
|
625
|
+
f"AND jbi.create_at >= '{start}' AND jbi.create_at < '{end}' "
|
|
626
|
+
"GROUP BY jbi.brand_id, jbi.organization_id, st.city_id"
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _match_effect_match_dimension_sql(start: str, end: str, signup_join: str, signup_city: str) -> str:
|
|
631
|
+
return (
|
|
632
|
+
f"SELECT jbi.brand_id AS brand_id, jbi.organization_id AS project_id, {signup_city} AS city_id "
|
|
633
|
+
"FROM mvp_applied_jobs_by_helped_account wo "
|
|
634
|
+
"JOIN job_basic_info jbi ON jbi.job_id = wo.job_id AND jbi.is_deleted = 0 "
|
|
635
|
+
f"{signup_join} "
|
|
636
|
+
"LEFT JOIN store st ON st.id = wo.store_id AND st.delete_at IS NULL "
|
|
637
|
+
"WHERE wo.delete_at IS NULL AND ("
|
|
638
|
+
f"(wo.sign_up_time >= '{start}' AND wo.sign_up_time < '{end}') "
|
|
639
|
+
f"OR (wo.on_work_time >= '{start}' AND wo.on_work_time < '{end}')"
|
|
640
|
+
") "
|
|
641
|
+
f"GROUP BY jbi.brand_id, jbi.organization_id, {signup_city}"
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _match_effect_supply_metric_sql(start: str, end: str) -> str:
|
|
646
|
+
return (
|
|
647
|
+
"SELECT jbi.brand_id AS brand_id, jbi.organization_id AS project_id, st.city_id AS city_id, "
|
|
648
|
+
"COUNT(DISTINCT jbi.id) AS 在招岗位数, "
|
|
649
|
+
"SUM(COALESCE(js.requirement_num, 0)) AS 招聘人数, "
|
|
650
|
+
"COUNT(DISTINCT js.store_id) AS 招聘门店数 "
|
|
651
|
+
"FROM job_basic_info jbi "
|
|
652
|
+
"LEFT JOIN job_store js ON js.job_basic_info_id = jbi.id AND js.is_deleted = 0 "
|
|
653
|
+
"LEFT JOIN store st ON st.id = js.store_id AND st.delete_at IS NULL "
|
|
654
|
+
"WHERE jbi.is_deleted = 0 AND jbi.status = 1 "
|
|
655
|
+
f"AND jbi.create_at >= '{start}' AND jbi.create_at < '{end}' "
|
|
656
|
+
"GROUP BY jbi.brand_id, jbi.organization_id, st.city_id"
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _match_effect_match_metric_sql(
|
|
661
|
+
start: str,
|
|
662
|
+
end: str,
|
|
663
|
+
signup_join: str,
|
|
664
|
+
signup_city: str,
|
|
665
|
+
candidate_expr: str,
|
|
666
|
+
) -> str:
|
|
667
|
+
return (
|
|
668
|
+
f"SELECT jbi.brand_id AS brand_id, jbi.organization_id AS project_id, {signup_city} AS city_id, "
|
|
669
|
+
f"COUNT(DISTINCT CASE WHEN wo.sign_up_time >= '{start}' AND wo.sign_up_time < '{end}' THEN wo.id END) AS 报名人数, "
|
|
670
|
+
"COUNT(DISTINCT CASE WHEN wo.on_work_status = 1 "
|
|
671
|
+
f"AND wo.on_work_time >= '{start}' AND wo.on_work_time < '{end}' THEN wo.id END) AS 入职人数, "
|
|
672
|
+
f"COUNT(DISTINCT CASE WHEN wo.sign_up_time >= '{start}' AND wo.sign_up_time < '{end}' THEN {candidate_expr} END) AS 候选人数 "
|
|
673
|
+
"FROM mvp_applied_jobs_by_helped_account wo "
|
|
674
|
+
"JOIN job_basic_info jbi ON jbi.job_id = wo.job_id AND jbi.is_deleted = 0 "
|
|
675
|
+
f"{signup_join} "
|
|
676
|
+
"LEFT JOIN store st ON st.id = wo.store_id AND st.delete_at IS NULL "
|
|
677
|
+
"WHERE wo.delete_at IS NULL AND ("
|
|
678
|
+
f"(wo.sign_up_time >= '{start}' AND wo.sign_up_time < '{end}') "
|
|
679
|
+
f"OR (wo.on_work_time >= '{start}' AND wo.on_work_time < '{end}')"
|
|
680
|
+
") "
|
|
681
|
+
f"GROUP BY jbi.brand_id, jbi.organization_id, {signup_city}"
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _recruiting_detail_from_and_where(table_names: set[str]) -> str:
|
|
686
|
+
parts = ["FROM job_basic_info jbi"]
|
|
687
|
+
if "job_store" in table_names:
|
|
688
|
+
parts.append("LEFT JOIN job_store js ON js.job_basic_info_id = jbi.id AND js.is_deleted = 0")
|
|
689
|
+
if {"job_store", "store"} <= table_names:
|
|
690
|
+
parts.append("LEFT JOIN store s ON s.id = js.store_id AND s.delete_at IS NULL")
|
|
691
|
+
if "job_salary" in table_names:
|
|
692
|
+
parts.append("LEFT JOIN job_salary jsa ON jsa.job_basic_info_id = jbi.id AND jsa.is_deleted = 0")
|
|
693
|
+
if "job_hiring_requirement" in table_names:
|
|
694
|
+
parts.append("LEFT JOIN job_hiring_requirement jhr ON jhr.job_basic_info_id = jbi.id AND jhr.is_deleted = 0")
|
|
695
|
+
parts.append("WHERE jbi.is_deleted = 0 AND jbi.status = 1")
|
|
696
|
+
return " ".join(parts)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _recruiting_detail_select_items(question: str, schema: dict[str, Any]) -> list[str]:
|
|
700
|
+
items = [
|
|
701
|
+
"jbi.id AS 岗位ID",
|
|
702
|
+
"jbi.job_name AS 岗位名称",
|
|
703
|
+
]
|
|
704
|
+
for item in (
|
|
705
|
+
_select_field_item(schema, "job_basic_info", "jbi", ("岗位类别", "岗位类型", "职能类别"), "岗位类型", ("job_type",)),
|
|
706
|
+
_select_field_item(schema, "job_store", "js", ("招聘人数",), "招聘人数", ("requirement_num",)),
|
|
707
|
+
_select_field_item(schema, "job_basic_info", "jbi", ("状态", "岗位状态"), "岗位状态", ("status",)),
|
|
708
|
+
):
|
|
709
|
+
if item is not None:
|
|
710
|
+
items.append(item)
|
|
711
|
+
time_range = _extract_time_range(question, schema)
|
|
712
|
+
if time_range is not None:
|
|
713
|
+
items.append(f"jbi.{time_range.field} AS {time_range.alias}")
|
|
714
|
+
if "门店" in question:
|
|
715
|
+
for item in (
|
|
716
|
+
_select_field_item(schema, "store", "s", ("门店名称",), "门店名称", ("name",)),
|
|
717
|
+
_select_field_item(schema, "store", "s", ("门店地址", "详细地址", "精确地址"), "门店地址", ("address", "exact_address")),
|
|
718
|
+
_select_field_item(schema, "store", "s", ("门店ID",), "门店ID", ("id",)),
|
|
719
|
+
):
|
|
720
|
+
if item is not None:
|
|
721
|
+
items.append(item)
|
|
722
|
+
if "薪资" in question:
|
|
723
|
+
for item in (
|
|
724
|
+
_select_field_item(schema, "job_salary", "jsa", ("最低综合薪资", "最低薪资", "薪资范围最低"), "最低薪资", ("min_comprehensive_salary",)),
|
|
725
|
+
_select_field_item(schema, "job_salary", "jsa", ("最高综合薪资", "最高薪资", "薪资范围最高"), "最高薪资", ("max_comprehensive_salary",)),
|
|
726
|
+
_select_field_item(schema, "job_salary", "jsa", ("薪资类型", "类型", "薪资结构"), "薪资类型", ("type", "salary_period")),
|
|
727
|
+
):
|
|
728
|
+
if item is not None:
|
|
729
|
+
items.append(item)
|
|
730
|
+
if re.search(r"(用人要求|学历|经验|年龄|技能)", question):
|
|
731
|
+
for item in (
|
|
732
|
+
_select_field_item(schema, "job_hiring_requirement", "jhr", ("学历", "最低学历"), "学历要求", ("education_id",)),
|
|
733
|
+
_select_field_item(schema, "job_hiring_requirement", "jhr", ("最少工作时长", "工作经验", "工作年限"), "经验要求", ("min_work_time",)),
|
|
734
|
+
_select_age_requirement_item(schema),
|
|
735
|
+
_select_field_item(schema, "job_hiring_requirement", "jhr", ("软性技能", "技能要求"), "技能要求", ("soft_skill",)),
|
|
736
|
+
):
|
|
737
|
+
if item is not None:
|
|
738
|
+
items.append(item)
|
|
739
|
+
if re.search(r"(岗位描述|工作内容|技能要求)", question):
|
|
740
|
+
item = _select_field_item(schema, "job_basic_info", "jbi", ("工作内容", "岗位描述"), "岗位描述", ("job_content",))
|
|
741
|
+
if item is not None:
|
|
742
|
+
items.append(item)
|
|
743
|
+
return list(dict.fromkeys(items))
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _select_age_requirement_item(schema: dict[str, Any]) -> str | None:
|
|
747
|
+
if _has_column(schema, "job_hiring_requirement", "min_age") and _has_column(schema, "job_hiring_requirement", "max_age"):
|
|
748
|
+
return "CONCAT(jhr.min_age, '-', jhr.max_age) AS 年龄要求"
|
|
749
|
+
return _select_field_item(schema, "job_hiring_requirement", "jhr", ("年龄",), "年龄要求", ("min_age", "max_age"))
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _select_field_item(
|
|
753
|
+
schema: dict[str, Any],
|
|
754
|
+
table_name: str,
|
|
755
|
+
table_alias: str,
|
|
756
|
+
terms: tuple[str, ...],
|
|
757
|
+
output_alias: str,
|
|
758
|
+
fallback_names: tuple[str, ...],
|
|
759
|
+
) -> str | None:
|
|
760
|
+
column = _select_column(schema, table_name, terms, fallback_names)
|
|
761
|
+
if column is None:
|
|
762
|
+
return None
|
|
763
|
+
return f"{table_alias}.{column} AS {output_alias}"
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _select_column(
|
|
767
|
+
schema: dict[str, Any],
|
|
768
|
+
table_name: str,
|
|
769
|
+
terms: tuple[str, ...],
|
|
770
|
+
fallback_names: tuple[str, ...],
|
|
771
|
+
) -> str | None:
|
|
772
|
+
columns = _table_columns(schema, table_name)
|
|
773
|
+
best: tuple[int, str] | None = None
|
|
774
|
+
normalized_terms = [_normalize_semantic_text(term) for term in terms]
|
|
775
|
+
for column in columns:
|
|
776
|
+
name = str(column.get("columnName") or column.get("name") or "")
|
|
777
|
+
if not name:
|
|
778
|
+
continue
|
|
779
|
+
text = _normalize_semantic_text(f"{name}{column.get('comment') or ''}")
|
|
780
|
+
score = sum(10 for term in normalized_terms if term and term in text)
|
|
781
|
+
if name in fallback_names:
|
|
782
|
+
score += 5
|
|
783
|
+
if score > 0 and (best is None or score > best[0]):
|
|
784
|
+
best = (score, name)
|
|
785
|
+
if best is not None:
|
|
786
|
+
return best[1]
|
|
787
|
+
for name in fallback_names:
|
|
788
|
+
if _has_column(schema, table_name, name):
|
|
789
|
+
return name
|
|
790
|
+
return None
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _has_column(schema: dict[str, Any], table_name: str, column_name: str) -> bool:
|
|
794
|
+
return any((column.get("columnName") or column.get("name")) == column_name for column in _table_columns(schema, table_name))
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _has_recruiting_location_tables(table_names: set[str]) -> bool:
|
|
798
|
+
required = {"job_basic_info", "job_address", "job_store", "store", "province", "city", "region"}
|
|
799
|
+
return required <= table_names
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _has_recruiting_tables(table_names: set[str]) -> bool:
|
|
803
|
+
return "job_basic_info" in table_names
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _recruiting_from_and_where() -> str:
|
|
807
|
+
return "FROM job_basic_info jbi WHERE jbi.is_deleted = 0 AND jbi.status = 1"
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _recruiting_location_from_and_where(location: str) -> str:
|
|
811
|
+
return (
|
|
812
|
+
"FROM job_basic_info jbi "
|
|
813
|
+
"LEFT JOIN job_address ja ON ja.job_id = jbi.job_id AND ja.delete_at IS NULL "
|
|
814
|
+
"LEFT JOIN job_store js ON js.job_basic_info_id = jbi.id AND js.is_deleted = 0 "
|
|
815
|
+
"LEFT JOIN store s ON s.id = js.store_id AND s.delete_at IS NULL "
|
|
816
|
+
f"WHERE jbi.is_deleted = 0 AND jbi.status = 1 AND {_recruiting_location_condition(location)}"
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _recruiting_location_condition(location: str) -> str:
|
|
821
|
+
return (
|
|
822
|
+
"("
|
|
823
|
+
"EXISTS ("
|
|
824
|
+
"SELECT 1 FROM province p JOIN city c ON c.province_id = p.id "
|
|
825
|
+
f"WHERE ({_province_name_matches(location)}) "
|
|
826
|
+
"AND (ja.city_id = c.id OR s.city_id = c.id OR FIND_IN_SET(c.id, jbi.city_ids) > 0)"
|
|
827
|
+
") OR EXISTS ("
|
|
828
|
+
"SELECT 1 FROM city c "
|
|
829
|
+
f"WHERE ({_city_name_matches(location)}) "
|
|
830
|
+
"AND (ja.city_id = c.id OR s.city_id = c.id OR FIND_IN_SET(c.id, jbi.city_ids) > 0)"
|
|
831
|
+
") OR EXISTS ("
|
|
832
|
+
"SELECT 1 FROM region r "
|
|
833
|
+
f"WHERE ({_region_name_matches(location)}) "
|
|
834
|
+
"AND (ja.region_id = r.id OR s.region_id = r.id)"
|
|
835
|
+
"))"
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _append_job_time_range_condition(from_and_where: str, question: str, schema: dict[str, Any]) -> str:
|
|
840
|
+
time_range = _extract_time_range(question, schema)
|
|
841
|
+
if time_range is None:
|
|
842
|
+
return from_and_where
|
|
843
|
+
return (
|
|
844
|
+
f"{from_and_where} AND jbi.{time_range.field} >= '{time_range.start}' "
|
|
845
|
+
f"AND jbi.{time_range.field} < '{time_range.end}'"
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _extract_time_range(question: str, schema: dict[str, Any] | None = None) -> TimeRange | None:
|
|
850
|
+
field = _resolve_job_time_field(question, schema or {})
|
|
851
|
+
if field is None:
|
|
852
|
+
return None
|
|
853
|
+
bounds = _extract_time_bounds(question)
|
|
854
|
+
if bounds is None:
|
|
855
|
+
return None
|
|
856
|
+
return TimeRange(field=field.name, alias=field.alias, start=bounds[0], end=bounds[1])
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _job_basic_select_items(question: str, schema: dict[str, Any]) -> str:
|
|
860
|
+
items = [
|
|
861
|
+
"jbi.id AS 岗位ID",
|
|
862
|
+
"jbi.job_name AS 岗位名称",
|
|
863
|
+
"jbi.job_content AS 工作内容",
|
|
864
|
+
]
|
|
865
|
+
time_range = _extract_time_range(question, schema)
|
|
866
|
+
if time_range is not None:
|
|
867
|
+
items.append(f"jbi.{time_range.field} AS {time_range.alias}")
|
|
868
|
+
else:
|
|
869
|
+
items.append("jbi.publish_at AS 发布时间")
|
|
870
|
+
items.append("jbi.status AS 状态")
|
|
871
|
+
return ", ".join(items)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _job_order_by(question: str, schema: dict[str, Any]) -> str:
|
|
875
|
+
time_range = _extract_time_range(question, schema)
|
|
876
|
+
if time_range is not None:
|
|
877
|
+
direction = "ASC" if re.search(r"(正序|升序|从早到晚)", question) else "DESC"
|
|
878
|
+
return f"ORDER BY jbi.{time_range.field} {direction}"
|
|
879
|
+
return "ORDER BY jbi.publish_at DESC"
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _resolve_job_time_field(question: str, schema: dict[str, Any]) -> FieldRef | None:
|
|
883
|
+
columns = _table_columns(schema, "job_basic_info")
|
|
884
|
+
question_text = _normalize_semantic_text(question)
|
|
885
|
+
best: tuple[int, FieldRef] | None = None
|
|
886
|
+
for column in columns:
|
|
887
|
+
name = str(column.get("columnName") or column.get("name") or "")
|
|
888
|
+
if not name:
|
|
889
|
+
continue
|
|
890
|
+
data_type = str(column.get("dataType") or column.get("type") or "")
|
|
891
|
+
comment = str(column.get("comment") or "")
|
|
892
|
+
if not _is_time_like_column(name, data_type, comment):
|
|
893
|
+
continue
|
|
894
|
+
score = _time_field_match_score(question_text, name, comment)
|
|
895
|
+
if score <= 0:
|
|
896
|
+
continue
|
|
897
|
+
field_ref = FieldRef(name=name, alias=_column_alias(name, comment))
|
|
898
|
+
if best is None or score > best[0]:
|
|
899
|
+
best = (score, field_ref)
|
|
900
|
+
if best is not None:
|
|
901
|
+
return best[1]
|
|
902
|
+
if _extract_time_bounds(question) is not None and re.search(r"(在招岗位数|岗位供给|招聘人数|招聘门店数|供给)", question):
|
|
903
|
+
created_column = _select_column(schema, "job_basic_info", ("创建时间",), ("create_at",))
|
|
904
|
+
if created_column is not None:
|
|
905
|
+
return FieldRef(created_column, _column_alias(created_column, "创建时间"))
|
|
906
|
+
fallback = _select_time_field(question)
|
|
907
|
+
if fallback is None:
|
|
908
|
+
return None
|
|
909
|
+
return fallback
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _select_time_field(question: str) -> FieldRef | None:
|
|
913
|
+
if re.search(r"(发布|上架)", question):
|
|
914
|
+
return FieldRef("publish_at", "发布时间")
|
|
915
|
+
if re.search(r"(下架)", question):
|
|
916
|
+
return FieldRef("off_at", "下架时间")
|
|
917
|
+
if re.search(r"(更新|修改)", question):
|
|
918
|
+
return FieldRef("update_at", "更新时间")
|
|
919
|
+
if re.search(r"(创建|新建|新增)", question):
|
|
920
|
+
return FieldRef("create_at", "创建时间")
|
|
921
|
+
return None
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _is_time_like_column(name: str, data_type: str, comment: str) -> bool:
|
|
925
|
+
lowered = f"{name} {data_type}".lower()
|
|
926
|
+
return bool(
|
|
927
|
+
re.search(r"(date|time|datetime|timestamp|_at$)", lowered)
|
|
928
|
+
or re.search(r"(时间|日期)", comment)
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _time_field_match_score(question_text: str, name: str, comment: str) -> int:
|
|
933
|
+
field_text = _normalize_semantic_text(f"{name}{comment}")
|
|
934
|
+
score = 0
|
|
935
|
+
for term in ("创建", "发布", "下架", "更新"):
|
|
936
|
+
if term in question_text and term in field_text:
|
|
937
|
+
score += 100
|
|
938
|
+
comment_text = _normalize_semantic_text(comment)
|
|
939
|
+
if comment_text and comment_text in question_text:
|
|
940
|
+
score += 50
|
|
941
|
+
name_text = _normalize_semantic_text(name)
|
|
942
|
+
if name_text and name_text in question_text:
|
|
943
|
+
score += 30
|
|
944
|
+
if "时间" in question_text and "时间" in field_text:
|
|
945
|
+
score += 5
|
|
946
|
+
return score
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _normalize_semantic_text(text: str) -> str:
|
|
950
|
+
normalized = text.lower()
|
|
951
|
+
replacements = {
|
|
952
|
+
"新建": "创建",
|
|
953
|
+
"新增": "创建",
|
|
954
|
+
"录入": "创建",
|
|
955
|
+
"生成": "创建",
|
|
956
|
+
"上架": "发布",
|
|
957
|
+
"上线": "发布",
|
|
958
|
+
"修改": "更新",
|
|
959
|
+
"下线": "下架",
|
|
960
|
+
}
|
|
961
|
+
for source, target in replacements.items():
|
|
962
|
+
normalized = normalized.replace(source, target)
|
|
963
|
+
return re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", normalized)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _column_alias(name: str, comment: str) -> str:
|
|
967
|
+
cleaned = re.sub(r"[。;;,,\s].*$", "", comment.strip())
|
|
968
|
+
if cleaned:
|
|
969
|
+
return cleaned
|
|
970
|
+
fallback_aliases = {
|
|
971
|
+
"create_at": "创建时间",
|
|
972
|
+
"publish_at": "发布时间",
|
|
973
|
+
"off_at": "下架时间",
|
|
974
|
+
"update_at": "更新时间",
|
|
975
|
+
}
|
|
976
|
+
return fallback_aliases.get(name, name)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _extract_time_bounds(question: str) -> tuple[str, str] | None:
|
|
980
|
+
date_range = _extract_explicit_date_range(question)
|
|
981
|
+
if date_range is not None:
|
|
982
|
+
return date_range
|
|
983
|
+
relative_month_match = re.search(r"(?P<relative_year>今年|去年|前年|明年|后年)\s*(?P<month>\d{1,2})\s*月(?:份)?", question)
|
|
984
|
+
if relative_month_match is not None:
|
|
985
|
+
year = _today().year + _relative_year_offset(relative_month_match.group("relative_year"))
|
|
986
|
+
month = int(relative_month_match.group("month"))
|
|
987
|
+
return _month_bounds(year, month)
|
|
988
|
+
month_match = re.search(r"(?P<year>20\d{2})\s*年\s*(?P<month>\d{1,2})\s*月(?:份)?", question)
|
|
989
|
+
if month_match is None:
|
|
990
|
+
month_match = re.search(r"(?P<year>20\d{2})[-/](?P<month>\d{1,2})(?![-/]\d)", question)
|
|
991
|
+
if month_match is None:
|
|
992
|
+
return None
|
|
993
|
+
year = int(month_match.group("year"))
|
|
994
|
+
month = int(month_match.group("month"))
|
|
995
|
+
return _month_bounds(year, month)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _extract_year_bounds(question: str) -> tuple[str, str] | None:
|
|
999
|
+
relative_year_match = re.search(r"(?P<relative_year>今年|去年|前年|明年|后年)(?:全年|年内|全年内)?", question)
|
|
1000
|
+
if relative_year_match is not None:
|
|
1001
|
+
year = _today().year + _relative_year_offset(relative_year_match.group("relative_year"))
|
|
1002
|
+
return _year_bounds(year)
|
|
1003
|
+
if re.search(r"(全年|年内|全年内)", question):
|
|
1004
|
+
return _year_bounds(_today().year)
|
|
1005
|
+
year_match = re.search(r"(?P<year>20\d{2})\s*年(?:全年|年内|全年内)?", question)
|
|
1006
|
+
if year_match is None:
|
|
1007
|
+
return None
|
|
1008
|
+
return _year_bounds(int(year_match.group("year")))
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _year_bounds(year: int) -> tuple[str, str]:
|
|
1012
|
+
return _format_datetime(date(year, 1, 1)), _format_datetime(date(year + 1, 1, 1))
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _relative_year_offset(relative_year: str) -> int:
|
|
1016
|
+
offsets = {
|
|
1017
|
+
"今年": 0,
|
|
1018
|
+
"去年": -1,
|
|
1019
|
+
"前年": -2,
|
|
1020
|
+
"明年": 1,
|
|
1021
|
+
"后年": 2,
|
|
1022
|
+
}
|
|
1023
|
+
return offsets[relative_year]
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _month_bounds(year: int, month: int) -> tuple[str, str] | None:
|
|
1027
|
+
if not 1 <= month <= 12:
|
|
1028
|
+
return None
|
|
1029
|
+
start = date(year, month, 1)
|
|
1030
|
+
end = date(year + 1, 1, 1) if month == 12 else date(year, month + 1, 1)
|
|
1031
|
+
return _format_datetime(start), _format_datetime(end)
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _extract_explicit_date_range(question: str) -> tuple[str, str] | None:
|
|
1035
|
+
date_pattern = r"20\d{2}[-/]\d{1,2}[-/]\d{1,2}"
|
|
1036
|
+
match = re.search(rf"(?P<start>{date_pattern})\s*(?:到|至|~|~)\s*(?P<end>{date_pattern})", question)
|
|
1037
|
+
if match is None:
|
|
1038
|
+
match = re.search(rf"(?P<start>{date_pattern})\s+-\s+(?P<end>{date_pattern})", question)
|
|
1039
|
+
if match is None:
|
|
1040
|
+
return None
|
|
1041
|
+
start = _parse_date(match.group("start"))
|
|
1042
|
+
end = _parse_date(match.group("end"))
|
|
1043
|
+
if start is None or end is None or end < start:
|
|
1044
|
+
return None
|
|
1045
|
+
return _format_datetime(start), _format_datetime(end + timedelta(days=1))
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _parse_date(value: str) -> date | None:
|
|
1049
|
+
normalized = value.replace("/", "-")
|
|
1050
|
+
parts = normalized.split("-")
|
|
1051
|
+
if len(parts) != 3:
|
|
1052
|
+
return None
|
|
1053
|
+
try:
|
|
1054
|
+
return date(int(parts[0]), int(parts[1]), int(parts[2]))
|
|
1055
|
+
except ValueError:
|
|
1056
|
+
return None
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _format_datetime(value: date) -> str:
|
|
1060
|
+
return f"{value.isoformat()} 00:00:00"
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def _today() -> date:
|
|
1064
|
+
return date.today()
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _extract_location_for_recruiting_job_question(question: str) -> str | None:
|
|
1068
|
+
if not _is_recruiting_job_question(question):
|
|
1069
|
+
return None
|
|
1070
|
+
text = re.sub(r"(查询|查看|看看|列出|给我|帮我查|当前|目前|现在)", "", question)
|
|
1071
|
+
text = re.sub(r"(所有|全部|每个|各个)", "", text)
|
|
1072
|
+
patterns = [
|
|
1073
|
+
r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:有)?(?:多少|几|数量|总数).*?(?:在招|招聘)(?:岗位|职位)",
|
|
1074
|
+
r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:的)?(?:在招|招聘)(?:岗位|职位)",
|
|
1075
|
+
r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:有|有哪些|有什么).*?(?:在招|招聘)(?:岗位|职位)",
|
|
1076
|
+
r"(?:在|位于)(?P<location>[\u4e00-\u9fff]{2,12}?)(?:的)?(?:岗位|职位)",
|
|
1077
|
+
]
|
|
1078
|
+
for pattern in patterns:
|
|
1079
|
+
match = re.search(pattern, text)
|
|
1080
|
+
if match is not None:
|
|
1081
|
+
return _clean_location_candidate(match.group("location"), require_location_signal=True)
|
|
1082
|
+
return None
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def _is_recruiting_job_question(question: str) -> bool:
|
|
1086
|
+
return bool(re.search(r"(岗位|职位|招聘|在招)", question))
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _is_recruiting_remaining_signup_query(question: str) -> bool:
|
|
1090
|
+
return bool("岗位" in question and "报名" in question and re.search(r"(剩余|还剩|余量|可报名)", question))
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _is_work_order_detail_query(question: str) -> bool:
|
|
1094
|
+
return bool(
|
|
1095
|
+
"品牌" in question
|
|
1096
|
+
and re.search(r"(报名|工单)", question)
|
|
1097
|
+
and re.search(r"(候选人状态|候选人|面试成功)", question)
|
|
1098
|
+
and re.search(r"(姓名|名字|岗位|职位|项目)", question)
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _extract_brand_filter(question: str) -> tuple[str, str] | None:
|
|
1103
|
+
contains_patterns = [
|
|
1104
|
+
r"品牌(?:名称)?(?:包含|含有|模糊匹配|like)(?P<brand>.+?)(?:,|,|。|;|;|的品牌|品牌信息|包括|返回|$)",
|
|
1105
|
+
r"(?:name|名称)\s+LIKE\s+[\"'“”‘’%]*(?P<brand>[\u4e00-\u9fffA-Za-z0-9()()·_\-]{1,40})",
|
|
1106
|
+
]
|
|
1107
|
+
for pattern in contains_patterns:
|
|
1108
|
+
match = re.search(pattern, question, flags=re.IGNORECASE)
|
|
1109
|
+
if match is None:
|
|
1110
|
+
continue
|
|
1111
|
+
brand = _clean_brand_candidate(match.group("brand"))
|
|
1112
|
+
if brand is not None:
|
|
1113
|
+
return "contains", brand
|
|
1114
|
+
|
|
1115
|
+
exact_patterns = [
|
|
1116
|
+
r"品牌(?:名称)?(?:为|是|=|等于)(?P<brand>.+?)(?:,|,|。|;|;|候选人|工单|报名|提供|输出|通过|包括|返回|$)",
|
|
1117
|
+
r"(?P<brand>[\u4e00-\u9fffA-Za-z0-9()()·_\-]{2,40})品牌(?:下|的)?",
|
|
1118
|
+
]
|
|
1119
|
+
for pattern in exact_patterns:
|
|
1120
|
+
match = re.search(pattern, question)
|
|
1121
|
+
if match is None:
|
|
1122
|
+
continue
|
|
1123
|
+
brand = _clean_brand_candidate(match.group("brand"))
|
|
1124
|
+
if brand is not None:
|
|
1125
|
+
return "exact", brand
|
|
1126
|
+
return None
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _extract_brand_name(question: str) -> str | None:
|
|
1130
|
+
brand_filter = _extract_brand_filter(question)
|
|
1131
|
+
return brand_filter[1] if brand_filter is not None else None
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _clean_brand_candidate(value: str) -> str | None:
|
|
1135
|
+
brand = re.sub(r"\s+", "", value)
|
|
1136
|
+
brand = brand.strip("\"'“”‘’%")
|
|
1137
|
+
brand = re.sub(r"^(名称为|名称是|为|是|=|等于)", "", brand)
|
|
1138
|
+
brand = re.sub(r"(的品牌信息|品牌信息|的品牌|品牌|的|下)$", "", brand)
|
|
1139
|
+
brand = brand.strip("\"'“”‘’%")
|
|
1140
|
+
if 2 <= len(brand) <= 40 and re.search(r"(查询|查看|给我|帮我|根据|roll-core|岗位|候选人|报名|入职|在招|招聘|数量|多少)", brand) is None:
|
|
1141
|
+
return brand
|
|
1142
|
+
return None
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _sql_references_brand_table(sql: str) -> bool:
|
|
1146
|
+
return "brand" in _sql_table_aliases(sql.lower()).values()
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def _sql_contains_brand_name_filter(sql: str, brand_name: str) -> bool:
|
|
1150
|
+
normalized = sql.lower()
|
|
1151
|
+
escaped = re.escape(_escape_sql_string(brand_name).lower())
|
|
1152
|
+
return bool(
|
|
1153
|
+
re.search(rf"\b[a-zA-Z_][a-zA-Z0-9_]*\.name\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized)
|
|
1154
|
+
or re.search(rf"\bname\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized)
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _is_monthly_recruiting_conversion_query(question: str) -> bool:
|
|
1159
|
+
return bool(
|
|
1160
|
+
re.search(r"(全年|年内|每月|月度)", question)
|
|
1161
|
+
and re.search(r"(报名人数|报名数)", question)
|
|
1162
|
+
and re.search(r"(入职人数|入职数|上岗人数|上岗数)", question)
|
|
1163
|
+
and re.search(r"(转化率|转化)", question)
|
|
1164
|
+
and "品牌" in question
|
|
1165
|
+
and "项目" in question
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def is_recruiting_match_effect_query(question: str) -> bool:
|
|
1170
|
+
return bool(
|
|
1171
|
+
"岗位" in question
|
|
1172
|
+
and re.search(r"(候选人|报名|入职|上岗)", question)
|
|
1173
|
+
and re.search(r"(匹配效果|报名多|入职少|在招多|报名少|候选人多|岗位少)", question)
|
|
1174
|
+
and re.search(r"(品牌|项目|城市)", question)
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _is_recruiting_supply_multi_metric_query(question: str) -> bool:
|
|
1179
|
+
return bool(
|
|
1180
|
+
_is_multi_metric_query(question)
|
|
1181
|
+
and re.search(r"(在招岗位数|岗位供给|招聘人数|招聘门店数)", question)
|
|
1182
|
+
and re.search(r"(品牌|项目|城市|区域)", question)
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _is_multi_metric_query(question: str) -> bool:
|
|
1187
|
+
metric_terms = (
|
|
1188
|
+
"岗位供给数量",
|
|
1189
|
+
"候选人供给数量",
|
|
1190
|
+
"报名人数",
|
|
1191
|
+
"入职人数",
|
|
1192
|
+
"报名转化率",
|
|
1193
|
+
"入职转化率",
|
|
1194
|
+
"在招岗位数",
|
|
1195
|
+
"招聘人数",
|
|
1196
|
+
"招聘门店数",
|
|
1197
|
+
)
|
|
1198
|
+
dimension_terms = ("品牌", "项目", "城市", "区域")
|
|
1199
|
+
metric_count = sum(1 for term in metric_terms if term in question)
|
|
1200
|
+
dimension_count = sum(1 for term in dimension_terms if term in question)
|
|
1201
|
+
has_list_separator = bool(re.search(r"[、,,]", question))
|
|
1202
|
+
return (metric_count >= 2) or (metric_count >= 1 and dimension_count >= 2 and has_list_separator)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _requires_recruiting_detail_joins(question: str) -> bool:
|
|
1206
|
+
if re.search(r"(明细|详情|包含|需要|显示|带上|返回)", question) is None:
|
|
1207
|
+
return False
|
|
1208
|
+
return bool(
|
|
1209
|
+
re.search(
|
|
1210
|
+
r"(门店|薪资|学历|经验|年龄|技能|用人要求|招聘人数|岗位类型|职能类别|工作年限|薪资范围|薪资类型|岗位描述)",
|
|
1211
|
+
question,
|
|
1212
|
+
)
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _question_requests_count(question: str) -> bool:
|
|
1217
|
+
if re.search(r"(每个|各个|分别|列表|明细|详情|有哪些|有什么|剩余|还剩|所有.*(?:岗位|职位)|全部.*(?:岗位|职位))", question):
|
|
1218
|
+
return False
|
|
1219
|
+
return bool(re.search(r"(多少|几|数量|总数|count|统计)", question, flags=re.IGNORECASE))
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _clean_location_candidate(candidate: str, *, require_location_signal: bool = False) -> str | None:
|
|
1223
|
+
cleaned = re.sub(r"[,,。;;??\s]", "", candidate)
|
|
1224
|
+
cleaned = re.sub(r"(有哪些|有什么|所有|全部|每个|各个|多少|数量|列表|明细|信息|数据|情况|都|的|有)$", "", cleaned)
|
|
1225
|
+
if not 2 <= len(cleaned) <= 12:
|
|
1226
|
+
return None
|
|
1227
|
+
if re.search(r"[\u4e00-\u9fff]", cleaned) is None:
|
|
1228
|
+
return None
|
|
1229
|
+
if re.search(r"(岗位|职位|招聘|在招|品牌|项目|查询|查看|当前|目前|现在)", cleaned):
|
|
1230
|
+
return None
|
|
1231
|
+
if _is_generic_recruiting_term(cleaned):
|
|
1232
|
+
return None
|
|
1233
|
+
if require_location_signal and not _has_location_signal(cleaned):
|
|
1234
|
+
return None
|
|
1235
|
+
return cleaned
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _has_location_signal(candidate: str) -> bool:
|
|
1239
|
+
if re.search(r"(省|市|区|县|镇|乡|街道|路|街|大道|广场|商圈|附近|地铁|站|商场|门店)$", candidate):
|
|
1240
|
+
return True
|
|
1241
|
+
return 2 <= len(candidate) <= 4 and not _is_generic_recruiting_term(candidate)
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _is_generic_recruiting_term(candidate: str) -> bool:
|
|
1245
|
+
generic_terms = {
|
|
1246
|
+
"兼职",
|
|
1247
|
+
"全职",
|
|
1248
|
+
"小时工",
|
|
1249
|
+
"暑假工",
|
|
1250
|
+
"寒假工",
|
|
1251
|
+
"长期工",
|
|
1252
|
+
"短期工",
|
|
1253
|
+
"服务员",
|
|
1254
|
+
"店员",
|
|
1255
|
+
"骑手",
|
|
1256
|
+
"厨师",
|
|
1257
|
+
"收银员",
|
|
1258
|
+
"营业员",
|
|
1259
|
+
"理货员",
|
|
1260
|
+
"分拣",
|
|
1261
|
+
"补货",
|
|
1262
|
+
"打包",
|
|
1263
|
+
"餐厅",
|
|
1264
|
+
"门店",
|
|
1265
|
+
"品牌",
|
|
1266
|
+
"项目",
|
|
1267
|
+
"高薪",
|
|
1268
|
+
"急招",
|
|
1269
|
+
"最新",
|
|
1270
|
+
"全部",
|
|
1271
|
+
"所有",
|
|
1272
|
+
"热门",
|
|
1273
|
+
"附近",
|
|
1274
|
+
"本周",
|
|
1275
|
+
"今天",
|
|
1276
|
+
"明天",
|
|
1277
|
+
}
|
|
1278
|
+
return candidate in generic_terms
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _escape_sql_string(value: str) -> str:
|
|
1282
|
+
return value.replace("\\", "\\\\").replace("'", "''")
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _province_name_matches(location: str) -> str:
|
|
1286
|
+
literal = f"'{location}'"
|
|
1287
|
+
return (
|
|
1288
|
+
f"p.name = {literal} OR "
|
|
1289
|
+
"REPLACE(REPLACE(REPLACE(REPLACE(p.name, '特别行政区', ''), '自治区', ''), '省', ''), '市', '') = "
|
|
1290
|
+
f"REPLACE(REPLACE(REPLACE(REPLACE({literal}, '特别行政区', ''), '自治区', ''), '省', ''), '市', '')"
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _city_name_matches(location: str) -> str:
|
|
1295
|
+
literal = f"'{location}'"
|
|
1296
|
+
return (
|
|
1297
|
+
f"c.name = {literal} OR "
|
|
1298
|
+
"REPLACE(REPLACE(REPLACE(c.name, '自治州', ''), '地区', ''), '市', '') = "
|
|
1299
|
+
f"REPLACE(REPLACE(REPLACE({literal}, '自治州', ''), '地区', ''), '市', '')"
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def _region_name_matches(location: str) -> str:
|
|
1304
|
+
literal = f"'{location}'"
|
|
1305
|
+
return (
|
|
1306
|
+
f"r.name = {literal} OR "
|
|
1307
|
+
"REPLACE(REPLACE(REPLACE(r.name, '自治县', ''), '区', ''), '县', '') = "
|
|
1308
|
+
f"REPLACE(REPLACE(REPLACE({literal}, '自治县', ''), '区', ''), '县', '')"
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def _enforce_chinese_select_aliases(sql: str) -> None:
|
|
1313
|
+
select_list = _top_level_select_list(sql)
|
|
1314
|
+
if select_list is None:
|
|
1315
|
+
raise SqlGenerationError("SQL must contain a top-level FROM")
|
|
1316
|
+
for item in _split_top_level_select_items(select_list):
|
|
1317
|
+
cleaned = item.strip()
|
|
1318
|
+
if not cleaned:
|
|
1319
|
+
continue
|
|
1320
|
+
alias_match = re.search(
|
|
1321
|
+
r"\s+as\s+(`[^`]+`|\"[^\"]+\"|'[^']+'|[\u4e00-\u9fff][\w\u4e00-\u9fff]*)\s*$",
|
|
1322
|
+
cleaned,
|
|
1323
|
+
flags=re.IGNORECASE,
|
|
1324
|
+
)
|
|
1325
|
+
if alias_match is None:
|
|
1326
|
+
raise SqlGenerationError("Every SELECT field must use AS with a Chinese alias")
|
|
1327
|
+
alias = alias_match.group(1).strip("`\"'")
|
|
1328
|
+
if re.search(r"[\u4e00-\u9fff]", alias) is None:
|
|
1329
|
+
raise SqlGenerationError("Every SELECT alias must contain Chinese characters")
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def _enforce_no_null_placeholder_select_items(sql: str) -> None:
|
|
1333
|
+
select_list = _top_level_select_list(sql)
|
|
1334
|
+
if select_list is None:
|
|
1335
|
+
raise SqlGenerationError("SQL must contain a top-level FROM")
|
|
1336
|
+
for item in _split_top_level_select_items(select_list):
|
|
1337
|
+
expression = _select_expression_without_alias(item)
|
|
1338
|
+
if _is_null_placeholder_expression(expression):
|
|
1339
|
+
raise SqlGenerationError("SELECT fields must not use NULL placeholders")
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _select_expression_without_alias(item: str) -> str:
|
|
1343
|
+
return re.sub(
|
|
1344
|
+
r"\s+as\s+(`[^`]+`|\"[^\"]+\"|'[^']+'|[\u4e00-\u9fff][\w\u4e00-\u9fff]*)\s*$",
|
|
1345
|
+
"",
|
|
1346
|
+
item.strip(),
|
|
1347
|
+
flags=re.IGNORECASE,
|
|
1348
|
+
).strip()
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def _is_null_placeholder_expression(expression: str) -> bool:
|
|
1352
|
+
normalized = expression.strip().rstrip(";")
|
|
1353
|
+
if re.fullmatch(r"null", normalized, flags=re.IGNORECASE):
|
|
1354
|
+
return True
|
|
1355
|
+
if re.fullmatch(r"cast\s*\(\s*null\s+as\s+[^)]+\)", normalized, flags=re.IGNORECASE):
|
|
1356
|
+
return True
|
|
1357
|
+
return bool(re.fullmatch(r"convert\s*\(\s*null\s*,\s*[^)]+\)", normalized, flags=re.IGNORECASE))
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def _top_level_select_list(sql: str) -> str | None:
|
|
1361
|
+
stripped = sql.strip().rstrip(";")
|
|
1362
|
+
match = re.match(r"select\s+(?:distinct\s+)?", stripped, flags=re.IGNORECASE)
|
|
1363
|
+
if match is None:
|
|
1364
|
+
return None
|
|
1365
|
+
start = match.end()
|
|
1366
|
+
from_index = _find_top_level_keyword(stripped, "from", start=start)
|
|
1367
|
+
if from_index is None:
|
|
1368
|
+
return None
|
|
1369
|
+
return stripped[start:from_index]
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def _find_top_level_keyword(sql: str, keyword: str, *, start: int) -> int | None:
|
|
1373
|
+
depth = 0
|
|
1374
|
+
quote: str | None = None
|
|
1375
|
+
keyword_lower = keyword.lower()
|
|
1376
|
+
index = start
|
|
1377
|
+
while index < len(sql):
|
|
1378
|
+
char = sql[index]
|
|
1379
|
+
if quote is not None:
|
|
1380
|
+
if char == quote:
|
|
1381
|
+
quote = None
|
|
1382
|
+
elif char == "\\":
|
|
1383
|
+
index += 1
|
|
1384
|
+
index += 1
|
|
1385
|
+
continue
|
|
1386
|
+
if char in ("'", '"', "`"):
|
|
1387
|
+
quote = char
|
|
1388
|
+
index += 1
|
|
1389
|
+
continue
|
|
1390
|
+
if char == "(":
|
|
1391
|
+
depth += 1
|
|
1392
|
+
index += 1
|
|
1393
|
+
continue
|
|
1394
|
+
if char == ")":
|
|
1395
|
+
depth = max(0, depth - 1)
|
|
1396
|
+
index += 1
|
|
1397
|
+
continue
|
|
1398
|
+
if depth == 0 and sql[index : index + len(keyword_lower)].lower() == keyword_lower:
|
|
1399
|
+
before = sql[index - 1] if index > 0 else " "
|
|
1400
|
+
after_index = index + len(keyword_lower)
|
|
1401
|
+
after = sql[after_index] if after_index < len(sql) else " "
|
|
1402
|
+
if not (before.isalnum() or before == "_") and not (after.isalnum() or after == "_"):
|
|
1403
|
+
return index
|
|
1404
|
+
index += 1
|
|
1405
|
+
return None
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def _split_top_level_select_items(select_list: str) -> list[str]:
|
|
1409
|
+
items: list[str] = []
|
|
1410
|
+
depth = 0
|
|
1411
|
+
quote: str | None = None
|
|
1412
|
+
start = 0
|
|
1413
|
+
for index, char in enumerate(select_list):
|
|
1414
|
+
if quote is not None:
|
|
1415
|
+
if char == quote:
|
|
1416
|
+
quote = None
|
|
1417
|
+
elif char == "\\":
|
|
1418
|
+
continue
|
|
1419
|
+
continue
|
|
1420
|
+
if char in ("'", '"', "`"):
|
|
1421
|
+
quote = char
|
|
1422
|
+
continue
|
|
1423
|
+
if char == "(":
|
|
1424
|
+
depth += 1
|
|
1425
|
+
continue
|
|
1426
|
+
if char == ")":
|
|
1427
|
+
depth = max(0, depth - 1)
|
|
1428
|
+
continue
|
|
1429
|
+
if char == "," and depth == 0:
|
|
1430
|
+
items.append(select_list[start:index])
|
|
1431
|
+
start = index + 1
|
|
1432
|
+
items.append(select_list[start:])
|
|
1433
|
+
return items
|