@tikomni/skills 0.1.2 → 0.1.3

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.
Files changed (22) hide show
  1. package/package.json +4 -2
  2. package/skills/single-work-analysis/env.example +3 -3
  3. package/skills/single-work-analysis/references/config-templates/defaults.yaml +8 -19
  4. package/skills/single-work-analysis/references/prompt-contracts/{insight.md → analysis-bundle.md} +43 -8
  5. package/skills/single-work-analysis/scripts/core/analysis_adapter.py +384 -0
  6. package/skills/single-work-analysis/scripts/core/analysis_pipeline.py +399 -76
  7. package/skills/single-work-analysis/scripts/core/config_loader.py +18 -42
  8. package/skills/single-work-analysis/scripts/core/progress_report.py +163 -16
  9. package/skills/single-work-analysis/scripts/core/storage_router.py +24 -57
  10. package/skills/single-work-analysis/scripts/core/tikomni_common.py +13 -3
  11. package/skills/single-work-analysis/scripts/pipeline/asr/asr_pipeline.py +154 -7
  12. package/skills/single-work-analysis/scripts/pipeline/asr/poll_u2_task.py +3 -1
  13. package/skills/single-work-analysis/scripts/platform/douyin/run_douyin_single_video.py +243 -44
  14. package/skills/single-work-analysis/scripts/platform/xiaohongshu/run_xiaohongshu_extract.py +263 -25
  15. package/skills/single-work-analysis/scripts/writers/write_benchmark_card.py +244 -894
  16. package/skills/single-work-analysis/references/prompt-contracts/asr-clean.md +0 -28
  17. package/skills/single-work-analysis/references/prompt-contracts/cta.md +0 -24
  18. package/skills/single-work-analysis/references/prompt-contracts/hook.md +0 -25
  19. package/skills/single-work-analysis/references/prompt-contracts/structure.md +0 -25
  20. package/skills/single-work-analysis/references/prompt-contracts/style.md +0 -27
  21. package/skills/single-work-analysis/references/prompt-contracts/summary.md +0 -29
  22. package/skills/single-work-analysis/references/prompt-contracts/topic.md +0 -29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikomni/skills",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "TikOmni skill installer CLI for Codex, Claude Code, and OpenClaw",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/mark-ly-wang/TikOmni-Skills#readme",
@@ -13,7 +13,9 @@
13
13
  "files": [
14
14
  "bin/",
15
15
  "lib/",
16
- "skills/",
16
+ "skills/creator-analysis/",
17
+ "skills/meta-capability/",
18
+ "skills/single-work-analysis/",
17
19
  ".skill-package-allowlist.txt",
18
20
  "env.example",
19
21
  "README.md",
@@ -23,9 +23,9 @@ TIKOMNI_PATH_LOCALE="zh"
23
23
  # [EN] Work card filename prefix. Default: CBV
24
24
  TIKOMNI_CARD_PREFIX_WORK="CBV"
25
25
 
26
- # [ZH] 卡片文件名模板。默认值:{prefix}-{author_slug}-{title_slug}{ext}
27
- # [EN] Card filename pattern. Default: {prefix}-{author_slug}-{title_slug}{ext}
28
- TIKOMNI_FILENAME_PATTERN_CARD="{prefix}-{author_slug}-{title_slug}{ext}"
26
+ # [ZH] 卡片文件名模板。默认值:{prefix}-{platform}-{author_slug}-{title_slug}{ext}
27
+ # [EN] Card filename pattern. Default: {prefix}-{platform}-{author_slug}-{title_slug}{ext}
28
+ TIKOMNI_FILENAME_PATTERN_CARD="{prefix}-{platform}-{author_slug}-{title_slug}{ext}"
29
29
 
30
30
  # [ZH] JSON 文件名模板。默认值:{timestamp}-{platform}-{identifier}{ext}
31
31
  # [EN] JSON filename pattern. Default: {timestamp}-{platform}-{identifier}{ext}
@@ -14,10 +14,8 @@ storage_routes:
14
14
  errors_dir: "_errors"
15
15
  content_kind_card_type:
16
16
  single_video: work
17
+ note: work
17
18
  work: work
18
- author_home: author_sample_work
19
- author_sample_work: author_sample_work
20
- author_analysis: author
21
19
  card_type_routes:
22
20
  work:
23
21
  prefix: "CBV"
@@ -25,27 +23,18 @@ storage_routes:
25
23
  - "内容系统"
26
24
  - "对标研究"
27
25
  - "作品卡"
28
- author:
29
- prefix: "CBA"
30
- parts:
31
- - "内容系统"
32
- - "对标研究"
33
- - "作者卡"
34
- author_sample_work:
35
- prefix: "CBV"
36
- parts:
37
- - "内容系统"
38
- - "对标研究"
39
- - "作者样本卡"
40
- - "{platform}-{author_slug}"
41
26
 
42
27
  naming_rules:
43
- card_filename_pattern: "{prefix}-{author_slug}-{title_slug}{ext}"
28
+ card_filename_pattern: "{prefix}-{platform}-{author_slug}-{title_slug}{ext}"
44
29
  json_filename_pattern: "{timestamp}-{platform}-{identifier}{ext}"
45
30
 
31
+ analysis:
32
+ provider: auto
33
+ timeout_sec: 90
34
+
46
35
  asr_strategy:
47
36
  poll_interval_sec: 3.0
48
- max_polls: 30
37
+ max_polls: 10
49
38
  submit_retry:
50
39
  douyin_video:
51
40
  max_retries: 2
@@ -55,4 +44,4 @@ asr_strategy:
55
44
  backoff_ms: 0
56
45
  u2_timeout_retry:
57
46
  enabled: true
58
- max_retries: 3
47
+ max_retries: 0
@@ -1,4 +1,4 @@
1
- # Prompt Contract · 洞察分析(insight
1
+ # Prompt Contract · 单次结构化作品分析(analysis-bundle
2
2
 
3
3
  ## User Prompt (Verbatim)
4
4
 
@@ -32,16 +32,51 @@
32
32
  === 终极使命 ===
33
33
  帮助创作者建立"爆款直觉"——
34
34
  不是教他们追逐每一个热点,而是理解什么样的内容天生具有传播基因。
35
+
36
+ 任务:
37
+ 基于输入数据,一次性输出 5 个字段:
38
+ - topic
39
+ - style
40
+ - hook
41
+ - structure
42
+ - insight
43
+
44
+ 输出要求:
45
+ 1. 只输出一个 JSON 对象,不要输出 markdown,不要输出解释。
46
+ 2. 每个字段的值必须是字符串数组。
47
+ 3. 中文输出。
48
+ 4. 缺少数据时直接写“数据不足”。
49
+ 5. 只能基于输入字段推断,不得臆造事实。
50
+
51
+ JSON schema:
52
+ {
53
+ "topic": ["..."],
54
+ "style": ["..."],
55
+ "hook": ["..."],
56
+ "structure": ["..."],
57
+ "insight": ["..."]
58
+ }
59
+
60
+ 字段约束:
61
+ - topic:聚焦选题类型、细分主题、受众痛点
62
+ - style:聚焦句式、语气、人设表达
63
+ - hook:聚焦开头、中段、结尾钩子
64
+ - structure:聚焦结构标签、模板、缺失模块
65
+ - insight:聚焦情绪共振点/认知势能差/社交货币值/行动驱动力
66
+ 必须保证表达方式和分析美学的要求,而不是因为格式约束为了精炼而精炼
35
67
  ```
36
68
 
37
69
  ## Inputs
70
+ - `platform`
38
71
  - `title`
39
- - `asr_raw`(优先)
40
- - `raw_content`(`asr_raw` 缺失时回退)
41
- - `asr_clean`(遗留兼容字段)
42
- - 指标:`digg_count/comment_count/collect_count/share_count/play_count`
72
+ - `caption_raw`
73
+ - `asr_raw`
74
+ - `asr_clean`
75
+ - `work_modality`
76
+ - `tags`
77
+ - `metrics`
43
78
 
44
79
  ## Constraints
45
- - 仅基于输入字段推断,不得臆造事实。
46
- - 输出为中文,短句、可执行、可追溯。
47
- - 缺数据时明确标注“数据不足”。
80
+ - 不得输出思维链。
81
+ - 不得引用未提供的外部知识。
82
+ - 若输入文本稀缺,允许多处返回“数据不足”。
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env python3
2
+ """Host adapters for single-work structured analysis."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import signal
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+ import time
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+ from scripts.core.progress_report import ProgressReporter
17
+
18
+
19
+ SUPPORTED_PROVIDERS = {"auto", "openclaw", "claude_code", "codex", "local"}
20
+ DEFAULT_PROVIDER_ORDER = ["openclaw", "claude_code", "codex"]
21
+
22
+
23
+ def normalize_provider(value: Any) -> str:
24
+ text = str(value or "").strip().lower().replace("-", "_")
25
+ return text if text in SUPPORTED_PROVIDERS else "auto"
26
+
27
+
28
+ def resolve_preferred_provider(config: Optional[Dict[str, Any]] = None) -> str:
29
+ env_value = os.getenv("TIKOMNI_ANALYSIS_PROVIDER", "").strip()
30
+ configured = ""
31
+ if isinstance(config, dict):
32
+ configured = str(config.get("provider") or "").strip()
33
+ return normalize_provider(configured or env_value or "auto")
34
+
35
+
36
+ def resolve_analysis_timeout(config: Optional[Dict[str, Any]] = None, default: int = 30) -> int:
37
+ env_value = os.getenv("TIKOMNI_ANALYSIS_TIMEOUT_SEC", "").strip()
38
+ raw_value = env_value
39
+ if isinstance(config, dict) and str(config.get("timeout_sec") or "").strip():
40
+ raw_value = str(config.get("timeout_sec")).strip()
41
+ try:
42
+ parsed = int(raw_value or default)
43
+ except Exception:
44
+ parsed = default
45
+ return max(5, min(parsed, 90))
46
+
47
+
48
+ def is_provider_available(provider: str) -> bool:
49
+ normalized = normalize_provider(provider)
50
+ if normalized == "openclaw":
51
+ return bool(shutil.which("openclaw"))
52
+ if normalized == "claude_code":
53
+ return bool(shutil.which("claude"))
54
+ if normalized == "codex":
55
+ return bool(shutil.which("codex"))
56
+ if normalized == "local":
57
+ return True
58
+ return False
59
+
60
+
61
+ def choose_provider(preferred: str) -> Tuple[str, List[str]]:
62
+ normalized = normalize_provider(preferred)
63
+ if normalized == "local":
64
+ return "local", []
65
+ if normalized != "auto":
66
+ return normalized, [normalized]
67
+
68
+ available = [provider for provider in DEFAULT_PROVIDER_ORDER if is_provider_available(provider)]
69
+ if available:
70
+ return available[0], available
71
+ return "local", []
72
+
73
+
74
+ def _extract_json_object(text: str) -> Dict[str, Any]:
75
+ candidate = str(text or "").strip()
76
+ if not candidate:
77
+ return {}
78
+
79
+ fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", candidate, flags=re.S)
80
+ if fenced:
81
+ candidate = fenced.group(1).strip()
82
+
83
+ try:
84
+ parsed = json.loads(candidate)
85
+ return parsed if isinstance(parsed, dict) else {}
86
+ except Exception:
87
+ pass
88
+
89
+ start = candidate.find("{")
90
+ end = candidate.rfind("}")
91
+ if start >= 0 and end > start:
92
+ try:
93
+ parsed = json.loads(candidate[start : end + 1])
94
+ return parsed if isinstance(parsed, dict) else {}
95
+ except Exception:
96
+ return {}
97
+ return {}
98
+
99
+
100
+ def _extract_openclaw_text(stdout: str) -> str:
101
+ payload = _extract_json_object(stdout)
102
+ texts: List[str] = []
103
+ result = payload.get("result")
104
+ payloads = result.get("payloads", []) if isinstance(result, dict) else []
105
+ for item in payloads:
106
+ if not isinstance(item, dict):
107
+ continue
108
+ text = str(item.get("text") or "").strip()
109
+ if text:
110
+ texts.append(text)
111
+ return "\n".join(texts).strip()
112
+
113
+
114
+ def _run_command(
115
+ *,
116
+ cmd: List[str],
117
+ provider: str,
118
+ operation: str,
119
+ timeout_sec: int,
120
+ progress: Optional[ProgressReporter],
121
+ output_file: Optional[str] = None,
122
+ ) -> Dict[str, Any]:
123
+ started_at = time.perf_counter()
124
+ if progress is not None:
125
+ progress.subprocess_event(
126
+ stage="analysis.host",
127
+ provider=provider,
128
+ operation=operation,
129
+ event="started",
130
+ )
131
+
132
+ try:
133
+ process = subprocess.Popen(
134
+ cmd,
135
+ stdout=subprocess.PIPE,
136
+ stderr=subprocess.PIPE,
137
+ text=True,
138
+ start_new_session=True,
139
+ )
140
+ except Exception as error:
141
+ duration_ms = int((time.perf_counter() - started_at) * 1000)
142
+ if progress is not None:
143
+ progress.subprocess_event(
144
+ stage="analysis.host",
145
+ provider=provider,
146
+ operation=operation,
147
+ event="failed",
148
+ duration_ms=duration_ms,
149
+ summary={"error_reason": f"spawn_failed:{error}"},
150
+ )
151
+ return {
152
+ "ok": False,
153
+ "provider": provider,
154
+ "duration_ms": duration_ms,
155
+ "exit_code": None,
156
+ "stdout": "",
157
+ "stderr": "",
158
+ "error_reason": f"spawn_failed:{error}",
159
+ }
160
+
161
+ heartbeat_sec = 5.0
162
+ stdout = ""
163
+ stderr = ""
164
+ exit_code: Optional[int] = None
165
+ error_reason = ""
166
+
167
+ while True:
168
+ elapsed = time.perf_counter() - started_at
169
+ remaining = float(timeout_sec) - elapsed
170
+ if remaining <= 0:
171
+ try:
172
+ os.killpg(process.pid, signal.SIGKILL)
173
+ except Exception:
174
+ try:
175
+ process.kill()
176
+ except Exception:
177
+ pass
178
+ stdout, stderr = process.communicate()
179
+ exit_code = process.returncode
180
+ error_reason = "analysis_timeout"
181
+ break
182
+ try:
183
+ stdout, stderr = process.communicate(timeout=min(heartbeat_sec, remaining))
184
+ exit_code = process.returncode
185
+ break
186
+ except subprocess.TimeoutExpired:
187
+ if progress is not None:
188
+ progress.heartbeat(
189
+ stage="analysis.host",
190
+ message="analysis subprocess still running",
191
+ data={
192
+ "provider": provider,
193
+ "operation": operation,
194
+ "elapsed_ms": int((time.perf_counter() - started_at) * 1000),
195
+ },
196
+ )
197
+
198
+ duration_ms = int((time.perf_counter() - started_at) * 1000)
199
+ if not error_reason and int(exit_code or 0) != 0:
200
+ error_reason = f"subprocess_exit_{exit_code}"
201
+
202
+ if output_file and os.path.isfile(output_file):
203
+ try:
204
+ stdout = open(output_file, "r", encoding="utf-8").read()
205
+ except Exception:
206
+ pass
207
+
208
+ if progress is not None:
209
+ progress.subprocess_event(
210
+ stage="analysis.host",
211
+ provider=provider,
212
+ operation=operation,
213
+ event="done" if not error_reason else "failed",
214
+ duration_ms=duration_ms,
215
+ exit_code=exit_code,
216
+ summary={
217
+ "error_reason": error_reason or None,
218
+ "stdout_len": len(stdout or ""),
219
+ "stderr_len": len(stderr or ""),
220
+ },
221
+ )
222
+
223
+ return {
224
+ "ok": not bool(error_reason),
225
+ "provider": provider,
226
+ "duration_ms": duration_ms,
227
+ "exit_code": exit_code,
228
+ "stdout": stdout,
229
+ "stderr": stderr,
230
+ "error_reason": error_reason or None,
231
+ }
232
+
233
+
234
+ def _build_message(prompt_text: str, payload: Dict[str, Any]) -> str:
235
+ return (
236
+ "请严格执行下面的提示词契约,输出唯一 JSON 对象,不要输出 markdown 代码块,不要输出解释。\n\n"
237
+ "=== 提示词契约 ===\n"
238
+ f"{prompt_text}\n\n"
239
+ "=== 输入数据(JSON) ===\n"
240
+ f"{json.dumps(payload, ensure_ascii=False)}"
241
+ )
242
+
243
+
244
+ def _run_openclaw(
245
+ *,
246
+ prompt_text: str,
247
+ payload: Dict[str, Any],
248
+ timeout_sec: int,
249
+ progress: Optional[ProgressReporter],
250
+ ) -> Dict[str, Any]:
251
+ result = _run_command(
252
+ cmd=[
253
+ "openclaw",
254
+ "agent",
255
+ "--agent",
256
+ "main",
257
+ "--message",
258
+ _build_message(prompt_text, payload),
259
+ "--json",
260
+ ],
261
+ provider="openclaw",
262
+ operation="structured_analysis",
263
+ timeout_sec=timeout_sec,
264
+ progress=progress,
265
+ )
266
+ result["raw_text"] = _extract_openclaw_text(result.get("stdout", ""))
267
+ return result
268
+
269
+
270
+ def _run_codex(
271
+ *,
272
+ prompt_text: str,
273
+ payload: Dict[str, Any],
274
+ timeout_sec: int,
275
+ progress: Optional[ProgressReporter],
276
+ ) -> Dict[str, Any]:
277
+ output_file = tempfile.NamedTemporaryFile(prefix="single-work-analysis-", suffix=".txt", delete=False)
278
+ output_path = output_file.name
279
+ output_file.close()
280
+ try:
281
+ result = _run_command(
282
+ cmd=[
283
+ "codex",
284
+ "exec",
285
+ "--skip-git-repo-check",
286
+ "-o",
287
+ output_path,
288
+ _build_message(prompt_text, payload),
289
+ ],
290
+ provider="codex",
291
+ operation="structured_analysis",
292
+ timeout_sec=timeout_sec,
293
+ progress=progress,
294
+ output_file=output_path,
295
+ )
296
+ result["raw_text"] = str(result.get("stdout") or "").strip()
297
+ return result
298
+ finally:
299
+ try:
300
+ os.unlink(output_path)
301
+ except Exception:
302
+ pass
303
+
304
+
305
+ def _run_claude_code(
306
+ *,
307
+ prompt_text: str,
308
+ payload: Dict[str, Any],
309
+ timeout_sec: int,
310
+ progress: Optional[ProgressReporter],
311
+ ) -> Dict[str, Any]:
312
+ result = _run_command(
313
+ cmd=["claude", "-p", _build_message(prompt_text, payload)],
314
+ provider="claude_code",
315
+ operation="structured_analysis",
316
+ timeout_sec=timeout_sec,
317
+ progress=progress,
318
+ )
319
+ result["raw_text"] = str(result.get("stdout") or "").strip()
320
+ return result
321
+
322
+
323
+ def run_structured_analysis(
324
+ *,
325
+ prompt_text: str,
326
+ payload: Dict[str, Any],
327
+ provider: str,
328
+ timeout_sec: int,
329
+ progress: Optional[ProgressReporter] = None,
330
+ ) -> Dict[str, Any]:
331
+ preferred = normalize_provider(provider)
332
+ chosen_provider, attempted_providers = choose_provider(preferred)
333
+ if chosen_provider == "local":
334
+ return {
335
+ "ok": False,
336
+ "provider": "local",
337
+ "duration_ms": 0,
338
+ "attempted_providers": attempted_providers,
339
+ "error_reason": "analysis_provider_unavailable",
340
+ "structured": {},
341
+ }
342
+
343
+ if not is_provider_available(chosen_provider):
344
+ return {
345
+ "ok": False,
346
+ "provider": chosen_provider,
347
+ "duration_ms": 0,
348
+ "attempted_providers": attempted_providers or [chosen_provider],
349
+ "error_reason": "analysis_provider_unavailable",
350
+ "structured": {},
351
+ }
352
+
353
+ if chosen_provider == "openclaw":
354
+ result = _run_openclaw(
355
+ prompt_text=prompt_text,
356
+ payload=payload,
357
+ timeout_sec=timeout_sec,
358
+ progress=progress,
359
+ )
360
+ elif chosen_provider == "codex":
361
+ result = _run_codex(
362
+ prompt_text=prompt_text,
363
+ payload=payload,
364
+ timeout_sec=timeout_sec,
365
+ progress=progress,
366
+ )
367
+ else:
368
+ result = _run_claude_code(
369
+ prompt_text=prompt_text,
370
+ payload=payload,
371
+ timeout_sec=timeout_sec,
372
+ progress=progress,
373
+ )
374
+
375
+ raw_text = str(result.get("raw_text") or "")
376
+ structured = _extract_json_object(raw_text)
377
+ return {
378
+ "ok": bool(result.get("ok")) and bool(structured),
379
+ "provider": chosen_provider,
380
+ "duration_ms": int(result.get("duration_ms") or 0),
381
+ "attempted_providers": attempted_providers or [chosen_provider],
382
+ "error_reason": result.get("error_reason") or (None if structured else "analysis_parse_failed"),
383
+ "structured": structured if isinstance(structured, dict) else {},
384
+ }