@geoly-ai/social-hub-cli 0.3.14 → 0.3.16

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.
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import importlib.util
5
+ import io
6
+ import json
7
+ from pathlib import Path
8
+ import sys
9
+ import unittest
10
+ from unittest import mock
11
+
12
+
13
+ SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "reddit_voc_volume.py"
14
+ SPEC = importlib.util.spec_from_file_location("reddit_voc_volume_under_test", SCRIPT)
15
+ assert SPEC is not None and SPEC.loader is not None
16
+ voc = importlib.util.module_from_spec(SPEC)
17
+ SPEC.loader.exec_module(voc)
18
+
19
+
20
+ class ArchivedCommentTests(unittest.TestCase):
21
+ def run_main(self, *args: str) -> tuple[int, dict]:
22
+ stdout = io.StringIO()
23
+ with mock.patch.object(sys, "argv", [str(SCRIPT), *args]), contextlib.redirect_stdout(stdout):
24
+ code = voc.main()
25
+ return code, json.loads(stdout.getvalue())
26
+
27
+ def test_post_and_comment_id_normalization(self) -> None:
28
+ self.assertEqual(
29
+ voc.normalize_reddit_post_id("https://www.reddit.com/r/example/comments/AbC123/a_slug/"),
30
+ "abc123",
31
+ )
32
+ self.assertEqual(voc.normalize_reddit_post_id("t3_ABC123"), "abc123")
33
+ self.assertEqual(voc.normalize_reddit_post_id("https://redd.it/AbC123"), "abc123")
34
+ self.assertEqual(voc.normalize_reddit_comment_id("t1_DeF456"), "def456")
35
+ with self.assertRaisesRegex(ValueError, "comment ID"):
36
+ voc.normalize_reddit_post_id("t1_def456")
37
+ with self.assertRaisesRegex(ValueError, "unsupported post URL host"):
38
+ voc.normalize_reddit_post_id("https://example.com/comments/abc123")
39
+ with self.assertRaisesRegex(ValueError, "maximum 500"):
40
+ voc.parse_reddit_comment_ids([f"c{index:x}" for index in range(501)])
41
+
42
+ def test_tree_mode_flattens_body_and_preserves_hierarchy(self) -> None:
43
+ payload = {
44
+ "data": [
45
+ {
46
+ "kind": "t1",
47
+ "data": {
48
+ "id": "root1",
49
+ "name": "t1_root1",
50
+ "link_id": "t3_post1",
51
+ "parent_id": "t3_post1",
52
+ "author": "alice",
53
+ "body": "Root comment",
54
+ "created_utc": 100,
55
+ "score": 9,
56
+ "subreddit": "example",
57
+ "replies": {
58
+ "data": {
59
+ "children": [
60
+ {
61
+ "kind": "t1",
62
+ "data": {
63
+ "id": "child1",
64
+ "name": "t1_child1",
65
+ "link_id": "t3_post1",
66
+ "parent_id": "t1_root1",
67
+ "author": "bob",
68
+ "body": "Child comment",
69
+ "created_utc": 101,
70
+ "score": 3,
71
+ "subreddit": "example",
72
+ },
73
+ },
74
+ {
75
+ "kind": "more",
76
+ "data": {
77
+ "id": "more1",
78
+ "parent_id": "t1_root1",
79
+ "count": 2,
80
+ "children": ["hidden1", "hidden2"],
81
+ },
82
+ },
83
+ ]
84
+ }
85
+ },
86
+ },
87
+ }
88
+ ]
89
+ }
90
+ with mock.patch.object(voc, "arctic_get", return_value=payload) as get:
91
+ code, out = self.run_main("comments", "https://reddit.com/r/example/comments/post1/slug")
92
+
93
+ self.assertEqual(code, 0)
94
+ self.assertEqual(get.call_args.args[0], "/api/comments/tree")
95
+ self.assertEqual(get.call_args.args[1]["link_id"], "t3_post1")
96
+ self.assertEqual(get.call_args.args[1]["limit"], 25_000)
97
+ self.assertEqual([item["id"] for item in out["items"]], ["root1", "child1"])
98
+ self.assertEqual(out["items"][0]["child_ids"], ["child1"])
99
+ self.assertEqual(out["items"][1]["parent_id"], "t1_root1")
100
+ self.assertEqual(out["items"][1]["depth"], 1)
101
+ self.assertEqual(out["items"][1]["tree_path"], [0, 0])
102
+ self.assertEqual(out["items"][1]["body"], "Child comment")
103
+ self.assertFalse(out["items"][1]["body_truncated"])
104
+ self.assertFalse(out["tree_complete"])
105
+ self.assertEqual(out["collapsed"][0]["children"], ["hidden1", "hidden2"])
106
+ self.assertFalse(out["archive_caveats"]["score_realtime"])
107
+
108
+ def test_search_mode_forwards_supported_filters(self) -> None:
109
+ with mock.patch.object(voc, "arctic_get", return_value={"data": []}) as get:
110
+ code, out = self.run_main(
111
+ "comments",
112
+ "t3_post1",
113
+ "--source",
114
+ "search",
115
+ "--body",
116
+ "battery life",
117
+ "--author",
118
+ "alice",
119
+ "--after",
120
+ "2024-01-01",
121
+ "--before",
122
+ "2024-02-01",
123
+ "--parent-id",
124
+ "top",
125
+ "--limit",
126
+ "10",
127
+ "--sort",
128
+ "desc",
129
+ )
130
+
131
+ self.assertEqual(code, 0)
132
+ self.assertEqual(out["retrieval_mode"], "search")
133
+ self.assertEqual(get.call_args.args[0], "/api/comments/search")
134
+ params = get.call_args.args[1]
135
+ self.assertEqual(params["link_id"], "post1")
136
+ self.assertEqual(params["body"], "battery life")
137
+ self.assertEqual(params["author"], "alice")
138
+ self.assertEqual(params["parent_id"], "")
139
+ self.assertEqual(get.call_args.kwargs["keep_empty_params"], {"parent_id"})
140
+ self.assertEqual(params["limit"], 10)
141
+ self.assertEqual(params["sort"], "desc")
142
+
143
+ def test_ids_mode_restores_requested_order(self) -> None:
144
+ payload = {
145
+ "data": [
146
+ {"id": "second2", "body": "second", "link_id": "t3_post1", "parent_id": "t3_post1"},
147
+ {"id": "first1", "body": "first", "link_id": "t3_post1", "parent_id": "t3_post1"},
148
+ ]
149
+ }
150
+ with mock.patch.object(voc, "arctic_get", return_value=payload) as get:
151
+ code, out = self.run_main("comments", "--ids", "t1_first1,second2")
152
+
153
+ self.assertEqual(code, 0)
154
+ self.assertEqual(get.call_args.args[0], "/api/comments/ids")
155
+ self.assertEqual(get.call_args.args[1]["ids"], "first1,second2")
156
+ self.assertEqual([item["id"] for item in out["items"]], ["first1", "second2"])
157
+
158
+ def test_tree_failure_and_malformed_payload_are_never_reported_complete(self) -> None:
159
+ with mock.patch.object(voc, "arctic_get", side_effect=voc.ArcticError("upstream unavailable")):
160
+ code, out = self.run_main("comments", "post1")
161
+ self.assertEqual(code, 1)
162
+ self.assertFalse(out["tree_complete"])
163
+ self.assertIn("upstream unavailable", out["errors"][0])
164
+
165
+ with mock.patch.object(voc, "arctic_get", return_value={"data": {}}):
166
+ code, out = self.run_main("comments", "post1")
167
+ self.assertEqual(code, 1)
168
+ self.assertFalse(out["tree_complete"])
169
+ self.assertIn("arctic_comments_tree_malformed_payload", out["errors"][0])
170
+
171
+ def test_malformed_id_and_out_of_range_limits_fail_before_network(self) -> None:
172
+ with mock.patch.object(voc, "arctic_get") as get, contextlib.redirect_stderr(io.StringIO()):
173
+ with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "--ids", "t3_wrongkind"]):
174
+ with self.assertRaisesRegex(SystemExit, "2"):
175
+ voc.main()
176
+ with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "post1", "--limit", "25001"]):
177
+ with self.assertRaisesRegex(SystemExit, "2"):
178
+ voc.main()
179
+ with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "post1", "--parent-id", "top"]):
180
+ with self.assertRaisesRegex(SystemExit, "2"):
181
+ voc.main()
182
+ with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "--ids", "comment1", "--source", "search"]):
183
+ with self.assertRaisesRegex(SystemExit, "2"):
184
+ voc.main()
185
+ get.assert_not_called()
186
+
187
+
188
+ if __name__ == "__main__":
189
+ unittest.main()
@@ -160,7 +160,6 @@ social-hub agent-teams list
160
160
  social-hub agent-teams create --slug my-team --name "My Agent Team" --apply
161
161
  social-hub agent-teams update -t <team-id> -j '{"name":"New Name"}' --apply
162
162
  social-hub agent-teams add-member -t <team-id> --user <userId> --role manager --apply
163
- social-hub agent-teams workspace-docs -t <team-id> # 只读:Hub 内工作区文档索引
164
163
  ```
165
164
 
166
165
  ## 通知渠道
@@ -40,10 +40,10 @@ social-hub version --json
40
40
  | style-marks / curator / style-guide | `social-hub-style-curator` |
41
41
  | 板块画像四眼 / 帖子标注 | `social-hub-style-profiles` |
42
42
  | 日历 / scheduled jobs | `social-hub-calendar-jobs` |
43
- | 发布闭环(brand→report) | `social-hub-publishing` |
43
+ | 发布闭环(草稿→计划→发布) | `social-hub-publishing` |
44
44
  | 评论发布前复审/优化(三层 gate 层③) | `social-hub-content-review` |
45
45
  | 帖子发布前审核/优化(post-review) | `social-hub-post-review` |
46
- | 事件 / dashboard / audit / reports | `social-hub-events-observability` |
46
+ | 事件 / dashboard / audit | `social-hub-events-observability` |
47
47
  | 图谱 / 合规 / 风控 | `social-hub-graph-compliance` |
48
48
  | settings / invites / members / api-keys | `social-hub-admin` |
49
49
  | brand-registry / brand-fit / trigger 注册表四眼 / brand-auth | `social-hub-admin` |
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: social-hub-events-observability
3
3
  description: >-
4
- 互动事件与可观测性:events append/list/export、dashboard summary、audit、reports
5
- 用户要记事件、查运营总览、审计或报告库时用本 skill。不用于草稿/计划创建(social-hub-publishing)
4
+ 互动事件与可观测性:events append/list/export、dashboard summary、audit。
5
+ 用户要记事件、查运营总览或审计时用本 skill。不用于草稿/计划创建(social-hub-publishing)
6
6
  或 OpenClaw 任务 claim(social-hub-ops-runtime)。
7
7
  metadata:
8
8
  cliVersion: ">=0.0.12"
@@ -55,21 +55,12 @@ social-hub dashboard summary -t <team-id> --brand <brand-uuid>
55
55
  social-hub audit list -t <team-id> -n 50 [--type <prefix>] [--actor agent] [--account <uuid>]
56
56
  ```
57
57
 
58
- ## 报告库
59
-
60
- ```bash
61
- social-hub reports list -t <team-id> -n 20
62
- social-hub reports create -t <team-id> -j '{"reportType":"content-weekly","title":"Weekly","source":"openclaw","contentMarkdown":"..."}'
63
- social-hub reports ingest -t <team-id> -j '{"reportType":"content-weekly","source":"external","traceId":"..."}'
64
- ```
65
-
66
58
  ## 写操作护栏
67
59
 
68
60
  | 操作 | 要求 |
69
61
  | -------------------------------- | ------------------------------------------- |
70
62
  | `events batch` | 先小批量或 `list` 抽样;确认 payload 结构 |
71
63
  | `events import-openclaw-history` | **必须**先 `--dry-run`;`-f` 指向可读 JSONL |
72
- | `reports create` | 确认 `reportType` 与 team 权限 |
73
64
 
74
65
  ## 相关 skill
75
66
 
@@ -42,11 +42,16 @@ social-hub intelligence handoffs-list [--phase research] [--ready-only]
42
42
  social-hub intelligence comments-fetch --permalinks "https://www.reddit.com/r/espresso/comments/x1/a/,https://…"
43
43
  # 查询评论样本(含父子关系/关键词命中/intent/sentiment 标注)
44
44
  social-hub intelligence comments [--post-id t3_xxx] [--subreddit espresso] [--keyword aporro] [--intent complaint] [--sentiment negative]
45
+ # 评论样本**精确总数**(不封顶,无分页;不接受 keyword;昂贵读,禁止轮询)
46
+ # —— comments 列表的 total 被 10000 封顶(totalRelation=gte),要精确基线用这个
47
+ social-hub intelligence comments-count [--post-id t3_xxx] [--subreddit espresso] [--intent complaint] [--sentiment negative] [--origin hub_collector]
45
48
  ```
46
49
 
47
50
  评论正文与评论树在库(reddit*comment_samples);intent/sentiment 由标注 cron
48
51
  批量回写(需部署 REDDIT_COMMENT_REVIEW_LLM*\* key)。VOC 帖子任务默认不采评论,
49
52
  需要时对命中帖 comments-fetch,或用 coverage 的 needComments 判断缺口后派发。
53
+ 口径注意:comments / comments-count 数的是**库内样本行数**(sampleMode=partial_sample),
54
+ 不是 Reddit 线程真实评论总数。
50
55
 
51
56
  ## Emergency Acquisition 回灌(矩阵 skill 自爬数据写回)
52
57
 
@@ -67,15 +67,6 @@ social-hub openclaw-ingest -t <team-id> -j '{
67
67
 
68
68
  `sourcePaths` 中的路径默认相对 `sourceRoot`,也支持绝对路径。数组字段包括 `opReportsDirs` 和 `opReportFiles`。重复提交请提供 `idempotencyKey`;enqueue 成功后可通过 `status --run` 查看 `jobId` 与 run 状态。
69
69
 
70
- ## workspace 文档 refs
71
-
72
- 需 `OPENCLAW_WORKSPACE_REFS_ENABLED=true`。
73
-
74
- ```bash
75
- social-hub migration workspace-docs list -t <team-id>
76
- social-hub migration workspace-docs refs -t <team-id> --doc <document-uuid>
77
- ```
78
-
79
70
  **不要**在本 skill 内自动化批量 sync 整个 agent 工作区 `shared/`,除非用户明确要求并指定路径。
80
71
 
81
72
  运行时查询请用 `social-hub-openclaw-context`(Hub DB 真源),不是解析 Markdown。
@@ -85,10 +85,9 @@ social-hub calendar patch -t <team-id> -e <entryId> --body '{"status":"succeeded
85
85
  social-hub events append -t <team-id> --type post.published --payload '{"permalink":"..."}'
86
86
  ```
87
87
 
88
- ## 帖快照与报告
88
+ ## 帖快照
89
89
 
90
90
  - 帖快照 CRUD / `--brand` 统计:见 **`social-hub-posts`**
91
- - 报告库:`reports list` / `reports ingest`(`reportType: "content-weekly"`)
92
91
 
93
92
  ## 写操作护栏
94
93
 
@@ -13,11 +13,11 @@ description: >-
13
13
 
14
14
  ## ⚠️ 先看清楚:catalog / tier-rules 是**系统级共享**,aliases 才是团队级
15
15
 
16
- | 资源 | 层级 | 全局唯一键 | `-t <team>` 的作用 |
17
- | ------------ | -------------- | ------------------------------- | -------------------------------- |
18
- | `catalog` | **系统级共享** | `(industryKey, subreddit)` | 鉴权 + 新建行署名 + 列表视图过滤 |
19
- | `tier-rules` | **系统级共享** | `subreddit` | 鉴权 + 新建行署名 + 列表视图过滤 |
20
- | `aliases` | 团队级(真隔离) | `(team, aliasRef, industryKey)` | 真正的数据边界 |
16
+ | 资源 | 层级 | 全局唯一键 | `-t <team>` 的作用 |
17
+ | ------------ | -------------- | ------------------------------- | ----------------------------------- |
18
+ | `catalog` | **系统级共享** | `(industryKey, subreddit)` | 鉴权 + 新建行署名 + 列表视图过滤 |
19
+ | `tier-rules` | **系统级共享** | `subreddit` | 鉴权 + 新建行署名(**列表默认全量**) |
20
+ | `aliases` | 团队级(真隔离) | `(team, aliasRef, industryKey)` | 真正的数据边界 |
21
21
 
22
22
  对 catalog / tier-rules:
23
23
 
@@ -27,16 +27,14 @@ description: >-
27
27
  `tier-rules-delete` 的 `--id` 是**全局 id**,任何团队路径都能命中任何一行。
28
28
  所以 `catalog-list -t A` 列不出来的行,`catalog-delete -t A --id <行id>` 照样能改
29
29
  —— 这是系统级资源的固有结果,**不是 bug**。
30
- - `catalog-list` / `tier-rules-list` 的 `-t` 只是**按归属署名过滤列表视图**
31
- (一个 view filter,不是授权边界)。⚠️ **列表只能看到本团队署名的行,但改是全局的**
32
- —— 别把"列表里没有"当成"这个板块不存在"。
33
- ⚠️ `--industry-key` **绕不过**署名过滤(它只是再叠一个条件)。要跨团队查重,
34
- 必须显式加 `--aggregate-visible-teams`(`catalog-list` / `tier-rules-list` 都支持):
30
+ - `catalog-list` 的 `-t` 仍是**按归属署名过滤列表视图**(一个 view filter,不是授权
31
+ 边界)。⚠️ **列表只能看到本团队署名的行,但改是全局的** —— 别把"列表里没有"当成
32
+ "这个板块不存在"。⚠️ 加 `--industry-key` **绕不过**署名过滤(只是再叠一个条件)
33
+ 要跨团队查重,必须显式加 `--aggregate-visible-teams`:
35
34
 
36
35
  ```bash
37
36
  # 建行前查重:这个 (industryKey, subreddit) 是不是已经有别的团队录过了
38
37
  social-hub pools catalog-list -t <team-id> --aggregate-visible-teams --industry-key coffee -n 200
39
- social-hub pools tier-rules-list -t <team-id> --aggregate-visible-teams -n 200
40
38
  ```
41
39
 
42
40
  🔴 **它是「跨可见团队的署名聚合视图」,不是无条件全表**:范围 = 当前登录主体
@@ -45,9 +43,26 @@ description: >-
45
43
  但**不是**并发正确性保证。真正兜底重复的是 DB 的全局唯一约束 + upsert 语义:
46
44
  撞上了会**更新那一行**(见下),不会新建副本。
47
45
 
46
+ - 🔵 **`tier-rules-list` 已改成默认全量**(系统级口径):不带任何 flag 就能看到全库
47
+ 所有 S0–S3 规则,`-t` 只用于鉴权,不再过滤列表。`--aggregate-visible-teams` 对
48
+ `tier-rules-list` 变成 **no-op**(它只会缩小到"可见团队署名",比默认还窄),留着
49
+ 是为兼容老脚本,新脚本不用加。
50
+
51
+ 响应带 `scope` 字段,**这是安全语义,别忽略**:
52
+ - `scope: "system"` = 全量读 → **"列表里没有这个 subreddit" 才等于"没有 tier 记录"**,
53
+ 此时三级闸「查不到 tier → 按 S1 保守」才成立;
54
+ - `scope: "attribution"`(只有显式传 `scopeTeamId(s)` 时才会出现)= 署名过滤视图 →
55
+ **空集 ≠ 没有规则**,不能据此下"查不到"的结论。
56
+
57
+ ⚠️ 另一条同样重要:**"有行"不等于"该规则在生效"**。列表默认不过滤 `enabled`,
58
+ 停用行照样列出、带 `enabled: false`;worker 判定只取 `enabled=true`。看到
59
+ `enabled:false` 要按"无有效规则"(即 S1 保守)处理,不要当成放行依据。
60
+
48
61
  - `catalog-create` / `tier-rules-create` 是 **upsert**:提交一个已存在的
49
62
  `(industryKey, subreddit)` / `subreddit` 会**更新那一行**(可能是别的团队建的),
50
- 不会新建一份团队私有副本。
63
+ 不会新建一份团队私有副本。`tier-rules-create` 的响应带 `created`:
64
+ `created:true`(HTTP 201)= 真新建;`created:false`(HTTP 200)= **你刚改掉了一条
65
+ 已存在的规则**(署名仍是原团队)。看到 `created:false` 请回查那一行原来的值。
51
66
  - **归属署名(行上的 `teamId`)不可变**:只在真正新建时写入,后续任何团队编辑都不改它。
52
67
  想知道"谁改的"看审计,别看这一列。
53
68
  - ⚠️ **`catalog-delete` 实际是 archive**(`status='archived'`),而且影响**全局**:
@@ -67,7 +82,8 @@ social-hub pools aliases-create -t <team-id> -j '{"aliasRef":"persona_pools.P23.
67
82
  social-hub pools aliases-update -t <team-id> --id <alias-id> -j '{...}'
68
83
  social-hub pools aliases-delete -t <team-id> --id <alias-id>
69
84
 
70
- social-hub pools tier-rules-list -t <team-id> [--tier S0|S1|S2|S3] [--enabled true|false] [--aggregate-visible-teams]
85
+ # tier-rules-list 默认就是全库全量(响应带 scope:"system");--aggregate-visible-teams 是兼容用的 no-op
86
+ social-hub pools tier-rules-list -t <team-id> [--tier S0|S1|S2|S3] [--enabled true|false]
71
87
  social-hub pools tier-rules-create -t <team-id> -j '{...}'
72
88
  social-hub pools tier-rules-update -t <team-id> --id <rule-id> -j '{...}'
73
89
  social-hub pools tier-rules-delete -t <team-id> --id <rule-id>
@@ -100,6 +116,11 @@ social-hub pools tier-rules-delete -t <team-id> --id <rule-id>
100
116
  catalog 全局只有一行同 `(industryKey, subreddit)`,`-t` 不参与定位。改之前先用
101
117
  `catalog-list --aggregate-visible-teams`(或后台页面的跨团队聚合视图)确认这一行的
102
118
  归属与内容。
119
+ - `tier-rules-create` 回了 200 + `created:false`:说明这个 subreddit 已有规则,你这次
120
+ 是**更新**(不是"团队定位失效"、也不是新建失败)。该表全局只有一行同 `subreddit`。
121
+ - ⚠️ 旧命名空间 `GET /v1/teams/:teamId/subreddit-intelligence/tier-rules` **仍是默认
122
+ 按署名过滤**(未随本次改动切全量)。要全量口径请走 `pools tier-rules-list` /
123
+ `subreddit-pools/tier-rules`。
103
124
  - `tier-rules-update` 想把规则的 `subreddit` 改成另一条规则已占用的值 → 返回 409。
104
125
  以前这里会静默改到那另一条规则上、无视 URL 里的 `--id`;现在明确报冲突。
105
126
  - Pool Aliases 页面为空:先确认是否跑过 `db:seed` 或 persona pool bootstrap;再用 `aliases-list` 查当前 team,而不是只看 catalog。