@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.
- package/package.json +4 -2
- package/skills/single-work-analysis/env.example +3 -3
- package/skills/single-work-analysis/references/config-templates/defaults.yaml +8 -19
- package/skills/single-work-analysis/references/prompt-contracts/{insight.md → analysis-bundle.md} +43 -8
- package/skills/single-work-analysis/scripts/core/analysis_adapter.py +384 -0
- package/skills/single-work-analysis/scripts/core/analysis_pipeline.py +399 -76
- package/skills/single-work-analysis/scripts/core/config_loader.py +18 -42
- package/skills/single-work-analysis/scripts/core/progress_report.py +163 -16
- package/skills/single-work-analysis/scripts/core/storage_router.py +24 -57
- package/skills/single-work-analysis/scripts/core/tikomni_common.py +13 -3
- package/skills/single-work-analysis/scripts/pipeline/asr/asr_pipeline.py +154 -7
- package/skills/single-work-analysis/scripts/pipeline/asr/poll_u2_task.py +3 -1
- package/skills/single-work-analysis/scripts/platform/douyin/run_douyin_single_video.py +243 -44
- package/skills/single-work-analysis/scripts/platform/xiaohongshu/run_xiaohongshu_extract.py +263 -25
- package/skills/single-work-analysis/scripts/writers/write_benchmark_card.py +244 -894
- package/skills/single-work-analysis/references/prompt-contracts/asr-clean.md +0 -28
- package/skills/single-work-analysis/references/prompt-contracts/cta.md +0 -24
- package/skills/single-work-analysis/references/prompt-contracts/hook.md +0 -25
- package/skills/single-work-analysis/references/prompt-contracts/structure.md +0 -25
- package/skills/single-work-analysis/references/prompt-contracts/style.md +0 -27
- package/skills/single-work-analysis/references/prompt-contracts/summary.md +0 -29
- 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.
|
|
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:
|
|
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:
|
|
47
|
+
max_retries: 0
|
package/skills/single-work-analysis/references/prompt-contracts/{insight.md → analysis-bundle.md}
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Prompt Contract ·
|
|
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
|
-
- `
|
|
40
|
-
- `
|
|
41
|
-
- `asr_clean
|
|
42
|
-
-
|
|
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
|
+
}
|