sophhub 0.4.1 → 0.4.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/agents/ai-cs-admin/AGENTS.md +1 -1
- package/package.json +1 -1
- package/skills/bot-secret/skill.json +27 -0
- package/skills/bot-secret/src/SKILL.md +28 -0
- package/skills/bot-secret/src/pyproject.toml +5 -0
- package/skills/bot-secret/src/scripts/secret.py +68 -0
- package/skills/image-description/skill.json +27 -0
- package/skills/image-description/src/SKILL.md +23 -0
- package/skills/image-description/src/pyproject.toml +8 -0
- package/skills/image-description/src/scripts/ana_image.py +107 -0
- package/skills/sessions-analysis/skill.json +34 -0
- package/skills/sessions-analysis/src/SKILL.md +81 -0
- package/skills/sessions-analysis/src/pyproject.toml +5 -0
- package/skills/sessions-analysis/src/scripts/ana_logs.py +206 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
`workspace-qa/memory/` 为知识库内容的访问映射,供客服 Agent 查询使用;知识库的实际维护以 `knowledge/` 目录为准。
|
|
12
12
|
|
|
13
|
-
可以通过调用 `sessions-analysis` skill 获取问答 Agent 的会话记录,问答 Agent 的会话记录存放在 `/home/node/.openclaw/agents/qa
|
|
13
|
+
可以通过调用 `sessions-analysis` skill 获取问答 Agent 的会话记录,问答 Agent 的会话记录存放在 `/home/node/.openclaw/agents/ai-cs-qa/sessions/` 目录下。
|
|
14
14
|
|
|
15
15
|
本会话面向管理员使用,默认依赖会话隔离进行访问控制,不额外要求口令认证;若部署环境发生变化,应由外层系统补充身份校验。
|
|
16
16
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bot-secret",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"types": [
|
|
5
|
+
"store"
|
|
6
|
+
],
|
|
7
|
+
"displayName": "客服秘钥管理",
|
|
8
|
+
"description": "管理客服秘钥",
|
|
9
|
+
"changelog": [
|
|
10
|
+
{
|
|
11
|
+
"changes": [
|
|
12
|
+
"pyproject.toml 版本与 skill 对齐"
|
|
13
|
+
],
|
|
14
|
+
"date": "2026-04-21",
|
|
15
|
+
"version": "1.0.1"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"changes": [
|
|
19
|
+
"初次提交"
|
|
20
|
+
],
|
|
21
|
+
"date": "2026-04-21",
|
|
22
|
+
"version": "1.0.0"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"createdAt": "2026-04-21",
|
|
26
|
+
"updatedAt": "2026-04-21"
|
|
27
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bot-secret
|
|
3
|
+
description: Query and reset the customer service (qa-agent) API Secret. Use when the user asks to view, query, or reset API Secret, 查询客服 API Secret, 重置 API Secret.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 客服子系统 API Secret 管理
|
|
7
|
+
|
|
8
|
+
查询或重置 qa-agent 的 API Secret(读写 openclaw 配置文件)。
|
|
9
|
+
|
|
10
|
+
## 用法
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# 查询
|
|
14
|
+
uv run {baseDir}/scripts/secret.py get
|
|
15
|
+
|
|
16
|
+
# 重置
|
|
17
|
+
uv run {baseDir}/scripts/secret.py reset
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 输出格式
|
|
21
|
+
|
|
22
|
+
- 获取成功:`当前API Secret为:xxx`
|
|
23
|
+
- 重置成功:`API Secret已重置为:yyy`
|
|
24
|
+
|
|
25
|
+
## 注意事项
|
|
26
|
+
|
|
27
|
+
- `reset` 会直接修改配置文件 `/home/node/.openclaw/openclaw.json`,执行前确认环境正确。
|
|
28
|
+
- 配置中缺少 `channels.bot-api.accounts.qa-agent` 时,`get` 无输出,`reset` 不会自动创建缺失节点。
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import secrets
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_api_secret(config_path: str = "/home/node/.openclaw/openclaw.json"):
|
|
8
|
+
if not os.path.exists(config_path):
|
|
9
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
10
|
+
try:
|
|
11
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
12
|
+
config = json.load(f)
|
|
13
|
+
except json.JSONDecodeError:
|
|
14
|
+
raise ValueError(f"Invalid JSON in config file: {config_path}")
|
|
15
|
+
channels = config.get("channels", {})
|
|
16
|
+
bot_api_channel_config = channels.get("bot-api", {})
|
|
17
|
+
if not bot_api_channel_config:
|
|
18
|
+
return None
|
|
19
|
+
accounts_config = bot_api_channel_config.get("accounts", {})
|
|
20
|
+
if not accounts_config:
|
|
21
|
+
return None
|
|
22
|
+
qa_agent_config = accounts_config.get("qa-agent", {})
|
|
23
|
+
if not qa_agent_config:
|
|
24
|
+
return None
|
|
25
|
+
return qa_agent_config.get("apiSecret", None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def reset_api_secret(config_path: str = "/home/node/.openclaw/openclaw.json"):
|
|
29
|
+
if not os.path.exists(config_path):
|
|
30
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
31
|
+
try:
|
|
32
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
33
|
+
config = json.load(f)
|
|
34
|
+
except json.JSONDecodeError:
|
|
35
|
+
raise ValueError(f"Invalid JSON in config file: {config_path}")
|
|
36
|
+
channels = config.get("channels", {})
|
|
37
|
+
bot_api_channel_config = channels.get("bot-api", {})
|
|
38
|
+
if not bot_api_channel_config:
|
|
39
|
+
return None
|
|
40
|
+
accounts_config = bot_api_channel_config.get("accounts", {})
|
|
41
|
+
if not accounts_config:
|
|
42
|
+
return None
|
|
43
|
+
qa_agent_config = accounts_config.get("qa-agent", {})
|
|
44
|
+
if not qa_agent_config:
|
|
45
|
+
return None
|
|
46
|
+
new_secret = secrets.token_hex(32)
|
|
47
|
+
qa_agent_config["apiSecret"] = new_secret
|
|
48
|
+
config["channels"]["bot-api"]["accounts"]["qa-agent"] = qa_agent_config
|
|
49
|
+
with open(config_path, "w", encoding="utf-8") as f:
|
|
50
|
+
json.dump(config, f, ensure_ascii=False, indent=4)
|
|
51
|
+
return new_secret
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
if len(sys.argv) != 2:
|
|
56
|
+
print("Usage: python secret.py <reset|get>")
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
if sys.argv[1] == "reset":
|
|
59
|
+
new_secret = reset_api_secret()
|
|
60
|
+
if new_secret is not None:
|
|
61
|
+
print(f"API Secret已重置为:{new_secret}")
|
|
62
|
+
elif sys.argv[1] == "get":
|
|
63
|
+
secret = get_api_secret()
|
|
64
|
+
if secret is not None:
|
|
65
|
+
print(f"当前API Secret为:{secret}")
|
|
66
|
+
else:
|
|
67
|
+
print("Usage: python secret.py <reset|get>")
|
|
68
|
+
sys.exit(1)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "image-description",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"types": [
|
|
5
|
+
"store"
|
|
6
|
+
],
|
|
7
|
+
"displayName": "图片描述",
|
|
8
|
+
"description": "生成图片描述",
|
|
9
|
+
"changelog": [
|
|
10
|
+
{
|
|
11
|
+
"changes": [
|
|
12
|
+
"在 pyproject.toml 中声明 sophnet-tools 依赖,并与 skill 版本对齐"
|
|
13
|
+
],
|
|
14
|
+
"date": "2026-04-21",
|
|
15
|
+
"version": "1.0.1"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"changes": [
|
|
19
|
+
"初次提交"
|
|
20
|
+
],
|
|
21
|
+
"date": "2026-04-21",
|
|
22
|
+
"version": "1.0.0"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"createdAt": "2026-04-21",
|
|
26
|
+
"updatedAt": "2026-04-21"
|
|
27
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: image-description
|
|
3
|
+
description: Generate image descriptions with VLM. Use when the user asks to describe images, 图像描述, 看图说话, or extract visible content from an image URL/base64/local file.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## 用法
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
# 使用 URL
|
|
10
|
+
uv run {baseDir}/scripts/ana_image.py "https://example.com/a.jpg"
|
|
11
|
+
|
|
12
|
+
# 使用本地文件(会先上传 OSS 再调用模型)
|
|
13
|
+
uv run {baseDir}/scripts/ana_image.py "/path/to/local.png"
|
|
14
|
+
|
|
15
|
+
# 使用 base64(直接透传到接口)
|
|
16
|
+
uv run {baseDir}/scripts/ana_image.py "data:image/iVBORw0KGgoAAAANSUhEUgAA..."
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 输出格式
|
|
21
|
+
|
|
22
|
+
- 成功:直接输出图片描述文本。
|
|
23
|
+
- 失败:抛出错误信息(HTTP 错误、网络错误、输入格式错误等)。
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import base64
|
|
4
|
+
import argparse
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
import sophnet_tools
|
|
8
|
+
|
|
9
|
+
API_URL = "https://www.sophnet.com/api/open-apis/v1/chat/completions"
|
|
10
|
+
MODEL = "Qwen2.5-VL-32B-Instruct"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _is_likely_base64(raw: str) -> bool:
|
|
14
|
+
text = raw.strip()
|
|
15
|
+
if not text or any(ch.isspace() for ch in text):
|
|
16
|
+
return False
|
|
17
|
+
try:
|
|
18
|
+
base64.b64decode(text, validate=True)
|
|
19
|
+
return True
|
|
20
|
+
except Exception:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalize_image_input(image_input: str, upload_timeout: int = 30) -> str:
|
|
25
|
+
text = image_input.strip()
|
|
26
|
+
if not text:
|
|
27
|
+
raise ValueError("图片输入不能为空")
|
|
28
|
+
|
|
29
|
+
if text.startswith(("http://", "https://")):
|
|
30
|
+
return text
|
|
31
|
+
|
|
32
|
+
if text.startswith("data:image/") and ";base64," in text:
|
|
33
|
+
return text
|
|
34
|
+
|
|
35
|
+
if os.path.exists(text):
|
|
36
|
+
return sophnet_tools.upload_oss(text, upload_timeout)
|
|
37
|
+
|
|
38
|
+
if _is_likely_base64(text):
|
|
39
|
+
return text
|
|
40
|
+
|
|
41
|
+
raise ValueError("无法识别图片输入:请传入 URL、base64 或本地文件路径")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def call_vlm(image_input: str) -> str:
|
|
45
|
+
api_key = sophnet_tools.get_api_key()
|
|
46
|
+
image_url = normalize_image_input(image_input)
|
|
47
|
+
|
|
48
|
+
payload = {
|
|
49
|
+
"messages": [
|
|
50
|
+
{
|
|
51
|
+
"role": "system",
|
|
52
|
+
"content": "详细描述图片中的所有内容,包括文字、物体、场景、人物等。直接描述,禁止废话",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"role": "user",
|
|
56
|
+
"content": [
|
|
57
|
+
{
|
|
58
|
+
"type": "image_url",
|
|
59
|
+
"image_url": {"url": image_url},
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
"model": MODEL,
|
|
65
|
+
"stream": False,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
body = json.dumps(payload).encode("utf-8")
|
|
69
|
+
request = urllib.request.Request(
|
|
70
|
+
url=API_URL,
|
|
71
|
+
data=body,
|
|
72
|
+
method="POST",
|
|
73
|
+
headers={
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
"Accept": "application/json",
|
|
76
|
+
"Authorization": f"Bearer {api_key}",
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
with urllib.request.urlopen(request, timeout=120) as response:
|
|
82
|
+
raw = response.read().decode("utf-8")
|
|
83
|
+
data = json.loads(raw)
|
|
84
|
+
except urllib.error.HTTPError as exc:
|
|
85
|
+
detail = exc.read().decode("utf-8", errors="ignore")
|
|
86
|
+
raise RuntimeError(f"HTTPError: {exc.code} {exc.reason} | {detail}") from exc
|
|
87
|
+
except urllib.error.URLError as exc:
|
|
88
|
+
raise RuntimeError(f"URLError: {exc.reason}") from exc
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
return data["choices"][0]["message"]["content"]
|
|
92
|
+
except (KeyError, IndexError, TypeError):
|
|
93
|
+
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_parser():
|
|
97
|
+
parser = argparse.ArgumentParser(description="调用 VLM 生成图片描述")
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"image",
|
|
100
|
+
help="图片输入,支持 http(s) URL、base64、data URL 或本地文件路径",
|
|
101
|
+
)
|
|
102
|
+
return parser
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
args = build_parser().parse_args()
|
|
107
|
+
print(call_vlm(args.image))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sessions-analysis",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"types": [
|
|
5
|
+
"store"
|
|
6
|
+
],
|
|
7
|
+
"displayName": "日志分析",
|
|
8
|
+
"description": "分析客服日志",
|
|
9
|
+
"changelog": [
|
|
10
|
+
{
|
|
11
|
+
"changes": [
|
|
12
|
+
"默认会话日志目录从 qa-agent 改为 ai-cs-qa;与 pyproject 版本对齐"
|
|
13
|
+
],
|
|
14
|
+
"date": "2026-04-21",
|
|
15
|
+
"version": "1.0.2"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"changes": [
|
|
19
|
+
"日志目录不存在或不可读时返回空结果;新增 pyproject.toml"
|
|
20
|
+
],
|
|
21
|
+
"date": "2026-04-21",
|
|
22
|
+
"version": "1.0.1"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"changes": [
|
|
26
|
+
"初次提交"
|
|
27
|
+
],
|
|
28
|
+
"date": "2026-04-21",
|
|
29
|
+
"version": "1.0.0"
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"createdAt": "2026-04-21",
|
|
33
|
+
"updatedAt": "2026-04-21"
|
|
34
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sessions-analysis
|
|
3
|
+
description: 查询指定日期的 QA 客服问答记录。当用户询问「昨天/今天客户问了哪些问题」「某天的问答记录」「QA 记录查询」时使用。默认从 ai-cs-qa 会话日志目录读取。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# QA 记录查询
|
|
7
|
+
|
|
8
|
+
从 ai-cs-qa 会话日志中按日期汇总用户提问与助手回答,用于查看某天的客服问答记录。
|
|
9
|
+
|
|
10
|
+
## Processing Mode
|
|
11
|
+
|
|
12
|
+
**STRICT SERIAL PROCESSING ONLY** — 单次顺序执行,不拆分子任务、不并行。
|
|
13
|
+
|
|
14
|
+
## Prerequisites
|
|
15
|
+
|
|
16
|
+
- Python 3.10+
|
|
17
|
+
- 日志目录可读(默认 `/home/node/.openclaw/agents/ai-cs-qa/sessions/`)
|
|
18
|
+
|
|
19
|
+
## 默认日志目录
|
|
20
|
+
|
|
21
|
+
- **默认路径**:`/home/node/.openclaw/agents/ai-cs-qa/sessions/`
|
|
22
|
+
- 支持当前日志(`*.jsonl`)与轮转日志(`*.jsonl.reset.YYYY-MM-DDTHH-MM-SS.*`)
|
|
23
|
+
- 若需使用其他目录,通过 `--log-dir` / `-d` 指定
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
当用户要查看某天的 QA 记录时:
|
|
28
|
+
|
|
29
|
+
1. **解析日期**:从用户表述推断查询日期(如「昨天」「今天」「2026-03-05」),格式统一为 `YYYY-MM-DD`。
|
|
30
|
+
2. **执行脚本**(在 workspace 或 skill 目录下用 uv 运行):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv run {baseDir}/scripts/ana_logs.py [YYYY-MM-DD] [--log-dir /path/to/sessions] [--json]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Parameters
|
|
37
|
+
|
|
38
|
+
| 参数 | 说明 | 默认值 |
|
|
39
|
+
|------|------|--------|
|
|
40
|
+
| `date` | 查询日期,格式 `YYYY-MM-DD` | 当天 |
|
|
41
|
+
| `--log-dir` / `-d` | 会话日志所在目录 | `/home/node/.openclaw/agents/ai-cs-qa/sessions/` |
|
|
42
|
+
| `--json` | 以 JSON 数组输出 | 否(人类可读文本) |
|
|
43
|
+
|
|
44
|
+
## Output
|
|
45
|
+
|
|
46
|
+
**不加 `--json` 时**(默认):
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
[YYYY-MM-DD HH:MM:SS] Q: 用户问题内容
|
|
50
|
+
A: 助手回答内容
|
|
51
|
+
|
|
52
|
+
[YYYY-MM-DD HH:MM:SS] Q: 下一个问题
|
|
53
|
+
A: 对应回答
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**加 `--json` 时**:输出 JSON 数组,每项形如 `{"Q": "问题", "DATE": "YYYY-MM-DD HH:MM:SS", "A": "回答"}`。
|
|
57
|
+
|
|
58
|
+
**向用户展示时建议**:先说明查询日期与记录条数,再按时间顺序列出「时间 - 问题 - 回答」摘要;若记录较多可只展示前 N 条并注明「共 N 条,仅展示前若干条」。
|
|
59
|
+
|
|
60
|
+
## Example Workflow
|
|
61
|
+
|
|
62
|
+
- 用户:「昨天客户都问了哪些问题」
|
|
63
|
+
1. 计算昨天日期,如 `2026-03-05`
|
|
64
|
+
2. 执行:`uv run {baseDir}/scripts/ana_logs.py 2026-03-05`
|
|
65
|
+
3. 解析输出,向用户汇总展示
|
|
66
|
+
|
|
67
|
+
- 用户:「查一下 3 月 4 号的 QA 记录」
|
|
68
|
+
1. 日期:`2026-03-04`
|
|
69
|
+
2. 执行:`uv run {baseDir}/scripts/ana_logs.py 2026-03-04`
|
|
70
|
+
3. 展示结果
|
|
71
|
+
|
|
72
|
+
- 用户:「从 /data/qa-logs 查今天的问答」
|
|
73
|
+
1. 日期:当天
|
|
74
|
+
2. 执行:`uv run {baseDir}/scripts/ana_logs.py --log-dir /data/qa-logs`
|
|
75
|
+
3. 展示结果
|
|
76
|
+
|
|
77
|
+
## Notes
|
|
78
|
+
|
|
79
|
+
- 脚本仅读取可能包含该日期的日志文件(当前日志视为含「今天+昨天」,轮转日志按文件名日期判断),再按记录内时间戳过滤到指定日期。
|
|
80
|
+
- 若目录不存在或无可读日志,脚本不会报错但结果为空;此时可提示用户检查日志目录或日期。
|
|
81
|
+
- `{baseDir}` 指本 skill 根目录(如 `skills/sessions-analysis`),调用时替换为实际路径。
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
import json
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
# 列出文件夹中所有的日志文件(当前日志 + 轮转日志)
|
|
7
|
+
def list_logs(folder):
|
|
8
|
+
if not os.path.isdir(folder):
|
|
9
|
+
return []
|
|
10
|
+
try:
|
|
11
|
+
names = os.listdir(folder)
|
|
12
|
+
except OSError:
|
|
13
|
+
return []
|
|
14
|
+
logs = []
|
|
15
|
+
for file in names:
|
|
16
|
+
# 当前日志:xxx.jsonl
|
|
17
|
+
if file.endswith(".jsonl") and ".jsonl.reset." not in file:
|
|
18
|
+
logs.append(os.path.join(folder, file))
|
|
19
|
+
# 轮转日志:xxx.jsonl.reset.YYYY-MM-DDTHH-MM-SS.ZZZZ
|
|
20
|
+
elif ".jsonl.reset." in file:
|
|
21
|
+
logs.append(os.path.join(folder, file))
|
|
22
|
+
return logs
|
|
23
|
+
|
|
24
|
+
# 获取日志保存的时间戳,返回该文件覆盖的日期 (当天, 前一天)
|
|
25
|
+
def get_log_save_time(file_path: str):
|
|
26
|
+
"""返回 (date1, date2),表示该日志文件最多只包含 date1 和 date2 两天的数据。"""
|
|
27
|
+
name = os.path.basename(file_path)
|
|
28
|
+
# 当前日志:xxx.jsonl → 今天 + 昨天
|
|
29
|
+
if name.endswith(".jsonl") and ".jsonl.reset." not in name:
|
|
30
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
31
|
+
prev = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
|
32
|
+
return today, prev
|
|
33
|
+
# 轮转日志:xxx.jsonl.reset.YYYY-MM-DDTHH-MM-SS.703Z → 轮转日 + 轮转前一日
|
|
34
|
+
if ".jsonl.reset." in name:
|
|
35
|
+
part = name.split("reset.")[-1]
|
|
36
|
+
save_date = part.split("T")[0] # YYYY-MM-DD
|
|
37
|
+
try:
|
|
38
|
+
dt = datetime.strptime(save_date, "%Y-%m-%d")
|
|
39
|
+
prev = (dt - timedelta(days=1)).strftime("%Y-%m-%d")
|
|
40
|
+
return save_date, prev
|
|
41
|
+
except ValueError:
|
|
42
|
+
return None, None
|
|
43
|
+
return None, None
|
|
44
|
+
|
|
45
|
+
def extract_user_questions(path: str) -> List[dict]:
|
|
46
|
+
"""
|
|
47
|
+
从日志 jsonl 中提取用户提问及对应的回答。
|
|
48
|
+
返回格式: [{"Q": "问题", "DATE": "Y-M-D H:M:S", "A": "回答"}, ...]
|
|
49
|
+
只保留包含 message_id 的用户消息。
|
|
50
|
+
"""
|
|
51
|
+
records = []
|
|
52
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
53
|
+
for line in f:
|
|
54
|
+
line = line.strip()
|
|
55
|
+
if not line:
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
records.append(json.loads(line))
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
results: List[dict] = []
|
|
63
|
+
|
|
64
|
+
for i, record in enumerate(records):
|
|
65
|
+
if record.get("type") != "message":
|
|
66
|
+
continue
|
|
67
|
+
message = record.get("message", {})
|
|
68
|
+
if message.get("role") != "user":
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
question = None
|
|
72
|
+
date_str = None
|
|
73
|
+
|
|
74
|
+
for item in (message.get("content") or []):
|
|
75
|
+
if item.get("type") != "text":
|
|
76
|
+
continue
|
|
77
|
+
text = item.get("text", "")
|
|
78
|
+
if '"message_id"' not in text:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
lines = [l.strip() for l in text.splitlines() if l.strip()]
|
|
82
|
+
last_line = lines[-1] if lines else ""
|
|
83
|
+
question = None
|
|
84
|
+
|
|
85
|
+
# 格式一:末行形如 [Fri 2026-03-06 16:09 GMT+8] 算能有哪些产品
|
|
86
|
+
if last_line.startswith("[") and "]" in last_line:
|
|
87
|
+
time_part, question = last_line.split("]", 1)
|
|
88
|
+
question = question.strip()
|
|
89
|
+
ts = record.get("timestamp", "")
|
|
90
|
+
try:
|
|
91
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
92
|
+
dt_local = dt.astimezone()
|
|
93
|
+
date_str = dt_local.strftime("%Y-%m-%d %H:%M:%S")
|
|
94
|
+
except Exception:
|
|
95
|
+
date_str = time_part.strip("[] ").strip()
|
|
96
|
+
else:
|
|
97
|
+
# 格式二:Conversation info + 元数据 JSON 块 + 空行 + 问题(无方括号时间戳)
|
|
98
|
+
# 取最后一行作为问题,跳过纯 JSON/代码块行
|
|
99
|
+
for candidate in reversed(lines):
|
|
100
|
+
if candidate.startswith("```") or candidate.startswith("{") or candidate.startswith("}"):
|
|
101
|
+
continue
|
|
102
|
+
if "message_id" in candidate and "sender" in candidate:
|
|
103
|
+
continue
|
|
104
|
+
question = candidate.strip()
|
|
105
|
+
break
|
|
106
|
+
ts = record.get("timestamp", "")
|
|
107
|
+
try:
|
|
108
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
109
|
+
dt_local = dt.astimezone()
|
|
110
|
+
date_str = dt_local.strftime("%Y-%m-%d %H:%M:%S")
|
|
111
|
+
except Exception:
|
|
112
|
+
date_str = ""
|
|
113
|
+
if question:
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
if question is None:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# 向后收集 assistant 的文本回答,直到遇到下一个 user 消息
|
|
120
|
+
answer_parts = []
|
|
121
|
+
for j in range(i + 1, len(records)):
|
|
122
|
+
r = records[j]
|
|
123
|
+
if r.get("type") != "message":
|
|
124
|
+
continue
|
|
125
|
+
msg = r.get("message", {})
|
|
126
|
+
if msg.get("role") == "user":
|
|
127
|
+
break
|
|
128
|
+
if msg.get("role") == "assistant":
|
|
129
|
+
for item in (msg.get("content") or []):
|
|
130
|
+
if item.get("type") == "text" and item.get("text", "").strip():
|
|
131
|
+
answer_parts.append(item["text"].strip())
|
|
132
|
+
|
|
133
|
+
results.append({
|
|
134
|
+
"Q": question,
|
|
135
|
+
"DATE": date_str,
|
|
136
|
+
"A": "\n".join(answer_parts),
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
return results
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_daily_qa(date: str, log_folder: str="/home/node/.openclaw/agents/ai-cs-qa/sessions/") -> List[dict]:
|
|
143
|
+
"""
|
|
144
|
+
按日期汇总当天的 QA 结果。仅根据 get_log_save_time 读取可能包含该日期的日志文件。
|
|
145
|
+
|
|
146
|
+
参数:
|
|
147
|
+
date: 查询日期,格式 'YYYY-MM-DD'
|
|
148
|
+
log_folder: 日志文件所在目录
|
|
149
|
+
|
|
150
|
+
返回:
|
|
151
|
+
[{"Q": "问题", "DATE": "YYYY-MM-DD HH:MM:SS", "A": "回答"}, ...]
|
|
152
|
+
"""
|
|
153
|
+
daily_results: List[dict] = []
|
|
154
|
+
|
|
155
|
+
for log_path in list_logs(log_folder):
|
|
156
|
+
d1, d2 = get_log_save_time(log_path)
|
|
157
|
+
if d1 is None or date not in (d1, d2):
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
qas = extract_user_questions(log_path)
|
|
162
|
+
except FileNotFoundError:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
for item in qas:
|
|
166
|
+
dt_str = (item.get("DATE") or "").strip()
|
|
167
|
+
if dt_str.startswith(date):
|
|
168
|
+
daily_results.append(item)
|
|
169
|
+
|
|
170
|
+
return daily_results
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# 默认 QA 会话日志目录(可被 skill/CLI 覆盖)
|
|
174
|
+
DEFAULT_LOG_FOLDER = "/home/node/.openclaw/agents/ai-cs-qa/sessions/"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
import argparse
|
|
179
|
+
parser = argparse.ArgumentParser(description="查询指定日期的 QA 记录")
|
|
180
|
+
parser.add_argument(
|
|
181
|
+
"date",
|
|
182
|
+
nargs="?",
|
|
183
|
+
default=datetime.now().strftime("%Y-%m-%d"),
|
|
184
|
+
help="查询日期,格式 YYYY-MM-DD,默认今天",
|
|
185
|
+
)
|
|
186
|
+
parser.add_argument(
|
|
187
|
+
"--log-dir",
|
|
188
|
+
"-d",
|
|
189
|
+
default=DEFAULT_LOG_FOLDER,
|
|
190
|
+
help="日志目录,默认 %(default)s",
|
|
191
|
+
)
|
|
192
|
+
parser.add_argument(
|
|
193
|
+
"--json",
|
|
194
|
+
action="store_true",
|
|
195
|
+
help="以 JSON 数组输出",
|
|
196
|
+
)
|
|
197
|
+
args = parser.parse_args()
|
|
198
|
+
|
|
199
|
+
daily_qa = get_daily_qa(args.date, args.log_dir)
|
|
200
|
+
if args.json:
|
|
201
|
+
print(json.dumps(daily_qa, ensure_ascii=False, indent=2))
|
|
202
|
+
else:
|
|
203
|
+
for qa in daily_qa:
|
|
204
|
+
print(f"[{qa.get('DATE', '')}] Q: {qa.get('Q', '')}")
|
|
205
|
+
print(f" A: {qa.get('A', '')}")
|
|
206
|
+
print()
|