feat: initial commit
This commit is contained in:
commit
a8bea67ad7
|
|
@ -0,0 +1,6 @@
|
|||
# Local runtime config for client-finder skill
|
||||
# Copy to .env.local in the same folder and fill real values.
|
||||
|
||||
AUTH_BASE=https://api-gw-test.yuanwei-lnc.com
|
||||
CLIENT_KEY=sk_xxx_replace_with_real_key
|
||||
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
name: register-skill-release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skill_slug:
|
||||
description: Skill slug override (optional)
|
||||
required: false
|
||||
skill_subpath:
|
||||
description: Skill folder path override (optional)
|
||||
required: false
|
||||
skill_doc_path:
|
||||
description: Skill doc path override
|
||||
required: false
|
||||
default: SKILL.md
|
||||
skill_version:
|
||||
description: Version override (default tag name)
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
register-skill-version:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
API_BASE: ${{ vars.API_BASE || secrets.API_BASE }}
|
||||
CLIENT_KEY: ${{ secrets.CLIENT_KEY }}
|
||||
SKILL_VERSION: ${{ github.event.inputs.skill_version || github.ref_name }}
|
||||
SKILL_SUBPATH: ${{ github.event.inputs.skill_subpath || vars.SKILL_SUBPATH || secrets.SKILL_SUBPATH }}
|
||||
SKILL_DOC_PATH: ${{ github.event.inputs.skill_doc_path || vars.SKILL_DOC_PATH || secrets.SKILL_DOC_PATH || 'SKILL.md' }}
|
||||
SKILL_SLUG: ${{ github.event.inputs.skill_slug || vars.SKILL_SLUG || secrets.SKILL_SLUG }}
|
||||
RELEASE_NOTE: ${{ github.event.release.body }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Load skill doc content
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DOC_ABS_PATH="${SKILL_SUBPATH:+$SKILL_SUBPATH/}${SKILL_DOC_PATH}"
|
||||
if [ ! -f "$DOC_ABS_PATH" ]; then
|
||||
if [ -f "${SKILL_SUBPATH:+$SKILL_SUBPATH/}README.md" ]; then
|
||||
DOC_ABS_PATH="${SKILL_SUBPATH:+$SKILL_SUBPATH/}README.md"
|
||||
export SKILL_DOC_PATH="README.md"
|
||||
else
|
||||
echo "skill doc not found: $DOC_ABS_PATH"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
jq -Rs . < "$DOC_ABS_PATH" > /tmp/skill_doc.json
|
||||
|
||||
- name: Register version to business system
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${API_BASE:-}" ]; then
|
||||
echo "API_BASE is required (global/repo var or secret)."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${CLIENT_KEY:-}" ]; then
|
||||
echo "CLIENT_KEY is required (secret)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SKILL_BASE_DIR="${SKILL_SUBPATH:-.}"
|
||||
|
||||
if [ -z "${SKILL_SLUG:-}" ]; then
|
||||
if [ -f "${SKILL_BASE_DIR}/package.json" ]; then
|
||||
PKG_NAME=$(jq -r '.name // empty' "${SKILL_BASE_DIR}/package.json")
|
||||
if [ -n "$PKG_NAME" ]; then
|
||||
# Strip npm scope: @scope/skill-name -> skill-name
|
||||
SKILL_SLUG="${PKG_NAME##*/}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${SKILL_SLUG:-}" ]; then
|
||||
if [ -f "${SKILL_BASE_DIR}/pyproject.toml" ]; then
|
||||
PYPROJECT_NAME=$(python3 -c "import sys,tomllib; p=sys.argv[1]; d=tomllib.load(open(p,'rb')); print((d.get('project',{}).get('name') or d.get('tool',{}).get('poetry',{}).get('name') or ''))" "${SKILL_BASE_DIR}/pyproject.toml" 2>/dev/null || true)
|
||||
if [ -n "$PYPROJECT_NAME" ]; then
|
||||
SKILL_SLUG="${PYPROJECT_NAME##*/}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${SKILL_SLUG:-}" ]; then
|
||||
SKILL_SLUG="${GITHUB_REPOSITORY##*/}"
|
||||
fi
|
||||
|
||||
SESSION_RES=$(curl -sS -X POST "${API_BASE}/auth/skill-credit/session" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"clientKey\":\"${CLIENT_KEY}\"}")
|
||||
ACCESS_TOKEN=$(printf '%s' "$SESSION_RES" | jq -r '.accessToken // empty')
|
||||
if [ -z "$ACCESS_TOKEN" ]; then
|
||||
echo "failed to exchange access token from client key"
|
||||
echo "$SESSION_RES"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RUNTIME_META=$(jq -nc --arg entry "${SKILL_SUBPATH:+$SKILL_SUBPATH/}scripts" '{entry_hint:$entry, provider:"forgejo"}')
|
||||
|
||||
cat > /tmp/register_payload.json <<JSON
|
||||
{
|
||||
"skill_slug": "${SKILL_SLUG}",
|
||||
"version": "${SKILL_VERSION}",
|
||||
"release_note": $(printf '%s' "${RELEASE_NOTE:-}" | jq -Rs .),
|
||||
"source_type": "git_ci",
|
||||
"repo_url": "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git",
|
||||
"repo_subpath": "${SKILL_SUBPATH:-}",
|
||||
"git_ref": "${GITHUB_REF_NAME}",
|
||||
"commit_sha": "${GITHUB_SHA}",
|
||||
"skill_doc_path": "${SKILL_DOC_PATH}",
|
||||
"skill_doc_content": $(cat /tmp/skill_doc.json),
|
||||
"runtime_meta": ${RUNTIME_META}
|
||||
}
|
||||
JSON
|
||||
|
||||
curl -sS -X POST "${API_BASE}/ecom/skills/register-by-slug" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/tmp/register_payload.json
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env.local
|
||||
*.skill
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
# Client Finder Async Skill
|
||||
|
||||
> 异步执行 client-finder 查询,不阻塞主 session
|
||||
|
||||
## 触发方式
|
||||
|
||||
### 方式 1: 通过 sessions_spawn 调用
|
||||
|
||||
```typescript
|
||||
sessions_spawn({
|
||||
runtime: "subagent",
|
||||
mode: "run",
|
||||
task: "client-finder-async",
|
||||
label: "client-finder: farm equipment Dallas",
|
||||
agentId: "main",
|
||||
cleanup: "keep"
|
||||
})
|
||||
```
|
||||
|
||||
### 方式 2: 直接调用脚本
|
||||
|
||||
```bash
|
||||
cd ~/clawd/skills/client-finder
|
||||
CLIENT_KEY=sk_xxx.yyy bun run scripts/run.ts "farm equipment Dallas" "us"
|
||||
```
|
||||
|
||||
## 输入参数
|
||||
|
||||
通过 task 传递 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "farm equipment parts Dallas",
|
||||
"country": "us",
|
||||
"client_key": "sk_ae28fc4e.2e0b218cf22ff1a27d52ab4601e1be5e1436f6932bd6282f"
|
||||
}
|
||||
```
|
||||
|
||||
## 输出
|
||||
|
||||
- **立即返回**: workflow ID 和状态
|
||||
- **异步推送**: 完整结果通过 webhook 推送
|
||||
|
||||
## 并发优势
|
||||
|
||||
| 方式 | 阻塞主 session | 并发能力 | 结果推送 |
|
||||
|------|--------------|---------|---------|
|
||||
| 直接调用 | ✅ 是 | ❌ 单线程 | ✅ webhook |
|
||||
| sub-agent | ❌ 否 | ✅ 多线程 | ✅ webhook |
|
||||
|
||||
## 批量查询示例
|
||||
|
||||
```typescript
|
||||
// 并发执行多个查询
|
||||
const queries = [
|
||||
"farm equipment Dallas",
|
||||
"tractor parts Texas",
|
||||
"agricultural machinery DFW"
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
sessions_spawn({
|
||||
runtime: "subagent",
|
||||
mode: "run",
|
||||
task: `client-finder: ${query}`,
|
||||
label: `client-finder: ${query}`,
|
||||
cleanup: "keep"
|
||||
});
|
||||
}
|
||||
// 主 session 立即空闲,可以处理其他请求
|
||||
```
|
||||
|
||||
## 监控子任务
|
||||
|
||||
```bash
|
||||
# 查看活跃的子 agent
|
||||
/subagents list
|
||||
|
||||
# 查看特定子任务的日志
|
||||
/subagents log <id>
|
||||
|
||||
# 停止子任务
|
||||
/subagents stop <id>
|
||||
```
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# 配置迁移说明
|
||||
|
||||
## v2.0 重大变更
|
||||
|
||||
从 v2.0 开始,本 skill 不再使用 `.env.local` 文件,改为使用全局配置文件 `~/.openclaw/.env`。
|
||||
|
||||
### 迁移步骤
|
||||
|
||||
1. **全局配置已自动创建**
|
||||
```bash
|
||||
~/.openclaw/.env
|
||||
```
|
||||
|
||||
2. **删除本地 .env.local**
|
||||
```bash
|
||||
rm .env.local
|
||||
```
|
||||
|
||||
3. **验证配置**
|
||||
```bash
|
||||
cat ~/.openclaw/.env
|
||||
```
|
||||
|
||||
### 优势
|
||||
|
||||
- ✅ 一处配置,所有 skill 共享
|
||||
- ✅ 更换 KEY 只需修改一个文件
|
||||
- ✅ 新 skill 无需重复配置
|
||||
|
||||
### 配置优先级
|
||||
|
||||
1. 命令行参数(最高)
|
||||
2. 全局配置 `~/.openclaw/.env`
|
||||
3. 默认值
|
||||
|
||||
### 保留 .env.example
|
||||
|
||||
`.env.example` 文件保留作为参考,展示可用的配置项。
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
# Client Finder - Bun + TypeScript Implementation
|
||||
|
||||
This document describes the Bun + TypeScript implementation of the client-finder skill.
|
||||
|
||||
## Overview
|
||||
|
||||
The client-finder skill is implemented with Bun + TypeScript, providing:
|
||||
|
||||
- Type safety
|
||||
- Modular structure
|
||||
- Easier testing
|
||||
- Robust error handling
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
client-finder/
|
||||
├── src/
|
||||
│ ├── index.ts # Main entry point and orchestration logic
|
||||
│ ├── expansion.ts # Query expansion logic (LLM and rule-based)
|
||||
│ ├── workflow.ts # Workflow API calls
|
||||
│ └── types.ts # TypeScript type definitions
|
||||
├── scripts/
|
||||
│ ├── run.ts # Bun CLI entry point
|
||||
│ ├── test.ts # Test suite
|
||||
│ └── skill-run-uat.sh # Endpoint UAT script
|
||||
├── package.json
|
||||
├── SKILL.md
|
||||
└── output_schema.json
|
||||
```
|
||||
|
||||
## Runtime Flow
|
||||
|
||||
1. Exchange token: `POST /auth/skill-credit/session` with `clientKey`
|
||||
2. Read `accessToken`
|
||||
3. Start workflow: `POST /ecom/cold-outreach/run-flow` with `Authorization: Bearer <accessToken>`
|
||||
4. If response indicates runtime session expired (`401/403`), `@clawd/auth-runtime` auto refreshes token and retries once
|
||||
5. Return accepted immediately
|
||||
|
||||
Note: this skill does not pass `webhook_url`/`webhook_token` in body. Webhook config is resolved from client-key binding by backend.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
bun run scripts/run.ts --client-key='<sk_xxx.yyy>' "office machine" "us"
|
||||
```
|
||||
|
||||
Dry-run:
|
||||
|
||||
```bash
|
||||
bun run scripts/run.ts --client-key='<sk_xxx.yyy>' "office machine" "us" --dry-run
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
- `AUTH_BASE` (default: `https://api-gw-test.yuanwei-lnc.com`)
|
||||
- `CLIENT_KEY` (required for live)
|
||||
- `QUERY_EXPANSION_JSON` (optional)
|
||||
- `AUTH_CACHE_DIR` (optional)
|
||||
- `AUTH_MIN_TTL_SEC` (optional)
|
||||
|
||||
`--client-key` and `--auth-base` CLI args override env values.
|
||||
|
||||
## Testing
|
||||
|
||||
Run unit-like checks:
|
||||
|
||||
```bash
|
||||
bun run test
|
||||
```
|
||||
|
||||
Run endpoint UAT:
|
||||
|
||||
```bash
|
||||
./scripts/skill-run-uat.sh --live
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Output must match `output_schema.json`.
|
||||
- This skill is async fire-and-return: no local polling/finalize/hook emission.
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
# Client-Finder Refactoring Summary
|
||||
|
||||
## Task Completed: Bash → Bun + TypeScript
|
||||
|
||||
Successfully refactored the client-finder skill from bash to Bun + TypeScript while maintaining full compatibility with the original implementation.
|
||||
|
||||
## Files Created
|
||||
|
||||
### Source Code (src/)
|
||||
- **types.ts** - TypeScript type definitions for all data structures
|
||||
- **auth-runtime.ts** - Authentication and token caching logic
|
||||
- **expansion.ts** - Query expansion (LLM and rule-based)
|
||||
- **workflow.ts** - Workflow API integration
|
||||
- **index.ts** - Main orchestration logic
|
||||
|
||||
### Scripts (scripts/)
|
||||
- **run.ts** - Bun CLI entry point (replaces cliet-finder.sh)
|
||||
- **test.ts** - Comprehensive test suite
|
||||
|
||||
### Configuration
|
||||
- **package.json** - Bun project configuration with npm scripts
|
||||
- **README.md** - Complete documentation and migration guide
|
||||
|
||||
### Documentation
|
||||
- **SKILL.md** - Updated with Bun usage instructions
|
||||
- **REFACTORING_SUMMARY.md** - This file
|
||||
|
||||
## Files Modified/Preserved
|
||||
|
||||
### Backup
|
||||
- **scripts/cliet-finder.sh.bak** - Original bash script backup
|
||||
|
||||
### Unchanged
|
||||
- **scripts/cliet-finder.sh** - Original bash script (preserved for reference)
|
||||
- **output_schema.json** - Output schema (unchanged)
|
||||
- All other bash scripts and reference documents
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
### 1. Full Type Safety
|
||||
- Complete TypeScript type annotations
|
||||
- Type checking at compile time
|
||||
- Better IDE support and autocomplete
|
||||
|
||||
### 2. Modular Architecture
|
||||
- Clear separation of concerns
|
||||
- Reusable components
|
||||
- Easy to maintain and extend
|
||||
|
||||
### 3. Authentication & Token Caching
|
||||
- SHA256-based cache keys
|
||||
- Configurable TTL
|
||||
- Automatic cache refresh
|
||||
- Compatible with bash version
|
||||
|
||||
### 4. Query Expansion
|
||||
- LLM-based expansion from QUERY_EXPANSION_JSON
|
||||
- Rule-based expansion with domain-specific logic
|
||||
- Query normalization and deduplication
|
||||
- Fallback to raw query on failure
|
||||
|
||||
### 5. Workflow Integration
|
||||
- API call to /ecom/cold-outreach/run-flow
|
||||
- Workflow ID extraction
|
||||
- Error handling and reporting
|
||||
|
||||
### 6. CLI Compatibility
|
||||
- Same command-line interface as bash version
|
||||
- Supports all original flags and arguments
|
||||
- Same output format (strictly compatible with output_schema.json)
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Suite Coverage
|
||||
1. ✓ Dry run with query
|
||||
2. ✓ Missing query error handling
|
||||
3. ✓ Query with country context
|
||||
4. ✓ Cold-outreach prefix normalization
|
||||
5. ✓ LLM expansion
|
||||
|
||||
### Run Tests
|
||||
```bash
|
||||
bun run test
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Usage
|
||||
```bash
|
||||
bun run scripts/run.ts "office machine" "us"
|
||||
```
|
||||
|
||||
### Dry Run
|
||||
```bash
|
||||
bun run scripts/run.ts "office machine" "us" --dry-run
|
||||
```
|
||||
|
||||
### With LLM Expansion
|
||||
```bash
|
||||
QUERY_EXPANSION_JSON='{"expandedQueries":["..."],"primaryQuery":"..."}' \
|
||||
bun run scripts/run.ts "query" "us"
|
||||
```
|
||||
|
||||
### Help
|
||||
```bash
|
||||
bun run scripts/run.ts --help
|
||||
```
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [x] All source files created and type-safe
|
||||
- [x] CLI interface matches bash version
|
||||
- [x] --dry-run mode works correctly
|
||||
- [x] QUERY_EXPANSION_JSON environment variable supported
|
||||
- [x] Output matches output_schema.json
|
||||
- [x] Token caching implemented and tested
|
||||
- [x] All tests passing
|
||||
- [x] Original bash script backed up
|
||||
- [x] Documentation updated (SKILL.md, README.md)
|
||||
- [x] README includes migration guide
|
||||
- [x] Code has complete TypeScript type annotations
|
||||
|
||||
## Bug Fixed
|
||||
|
||||
During implementation, discovered and fixed a critical bug in the expansion logic where the return statement was using an undefined variable name. This was caught during testing and resolved.
|
||||
|
||||
## Performance Benefits
|
||||
|
||||
- Bun's fast startup and execution
|
||||
- Token caching reduces API calls
|
||||
- Efficient JSON parsing and string operations
|
||||
- Minimal dependencies (only Bun runtime)
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- Original bash script preserved
|
||||
- All existing UAT tests remain functional
|
||||
- Same environment variables
|
||||
- Same command-line interface
|
||||
- Same output format
|
||||
|
||||
## Next Steps (Optional)
|
||||
|
||||
1. Consider removing .backup files after validation period
|
||||
2. Add more comprehensive integration tests
|
||||
3. Add optional verbose logging for debugging
|
||||
4. Consider adding metrics collection
|
||||
5. Explore parallel expansion strategies
|
||||
|
||||
## Conclusion
|
||||
|
||||
The refactoring is complete and all acceptance criteria have been met:
|
||||
|
||||
✅ Functionally equivalent to original bash script
|
||||
✅ Code has complete TypeScript type annotations
|
||||
✅ README updated with Bun running instructions
|
||||
✅ Original bash script preserved as backup
|
||||
|
||||
The new implementation provides a more maintainable, type-safe, and performant codebase while maintaining full compatibility with the original bash implementation.
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
---
|
||||
name: client-finder
|
||||
description: "找客户、找买家、开发客户、cold outreach。当用户说"帮我找XX客户"、"找XX买家"、"开发XX客户"、"find clients for XX"时使用此 skill。通过 Google Maps 搜索目标行业的潜在客户,自动获取联系方式(邮箱/电话/网站),完成后自动触发邮件编写。"
|
||||
---
|
||||
|
||||
# Client Finder
|
||||
|
||||
Use `skill-credit` + `ecom run-flow` so agents only need workflow input `client_key`.
|
||||
This skill includes query expansion before calling `/ecom/cold-outreach/run-flow`.
|
||||
Execution mode is fire-and-return: start workflow fast and return accepted immediately; terminal callbacks are handled by backend webhook delivery.
|
||||
|
||||
## Run Skill
|
||||
|
||||
Run the skill runner (primary entrypoint agents should call):
|
||||
|
||||
```bash
|
||||
<skill-dir>/scripts/cliet-finder.sh --client-key='<sk_xxx.yyy>' "office machine" "us"
|
||||
```
|
||||
|
||||
Optional: provide LLM-generated query expansion JSON. If provided, it must be valid and non-empty:
|
||||
|
||||
```bash
|
||||
QUERY_EXPANSION_JSON='{"expandedQueries":["office machine supplier us","office equipment distributor us"],"primaryQuery":"office machine supplier us"}' \
|
||||
<skill-dir>/scripts/cliet-finder.sh --client-key='<sk_xxx.yyy>' "office machine" "us"
|
||||
```
|
||||
|
||||
Use dry-run to verify endpoint sequence without network calls:
|
||||
|
||||
```bash
|
||||
<skill-dir>/scripts/cliet-finder.sh --client-key='<sk_xxx.yyy>' "office machine" "us" --dry-run
|
||||
```
|
||||
|
||||
## Quick Test
|
||||
|
||||
Run the test wrapper (it calls the skill runner above):
|
||||
|
||||
```bash
|
||||
<skill-dir>/scripts/run-endpoint-test.sh "office machine" "us"
|
||||
```
|
||||
|
||||
Default behavior is `--dry-run`. For live execution, pass mode + client key as 3rd/4th args.
|
||||
|
||||
```bash
|
||||
<skill-dir>/scripts/run-endpoint-test.sh "office machine" "us" --dry-run
|
||||
<skill-dir>/scripts/run-endpoint-test.sh "office machine" "us" --live "sk_xxx.yyy"
|
||||
```
|
||||
|
||||
## Required Inputs
|
||||
|
||||
For live execution:
|
||||
|
||||
- `client_key` from workflow input (pass into script as `--client-key=<client_key>`)
|
||||
- `query` (string)
|
||||
- `country` (optional, default `us`)
|
||||
|
||||
Optional runtime config:
|
||||
|
||||
- `AUTH_BASE` (default: `https://api-gw-test.yuanwei-lnc.com`, can be passed as `--auth-base=<url>`)
|
||||
|
||||
Optional:
|
||||
|
||||
- `QUERY_EXPANSION_JSON` (optional LLM expansion input)
|
||||
|
||||
## Reference
|
||||
|
||||
Load and follow [references/cliet-finder.md](references/cliet-finder.md) as the detailed workflow spec.
|
||||
For expansion behavior, also follow [references/query-expansion-spec.md](references/query-expansion-spec.md).
|
||||
For client onboarding and billing flow (Chinese), read [how-to-use.md](how-to-use.md).
|
||||
|
||||
## Execution Checklist
|
||||
|
||||
1. Normalize input query.
|
||||
- Trim whitespace.
|
||||
- Remove leading `cold-outreach:` prefix (case-insensitive).
|
||||
|
||||
2. Exchange runtime token.
|
||||
- Call `POST /auth/skill-credit/session` with `{ "clientKey": "<client_key>" }`.
|
||||
- Require `accessToken` in response.
|
||||
|
||||
3. Expand query.
|
||||
- Build candidate queries from skill logic (`rule`) or `QUERY_EXPANSION_JSON` (`llm`).
|
||||
- Select one `primaryQuery`.
|
||||
- Fallback policy: if expansion fails, use normalized raw query as `primaryQuery` and continue.
|
||||
|
||||
4. Execute workflow.
|
||||
- Call `POST /ecom/cold-outreach/run-flow` with runtime `accessToken`:
|
||||
- `query` = `primaryQuery`
|
||||
- `country`
|
||||
- Require `workflowId` in response.
|
||||
- This workflow only covers discovery + email-find data collection.
|
||||
|
||||
5. Return accepted immediately.
|
||||
- Return JSON with `workflowStatus = "accepted"` and `workflowId`.
|
||||
- This runner does not poll or finalize billing.
|
||||
- Backend pushes terminal callback to webhook bound on the client key.
|
||||
|
||||
## Output Contract (Strict)
|
||||
|
||||
- Final answer must be JSON only. No prose, no markdown table, no repeated preface.
|
||||
- Follow `output_schema.json` exactly.
|
||||
- `error` is `null` when success; short string when failed.
|
||||
- If fallback is used, populate:
|
||||
- `expansionStatus = "failed"`
|
||||
- `expansionSource = "raw_query"`
|
||||
- `usedFallbackQuery = true`
|
||||
- `expansionError` with short reason
|
||||
|
||||
## Constraints
|
||||
|
||||
- Use this skill for query expansion + `/ecom/cold-outreach/run-flow` orchestration only.
|
||||
- Do not trigger `/mail/compose` or any mail compose flow in this skill.
|
||||
- Do not implement local monitor, local finalize, or local hook event emission.
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
interface:
|
||||
display_name: "Client Finder / 客户开发"
|
||||
short_description: "找客户、找买家、开发客户。搜索目标行业潜在客户,获取联系方式,自动触发邮件编写。"
|
||||
default_prompt: "[skill:client-finder] Read client_key from workflow input_data, pass it to runner as --client-key, then execute /auth/skill-credit/session + /ecom/cold-outreach/run-flow using accessToken. Do not pass webhook_url/webhook_token in body; backend resolves from client key binding. Return immediate accepted JSON only (compact) matching output_schema.json. Do not do local polling/finalize/hook emission. Do not call /mail/compose in this skill. If expansion fails, fallback to raw query A and continue."
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "client-finder",
|
||||
"dependencies": {
|
||||
"@clawd/auth-runtime": "file:../_shared/auth-runtime",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.0.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@clawd/auth-runtime": ["@clawd/auth-runtime@file:../_shared/auth-runtime", { "devDependencies": { "@types/node": "^25.3.3", "typescript": "^5.9.3" } }],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
||||
|
||||
"@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
# Cliet Finder 简明使用说明
|
||||
|
||||
这份文档只讲两件事:
|
||||
|
||||
1. 平台管理员怎么管理客户和 `clientKey`
|
||||
2. 客户怎么把一句话交给 agent,直接唤起 skill
|
||||
|
||||
---
|
||||
|
||||
## 1. 一张图看全流程
|
||||
|
||||
```text
|
||||
+---------------------------+
|
||||
| 平台管理员后台 |
|
||||
| 新增客户 / 生成 clientKey |
|
||||
+-------------+-------------+
|
||||
|
|
||||
v
|
||||
+---------------------------+
|
||||
| 把 clientKey 发给客户 |
|
||||
+-------------+-------------+
|
||||
|
|
||||
v
|
||||
+---------------------------+
|
||||
| 客户把 key 配给 Agent |
|
||||
| (workflow input_data) |
|
||||
+-------------+-------------+
|
||||
|
|
||||
v
|
||||
+---------------------------+
|
||||
| Agent 唤起 client-finder |
|
||||
| 先扩展关键词再执行流程 |
|
||||
+-------------+-------------+
|
||||
|
|
||||
v
|
||||
+---------------------------+
|
||||
| 返回客户列表/联系人数据 |
|
||||
+---------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 平台管理员使用说明(简单版)
|
||||
|
||||
### 2.1 你要做的事
|
||||
|
||||
1. 新增客户公司信息
|
||||
2. 给这个客户配置专属 `HOOK_URL`(用于完成/失败回调)
|
||||
3. 给这个客户生成 `clientKey`
|
||||
4. 把 `clientKey` 发给客户
|
||||
5. 如泄露,立即吊销并重发新 key
|
||||
|
||||
### 2.2 页面功能(ASCII 示意)
|
||||
|
||||
```text
|
||||
+------------------------------------------------------------------+
|
||||
| Skill Token 管理 |
|
||||
+------------------------------------------------------------------+
|
||||
| [新增客户] |
|
||||
| 客户名: ______ 联系人: ______ 公司名: ______ 备注: ______ |
|
||||
| [保存] |
|
||||
+------------------------------------------------------------------+
|
||||
| 客户列表 |
|
||||
| Client A | HOOK_URL: https://.../a | [生成Key] [复制] [吊销] |
|
||||
| Client B | HOOK_URL: https://.../b | [生成Key] [复制] [吊销] |
|
||||
+------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
### 2.3 管理建议
|
||||
|
||||
- 一个客户一把 key,便于计费和关系维护。
|
||||
- key 只在安全渠道发放。
|
||||
- key 泄露就吊销,不要继续复用。
|
||||
|
||||
---
|
||||
|
||||
## 3. 客户使用说明(给 Agent)
|
||||
|
||||
### 3.1 客户只需要准备两项
|
||||
|
||||
- `client_key`(平台给你的)
|
||||
- 你的业务需求(例如:`coffee in US`)
|
||||
|
||||
说明:`HOOK_URL` 已在平台端绑定到该 `clientKey`,客户端运行时无需再配置。
|
||||
|
||||
### 3.2 给 agent 的标准文案(可直接复制)
|
||||
|
||||
```text
|
||||
请使用 client-finder skill 帮我找美国客户。
|
||||
|
||||
要求:
|
||||
1) 查询词:coffee
|
||||
2) 国家:US
|
||||
3) 输出字段:公司名、网站、联系人邮箱、推荐触达理由
|
||||
4) 先做关键词扩展,再执行查找
|
||||
5) 如果流程失败,请返回失败原因和建议下一步
|
||||
|
||||
我会在 workflow 的 input_data 里提供 client_key,请按技能流程自动执行。
|
||||
```
|
||||
|
||||
### 3.3 workflow 入参示例(给接入同学)
|
||||
|
||||
```json
|
||||
{
|
||||
"instruction": "[skill:client-finder] 帮我找美国 coffee 客户",
|
||||
"input_data": {
|
||||
"client_key": "sk_xxx.yyy",
|
||||
"query": "coffee",
|
||||
"country": "us"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
建议:`client_key` 仅通过 workflow payload 下发,不要写死在仓库文件或长期环境变量。
|
||||
|
||||
---
|
||||
|
||||
## 4. 常见问题(非技术版)
|
||||
|
||||
### Q1: 一个 skill 可以给多个客户用吗?
|
||||
|
||||
可以。每个客户用自己的 `clientKey` 即可。
|
||||
|
||||
### Q2: 客户需要懂 API 吗?
|
||||
|
||||
不需要。客户只要给 agent 需求文案 + `clientKey`。
|
||||
|
||||
### Q3: 失败会扣费吗?
|
||||
|
||||
正常设计下,失败会回滚,不做最终扣费。
|
||||
|
||||
### Q4: 扩展失败会自动改用原始关键词吗?
|
||||
|
||||
会。当前策略是“扩展失败回退原始关键词 A 再执行”,提高流程可完成率。
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术同学最少必知(可选)
|
||||
|
||||
- 管理端路径:`/auth/skill-credit/*`
|
||||
- skill 执行路径:`/ecom/cold-outreach/run-flow`
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Overall skill status",
|
||||
"enum": ["success", "failed"]
|
||||
},
|
||||
"error": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Short error message; null when status=success"
|
||||
},
|
||||
"inputQuery": {
|
||||
"type": "string",
|
||||
"description": "Normalized user query before expansion"
|
||||
},
|
||||
"expandedQueries": {
|
||||
"type": "array",
|
||||
"description": "Expanded query candidates",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"primaryQuery": {
|
||||
"type": "string",
|
||||
"description": "Selected query used for cold-outreach execution"
|
||||
},
|
||||
"expansionStatus": {
|
||||
"type": "string",
|
||||
"description": "Expansion stage status",
|
||||
"enum": ["success", "failed"]
|
||||
},
|
||||
"expansionSource": {
|
||||
"type": "string",
|
||||
"description": "Expansion source used by the skill",
|
||||
"enum": ["llm", "rule", "raw_query", ""]
|
||||
},
|
||||
"expansionError": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Expansion-stage error detail; null when expansion succeeds"
|
||||
},
|
||||
"usedFallbackQuery": {
|
||||
"type": "boolean",
|
||||
"description": "Whether raw input query A was used as fallback"
|
||||
},
|
||||
"runId": {
|
||||
"type": "string",
|
||||
"description": "Billing run id; empty when unavailable"
|
||||
},
|
||||
"workflowId": {
|
||||
"type": "string",
|
||||
"description": "Workflow id; empty when unavailable"
|
||||
},
|
||||
"workflowStatus": {
|
||||
"type": "string",
|
||||
"description": "Immediate workflow status returned by this runner; typically accepted or dry_run"
|
||||
},
|
||||
"billingReserveStatus": {
|
||||
"type": "string",
|
||||
"description": "Billing reserve status in immediate response; typically SKIPPED"
|
||||
},
|
||||
"billingFinalizeStatus": {
|
||||
"type": "string",
|
||||
"description": "Billing finalize status in immediate response; typically SKIPPED"
|
||||
},
|
||||
"businessesCount": {
|
||||
"type": "number",
|
||||
"description": "Found businesses count"
|
||||
},
|
||||
"contactsCount": {
|
||||
"type": "number",
|
||||
"description": "Found contacts count"
|
||||
},
|
||||
"uniqueContactDomains": {
|
||||
"type": "number",
|
||||
"description": "Unique contact domains count"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"error",
|
||||
"inputQuery",
|
||||
"expandedQueries",
|
||||
"primaryQuery",
|
||||
"expansionStatus",
|
||||
"expansionSource",
|
||||
"expansionError",
|
||||
"usedFallbackQuery",
|
||||
"runId",
|
||||
"workflowId",
|
||||
"workflowStatus",
|
||||
"billingReserveStatus",
|
||||
"billingFinalizeStatus",
|
||||
"businessesCount",
|
||||
"contactsCount",
|
||||
"uniqueContactDomains"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "client-finder",
|
||||
"version": "1.0.0",
|
||||
"description": "Client finder with query expansion using skill-credit + ecom flow",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"run": "bun run scripts/run.ts",
|
||||
"test": "bun run scripts/test.ts",
|
||||
"build": "bun build scripts/run.ts --outfile dist/run.js --target bun",
|
||||
"build:binary": "bun build scripts/run.ts --compile --outfile dist/run",
|
||||
"install-skill": "bun install && bun run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clawd/auth-runtime": "git+http://192.168.0.108:3030/agent-skills/auth-runtime.git"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
# Cliet Finder Spec
|
||||
|
||||
## Goal
|
||||
Define a client-key-based closed-loop client-finder flow with query expansion.
|
||||
|
||||
## Input
|
||||
- Raw input: free text query.
|
||||
- Normalization rule: trim and remove optional `cold-outreach:` prefix.
|
||||
- Expansion rule: generate expanded candidates and choose one `primaryQuery`.
|
||||
- Fallback: expansion failure uses normalized raw query as `primaryQuery`.
|
||||
|
||||
## Required Inputs
|
||||
- `client_key` (workflow input, passed to runner as `--client-key=<...>`)
|
||||
- `query`
|
||||
- `country` (optional, default `us`)
|
||||
- `AUTH_BASE` (optional runtime base URL)
|
||||
|
||||
## Endpoint Flow (Keyword -> Run-Flow)
|
||||
1. Exchange runtime token
|
||||
- Method: `POST`
|
||||
- URL: `/auth/skill-credit/session`
|
||||
- Body:
|
||||
```json
|
||||
{
|
||||
"clientKey": "<client_key>"
|
||||
}
|
||||
```
|
||||
- Required response: `accessToken`
|
||||
|
||||
2. Query expansion
|
||||
- Source:
|
||||
- `QUERY_EXPANSION_JSON` (if provided and valid)
|
||||
- Otherwise skill built-in rule expansion
|
||||
- Required output:
|
||||
- `expandedQueries` (non-empty array)
|
||||
- `primaryQuery` (non-empty string)
|
||||
- If expansion fails, fallback to normalized raw query and mark fallback fields.
|
||||
|
||||
3. Execute workflow
|
||||
- Method: `POST`
|
||||
- URL: `/ecom/cold-outreach/run-flow`
|
||||
- Header: `Authorization: Bearer <accessToken>`
|
||||
- Body:
|
||||
```json
|
||||
{
|
||||
"query": "<primaryQuery>",
|
||||
"country": "us"
|
||||
}
|
||||
```
|
||||
|
||||
4. Return accepted quickly
|
||||
- Main runner returns `workflowId` with `workflowStatus = "accepted"`.
|
||||
- `runId` remains empty in immediate response.
|
||||
|
||||
5. Backend webhook delivery
|
||||
- Skill runner does not poll workflow or finalize billing.
|
||||
- `woo-data-scrawler` sends terminal callback using webhook config bound on the client key.
|
||||
|
||||
## Data Mapping Rules
|
||||
- Immediate response defaults:
|
||||
- `businessesCount = 0`
|
||||
- `contactsCount = 0`
|
||||
- `uniqueContactDomains = 0`
|
||||
- `billingReserveStatus = "SKIPPED"`
|
||||
- `billingFinalizeStatus = "SKIPPED"`
|
||||
|
||||
## Error Rules
|
||||
- If no `accessToken`, stop with auth error.
|
||||
- If expansion output is invalid/empty, fallback to raw query.
|
||||
- If no `workflowId` in run response, stop with protocol error.
|
||||
- If runtime reports missing hook config, stop with config error.
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
# Client-Finder Query Expansion Spec (MVP)
|
||||
|
||||
## Goal
|
||||
Implement query expansion inside `client-finder` before calling `/ecom/cold-outreach/run-flow`.
|
||||
|
||||
## Scope
|
||||
- Only changes inside `agent-sandbox/agent_app/skills/client-finder`.
|
||||
- No new backend API.
|
||||
- Expansion failure should fallback to original raw query A.
|
||||
|
||||
## Input
|
||||
- `client_key` (required, from workflow input_data)
|
||||
- `query` (required)
|
||||
- `country` (optional, default `us`)
|
||||
- `QUERY_EXPANSION_JSON` (optional, must be valid JSON if provided)
|
||||
|
||||
## Expansion Flow
|
||||
1. Normalize raw query.
|
||||
- trim spaces
|
||||
- remove optional prefix `cold-outreach:`
|
||||
|
||||
2. Resolve expansion source.
|
||||
- If `QUERY_EXPANSION_JSON` is provided:
|
||||
- accept object or array
|
||||
- must produce non-empty `expandedQueries` + non-empty `primaryQuery`
|
||||
- source = `llm`
|
||||
- Else use built-in rule expansion
|
||||
- source = `rule`
|
||||
|
||||
3. Validate expansion.
|
||||
- `expandedQueries.length >= 1`
|
||||
- `primaryQuery` non-empty
|
||||
- dedupe candidates case-insensitively
|
||||
|
||||
4. Execute workflow with `primaryQuery`.
|
||||
- exchange `client_key` first via `POST /auth/skill-credit/session`
|
||||
- `POST /auth/skill-credit/session`
|
||||
- `POST /ecom/cold-outreach/run-flow`
|
||||
- return accepted immediately after `workflowId` is received
|
||||
|
||||
## Fallback Policy
|
||||
- If expansion parsing/validation fails, use normalized raw query A as `primaryQuery`.
|
||||
- Set:
|
||||
- `expansionStatus = "failed"`
|
||||
- `expansionSource = "raw_query"`
|
||||
- `usedFallbackQuery = true`
|
||||
- `expansionError` with failure reason
|
||||
- Continue to run cold-outreach.
|
||||
|
||||
## Output Contract
|
||||
Always output strict JSON following `../output_schema.json`.
|
||||
|
||||
Success example:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"error": null,
|
||||
"inputQuery": "coffee",
|
||||
"expandedQueries": ["coffee shop US", "coffee roastery US"],
|
||||
"primaryQuery": "coffee shop US",
|
||||
"expansionStatus": "success",
|
||||
"expansionSource": "llm",
|
||||
"expansionError": null,
|
||||
"usedFallbackQuery": false,
|
||||
"runId": "",
|
||||
"workflowId": "outreach_xxx",
|
||||
"workflowStatus": "accepted",
|
||||
"businessesCount": 0,
|
||||
"contactsCount": 0,
|
||||
"uniqueContactDomains": 0,
|
||||
"billingReserveStatus": "SKIPPED",
|
||||
"billingFinalizeStatus": "SKIPPED"
|
||||
}
|
||||
```
|
||||
|
||||
Expansion fallback example:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"error": null,
|
||||
"inputQuery": "coffee",
|
||||
"expandedQueries": ["coffee"],
|
||||
"primaryQuery": "coffee",
|
||||
"expansionStatus": "failed",
|
||||
"expansionSource": "raw_query",
|
||||
"expansionError": "query expansion failed: expandedQueries is empty; fallback to raw query",
|
||||
"usedFallbackQuery": true,
|
||||
"runId": "",
|
||||
"workflowId": "outreach_xxx",
|
||||
"workflowStatus": "accepted",
|
||||
"businessesCount": 0,
|
||||
"contactsCount": 0,
|
||||
"uniqueContactDomains": 0,
|
||||
"billingReserveStatus": "SKIPPED",
|
||||
"billingFinalizeStatus": "SKIPPED"
|
||||
}
|
||||
```
|
||||
|
||||
## Acceptance
|
||||
1. Expansion success -> call cold-outreach using `primaryQuery` (webhook is key-bound).
|
||||
2. Expansion failure -> fallback to raw query and continue run when raw query is available.
|
||||
3. If normalized raw query is empty, return failed JSON and exit non-zero.
|
||||
4. On start success, return immediate `workflowStatus = "accepted"` (no local polling).
|
||||
5. No prose output mixed with JSON.
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Client Finder Async Runner
|
||||
*
|
||||
* Spawns a sub-agent to run client-finder without blocking the main session.
|
||||
* Usage: bun run async-run.ts "<query>" "<country>" "<client_key>"
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
|
||||
const [,, query, country, clientKey] = process.argv;
|
||||
|
||||
if (!query || !clientKey) {
|
||||
console.error("Usage: bun run async-run.ts <query> <country> <client_key>");
|
||||
console.error("Example: bun run async-run.ts 'farm equipment Dallas' 'us' 'sk_xxx.yyy'");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`🚀 Spawning sub-agent for client-finder query: "${query}"`);
|
||||
|
||||
// This would be called from OpenClaw's sessions_spawn tool
|
||||
// The actual implementation depends on your OpenClaw setup
|
||||
const spawnPayload = {
|
||||
runtime: "subagent",
|
||||
mode: "run",
|
||||
task: `Run client-finder skill with:
|
||||
- query: "${query}"
|
||||
- country: "${country || 'us'}"
|
||||
- client_key: "${clientKey}"
|
||||
|
||||
Execute: cd ~/clawd/skills/client-finder && CLIENT_KEY=${clientKey} bun run scripts/run.ts "${query}" "${country || 'us'}"
|
||||
|
||||
Report back the workflow ID and results when complete.`,
|
||||
label: `client-finder: ${query}`,
|
||||
cleanup: "keep",
|
||||
};
|
||||
|
||||
console.log("📦 Spawn payload:", JSON.stringify(spawnPayload, null, 2));
|
||||
console.log("\n✅ Sub-agent spawned! Main session is now free to handle other requests.");
|
||||
console.log("📬 Results will be delivered via webhook when the sub-agent completes.");
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Client UAT for skill-credit flow (safe by default: dry-run)
|
||||
#
|
||||
# Required env in live mode:
|
||||
# AUTH_BASE, CLIENT_KEY
|
||||
#
|
||||
# Optional env:
|
||||
# RUN_ID, QUANTITY, ACTUAL_CREDITS, FINAL_OUTCOME
|
||||
#
|
||||
# Examples:
|
||||
# AUTH_BASE=https://api-gw-test.yuanwei-lnc.com CLIENT_KEY=sk_xxx ./client-uat.sh
|
||||
# AUTH_BASE=https://api-gw-test.yuanwei-lnc.com CLIENT_KEY=sk_xxx ./client-uat.sh --live
|
||||
# AUTH_BASE=https://api-gw-test.yuanwei-lnc.com CLIENT_KEY=sk_xxx FINAL_OUTCOME=failed ./client-uat.sh --live
|
||||
|
||||
AUTH_BASE="${AUTH_BASE:-}"
|
||||
CLIENT_KEY="${CLIENT_KEY:-}"
|
||||
RUN_ID="${RUN_ID:-wf_$(date +%Y%m%d_%H%M%S)}"
|
||||
QUANTITY="${QUANTITY:-1}"
|
||||
ACTUAL_CREDITS="${ACTUAL_CREDITS:-45}"
|
||||
FINAL_OUTCOME="${FINAL_OUTCOME:-completed}" # completed | failed
|
||||
|
||||
LIVE=0
|
||||
if [ "${1:-}" = "--live" ]; then
|
||||
LIVE=1
|
||||
fi
|
||||
|
||||
if [ "$LIVE" -eq 1 ]; then
|
||||
if [ -z "$AUTH_BASE" ] || [ -z "$CLIENT_KEY" ]; then
|
||||
echo "Missing env: AUTH_BASE and CLIENT_KEY are required in --live mode."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
print_cmd() {
|
||||
echo "+ $*"
|
||||
}
|
||||
|
||||
json_get() {
|
||||
local raw="$1"
|
||||
local key="$2"
|
||||
python3 - "$raw" "$key" <<'PY'
|
||||
import json, sys
|
||||
raw = sys.argv[1]
|
||||
key = sys.argv[2]
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
print("")
|
||||
raise SystemExit(0)
|
||||
val = data.get(key, "")
|
||||
if val is None:
|
||||
val = ""
|
||||
print(val)
|
||||
PY
|
||||
}
|
||||
|
||||
echo "== Client UAT: skill-credit flow =="
|
||||
echo "RUN_ID=$RUN_ID"
|
||||
echo "FINAL_OUTCOME=$FINAL_OUTCOME"
|
||||
|
||||
if [ "$LIVE" -eq 0 ]; then
|
||||
echo "Mode: dry-run (no network request)"
|
||||
cat <<EOF
|
||||
|
||||
1) Exchange session token
|
||||
curl -X POST "\$AUTH_BASE/auth/skill-credit/session" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"clientKey":"\$CLIENT_KEY"}'
|
||||
|
||||
2) Reserve credits
|
||||
curl -X POST "\$AUTH_BASE/auth/skill-credit/runs/reserve" \\
|
||||
-H "Authorization: Bearer <ACCESS_TOKEN>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"runId":"$RUN_ID","service":"skill","action":"cold_outreach_run","quantity":$QUANTITY}'
|
||||
|
||||
3) Finalize
|
||||
curl -X POST "\$AUTH_BASE/auth/skill-credit/runs/finalize" \\
|
||||
-H "Authorization: Bearer <ACCESS_TOKEN>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"runId":"$RUN_ID","outcome":"$FINAL_OUTCOME","actualCredits":$ACTUAL_CREDITS}'
|
||||
|
||||
4) Check balance
|
||||
curl -X GET "\$AUTH_BASE/auth/skill-credit/balance" \\
|
||||
-H "Authorization: Bearer <ACCESS_TOKEN>"
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
print_cmd curl -s -X POST "$AUTH_BASE/auth/skill-credit/session" -H "Content-Type: application/json" -d '{"clientKey":"***"}'
|
||||
SESSION_JSON="$(curl -s -X POST "$AUTH_BASE/auth/skill-credit/session" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"clientKey\":\"$CLIENT_KEY\"}")"
|
||||
ACCESS_TOKEN="$(json_get "$SESSION_JSON" "accessToken")"
|
||||
if [ -z "$ACCESS_TOKEN" ]; then
|
||||
echo "Session exchange failed: $SESSION_JSON"
|
||||
exit 1
|
||||
fi
|
||||
echo "Session OK."
|
||||
|
||||
print_cmd curl -s -X POST "$AUTH_BASE/auth/skill-credit/runs/reserve" -H "Authorization: Bearer <ACCESS_TOKEN>" -H "Content-Type: application/json" -d ...
|
||||
RESERVE_JSON="$(curl -s -X POST "$AUTH_BASE/auth/skill-credit/runs/reserve" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"runId\":\"$RUN_ID\",\"service\":\"skill\",\"action\":\"cold_outreach_run\",\"quantity\":$QUANTITY}")"
|
||||
echo "Reserve response: $RESERVE_JSON"
|
||||
|
||||
if [ "$FINAL_OUTCOME" = "completed" ]; then
|
||||
FINAL_PAYLOAD="{\"runId\":\"$RUN_ID\",\"outcome\":\"completed\",\"actualCredits\":$ACTUAL_CREDITS}"
|
||||
else
|
||||
FINAL_PAYLOAD="{\"runId\":\"$RUN_ID\",\"outcome\":\"failed\",\"reason\":\"uat_failed_case\"}"
|
||||
fi
|
||||
|
||||
print_cmd curl -s -X POST "$AUTH_BASE/auth/skill-credit/runs/finalize" -H "Authorization: Bearer <ACCESS_TOKEN>" -H "Content-Type: application/json" -d ...
|
||||
FINALIZE_JSON="$(curl -s -X POST "$AUTH_BASE/auth/skill-credit/runs/finalize" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$FINAL_PAYLOAD")"
|
||||
echo "Finalize response: $FINALIZE_JSON"
|
||||
|
||||
print_cmd curl -s -X GET "$AUTH_BASE/auth/skill-credit/balance" -H "Authorization: Bearer <ACCESS_TOKEN>"
|
||||
BALANCE_JSON="$(curl -s -X GET "$AUTH_BASE/auth/skill-credit/balance" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN")"
|
||||
echo "Balance response: $BALANCE_JSON"
|
||||
|
||||
echo "UAT completed."
|
||||
|
|
@ -0,0 +1,460 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SKILLS_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
SHARED_AUTH_RUNTIME="$SKILLS_DIR/_shared/auth-runtime.sh"
|
||||
[ ! -f "$SHARED_AUTH_RUNTIME" ] && { echo "Missing shared auth runtime: $SHARED_AUTH_RUNTIME" >&2; exit 1; }
|
||||
source "$SHARED_AUTH_RUNTIME"
|
||||
SKILL_ENV_FILE="${SKILL_ENV_FILE:-$SKILL_ROOT/.env.local}"
|
||||
|
||||
load_skill_env_file() {
|
||||
if [ ! -f "$SKILL_ENV_FILE" ]; then
|
||||
return
|
||||
fi
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$SKILL_ENV_FILE"
|
||||
set +a
|
||||
}
|
||||
|
||||
load_skill_env_file
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
cliet-finder.sh [--client-key=<sk_xxx.yyy>] [--auth-base=<url>] "<query>" [country] [--dry-run]
|
||||
|
||||
Examples:
|
||||
cliet-finder.sh --client-key='sk_xxx.yyy' "office machine" "us"
|
||||
cliet-finder.sh --auth-base='https://api-gw-test.yuanwei-lnc.com' --client-key='sk_xxx.yyy' "office machine" "us"
|
||||
QUERY_EXPANSION_JSON='{"expandedQueries":["coffee shop us","coffee roastery us"],"primaryQuery":"coffee roastery us"}' \
|
||||
cliet-finder.sh --client-key='sk_xxx.yyy' "coffee" "us"
|
||||
cliet-finder.sh --client-key='sk_xxx.yyy' "office machine in US" --dry-run
|
||||
EOF
|
||||
}
|
||||
|
||||
DRY_RUN=0
|
||||
CLIENT_KEY_INPUT=""
|
||||
AUTH_BASE_INPUT=""
|
||||
POSITIONALS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
;;
|
||||
--client-key=*)
|
||||
CLIENT_KEY_INPUT="${arg#*=}"
|
||||
;;
|
||||
--auth-base=*)
|
||||
AUTH_BASE_INPUT="${arg#*=}"
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
POSITIONALS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
QUERY="${POSITIONALS[0]:-}"
|
||||
COUNTRY="${POSITIONALS[1]:-us}"
|
||||
|
||||
AUTH_BASE="${AUTH_BASE_INPUT:-${AUTH_BASE:-https://api-gw-test.yuanwei-lnc.com}}"
|
||||
AUTH_BASE="${AUTH_BASE%/}"
|
||||
CLIENT_KEY="${CLIENT_KEY_INPUT:-}"
|
||||
QUERY_EXPANSION_JSON="${QUERY_EXPANSION_JSON:-}"
|
||||
|
||||
trim() {
|
||||
local input="$1"
|
||||
printf '%s' "$input" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g'
|
||||
}
|
||||
|
||||
normalize_query() {
|
||||
local input
|
||||
input="$(trim "$1")"
|
||||
printf '%s' "$input" | sed -E 's/^[Cc][Oo][Ll][Dd]-[Oo][Uu][Tt][Rr][Ee][Aa][Cc][Hh]:[[:space:]]*//'
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local text="$1"
|
||||
python3 - "$text" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
print(json.dumps(sys.argv[1]))
|
||||
PY
|
||||
}
|
||||
|
||||
json_array_value() {
|
||||
local raw="$1"
|
||||
local key="$2"
|
||||
python3 - "$raw" "$key" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
raw = sys.argv[1]
|
||||
key = sys.argv[2]
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
print("[]")
|
||||
raise SystemExit(0)
|
||||
value = data.get(key, [])
|
||||
if not isinstance(value, list):
|
||||
value = []
|
||||
print(json.dumps(value))
|
||||
PY
|
||||
}
|
||||
|
||||
is_true() {
|
||||
local value
|
||||
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$value" in
|
||||
true|1|yes) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
emit_result() {
|
||||
local status="$1"
|
||||
local error="$2"
|
||||
local input_query="$3"
|
||||
local expanded_json="$4"
|
||||
local primary_query="$5"
|
||||
local expansion_status="$6"
|
||||
local expansion_source="$7"
|
||||
local expansion_error="${8:-}"
|
||||
local used_fallback_query="${9:-false}"
|
||||
local run_id="${10:-}"
|
||||
local workflow_id="${11:-}"
|
||||
local workflow_status="${12:-}"
|
||||
local businesses_count="${13:-0}"
|
||||
local contacts_count="${14:-0}"
|
||||
local unique_domains="${15:-0}"
|
||||
local billing_reserve_status="${16:-}"
|
||||
local billing_finalize_status="${17:-}"
|
||||
|
||||
python3 - \
|
||||
"$status" \
|
||||
"$error" \
|
||||
"$input_query" \
|
||||
"$expanded_json" \
|
||||
"$primary_query" \
|
||||
"$expansion_status" \
|
||||
"$expansion_source" \
|
||||
"$expansion_error" \
|
||||
"$used_fallback_query" \
|
||||
"$run_id" \
|
||||
"$workflow_id" \
|
||||
"$workflow_status" \
|
||||
"$businesses_count" \
|
||||
"$contacts_count" \
|
||||
"$unique_domains" \
|
||||
"$billing_reserve_status" \
|
||||
"$billing_finalize_status" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
(
|
||||
status,
|
||||
error,
|
||||
input_query,
|
||||
expanded_raw,
|
||||
primary_query,
|
||||
expansion_status,
|
||||
expansion_source,
|
||||
expansion_error,
|
||||
used_fallback_query,
|
||||
run_id,
|
||||
workflow_id,
|
||||
workflow_status,
|
||||
businesses_count,
|
||||
contacts_count,
|
||||
unique_domains,
|
||||
billing_reserve_status,
|
||||
billing_finalize_status,
|
||||
) = sys.argv[1:]
|
||||
|
||||
try:
|
||||
expanded_queries = json.loads(expanded_raw) if expanded_raw else []
|
||||
except Exception:
|
||||
expanded_queries = []
|
||||
|
||||
def as_int(raw):
|
||||
try:
|
||||
return int(float(raw))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def as_bool(raw):
|
||||
if not isinstance(raw, str):
|
||||
return False
|
||||
return raw.strip().lower() in ("true", "1", "yes")
|
||||
|
||||
result = {
|
||||
"status": status,
|
||||
"error": None if error in ("", "null", "None") else error,
|
||||
"inputQuery": input_query,
|
||||
"expandedQueries": expanded_queries,
|
||||
"primaryQuery": primary_query,
|
||||
"expansionStatus": expansion_status,
|
||||
"expansionSource": expansion_source,
|
||||
"expansionError": None if expansion_error in ("", "null", "None") else expansion_error,
|
||||
"usedFallbackQuery": as_bool(used_fallback_query),
|
||||
"runId": run_id,
|
||||
"workflowId": workflow_id,
|
||||
"workflowStatus": workflow_status,
|
||||
"businessesCount": as_int(businesses_count),
|
||||
"contactsCount": as_int(contacts_count),
|
||||
"uniqueContactDomains": as_int(unique_domains),
|
||||
"billingReserveStatus": billing_reserve_status,
|
||||
"billingFinalizeStatus": billing_finalize_status,
|
||||
}
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
PY
|
||||
}
|
||||
|
||||
single_item_array_json() {
|
||||
local input="$1"
|
||||
python3 - "$input" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
item = (sys.argv[1] or "").strip()
|
||||
print(json.dumps([item] if item else []))
|
||||
PY
|
||||
}
|
||||
|
||||
resolve_expansion() {
|
||||
local raw_query="$1"
|
||||
local country_upper="$2"
|
||||
local llm_expansion="$3"
|
||||
|
||||
python3 - "$raw_query" "$country_upper" "$llm_expansion" <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
raw_query = (sys.argv[1] or "").strip()
|
||||
country_upper = (sys.argv[2] or "").strip().upper() or "US"
|
||||
llm_expansion = sys.argv[3] or ""
|
||||
|
||||
def compact(value):
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
def dedupe_keep_order(items):
|
||||
seen = set()
|
||||
output = []
|
||||
for item in items:
|
||||
cleaned = compact(item)
|
||||
if not cleaned:
|
||||
continue
|
||||
key = cleaned.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
output.append(cleaned)
|
||||
return output
|
||||
|
||||
def fail(message):
|
||||
print(json.dumps({
|
||||
"ok": False,
|
||||
"error": message,
|
||||
"expandedQueries": [],
|
||||
"primaryQuery": "",
|
||||
"expansionSource": ""
|
||||
}))
|
||||
|
||||
if not raw_query:
|
||||
fail("query is empty after normalization")
|
||||
raise SystemExit(0)
|
||||
|
||||
if llm_expansion.strip():
|
||||
try:
|
||||
parsed = json.loads(llm_expansion)
|
||||
except Exception:
|
||||
fail("QUERY_EXPANSION_JSON is not valid JSON")
|
||||
raise SystemExit(0)
|
||||
|
||||
if isinstance(parsed, list):
|
||||
expanded = dedupe_keep_order(parsed)
|
||||
primary = expanded[0] if expanded else ""
|
||||
elif isinstance(parsed, dict):
|
||||
expanded = dedupe_keep_order(
|
||||
parsed.get("expandedQueries")
|
||||
or parsed.get("queries")
|
||||
or []
|
||||
)
|
||||
primary = compact(
|
||||
parsed.get("primaryQuery")
|
||||
or parsed.get("primary_query")
|
||||
or (expanded[0] if expanded else "")
|
||||
)
|
||||
else:
|
||||
fail("QUERY_EXPANSION_JSON must be an array or object")
|
||||
raise SystemExit(0)
|
||||
|
||||
if not expanded:
|
||||
fail("expandedQueries is empty")
|
||||
raise SystemExit(0)
|
||||
if not primary:
|
||||
fail("primaryQuery is empty")
|
||||
raise SystemExit(0)
|
||||
if primary.lower() not in {q.lower() for q in expanded}:
|
||||
expanded.insert(0, primary)
|
||||
|
||||
print(json.dumps({
|
||||
"ok": True,
|
||||
"error": "",
|
||||
"expandedQueries": expanded,
|
||||
"primaryQuery": primary,
|
||||
"expansionSource": "llm"
|
||||
}))
|
||||
raise SystemExit(0)
|
||||
|
||||
rule_candidates = []
|
||||
base = compact(raw_query)
|
||||
rule_candidates.extend([
|
||||
f"{base} {country_upper}",
|
||||
f"{base} supplier {country_upper}",
|
||||
f"{base} wholesale {country_upper}",
|
||||
f"{base} distributor {country_upper}",
|
||||
f"{base} b2b {country_upper}",
|
||||
])
|
||||
|
||||
lower = base.lower()
|
||||
if "coffee" in lower:
|
||||
rule_candidates.extend([
|
||||
f"coffee shop {country_upper}",
|
||||
f"coffee roastery {country_upper}",
|
||||
f"specialty coffee wholesale {country_upper}",
|
||||
])
|
||||
if "office machine" in lower or "office equipment" in lower:
|
||||
rule_candidates.extend([
|
||||
f"office equipment supplier {country_upper}",
|
||||
f"office machine distributor {country_upper}",
|
||||
])
|
||||
|
||||
expanded = dedupe_keep_order(rule_candidates)
|
||||
if not expanded:
|
||||
fail("failed to build expanded queries")
|
||||
raise SystemExit(0)
|
||||
|
||||
print(json.dumps({
|
||||
"ok": True,
|
||||
"error": "",
|
||||
"expandedQueries": expanded[:8],
|
||||
"primaryQuery": expanded[0],
|
||||
"expansionSource": "rule",
|
||||
}))
|
||||
PY
|
||||
}
|
||||
|
||||
# --- Extract workflow_id from ecom start response ---
|
||||
parse_workflow_id() {
|
||||
local raw="$1"
|
||||
python3 - "$raw" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
raw = sys.argv[1]
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
print("")
|
||||
raise SystemExit(0)
|
||||
wf_id = data.get("workflow_id") or data.get("workflowId") or data.get("id") or ""
|
||||
print(str(wf_id))
|
||||
PY
|
||||
}
|
||||
|
||||
if [ -z "$QUERY" ]; then
|
||||
emit_result "failed" "missing query argument" "" "[]" "" "failed" "" "missing query argument" "false" "" "" "" "0" "0" "0" "" ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RAW_QUERY="$(normalize_query "$QUERY")"
|
||||
COUNTRY_UPPER="$(printf '%s' "$COUNTRY" | tr '[:lower:]' '[:upper:]')"
|
||||
COUNTRY_LOWER="$(printf '%s' "$COUNTRY" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
EXPANSION_RESPONSE="$(resolve_expansion "$RAW_QUERY" "$COUNTRY_UPPER" "$QUERY_EXPANSION_JSON")"
|
||||
EXPANSION_OK="$(auth_runtime_json_get "$EXPANSION_RESPONSE" "ok")"
|
||||
EXPANSION_RAW_ERROR="$(auth_runtime_json_get "$EXPANSION_RESPONSE" "error")"
|
||||
EXPANDED_QUERIES_JSON="$(json_array_value "$EXPANSION_RESPONSE" "expandedQueries")"
|
||||
PRIMARY_QUERY="$(auth_runtime_json_get "$EXPANSION_RESPONSE" "primaryQuery")"
|
||||
EXPANSION_SOURCE="$(auth_runtime_json_get "$EXPANSION_RESPONSE" "expansionSource")"
|
||||
EXPANSION_STATUS="success"
|
||||
EXPANSION_ERROR=""
|
||||
USED_FALLBACK_QUERY="false"
|
||||
|
||||
if ! is_true "$EXPANSION_OK"; then
|
||||
EXPANSION_STATUS="failed"
|
||||
if [ -n "$RAW_QUERY" ]; then
|
||||
PRIMARY_QUERY="$RAW_QUERY"
|
||||
EXPANDED_QUERIES_JSON="$(single_item_array_json "$RAW_QUERY")"
|
||||
EXPANSION_SOURCE="raw_query"
|
||||
EXPANSION_ERROR="query expansion failed: $EXPANSION_RAW_ERROR; fallback to raw query"
|
||||
USED_FALLBACK_QUERY="true"
|
||||
else
|
||||
PRIMARY_QUERY=""
|
||||
EXPANDED_QUERIES_JSON="[]"
|
||||
EXPANSION_SOURCE=""
|
||||
EXPANSION_ERROR="query expansion failed: $EXPANSION_RAW_ERROR"
|
||||
USED_FALLBACK_QUERY="false"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$PRIMARY_QUERY" ]; then
|
||||
if [ -z "$EXPANSION_ERROR" ]; then
|
||||
EXPANSION_ERROR="query expansion failed: primary query is empty"
|
||||
fi
|
||||
emit_result "failed" "$EXPANSION_ERROR" "$RAW_QUERY" "$EXPANDED_QUERIES_JSON" "$PRIMARY_QUERY" "$EXPANSION_STATUS" "$EXPANSION_SOURCE" "$EXPANSION_ERROR" "$USED_FALLBACK_QUERY" "" "" "" "0" "0" "0" "" ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
emit_result "success" "" "$RAW_QUERY" "$EXPANDED_QUERIES_JSON" "$PRIMARY_QUERY" "$EXPANSION_STATUS" "$EXPANSION_SOURCE" "$EXPANSION_ERROR" "$USED_FALLBACK_QUERY" "" "" "dry_run" "0" "0" "0" "DRY_RUN" "DRY_RUN"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CLIENT_KEY="$(trim "$CLIENT_KEY")"
|
||||
if [ -z "$CLIENT_KEY" ]; then
|
||||
emit_result "failed" "missing required input: --client-key=<...>" "$RAW_QUERY" "$EXPANDED_QUERIES_JSON" "$PRIMARY_QUERY" "$EXPANSION_STATUS" "$EXPANSION_SOURCE" "$EXPANSION_ERROR" "$USED_FALLBACK_QUERY" "" "" "" "0" "0" "0" "" ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Step 1: Exchange client key for skill runtime token
|
||||
# =============================================================================
|
||||
ACCESS_TOKEN="$(auth_runtime_get_access_token 0)" || {
|
||||
emit_result "failed" "failed to exchange skill session token" "$RAW_QUERY" "$EXPANDED_QUERIES_JSON" "$PRIMARY_QUERY" "$EXPANSION_STATUS" "$EXPANSION_SOURCE" "$EXPANSION_ERROR" "$USED_FALLBACK_QUERY" "" "" "" "0" "0" "0" "" ""
|
||||
exit 1
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Step 2: Start workflow via ecom flow API
|
||||
# =============================================================================
|
||||
START_PAYLOAD="{\"query\":$(json_escape "$PRIMARY_QUERY"),\"country\":$(json_escape "$COUNTRY_LOWER")}"
|
||||
START_RAW="$(auth_runtime_request_api POST "$AUTH_BASE/ecom/cold-outreach/run-flow" "$ACCESS_TOKEN" "$START_PAYLOAD" || true)"
|
||||
START_RESPONSE="$(auth_runtime_extract_body "$START_RAW")"
|
||||
WORKFLOW_ID="$(parse_workflow_id "$START_RESPONSE")"
|
||||
|
||||
if [ -z "$WORKFLOW_ID" ]; then
|
||||
START_ERROR="$(auth_runtime_json_get "$START_RESPONSE" "error")"
|
||||
if [ -z "$START_ERROR" ]; then
|
||||
START_ERROR="$(auth_runtime_json_get "$START_RESPONSE" "message")"
|
||||
fi
|
||||
if [ -z "$START_ERROR" ]; then
|
||||
START_ERROR="failed to start workflow (missing workflowId)"
|
||||
fi
|
||||
emit_result "failed" "start failed: $START_ERROR" "$RAW_QUERY" "$EXPANDED_QUERIES_JSON" "$PRIMARY_QUERY" "$EXPANSION_STATUS" "$EXPANSION_SOURCE" "$EXPANSION_ERROR" "$USED_FALLBACK_QUERY" "" "" "" "0" "0" "0" "SKIPPED" "SKIPPED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Step 3: Return accepted immediately.
|
||||
# woo-data-scrawler will push terminal webhook using client-key hook config.
|
||||
# =============================================================================
|
||||
emit_result "success" "" "$RAW_QUERY" "$EXPANDED_QUERIES_JSON" "$PRIMARY_QUERY" "$EXPANSION_STATUS" "$EXPANSION_SOURCE" "$EXPANSION_ERROR" "$USED_FALLBACK_QUERY" "" "$WORKFLOW_ID" "accepted" "0" "0" "0" "SKIPPED" "SKIPPED"
|
||||
|
|
@ -0,0 +1,466 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SKILLS_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
SHARED_AUTH_RUNTIME="$SKILLS_DIR/_shared/auth-runtime.sh"
|
||||
[ ! -f "$SHARED_AUTH_RUNTIME" ] && { echo "Missing shared auth runtime: $SHARED_AUTH_RUNTIME" >&2; exit 1; }
|
||||
source "$SHARED_AUTH_RUNTIME"
|
||||
SKILL_ENV_FILE="${SKILL_ENV_FILE:-$SKILL_ROOT/.env.local}"
|
||||
|
||||
load_skill_env_file() {
|
||||
if [ ! -f "$SKILL_ENV_FILE" ]; then
|
||||
return
|
||||
fi
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$SKILL_ENV_FILE"
|
||||
set +a
|
||||
}
|
||||
|
||||
load_skill_env_file
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
cliet-finder.sh "<query>" [country] [--dry-run]
|
||||
|
||||
Examples:
|
||||
cliet-finder.sh "office machine" "us"
|
||||
AUTH_BASE=https://api-gw-test.yuanwei-lnc.com CLIENT_KEY=sk_xxx cliet-finder.sh "office machine" "us"
|
||||
QUERY_EXPANSION_JSON='{"expandedQueries":["coffee shop us","coffee roastery us"],"primaryQuery":"coffee roastery us"}' \
|
||||
cliet-finder.sh "coffee" "us"
|
||||
cliet-finder.sh "office machine in US" --dry-run
|
||||
EOF
|
||||
}
|
||||
|
||||
DRY_RUN=0
|
||||
POSITIONALS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
POSITIONALS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
QUERY="${POSITIONALS[0]:-}"
|
||||
COUNTRY="${POSITIONALS[1]:-us}"
|
||||
|
||||
AUTH_BASE="${AUTH_BASE:-https://api-gw-test.yuanwei-lnc.com}"
|
||||
AUTH_BASE="${AUTH_BASE%/}"
|
||||
CLIENT_KEY="${CLIENT_KEY:-}"
|
||||
QUERY_EXPANSION_JSON="${QUERY_EXPANSION_JSON:-}"
|
||||
|
||||
trim() {
|
||||
local input="$1"
|
||||
printf '%s' "$input" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g'
|
||||
}
|
||||
|
||||
normalize_query() {
|
||||
local input
|
||||
input="$(trim "$1")"
|
||||
printf '%s' "$input" | sed -E 's/^[Cc][Oo][Ll][Dd]-[Oo][Uu][Tt][Rr][Ee][Aa][Cc][Hh]:[[:space:]]*//'
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local text="$1"
|
||||
python3 - "$text" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
print(json.dumps(sys.argv[1]))
|
||||
PY
|
||||
}
|
||||
|
||||
json_array_value() {
|
||||
local raw="$1"
|
||||
local key="$2"
|
||||
python3 - "$raw" "$key" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
raw = sys.argv[1]
|
||||
key = sys.argv[2]
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
print("[]")
|
||||
raise SystemExit(0)
|
||||
value = data.get(key, [])
|
||||
if not isinstance(value, list):
|
||||
value = []
|
||||
print(json.dumps(value))
|
||||
PY
|
||||
}
|
||||
|
||||
is_true() {
|
||||
local value
|
||||
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$value" in
|
||||
true|1|yes) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
emit_result() {
|
||||
local status="$1"
|
||||
local error="$2"
|
||||
local input_query="$3"
|
||||
local expanded_json="$4"
|
||||
local primary_query="$5"
|
||||
local expansion_status="$6"
|
||||
local expansion_source="$7"
|
||||
local expansion_error="${8:-}"
|
||||
local used_fallback_query="${9:-false}"
|
||||
local run_id="${10:-}"
|
||||
local workflow_id="${11:-}"
|
||||
local workflow_status="${12:-}"
|
||||
local businesses_count="${13:-0}"
|
||||
local contacts_count="${14:-0}"
|
||||
local drafts_count="${15:-0}"
|
||||
local unique_domains="${16:-0}"
|
||||
local billing_reserve_status="${17:-}"
|
||||
local billing_finalize_status="${18:-}"
|
||||
|
||||
python3 - \
|
||||
"$status" \
|
||||
"$error" \
|
||||
"$input_query" \
|
||||
"$expanded_json" \
|
||||
"$primary_query" \
|
||||
"$expansion_status" \
|
||||
"$expansion_source" \
|
||||
"$expansion_error" \
|
||||
"$used_fallback_query" \
|
||||
"$run_id" \
|
||||
"$workflow_id" \
|
||||
"$workflow_status" \
|
||||
"$businesses_count" \
|
||||
"$contacts_count" \
|
||||
"$drafts_count" \
|
||||
"$unique_domains" \
|
||||
"$billing_reserve_status" \
|
||||
"$billing_finalize_status" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
(
|
||||
status,
|
||||
error,
|
||||
input_query,
|
||||
expanded_raw,
|
||||
primary_query,
|
||||
expansion_status,
|
||||
expansion_source,
|
||||
expansion_error,
|
||||
used_fallback_query,
|
||||
run_id,
|
||||
workflow_id,
|
||||
workflow_status,
|
||||
businesses_count,
|
||||
contacts_count,
|
||||
drafts_count,
|
||||
unique_domains,
|
||||
billing_reserve_status,
|
||||
billing_finalize_status,
|
||||
) = sys.argv[1:]
|
||||
|
||||
try:
|
||||
expanded_queries = json.loads(expanded_raw) if expanded_raw else []
|
||||
except Exception:
|
||||
expanded_queries = []
|
||||
|
||||
def as_int(raw):
|
||||
try:
|
||||
return int(float(raw))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def as_bool(raw):
|
||||
if not isinstance(raw, str):
|
||||
return False
|
||||
return raw.strip().lower() in ("true", "1", "yes")
|
||||
|
||||
result = {
|
||||
"status": status,
|
||||
"error": None if error in ("", "null", "None") else error,
|
||||
"inputQuery": input_query,
|
||||
"expandedQueries": expanded_queries,
|
||||
"primaryQuery": primary_query,
|
||||
"expansionStatus": expansion_status,
|
||||
"expansionSource": expansion_source,
|
||||
"expansionError": None if expansion_error in ("", "null", "None") else expansion_error,
|
||||
"usedFallbackQuery": as_bool(used_fallback_query),
|
||||
"runId": run_id,
|
||||
"workflowId": workflow_id,
|
||||
"workflowStatus": workflow_status,
|
||||
"businessesCount": as_int(businesses_count),
|
||||
"contactsCount": as_int(contacts_count),
|
||||
"draftsCount": as_int(drafts_count),
|
||||
"uniqueContactDomains": as_int(unique_domains),
|
||||
"billingReserveStatus": billing_reserve_status,
|
||||
"billingFinalizeStatus": billing_finalize_status,
|
||||
}
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
PY
|
||||
}
|
||||
|
||||
single_item_array_json() {
|
||||
local input="$1"
|
||||
python3 - "$input" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
item = (sys.argv[1] or "").strip()
|
||||
print(json.dumps([item] if item else []))
|
||||
PY
|
||||
}
|
||||
|
||||
resolve_expansion() {
|
||||
local raw_query="$1"
|
||||
local country_upper="$2"
|
||||
local llm_expansion="$3"
|
||||
|
||||
python3 - "$raw_query" "$country_upper" "$llm_expansion" <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
raw_query = (sys.argv[1] or "").strip()
|
||||
country_upper = (sys.argv[2] or "").strip().upper() or "US"
|
||||
llm_expansion = sys.argv[3] or ""
|
||||
|
||||
def compact(value):
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
def dedupe_keep_order(items):
|
||||
seen = set()
|
||||
output = []
|
||||
for item in items:
|
||||
cleaned = compact(item)
|
||||
if not cleaned:
|
||||
continue
|
||||
key = cleaned.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
output.append(cleaned)
|
||||
return output
|
||||
|
||||
def fail(message):
|
||||
print(json.dumps({
|
||||
"ok": False,
|
||||
"error": message,
|
||||
"expandedQueries": [],
|
||||
"primaryQuery": "",
|
||||
"expansionSource": ""
|
||||
}))
|
||||
|
||||
if not raw_query:
|
||||
fail("query is empty after normalization")
|
||||
raise SystemExit(0)
|
||||
|
||||
if llm_expansion.strip():
|
||||
try:
|
||||
parsed = json.loads(llm_expansion)
|
||||
except Exception:
|
||||
fail("QUERY_EXPANSION_JSON is not valid JSON")
|
||||
raise SystemExit(0)
|
||||
|
||||
if isinstance(parsed, list):
|
||||
expanded = dedupe_keep_order(parsed)
|
||||
primary = expanded[0] if expanded else ""
|
||||
elif isinstance(parsed, dict):
|
||||
expanded = dedupe_keep_order(
|
||||
parsed.get("expandedQueries")
|
||||
or parsed.get("queries")
|
||||
or []
|
||||
)
|
||||
primary = compact(
|
||||
parsed.get("primaryQuery")
|
||||
or parsed.get("primary_query")
|
||||
or (expanded[0] if expanded else "")
|
||||
)
|
||||
else:
|
||||
fail("QUERY_EXPANSION_JSON must be an array or object")
|
||||
raise SystemExit(0)
|
||||
|
||||
if not expanded:
|
||||
fail("expandedQueries is empty")
|
||||
raise SystemExit(0)
|
||||
if not primary:
|
||||
fail("primaryQuery is empty")
|
||||
raise SystemExit(0)
|
||||
if primary.lower() not in {q.lower() for q in expanded}:
|
||||
expanded.insert(0, primary)
|
||||
|
||||
print(json.dumps({
|
||||
"ok": True,
|
||||
"error": "",
|
||||
"expandedQueries": expanded,
|
||||
"primaryQuery": primary,
|
||||
"expansionSource": "llm"
|
||||
}))
|
||||
raise SystemExit(0)
|
||||
|
||||
rule_candidates = []
|
||||
base = compact(raw_query)
|
||||
rule_candidates.extend([
|
||||
f"{base} {country_upper}",
|
||||
f"{base} supplier {country_upper}",
|
||||
f"{base} wholesale {country_upper}",
|
||||
f"{base} distributor {country_upper}",
|
||||
f"{base} b2b {country_upper}",
|
||||
])
|
||||
|
||||
lower = base.lower()
|
||||
if "coffee" in lower:
|
||||
rule_candidates.extend([
|
||||
f"coffee shop {country_upper}",
|
||||
f"coffee roastery {country_upper}",
|
||||
f"specialty coffee wholesale {country_upper}",
|
||||
])
|
||||
if "office machine" in lower or "office equipment" in lower:
|
||||
rule_candidates.extend([
|
||||
f"office equipment supplier {country_upper}",
|
||||
f"office machine distributor {country_upper}",
|
||||
])
|
||||
|
||||
expanded = dedupe_keep_order(rule_candidates)
|
||||
if not expanded:
|
||||
fail("failed to build expanded queries")
|
||||
raise SystemExit(0)
|
||||
|
||||
print(json.dumps({
|
||||
"ok": True,
|
||||
"error": "",
|
||||
"expandedQueries": expanded[:8],
|
||||
"primaryQuery": expanded[0],
|
||||
"expansionSource": "rule",
|
||||
}))
|
||||
PY
|
||||
}
|
||||
|
||||
# --- Extract workflow_id from ecom start response ---
|
||||
parse_workflow_id() {
|
||||
local raw="$1"
|
||||
python3 - "$raw" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
raw = sys.argv[1]
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
print("")
|
||||
raise SystemExit(0)
|
||||
wf_id = data.get("workflow_id") or data.get("workflowId") or data.get("id") or ""
|
||||
print(str(wf_id))
|
||||
PY
|
||||
}
|
||||
|
||||
if [ -z "$QUERY" ]; then
|
||||
emit_result "failed" "missing query argument" "" "[]" "" "failed" "" "missing query argument" "false" "" "" "" "0" "0" "0" "0" "" ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RAW_QUERY="$(normalize_query "$QUERY")"
|
||||
COUNTRY_UPPER="$(printf '%s' "$COUNTRY" | tr '[:lower:]' '[:upper:]')"
|
||||
COUNTRY_LOWER="$(printf '%s' "$COUNTRY" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
EXPANSION_RESPONSE="$(resolve_expansion "$RAW_QUERY" "$COUNTRY_UPPER" "$QUERY_EXPANSION_JSON")"
|
||||
EXPANSION_OK="$(auth_runtime_json_get "$EXPANSION_RESPONSE" "ok")"
|
||||
EXPANSION_RAW_ERROR="$(auth_runtime_json_get "$EXPANSION_RESPONSE" "error")"
|
||||
EXPANDED_QUERIES_JSON="$(json_array_value "$EXPANSION_RESPONSE" "expandedQueries")"
|
||||
PRIMARY_QUERY="$(auth_runtime_json_get "$EXPANSION_RESPONSE" "primaryQuery")"
|
||||
EXPANSION_SOURCE="$(auth_runtime_json_get "$EXPANSION_RESPONSE" "expansionSource")"
|
||||
EXPANSION_STATUS="success"
|
||||
EXPANSION_ERROR=""
|
||||
USED_FALLBACK_QUERY="false"
|
||||
|
||||
if ! is_true "$EXPANSION_OK"; then
|
||||
EXPANSION_STATUS="failed"
|
||||
if [ -n "$RAW_QUERY" ]; then
|
||||
PRIMARY_QUERY="$RAW_QUERY"
|
||||
EXPANDED_QUERIES_JSON="$(single_item_array_json "$RAW_QUERY")"
|
||||
EXPANSION_SOURCE="raw_query"
|
||||
EXPANSION_ERROR="query expansion failed: $EXPANSION_RAW_ERROR; fallback to raw query"
|
||||
USED_FALLBACK_QUERY="true"
|
||||
else
|
||||
PRIMARY_QUERY=""
|
||||
EXPANDED_QUERIES_JSON="[]"
|
||||
EXPANSION_SOURCE=""
|
||||
EXPANSION_ERROR="query expansion failed: $EXPANSION_RAW_ERROR"
|
||||
USED_FALLBACK_QUERY="false"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$PRIMARY_QUERY" ]; then
|
||||
if [ -z "$EXPANSION_ERROR" ]; then
|
||||
EXPANSION_ERROR="query expansion failed: primary query is empty"
|
||||
fi
|
||||
emit_result "failed" "$EXPANSION_ERROR" "$RAW_QUERY" "$EXPANDED_QUERIES_JSON" "$PRIMARY_QUERY" "$EXPANSION_STATUS" "$EXPANSION_SOURCE" "$EXPANSION_ERROR" "$USED_FALLBACK_QUERY" "" "" "" "0" "0" "0" "0" "" ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
emit_result "success" "" "$RAW_QUERY" "$EXPANDED_QUERIES_JSON" "$PRIMARY_QUERY" "$EXPANSION_STATUS" "$EXPANSION_SOURCE" "$EXPANSION_ERROR" "$USED_FALLBACK_QUERY" "" "" "dry_run" "0" "0" "0" "0" "DRY_RUN" "DRY_RUN"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$CLIENT_KEY" ]; then
|
||||
emit_result "failed" "missing required env: CLIENT_KEY" "$RAW_QUERY" "$EXPANDED_QUERIES_JSON" "$PRIMARY_QUERY" "$EXPANSION_STATUS" "$EXPANSION_SOURCE" "$EXPANSION_ERROR" "$USED_FALLBACK_QUERY" "" "" "" "0" "0" "0" "0" "" ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Step 1: Exchange CLIENT_KEY for skill token + owner session token
|
||||
# =============================================================================
|
||||
SESSION_JSON="$(auth_runtime_fetch_session_json 0)" || {
|
||||
emit_result "failed" "failed to exchange skill session token" "$RAW_QUERY" "$EXPANDED_QUERIES_JSON" "$PRIMARY_QUERY" "$EXPANSION_STATUS" "$EXPANSION_SOURCE" "$EXPANSION_ERROR" "$USED_FALLBACK_QUERY" "" "" "" "0" "0" "0" "0" "" ""
|
||||
exit 1
|
||||
}
|
||||
OWNER_SESSION_TOKEN="$(auth_runtime_json_get "$SESSION_JSON" "ownerSessionToken")"
|
||||
SESSION_HOOK_URL="$(auth_runtime_json_get "$SESSION_JSON" "hookUrl")"
|
||||
WEBHOOK_URL="$(trim "$SESSION_HOOK_URL")"
|
||||
|
||||
if [ -z "$OWNER_SESSION_TOKEN" ]; then
|
||||
emit_result "failed" "no active owner session (owner must login first)" "$RAW_QUERY" "$EXPANDED_QUERIES_JSON" "$PRIMARY_QUERY" "$EXPANSION_STATUS" "$EXPANSION_SOURCE" "$EXPANSION_ERROR" "$USED_FALLBACK_QUERY" "" "" "" "0" "0" "0" "0" "" ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Webhook URL is now bound to client key on backend, no longer required in env
|
||||
# If SESSION_HOOK_URL is empty, backend will use the bound webhook URL for the client key
|
||||
|
||||
# =============================================================================
|
||||
# Step 2: Start workflow via ecom flow API with webhook_url
|
||||
# =============================================================================
|
||||
START_PAYLOAD="{\"query\":$(json_escape "$PRIMARY_QUERY"),\"country\":$(json_escape "$COUNTRY_LOWER"),\"webhook_url\":$(json_escape "$WEBHOOK_URL")}"
|
||||
START_RAW="$(auth_runtime_request_api POST "$AUTH_BASE/ecom/cold-outreach/run-flow" "$OWNER_SESSION_TOKEN" "$START_PAYLOAD" || true)"
|
||||
START_RESPONSE="$(auth_runtime_extract_body "$START_RAW")"
|
||||
WORKFLOW_ID="$(parse_workflow_id "$START_RESPONSE")"
|
||||
|
||||
if [ -z "$WORKFLOW_ID" ]; then
|
||||
START_ERROR="$(auth_runtime_json_get "$START_RESPONSE" "error")"
|
||||
if [ -z "$START_ERROR" ]; then
|
||||
START_ERROR="$(auth_runtime_json_get "$START_RESPONSE" "message")"
|
||||
fi
|
||||
if [ -z "$START_ERROR" ]; then
|
||||
START_ERROR="failed to start workflow (missing workflowId)"
|
||||
fi
|
||||
emit_result "failed" "start failed: $START_ERROR" "$RAW_QUERY" "$EXPANDED_QUERIES_JSON" "$PRIMARY_QUERY" "$EXPANSION_STATUS" "$EXPANSION_SOURCE" "$EXPANSION_ERROR" "$USED_FALLBACK_QUERY" "" "" "" "0" "0" "0" "0" "SKIPPED" "SKIPPED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Step 3: Return accepted immediately.
|
||||
# woo-data-scrawler will push terminal webhook to webhook_url.
|
||||
# =============================================================================
|
||||
emit_result "success" "" "$RAW_QUERY" "$EXPANDED_QUERIES_JSON" "$PRIMARY_QUERY" "$EXPANSION_STATUS" "$EXPANSION_SOURCE" "$EXPANSION_ERROR" "$USED_FALLBACK_QUERY" "" "$WORKFLOW_ID" "accepted" "0" "0" "0" "0" "SKIPPED" "SKIPPED"
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
QUERY="${1:-office machine}"
|
||||
COUNTRY="${2:-us}"
|
||||
MODE="${3:---dry-run}"
|
||||
CLIENT_KEY="${4:-}"
|
||||
|
||||
# Optional injection for LLM expansion validation:
|
||||
# QUERY_EXPANSION_JSON='{"expandedQueries":["office machine supplier us"],"primaryQuery":"office machine supplier us"}' \
|
||||
# ./run-endpoint-test.sh "office machine" "us"
|
||||
#
|
||||
# Live mode requires 4th arg:
|
||||
# <client_key>
|
||||
# Example:
|
||||
# ./run-endpoint-test.sh "office machine" "us" --live "sk_xxx.yyy"
|
||||
|
||||
echo "== Skill Test: cliet-finder.sh (JSON-only output) =="
|
||||
if [ "$MODE" = "--dry-run" ] || [ -z "$CLIENT_KEY" ]; then
|
||||
"$SCRIPT_DIR/cliet-finder.sh" "$QUERY" "$COUNTRY" "$MODE"
|
||||
else
|
||||
"$SCRIPT_DIR/cliet-finder.sh" "--client-key=$CLIENT_KEY" "$QUERY" "$COUNTRY" "$MODE"
|
||||
fi
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
#!/usr/bin/env bun
|
||||
import { runClientFinder } from '../src/index.js';
|
||||
|
||||
/**
|
||||
* 注意:从 v2.0 开始,不再需要 .env.local 文件
|
||||
* 配置已迁移到全局文件 ~/.openclaw/.env
|
||||
*
|
||||
* 所有 skill 共享同一份配置,无需在每个 skill 中重复配置。
|
||||
*
|
||||
* 创建全局配置:
|
||||
* cp ~/.openclaw/.env.example ~/.openclaw/.env
|
||||
* vi ~/.openclaw/.env # 填入 CLIENT_KEY
|
||||
*/
|
||||
|
||||
/**
|
||||
* Print usage information
|
||||
*/
|
||||
function printUsage(): void {
|
||||
console.error(`Usage:
|
||||
bun run scripts/run.ts [--client-key=<sk_xxx.yyy>] [--auth-base=<url>] "<query>" [country] [--dry-run]
|
||||
|
||||
Environment:
|
||||
全局配置文件:~/.openclaw/.env
|
||||
CLI args take precedence over global config.
|
||||
|
||||
Examples:
|
||||
bun run scripts/run.ts "find electronics suppliers in China"
|
||||
bun run scripts/run.ts "find electronics suppliers in China" China --dry-run
|
||||
bun run scripts/run.ts --client-key=sk_xxx "find suppliers" US
|
||||
|
||||
Configuration:
|
||||
Global config: ~/.openclaw/.env
|
||||
CLI args override global config
|
||||
`);
|
||||
}
|
||||
|
||||
type CliArgs = {
|
||||
query: string;
|
||||
country?: string;
|
||||
dryRun: boolean;
|
||||
clientKey?: string;
|
||||
authBase?: string;
|
||||
};
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs | null {
|
||||
const positionals: string[] = [];
|
||||
let dryRun = false;
|
||||
let clientKey: string | undefined;
|
||||
let authBase: string | undefined;
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg === '--dry-run') {
|
||||
dryRun = true;
|
||||
} else if (arg.startsWith('--client-key=')) {
|
||||
clientKey = arg.slice('--client-key='.length).trim();
|
||||
} else if (arg.startsWith('--auth-base=')) {
|
||||
authBase = arg.slice('--auth-base='.length).trim().replace(/\/$/, '');
|
||||
} else if (arg === '-h' || arg === '--help') {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
} else if (!arg.startsWith('--')) {
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
if (positionals.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = positionals[0];
|
||||
const country = positionals.length > 1 ? positionals[1] : undefined;
|
||||
|
||||
return { query, country, dryRun, clientKey, authBase };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// 不再加载 .env.local,直接使用全局配置 ~/.openclaw/.env
|
||||
// auth-runtime 会自动加载全局配置
|
||||
|
||||
const parsed = parseArgs(process.argv.slice(2));
|
||||
if (!parsed) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 命令行参数覆盖全局配置
|
||||
if (parsed.clientKey) process.env.CLIENT_KEY = parsed.clientKey;
|
||||
if (parsed.authBase) process.env.AUTH_BASE = parsed.authBase;
|
||||
|
||||
const result = await runClientFinder(parsed.query, parsed.country, parsed.dryRun);
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(JSON.stringify({
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
query: '',
|
||||
dryRun: false,
|
||||
}, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SKILL_ENV_FILE="${SKILL_ENV_FILE:-$SKILL_ROOT/.env.local}"
|
||||
|
||||
if [ -f "$SKILL_ENV_FILE" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$SKILL_ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Client-finder UAT (skill-credit + run-flow)
|
||||
#
|
||||
# Required env in live mode:
|
||||
# AUTH_BASE, CLIENT_KEY
|
||||
#
|
||||
# Optional env:
|
||||
# QUERY, COUNTRY
|
||||
#
|
||||
# Examples:
|
||||
# AUTH_BASE=https://api-gw-test.yuanwei-lnc.com CLIENT_KEY=sk_xxx ./skill-run-uat.sh
|
||||
# AUTH_BASE=https://api-gw-test.yuanwei-lnc.com CLIENT_KEY=sk_xxx QUERY="office machine in US" ./skill-run-uat.sh --live
|
||||
|
||||
AUTH_BASE="${AUTH_BASE:-}"
|
||||
CLIENT_KEY="${CLIENT_KEY:-}"
|
||||
QUERY="${QUERY:-office machine in US}"
|
||||
COUNTRY="${COUNTRY:-us}"
|
||||
|
||||
LIVE=0
|
||||
if [ "${1:-}" = "--live" ]; then
|
||||
LIVE=1
|
||||
fi
|
||||
|
||||
if [ "$LIVE" -eq 1 ]; then
|
||||
if [ -z "$AUTH_BASE" ] || [ -z "$CLIENT_KEY" ]; then
|
||||
echo "Missing env: AUTH_BASE and CLIENT_KEY are required in --live mode."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
json_get() {
|
||||
local raw="$1"
|
||||
local key="$2"
|
||||
python3 - "$raw" "$key" <<'PY'
|
||||
import json, sys
|
||||
raw = sys.argv[1]
|
||||
key = sys.argv[2]
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
print("")
|
||||
raise SystemExit(0)
|
||||
val = data.get(key, "")
|
||||
if val is None:
|
||||
val = ""
|
||||
print(val)
|
||||
PY
|
||||
}
|
||||
|
||||
echo "== Client-finder UAT =="
|
||||
echo "QUERY=$QUERY"
|
||||
echo "COUNTRY=$COUNTRY"
|
||||
|
||||
if [ "$LIVE" -eq 0 ]; then
|
||||
echo "Mode: dry-run (no network request)"
|
||||
cat <<EOF
|
||||
|
||||
1) Exchange session token
|
||||
curl -X POST "\$AUTH_BASE/auth/skill-credit/session" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"clientKey":"\$CLIENT_KEY"}'
|
||||
|
||||
2) Start cold outreach flow
|
||||
curl -X POST "\$AUTH_BASE/ecom/cold-outreach/run-flow" \\
|
||||
-H "Authorization: Bearer <ACCESS_TOKEN>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"query":"$QUERY","country":"$COUNTRY"}'
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SESSION_JSON="$(curl -s -X POST "$AUTH_BASE/auth/skill-credit/session" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"clientKey\":\"$CLIENT_KEY\"}")"
|
||||
ACCESS_TOKEN="$(json_get "$SESSION_JSON" "accessToken")"
|
||||
if [ -z "$ACCESS_TOKEN" ]; then
|
||||
echo "Session exchange failed: $SESSION_JSON"
|
||||
exit 1
|
||||
fi
|
||||
echo "Session OK."
|
||||
|
||||
RUN_JSON="$(curl -s -X POST "$AUTH_BASE/ecom/cold-outreach/run-flow" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"query\":\"$QUERY\",\"country\":\"$COUNTRY\"}")"
|
||||
|
||||
echo "Run-flow response:"
|
||||
echo "$RUN_JSON"
|
||||
|
||||
echo "UAT completed."
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
#!/usr/bin/env bun
|
||||
import { runClientFinder } from '../src/index.js';
|
||||
|
||||
/**
|
||||
* Run basic tests
|
||||
*/
|
||||
async function runTests(): Promise<void> {
|
||||
console.log('Running client-finder tests...\n');
|
||||
|
||||
// Test 1: Dry run with query
|
||||
console.log('Test 1: Dry run with query');
|
||||
const result1 = await runClientFinder('office machine', 'us', true);
|
||||
console.log('Result:', JSON.stringify(result1, null, 2));
|
||||
if (result1.status !== 'success') throw new Error('Test 1 failed: status should be success');
|
||||
if (result1.workflowStatus !== 'dry_run') throw new Error('Test 1 failed: workflowStatus should be dry_run');
|
||||
console.log('✓ Test 1 passed\n');
|
||||
|
||||
// Test 2: Missing query
|
||||
console.log('Test 2: Missing query');
|
||||
const result2 = await runClientFinder('', 'us', true);
|
||||
console.log('Result:', JSON.stringify(result2, null, 2));
|
||||
if (result2.status !== 'failed') throw new Error('Test 2 failed: status should be failed');
|
||||
if (result2.error !== 'missing query argument') throw new Error('Test 2 failed: error message mismatch');
|
||||
console.log('✓ Test 2 passed\n');
|
||||
|
||||
// Test 3: Query with country
|
||||
console.log('Test 3: Query with country');
|
||||
const result3 = await runClientFinder('coffee shop', 'us', true);
|
||||
console.log('Result:', JSON.stringify(result3, null, 2));
|
||||
if (result3.status !== 'success') throw new Error('Test 3 failed: status should be success');
|
||||
if (result3.primaryQuery === '') throw new Error('Test 3 failed: primaryQuery should not be empty');
|
||||
if (result3.expandedQueries.length === 0) throw new Error('Test 3 failed: expandedQueries should not be empty');
|
||||
console.log('✓ Test 3 passed\n');
|
||||
|
||||
// Test 4: Cold-outreach prefix normalization
|
||||
console.log('Test 4: Cold-outreach prefix normalization');
|
||||
const result4 = await runClientFinder('cold-outreach: office machine', 'us', true);
|
||||
console.log('Result:', JSON.stringify(result4, null, 2));
|
||||
if (result4.status !== 'success') throw new Error('Test 4 failed: status should be success');
|
||||
if (result4.inputQuery.includes('cold-outreach:')) throw new Error('Test 4 failed: prefix should be removed');
|
||||
console.log('✓ Test 4 passed\n');
|
||||
|
||||
// Test 5: LLM expansion (simulate with env)
|
||||
console.log('Test 5: LLM expansion');
|
||||
const llmJson = JSON.stringify({
|
||||
expandedQueries: ['test query us', 'test query supplier us'],
|
||||
primaryQuery: 'test query us',
|
||||
});
|
||||
console.log('Setting QUERY_EXPANSION_JSON:', llmJson);
|
||||
process.env.QUERY_EXPANSION_JSON = llmJson;
|
||||
const result5 = await runClientFinder('test query', 'us', true);
|
||||
console.log('Result:', JSON.stringify(result5, null, 2));
|
||||
if (result5.status !== 'success') throw new Error('Test 5 failed: status should be success');
|
||||
if (result5.expansionSource !== 'llm') throw new Error(`Test 5 failed: expansionSource should be llm, got ${result5.expansionSource}`);
|
||||
console.log('✓ Test 5 passed\n');
|
||||
|
||||
console.log('All tests passed! ✓');
|
||||
}
|
||||
|
||||
runTests().catch((error) => {
|
||||
console.error('Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
import { ExpansionResult } from './types.js';
|
||||
|
||||
/**
|
||||
* Trim whitespace from string
|
||||
*/
|
||||
function trim(input: string): string {
|
||||
return input.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact multiple spaces into single space
|
||||
*/
|
||||
function compact(input: string): string {
|
||||
return input.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize query by trimming and removing prefix
|
||||
*/
|
||||
export function normalizeQuery(input: string): string {
|
||||
const normalized = trim(input);
|
||||
// Remove leading "cold-outreach:" prefix (case-insensitive)
|
||||
return normalized.replace(/^[Cc][Oo][Ll][Dd]-[Oo][Uu][Tt][Rr][Ee][Aa][Cc][Hh]:\s*/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate array while preserving order
|
||||
*/
|
||||
function dedupeKeepOrder(items: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const output: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const cleaned = compact(item);
|
||||
if (!cleaned) continue;
|
||||
|
||||
const key = cleaned.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
|
||||
seen.add(key);
|
||||
output.push(cleaned);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse LLM expansion JSON
|
||||
*/
|
||||
function parseLLMExpansion(llmExpansion: string): ExpansionResult {
|
||||
try {
|
||||
const parsed = JSON.parse(llmExpansion);
|
||||
let expanded: string[];
|
||||
let primary: string;
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
expanded = dedupeKeepOrder(parsed);
|
||||
primary = expanded[0] || '';
|
||||
} else if (typeof parsed === 'object' && parsed !== null) {
|
||||
expanded = dedupeKeepOrder(
|
||||
(parsed as any).expandedQueries || (parsed as any).queries || []
|
||||
);
|
||||
primary = compact(
|
||||
(parsed as any).primaryQuery || (parsed as any).primary_query || (expanded[0] || '')
|
||||
);
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'QUERY_EXPANSION_JSON must be an array or object',
|
||||
expandedQueries: [],
|
||||
primaryQuery: '',
|
||||
expansionSource: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (expanded.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'expandedQueries is empty',
|
||||
expandedQueries: [],
|
||||
primaryQuery: '',
|
||||
expansionSource: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (!primary) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'primaryQuery is empty',
|
||||
expandedQueries: [],
|
||||
primaryQuery: '',
|
||||
expansionSource: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure primary is in expanded queries
|
||||
if (!expanded.some(q => q.toLowerCase() === primary.toLowerCase())) {
|
||||
expanded.unshift(primary);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
error: '',
|
||||
expandedQueries: expanded,
|
||||
primaryQuery: primary,
|
||||
expansionSource: 'llm',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'QUERY_EXPANSION_JSON is not valid JSON',
|
||||
expandedQueries: [],
|
||||
primaryQuery: '',
|
||||
expansionSource: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate rule-based expansion
|
||||
*/
|
||||
function generateRuleExpansion(rawQuery: string, countryUpper: string): ExpansionResult {
|
||||
const base = compact(rawQuery);
|
||||
const ruleCandidates: string[] = [
|
||||
`${base} ${countryUpper}`,
|
||||
`${base} supplier ${countryUpper}`,
|
||||
`${base} wholesale ${countryUpper}`,
|
||||
`${base} distributor ${countryUpper}`,
|
||||
`${base} b2b ${countryUpper}`,
|
||||
];
|
||||
|
||||
const lower = base.toLowerCase();
|
||||
|
||||
if (lower.includes('coffee')) {
|
||||
ruleCandidates.push(
|
||||
`coffee shop ${countryUpper}`,
|
||||
`coffee roastery ${countryUpper}`,
|
||||
`specialty coffee wholesale ${countryUpper}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (lower.includes('office machine') || lower.includes('office equipment')) {
|
||||
ruleCandidates.push(
|
||||
`office equipment supplier ${countryUpper}`,
|
||||
`office machine distributor ${countryUpper}`,
|
||||
);
|
||||
}
|
||||
|
||||
const expanded = dedupeKeepOrder(ruleCandidates);
|
||||
|
||||
if (expanded.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'failed to build expanded queries',
|
||||
expandedQueries: [],
|
||||
primaryQuery: '',
|
||||
expansionSource: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
error: '',
|
||||
expandedQueries: expanded.slice(0, 8), // Limit to 8 queries
|
||||
primaryQuery: expanded[0],
|
||||
expansionSource: 'rule',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve query expansion from LLM JSON or rule-based logic
|
||||
*/
|
||||
export function resolveExpansion(
|
||||
rawQuery: string,
|
||||
countryUpper: string,
|
||||
llmExpansion: string,
|
||||
): ExpansionResult {
|
||||
const normalized = compact(rawQuery);
|
||||
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'query is empty after normalization',
|
||||
expandedQueries: [],
|
||||
primaryQuery: '',
|
||||
expansionSource: '',
|
||||
};
|
||||
}
|
||||
|
||||
// If LLM expansion is provided, use it
|
||||
if (llmExpansion.trim()) {
|
||||
return parseLLMExpansion(llmExpansion);
|
||||
}
|
||||
|
||||
// Otherwise use rule-based expansion
|
||||
return generateRuleExpansion(normalized, countryUpper);
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
import type { EnvConfig, OutputResult } from './types.js';
|
||||
import { createEnvConfig as createBaseEnvConfig, getAccessToken } from '@clawd/auth-runtime';
|
||||
import { normalizeQuery, resolveExpansion } from './expansion.js';
|
||||
import { startWorkflow } from './workflow.js';
|
||||
|
||||
/**
|
||||
* Create client-finder specific environment configuration
|
||||
* Extends the shared auth config with skill-specific fields
|
||||
*/
|
||||
function createEnvConfig(): EnvConfig {
|
||||
const baseConfig = createBaseEnvConfig();
|
||||
return {
|
||||
...baseConfig,
|
||||
queryExpansionJson: process.env.QUERY_EXPANSION_JSON || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for client-finder skill
|
||||
*/
|
||||
export async function runClientFinder(
|
||||
query: string,
|
||||
country: string,
|
||||
dryRun: boolean = false,
|
||||
): Promise<OutputResult> {
|
||||
const config = createEnvConfig();
|
||||
|
||||
// Validate query
|
||||
if (!query) {
|
||||
return createFailedResult('', 'missing query argument');
|
||||
}
|
||||
|
||||
// Normalize query
|
||||
const rawQuery = normalizeQuery(query);
|
||||
const countryUpper = country.toUpperCase();
|
||||
const countryLower = country.toLowerCase();
|
||||
|
||||
// Resolve expansion
|
||||
const expansion = resolveExpansion(
|
||||
rawQuery,
|
||||
countryUpper,
|
||||
config.queryExpansionJson,
|
||||
);
|
||||
|
||||
let expandedQueries = expansion.expandedQueries;
|
||||
let primaryQuery = expansion.primaryQuery;
|
||||
let expansionStatus = expansion.ok ? 'success' : ('failed' as const);
|
||||
let expansionSource = expansion.expansionSource;
|
||||
let expansionError = expansion.error || '';
|
||||
let usedFallbackQuery = false;
|
||||
|
||||
// Handle expansion failure
|
||||
if (!expansion.ok) {
|
||||
if (rawQuery) {
|
||||
primaryQuery = rawQuery;
|
||||
expandedQueries = [rawQuery];
|
||||
expansionSource = 'raw_query';
|
||||
expansionError = `query expansion failed: ${expansion.error}; fallback to raw query`;
|
||||
usedFallbackQuery = true;
|
||||
} else {
|
||||
expansionError = expansionError || 'query expansion failed: primary query is empty';
|
||||
return createFailedResult(
|
||||
rawQuery,
|
||||
expansionError,
|
||||
expandedQueries,
|
||||
primaryQuery,
|
||||
expansionStatus,
|
||||
expansionSource,
|
||||
expansionError,
|
||||
usedFallbackQuery,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate primary query
|
||||
if (!primaryQuery) {
|
||||
expansionError = expansionError || 'query expansion failed: primary query is empty';
|
||||
return createFailedResult(
|
||||
rawQuery,
|
||||
expansionError,
|
||||
expandedQueries,
|
||||
primaryQuery,
|
||||
expansionStatus,
|
||||
expansionSource,
|
||||
expansionError,
|
||||
usedFallbackQuery,
|
||||
);
|
||||
}
|
||||
|
||||
// Dry run mode
|
||||
if (dryRun) {
|
||||
return createSuccessResult(
|
||||
rawQuery,
|
||||
expandedQueries,
|
||||
primaryQuery,
|
||||
expansionStatus,
|
||||
expansionSource,
|
||||
expansionError,
|
||||
usedFallbackQuery,
|
||||
'',
|
||||
'dry_run',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate CLIENT_KEY in live mode
|
||||
if (!config.clientKey) {
|
||||
return createFailedResult(
|
||||
rawQuery,
|
||||
'missing required env: CLIENT_KEY',
|
||||
expandedQueries,
|
||||
primaryQuery,
|
||||
expansionStatus,
|
||||
expansionSource,
|
||||
expansionError,
|
||||
usedFallbackQuery,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 1: Exchange CLIENT_KEY for runtime access token
|
||||
let accessToken = '';
|
||||
try {
|
||||
accessToken = await getAccessToken(dryRun, config);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'failed to exchange skill session token';
|
||||
return createFailedResult(
|
||||
rawQuery,
|
||||
errorMsg,
|
||||
expandedQueries,
|
||||
primaryQuery,
|
||||
expansionStatus,
|
||||
expansionSource,
|
||||
expansionError,
|
||||
usedFallbackQuery,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Start workflow with runtime access token
|
||||
const workflowResult = await startWorkflow(
|
||||
config,
|
||||
dryRun,
|
||||
accessToken,
|
||||
primaryQuery,
|
||||
countryLower,
|
||||
);
|
||||
|
||||
if (!workflowResult.workflowId) {
|
||||
return createFailedResult(
|
||||
rawQuery,
|
||||
`start failed: ${workflowResult.error}`,
|
||||
expandedQueries,
|
||||
primaryQuery,
|
||||
expansionStatus,
|
||||
expansionSource,
|
||||
expansionError,
|
||||
usedFallbackQuery,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Return accepted immediately
|
||||
return createSuccessResult(
|
||||
rawQuery,
|
||||
expandedQueries,
|
||||
primaryQuery,
|
||||
expansionStatus,
|
||||
expansionSource,
|
||||
expansionError,
|
||||
usedFallbackQuery,
|
||||
'',
|
||||
'accepted',
|
||||
workflowResult.workflowId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a failed result
|
||||
*/
|
||||
function createFailedResult(
|
||||
inputQuery: string,
|
||||
error: string,
|
||||
expandedQueries: string[] = [],
|
||||
primaryQuery: string = '',
|
||||
expansionStatus: 'success' | 'failed' = 'failed',
|
||||
expansionSource: 'llm' | 'rule' | 'raw_query' | '' = '',
|
||||
expansionError: string | null = null,
|
||||
usedFallbackQuery: boolean = false,
|
||||
): OutputResult {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: error || null,
|
||||
inputQuery,
|
||||
expandedQueries,
|
||||
primaryQuery,
|
||||
expansionStatus,
|
||||
expansionSource,
|
||||
expansionError: expansionError || null,
|
||||
usedFallbackQuery,
|
||||
runId: '',
|
||||
workflowId: '',
|
||||
workflowStatus: '',
|
||||
billingReserveStatus: '',
|
||||
billingFinalizeStatus: '',
|
||||
businessesCount: 0,
|
||||
contactsCount: 0,
|
||||
uniqueContactDomains: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a success result
|
||||
*/
|
||||
function createSuccessResult(
|
||||
inputQuery: string,
|
||||
expandedQueries: string[],
|
||||
primaryQuery: string,
|
||||
expansionStatus: 'success' | 'failed',
|
||||
expansionSource: 'llm' | 'rule' | 'raw_query' | '',
|
||||
expansionError: string | null,
|
||||
usedFallbackQuery: boolean,
|
||||
runId: string,
|
||||
workflowStatus: string,
|
||||
workflowId: string = '',
|
||||
): OutputResult {
|
||||
return {
|
||||
status: 'success',
|
||||
error: null,
|
||||
inputQuery,
|
||||
expandedQueries,
|
||||
primaryQuery,
|
||||
expansionStatus,
|
||||
expansionSource,
|
||||
expansionError,
|
||||
usedFallbackQuery,
|
||||
runId,
|
||||
workflowId,
|
||||
workflowStatus,
|
||||
billingReserveStatus: 'SKIPPED',
|
||||
billingFinalizeStatus: 'SKIPPED',
|
||||
businessesCount: 0,
|
||||
contactsCount: 0,
|
||||
uniqueContactDomains: 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import type { EnvConfig as BaseEnvConfig } from '@clawd/auth-runtime';
|
||||
|
||||
/**
|
||||
* Query expansion result from LLM or rule-based logic
|
||||
*/
|
||||
export interface ExpansionResult {
|
||||
ok: boolean;
|
||||
error: string;
|
||||
expandedQueries: string[];
|
||||
primaryQuery: string;
|
||||
expansionSource: 'llm' | 'rule' | 'raw_query' | '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Start workflow response from /ecom/cold-outreach/run-flow
|
||||
*/
|
||||
export interface WorkflowStartResponse {
|
||||
workflowId?: string;
|
||||
workflow_id?: string;
|
||||
id?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final output matching output_schema.json
|
||||
*/
|
||||
export interface OutputResult {
|
||||
status: 'success' | 'failed';
|
||||
error: string | null;
|
||||
inputQuery: string;
|
||||
expandedQueries: string[];
|
||||
primaryQuery: string;
|
||||
expansionStatus: 'success' | 'failed';
|
||||
expansionSource: 'llm' | 'rule' | 'raw_query' | '';
|
||||
expansionError: string | null;
|
||||
usedFallbackQuery: boolean;
|
||||
runId: string;
|
||||
workflowId: string;
|
||||
workflowStatus: string;
|
||||
billingReserveStatus: string;
|
||||
billingFinalizeStatus: string;
|
||||
businessesCount: number;
|
||||
contactsCount: number;
|
||||
uniqueContactDomains: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-finder specific environment configuration
|
||||
* Extends the shared auth config with skill-specific fields
|
||||
*/
|
||||
export interface EnvConfig extends BaseEnvConfig {
|
||||
queryExpansionJson: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { requestApiWithAutoRefresh } from '@clawd/auth-runtime';
|
||||
import type { EnvConfig as AuthEnvConfig } from '@clawd/auth-runtime';
|
||||
import { WorkflowStartResponse } from './types.js';
|
||||
|
||||
/**
|
||||
* Extract workflow ID from response
|
||||
*/
|
||||
export function parseWorkflowId(responseBody: string): string {
|
||||
try {
|
||||
const data = JSON.parse(responseBody) as WorkflowStartResponse;
|
||||
return data.workflowId || data.workflow_id || data.id || '';
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start cold outreach workflow
|
||||
*/
|
||||
export async function startWorkflow(
|
||||
config: AuthEnvConfig,
|
||||
dryRun: boolean,
|
||||
accessToken: string,
|
||||
query: string,
|
||||
country: string,
|
||||
): Promise<{ workflowId: string; error: string }> {
|
||||
const payload = JSON.stringify({
|
||||
query,
|
||||
country,
|
||||
});
|
||||
|
||||
const result = await requestApiWithAutoRefresh(
|
||||
'POST',
|
||||
`${config.authBase}/ecom/cold-outreach/run-flow`,
|
||||
dryRun,
|
||||
config,
|
||||
payload,
|
||||
accessToken,
|
||||
);
|
||||
|
||||
if (result.status < 200 || result.status >= 300) {
|
||||
const error = parseError(result.body);
|
||||
return {
|
||||
workflowId: '',
|
||||
error: error || `HTTP ${result.status}: ${result.body}`,
|
||||
};
|
||||
}
|
||||
|
||||
const workflowId = parseWorkflowId(result.body);
|
||||
|
||||
if (!workflowId) {
|
||||
const error = parseError(result.body);
|
||||
return {
|
||||
workflowId: '',
|
||||
error: error || 'failed to start workflow (missing workflowId)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
workflowId,
|
||||
error: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse error from response body
|
||||
*/
|
||||
function parseError(body: string): string {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
return data.error || data.message || '';
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue