@roll-agent/octopus-agent 0.0.1 → 0.0.4
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 +448 -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
|
@@ -24,12 +24,10 @@ class SamplingSqlGenerator:
|
|
|
24
24
|
*,
|
|
25
25
|
question: str,
|
|
26
26
|
schema_context: SchemaContext,
|
|
27
|
-
scope_hints: dict[str, Any],
|
|
28
27
|
) -> GeneratedSql:
|
|
29
28
|
prompt = build_nl2sql_prompt(
|
|
30
29
|
question=question,
|
|
31
30
|
schema_context=schema_context,
|
|
32
|
-
scope_hints=scope_hints,
|
|
33
31
|
default_limit=self.default_limit,
|
|
34
32
|
max_limit=self.max_limit,
|
|
35
33
|
)
|
|
@@ -42,14 +40,13 @@ class SamplingSqlGenerator:
|
|
|
42
40
|
original_sql: str,
|
|
43
41
|
validation_error: dict[str, Any],
|
|
44
42
|
schema_context: SchemaContext,
|
|
45
|
-
scope_hints: dict[str, Any],
|
|
46
43
|
) -> GeneratedSql:
|
|
47
44
|
prompt = build_sql_repair_prompt(
|
|
48
45
|
question=question,
|
|
49
46
|
original_sql=original_sql,
|
|
50
47
|
validation_error=validation_error,
|
|
51
48
|
schema_context=schema_context,
|
|
52
|
-
|
|
49
|
+
default_limit=self.default_limit,
|
|
53
50
|
max_limit=self.max_limit,
|
|
54
51
|
)
|
|
55
52
|
return _parse_generated_sql(self.sampler(NL2SQL_SYSTEM_PROMPT, prompt))
|
|
@@ -62,19 +59,31 @@ def _parse_generated_sql(text: str) -> GeneratedSql:
|
|
|
62
59
|
sql = _extract_sql_text(text)
|
|
63
60
|
if sql is None:
|
|
64
61
|
raise
|
|
62
|
+
_require_select_sql(sql)
|
|
65
63
|
return GeneratedSql(sql=sql, tables=[], placeholders=[])
|
|
66
64
|
sql = payload.get("sql")
|
|
67
|
-
if
|
|
65
|
+
if isinstance(sql, str) and not sql.strip():
|
|
66
|
+
return GeneratedSql(sql="", tables=[], placeholders=[], used_columns=[], used_joins=[], metric_refs=[])
|
|
67
|
+
if not isinstance(sql, str):
|
|
68
68
|
fallback_sql = _extract_sql_text(text)
|
|
69
69
|
if fallback_sql is None:
|
|
70
70
|
raise SqlGenerationError("LLM output must contain sql")
|
|
71
|
+
_require_select_sql(fallback_sql)
|
|
71
72
|
return GeneratedSql(sql=fallback_sql, tables=[], placeholders=[])
|
|
73
|
+
sql = sql.strip()
|
|
74
|
+
_require_select_sql(sql)
|
|
72
75
|
tables = payload.get("tables") if isinstance(payload.get("tables"), list) else []
|
|
73
76
|
placeholders = payload.get("placeholders") if isinstance(payload.get("placeholders"), list) else []
|
|
77
|
+
used_columns = payload.get("usedColumns") if isinstance(payload.get("usedColumns"), list) else []
|
|
78
|
+
used_joins = payload.get("usedJoins") if isinstance(payload.get("usedJoins"), list) else []
|
|
79
|
+
metric_refs = payload.get("metricRefs") if isinstance(payload.get("metricRefs"), list) else []
|
|
74
80
|
return GeneratedSql(
|
|
75
|
-
sql=sql
|
|
81
|
+
sql=sql,
|
|
76
82
|
tables=[str(table) for table in tables],
|
|
77
83
|
placeholders=[str(placeholder) for placeholder in placeholders],
|
|
84
|
+
used_columns=[str(column) for column in used_columns],
|
|
85
|
+
used_joins=[str(join) for join in used_joins],
|
|
86
|
+
metric_refs=[str(metric) for metric in metric_refs],
|
|
78
87
|
)
|
|
79
88
|
|
|
80
89
|
|
|
@@ -110,3 +119,11 @@ def _extract_sql_text(text: str) -> str | None:
|
|
|
110
119
|
if sql.endswith(";"):
|
|
111
120
|
sql = sql[:-1].strip()
|
|
112
121
|
return sql
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _require_select_sql(sql: str) -> None:
|
|
125
|
+
normalized = sql.strip().lower()
|
|
126
|
+
if not normalized.startswith("select "):
|
|
127
|
+
raise SqlGenerationError("LLM SQL must be SELECT only")
|
|
128
|
+
if re.search(r"\b(insert|update|delete|drop|alter|create|truncate)\b", normalized):
|
|
129
|
+
raise SqlGenerationError("LLM SQL contains a forbidden keyword")
|
|
@@ -9,9 +9,7 @@ from .config import load_config_from_env
|
|
|
9
9
|
def main() -> None:
|
|
10
10
|
parser = argparse.ArgumentParser(prog="octopus-sponge")
|
|
11
11
|
parser.add_argument("question")
|
|
12
|
-
parser.add_argument("--user-id", type=int, required=True)
|
|
13
12
|
parser.add_argument("--session-id")
|
|
14
|
-
parser.add_argument("--device-id")
|
|
15
13
|
args = parser.parse_args()
|
|
16
14
|
|
|
17
15
|
config = load_config_from_env()
|
|
@@ -19,9 +17,7 @@ def main() -> None:
|
|
|
19
17
|
print(
|
|
20
18
|
agent.answer(
|
|
21
19
|
question=args.question,
|
|
22
|
-
user_id=args.user_id,
|
|
23
20
|
session_id=args.session_id,
|
|
24
|
-
device_id=args.device_id,
|
|
25
21
|
)
|
|
26
22
|
)
|
|
27
23
|
|
|
@@ -21,46 +21,28 @@ class SpongeMcpClient:
|
|
|
21
21
|
self,
|
|
22
22
|
*,
|
|
23
23
|
trace_id: str,
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
session_id: str | None = None,
|
|
25
|
+
schema_version: str | None = None,
|
|
26
26
|
) -> dict[str, Any]:
|
|
27
27
|
payload = {
|
|
28
28
|
"traceId": trace_id,
|
|
29
|
+
"sessionId": session_id,
|
|
29
30
|
"schemaVersion": schema_version,
|
|
30
|
-
"refreshReason": refresh_reason,
|
|
31
31
|
}
|
|
32
32
|
return self._post("get_schema", payload)
|
|
33
33
|
|
|
34
|
-
def get_allowed_context(
|
|
35
|
-
self,
|
|
36
|
-
*,
|
|
37
|
-
user_id: int,
|
|
38
|
-
trace_id: str,
|
|
39
|
-
session_id: str,
|
|
40
|
-
question: str,
|
|
41
|
-
device_id: str | None = None,
|
|
42
|
-
) -> dict[str, Any]:
|
|
43
|
-
payload = {
|
|
44
|
-
"userId": user_id,
|
|
45
|
-
"traceId": trace_id,
|
|
46
|
-
"sessionId": session_id,
|
|
47
|
-
"deviceId": device_id,
|
|
48
|
-
"question": question,
|
|
49
|
-
}
|
|
50
|
-
return self._post("get_allowed_context", payload)
|
|
51
|
-
|
|
52
34
|
def validate_sql(
|
|
53
35
|
self,
|
|
54
36
|
*,
|
|
55
|
-
user_id: int,
|
|
56
37
|
trace_id: str,
|
|
57
38
|
session_id: str,
|
|
39
|
+
question: str,
|
|
58
40
|
sql: str,
|
|
59
41
|
) -> dict[str, Any]:
|
|
60
42
|
payload = {
|
|
61
|
-
"userId": user_id,
|
|
62
43
|
"traceId": trace_id,
|
|
63
44
|
"sessionId": session_id,
|
|
45
|
+
"question": question,
|
|
64
46
|
"sql": sql,
|
|
65
47
|
}
|
|
66
48
|
return self._post("validate_sql", payload)
|
|
@@ -68,16 +50,16 @@ class SpongeMcpClient:
|
|
|
68
50
|
def execute_sql(
|
|
69
51
|
self,
|
|
70
52
|
*,
|
|
71
|
-
user_id: int,
|
|
72
53
|
trace_id: str,
|
|
73
54
|
session_id: str,
|
|
55
|
+
question: str,
|
|
74
56
|
sql: str,
|
|
75
57
|
timeout_ms: int | None = None,
|
|
76
58
|
) -> dict[str, Any]:
|
|
77
59
|
payload = {
|
|
78
|
-
"userId": user_id,
|
|
79
60
|
"traceId": trace_id,
|
|
80
61
|
"sessionId": session_id,
|
|
62
|
+
"question": question,
|
|
81
63
|
"sql": sql,
|
|
82
64
|
"timeoutMs": timeout_ms or self.timeout_ms,
|
|
83
65
|
}
|
|
@@ -8,37 +8,59 @@ from .schema_context_retriever import SchemaContext
|
|
|
8
8
|
|
|
9
9
|
NL2SQL_SYSTEM_PROMPT = """你是 Octopus 的 Sponge 数据查询 SQL 生成器。
|
|
10
10
|
你只能基于提供的 schema context 生成 MySQL SELECT 查询。
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
返回 JSON 中的 sql 字段必须直接以 SELECT 开头,不能返回非 SQL 文本。
|
|
12
|
+
只生成业务 SQL,不要生成权限占位符或权限条件。
|
|
13
|
+
不得使用 :scope.project_ids、:scope.brand_ids 或任何 :scope.* placeholder(占位符)。
|
|
14
|
+
如果用户没有明确要求返回条数,LIMIT 必须使用 defaultLimit。
|
|
13
15
|
不得查询 auth 库、密码、令牌、密钥、凭证字段。
|
|
14
|
-
不得生成 INSERT、UPDATE、DELETE、DDL 或多语句 SQL。
|
|
16
|
+
不得生成 INSERT、UPDATE、DELETE、DDL、非 SELECT SQL 或多语句 SQL。
|
|
17
|
+
SELECT 中每个输出字段都必须使用 AS 中文含义,例如 b.name AS 品牌名称。
|
|
18
|
+
禁止使用英文别名,例如 brand_name、project_name。
|
|
19
|
+
用户点名要求输出的字段,必须优先使用 schema context 中含义匹配的真实字段或真实关联表生成。
|
|
20
|
+
不得为了凑齐字段写 NULL AS 字段名、CAST(NULL AS ...) AS 字段名 等空占位表达式。
|
|
21
|
+
只有 schema context 中完全没有相关真实字段或关联表时,才允许不返回该字段;不要用空占位字段替代。
|
|
22
|
+
涉及业务指标或业务口径时,必须优先使用 schemaContext.metrics 中的 definition、aggregation 和 filters。
|
|
23
|
+
如果 schemaContext.metrics 没有给出对应业务口径,不得自行推断状态枚举、有效性过滤或跨轮次分割规则。
|
|
24
|
+
所有业务字段、状态枚举、状态映射、表关联方式必须来自 schemaContext.tables、schemaContext.joins、schemaContext.glossary、schemaContext.metrics 或 schemaContext.examples。
|
|
25
|
+
不得自行猜测状态值;例如“面试成功”“进行中”“已上岗”等状态必须使用 schemaContext 中给出的字段和枚举。
|
|
26
|
+
所有 JOIN 必须优先使用 schemaContext.joins;如果相关 JOIN 只出现在 schemaContext.metrics.filters 或 schemaContext.examples 中,也必须严格照用。
|
|
27
|
+
如果 schemaContext 没有明确给出表、字段、JOIN 或指标口径,不要猜测;返回 {"sql":"","tables":[],"placeholders":[],"usedColumns":[],"usedJoins":[],"metricRefs":[]}。
|
|
28
|
+
报名/工单/候选人状态/姓名/岗位/项目明细查询,必须优先使用 schemaContext 中的报名工单表和示例,不得退化为只查询 brand 品牌列表。
|
|
29
|
+
报名工单与岗位的关联方式必须来自 schemaContext;如果 schemaContext 指明 job_id 关联 job_basic_info.job_id,不得改用 job_basic_info.id。
|
|
30
|
+
地点条件必须先按业务字典判断层级:province.name 省份优先,其次 city.name 城市,再其次 region.name 区域。
|
|
31
|
+
用户提到南京、上海、江苏等地点时,不要先猜测为道路、商圈、门店或地址关键词;只有省/城市/区域都无法匹配时,才按地址关键词模糊查询。
|
|
32
|
+
岗位或门店地点查询优先使用 job_address.city_id、job_address.region_id、store.city_id、store.region_id 等结构化字段。
|
|
33
|
+
用户问题包含时间范围时,SQL 的 WHERE 条件必须直接包含对应时间字段过滤,不得先查询全量数据再补充时间范围。
|
|
34
|
+
时间字段按语义选择:创建/新建/新增使用 create_at,发布/上架使用 publish_at,下架使用 off_at,更新/修改使用 update_at。
|
|
15
35
|
必须包含 LIMIT,且 LIMIT 不超过 500。
|
|
16
|
-
|
|
36
|
+
不要使用 WITH;低版本 MySQL 不支持公共表表达式,请使用 JOIN 或派生表。
|
|
37
|
+
只输出 JSON,不输出解释文本、拒绝文本或 Markdown。"""
|
|
17
38
|
|
|
18
39
|
|
|
19
40
|
def build_nl2sql_prompt(
|
|
20
41
|
*,
|
|
21
42
|
question: str,
|
|
22
43
|
schema_context: SchemaContext,
|
|
23
|
-
scope_hints: dict[str, Any],
|
|
24
44
|
default_limit: int,
|
|
25
45
|
max_limit: int,
|
|
26
46
|
) -> str:
|
|
27
47
|
payload = {
|
|
28
48
|
"question": question,
|
|
29
|
-
"scopeHints": scope_hints,
|
|
30
49
|
"limits": {"defaultLimit": default_limit, "maxLimit": max_limit},
|
|
31
50
|
"schemaContext": {
|
|
32
51
|
"tables": schema_context.tables,
|
|
33
52
|
"joins": schema_context.joins,
|
|
34
53
|
"glossary": schema_context.glossary,
|
|
35
54
|
"metrics": schema_context.metrics,
|
|
36
|
-
"examples": schema_context.examples,
|
|
55
|
+
"examples": _business_examples(schema_context.examples),
|
|
37
56
|
},
|
|
38
57
|
"outputSchema": {
|
|
39
|
-
"sql": "SELECT ... LIMIT
|
|
58
|
+
"sql": f"SELECT column AS 中文含义 ... LIMIT {default_limit}",
|
|
40
59
|
"tables": ["table_name"],
|
|
41
|
-
"placeholders": [
|
|
60
|
+
"placeholders": [],
|
|
61
|
+
"usedColumns": ["table.column"],
|
|
62
|
+
"usedJoins": ["left_table.left_column = right_table.right_column"],
|
|
63
|
+
"metricRefs": ["schemaContext.metrics.name"],
|
|
42
64
|
},
|
|
43
65
|
}
|
|
44
66
|
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
@@ -50,7 +72,7 @@ def build_sql_repair_prompt(
|
|
|
50
72
|
original_sql: str,
|
|
51
73
|
validation_error: dict[str, Any],
|
|
52
74
|
schema_context: SchemaContext,
|
|
53
|
-
|
|
75
|
+
default_limit: int,
|
|
54
76
|
max_limit: int,
|
|
55
77
|
) -> str:
|
|
56
78
|
payload = {
|
|
@@ -58,20 +80,25 @@ def build_sql_repair_prompt(
|
|
|
58
80
|
"question": question,
|
|
59
81
|
"originalSql": original_sql,
|
|
60
82
|
"validationError": validation_error,
|
|
61
|
-
"
|
|
62
|
-
"limits": {"maxLimit": max_limit},
|
|
83
|
+
"limits": {"defaultLimit": default_limit, "maxLimit": max_limit},
|
|
63
84
|
"schemaContext": {
|
|
64
85
|
"tables": schema_context.tables,
|
|
65
86
|
"joins": schema_context.joins,
|
|
66
87
|
"glossary": schema_context.glossary,
|
|
67
88
|
"metrics": schema_context.metrics,
|
|
68
|
-
"examples": schema_context.examples,
|
|
89
|
+
"examples": _business_examples(schema_context.examples),
|
|
69
90
|
},
|
|
70
91
|
"outputSchema": {
|
|
71
|
-
"sql": "SELECT ... LIMIT
|
|
92
|
+
"sql": f"SELECT column AS 中文含义 ... LIMIT {default_limit}",
|
|
72
93
|
"tables": ["table_name"],
|
|
73
|
-
"placeholders": [
|
|
94
|
+
"placeholders": [],
|
|
95
|
+
"usedColumns": ["table.column"],
|
|
96
|
+
"usedJoins": ["left_table.left_column = right_table.right_column"],
|
|
97
|
+
"metricRefs": ["schemaContext.metrics.name"],
|
|
74
98
|
},
|
|
75
99
|
}
|
|
76
100
|
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
77
101
|
|
|
102
|
+
|
|
103
|
+
def _business_examples(examples: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
104
|
+
return [example for example in examples if ":scope." not in str(example.get("sql") or "")]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SQL_GENERATION_CACHE_VERSION = "v9"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class QuerySqlCache:
|
|
17
|
+
path: Path
|
|
18
|
+
ttl_minutes: int
|
|
19
|
+
|
|
20
|
+
def get(self, *, question: str, schema_version: str | None) -> str | None:
|
|
21
|
+
if self.ttl_minutes <= 0:
|
|
22
|
+
return None
|
|
23
|
+
entry = self._entries().get(_cache_key(question, schema_version))
|
|
24
|
+
if not isinstance(entry, dict):
|
|
25
|
+
return None
|
|
26
|
+
created_at = entry.get("createdAt")
|
|
27
|
+
sql = entry.get("sql")
|
|
28
|
+
if not isinstance(created_at, (int, float)) or not isinstance(sql, str):
|
|
29
|
+
return None
|
|
30
|
+
if time.time() - float(created_at) > self.ttl_minutes * 60:
|
|
31
|
+
return None
|
|
32
|
+
return sql
|
|
33
|
+
|
|
34
|
+
def delete(self, *, question: str, schema_version: str | None) -> None:
|
|
35
|
+
entries = self._entries()
|
|
36
|
+
key = _cache_key(question, schema_version)
|
|
37
|
+
if key not in entries:
|
|
38
|
+
return
|
|
39
|
+
del entries[key]
|
|
40
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
self.path.write_text(json.dumps(entries, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
42
|
+
|
|
43
|
+
def set(self, *, question: str, schema_version: str | None, sql: str) -> None:
|
|
44
|
+
if self.ttl_minutes <= 0:
|
|
45
|
+
return
|
|
46
|
+
entries = self._entries()
|
|
47
|
+
entries[_cache_key(question, schema_version)] = {
|
|
48
|
+
"questionKey": _normalize_question(question),
|
|
49
|
+
"schemaVersion": schema_version,
|
|
50
|
+
"sql": sql,
|
|
51
|
+
"createdAt": time.time(),
|
|
52
|
+
}
|
|
53
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
self.path.write_text(json.dumps(entries, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
55
|
+
|
|
56
|
+
def _entries(self) -> dict[str, Any]:
|
|
57
|
+
if not self.path.exists():
|
|
58
|
+
return {}
|
|
59
|
+
try:
|
|
60
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
return {}
|
|
63
|
+
return data if isinstance(data, dict) else {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cache_key(question: str, schema_version: str | None) -> str:
|
|
67
|
+
raw = f"{SQL_GENERATION_CACHE_VERSION}:{schema_version or ''}:{_normalize_question(question)}"
|
|
68
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _normalize_question(question: str) -> str:
|
|
72
|
+
return re.sub(r"\s+", "", question.strip().lower())
|
|
@@ -2,10 +2,12 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import html
|
|
4
4
|
import json
|
|
5
|
+
import re
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
|
|
8
|
-
SUPPORTED_RESULT_FORMATS = {"text", "yaml", "md", "markdown", "html"}
|
|
9
|
+
SUPPORTED_RESULT_FORMATS = {"text", "json", "yaml", "md", "markdown", "html"}
|
|
10
|
+
RESULT_ROW_DISPLAY_LIMIT = 500
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
def normalize_result_format(question: str, result_format: str | None = None) -> str:
|
|
@@ -15,12 +17,16 @@ def normalize_result_format(question: str, result_format: str | None = None) ->
|
|
|
15
17
|
raise ValueError(f"Unsupported resultFormat: {result_format}")
|
|
16
18
|
return "markdown" if normalized == "md" else normalized
|
|
17
19
|
lowered = question.lower()
|
|
20
|
+
if "json" in lowered:
|
|
21
|
+
return "json"
|
|
18
22
|
if "yaml" in lowered:
|
|
19
23
|
return "yaml"
|
|
20
24
|
if "html" in lowered:
|
|
21
25
|
return "html"
|
|
22
26
|
if "markdown" in lowered or " md" in lowered or "表格" in question:
|
|
23
27
|
return "markdown"
|
|
28
|
+
if _is_match_effect_report_question(question):
|
|
29
|
+
return "markdown"
|
|
24
30
|
return "text"
|
|
25
31
|
|
|
26
32
|
|
|
@@ -33,10 +39,14 @@ def render_result(question: str, result: dict[str, Any], result_format: str | No
|
|
|
33
39
|
columns = result.get("columns", [])
|
|
34
40
|
if row_count == 0:
|
|
35
41
|
return _format_empty(output_format)
|
|
36
|
-
sample = rows[:
|
|
42
|
+
sample = rows[:RESULT_ROW_DISPLAY_LIMIT] if isinstance(rows, list) else []
|
|
43
|
+
if output_format == "json":
|
|
44
|
+
return _render_json(row_count, bool(result.get("limited", False)), sample)
|
|
37
45
|
if output_format == "yaml":
|
|
38
46
|
return _render_yaml(question, row_count, bool(result.get("limited", False)), sample)
|
|
39
47
|
if output_format == "markdown":
|
|
48
|
+
if _is_match_effect_report_question(question):
|
|
49
|
+
return _render_match_effect_report(question, row_count, bool(result.get("limited", False)), sample, columns)
|
|
40
50
|
return _render_markdown(question, row_count, bool(result.get("limited", False)), sample, columns)
|
|
41
51
|
if output_format == "html":
|
|
42
52
|
return _render_html(question, row_count, bool(result.get("limited", False)), sample, columns)
|
|
@@ -44,6 +54,8 @@ def render_result(question: str, result: dict[str, Any], result_format: str | No
|
|
|
44
54
|
|
|
45
55
|
|
|
46
56
|
def _format_failure(output_format: str) -> str:
|
|
57
|
+
if output_format == "json":
|
|
58
|
+
return json.dumps({"success": False, "message": "查询失败。"}, ensure_ascii=False, indent=2) + "\n"
|
|
47
59
|
if output_format == "yaml":
|
|
48
60
|
return "success: false\nmessage: 查询失败。\n"
|
|
49
61
|
if output_format == "markdown":
|
|
@@ -54,6 +66,12 @@ def _format_failure(output_format: str) -> str:
|
|
|
54
66
|
|
|
55
67
|
|
|
56
68
|
def _format_empty(output_format: str) -> str:
|
|
69
|
+
if output_format == "json":
|
|
70
|
+
return json.dumps(
|
|
71
|
+
{"success": True, "rowCount": 0, "rows": [], "message": "没有查询到符合条件的数据。"},
|
|
72
|
+
ensure_ascii=False,
|
|
73
|
+
indent=2,
|
|
74
|
+
) + "\n"
|
|
57
75
|
if output_format == "yaml":
|
|
58
76
|
return "success: true\nrowCount: 0\nrows: []\nmessage: 没有查询到符合条件的数据。\n"
|
|
59
77
|
if output_format == "markdown":
|
|
@@ -63,6 +81,104 @@ def _format_empty(output_format: str) -> str:
|
|
|
63
81
|
return "没有查询到符合条件的数据。"
|
|
64
82
|
|
|
65
83
|
|
|
84
|
+
def _is_match_effect_report_question(question: str) -> bool:
|
|
85
|
+
return bool(
|
|
86
|
+
("匹配效果" in question or "分析报告" in question)
|
|
87
|
+
and "岗位" in question
|
|
88
|
+
and any(term in question for term in ("候选人", "报名", "入职", "上岗"))
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _render_match_effect_report(
|
|
93
|
+
question: str,
|
|
94
|
+
row_count: int,
|
|
95
|
+
limited: bool,
|
|
96
|
+
rows: list[Any],
|
|
97
|
+
columns: Any,
|
|
98
|
+
) -> str:
|
|
99
|
+
table_columns = _column_names(columns, rows)
|
|
100
|
+
period = _extract_report_period(question)
|
|
101
|
+
lines = [
|
|
102
|
+
f"# {period}岗位与候选人匹配效果分析报告",
|
|
103
|
+
"",
|
|
104
|
+
"## 完整分析报告",
|
|
105
|
+
"",
|
|
106
|
+
f"数据范围:{period};维度:品牌、项目、城市;口径:入职按上岗结果近似统计。",
|
|
107
|
+
f"返回 {row_count} 行。limited: {str(limited).lower()}",
|
|
108
|
+
]
|
|
109
|
+
if table_columns:
|
|
110
|
+
lines.extend(["", "|" + "|".join(_escape_markdown(column) for column in table_columns) + "|"])
|
|
111
|
+
lines.append("|" + "|".join("---" for _ in table_columns) + "|")
|
|
112
|
+
for row in rows:
|
|
113
|
+
lines.append("|" + "|".join(_escape_markdown(_cell_value(row, column)) for column in table_columns) + "|")
|
|
114
|
+
else:
|
|
115
|
+
lines.extend(["", "```json", json.dumps(rows, ensure_ascii=False, indent=2), "```"])
|
|
116
|
+
lines.extend(["", "## 总结关键发现", ""])
|
|
117
|
+
findings = _match_effect_findings(rows)
|
|
118
|
+
lines.extend(findings or ["- 暂未发现明显异常组合。"])
|
|
119
|
+
return "\n".join(lines)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _extract_report_period(question: str) -> str:
|
|
123
|
+
relative_month = re.search(r"(今年|去年|前年|明年|后年)\s*(\d{1,2})\s*月", question)
|
|
124
|
+
if relative_month is not None:
|
|
125
|
+
return f"{relative_month.group(1)}{int(relative_month.group(2))}月"
|
|
126
|
+
explicit_month = re.search(r"(20\d{2})\s*年\s*(\d{1,2})\s*月", question)
|
|
127
|
+
if explicit_month is not None:
|
|
128
|
+
return f"{explicit_month.group(1)}年{int(explicit_month.group(2))}月"
|
|
129
|
+
return "本期"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _match_effect_findings(rows: list[Any]) -> list[str]:
|
|
133
|
+
if not rows:
|
|
134
|
+
return []
|
|
135
|
+
buckets = {
|
|
136
|
+
"报名多但入职少": [],
|
|
137
|
+
"在招多但报名少": [],
|
|
138
|
+
"候选人多但岗位少": [],
|
|
139
|
+
}
|
|
140
|
+
for row in rows:
|
|
141
|
+
if not isinstance(row, dict):
|
|
142
|
+
continue
|
|
143
|
+
issue = str(row.get("问题类型", ""))
|
|
144
|
+
label = _dimension_label(row)
|
|
145
|
+
for bucket in buckets:
|
|
146
|
+
if bucket in issue:
|
|
147
|
+
buckets[bucket].append(label)
|
|
148
|
+
findings: list[str] = []
|
|
149
|
+
for bucket, labels in buckets.items():
|
|
150
|
+
if labels:
|
|
151
|
+
findings.append(f"- {bucket}:{_join_limited(labels)}。")
|
|
152
|
+
return findings
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _dimension_label(row: dict[str, Any]) -> str:
|
|
156
|
+
parts = [
|
|
157
|
+
str(row.get("品牌名称") or "未知品牌"),
|
|
158
|
+
str(row.get("项目名称") or "未知项目"),
|
|
159
|
+
str(row.get("城市名称") or "未知城市"),
|
|
160
|
+
]
|
|
161
|
+
return " / ".join(parts)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _join_limited(values: list[str], limit: int = 3) -> str:
|
|
165
|
+
visible = values[:limit]
|
|
166
|
+
suffix = f"等 {len(values)} 项" if len(values) > limit else ""
|
|
167
|
+
return "、".join(visible + ([suffix] if suffix else []))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _render_json(row_count: int, limited: bool, rows: list[Any]) -> str:
|
|
171
|
+
return json.dumps(
|
|
172
|
+
{
|
|
173
|
+
"rowCount": row_count,
|
|
174
|
+
"limited": limited,
|
|
175
|
+
"rows": rows,
|
|
176
|
+
},
|
|
177
|
+
ensure_ascii=False,
|
|
178
|
+
indent=2,
|
|
179
|
+
) + "\n"
|
|
180
|
+
|
|
181
|
+
|
|
66
182
|
def _render_yaml(question: str, row_count: int, limited: bool, rows: list[Any]) -> str:
|
|
67
183
|
lines = [
|
|
68
184
|
f"rowCount: {row_count}",
|