From 379f636779b3934908ed758ee20bebf2abdc82b6 Mon Sep 17 00:00:00 2001 From: chupei Date: Mon, 21 Sep 2026 16:05:11 +0800 Subject: [PATCH 1/2] feat: add dingo-saas skill --- skills/dingo-saas/README.md | 75 +++++ skills/dingo-saas/SKILL.md | 258 +++++++++++++++ skills/dingo-saas/agents/openai.yaml | 7 + skills/dingo-saas/assets/mineru_pdf.jsonl | 3 + .../dingo-saas/scripts/connection_actions.mjs | 44 +++ skills/dingo-saas/scripts/dataset_actions.mjs | 130 ++++++++ .../scripts/dataset_actions.test.mjs | 151 +++++++++ .../dingo-saas/scripts/experiment_actions.mjs | 156 +++++++++ .../scripts/experiment_actions.test.mjs | 106 ++++++ .../dingo-saas/scripts/lib/dingo_client.mjs | 243 ++++++++++++++ skills/dingo-saas/scripts/metric_actions.mjs | 304 ++++++++++++++++++ .../scripts/metric_actions.test.mjs | 169 ++++++++++ skills/dingo-saas/scripts/report_actions.mjs | 190 +++++++++++ .../scripts/report_actions.test.mjs | 179 +++++++++++ 14 files changed, 2015 insertions(+) create mode 100644 skills/dingo-saas/README.md create mode 100644 skills/dingo-saas/SKILL.md create mode 100644 skills/dingo-saas/agents/openai.yaml create mode 100644 skills/dingo-saas/assets/mineru_pdf.jsonl create mode 100644 skills/dingo-saas/scripts/connection_actions.mjs create mode 100644 skills/dingo-saas/scripts/dataset_actions.mjs create mode 100644 skills/dingo-saas/scripts/dataset_actions.test.mjs create mode 100644 skills/dingo-saas/scripts/experiment_actions.mjs create mode 100644 skills/dingo-saas/scripts/experiment_actions.test.mjs create mode 100644 skills/dingo-saas/scripts/lib/dingo_client.mjs create mode 100644 skills/dingo-saas/scripts/metric_actions.mjs create mode 100644 skills/dingo-saas/scripts/metric_actions.test.mjs create mode 100644 skills/dingo-saas/scripts/report_actions.mjs create mode 100644 skills/dingo-saas/scripts/report_actions.test.mjs diff --git a/skills/dingo-saas/README.md b/skills/dingo-saas/README.md new file mode 100644 index 00000000..e5b393d4 --- /dev/null +++ b/skills/dingo-saas/README.md @@ -0,0 +1,75 @@ +# 用自然语言运行 Dingo SaaS 评测 + +Dingo SaaS Skill 让你通过自然语言管理数据集和指标、运行实验并分析报告。你只需要说明评测目标,代理会查找资源、调用 Dingo SaaS API,并按正确顺序完成操作。 + +## 开始之前 + +你需要: + +- Dingo SaaS 网站地址; +- 一个有效的 API Key。 + +登录 Dingo SaaS 后,进入 `Dashboard → Settings`(`/dashboard/settings`)创建 API Key。完整 Key 只会在创建时显示一次,请及时保存。 + +## 连接 Dingo SaaS + +第一次使用时输入: + +```text +使用 $dingo-saas 连接我的 Dingo SaaS。 +``` + +代理会询问网站地址和 API Key,并在本地保存连接。随后用一个只读请求验证连接: + +```text +检查连接,并列出我的数据集、指标组和最近 5 份报告。不要修改任何资源。 +``` + +连接成功后,代理会返回资源名称和 ID,但不会在回答中显示完整 API Key。 + +## 运行第一次完整质检 + +你可以在一条消息里描述完整目标: + +```text +上传 Skill 目录中的 assets/mineru_pdf.jsonl,创建“MinerU PDF 抽取结果”数据集。 +从已有 Metric 中列出适合检查 PDF 抽取内容质量的指标,并说明各自的评判标准,让我选择一个。 +选择后创建并运行质检实验。质检完成后,将报告发送给我。 +``` + +代理会依次完成: + +1. 上传 JSONL 文件并创建数据集; +2. 从已有 Metric 中筛选适合的指标; +3. 等你选定 Metric 后创建并启动实验; +4. 持续检查状态,完成后将报告发送给你。 + +完成后,你会收到类似结果: + +| 项目 | 结果示例 | +| --- | --- | +| 数据集 | MinerU PDF 抽取结果 | +| 质检 Metric | PDF 内容完整性检查 | +| 实验状态 | `success` | +| 报告 | MinerU PDF 抽取质量测试_20260914_093000 | +| 交付内容 | 完整质检报告 | + +## 还可以这样使用 + +```text +预览“MinerU PDF 抽取结果”数据集,告诉我有哪些字段,并显示前 3 条记录。 +``` + +```text +如果没有合适的 Metric,帮我起草一个“PDF 内容抽取质量”自定义指标,先给我看评判标准,不要保存。 +``` + +```text +把“MinerU PDF 抽取质量测试”实验设置为工作日每天 09:30 执行,时区使用 Asia/Shanghai。 +``` + +```text +找到最近一次 MinerU 抽取质量报告并发送给我。 +``` + +还可以通过 Skill 完成更多数据集、指标、实验和报告操作。完整动作和执行约束见 [SKILL.md](SKILL.md)。 diff --git a/skills/dingo-saas/SKILL.md b/skills/dingo-saas/SKILL.md new file mode 100644 index 00000000..ab4d2cd1 --- /dev/null +++ b/skills/dingo-saas/SKILL.md @@ -0,0 +1,258 @@ +--- +name: dingo-saas +description: Operate Dingo SaaS through conversation by mapping user requests to backend API actions. Use for Dingo SaaS dataset, metric, experiment, and report workflows. +--- + +# Dingo SaaS + +Translate the user's request into one or more actions, then execute them in dependency order through the corresponding backend APIs. + +## Implemented Modules + +- Datasets: use `scripts/dataset_actions.mjs` and the actions below. +- Experiments: use `scripts/experiment_actions.mjs` and the actions below. +- Reports: use `scripts/report_actions.mjs` and the actions below. +- Metrics: use `scripts/metric_actions.mjs` and the actions below. + +Use only implemented actions. Do not claim that an unimplemented Dingo SaaS page is available through this skill. + +## Connection + +Before any Dingo SaaS action, check the saved connection: + +```powershell +node scripts/connection_actions.mjs connection_status +``` + +If the response says `configured: false`, immediately ask the user for both the Dingo SaaS +website URL and API key. Save them with: + +```powershell +node scripts/connection_actions.mjs configure --url --key +``` + +The script persists them in `config.json` in this skill directory. Once saved, reuse that +connection for every action without asking again. If an API returns 401, ask the user for a +new URL and key and overwrite the saved configuration with `configure`. + +Never print or repeat the complete key. `configure` and `connection_status` return only a +masked key prefix. When the user explicitly asks to forget the connection, run: + +```powershell +node scripts/connection_actions.mjs clear_config +``` + +Run an action with its page module script: + +```powershell +node scripts/dataset_actions.mjs [options] +node scripts/experiment_actions.mjs [options] +node scripts/report_actions.mjs [options] +node scripts/metric_actions.mjs [options] +``` + +Resolve the script path relative to this `SKILL.md` file. + +## Dataset Actions + +| User intent | Action | Backend API | +|---|---|---| +| List, search, or locate datasets | `list_datasets` | `GET /api/v1/datasets/` | +| View dataset details | `get_dataset` | `GET /api/v1/datasets/{dataset_id}` | +| Preview dataset rows | `preview_dataset` | `GET /api/v1/datasets/{dataset_id}/preview` | +| Initialize example data | `initialize_demo_data` | `POST /api/v1/demo/initialize` | +| Upload a local source file | `upload_dataset_file` | `POST /api/v1/datasets/upload` | +| Create a dataset | `create_dataset` | `POST /api/v1/datasets/` | +| Edit or rename a dataset | `update_dataset` | `PUT /api/v1/datasets/{dataset_id}` | +| Copy or clone a dataset | `copy_dataset` | `POST /api/v1/datasets/{dataset_id}/copy` | +| Delete a dataset | `delete_dataset` | `DELETE /api/v1/datasets/{dataset_id}` | + +Use `node scripts/dataset_actions.mjs actions` to see parameters. + +`create_dataset` accepts the backend `DatasetCreate` JSON body. For a local upload, use at least: + +```json +{ + "name": "Dataset name", + "source": "local", + "format": "jsonl", + "temp_file_paths": ["temp_path returned by upload_dataset_file"], + "relative_paths": ["original-file.jsonl"] +} +``` + +`update_dataset` accepts any of `name`, `description`, `config`, and `status`. + +## Experiment Actions + +| User intent | Action | Backend API | +|---|---|---| +| List, filter, or locate experiments | `list_experiments` | `GET /api/v1/experiments/` | +| View or edit experiment details | `get_experiment`, `update_experiment` | `GET/PUT /api/v1/experiments/{experiment_id}` | +| Create an experiment | `create_experiment` | `POST /api/v1/experiments/` | +| Copy an experiment | `copy_experiment` | `POST /api/v1/experiments/{experiment_id}/copy` | +| Delete an experiment | `delete_experiment` | `DELETE /api/v1/experiments/{experiment_id}` | +| Start an experiment | `start_experiment` | `POST /api/v1/experiments/{experiment_id}/start` | +| Stop a running experiment | `stop_experiment` | `POST /api/v1/experiments/{experiment_id}/stop` | +| View, create, or edit a schedule | `get_experiment_schedule`, `upsert_experiment_schedule` | `GET/PUT /api/v1/experiments/{experiment_id}/schedule` | +| Enable or disable a schedule | `toggle_experiment_schedule` | `POST /api/v1/experiments/{experiment_id}/schedule/toggle` | +| Delete a schedule | `delete_experiment_schedule` | `DELETE /api/v1/experiments/{experiment_id}/schedule` | +| View generated Dingo input | `get_experiment_dingo_input` | `GET /api/v1/experiments/{experiment_id}/dingo-input` | + +Use `node scripts/experiment_actions.mjs actions` to see parameters. + +`create_experiment` accepts the backend `ExperimentCreate` JSON body. Common fields are +`name`, `description`, `dataset_id`, `task_type`, `evaluators`, `dingo_config`, `config_yaml`, +and `retrieval_config`. `update_experiment` accepts the corresponding editable fields. + +`upsert_experiment_schedule` accepts: + +```json +{ + "start_date": "2026-09-11", + "end_date": "2026-09-30", + "weekdays": [1, 3, 5], + "start_time": "09:30", + "timezone": "Asia/Shanghai", + "enabled": true, + "schedule_node": "optional-node" +} +``` + +Weekdays use `1` for Monday through `7` for Sunday. + +## Report Actions + +Reports have three distinct page scopes. Keep them separate when interpreting the user's request. + +### Report List Page + +| User intent | Action | Backend API | +|---|---|---| +| List, search, or filter reports | `list_reports` | `GET /api/v1/outputs/` | +| Delete a report | `delete_report` | `DELETE /api/v1/outputs/{report_id}` | +| View a report's execution log | `get_report_log` | `GET /api/v1/outputs/{report_id}` | +| Get the public share URL | `get_report_share_url` | Local URL composition: `/share/{report_id}` | +| Download the report archive | `download_report` | `GET /api/v1/outputs/{report_id}/download` | + +### Report Detail Page + +| User intent | Action | Backend API | +|---|---|---| +| View basic information, Dingo input, and execution metadata | `get_report_detail` | `GET /api/v1/outputs/{report_id}` | +| Rename a report or edit its description | `update_report` | `PUT /api/v1/outputs/{report_id}` | +| Delete a report | `delete_report` | `DELETE /api/v1/outputs/{report_id}` | +| Download the report archive | `download_report` | `GET /api/v1/outputs/{report_id}/download` | + +### Report Results Page + +| User intent | Action | Backend API | +|---|---|---| +| Open or summarize the complete results page | `get_report_results` | Report detail + result-row list | +| List or filter result rows only | `list_report_results` | `GET /api/v1/output-items/output/{report_id}` | +| View one result row | `get_report_result` | `GET /api/v1/output-items/{item_id}` | +| Get the public results URL | `get_report_share_url` | Local URL composition: `/share/{report_id}` | +| Download the report archive | `download_report` | `GET /api/v1/outputs/{report_id}/download` | + +Use `node scripts/report_actions.mjs actions` to see parameters. + +`list_reports` supports experiment, status, creation-date, and pagination filters. Its optional +`--search` matches report ID, name, or description within the returned page, like the report page. +`list_report_results` supports `eval_status` (`all`, `true`, or `false`) and label filtering. + +The detail page and results page are not interchangeable. `get_report_detail` returns report +metadata, Dingo input, execution status, and `dingo_result`. The results page additionally needs +the paginated records from `output-items`; use `get_report_results` to retrieve both parts. Treat +`dingo_result.summary` as the results-page summary, not as the individual result rows. + +`update_report` accepts a JSON body containing only the page-editable fields `name` and/or +`description`. `get_report_log` returns the persisted log, or the report message when no log was +captured. `get_report_share_url` only constructs the existing public URL; it does not create a +share token or change server state. `download_report` requires `--output PATH` and saves the ZIP +archive to that location without overwriting an existing file. + +## Metric Actions + +The Metrics page has three tabs. Do not treat the read-only SDK metric catalog, user metric +groups, and user-defined custom LLM metrics as the same resource. + +### Metric List Tab + +| User intent | Action | Backend API | +|---|---|---| +| List, search, or filter available SDK metrics | `list_metrics` | `GET /api/v1/metrics/list` | + +Use `--type rule` or `--type llm` to filter by metric type. `--search` matches metric name, +display name, or description within the returned catalog. This tab is read-only; global metric +configuration belongs to the separate administrator configuration page and is not part of this +module. + +### Metric Groups Tab + +| User intent | Action | Backend API | +|---|---|---| +| List or search metric groups | `list_metric_groups` | `GET /api/v1/metric-groups/` | +| View a group and its metrics | `get_metric_group_detail` | Group detail + group-item list | +| Create a metric group | `create_metric_group` | `POST /api/v1/metric-groups/` | +| Rename or edit a metric group | `update_metric_group` | `PUT /api/v1/metric-groups/{group_id}` | +| Delete a metric group | `delete_metric_group` | `DELETE /api/v1/metric-groups/{group_id}` | +| Add a metric to a group | `add_metric_to_group` | `POST /api/v1/metric-group-items/` | +| Add a saved custom metric to a group | `add_custom_metric_to_group` | Custom metric detail + group-item create | +| Edit a grouped metric's configuration | `update_group_metric` | `PUT /api/v1/metric-group-items/{item_id}` | +| Remove a metric from a group | `remove_metric_from_group` | `DELETE /api/v1/metric-group-items/{item_id}` | + +`create_metric_group` accepts `name` and optional `description`. If the user also specifies +metrics, create the group first and then call `add_metric_to_group` once for each selected metric +using the returned group ID. A group item body contains `group_id`, metric `name`, and optional +`config`. + +### Custom Metrics Tab + +| User intent | Action | Backend API | +|---|---|---| +| List or search custom metrics | `list_custom_metrics` | `GET /api/v1/custom-llm-metrics/` | +| View a custom metric | `get_custom_metric` | `GET /api/v1/custom-llm-metrics/{metric_id}` | +| Create a custom metric | `create_custom_metric` | `POST /api/v1/custom-llm-metrics/` | +| Edit a custom metric | `update_custom_metric` | `PUT /api/v1/custom-llm-metrics/{metric_id}` | +| Delete a custom metric | `delete_custom_metric` | `DELETE /api/v1/custom-llm-metrics/{metric_id}` | +| Ask the assistant to draft a custom metric | `draft_custom_metric` | `POST /api/v1/custom-llm-metrics/assistant` | +| Start or check a custom metric quick try | `start_custom_metric_quick_try`, `get_metric_quick_try` | Custom metric detail + `POST/GET /api/v1/quick-eval/...` | + +A custom metric body uses `metric`, `description`, `criteria`, `input_fields`, and `llm_config`. +`criteria` and `input_fields` are non-empty string arrays. `llm_config` contains `model`, `key`, +and `api_url`; system defaults are `SYSTEM_LLM_MODEL`, `SYSTEM_LLM_KEY`, and +`SYSTEM_LLM_API_URL`. Treat an explicit LLM key as a credential: pass the body through `--stdin` +and never repeat the key in the response. + +`draft_custom_metric` accepts `messages` plus `current_metric` and only returns a proposed draft; +do not create or update the metric until the user asks to save or apply it. +`start_custom_metric_quick_try` accepts the input-field values as its JSON body, loads the saved +custom metric, builds the `LLMCustomMetric` evaluator snapshot, and submits the job. Poll +`get_metric_quick_try` every 2 seconds until status is `done` or `error`, stopping after 5 minutes. + +Use `node scripts/metric_actions.mjs actions` to see all parameters. + +## Execution + +1. Map the request to the smallest ordered action list. +2. When a dataset, experiment, or report ID is missing, call its list action and match the user's name against `name`. If multiple records match, ask the user which one they mean. +3. For a local file, call `upload_dataset_file`, then pass its `temp_path` in `temp_file_paths` to `create_dataset`. +4. Pass JSON request bodies with `--body`, `--body-file`, or `--stdin`. Prefer `--stdin` when a body contains credentials; never echo secrets in the response. +5. Before deleting a dataset, experiment, experiment schedule, report, metric group, grouped metric, or custom metric, identify the exact target and obtain confirmation unless the user explicitly requested deletion of that exact target in the current message. Use soft dataset deletion by default; use `--hard-delete` only when explicitly requested. +6. For `start_experiment`, use the user's report name and description. If no report name is provided, get the experiment and generate `_YYYYMMDD_HHMMSS`; use an empty description when omitted. +7. After starting an experiment, use `get_experiment` to check status. Stop polling when status is no longer `running`. +8. When the user asks to follow a running report's log, call `get_report_log` every 3 seconds and stop when its status is `success`, `failed`, `error`, or `stopped`. +9. Execute actions in order. If one fails, stop dependent actions and report the API error. +10. Return the completed actions and important result fields, such as resource name, ID, and status. + +Examples of composition: + +- “Create a dataset from this local file” → `upload_dataset_file`, then `create_dataset`. +- “Copy dataset A and rename it B” → `list_datasets` when needed, `copy_dataset`, then `update_dataset` using the copied dataset ID. +- “Run experiment A” → `list_experiments` when needed, `get_experiment`, `start_experiment`, then check its status. +- “Schedule experiment A on weekdays” → `list_experiments` when needed, then `upsert_experiment_schedule`. +- “Show failed reports from experiment A” → `list_experiments` when needed, then `list_reports` with its experiment ID and failed status. +- “View the log for report R” → `list_reports` when needed, then `get_report_log`. +- “Download report R” → `list_reports` when needed, then `download_report` to the requested local path. +- “Create metric group G with metrics A and B” → `create_metric_group`, then `add_metric_to_group` for A and B. +- “Draft a custom relevance metric” → `draft_custom_metric`; create it only after the user asks to save it. diff --git a/skills/dingo-saas/agents/openai.yaml b/skills/dingo-saas/agents/openai.yaml new file mode 100644 index 00000000..66bc1017 --- /dev/null +++ b/skills/dingo-saas/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Dingo SaaS" + short_description: "Operate Dingo SaaS through API-backed actions" + default_prompt: "Use $dingo-saas to manage my Dingo SaaS resources." + +policy: + allow_implicit_invocation: true diff --git a/skills/dingo-saas/assets/mineru_pdf.jsonl b/skills/dingo-saas/assets/mineru_pdf.jsonl new file mode 100644 index 00000000..2cae5706 --- /dev/null +++ b/skills/dingo-saas/assets/mineru_pdf.jsonl @@ -0,0 +1,3 @@ +{"sha256":"dddcd9a39b81ed480083c007ceb7fc2352cc14bd8853e6da795c5c84ad9c5c3c","pdf_pages_nums":4,"theme":"Entertainment","content":"SC WIETZENBRUCH\nMen\nSpO Kennziffer: 6.10.10. QUALIFIKATIONSRUNDE\n
Start_NrNameVereinJahr.Land90m70m50m30m10'X'Total
120B Mohr, AdolfSG Kirberg1964HS28933333135464261307
218D Rohrbeck, HolgerGoslarer SC 081971NS29332131334345141270
317D Meyer, ArminSV Wieckenberg1962NS25429730933329121193
416C Franke, AndreasVfL Grasdorf1966NS2662832973222361168
516A Marquard, PeterMTV Dannenberg1964NS2652782933262321162
616D Sembdner, HeikoSges Hagenburg-Altenhagen1962NS27527626933023101150
720A Schneider, JürgenTV Jahn Walsrode1962NS2352802893433291147
818C Unruh, KlausSchützenbund Stadthagen1963NS2222932933071841115
918B Lieke, MichaelSarstedter Bogenschützen1971NS2232822663422341113
1017C Kaspereit, JörgPolizei-SV Braunschweig1959NS2422762643201221102
1120D Turzinski, JürgenNDSB1967WF2312782693181251096
1218A Voigt, Frank-PeterSV Schwagstorf1952NW2102682573202171055
1320C Weißflog, StefanBSV Werste1964NW218243247312811020
1419C März, UweSK Ohlendorf1966HH1952452583141741012
1519D Hansen, DirkSV Sarstedt 511963NS187263254287176991
1619A Erler, JensPSV Grün-Weiss Hildesheim1976NS25119323330183978
1716B Tammer, RolandASC Göttingen1964NS8410112620851519
\nWomen\nSpO Kennziffer: 6.10.11. QUALIFIKATIONSRUNDE\nCadet male\n
Start_NrNameVereinJahr.Land70m60m50m30m10'X'Total
1 .5C Kreipe, AndreaSV Watenbüttel1969NS28931630433739131246
2 .4D Disse, AnjaNDSB1967WF2883142943322991228
3 .6B Dumke, DagmarSV Isernhagen1990NS2603032983242371185
4 .4B Lo Manto, YvonneSV 1912 Seeheim1964HS2772892573161961139
5 .5D Koch, KerstinKKS Falkenauge Edemissen1968NS2482612823262391117
6 .5B Warrlich, SimoneSV 1912 Seeheim1982HS2392832462961031064
7 .6A Wildemann, MonikaBlau Weiß Oberbauerschaft1971NS2282562522911041027
8 .4C Sander, RamonaSV Sarstedt 511971NS2312752432521371001
9 .5A Hahne, PetraBSC Wannsee1969BL13320015318562671
\nSpO Kennziffer: 6.10.20\n
Start_NrNameVereinJahr.Land70m60m50m30m10'X'Total
1 . 15BLieke, FabianSarstedter Bogenschützen1996NS3213123103395114
2 . 15CWilke, JonasKKS Falkenauge Edemissen1996NS271254275314165
3 . 15ABertram, MariusKKS Falkenauge Edemissen1994NS216279247300154
4 . 15DMeißner, StevenSV Isernhagen1994NS232250210285164
\nCadet female\nSpO Kennziffer: 6.10.21\n
Start_NrNameVereinJahr.Land60m50m40m30m10'X'Total
1 .2A Kuhnert, VeraSarstedter Bogenschützen1995NS2732882823011761144
2 .2C Jaschik, RonjaBlumenthaler SV1994NW2432672693101561089
3 .2D Schmidt, ViviSarstedter Bogenschützen1997NS16721722223272838
4 .2B Sander, PatriciaSV Sarstedt 511994NS14816212819561633
\nJunior men Start_NrName 1 . 17A Kalweit, Marc\nJunior women Start_NrName 1 . 4A Schwarzkopf, Jule\nMaster Men\n
SpO Kennziffer: 6.10.40.QUALIFIKATIONSRUNDE
VereinJahr.Land90m70m50m30m10'X'Total
SV Wieckenberg1991NS180235258286122959
\n
SpO Kennziffer: 6.10.41. QUALIFIKATIONSRUNDE
VereinJahr.Land70m60m50m30m10'X'Total
SV Sarstedt 511991NS2182582373262771039
\n
Start_NrNameVereinJahr.Land70m60m50m30m10'X'Total
1 . 10DWeller, FranzGoldener Pfeil Herzberg1948NS2693092983232681199
2 . 10AKoch, JoachimKKS Falkenauge Edemissen1959NS2982922773112971178
3 . 14BBatel, JürgenSV WieckenbergNS2972772743262591174
4 . 13BGlashagel, Karl-HeinzNDSB1951WF2852842853202371174
5 . 13ABrill, NorbertSV Wieckenberg1957NS2642982423191641123
6 . 11ALange, JoachimSV WatenbüttelNS24626827932928101122
7 . 11CLissner, MartinSV Wieckenberg1959NS2482792703061941103
8 . 12DKurzmann, WernerTSV Helmstedt1938NS2492812583083271096
9 . 13CGalle, RolandBSG Schweinfurt1951BY2562612593201561096
10 . 12CHein, WolfgangSV Wieckenberg1961NS2842712522872171094
11 . 12ASiefert, ChristianSV Watenbüttel1960NS219231219240103909
12 . 10CKleinert, WinfriedGraue Wölfe1943NW16310711912052509
\n
Master WomenSpO Kennziffer: 6.10.51
Start_NrNameVereinJahr.Land60m50m40m30m10'X' Total
1 .1C Wiechers, GudrunTV Jahn Walsrode1953NS266281272296198 1115
2 .1B Sukop, UteSBr Hornburg1954NS238242235280166 995
Start_NrNameVereinJahr.Land90m70m50m30m10'X'Total
1 . 25ATitscher, PaulSV Querum1986NS34835435336012051
2 . 26ALaube, MarcusSV QuerumNS340351354359110491404
3 . 22CHamdorf, SebastianSV RethwischND33334434935996411385
4 . 27BJanas, FrankSV Loccum1968NS3283473503599644
5 . 23CLeuschner, MarcSV RethwischND33234734235695471377
6 . 24AKnechtel, KaiSV Querum1980NS3253453453609243
7 . 27CRasmussen, HenrikSV RethwischDK33733933735690381369
8 . 27AVoss, Rainer1. BBSBL32734234235887391369
9 . 25CDarum, Andreas1. BBSBL32133933935877321357
10 . 21DGobel, UlrichSBr Hornburg1969NS3173423403578036
11 . 26BOsterfeld, HerbertSV Schwagstorf1961NW3183313323557025
12 . 26CMattes, AndreasBSC Garbsen1959NS3133413323487130
13 . 23ABaden, MatthiasSBr Hornburg1969NS3163283353456722
14 . 24BSchünemann, RalfPolizei-SV Braunschweig1966NS3073313333525918
15 . 21CRichter, HolgerSBr Hornburg1967NS3063293263445723
16 . 21ATiede, PeterBS Gerdau1940NS3003253203485119
17 . 27DBethe, StefanSV Oyten1967NS3023183283395428
18 . 21BEbeling, ThomasSV LoccumNS29532731633761221275
19 . 23BPöschel, MarkoVfL Grasdorf1976NS2913213133364014
20 . 22ADienst FriedbertSV Loccum1963NS2513123273444312
21 . 25BJaschik, UweBlumenthaler SV1962NW271293296321289
\nSeite: 2\n
Compound WomenSpO Kennziffer: 6.15.11.QUALIFIKATIONSRUNDE
Start_NrNameVereinJahr.Land70m60m50m30m10'X'Total
1 . 7D Baden, AndreaSBr Hornburg1970NS32533633134962191341
2 . 7B Rosek, Iris1. BBSBL31633933134967291335
3 . 6D Nischan, AndreaSBr Hornburg1964NS31832631334354161300
4 . 6C Glashagel, KarinNDSB1964WF31432431034754231295
5 . 7C Jaschik, MonikaBlumenthaler SV1964NW29530130631930101221
Compound Cadet maleSpO Kennziffer: 6.15.30
Start_NrNameVereinJahr.Land70m60m50m30m10'X'Total
1 . 14C Stellmach, AnsgarSBr Hornburg1995NS31332530933546141282
Compound Cadet femaleSpO Kennziffer: 6.15.40
Start_NrNameVereinJahr.Land60m50m40m30m10'X'Total
1 . 3B Jaschik, MaditaBlumenthaler SV1994NW29632831634342151283
Compound Junior MenSpO Kennziffer: 6.15.40
Start_NrNameVereinJahr.Land90m70m50m30m10'X'Total
1 . 22B Rosek, Philipp1. BBSBL33734534135892361381
\nSeite: 3\nSC WIETZENBRUCH 39. Heide-Turnier Fita-Sternturnier Sonntag, den 04.09.2011 09.00 Uhr\nSC WIETZENBRUCH 39. Heide-Turnier Fita-Sternturnier Sonntag, den 04.09.2011 09.00 Uhr\n
Compound Master MenSpO Kennziffer: 6.15.50
Start_NrNameVereinJahr.Land70m60m50m30m10'X'Total
1 . 8B Großstück, BerndBSC Garbsen1947NS33333632835273291349
2 . 9D O'Connor, SteveSV RethwischND31833432735164301330
3 . 9C Bünning, CordSV Oyten1957NS32933232034760171328
4 . 8C Schöpke, GunnarBrunswiker Schützengilde1958ND32732631034654231309
5 . 8A Laube, ThorstenBSC Garbsen1953NS31630630433238171258
6 . 13D Sukop, HartmutSBr Hornburg1951NS30530630632443101241
Compound Master WomenSpO Kennziffer: 6.15.51
Start_NrNameVereinJahr.Land60m50m40m30m10'X'Total
1 . 3A Kranz, IngeborgSGI Seehausen1947ST29432229931735131232
\nSC WIETZENBRUCH\n39. Heide-Turnier Fita-Sternturnier Sonntag, den 04.09.2011 09.00 Uhr\nMannschaftsergebnis : Vereinsmannschaft Recurve\n
1. NDSB4DDisse, Anja1228
13BGlashagel, Karl-Heinz1174
20DTurzinski, Jürgen10963498
2. SV Wieckenberg 117DMeyer, Armin1193
14BBatel, Jürgen1174
13ABrill, Norbert11233490
3. KKS Falkenauge Edemissen10AKoch, Joachim1178
15CWilke, Jonas1114
15ABertram, Marius10423334
4. SV Watenbüttel5CKreipe, Andrea1246
11ALange, Joachim1122
12ASiefert, Christian9093277
5. Sarstedter Bogenschützen15BLieke, Fabian1282
2AKuhnert, Vera1144
2DSchmidt, Vivi8383264
6. SV Wieckenberg11CLissner, Martin1103
12CHein, Wolfgang1094
17AKalweit, Marc9593156
7. Sarstedt 514ASchwarzkopf, Jule1039
4CSander, Ramona1001
19DHansen, Dirk9913031
","token":5221,"lang":"en"} +{"sha256":"aed5daae47ab81dcad866f3c181d1901ed8cf57970a4469555e84b4dde5afdca","pdf_pages_nums":4,"theme":"Engineering","content":"
Suitable extinguishing mediaUse extinguishing media most appropriate for the surrounding fire such as water, foam or dry agent (carbon dioxide, dry chemical powder).If safe to do so, move undamaged containers from the fire area If a significant quantity (>200L) of this product is involved in a fire, call the fire brigade.
\n
1. Identification
GHS Product identifierExit
Company NameBlue Lion Supplies Pty. Ltd.
AddressFact. 3, 29 Barry Street, Bayswater, VICTORIA 3153
Telephone(03) 9720 1577
Fax Number(03) 9720 1799
ContactJim Gillman
Recommended use of the chemical and restrictions
one usePrewash stain remover
applicationExit removes stubborn grease and stains in minutes. Apply undiluted Exit to dry clothes only. Spray Exit onto the stained area, wait one minute, and then wash in the usual way.
Other NamesNone
Other InformationEmergency contact: Mobile: 0412 646 246
\n2. Hazard Identification\nNOT classified as hazardous according to the criteria of Safework Australia. NOT a Dangerous Good according to the Australian Dangerous Goods (ADG) Code version 7.\n
GHS classification of the substance/mixtureNone allocated
Signal Word (s)None
Hazard Statement(s)None
Pictogram (s)None
\n3. Composition/information on ingredients\n
NameCAS no.ProportionHazard symbolRisk phrase
\nKEY: Proportion, (wt %) - V HIGH >60, HIGH 30 - 60, MED 10 –29, LOW 1-9, V LOW <1\nThere are no hazardous components at levels to declare in this product.\n
4. First-aid measures
\n
Ingestion:Rinse mouth with water. Never give anything by mouth to an unconscious person. Consult a physician.
Skin:Wash off with soap and plenty of water. Consult a physician.
Eye contactRinse thoroughly with plenty of water for at least 15 minutes and consult a physician.
InhalationIf breathed in, move person into fresh air. If not breathing, give artificial respiration. Consult a physician.
First Aid FacilitiesMaintain eyewash fountain and safety shower in work area.
Advice to DoctorTreat symptomatically. Consult Poisons Information Centre
Other InformationFor advice, contact the National Poisons Information Centre (Phone Australia 13 11 26 and New Zealand 0800 764 766) or a doctor.
\n5. Fire-fighting measures\nSafety Data Sheet\nISSUE DATE: 15/07/2018\nEXIT\nIssued by Blue Lion Supplies Pty. Ltd.\nABN 65 099 391 572\nRevision No. 1\nDO NOT DETACH THIS PAGE FROM THE SDS\n
Occupational exposure limit values
NameSTELTWAFootnote
mg/m3ppmmg/m3ppm
There are no occupational exposure limits set for this product or components in this product.
Appropriate engineering controlsIn industrial situations maintain the concentrations values below the TWA. This may be achieved by process modification, use of local exhaust ventilation, capturing substances at the source, or other methods.
Personal Protective EquipmentFinal choice of personal protective equipment will depend on individual circumstances and/or according to risk assessments undertaken.
Respiratory ProtectionWhere ventilation is not adequate, respiratory protection may be required. Avoid breathing dust, vapours or mists. Respiratory protection should comply with AS 1716 - Respiratory Protective Devices and be selected in accordance with AS 1715 - Selection, Use and Maintenance of Respiratory Protective Devices. Filter capacity and respirator type depends on exposure levels. In event of emergency or planned entry into unknown concentrations a positive pressure, full-face piece SCBA should be used. If respiratory protection is required; institute a complete respiratory protection program including selection, fit testing, training, maintenance and inspection.
Eye ProtectionThe use of a face shield, chemical goggles or safety glasses with side shield protection is appropriate. Must comply with Australian Standards AS 1337 and be selected and used in accordance with AS 1336.
\nISSUE DATE: 15/07/2018\nEXIT\nfrom the chemical No specific hazards. Non-flammable. Non-combustible, however keep containers cool by spraying with water to prevent pressure build up and drums bursting. When subject to high heat may produce water vapour, carbon monoxide, carbon dioxide and other unidentified organic compounds.\nPrecautions in connection Wear SCBA and chemical splash suit. Fully encapsulating, gas-tight suits should be worn for maximum with fire protection.\n6. Accidental release measures\nPersonal Precautions Use personal protective equipment. Avoid skin and eye contact. Ensure adequate ventilation.\nPersonal Protection Wear protective clothing specified for normal operations (see Section 8)\nSmall Spillages Slippery when spilt, clean up immediately. Contain and absorb spills using an inert absorbent material (soil, sand or vermiculite) or mop-up small spills. Pick up with shovel and place in clean, labeled receptacle for disposal. Wash area thoroughly with a detergent solution and rinse well with water. Small spills do not require special clean up measures or emergency procedures. Wear recommended personal protective equipment outlined in Section 8 when containing any spillage. Large Spillages Seek expert advice on handling and disposal\nEnvironmental Precautions Prevent further leakage or spillage if safe to do so. Do not let product enter drains or waterways.\n7. Handling and storage\n
Precautions for Safe
HandlingProduct is safe to handle under normal conditions of use Store tightly closed container in a cool, dry area. Store away from strong oxidising compounds. Not classified under the Transportation of Dangerous Goods Code.Ensure containers are correctly labeled and securely sealed and stowed.
Conditions for safe storageStore in cool place. Keep container tightly closed in a dry and well-ventilated place. Containers which are opened must be carefully resealed and kept upright to prevent leakage.
Incompatible productsNone known.
Incompatible materialsNone known
\n8. Exposure controls/personal protection\nRevision No. 1\nSafety Data Sheet\nIssued by Blue Lion Supplies Pty. Ltd.\nABN 65 099 391 572\nDO NOT DETACH THIS PAGE FROM THE SDS\n
Appearance and OdourClear liquid with mild solvent odour.
Boiling Point (°C)Approx. 88
Vapour PressureNot Available
% Volatile by volume90 - 95
Specific Gravity $0.98g/cm^3$
pH (concentrate)8.0 - 8.5
pH (use in dilution)7.0 - 7.5 (1:80)
Solubility in water :Complete
Flash Point (°C)None, Non-flammable
\n
ISSUE DATE: 15/07/2018EXIT
\n
Hand ProtectionAvoid skin contact when removing gloves from hands, do not touch the gloves outer surface.Hand protection should comply with AS 2161, Occupational protective gloves - Selection, use and maintenance.Recommendation: Nitrile rubber gloves.
FootwearSafety boots in industrial situations is advisory, foot protection should comply with AS 2210, Occupational protective footwear - Guide to selection, care and use.
Body ProtectionClean clothing or protective clothing should be worn, preferably with and apron. Clothing for protection against chemicals should comply with AS 3765 Clothing for Protection Against Hazardous Chemicals.
Hygiene MeasuresDo not eat, drink or smoke in work areas. Wash hands thoroughly after handling this material. Maintain good housekeeping.
\n9. Physical and chemical properties\n10. Stability and reactivity\n
Chemical Stability Conditions to Avoid Incompatible Materials Hazardous Decomposition productsStable under normal conditions of use.Store away from strong oxidising compounds.Strong oxidising agents.Carbon monoxide, carbon dioxide and other unidentified organic compounds.
Possibility of hazardous reactions Hazardous PolymerizationNot determined.Will not occur.
\n11. Toxicological Information\nNo adverse health effects expected if the product is handled in accordance with this Safety Data Sheet and the product label Symptoms that may arise if the product is mishandled are:\n
ACUTE EFFECTS
SwallowedSwallowing can result in irritating mouth, oesophagus and stomach. Headaches and nausea possible.
EyeIn the concentrated form may be an eye irritant. Inflammation of the eye tissue is characterised by redness, watering and/or itching.
SkinIn the concentrated form, prolonged skin contact without rinsing may irritate skin upon contact. Skin inflammation is characterized by itching, scaling or reddening.
InhaledNot normally an inhalation risk under normal conditions. Excessive inhalation may product headaches and nausea. Prolonged exposure effects are not known.
\nCHRONIC EFFECTS No information available for product.\nSafety Data Sheet\nIssued by Blue Lion Supplies Pty. Ltd.\nABN 65 099 391 572\nRevision No. 1\nDO NOT DETACH THIS PAGE FROM THE SDS\n
15. Regulatory information
\n
12. Ecological information
EcotoxicityNo data available.
Persistence and degradabilityNo data available.
\n
13. Disposal considerations
\nDisposal Considerations Small quantities may be flushed down the sink or toilet. Otherwise, place in plastic container prior to disposing through normal commercial refuse system. Refer to State Land Waste Management Authority. Normally suitable for disposal at approved land waste site.\n
14. Transport information
\n
U.N. NumberNone allocated
UN proper shipping nameNone allocated
Transport hazard class(es)None allocated
Hazchem CodeNone allocated
Packing GroupNone allocated
\n
16. Other Information
\n
THIS MSDS SUMMARISES OUR BEST KNOWLEDGE OF THE HEALTH AND SAFETY HAZARD INFORMATION OF THE PRODUCT AND HOW TO SAFELY USE THE PRODUCT IN THE WORKPLACE. EACH USER MUST REVIEW THIS MSDS IN THE CONTEXT OF HOW THE PRODUCT WILL BE HANDLED AND USED IN THE WORKPLACE.IF CLARIFICATION OR FURTHER INFORMATION IS NEEDED TO ENSURE THAT AN APPROPRIATE RISK ASSESSMENT CAN BE MADE, THE USER SHOULD CONTACT THIS COMPANY SO WE CAN ATTEMPT TO OBTAIN ADDITIONAL INFORMATION FROM OUR SUPPLIERS.
\nSafety Data Sheet\nISSUE DATE: 15/07/2018\nEXIT\nIssued by Blue Lion Supplies Pty. Ltd.\nABN 65 099 391 572\nRevision No. 1\nDO NOT DETACH THIS PAGE FROM THE SDS","token":3037,"lang":"en"} +{"sha256":"d50befc0b179aaca79eb25632ed7803b5a385350ac616d86dfab921b777e8738","pdf_pages_nums":31,"theme":"Economics","content":"Bloomberg\n如何使用彭博终端进行市场分析?\n对外经贸大学专场\nZijing ZHOU\nBloomberg\nBloomberg\n- 终端使用简介;\n- 宏观经济、外汇;\n- 股票分析功能入门;\n- Excel插件使用与数据下载\nBloomberg\n彭博中心\nChina ▼ Foreign Exchange\nChina Renminbi Spot FXIP CNY\nWorld Currency Rates WCR\n- 选择国家\n- 关注的资产类别\n- 功能分类 >> 右击选择屏幕开启\nEconomic Data\nChina Economic Statistics CHEC\nWorld Economic Statistics ECST\nEconomic Calendars: Chin... ECO CH - 灵活屏中开启并保存视图\nChina CENB CN\nUnited States CENB US\n▶ Derivatives & Risk Mgmt\n▶ Collaboration Tools\n▶ Help and Learning\nBHUB \nBloomberg\nBloomberg\n熟悉彭博页面\nGP 线图功能\nBloomberg\nBloomberg\n熟悉彭博页面\nHP \n
ExportSettingsPage 1/6Historical Price
International Business Machines CorpHigh150.57 on12/13/22
Range10/31/2022-10/27/2023PeriodDailyLow120.90 on05/11/23
MarketLast PriceP/ECurrencyUSDAverage136.989615.9395
ViewPrice TableNet Chg4.233.06%
DateLast PriceP/EDateLast PriceP/EDateLast PriceP/E
Fr10/27/23142.5215.3321Fr10/06/23142.0315.2794Fr09/15/23145.9916.7037
Th10/26/23143.7615.4655Th10/05/23141.5215.2245Th09/14/23147.3516.8593
We10/25/23137.0814.7469We10/04/23141.0715.1761We09/13/23146.5516.7677
Tu10/24/23137.7914.8233Tu10/03/23140.3915.1030Tu09/12/23146.3016.7391
Mo10/23/23136.3814.6716Mo10/02/23140.8015.1471Mo09/11/23148.3816.9771
Fr10/20/23137.1614.7555Fr09/29/23140.3015.0933Fr09/08/23147.6816.8970
Th10/19/23138.0114.8469Th09/28/23141.5816.1991Th09/07/23147.5216.8787
We10/18/23139.9715.0578We09/27/23143.1716.3810We09/06/23148.0616.9405
Tu10/17/23140.3215.0954Tu09/26/23143.2416.3890Tu09/05/23148.1316.9485
Mo10/16/23139.2114.9760Mo09/25/23146.4816.7597Mo09/04/23
Fr10/13/23138.4614.8953Fr09/22/23146.9116.8089Fr09/01/23147.9416.9268
Th10/12/23141.2415.1944Th09/21/23147.3816.8627Th08/31/23146.8316.7998
We10/11/23143.2315.4085We09/20/23149.8317.1430We08/30/23146.8616.8032
Tu10/10/23142.1115.2880Tu09/19/23146.5216.7643Tu08/29/23146.4516.7563
Mo10/09/23142.2015.2977Mo09/18/23145.0916.6007Mo08/28/23146.0216.7071
\n历史数据表功能\nBloomberg\nBloomberg\n彭博新闻\n08/27/2020 03:14:35 [CBN]\n美联储维持近零利率的时间可能至少长达五年(1)\n记者 Rich Miller\n【彭博】-- 在实施新的货币政策策略后,美联储料将把短期利率至少未来五年维持在接近零的水平。\n新策略最快可能在下个月公布,或令决策者对通货膨胀采取更为宽松的态度,甚至欢迎通胀率适度的暂时性超过2%目标,以弥补过去通胀率低于目标值的情况。\nBack To The Future\nFed to again hold rates near zero for years\n■ Federal Funds Target Rate - Upper Bound - Last Price\n101) How the Fed Is Bringing an Inflation Debate to a Boil: QuickTake \n102) 数十年不遇的大通胀即将来临?经济学家激辩未来价格走势 \n08/26/2020 01:07:01 [CBN]\n数十年不遇的大通胀即将来临?经济学家激辩未来价格走势\n记者 Ben Holland、Enda Curran、Vivien Lou Chen、Kyoungwha Kim\n【彭博】-- 通货膨胀是否正在回升?在经济学领域,几乎没有任何事情比这个问题更加重要,没有任何问题比它存在更尖锐的意见分歧。\n一个阵营坚信,对抗新冠疫情耗资庞大,使发达经济体的物价进入上涨轨道,通胀将达到几十年来未曾见过的水平。另一方阵营则认为,疫情正在加剧过去十二年左右的状况,即通货紧缩而非过热是最大威胁。\n昨天的问题,还是明天的问题?\nOECD经济体通胀率\n08/28/2020 11:24:40 [CBN]\n特朗普火力全开抨击拜登 誓言要重建美国经济(1)\n记者 Mark Niquette、Justin Sink\n【彭博】-- 唐纳德·特朗普週四在共和党代表大会的闭幕演说中接受了该党总统候选人的提名,他宣称,选民不相信乔·拜登能够在疫情大流行中带领国家或愈合美国的种族分裂。\n特朗普说,我衷心地接受竞选美国总统的提名。在新的总统任期期间,我们将再次建立历史上最伟大的经济体,迅速重返充分就业,收入飙升及创纪录的荣景。\n特朗普此前一直努力为第二任期阐明一个议程,他承诺减税,在10个月内创造1000万个就业机会,使美国成为“世界制造业超级大国”并“终结我们对中国的依赖。”\n08/29/2020 01:45:33 [BFW]\n美国将中国交建、中国联通、中国建筑等11家公司加入“军方企业”名单记者 Jacob Gu\n【彭博】-- 美国国防部宣布根据1999财年国防政策法,将11家中国企业加入其认为由中国军方拥有或控制的企业的名单。\n- 以下是新列入名单的企业:\n- 中国交通建设股份有限公司\n- 中国运载火箭技术研究院\n- 中国东方红卫星股份有限公司\n○ 中国联合网络通信集团有限公司\n- 中国电子信息产业集团有限公司\nBloomberg\n彭博新闻\n全球覆盖\n多渠道内容\n78,000 只股票代码\n9,000 全球主题\n1,700 中国主题\n60万\n每天发布和处理新闻报道\n彭博独家\n第三方 内容\n网页自动抓取\n社交媒体\nBloomberg BLUEORCHARD'S MONEY-MAKING STRATEGY\nFIRST WORD\nBloomberg Business\nBloomberg Radio\nBloomberg VIEW\nBloomberg\n
选项活动与聊天室设定彭博行业研究
<检索BI研究与数据>热门检索 Automobiles, China, Real Estate
浏览所有BI专题研究报告聚焦 | 更多 >>
行业1) BI Focus: Expanded Market, Weak Competition Aid TandemInvesting Volume by Partici...
公用事业2) Ratings Trends Not the Bondholders' Friend
医疗保健3) BI Gold Relative Value Framework
工业4) OPEC Set to Ease Cuts, Outlook Remains Bleak
必需消费品5) Tech Supply Chains Spread to India
材料6) China-India Rift Could Snap Oil's Price Rally
科技
能源
通讯建议BI所有地区
媒体Suggested Key Data | 更多 »
互联网媒体7)☆ U.S. Domestic Box Office Overall Gross: Weekly Data Update07/13
出版与广播8)☆ Italy Lending To Households: Monthly Data Update07/09
娱乐内容9)☆ Italy Doubtful Loans: Monthly Data Update07/09
广告营销10)☆ Average 60-Month New-Vehicle Auto Loan Rate: Monthly Data Update07/09
有线和卫星11)☆ U.S. Industry Lease Penetration: Monthly Data Update07/07
电信Suggested Research | 更多 »
电信运营商12) Streaming Brings Back Disney Magic in Dismal Quarter: 3Q Preview20:43
金融13) U.K. Banks' 2Q to Offer Sneak Preview of House-Price Resilience20:39
非必需消费14) Uber, Lyft Face Uphill Legal Drive Against California Labor Law20:25
策略15) BBVA Charges to Come Down, Mexico Momentum May Slow: 2Q Preview20:20
BI产品16) Booking Boosts Liquidity to $13 Billion to Weather Virus Storm19:33
BI覆盖 | 更多 »17) The Daily Mail, Guardian May Win Amid Google's Antitrust Battles19:25
18) BlackBerry's Bid for Facebook, WhatsApp IP Royalties Likely Dead19:15
19) Midyear Outlook: U.S. Auto Manufacturing19:09
20) U.S. Auto $68 Billion Revenue Hit Is Survivable, Not Recoverable19:09
\nBI 彭博行业研究\n点击具体行业后进入行业面板\nBloomberg\nBloomberg\n平衡投资组合,也给该指数造成压力\n- 10年期实际收益率在周五一度跌至-1.066%,后回升到-1.... More\n02:10 华平投资据悉寻求募资25亿美元 设立投资金融行业的新基金\n据一位知情人士透露,华平投资正寻求为第二支投资金融业交易的基金筹资25亿美元。\n这位因事未公开而不愿具名的知情人士称,华平投资已开始与投资者就WP Financial Sector II基金展开初步磋商。该基金将与华平的旗舰基金一样投资支付、金融科技等领域。知情人士称,该公司计划11月正式启动筹资,目标是2021年中完成... More\n02:06 美元全球霸主地位受到欧元挑战 拜登胜选或拖累美元走势\n随着欧元的强势复苏,有关其挑战美元霸主地位的猜测也随之而生。\n得益于欧盟的疫情应对措施,以及美联储低利率趋势,欧元自3月份新冠疫情冲击市场以来累计上涨了约12%。对冲基金目前押注美国大选后欧元将进一步跳升至1.25,这也是高盛预估的目标位。8... More\n01:45 美国将中国交建、中国联通、中国建筑等11家公司加入“军方企业”名单\n美国国防部宣布根据1999财年国防政策法,将11家中国企业加入其认为由中国军方拥有或控制的企业的名单。\n- 以下是新列入名单的企业:\n- 中国交通建设股份有限公司\n- 中国运载火箭技术研究院\n- 中国东方红卫星股份有限公司... More\n01:38 美联储官员Mester:还不到美联储给出新前瞻指引的时候\n克利夫兰联储银行行长Loretta Mester表示,在美国经济从新冠疫情冲击中更坚定地复苏之前,美联储可能不需要就未来利率路径给出额外的前瞻指引。\n- 彭博率先报道\n- 提炼要点,快速阅读\n- 实时追踪市场动向,提供更丰富洞见所有报道 页面 我的检索 翻译\n<缩小检索范围> Q || 暂停\n彭博精选要闻报道 更多 »\n率先报道 FIRS \nLLP在灵活屏中打开。\n选项 ▼ 我的检索\nChina (中文)\n彭博率先报道\n全球宏观新闻\nBloomberg\nBloomberg\n1) Hong Kong Testing Push Begins, College Cases Climb: Virus Update BN 14:19\n2) Winners Taking Everything in Relentless Five-Week Nasdaq Surge BN 04:19\n3) Centricus, Triller Seek to Buy TikTok Assets for \\$20 Billion BN 08:48\n4) Apple Ends Epic's Developer Account After Fortnite Fee Clash BN 08:48\n5) Elon Musk Unveils Neuralink Brain Computer Implanted in Pigs\n6) Top Analyst Bets Big on Small Singapore Stocks On Vaccine Hopes\n7) Steven A. Cohen Enters Exclusive Talks for New York Mets\n8) Jokowi's Economic Reboot Stumbles on Factories Shut by Outbreaks\n9) Commodities at Highest Since 2018 Face Crude-to-Crops Checkup\n10) World's Richest People Smashed Wealth Records This Week\n11) Google, Facebook Dump Hong Kong Cable After U.S. Security Alarm\n12) Vietnam Exports Rise 2.5% in Aug.; Inflation Slows to 3.18%\n13) Women Fund Managers Beating Men in Picking Stocks, Goldman Finds\n14) Trump Offers Praise for Abe as Leaders Plan Call for Next Week\n15) Wuhan Beat the Virus. Now It's Moving on by Shutting Out the World.\n16) Messi Transfer Is a Big Opportunity for Barcelona: Alex Webb \n17) Abe Defied Expectations to Build a Better Japan: Noah Smith \n18) Boris and Covid Could Be the End of the U.K.: Therese Raphael \n19) Europe Activity Dips; Fed Inflation Revamp: Eco Best of Week \n20) ING's Potential 25% EPS Lift Boosts Dividend Hopes: Intelligence \n21) Long Sterling Volatility Is a Way for Beaten Bears: Intelligence \n向下滚动:■交易 ■金融 ■经济与政府 ■科技 ■体育\n市场要闻,由资深编辑深度筛选\n率先报道 | 更多 » Madison Square Garden to Serve as NYC's Biggest Polling Site [07:18]\nAmazon's Green Effort,\nIntelsat Seeks Gogo Unit: N.A.\nTMT Wrap [05:42]\nSOFTS: Cocoa Caps Biggest Weekly Gain in Two Years; Coffee Jumps [05:40]\nExplorers Park Rigs, Kinder Dodges Injunction: N.A. Energy Wrap [05:12]\n彭博要闻 TOP\n要闻\nBloomberg\nBloomberg\nQUIC QUICK TAKE\n
新检索选项我的检索翻译来源所有日期我的语言时间
Bloomberg QuickTake
最热门要闻 | 更多 >>
1) When Athletes Walked, Was It a Boycott or a Strike?: QuickTake08/28
2) How the Shock Therapy of 'Abenomics' Worked in Japan: QuickTake08/28
3) How Trump 阶位系 选项: 我的拉索 谢库? about a Socialist: QuickTake08/28
按时间排序新 Bloomberg QuickTakeZH-CN
4) When Athlete 最热门要闻 | 更多 >>
5) How Trump FCBN08/28
6) How the ShoCBN08/24
按时间排序新闻
7) How U.S. DrCBN08/28
8) Mapping theCBN08/24
9) How the FedCBN08/19
10) TikTok, 香港CBN08/11
11) Why a GendeCBN08/08
12) TikTok, HongCBN08/07
13) How Do PeoCBN07/24
14) Why 'DeFi' UCBN07/20
15) Why PresideCBN07/15
16) What Lies BeCBN07/08
17) The Keys toCBN06/19
18) Why a HistorCBN06/18
19) Why EU's BaCBN06/17
10) 为什么中国和印度军队再次发生冲突: QuickTakeCBN06/11
11) 温暖的天气会抑制冠状病毒吗? 听听专家怎么说: QuickTakeCBN05/28
12) 我们寻找冠状病毒药物和疫苗的征途行进到哪儿了? : QuickTakeCBN05/28
13) 香港失去“特殊地位”有何意味: QuickTake (2)CBN05/28
14) 为什么中国负债累累的公司面临现实考验: QuicktakeCBN05/21
\n彭博百科\n- 一页报道快速了解市场热门\n- 彭博资深编辑解析市场最新话题\nBloomberg\nBloomberg\n彭博研究-中文特别版 NI CHBI\n
Top Ranked News | More »
1)中国信贷飙升对全球油价而言或为负面征兆BI08/28
2)里海、东地中海紧张局势加剧,天然气供应受到威胁BI08/28
3)美国LNG出口将受益于欧洲推动供气多元化BI08/28
Time Ordered News
4)中国信贷飙升对全球油价而言或为负面征兆BI08/28
5)里海、东地中海紧张局势加剧,天然气供应受到威胁BI08/28
6)美国LNG出口将受益于欧洲推动供气多元化BI08/28
7)美国制裁下,俄罗斯至欧洲的天然气管道项目完工或推迟至2021年以后BI08/27
8)若“苹果税”降低,内容提供商有望获得80亿美元的意外之财BI08/27
9)零售交易量激增,数据证实投资者、经纪商受益BI08/26
10)美国航空业收入下滑不止,因休闲需求复苏不足BI08/26
11)抛开战略不谈,甲骨文开张支票就可收购TikTokBI08/25
12)押注香港联系汇率制度崩盘将面临多重挑战BI08/25
13)第2季度订单流付款价格各异,股票下跌,期权上升BI08/24
14)投资者价格改善远超给经纪商的订单流付款BI08/24
15)英镑强劲—不,是疲软!—令富时指数盈利陷入混乱BI08/21
16)5G扩张可能缓慢,因4G仍将沿用数年BI08/21
17)订单流付款激增26%,但不敌价格改善BI08/20
18)7月消费复苏放缓,因新冠病例增多、刺激措施减少BI08/20
19)中国药品分销商借助并购、国家改革缓解疫情冲击BI08/20
\n- 彭博独家研究 深度解析市场\nBloomberg\nBloomberg\nChina ▼ Foreign Exchange\n- 选择国家\n- 关注的资产类别\n- 功能分类 >> 右击选择屏幕开启\nEconomic Data\nChina Economic Statistics CHEC\nWorld Economic Statistics ECST\nEconomic Calendars: Chin... ECO CH - 灵活屏中开启并保存视图\nChina CENB CN\n▶ Derivatives & Risk Mgmt\n▶ Collaboration Tools\n▶ Help and Learning\n彭博中心\nBHUB \nBloomberg\nBloomberg\n1. 宏观经济功能总览 - 宏观经济与央行政策\n查找...\n央行门户页面 - CENB\n美联储活动 - FED/FOMC\n央行政策\n分析...\n央行利率走向 - TAYL\n全球利率概率 - WIRP\n隐含政策利率 - MIPR\nBloomberg\nBloomberg\n1. 宏观经济功能总览\n- 彭博资源中心 BPS - 新闻与经济 - 经济数据与洞悉 - 经济数据功能表\n< > | Net Change SA Index ▼ | BPS ▼ | Related Functions Menu ▼\nMessage ★▼ □▼ ?▼\n检索内容\n<资源中心主页\n语言 ▼ 选项 ▼\n资源中心主页\n新闻与经济\n经济数据与洞悉\n经济数据手册(ENG)\n经济数据功能表(ENG)\n联储观察功能表(ENG)\nCOVID-19市场影响测量功能...\n跨境流量简介表(ENG)\n报告制作简介表(ENG)\n经济工作台视频教程(ENG)\nECON AND GLOBAL MACRO KEY FUNCTIONALITY\nThe list below reflects key functionality for an area of interest on the Bloomberg Professional® service. Press the key on the Bloomberg keyboard from any function to browse related tools. Press the key once from a function to learn more about what it does and how to use it.\n
> BASIC ECON AND GLOBAL DATA> MACRO FOREIGN EXCHANGE
ECONOMIC FORECASTSECFC**FX INFORMATION PORTALFXIP**
ECO AND FINANCIAL MKT CALENDARSECO**FX RATE AND VOLATILITY RANKINGWCRS**
ECONOMIC STATISTICSECST**FX FORECASTSFXFC**
ECONOMIC STATISTICS SEARCHECST S**FX SCENARIO ANALYSISFXFM**
ECONOMIC MATRIXECMX**FX MARKET DASHBOARDXDSH**
ECONOMIC SURPRISESECSU**VIRTUAL CURRENCY DASHBOARDVCCY**
GLOBAL INFLATION MONITORSIFMO**FX SPOT AND VOLATILITY COMPARISONVOLC**
ECONOMIC ESTIMATESECOS**> CAPITAL FLOWS AND POSITIONING
ECONOMIC WORKBENCHECWB**GLOBAL MERGERS AND ACQUISITIONSMA**
REGRESSION ANALYSISHRA**GLOBAL FOREIGN DIRECT INVESTMENTFLMA**
> CENTRAL BANK ANALYSISGLOBAL PORTFOLIO FLOWSFOFP**
CENTRAL BANK DASHBOARDCENB**FOREIGN OWNERSHIP OF USTDEBT**
FED FORWARD GUIDANCE DASHBOARDECST C**ETF FLOWSFFLO**
FED STATEMENT AND MINUTESFOMS**FOREIGN PORTFOLIO FLOWSWFII**
BLOOMBERG GUIDE TO FED VIEWSFOMC**GLOBAL TRADE FLOWSECTR**
ANALYST RATE FORECASTSBYFC**OFFICIAL RESERVESBI ECON
\nBPS \nBloomberg\nBloomberg\n2. 如何查找经济数据以及使用相关功能分析\n- 全球经济数据矩阵 - 对比不同国家数据\n< > | Net Change SA Index ▼ | ECMX ▼ | Related Functions Menu ▼\nUse the left mouse button to click and drag an area of the desktop.\n3) << 4) < 06/20/20 □ 5) 今天 6) > 7) >>\n1) 矩阵 2) 全球地图\n全球经济数据矩阵\n- 点击“+”自设视图\n- 选择数据类型和国家\n- 点击铅笔修改当前视图\n
国家实际GDP年同比消费价格指数年同比失业(千)央行利率
数值日期数值日期数值日期数值日期
澳大利亚1.4%03/202.2%03/20927.605/200.2%06/20
比利时-2.5%03/200.5%05/20287.004/200.0%06/20
加拿大-5.8%03/20-0.4%05/202619.205/200.2%06/20
中国-6.8%03/202.4%05/204.3%06/20
德国-2.3%03/200.6%05/201569.004/200.0%06/20
法国-5.0%03/200.4%05/202366.006/190.0%06/20
英国-1.6%03/200.5%05/201336.004/200.1%06/20
意大利-5.4%03/20-0.2%05/201543.004/200.0%06/20
日本-1.7%03/200.1%05/20-0.1%06/20
荷兰-0.5%03/201.2%05/20330.005/200.0%06/20
瑞典0.4%03/200.0%05/20497.005/200.0%04/20
新加坡-0.7%03/20-0.7%04/2082.703/20
美国0.2%03/200.1%05/2020985.005/200.2%06/20
\nECMX \nBloomberg\nBloomberg\n2. 如何查找经济数据以及使用相关功能分析 - 对比不同国家数据 – 全球经济数据矩阵\n自设视图\n选择数据和国家列表\n点击更新\nECMX \nBloomberg\nBloomberg\n2. 如何查找经济数据以及使用相关功能分析\n- 经济数据发布-经济日历\n< > | Net Change SA Index ▼ | ECO ▼ | Related Functions Menu ▼\n
日期时间AMR事件期间调查(M)实际上次修正
MM/DD/YY
42)06/04 20:30Revisions: U.S. International Trade
43)06/04 20:30贸易余额Apr-$49.4b-$49.4b-$44.4b-$42.3b
44)06/04 20:30非农生产力1Q F-2.7%-0.9%-2.5%--
45)06/04 20:30单位劳务成本1Q F5.0%5.1%4.8%--
46)06/04 21:45彭博消费者舒适度May 3--37.035.5--
47)06/05 20:30非农就业人数增减May-7500k2509k-20537-20687
48)06/05 20:30两个月薪水净修正May---642k----
49)06/05 20:30非农私企就业人数增减May-6750k3094k-19557-19724
50)06/05 20:30制造业就业人数增减May-400k225k-1330k-1324k
51)06/05 20:30失业率May19.0%13.3%14.7%--
52)06/05 20:30周平均工时-所有员工May34.334.734.2--
53)06/05 20:30平均小时收入月环比May1.0%-1.0%4.7%4.7%
\n彭博经济新闻与分析 | BE >>\n11) Kashkari Says Second Virus Wave Will Boost Unemployment Again\n12) CANADA BONDWRAP: BoC Takes Back Seat to Overseas Buyers, Fed\n13) Fed's Rosengren Expects Main Street Program to Build Over Time BN 01:17\n14) Roaring U.S. Auto Sales Spur JPMorgan to Raise Price Targets\n15) [Delayed] Building Materials: US Starts and Permits: May Single-family Starts & P... JPM 07:01\nECO \nBloomberg\nBloomberg\n2. 如何查找经济数据以及使用相关功能分析\n- 经济数据发布-经济日历\n设定提醒\n美国 - 非农就业人数增减\n对于非农就业人数增减,您希望什么时候收到提醒?\n公布时\n当差额大于或等于\n公布前\n订阅该指标的彭博经济分析。\n+/- 2.00 标准差\n1分钟\n修改列\n显示列\n发布日期与时间\n音频提醒图标\n提醒管理图标\n3) 添加 →\n相关度水平\n5) 上移↑\n公报/事件名称\n4)移除←\n观测期间\n6) 下移 ↓\n经济学人调查中值\n实际公报\n前次公报\n修正\n显示日期/时间\n- 当地时区,GMT+0800 (CST)\n事件时区\n应用于 类型: 经济公报\n2) 恢复预设\n1) 保存\nECO \n关闭\nBloomberg\nBloomberg\n2. 如何查找经济数据以及使用相关功能分析 - 经济数据发布 – 经济日历 – 经济公报详情\n3) 当前新闻 | 更多 >>\n6) U.S. May Nonfarm Payrolls By State and Industry (Table)\n7) U.S. May Labor Force Comparison Statistics (Table)\n8) SURVEY: U.S. Economy to Contract 35% in 2Q20; Prior -32.7%\nECOD \nBloomberg\nBloomberg\n2. 如何查找经济数据以及使用相关功能分析 - 经济数据发布 – 经济日历 – 经济学家预测 – 经济指数简介\n< > | Net Change SA Index ▼ | ECOC ▼ | Related Functions Menu ▼\nMessage\nUse the left mouse button to click and drag an area of the desktop.\nNFP TCH Index\n系列 📄 美国非农就业人口薪资总计月环比净变动经季调 »\n ⓘ (经季调, 净月度变动)\n国家/地区 美国 » 来源 劳工统计局 »\n经济指数简介\n修正版-最新值\n实际值-前值\n'00 | '01 | '02 | '03 | '04 | '05 | '06 | '07 | '08 | '09 | '10 | '11 | '12 | '13 | '14 | '15 | '16 | '17 | '18 | '19 | '20\nECOC \nBloomberg\nBloomberg\n2. 如何查找经济数据以及使用相关功能分析\n• 经济预测\n
指标2013201420152016201720182019202020212022
经济活动
实际GDP(同比%)7.87.47.06.86.96.76.11.88.05.5
固定资产投资(同比%)5.22.06.55.1
工业产值(同比%)9.78.26.06.26.66.15.51.57.75.1
零售销售额(同比%)8.00.110.07.4
价格指数
CPI(同比%)2.62.01.42.01.62.12.93.02.22.2
生产者价格指数(同比%)-0.3-2.01.01.1
劳动力市场
失业率(%)4.14.14.14.03.93.83.64.44.24.0
国际收支平衡
经常账户(占GDP%)1.52.32.81.81.60.21.20.60.70.4
\n摘要表e.g.\n1年期LPR:\n52) SURVEY REPORT: Hong Kong Economic Forecasts in Q2 2020\n53) 彭博中國經濟預測調查:摘要表\n利率预测\n54) √彭博中国经济预测调查:摘要表\n55) SURVEY REPORT: China Economic Forecasts in May 2020\n白色数字: 预测值\n点击查看资讯提供者的预测\n对应代码\n加载查看\n历史预测\nBN 06/09\nHBN 05/28\nCBN 05/28\nBN 05/28\nECFC \nBloomberg\nBloomberg\n2. 如何查找经济数据以及使用相关功能分析\n- 中国经济统计数据\n< > | Adjustment Announcements Index ▼ | CHEC ▼ | Related Functions Menu ▼\nUse the left mouse button to click and drag an area of the desktop.\n中国经济统计数据\n
隐藏分类国民经济核算>概览
国民经济核算指标最新数值日期转换频率规模货币来源
概览
实际GDP(支出法)11)中国以不变价格计算GDP...-6.82020/03/31同比%未...季度中国国...
名义GDP(支出法)12)中国国内生产总值(不变...6.12019/12/31同比%年...年度中国国...
名义GDP(收入法)13)中国现行价格计算GDP20650.432020/03/31数值未经...季度十亿人...中国国...
实际GDP(按行业)14)中国以现行价格计算年GD...7.82019/12/31同比%年...年度中国国...
名义GDP(按行业)15)中国以不变价格计算第一...-3.22020/03/31同比%未...季度中国国...
实际国民生产总值指数GNP16)中国以不变价格计算第二...-9.62020/03/31同比%未...季度中国国...
实际GDP指数(1978=100)17)中国以不变价格计算第三...-5.22020/03/31同比%未...季度中国国...
名义国民生产总值指数GNP18)中国现行价格计算第一产...1018.622020/03/31数值未经...季度十亿人...中国国...
价格19)中国现行价格计算第二产...7363.82020/03/31数值未经...季度十亿人...中国国...
工业20)中国现行价格计算第三产...12268.012020/03/31数值未经...季度十亿人...中国国...
财政21)北京生产总值不变价累计...-6.62020/03/31年至今同...季度北京统...
金融22)北京现价生产累计总值7462.22020/03/31年至今数...季度亿人...北京统...
经济指标23)天津市生产总值累计增速-9.52020/03/31年至今同...季度天津统...
贸易和对外经济24)天津市生产总值累计2874.352020/03/31年至今数...季度亿人...天津统...
外汇25)河北不变价生产总值同比-6.22020/03/31年至今同...季度河北省...
固定资产投资和房地产26)河北现价生产总值7410.12020/03/31年至今数...季度亿人...河北省...
批发和零售业27)山西不变价生产总值累计...-4.62020/03/31年至今同...季度山西统...
就业人员和工资28)山西现价累计生产总值3634.732020/03/31年至今数...季度亿人...山西统...
人口29)吉林省生产总值同比-6.62020/03/31年至今同...季度吉林统...
农业30)吉林省生产总值2441.842020/03/31年至今数...季度亿人...吉林统...
物流运输31)黑龙江省生产总值累计增速-8.32020/03/31年至今同...季度黑龙江...
旅游32)黑龙江省生产总值累计2409.02020/03/31年至今数...季度亿人...黑龙江...
通讯33)上海市生产总值累计增速-6.72020/03/31年至今同...季度上海统...
\nCHEC \nBloomberg\nBloomberg\n2.如何查找宏观经济数据以及功能介绍\n- 经济数据分析-添加转换\n< > | China GDP Forecast Index ▼ | ECWB ▼ | Related Functions Menu ▼\n修改图表显示\nUS Employees on Nonfarm Payrolls Tot.\nNFP TCH Index\n(千)\n净月变动经季调\n添加转换\n添加转换 \ne.g. 滞后 \n对比去年同期与今年同比/合并/移动等使用修正值(若已有)\n
DateNFP TCH
<净月变动经季调> LAGm
06/30/2020182.0
05/31/202085.0
04/30/2020210.0
03/31/2020147.0
02/29/20201.0
\nECWB \nBloomberg\nBloomberg\n3.如何查找央行政策信息以及使用相关功能分析\n- 全球利率概率\n< > | Adjustment Announcements Index ▼ | WIRP ▼ | Related Functions Menu ▼\nUse the left mouse button to click and drag an area of the desktop.\n地区:美国 » 目标利率 有效利率\nHELP WIRP\n\n具体计算方式参考\n0.25\n0.09\n市场认为加息/降息次数工具:联邦基金期货 »\n定价日期\n当前隐含隔夜利率\n
AU - Fut07/07/2020-58.1%▼
NZ - OIS06/24/2020+0.0%▲
JP - OIS07/15/2020-13.3%▼
IN - OIS08/06/2020-105.6%▼
\n隐含隔夜利率及加息/降息次数\n
会议#加息/降息%加息/降息隐含利率Δ隐含利率假定利率变动
07/29/2020-0.043-4.3%-0.0110.0730.250
09/16/2020-0.084-4.1%-0.0210.0620.250
11/05/2020-0.131-4.7%-0.0330.0510.250
12/16/2020-0.147-1.7%-0.0370.0460.250
01/27/2021-0.183-3.5%-0.0460.0380.250
\n- 隐含政策利率(%)\n- 已定价加息/降息次数-\n全球利率概率\n06/19/2020\n0.083自设默认隐含利率和假定利率变动\n鼠标放上每列标题即可看到对应计算公式\n当前 07/29/2020 09/16/2020 11/05/2020 12/16/2020 01/27/2021\nWIRP \nBloomberg\nBloomberg\n公司新闻-CN\n
Search News Actions Custom Searches TranslatePage 1 News Feed
Tesla Inc BBG Only All Dates My Lang Time
AI News Importance Lower Higher
Top Ranked News MoreTop NewsBackground & Opinion
1) Social Media Buzz: Japan PM Successors, New Zealand, Tesla HackBN 08/28
2) Musk Confirms Tesla Factory Was Target of Thwarted CyberattackBN 08/28
3) Late Tesla Challenge Won't Scratch U.S. Autos From Profit RaceBI 08/28
Time Ordered News
4) Stocks and Bonds Climb, Powell Put Alive and Well: Macro SquawkBFW 04:00
5) Late Tesla Challenge Won't Scratch U.S. Autos From Profit RaceBI 08/28
6) Social Media Buzz: Japan PM Successors, New Zealand, Tesla HackBN 08/28
7) Musk Confirms Tesla Factory Was Target of Thwarted CyberattackBN 08/28
8) *CORRECT: MUSK SAYS PLANNED CYBERATTACK ON PLANT WAS 'SERIOUS'BN 08/28
9) Elon Musk Says Planned Cyberattack on Gigafactory Was 'Serious'BFW 08/28
10) Fed's Inflation Shift Lifts Stocks, Steepens Curve: Macro SquawkBFW 08/28
11) Tesla Implied Volatility Reaches 77th PercentileBN 08/28
12) Tesla Zooms Past Visa as Shares Rise Unchecked: ChartBN 08/27
13) Electric-Truck Maker Rivian Hires Ex-Tesla Exec as Top EngineerBN 08/27
14) Stocks Extend Gains, Durable Goods Double Estimate: Macro SquawkBFW 08/27
15) Tesla Touches $400 Billion After Latest Boost From Street (1)BN 08/27
16) Tesla Social Media Volume Surges; Sentiment Somewhat PositiveBN 08/26
17) Tesla's Valuation Has Logic on 'Phenomenal' Growth: JefferiesBFW 08/26
18) Tesla PT Raised to $2,500 from $1,200 at JefferiesBFW 08/26
\n精准查找新闻\n- 来源设置\n- 相关度设置\nBloomberg\nBloomberg\nExcel插件使用与数据下载\n- {HELP DAPI} 彭博公式使用指南; - {FLDS} 查找数据栏目; - {SMNR} 注册研讨会、查看研讨会录像回放\nBloomberg\n谢谢","token":10801,"lang":"zh"} diff --git a/skills/dingo-saas/scripts/connection_actions.mjs b/skills/dingo-saas/scripts/connection_actions.mjs new file mode 100644 index 00000000..dd055f38 --- /dev/null +++ b/skills/dingo-saas/scripts/connection_actions.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +import { + clearConnection, + configureConnection, + getConnectionStatus, + parseArgs, + writeError, + writeResult, +} from "./lib/dingo_client.mjs"; + +const ACTIONS = { + configure: "--url URL --key KEY", + connection_status: "", + clear_config: "", +}; + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const action = args._[0]; + + if (!action || action === "actions" || action === "help") { + writeResult( + Object.entries(ACTIONS).map(([name, options]) => ({ action: name, options })), + ); + return; + } + + switch (action) { + case "configure": + writeResult(await configureConnection(args)); + return; + case "connection_status": + writeResult(await getConnectionStatus()); + return; + case "clear_config": + writeResult(await clearConnection()); + return; + default: + throw new Error(`Unknown action: ${action}. Run with 'actions' to list supported actions.`); + } +} + +main().catch(writeError); diff --git a/skills/dingo-saas/scripts/dataset_actions.mjs b/skills/dingo-saas/scripts/dataset_actions.mjs new file mode 100644 index 00000000..85ad48cd --- /dev/null +++ b/skills/dingo-saas/scripts/dataset_actions.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node + +import { openAsBlob } from "node:fs"; +import { basename, resolve } from "node:path"; + +import { + callApi, + parseArgs, + parseIntegerOption, + readJsonBody, + requireOption, + writeError, + writeResult, +} from "./lib/dingo_client.mjs"; + +const ACTIONS = { + list_datasets: { + method: "GET", + path: "/api/v1/datasets/", + options: "[--source SOURCE] [--skip N] [--limit N]", + }, + get_dataset: { + method: "GET", + path: "/api/v1/datasets/{dataset_id}", + options: "--dataset-id ID", + }, + preview_dataset: { + method: "GET", + path: "/api/v1/datasets/{dataset_id}/preview", + options: "--dataset-id ID", + }, + initialize_demo_data: { + method: "POST", + path: "/api/v1/demo/initialize", + options: "", + }, + upload_dataset_file: { + method: "POST", + path: "/api/v1/datasets/upload", + options: "--file PATH [--relative-path PATH]", + }, + create_dataset: { + method: "POST", + path: "/api/v1/datasets/", + options: "--body JSON | --body-file PATH | --stdin", + }, + update_dataset: { + method: "PUT", + path: "/api/v1/datasets/{dataset_id}", + options: "--dataset-id ID (--body JSON | --body-file PATH | --stdin)", + }, + copy_dataset: { + method: "POST", + path: "/api/v1/datasets/{dataset_id}/copy", + options: "--dataset-id ID", + }, + delete_dataset: { + method: "DELETE", + path: "/api/v1/datasets/{dataset_id}", + options: "--dataset-id ID [--hard-delete]", + }, +}; + +function showActions() { + const rows = Object.entries(ACTIONS).map(([name, definition]) => ({ + action: name, + method: definition.method, + path: definition.path, + options: definition.options, + })); + writeResult(rows); +} + +async function execute(action, args) { + switch (action) { + case "list_datasets": + return callApi("GET", ACTIONS[action].path, { + query: { + source: args.source, + skip: parseIntegerOption(args, "skip", 0), + limit: parseIntegerOption(args, "limit", 100), + }, + }); + case "get_dataset": + case "preview_dataset": + case "copy_dataset": { + const datasetId = encodeURIComponent(requireOption(args, "dataset-id")); + return callApi(ACTIONS[action].method, ACTIONS[action].path.replace("{dataset_id}", datasetId)); + } + case "initialize_demo_data": + return callApi("POST", ACTIONS[action].path); + case "upload_dataset_file": { + const filePath = resolve(requireOption(args, "file")); + const form = new FormData(); + form.append("file", await openAsBlob(filePath), basename(filePath)); + form.append("relative_path", String(args["relative-path"] || basename(filePath))); + return callApi("POST", ACTIONS[action].path, { form }); + } + case "create_dataset": + return callApi("POST", ACTIONS[action].path, { json: await readJsonBody(args) }); + case "update_dataset": { + const datasetId = encodeURIComponent(requireOption(args, "dataset-id")); + return callApi("PUT", ACTIONS[action].path.replace("{dataset_id}", datasetId), { + json: await readJsonBody(args), + }); + } + case "delete_dataset": { + const datasetId = encodeURIComponent(requireOption(args, "dataset-id")); + return callApi("DELETE", ACTIONS[action].path.replace("{dataset_id}", datasetId), { + query: { hard_delete: args["hard-delete"] === true }, + }); + } + default: + throw new Error(`Unknown action: ${action}. Run with 'actions' to list supported actions.`); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const action = args._[0]; + if (!action || action === "actions" || action === "--help" || action === "help") { + showActions(); + return; + } + + const result = await execute(action, args); + writeResult(result); +} + +main().catch(writeError); diff --git a/skills/dingo-saas/scripts/dataset_actions.test.mjs b/skills/dingo-saas/scripts/dataset_actions.test.mjs new file mode 100644 index 00000000..39c4ab4a --- /dev/null +++ b/skills/dingo-saas/scripts/dataset_actions.test.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import test from "node:test"; + +const execFileAsync = promisify(execFile); +const scriptPath = join(dirname(fileURLToPath(import.meta.url)), "dataset_actions.mjs"); +const connectionScriptPath = join(dirname(fileURLToPath(import.meta.url)), "connection_actions.mjs"); + +async function withMockApi(run) { + const requests = []; + const directory = await mkdtemp(join(tmpdir(), "dingo-saas-config-test-")); + const configPath = join(directory, "config.json"); + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + requests.push({ + method: request.method, + url: request.url, + authorization: request.headers.authorization, + contentType: request.headers["content-type"], + body: Buffer.concat(chunks).toString("utf8"), + }); + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + const env = { + ...process.env, + DINGO_SAAS_CONFIG_PATH: configPath, + }; + + try { + await run({ requests, env, configPath, url: `http://127.0.0.1:${port}` }); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await rm(directory, { recursive: true, force: true }); + } +} + +async function runAction(env, ...args) { + const { stdout } = await execFileAsync(process.execPath, [scriptPath, ...args], { env }); + return JSON.parse(stdout); +} + +async function runConnectionAction(env, ...args) { + const { stdout } = await execFileAsync(process.execPath, [connectionScriptPath, ...args], { env }); + return JSON.parse(stdout); +} + +test("maps dataset actions to their backend APIs", async () => { + await withMockApi(async ({ requests, env, configPath, url }) => { + const configured = await runConnectionAction( + env, + "configure", + "--url", + url, + "--key", + "sk-test-token", + ); + assert.deepEqual(configured, { + configured: true, + url, + key_prefix: "sk-test-to...", + }); + assert.equal(JSON.parse(await readFile(configPath, "utf8")).key, "sk-test-token"); + + await runAction(env, "list_datasets", "--source", "sql", "--skip", "2", "--limit", "5"); + await runAction(env, "get_dataset", "--dataset-id", "data/id"); + await runAction(env, "preview_dataset", "--dataset-id", "abc"); + await runAction(env, "initialize_demo_data"); + await runAction(env, "create_dataset", "--body", '{"name":"New","source":"local","format":"jsonl"}'); + await runAction(env, "update_dataset", "--dataset-id", "abc", "--body", '{"name":"Renamed"}'); + await runAction(env, "copy_dataset", "--dataset-id", "abc"); + await runAction(env, "delete_dataset", "--dataset-id", "abc", "--hard-delete"); + + assert.deepEqual( + requests.map(({ method, url }) => ({ method, url })), + [ + { method: "GET", url: "/api/v1/datasets/?source=sql&skip=2&limit=5" }, + { method: "GET", url: "/api/v1/datasets/data%2Fid" }, + { method: "GET", url: "/api/v1/datasets/abc/preview" }, + { method: "POST", url: "/api/v1/demo/initialize" }, + { method: "POST", url: "/api/v1/datasets/" }, + { method: "PUT", url: "/api/v1/datasets/abc" }, + { method: "POST", url: "/api/v1/datasets/abc/copy" }, + { method: "DELETE", url: "/api/v1/datasets/abc?hard_delete=true" }, + ], + ); + assert.ok(requests.every((request) => request.authorization === "Bearer sk-test-token")); + assert.equal(JSON.parse(requests[4].body).name, "New"); + assert.equal(JSON.parse(requests[5].body).name, "Renamed"); + }); +}); + +test("uploads a local file as multipart form data", async () => { + const directory = await mkdtemp(join(tmpdir(), "dingo-saas-test-")); + const filePath = join(directory, "sample.jsonl"); + await writeFile(filePath, '{"text":"hello"}\n'); + + try { + await withMockApi(async ({ requests, env, url }) => { + await runConnectionAction(env, "configure", "--url", url, "--key", "sk-test-token"); + await runAction( + env, + "upload_dataset_file", + "--file", + filePath, + "--relative-path", + "folder/sample.jsonl", + ); + + assert.equal(requests[0].method, "POST"); + assert.equal(requests[0].url, "/api/v1/datasets/upload"); + assert.match(requests[0].contentType, /^multipart\/form-data; boundary=/); + assert.match(requests[0].body, /sample\.jsonl/); + assert.match(requests[0].body, /folder\/sample\.jsonl/); + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("reports, verifies, and clears the saved connection", async () => { + await withMockApi(async ({ requests, env, url }) => { + assert.deepEqual(await runConnectionAction(env, "connection_status"), { configured: false }); + + await runConnectionAction(env, "configure", "--url", `${url}/ignored/path`, "--key", "sk-secret-key"); + const status = await runConnectionAction(env, "connection_status"); + assert.deepEqual(status, { + configured: true, + url, + key_prefix: "sk-secret-...", + user: { ok: true }, + }); + assert.equal(requests[0].url, "/api/v1/auth/me"); + assert.equal(requests[0].authorization, "Bearer sk-secret-key"); + + assert.deepEqual(await runConnectionAction(env, "clear_config"), { configured: false }); + assert.deepEqual(await runConnectionAction(env, "connection_status"), { configured: false }); + }); +}); diff --git a/skills/dingo-saas/scripts/experiment_actions.mjs b/skills/dingo-saas/scripts/experiment_actions.mjs new file mode 100644 index 00000000..b3821b5e --- /dev/null +++ b/skills/dingo-saas/scripts/experiment_actions.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +import { + callApi, + parseArgs, + parseIntegerOption, + readJsonBody, + requireOption, + writeError, + writeResult, +} from "./lib/dingo_client.mjs"; + +const ACTIONS = { + list_experiments: { + method: "GET", + path: "/api/v1/experiments/", + options: "[--dataset-id ID] [--skip N] [--limit N]", + }, + get_experiment: { + method: "GET", + path: "/api/v1/experiments/{experiment_id}", + options: "--experiment-id ID", + }, + create_experiment: { + method: "POST", + path: "/api/v1/experiments/", + options: "--body JSON | --body-file PATH | --stdin", + }, + update_experiment: { + method: "PUT", + path: "/api/v1/experiments/{experiment_id}", + options: "--experiment-id ID (--body JSON | --body-file PATH | --stdin)", + }, + copy_experiment: { + method: "POST", + path: "/api/v1/experiments/{experiment_id}/copy", + options: "--experiment-id ID --name NAME", + }, + delete_experiment: { + method: "DELETE", + path: "/api/v1/experiments/{experiment_id}", + options: "--experiment-id ID", + }, + start_experiment: { + method: "POST", + path: "/api/v1/experiments/{experiment_id}/start", + options: "--experiment-id ID --report-name NAME [--report-description TEXT]", + }, + stop_experiment: { + method: "POST", + path: "/api/v1/experiments/{experiment_id}/stop", + options: "--experiment-id ID", + }, + get_experiment_schedule: { + method: "GET", + path: "/api/v1/experiments/{experiment_id}/schedule", + options: "--experiment-id ID", + }, + upsert_experiment_schedule: { + method: "PUT", + path: "/api/v1/experiments/{experiment_id}/schedule", + options: "--experiment-id ID (--body JSON | --body-file PATH | --stdin)", + }, + toggle_experiment_schedule: { + method: "POST", + path: "/api/v1/experiments/{experiment_id}/schedule/toggle", + options: "--experiment-id ID --enabled true|false", + }, + delete_experiment_schedule: { + method: "DELETE", + path: "/api/v1/experiments/{experiment_id}/schedule", + options: "--experiment-id ID", + }, + get_experiment_dingo_input: { + method: "GET", + path: "/api/v1/experiments/{experiment_id}/dingo-input", + options: "--experiment-id ID", + }, +}; + +function showActions() { + writeResult( + Object.entries(ACTIONS).map(([action, definition]) => ({ action, ...definition })), + ); +} + +function experimentPath(action, args) { + const experimentId = encodeURIComponent(requireOption(args, "experiment-id")); + return ACTIONS[action].path.replace("{experiment_id}", experimentId); +} + +function parseBooleanOption(args, name) { + const value = requireOption(args, name).toLowerCase(); + if (value !== "true" && value !== "false") { + throw new Error(`--${name} must be true or false`); + } + return value === "true"; +} + +async function execute(action, args) { + switch (action) { + case "list_experiments": + return callApi("GET", ACTIONS[action].path, { + query: { + dataset_id: args["dataset-id"], + skip: parseIntegerOption(args, "skip", 0), + limit: parseIntegerOption(args, "limit", 100), + }, + }); + case "get_experiment": + case "stop_experiment": + case "get_experiment_schedule": + case "delete_experiment_schedule": + case "get_experiment_dingo_input": + return callApi(ACTIONS[action].method, experimentPath(action, args)); + case "create_experiment": + return callApi("POST", ACTIONS[action].path, { json: await readJsonBody(args) }); + case "update_experiment": + case "upsert_experiment_schedule": + return callApi(ACTIONS[action].method, experimentPath(action, args), { + json: await readJsonBody(args), + }); + case "copy_experiment": + return callApi("POST", experimentPath(action, args), { + json: { name: requireOption(args, "name") }, + }); + case "delete_experiment": + return callApi("DELETE", experimentPath(action, args)); + case "start_experiment": + return callApi("POST", experimentPath(action, args), { + json: { + report_name: requireOption(args, "report-name"), + report_description: String(args["report-description"] || ""), + }, + }); + case "toggle_experiment_schedule": + return callApi("POST", experimentPath(action, args), { + json: { enabled: parseBooleanOption(args, "enabled") }, + }); + default: + throw new Error(`Unknown action: ${action}. Run with 'actions' to list supported actions.`); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const action = args._[0]; + if (!action || action === "actions" || action === "help") { + showActions(); + return; + } + + writeResult(await execute(action, args)); +} + +main().catch(writeError); diff --git a/skills/dingo-saas/scripts/experiment_actions.test.mjs b/skills/dingo-saas/scripts/experiment_actions.test.mjs new file mode 100644 index 00000000..69aa1d54 --- /dev/null +++ b/skills/dingo-saas/scripts/experiment_actions.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import test from "node:test"; + +const execFileAsync = promisify(execFile); +const scriptPath = join(dirname(fileURLToPath(import.meta.url)), "experiment_actions.mjs"); + +async function withMockApi(run) { + const requests = []; + const directory = await mkdtemp(join(tmpdir(), "dingo-saas-experiment-test-")); + const configPath = join(directory, "config.json"); + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + requests.push({ + method: request.method, + url: request.url, + authorization: request.headers.authorization, + body: Buffer.concat(chunks).toString("utf8"), + }); + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + const url = `http://127.0.0.1:${port}`; + await writeFile(configPath, JSON.stringify({ url, key: "sk-experiment-test" })); + + try { + await run({ + requests, + env: { ...process.env, DINGO_SAAS_CONFIG_PATH: configPath }, + }); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await rm(directory, { recursive: true, force: true }); + } +} + +async function runAction(env, ...args) { + const { stdout } = await execFileAsync(process.execPath, [scriptPath, ...args], { env }); + return JSON.parse(stdout); +} + +test("maps experiment page actions to backend APIs", async () => { + await withMockApi(async ({ requests, env }) => { + await runAction(env, "list_experiments", "--dataset-id", "dataset/1", "--skip", "2", "--limit", "5"); + await runAction(env, "get_experiment", "--experiment-id", "experiment/1"); + await runAction(env, "create_experiment", "--body", '{"name":"New","dataset_id":"d1"}'); + await runAction(env, "update_experiment", "--experiment-id", "e1", "--body", '{"name":"Renamed"}'); + await runAction(env, "copy_experiment", "--experiment-id", "e1", "--name", "Copied"); + await runAction(env, "delete_experiment", "--experiment-id", "e1"); + await runAction(env, "start_experiment", "--experiment-id", "e1", "--report-name", "Report", "--report-description", "Description"); + await runAction(env, "stop_experiment", "--experiment-id", "e1"); + await runAction(env, "get_experiment_schedule", "--experiment-id", "e1"); + await runAction(env, "upsert_experiment_schedule", "--experiment-id", "e1", "--body", '{"start_date":"2026-09-11","end_date":"2026-09-30","weekdays":[1,3,5],"start_time":"09:30","timezone":"Asia/Shanghai","enabled":true}'); + await runAction(env, "toggle_experiment_schedule", "--experiment-id", "e1", "--enabled", "false"); + await runAction(env, "delete_experiment_schedule", "--experiment-id", "e1"); + await runAction(env, "get_experiment_dingo_input", "--experiment-id", "e1"); + + assert.deepEqual( + requests.map(({ method, url }) => ({ method, url })), + [ + { method: "GET", url: "/api/v1/experiments/?dataset_id=dataset%2F1&skip=2&limit=5" }, + { method: "GET", url: "/api/v1/experiments/experiment%2F1" }, + { method: "POST", url: "/api/v1/experiments/" }, + { method: "PUT", url: "/api/v1/experiments/e1" }, + { method: "POST", url: "/api/v1/experiments/e1/copy" }, + { method: "DELETE", url: "/api/v1/experiments/e1" }, + { method: "POST", url: "/api/v1/experiments/e1/start" }, + { method: "POST", url: "/api/v1/experiments/e1/stop" }, + { method: "GET", url: "/api/v1/experiments/e1/schedule" }, + { method: "PUT", url: "/api/v1/experiments/e1/schedule" }, + { method: "POST", url: "/api/v1/experiments/e1/schedule/toggle" }, + { method: "DELETE", url: "/api/v1/experiments/e1/schedule" }, + { method: "GET", url: "/api/v1/experiments/e1/dingo-input" }, + ], + ); + assert.ok(requests.every((request) => request.authorization === "Bearer sk-experiment-test")); + assert.deepEqual(JSON.parse(requests[4].body), { name: "Copied" }); + assert.deepEqual(JSON.parse(requests[6].body), { + report_name: "Report", + report_description: "Description", + }); + assert.deepEqual(JSON.parse(requests[10].body), { enabled: false }); + }); +}); + +test("rejects invalid schedule toggle values before calling the API", async () => { + await withMockApi(async ({ requests, env }) => { + await assert.rejects( + runAction(env, "toggle_experiment_schedule", "--experiment-id", "e1", "--enabled", "yes"), + /--enabled must be true or false/, + ); + assert.equal(requests.length, 0); + }); +}); diff --git a/skills/dingo-saas/scripts/lib/dingo_client.mjs b/skills/dingo-saas/scripts/lib/dingo_client.mjs new file mode 100644 index 00000000..7ea6296a --- /dev/null +++ b/skills/dingo-saas/scripts/lib/dingo_client.mjs @@ -0,0 +1,243 @@ +import { readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const LIB_DIR = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_CONFIG_PATH = resolve(LIB_DIR, "..", "..", "config.json"); + +export const CONFIG_PATH = process.env.DINGO_SAAS_CONFIG_PATH || DEFAULT_CONFIG_PATH; + +export function parseArgs(argv) { + const parsed = { _: [] }; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith("--")) { + parsed._.push(token); + continue; + } + + const key = token.slice(2); + const next = argv[index + 1]; + if (next === undefined || next.startsWith("--")) { + parsed[key] = true; + continue; + } + + parsed[key] = next; + index += 1; + } + return parsed; +} + +export function requireOption(args, name) { + const value = args[name]; + if (value === undefined || value === true || String(value).trim() === "") { + throw new Error(`Missing required option --${name}`); + } + return String(value); +} + +export function parseIntegerOption(args, name, fallback) { + if (args[name] === undefined) return fallback; + const value = Number.parseInt(String(args[name]), 10); + if (!Number.isInteger(value) || value < 0) { + throw new Error(`--${name} must be a non-negative integer`); + } + return value; +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +export async function readJsonBody(args) { + const sources = [args.body !== undefined, args["body-file"] !== undefined, args.stdin === true]; + if (sources.filter(Boolean).length !== 1) { + throw new Error("Provide exactly one of --body, --body-file, or --stdin"); + } + + let raw; + if (args.body !== undefined) { + raw = String(args.body); + } else if (args["body-file"] !== undefined) { + raw = await readFile(resolve(String(args["body-file"])), "utf8"); + } else { + raw = await readStdin(); + } + + try { + return JSON.parse(raw); + } catch (error) { + throw new Error(`Invalid JSON body: ${error.message}`); + } +} + +function normalizeBaseUrl(value) { + let url; + try { + url = new URL(String(value).trim()); + } catch { + throw new Error("--url must be a valid Dingo SaaS website URL"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("--url must use http or https"); + } + return url.origin; +} + +export function maskKey(key) { + return key.length <= 10 ? "***" : `${key.slice(0, 10)}...`; +} + +export async function loadConnection({ allowMissing = false } = {}) { + let raw; + try { + raw = await readFile(CONFIG_PATH, "utf8"); + } catch (error) { + if (allowMissing && error.code === "ENOENT") return null; + if (error.code === "ENOENT") { + throw new Error("Dingo SaaS is not configured. Ask the user for the website URL and API key, then run configure."); + } + throw error; + } + + let config; + try { + config = JSON.parse(raw); + } catch { + throw new Error("The saved Dingo SaaS config.json is invalid. Ask the user for the website URL and API key again."); + } + + if (!config?.url || !config?.key) { + throw new Error("The saved Dingo SaaS configuration is incomplete. Ask the user for the website URL and API key again."); + } + + return { url: normalizeBaseUrl(config.url), key: String(config.key) }; +} + +export async function configureConnection(args) { + const url = normalizeBaseUrl(requireOption(args, "url")); + const key = requireOption(args, "key"); + await writeFile(CONFIG_PATH, `${JSON.stringify({ url, key }, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + return { configured: true, url, key_prefix: maskKey(key) }; +} + +export async function clearConnection() { + await rm(CONFIG_PATH, { force: true }); + return { configured: false }; +} + +function buildUrl(baseUrl, path, query = {}) { + const url = new URL(`${baseUrl}${path}`); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + return url; +} + +function formatErrorDetail(responseBody, fallback) { + const detail = responseBody?.detail ?? responseBody ?? fallback; + return typeof detail === "string" ? detail : JSON.stringify(detail); +} + +export async function callApi(method, path, { query, json, form } = {}) { + const connection = await loadConnection(); + const headers = { Authorization: `Bearer ${connection.key}`, Accept: "application/json" }; + const request = { method, headers }; + if (json !== undefined) { + headers["Content-Type"] = "application/json"; + request.body = JSON.stringify(json); + } else if (form !== undefined) { + request.body = form; + } + + const response = await fetch(buildUrl(connection.url, path, query), request); + const responseText = await response.text(); + let responseBody = null; + if (responseText) { + try { + responseBody = JSON.parse(responseText); + } catch { + responseBody = responseText; + } + } + + if (!response.ok) { + throw new Error( + `${method} ${path} failed (${response.status}): ${formatErrorDetail(responseBody, response.statusText)}`, + ); + } + + return responseBody ?? { success: true, status: response.status }; +} + +export async function downloadApiFile(path, outputPath) { + const connection = await loadConnection(); + const response = await fetch(buildUrl(connection.url, path), { + method: "GET", + headers: { + Authorization: `Bearer ${connection.key}`, + Accept: "application/zip", + }, + }); + + if (!response.ok) { + const responseText = await response.text(); + let responseBody = responseText; + if (responseText) { + try { + responseBody = JSON.parse(responseText); + } catch { + // Keep the plain-text response body. + } + } + throw new Error( + `GET ${path} failed (${response.status}): ${formatErrorDetail(responseBody, response.statusText)}`, + ); + } + + const resolvedPath = resolve(outputPath); + const contents = Buffer.from(await response.arrayBuffer()); + try { + await writeFile(resolvedPath, contents, { flag: "wx" }); + } catch (error) { + if (error.code === "EEXIST") { + throw new Error(`Download target already exists: ${resolvedPath}`); + } + throw error; + } + return { + downloaded: true, + path: resolvedPath, + bytes: contents.length, + content_type: response.headers.get("content-type"), + }; +} + +export async function getConnectionStatus() { + const connection = await loadConnection({ allowMissing: true }); + if (!connection) return { configured: false }; + const user = await callApi("GET", "/api/v1/auth/me"); + return { + configured: true, + url: connection.url, + key_prefix: maskKey(connection.key), + user, + }; +} + +export function writeResult(result) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +export function writeError(error) { + process.stderr.write(`${JSON.stringify({ error: error.message }, null, 2)}\n`); + process.exitCode = 1; +} diff --git a/skills/dingo-saas/scripts/metric_actions.mjs b/skills/dingo-saas/scripts/metric_actions.mjs new file mode 100644 index 00000000..b62751b1 --- /dev/null +++ b/skills/dingo-saas/scripts/metric_actions.mjs @@ -0,0 +1,304 @@ +#!/usr/bin/env node + +import { + callApi, + parseArgs, + parseIntegerOption, + readJsonBody, + requireOption, + writeError, + writeResult, +} from "./lib/dingo_client.mjs"; + +const ACTIONS = { + list_metrics: { + tab: "metric_list", + method: "GET", + path: "/api/v1/metrics/list", + options: "[--type all|rule|llm] [--search TEXT]", + }, + list_metric_groups: { + tab: "metric_groups", + method: "GET", + path: "/api/v1/metric-groups/", + options: "[--name TEXT] [--skip N] [--limit N]", + }, + get_metric_group_detail: { + tab: "metric_groups", + method: "COMPOSITE", + path: "/api/v1/metric-groups/{group_id} + /api/v1/metric-group-items/", + options: "--group-id ID [--skip N] [--limit N]", + }, + create_metric_group: { + tab: "metric_groups", + method: "POST", + path: "/api/v1/metric-groups/", + options: "--body JSON | --body-file PATH | --stdin", + }, + update_metric_group: { + tab: "metric_groups", + method: "PUT", + path: "/api/v1/metric-groups/{group_id}", + options: "--group-id ID (--body JSON | --body-file PATH | --stdin)", + }, + delete_metric_group: { + tab: "metric_groups", + method: "DELETE", + path: "/api/v1/metric-groups/{group_id}", + options: "--group-id ID", + }, + add_metric_to_group: { + tab: "metric_groups", + method: "POST", + path: "/api/v1/metric-group-items/", + options: "--body JSON | --body-file PATH | --stdin", + }, + add_custom_metric_to_group: { + tab: "metric_groups", + method: "COMPOSITE", + path: "/api/v1/custom-llm-metrics/{metric_id} + /api/v1/metric-group-items/", + options: "--group-id ID --metric-id ID", + }, + update_group_metric: { + tab: "metric_groups", + method: "PUT", + path: "/api/v1/metric-group-items/{item_id}", + options: "--item-id ID (--body JSON | --body-file PATH | --stdin)", + }, + remove_metric_from_group: { + tab: "metric_groups", + method: "DELETE", + path: "/api/v1/metric-group-items/{item_id}", + options: "--item-id ID", + }, + list_custom_metrics: { + tab: "custom_metrics", + method: "GET", + path: "/api/v1/custom-llm-metrics/", + options: "[--metric TEXT] [--skip N] [--limit N]", + }, + get_custom_metric: { + tab: "custom_metrics", + method: "GET", + path: "/api/v1/custom-llm-metrics/{metric_id}", + options: "--metric-id ID", + }, + create_custom_metric: { + tab: "custom_metrics", + method: "POST", + path: "/api/v1/custom-llm-metrics/", + options: "--body JSON | --body-file PATH | --stdin", + }, + update_custom_metric: { + tab: "custom_metrics", + method: "PUT", + path: "/api/v1/custom-llm-metrics/{metric_id}", + options: "--metric-id ID (--body JSON | --body-file PATH | --stdin)", + }, + delete_custom_metric: { + tab: "custom_metrics", + method: "DELETE", + path: "/api/v1/custom-llm-metrics/{metric_id}", + options: "--metric-id ID", + }, + draft_custom_metric: { + tab: "custom_metrics", + method: "POST", + path: "/api/v1/custom-llm-metrics/assistant", + options: "--body JSON | --body-file PATH | --stdin", + }, + start_custom_metric_quick_try: { + tab: "custom_metrics", + method: "COMPOSITE", + path: "/api/v1/custom-llm-metrics/{metric_id} + /api/v1/quick-eval/", + options: "--metric-id ID (--body JSON | --body-file PATH | --stdin)", + }, + get_metric_quick_try: { + tab: "custom_metrics", + method: "GET", + path: "/api/v1/quick-eval/{job_id}", + options: "--job-id ID", + }, +}; + +function showActions() { + writeResult( + Object.entries(ACTIONS).map(([action, definition]) => ({ action, ...definition })), + ); +} + +function idPath(action, args, placeholder, option) { + const id = encodeURIComponent(requireOption(args, option)); + return ACTIONS[action].path.replace(`{${placeholder}}`, id); +} + +function parseMetricType(args) { + const value = String(args.type || "all").toLowerCase(); + if (!["all", "rule", "llm"].includes(value)) { + throw new Error("--type must be all, rule, or llm"); + } + return value; +} + +function matchesMetric(metric, keyword) { + return [metric?.name, metric?.display_name, metric?.description].some((value) => + String(value || "").toLowerCase().includes(keyword), + ); +} + +function filterMetricCatalog(result, metricType, search) { + const keyword = String(search || "").trim().toLowerCase(); + if (!keyword) return result; + + if (metricType === "all") { + const rule = (result?.rule || []).filter((metric) => matchesMetric(metric, keyword)); + const llm = (result?.llm || []).filter((metric) => matchesMetric(metric, keyword)); + return { ...result, rule, llm, total: rule.length + llm.length }; + } + + const metrics = (result?.metrics || []).filter((metric) => matchesMetric(metric, keyword)); + return { ...result, metrics, total: metrics.length }; +} + +function groupPath(action, args) { + return idPath(action, args, "group_id", "group-id"); +} + +function groupItemPath(action, args) { + return idPath(action, args, "item_id", "item-id"); +} + +function customMetricPath(action, args) { + return idPath(action, args, "metric_id", "metric-id"); +} + +function customMetricEvaluator(metric) { + const llmConfig = metric.llm_config || {}; + const rawKey = String(llmConfig.key || "").trim(); + const key = !rawKey + ? "SYSTEM_LLM_KEY" + : rawKey === "SYSTEM_LLM_KEY" + ? rawKey + : metric.id && rawKey.includes("*") + ? "CUSTOM_LLM_METRIC_KEY" + : rawKey; + + return { + name: "LLMCustomMetric", + config: { + model: String(llmConfig.model || "").trim() || "SYSTEM_LLM_MODEL", + key, + api_url: String(llmConfig.api_url || "").trim() || "SYSTEM_LLM_API_URL", + custom_metric: { + id: metric.id, + metric: metric.metric, + description: metric.description, + criteria: metric.criteria, + input_fields: metric.input_fields, + }, + }, + }; +} + +async function execute(action, args) { + switch (action) { + case "list_metrics": { + const metricType = parseMetricType(args); + const result = await callApi("GET", ACTIONS[action].path, { + query: { metric_type: metricType === "all" ? undefined : metricType }, + }); + return filterMetricCatalog(result, metricType, args.search); + } + case "list_metric_groups": + return callApi("GET", ACTIONS[action].path, { + query: { + name: args.name, + skip: parseIntegerOption(args, "skip", 0), + limit: parseIntegerOption(args, "limit", 100), + }, + }); + case "get_metric_group_detail": { + const groupId = encodeURIComponent(requireOption(args, "group-id")); + const [group, metrics] = await Promise.all([ + callApi("GET", `/api/v1/metric-groups/${groupId}`), + callApi("GET", "/api/v1/metric-group-items/", { + query: { + group_id: requireOption(args, "group-id"), + skip: parseIntegerOption(args, "skip", 0), + limit: parseIntegerOption(args, "limit", 100), + }, + }), + ]); + return { group, metrics }; + } + case "create_metric_group": + case "create_custom_metric": + case "draft_custom_metric": + case "add_metric_to_group": + return callApi("POST", ACTIONS[action].path, { json: await readJsonBody(args) }); + case "add_custom_metric_to_group": { + const metric = await callApi( + "GET", + `/api/v1/custom-llm-metrics/${encodeURIComponent(requireOption(args, "metric-id"))}`, + ); + const evaluator = customMetricEvaluator(metric); + return callApi("POST", "/api/v1/metric-group-items/", { + json: { + group_id: requireOption(args, "group-id"), + name: evaluator.name, + config: evaluator.config, + }, + }); + } + case "update_metric_group": + return callApi("PUT", groupPath(action, args), { json: await readJsonBody(args) }); + case "delete_metric_group": + return callApi("DELETE", groupPath(action, args)); + case "update_group_metric": + return callApi("PUT", groupItemPath(action, args), { json: await readJsonBody(args) }); + case "remove_metric_from_group": + return callApi("DELETE", groupItemPath(action, args)); + case "list_custom_metrics": + return callApi("GET", ACTIONS[action].path, { + query: { + metric: args.metric, + skip: parseIntegerOption(args, "skip", 0), + limit: parseIntegerOption(args, "limit", 100), + }, + }); + case "get_custom_metric": + case "delete_custom_metric": + return callApi(ACTIONS[action].method, customMetricPath(action, args)); + case "update_custom_metric": + return callApi("PUT", customMetricPath(action, args), { json: await readJsonBody(args) }); + case "start_custom_metric_quick_try": { + const metric = await callApi( + "GET", + `/api/v1/custom-llm-metrics/${encodeURIComponent(requireOption(args, "metric-id"))}`, + ); + return callApi("POST", "/api/v1/quick-eval/", { + json: { + data: await readJsonBody(args), + evaluators: [customMetricEvaluator(metric)], + }, + }); + } + case "get_metric_quick_try": + return callApi("GET", idPath(action, args, "job_id", "job-id")); + default: + throw new Error(`Unknown action: ${action}. Run with 'actions' to list supported actions.`); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const action = args._[0]; + if (!action || action === "actions" || action === "help") { + showActions(); + return; + } + + writeResult(await execute(action, args)); +} + +main().catch(writeError); diff --git a/skills/dingo-saas/scripts/metric_actions.test.mjs b/skills/dingo-saas/scripts/metric_actions.test.mjs new file mode 100644 index 00000000..982520a6 --- /dev/null +++ b/skills/dingo-saas/scripts/metric_actions.test.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import test from "node:test"; + +const execFileAsync = promisify(execFile); +const scriptPath = join(dirname(fileURLToPath(import.meta.url)), "metric_actions.mjs"); + +async function withMockApi(run) { + const requests = []; + const directory = await mkdtemp(join(tmpdir(), "dingo-saas-metric-test-")); + const configPath = join(directory, "config.json"); + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + requests.push({ + method: request.method, + url: request.url, + authorization: request.headers.authorization, + body: Buffer.concat(chunks).toString("utf8"), + }); + + response.writeHead(200, { "Content-Type": "application/json" }); + if (request.url === "/api/v1/metrics/list") { + response.end(JSON.stringify({ + rule: [{ name: "RuleLength", description: "Checks length" }], + llm: [{ name: "LLMQuality", description: "Quality score" }], + })); + return; + } + if ( + request.method === "GET" && + (request.url === "/api/v1/custom-llm-metrics/m1" || + request.url === "/api/v1/custom-llm-metrics/metric%2F1") + ) { + response.end(JSON.stringify({ + id: "m1", + metric: "quality", + description: "Quality check", + criteria: ["good"], + input_fields: ["content"], + llm_config: { + model: "gpt-test", + key: "sk-****1234", + api_url: "https://example.test/v1", + }, + })); + return; + } + response.end(JSON.stringify({ ok: true })); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + await writeFile(configPath, JSON.stringify({ + url: `http://127.0.0.1:${port}`, + key: "sk-metric-test", + })); + + try { + await run({ requests, env: { ...process.env, DINGO_SAAS_CONFIG_PATH: configPath } }); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await rm(directory, { recursive: true, force: true }); + } +} + +async function runAction(env, ...args) { + const { stdout } = await execFileAsync(process.execPath, [scriptPath, ...args], { env }); + return JSON.parse(stdout); +} + +test("maps metric page actions to backend APIs", async () => { + await withMockApi(async ({ requests, env }) => { + const catalog = await runAction(env, "list_metrics", "--search", "quality"); + await runAction(env, "list_metric_groups", "--name", "Core", "--skip", "1", "--limit", "5"); + await runAction(env, "create_metric_group", "--body", '{"name":"Core","description":"Checks"}'); + await runAction(env, "update_metric_group", "--group-id", "group/1", "--body", '{"name":"Core v2"}'); + await runAction(env, "delete_metric_group", "--group-id", "g1"); + await runAction(env, "add_metric_to_group", "--body", '{"group_id":"g1","name":"RuleLength","config":null}'); + await runAction(env, "add_custom_metric_to_group", "--group-id", "g1", "--metric-id", "m1"); + await runAction(env, "update_group_metric", "--item-id", "item/1", "--body", '{"config":{"min":1}}'); + await runAction(env, "remove_metric_from_group", "--item-id", "i1"); + await runAction(env, "list_custom_metrics", "--metric", "quality", "--skip", "2", "--limit", "4"); + await runAction(env, "get_custom_metric", "--metric-id", "metric/1"); + await runAction(env, "create_custom_metric", "--body", '{"metric":"quality","criteria":["good"],"input_fields":["content"]}'); + await runAction(env, "update_custom_metric", "--metric-id", "m1", "--body", '{"description":"Updated"}'); + await runAction(env, "delete_custom_metric", "--metric-id", "m1"); + await runAction(env, "draft_custom_metric", "--body", '{"messages":[{"role":"user","content":"Draft a metric"}],"current_metric":{}}'); + await runAction(env, "start_custom_metric_quick_try", "--metric-id", "m1", "--body", '{"content":"hello"}'); + await runAction(env, "get_metric_quick_try", "--job-id", "job/1"); + + assert.equal(catalog.total, 1); + assert.equal(catalog.rule.length, 0); + assert.equal(catalog.llm[0].name, "LLMQuality"); + assert.deepEqual( + requests.map(({ method, url }) => ({ method, url })), + [ + { method: "GET", url: "/api/v1/metrics/list" }, + { method: "GET", url: "/api/v1/metric-groups/?name=Core&skip=1&limit=5" }, + { method: "POST", url: "/api/v1/metric-groups/" }, + { method: "PUT", url: "/api/v1/metric-groups/group%2F1" }, + { method: "DELETE", url: "/api/v1/metric-groups/g1" }, + { method: "POST", url: "/api/v1/metric-group-items/" }, + { method: "GET", url: "/api/v1/custom-llm-metrics/m1" }, + { method: "POST", url: "/api/v1/metric-group-items/" }, + { method: "PUT", url: "/api/v1/metric-group-items/item%2F1" }, + { method: "DELETE", url: "/api/v1/metric-group-items/i1" }, + { method: "GET", url: "/api/v1/custom-llm-metrics/?metric=quality&skip=2&limit=4" }, + { method: "GET", url: "/api/v1/custom-llm-metrics/metric%2F1" }, + { method: "POST", url: "/api/v1/custom-llm-metrics/" }, + { method: "PUT", url: "/api/v1/custom-llm-metrics/m1" }, + { method: "DELETE", url: "/api/v1/custom-llm-metrics/m1" }, + { method: "POST", url: "/api/v1/custom-llm-metrics/assistant" }, + { method: "GET", url: "/api/v1/custom-llm-metrics/m1" }, + { method: "POST", url: "/api/v1/quick-eval/" }, + { method: "GET", url: "/api/v1/quick-eval/job%2F1" }, + ], + ); + assert.ok(requests.every((request) => request.authorization === "Bearer sk-metric-test")); + assert.deepEqual(JSON.parse(requests[5].body), { + group_id: "g1", + name: "RuleLength", + config: null, + }); + assert.equal(JSON.parse(requests[7].body).name, "LLMCustomMetric"); + assert.equal(JSON.parse(requests[7].body).config.key, "CUSTOM_LLM_METRIC_KEY"); + assert.deepEqual(JSON.parse(requests[17].body).data, { content: "hello" }); + assert.equal( + JSON.parse(requests[17].body).evaluators[0].config.key, + "CUSTOM_LLM_METRIC_KEY", + ); + }); +}); + +test("loads a metric group with its metrics", async () => { + await withMockApi(async ({ requests, env }) => { + const detail = await runAction( + env, + "get_metric_group_detail", + "--group-id", + "group/1", + "--limit", + "20", + ); + assert.deepEqual(detail, { group: { ok: true }, metrics: { ok: true } }); + assert.deepEqual( + requests.map(({ method, url }) => ({ method, url })).sort((a, b) => a.url.localeCompare(b.url)), + [ + { method: "GET", url: "/api/v1/metric-group-items/?group_id=group%2F1&skip=0&limit=20" }, + { method: "GET", url: "/api/v1/metric-groups/group%2F1" }, + ], + ); + }); +}); + +test("rejects invalid metric types before calling the API", async () => { + await withMockApi(async ({ requests, env }) => { + await assert.rejects(runAction(env, "list_metrics", "--type", "custom"), /--type must be/); + assert.equal(requests.length, 0); + }); +}); diff --git a/skills/dingo-saas/scripts/report_actions.mjs b/skills/dingo-saas/scripts/report_actions.mjs new file mode 100644 index 00000000..fe34afac --- /dev/null +++ b/skills/dingo-saas/scripts/report_actions.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node + +import { resolve } from "node:path"; + +import { + callApi, + downloadApiFile, + loadConnection, + parseArgs, + parseIntegerOption, + readJsonBody, + requireOption, + writeError, + writeResult, +} from "./lib/dingo_client.mjs"; + +const ACTIONS = { + list_reports: { + page: "report_list", + method: "GET", + path: "/api/v1/outputs/", + options: + "[--experiment-id ID] [--status STATUS] [--created-at YYYY-MM-DD] [--search TEXT] [--skip N] [--limit N]", + }, + get_report_detail: { + page: "report_detail", + method: "GET", + path: "/api/v1/outputs/{report_id}", + options: "--report-id ID", + }, + update_report: { + page: "report_detail", + method: "PUT", + path: "/api/v1/outputs/{report_id}", + options: "--report-id ID (--body JSON | --body-file PATH | --stdin)", + }, + delete_report: { + page: "report_list_or_detail", + method: "DELETE", + path: "/api/v1/outputs/{report_id}", + options: "--report-id ID", + }, + get_report_results: { + page: "report_results", + method: "COMPOSITE", + path: "/api/v1/outputs/{report_id} + /api/v1/output-items/output/{report_id}", + options: "--report-id ID [--eval-status all|true|false] [--label TEXT] [--skip N] [--limit N]", + }, + list_report_results: { + page: "report_results", + method: "GET", + path: "/api/v1/output-items/output/{report_id}", + options: "--report-id ID [--eval-status all|true|false] [--label TEXT] [--skip N] [--limit N]", + }, + get_report_result: { + page: "report_results", + method: "GET", + path: "/api/v1/output-items/{item_id}", + options: "--item-id ID", + }, + get_report_log: { + page: "report_list", + method: "GET", + path: "/api/v1/outputs/{report_id}", + options: "--report-id ID", + }, + get_report_share_url: { + page: "report_list_or_results", + method: "LOCAL", + path: "/share/{report_id}", + options: "--report-id ID", + }, + download_report: { + page: "report_list_or_detail_or_results", + method: "GET", + path: "/api/v1/outputs/{report_id}/download", + options: "--report-id ID --output PATH", + }, +}; + +function showActions() { + writeResult( + Object.entries(ACTIONS).map(([action, definition]) => ({ action, ...definition })), + ); +} + +function reportPath(action, args) { + const reportId = encodeURIComponent(requireOption(args, "report-id")); + return ACTIONS[action].path.replace("{report_id}", reportId); +} + +function parseEvalStatus(args) { + const value = String(args["eval-status"] || "all").toLowerCase(); + if (!["all", "true", "false"].includes(value)) { + throw new Error("--eval-status must be all, true, or false"); + } + return value; +} + +function filterReports(result, search) { + const keyword = String(search || "").trim().toLowerCase(); + if (!keyword || !Array.isArray(result?.items)) return result; + const items = result.items.filter((report) => + [report?.id, report?.name, report?.description].some((value) => + String(value || "").toLowerCase().includes(keyword), + ), + ); + return { ...result, source_total: result.total, total: items.length, items }; +} + +async function execute(action, args) { + switch (action) { + case "list_reports": { + const result = await callApi("GET", ACTIONS[action].path, { + query: { + experiment_id: args["experiment-id"], + status: args.status, + created_at: args["created-at"], + skip: parseIntegerOption(args, "skip", 0), + limit: parseIntegerOption(args, "limit", 100), + }, + }); + return filterReports(result, args.search); + } + case "get_report_detail": + case "delete_report": + return callApi(ACTIONS[action].method, reportPath(action, args)); + case "update_report": + return callApi("PUT", reportPath(action, args), { json: await readJsonBody(args) }); + case "get_report_results": { + const reportId = encodeURIComponent(requireOption(args, "report-id")); + const itemQuery = { + skip: parseIntegerOption(args, "skip", 0), + limit: parseIntegerOption(args, "limit", 100), + eval_status: parseEvalStatus(args), + label: args.label, + }; + const [report, results] = await Promise.all([ + callApi("GET", `/api/v1/outputs/${reportId}`), + callApi("GET", `/api/v1/output-items/output/${reportId}`, { query: itemQuery }), + ]); + return { report, results }; + } + case "list_report_results": + return callApi("GET", reportPath(action, args), { + query: { + skip: parseIntegerOption(args, "skip", 0), + limit: parseIntegerOption(args, "limit", 100), + eval_status: parseEvalStatus(args), + label: args.label, + }, + }); + case "get_report_result": { + const itemId = encodeURIComponent(requireOption(args, "item-id")); + return callApi("GET", ACTIONS[action].path.replace("{item_id}", itemId)); + } + case "get_report_log": { + const report = await callApi("GET", reportPath(action, args)); + return { + report_id: report.id, + status: report.status, + log: report.log || report.message || null, + }; + } + case "get_report_share_url": { + const connection = await loadConnection(); + return { report_id: requireOption(args, "report-id"), url: `${connection.url}${reportPath(action, args)}` }; + } + case "download_report": + return downloadApiFile( + reportPath(action, args), + resolve(requireOption(args, "output")), + ); + default: + throw new Error(`Unknown action: ${action}. Run with 'actions' to list supported actions.`); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const action = args._[0]; + if (!action || action === "actions" || action === "help") { + showActions(); + return; + } + + writeResult(await execute(action, args)); +} + +main().catch(writeError); diff --git a/skills/dingo-saas/scripts/report_actions.test.mjs b/skills/dingo-saas/scripts/report_actions.test.mjs new file mode 100644 index 00000000..eb08055b --- /dev/null +++ b/skills/dingo-saas/scripts/report_actions.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import test from "node:test"; + +const execFileAsync = promisify(execFile); +const scriptPath = join(dirname(fileURLToPath(import.meta.url)), "report_actions.mjs"); + +async function withMockApi(run) { + const requests = []; + const directory = await mkdtemp(join(tmpdir(), "dingo-saas-report-test-")); + const configPath = join(directory, "config.json"); + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + requests.push({ + method: request.method, + url: request.url, + authorization: request.headers.authorization, + body: Buffer.concat(chunks).toString("utf8"), + }); + + if (request.url?.endsWith("/download")) { + response.writeHead(200, { "Content-Type": "application/zip" }); + response.end(Buffer.from("zip-contents")); + return; + } + if (request.url?.startsWith("/api/v1/outputs/?")) { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ + total: 2, + items: [ + { id: "r1", name: "Alpha report", description: "first" }, + { id: "r2", name: "Beta", description: "second" }, + ], + })); + return; + } + if (request.url === "/api/v1/outputs/r1") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ id: "r1", status: "failed", log: "trace output", message: "fallback" })); + return; + } + + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + const url = `http://127.0.0.1:${port}`; + await writeFile(configPath, JSON.stringify({ url, key: "sk-report-test" })); + + try { + await run({ + requests, + directory, + url, + env: { ...process.env, DINGO_SAAS_CONFIG_PATH: configPath }, + }); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await rm(directory, { recursive: true, force: true }); + } +} + +async function runAction(env, ...args) { + const { stdout } = await execFileAsync(process.execPath, [scriptPath, ...args], { env }); + return JSON.parse(stdout); +} + +test("maps report page actions to backend APIs", async () => { + await withMockApi(async ({ requests, directory, env, url }) => { + const reports = await runAction( + env, + "list_reports", + "--experiment-id", + "experiment/1", + "--status", + "success", + "--created-at", + "2026-09-11", + "--search", + "alpha", + "--skip", + "2", + "--limit", + "5", + ); + await runAction(env, "get_report_detail", "--report-id", "report/1"); + await runAction(env, "update_report", "--report-id", "r1", "--body", '{"name":"Renamed","description":"Updated"}'); + await runAction(env, "delete_report", "--report-id", "r1"); + await runAction(env, "list_report_results", "--report-id", "report/1", "--eval-status", "false", "--label", "bad", "--skip", "3", "--limit", "7"); + await runAction(env, "get_report_result", "--item-id", "item/1"); + const log = await runAction(env, "get_report_log", "--report-id", "r1"); + const share = await runAction(env, "get_report_share_url", "--report-id", "report/1"); + const outputPath = join(directory, "report.zip"); + const download = await runAction(env, "download_report", "--report-id", "r1", "--output", outputPath); + + assert.equal(reports.total, 1); + assert.equal(reports.source_total, 2); + assert.equal(reports.items[0].id, "r1"); + assert.deepEqual(log, { report_id: "r1", status: "failed", log: "trace output" }); + assert.deepEqual(share, { report_id: "report/1", url: `${url}/share/report%2F1` }); + assert.equal(download.bytes, 12); + assert.equal(await readFile(outputPath, "utf8"), "zip-contents"); + assert.deepEqual( + requests.map(({ method, url: requestUrl }) => ({ method, url: requestUrl })), + [ + { method: "GET", url: "/api/v1/outputs/?experiment_id=experiment%2F1&status=success&created_at=2026-09-11&skip=2&limit=5" }, + { method: "GET", url: "/api/v1/outputs/report%2F1" }, + { method: "PUT", url: "/api/v1/outputs/r1" }, + { method: "DELETE", url: "/api/v1/outputs/r1" }, + { method: "GET", url: "/api/v1/output-items/output/report%2F1?skip=3&limit=7&eval_status=false&label=bad" }, + { method: "GET", url: "/api/v1/output-items/item%2F1" }, + { method: "GET", url: "/api/v1/outputs/r1" }, + { method: "GET", url: "/api/v1/outputs/r1/download" }, + ], + ); + assert.ok(requests.every((request) => request.authorization === "Bearer sk-report-test")); + assert.deepEqual(JSON.parse(requests[2].body), { name: "Renamed", description: "Updated" }); + }); +}); + +test("validates report item filters before calling the API", async () => { + await withMockApi(async ({ requests, env }) => { + await assert.rejects( + runAction(env, "list_report_results", "--report-id", "r1", "--eval-status", "maybe"), + /--eval-status must be all, true, or false/, + ); + assert.equal(requests.length, 0); + }); +}); + +test("loads the report results page as report summary plus result rows", async () => { + await withMockApi(async ({ requests, env }) => { + const page = await runAction( + env, + "get_report_results", + "--report-id", + "report/1", + "--eval-status", + "true", + "--label", + "quality", + "--limit", + "10", + ); + + assert.deepEqual(page, { report: { ok: true }, results: { ok: true } }); + assert.deepEqual( + requests.map(({ method, url }) => ({ method, url })).sort((a, b) => a.url.localeCompare(b.url)), + [ + { method: "GET", url: "/api/v1/output-items/output/report%2F1?skip=0&limit=10&eval_status=true&label=quality" }, + { method: "GET", url: "/api/v1/outputs/report%2F1" }, + ], + ); + }); +}); + +test("does not overwrite an existing report download", async () => { + await withMockApi(async ({ requests, directory, env }) => { + const outputPath = join(directory, "existing.zip"); + await writeFile(outputPath, "keep-me"); + await assert.rejects( + runAction(env, "download_report", "--report-id", "r1", "--output", outputPath), + /Download target already exists/, + ); + assert.equal(await readFile(outputPath, "utf8"), "keep-me"); + assert.equal(requests.length, 1); + }); +}); From c680d9dfc338189114ca4d3f311bb7f3c7b54557 Mon Sep 17 00:00:00 2001 From: chupei Date: Mon, 21 Sep 2026 16:29:37 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Claude=20Code?= =?UTF-8?q?=20=E6=8F=92=E4=BB=B6=E5=B8=82=E5=9C=BA=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 .claude-plugin/marketplace.json,将 dingo-saas skill 发布为可安装插件 - 零重复:source 指向仓库根,skills 复用现有 skills/dingo-saas 目录 - 安装方式:claude plugin marketplace add MigoXLab/dingo && claude plugin install dingo-saas@dingo Co-Authored-By: Claude Opus 4.8 --- .claude-plugin/marketplace.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .claude-plugin/marketplace.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..bfd6475e --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "dingo", + "owner": { + "name": "MigoXLab", + "url": "https://github.com/MigoXLab/dingo" + }, + "plugins": [ + { + "name": "dingo-saas", + "source": "./", + "description": "Operate Dingo SaaS through conversation by mapping user requests to backend API actions. Covers dataset, metric, experiment, and report workflows.", + "skills": ["./skills/dingo-saas"] + } + ] +}