ugc-lens 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +105 -2
- package/dist/agent/events.d.ts +60 -0
- package/dist/agent/events.js +1 -0
- package/dist/agent/fake_model.d.ts +31 -0
- package/dist/agent/fake_model.js +53 -0
- package/dist/agent/llm_model.d.ts +9 -0
- package/dist/agent/llm_model.js +6 -0
- package/dist/agent/loop.d.ts +56 -0
- package/dist/agent/loop.js +193 -0
- package/dist/agent/model.d.ts +14 -0
- package/dist/agent/model.js +1 -0
- package/dist/analysis/danmaku.d.ts +39 -0
- package/dist/analysis/danmaku.js +50 -0
- package/dist/analysis/keywords.d.ts +75 -0
- package/dist/analysis/keywords.js +200 -0
- package/dist/analysis/stopwords.d.ts +7 -0
- package/dist/analysis/stopwords.js +43 -0
- package/dist/analysis/text.d.ts +18 -0
- package/dist/analysis/text.js +53 -0
- package/dist/analysis/timeline.d.ts +24 -0
- package/dist/analysis/timeline.js +59 -0
- package/dist/ask/index.d.ts +29 -0
- package/dist/ask/index.js +32 -0
- package/dist/cli/analyze.d.ts +11 -0
- package/dist/cli/analyze.js +69 -0
- package/dist/cli/approve.d.ts +9 -0
- package/dist/cli/approve.js +28 -0
- package/dist/cli/ask.d.ts +8 -0
- package/dist/cli/ask.js +54 -0
- package/dist/cli/common.d.ts +16 -0
- package/dist/cli/common.js +44 -0
- package/dist/cli/format_event.d.ts +3 -0
- package/dist/cli/format_event.js +31 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +79 -0
- package/dist/cli/init.d.ts +23 -0
- package/dist/cli/init.js +185 -0
- package/dist/cli/watch.d.ts +16 -0
- package/dist/cli/watch.js +105 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +107 -0
- package/dist/dataset/query.d.ts +34 -0
- package/dist/dataset/query.js +112 -0
- package/dist/dataset/stats.d.ts +25 -0
- package/dist/dataset/stats.js +38 -0
- package/dist/dataset/store.d.ts +7 -0
- package/dist/dataset/store.js +7 -0
- package/dist/dataset/types.d.ts +89 -0
- package/dist/dataset/types.js +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +18 -0
- package/dist/ingest/apply.d.ts +18 -0
- package/dist/ingest/apply.js +376 -0
- package/dist/ingest/cache.d.ts +231 -0
- package/dist/ingest/cache.js +70 -0
- package/dist/ingest/convert.d.ts +15 -0
- package/dist/ingest/convert.js +69 -0
- package/dist/ingest/index.d.ts +27 -0
- package/dist/ingest/index.js +122 -0
- package/dist/ingest/infer.d.ts +34 -0
- package/dist/ingest/infer.js +62 -0
- package/dist/ingest/mapping.d.ts +103 -0
- package/dist/ingest/mapping.js +78 -0
- package/dist/ingest/paths.d.ts +13 -0
- package/dist/ingest/paths.js +34 -0
- package/dist/ingest/shape.d.ts +20 -0
- package/dist/ingest/shape.js +79 -0
- package/dist/llm/client.d.ts +24 -0
- package/dist/llm/client.js +131 -0
- package/dist/llm/conversation.d.ts +11 -0
- package/dist/llm/conversation.js +32 -0
- package/dist/llm/providers.d.ts +112 -0
- package/dist/llm/providers.js +90 -0
- package/dist/llm/retry.d.ts +34 -0
- package/dist/llm/retry.js +55 -0
- package/dist/llm/tokens.d.ts +5 -0
- package/dist/llm/tokens.js +9 -0
- package/dist/llm/types.d.ts +107 -0
- package/dist/llm/types.js +1 -0
- package/dist/pipeline/analyze.d.ts +56 -0
- package/dist/pipeline/analyze.js +79 -0
- package/dist/pipeline/chunk.d.ts +89 -0
- package/dist/pipeline/chunk.js +237 -0
- package/dist/pipeline/merge.d.ts +41 -0
- package/dist/pipeline/merge.js +165 -0
- package/dist/pipeline/report.d.ts +28 -0
- package/dist/pipeline/report.js +157 -0
- package/dist/pipeline/summarize.d.ts +88 -0
- package/dist/pipeline/summarize.js +254 -0
- package/dist/store/tasks.d.ts +37 -0
- package/dist/store/tasks.js +69 -0
- package/dist/tools/approval.d.ts +18 -0
- package/dist/tools/approval.js +5 -0
- package/dist/tools/builtin/calculate.d.ts +2 -0
- package/dist/tools/builtin/calculate.js +35 -0
- package/dist/tools/builtin/command_policy.d.ts +7 -0
- package/dist/tools/builtin/command_policy.js +73 -0
- package/dist/tools/builtin/index.d.ts +12 -0
- package/dist/tools/builtin/index.js +14 -0
- package/dist/tools/builtin/read_file.d.ts +8 -0
- package/dist/tools/builtin/read_file.js +62 -0
- package/dist/tools/builtin/run_command.d.ts +7 -0
- package/dist/tools/builtin/run_command.js +109 -0
- package/dist/tools/builtin/write_file.d.ts +2 -0
- package/dist/tools/builtin/write_file.js +33 -0
- package/dist/tools/data/format.d.ts +24 -0
- package/dist/tools/data/format.js +67 -0
- package/dist/tools/data/get_dataset_stats.d.ts +6 -0
- package/dist/tools/data/get_dataset_stats.js +74 -0
- package/dist/tools/data/get_feedback.d.ts +4 -0
- package/dist/tools/data/get_feedback.js +57 -0
- package/dist/tools/data/index.d.ts +11 -0
- package/dist/tools/data/index.js +14 -0
- package/dist/tools/data/list_datasets.d.ts +3 -0
- package/dist/tools/data/list_datasets.js +21 -0
- package/dist/tools/data/search_feedback.d.ts +4 -0
- package/dist/tools/data/search_feedback.js +48 -0
- package/dist/tools/data/shared.d.ts +11 -0
- package/dist/tools/data/shared.js +21 -0
- package/dist/tools/define.d.ts +18 -0
- package/dist/tools/define.js +67 -0
- package/dist/tools/paths.d.ts +2 -0
- package/dist/tools/paths.js +8 -0
- package/dist/tools/types.d.ts +23 -0
- package/dist/tools/types.js +6 -0
- package/dist/watch/index.d.ts +65 -0
- package/dist/watch/index.js +173 -0
- package/dist/watch/pattern.d.ts +6 -0
- package/dist/watch/pattern.js +46 -0
- package/package.json +70 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MEI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,106 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ugc-lens
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
面向中文社媒用户互动内容(评论、回复、弹幕等)的轻量分析 Agent。给它一个搜集好的数据文件,生成带原文引用的 Markdown 分析报告,也可以继续追问。以 npm 包形式提供命令行和代码调用,MCP 服务后续提供。
|
|
4
|
+
|
|
5
|
+
> 状态:0.0.x 试用版。目前只支持 JSON 文件,接口和报告格式还可能调整。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
需要 Node.js 22.18 或更高版本。
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
npm install -g ugc-lens
|
|
13
|
+
# 或者不安装,直接用 npx ugc-lens ...
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 配置模型
|
|
17
|
+
|
|
18
|
+
最简单的方式:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
ugc-lens init
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
交互式地选提供商、从 `/models` 列表里选模型、填 API key 并发一次测试调用,写入全局配置 `~/.ugc-lens/config.json`(权限 600),所有目录下都能用。
|
|
25
|
+
|
|
26
|
+
也可以手动配置:在运行命令的目录里放一个 `.env`(或设置同名环境变量),选一个提供商并填它的 API key:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
UGC_LENS_PROVIDER=deepseek
|
|
30
|
+
UGC_LENS_MODEL=deepseek-flash
|
|
31
|
+
DEEPSEEK_API_KEY=你的 key
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
支持 DeepSeek、通义千问、Kimi、智谱、MiniMax、OpenAI、Claude、Gemini、Grok、OpenRouter 和本地模型,变量名和差异见 [模型提供商](docs/providers.md)。优先级:环境变量 > 当前目录的 `.env` / `.ugc-lens/config.json` > 全局配置。
|
|
35
|
+
|
|
36
|
+
## 使用
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
ugc-lens init # 初始化模型配置
|
|
40
|
+
|
|
41
|
+
# 分析一个文件:报告打印到标准输出,同时保存到 .ugc-lens/reports/
|
|
42
|
+
ugc-lens analyze bilibili_all.json > report.md
|
|
43
|
+
ugc-lens analyze bilibili_all.json --focus 价格,售后 # 只看这些方向
|
|
44
|
+
|
|
45
|
+
# 追问:回答里用 [ID] 标出依据,多个问题依次追问
|
|
46
|
+
ugc-lens ask bilibili_all.json "评论主要在吐槽什么?" "点赞最多的那条下面怎么回复的?"
|
|
47
|
+
|
|
48
|
+
# 监听目录:新出现的 *_all.json 写完后自动分析,每个文件只分析一次
|
|
49
|
+
ugc-lens watch ./data
|
|
50
|
+
ugc-lens watch ./data --pattern "*_all.json,*_comments.json" --existing
|
|
51
|
+
|
|
52
|
+
ugc-lens <命令> --help # 查看全部选项
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- 文件结构第一次由模型识别,规则缓存在 `.ugc-lens/mappings/`,同样结构的文件之后不再调用模型。
|
|
56
|
+
- 报告包括:概况、IP 属地、关键词、弹幕高峰、各方向(主要观点、吐槽点、正面评价、争议)的主题和原文引用,末尾是耗时和 token 用量。
|
|
57
|
+
- 统计、去重、关键词、引用校验都由代码完成,模型只负责分块总结和合并,一个 10 万条评论的文件大约用 6~7 万 token。
|
|
58
|
+
- `watch` 的任务状态存在 `.ugc-lens/tasks.json`,按 Ctrl+C 停止后再启动会继续没做完的任务。
|
|
59
|
+
|
|
60
|
+
## 代码调用
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { analyzeDataset, createChatClient, createLlmModel, loadDataset, loadLlmConfig } from 'ugc-lens'
|
|
64
|
+
|
|
65
|
+
const config = loadLlmConfig()
|
|
66
|
+
const model = createLlmModel(createChatClient(config), { temperature: 0, disableThinking: true })
|
|
67
|
+
const dataset = await loadDataset('bilibili_all.json', { model })
|
|
68
|
+
const result = await analyzeDataset({ model, dataset })
|
|
69
|
+
console.log(result.markdown)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
还导出了 `askDataset`(追问)、`watchDirectory`(监听目录)、`computeStats`、`searchFeedback` 等,类型声明随包提供。
|
|
73
|
+
|
|
74
|
+
## 文档
|
|
75
|
+
|
|
76
|
+
- [架构设计](docs/architecture.md):定位、分层、Agent 循环、工具规范、数据格式、分析流水线
|
|
77
|
+
- [模型提供商](docs/providers.md):支持的厂商、配置格式、各家差异
|
|
78
|
+
- [开发路线](docs/roadmap.md):按步骤实现,每一步的目标、要掌握的概念和完成标准
|
|
79
|
+
- [更新记录](docs/changelog/):每次更新一份,文件名为 `日期-大致功能.md`
|
|
80
|
+
- [项目指令](AGENTS.md):协作规则
|
|
81
|
+
|
|
82
|
+
## 开发
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
bun install # 安装依赖
|
|
86
|
+
npm run typecheck # 类型检查
|
|
87
|
+
npm test # 测试
|
|
88
|
+
npm run build # 编译到 dist/(发布用)
|
|
89
|
+
node src/cli/index.ts analyze <文件> # 开发时直接运行源码
|
|
90
|
+
npm run loop # 用假模型演示 Agent 循环
|
|
91
|
+
npm run tools # 真实模型 + 基础工具(需要 .env)
|
|
92
|
+
npm run params # 参数纠错实验(需要 .env)
|
|
93
|
+
npm run retry # 重试演示与 token 预算(budget 部分需要 .env)
|
|
94
|
+
npm run stats # 模型识别 JSON 文件结构,转换成统一格式并打印统计(需要 .env)
|
|
95
|
+
npm run ask # 用数据工具追问,回答带评论 ID 作为证据(需要 .env)
|
|
96
|
+
npm run chunks # 新词发现、关键词、弹幕高峰和分块结果,不调用模型做分析
|
|
97
|
+
npm run summarize # 分块总结:每块调用一次模型,打印各方向的观点、引用和 token 用量(需要 .env)
|
|
98
|
+
npm run analyze # 完整分析,Markdown 报告打印出来并保存到 .ugc-lens/reports/(需要 .env)
|
|
99
|
+
npm run watch -- <目录> # 监听目录,新出现的 *_all.json 写完后自动分析(需要 .env)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
发布:修改 `package.json` 的版本号并推送到 master,GitHub Actions(`release.yml`)会检查、编译并发布到 npm,然后打 `v版本号` 标签。
|
|
103
|
+
|
|
104
|
+
## 许可
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Usage } from '../llm/types.js';
|
|
2
|
+
export type RunEvent = {
|
|
3
|
+
type: 'run_start';
|
|
4
|
+
run_id: string;
|
|
5
|
+
kind: 'agent' | 'pipeline';
|
|
6
|
+
}
|
|
7
|
+
/** final 为 true 表示收尾调用 */
|
|
8
|
+
| {
|
|
9
|
+
type: 'model_start';
|
|
10
|
+
run_id: string;
|
|
11
|
+
step: number;
|
|
12
|
+
final: boolean;
|
|
13
|
+
} | {
|
|
14
|
+
type: 'model_delta';
|
|
15
|
+
run_id: string;
|
|
16
|
+
step: number;
|
|
17
|
+
text: string;
|
|
18
|
+
} | {
|
|
19
|
+
type: 'model_end';
|
|
20
|
+
run_id: string;
|
|
21
|
+
step: number;
|
|
22
|
+
usage: Usage;
|
|
23
|
+
tool_calls: number;
|
|
24
|
+
}
|
|
25
|
+
/** 模型接口暂时性错误,等待 delay_ms 后进行第 attempt 次重试 */
|
|
26
|
+
| {
|
|
27
|
+
type: 'model_retry';
|
|
28
|
+
run_id: string;
|
|
29
|
+
step: number;
|
|
30
|
+
attempt: number;
|
|
31
|
+
delay_ms: number;
|
|
32
|
+
reason: string;
|
|
33
|
+
} | {
|
|
34
|
+
type: 'tool_start';
|
|
35
|
+
run_id: string;
|
|
36
|
+
step: number;
|
|
37
|
+
call_id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
args: unknown;
|
|
40
|
+
} | {
|
|
41
|
+
type: 'tool_end';
|
|
42
|
+
run_id: string;
|
|
43
|
+
step: number;
|
|
44
|
+
call_id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
ok: boolean;
|
|
47
|
+
duration_ms: number;
|
|
48
|
+
} | {
|
|
49
|
+
type: 'pipeline_stage';
|
|
50
|
+
run_id: string;
|
|
51
|
+
stage: string;
|
|
52
|
+
progress?: number;
|
|
53
|
+
} | {
|
|
54
|
+
type: 'run_end';
|
|
55
|
+
run_id: string;
|
|
56
|
+
stop_reason: string;
|
|
57
|
+
usage: Usage;
|
|
58
|
+
duration_ms: number;
|
|
59
|
+
};
|
|
60
|
+
export type RunEventListener = (event: RunEvent) => void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ChatMessage, ToolChoice, ToolDefinition } from '../llm/types.js';
|
|
2
|
+
import type { AgentModel, AgentModelRequest } from './model.js';
|
|
3
|
+
export interface FakeToolCall {
|
|
4
|
+
name: string;
|
|
5
|
+
args?: unknown;
|
|
6
|
+
/** 直接指定 arguments 字符串,用来模拟模型给出不合法 JSON 的情况 */
|
|
7
|
+
raw_arguments?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface FakeReply {
|
|
10
|
+
content?: string;
|
|
11
|
+
tool_calls?: FakeToolCall[];
|
|
12
|
+
}
|
|
13
|
+
/** 数组:按调用顺序依次返回;函数:根据请求动态决定(call 从 1 开始) */
|
|
14
|
+
export type FakeScript = FakeReply[] | ((request: AgentModelRequest, call: number) => FakeReply);
|
|
15
|
+
/** 每次调用时请求内容的快照,测试用来检查循环发出了什么 */
|
|
16
|
+
export interface FakeRequestRecord {
|
|
17
|
+
messages: ChatMessage[];
|
|
18
|
+
tools: ToolDefinition[];
|
|
19
|
+
toolChoice: ToolChoice;
|
|
20
|
+
}
|
|
21
|
+
export interface FakeModel extends AgentModel {
|
|
22
|
+
readonly requests: readonly FakeRequestRecord[];
|
|
23
|
+
}
|
|
24
|
+
export declare function estimateTokens(text: string): number;
|
|
25
|
+
/**
|
|
26
|
+
* 按剧本返回结果的假模型。行为与真实模型的约束一致:
|
|
27
|
+
* tool_choice 为 none 时不能请求工具。
|
|
28
|
+
*/
|
|
29
|
+
export declare function createFakeModel(script: FakeScript, options?: {
|
|
30
|
+
delayMs?: number;
|
|
31
|
+
}): FakeModel;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
export function estimateTokens(text) {
|
|
3
|
+
return Math.ceil(text.length / 2);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* 按剧本返回结果的假模型。行为与真实模型的约束一致:
|
|
7
|
+
* tool_choice 为 none 时不能请求工具。
|
|
8
|
+
*/
|
|
9
|
+
export function createFakeModel(script, options = {}) {
|
|
10
|
+
const requests = [];
|
|
11
|
+
return {
|
|
12
|
+
requests,
|
|
13
|
+
async complete(request) {
|
|
14
|
+
const call = requests.length + 1;
|
|
15
|
+
requests.push({
|
|
16
|
+
messages: structuredClone([...request.messages]),
|
|
17
|
+
tools: structuredClone([...request.tools]),
|
|
18
|
+
toolChoice: request.toolChoice,
|
|
19
|
+
});
|
|
20
|
+
if (options.delayMs)
|
|
21
|
+
await sleep(options.delayMs, undefined, { signal: request.signal });
|
|
22
|
+
const reply = typeof script === 'function' ? script(request, call) : script[call - 1];
|
|
23
|
+
if (!reply)
|
|
24
|
+
throw new Error(`假模型剧本已用完:第 ${call} 次调用没有对应的回复`);
|
|
25
|
+
if (request.toolChoice === 'none' && reply.tool_calls?.length) {
|
|
26
|
+
throw new Error(`假模型剧本错误:第 ${call} 次调用的 tool_choice 为 none,却返回了工具调用`);
|
|
27
|
+
}
|
|
28
|
+
if (typeof request.toolChoice === 'object') {
|
|
29
|
+
const forced = request.toolChoice.function.name;
|
|
30
|
+
if (!reply.tool_calls?.length || reply.tool_calls.some((item) => item.name !== forced)) {
|
|
31
|
+
throw new Error(`假模型剧本错误:第 ${call} 次调用指定了工具 ${forced},返回的工具调用不符`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const toolCalls = (reply.tool_calls ?? []).map((item, index) => ({
|
|
35
|
+
id: `call_${call}_${index + 1}`,
|
|
36
|
+
type: 'function',
|
|
37
|
+
function: { name: item.name, arguments: item.raw_arguments ?? JSON.stringify(item.args ?? {}) },
|
|
38
|
+
}));
|
|
39
|
+
const message = { role: 'assistant', content: reply.content ?? null };
|
|
40
|
+
if (toolCalls.length > 0)
|
|
41
|
+
message.tool_calls = toolCalls;
|
|
42
|
+
return {
|
|
43
|
+
message,
|
|
44
|
+
finishReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
|
|
45
|
+
usage: {
|
|
46
|
+
input_tokens: estimateTokens(JSON.stringify(request.messages)),
|
|
47
|
+
output_tokens: estimateTokens(JSON.stringify(message)),
|
|
48
|
+
},
|
|
49
|
+
model: 'fake',
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ChatClient } from '../llm/client.js';
|
|
2
|
+
import type { AgentModel } from './model.js';
|
|
3
|
+
export interface LlmModelOptions {
|
|
4
|
+
temperature?: number;
|
|
5
|
+
maxTokens?: number;
|
|
6
|
+
disableThinking?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** 把模型客户端包装成 Agent 循环使用的 AgentModel */
|
|
9
|
+
export declare function createLlmModel(client: Pick<ChatClient, 'chat'>, options?: LlmModelOptions): AgentModel;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ChatMessage, Usage } from '../llm/types.js';
|
|
2
|
+
import { type Approver } from '../tools/approval.js';
|
|
3
|
+
import { type AgentTool } from '../tools/types.js';
|
|
4
|
+
import type { RunEventListener } from './events.js';
|
|
5
|
+
import type { AgentModel } from './model.js';
|
|
6
|
+
export type StopReason = 'completed' | 'max_steps' | 'token_budget' | 'timeout' | 'aborted' | 'tool_errors';
|
|
7
|
+
export interface AgentLimits {
|
|
8
|
+
maxSteps: number;
|
|
9
|
+
/** 输入加输出的累计 token */
|
|
10
|
+
tokenBudget: number;
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
/** 连续多少步的工具调用全部失败后停止 */
|
|
13
|
+
maxConsecutiveToolErrors: number;
|
|
14
|
+
}
|
|
15
|
+
export declare const DEFAULT_LIMITS: AgentLimits;
|
|
16
|
+
export interface ToolCallRecord {
|
|
17
|
+
call_id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
args: unknown;
|
|
20
|
+
ok: boolean;
|
|
21
|
+
result: string;
|
|
22
|
+
duration_ms: number;
|
|
23
|
+
}
|
|
24
|
+
export interface AgentStep {
|
|
25
|
+
step: number;
|
|
26
|
+
/** 收尾调用 */
|
|
27
|
+
final: boolean;
|
|
28
|
+
content: string | null;
|
|
29
|
+
tool_calls: ToolCallRecord[];
|
|
30
|
+
usage: Usage;
|
|
31
|
+
}
|
|
32
|
+
export interface AgentRunResult {
|
|
33
|
+
run_id: string;
|
|
34
|
+
answer: string;
|
|
35
|
+
stop_reason: StopReason;
|
|
36
|
+
steps: AgentStep[];
|
|
37
|
+
/** 运行结束时的完整历史 */
|
|
38
|
+
messages: ChatMessage[];
|
|
39
|
+
usage: Usage;
|
|
40
|
+
duration_ms: number;
|
|
41
|
+
}
|
|
42
|
+
export interface RunAgentOptions {
|
|
43
|
+
model: AgentModel;
|
|
44
|
+
/** 初始消息,通常是 system + user。不会被修改 */
|
|
45
|
+
messages: readonly ChatMessage[];
|
|
46
|
+
tools?: readonly AgentTool[];
|
|
47
|
+
limits?: Partial<AgentLimits>;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
onEvent?: RunEventListener;
|
|
50
|
+
/** 工具解析相对路径、执行命令的目录,默认 process.cwd() */
|
|
51
|
+
cwd?: string;
|
|
52
|
+
/** 有副作用的工具执行前的确认函数,默认一律拒绝 */
|
|
53
|
+
approve?: Approver;
|
|
54
|
+
}
|
|
55
|
+
export declare function finalizeNotice(reason: StopReason): string;
|
|
56
|
+
export declare function runAgent(options: RunAgentOptions): Promise<AgentRunResult>;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { denyAll } from '../tools/approval.js';
|
|
3
|
+
import { ToolArgumentError } from '../tools/define.js';
|
|
4
|
+
import { toToolDefinition } from '../tools/types.js';
|
|
5
|
+
export const DEFAULT_LIMITS = {
|
|
6
|
+
maxSteps: 10,
|
|
7
|
+
tokenBudget: 100_000,
|
|
8
|
+
timeoutMs: 5 * 60_000,
|
|
9
|
+
maxConsecutiveToolErrors: 3,
|
|
10
|
+
};
|
|
11
|
+
const FINALIZE_LABELS = {
|
|
12
|
+
max_steps: '最大步数',
|
|
13
|
+
token_budget: 'token 预算',
|
|
14
|
+
tool_errors: '连续工具错误上限',
|
|
15
|
+
};
|
|
16
|
+
export function finalizeNotice(reason) {
|
|
17
|
+
const label = FINALIZE_LABELS[reason] ?? reason;
|
|
18
|
+
return `【系统说明】已达到${label},不能再调用工具。请基于以上已有信息直接回答用户的问题,并说明哪些信息还不足。`;
|
|
19
|
+
}
|
|
20
|
+
export async function runAgent(options) {
|
|
21
|
+
const limits = { ...DEFAULT_LIMITS, ...options.limits };
|
|
22
|
+
const run_id = randomUUID();
|
|
23
|
+
const startedAt = Date.now();
|
|
24
|
+
const emit = options.onEvent ?? (() => { });
|
|
25
|
+
const messages = [...options.messages];
|
|
26
|
+
const tools = options.tools ?? [];
|
|
27
|
+
const toolMap = new Map(tools.map((tool) => [tool.name, tool]));
|
|
28
|
+
const definitions = tools.map(toToolDefinition);
|
|
29
|
+
const deadline = AbortSignal.timeout(limits.timeoutMs);
|
|
30
|
+
const signal = options.signal ? AbortSignal.any([options.signal, deadline]) : deadline;
|
|
31
|
+
const toolContext = {
|
|
32
|
+
run_id,
|
|
33
|
+
signal,
|
|
34
|
+
cwd: options.cwd ?? process.cwd(),
|
|
35
|
+
approve: options.approve ?? denyAll,
|
|
36
|
+
};
|
|
37
|
+
const steps = [];
|
|
38
|
+
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
39
|
+
let consecutiveToolErrors = 0;
|
|
40
|
+
let answer = '';
|
|
41
|
+
let stopReason;
|
|
42
|
+
const abortReason = () => {
|
|
43
|
+
if (options.signal?.aborted)
|
|
44
|
+
return 'aborted';
|
|
45
|
+
if (deadline.aborted)
|
|
46
|
+
return 'timeout';
|
|
47
|
+
return undefined;
|
|
48
|
+
};
|
|
49
|
+
// 停止检查放在工具执行之后、下一次调用模型之前
|
|
50
|
+
const limitReason = (step) => {
|
|
51
|
+
if (usage.input_tokens + usage.output_tokens >= limits.tokenBudget)
|
|
52
|
+
return 'token_budget';
|
|
53
|
+
if (consecutiveToolErrors >= limits.maxConsecutiveToolErrors)
|
|
54
|
+
return 'tool_errors';
|
|
55
|
+
if (step > limits.maxSteps)
|
|
56
|
+
return 'max_steps';
|
|
57
|
+
return undefined;
|
|
58
|
+
};
|
|
59
|
+
const callModel = async (step, toolChoice, final) => {
|
|
60
|
+
emit({ type: 'model_start', run_id, step, final });
|
|
61
|
+
const result = await options.model.complete({
|
|
62
|
+
messages,
|
|
63
|
+
tools: definitions,
|
|
64
|
+
toolChoice,
|
|
65
|
+
signal,
|
|
66
|
+
onRetry: ({ attempt, delayMs, error }) => emit({ type: 'model_retry', run_id, step, attempt, delay_ms: delayMs, reason: error.message.slice(0, 200) }),
|
|
67
|
+
});
|
|
68
|
+
usage.input_tokens += result.usage.input_tokens;
|
|
69
|
+
usage.output_tokens += result.usage.output_tokens;
|
|
70
|
+
emit({
|
|
71
|
+
type: 'model_end',
|
|
72
|
+
run_id,
|
|
73
|
+
step,
|
|
74
|
+
usage: result.usage,
|
|
75
|
+
tool_calls: result.message.tool_calls?.length ?? 0,
|
|
76
|
+
});
|
|
77
|
+
return result;
|
|
78
|
+
};
|
|
79
|
+
emit({ type: 'run_start', run_id, kind: 'agent' });
|
|
80
|
+
for (let step = 1;; step++) {
|
|
81
|
+
stopReason = abortReason() ?? limitReason(step);
|
|
82
|
+
if (stopReason)
|
|
83
|
+
break;
|
|
84
|
+
let result;
|
|
85
|
+
try {
|
|
86
|
+
result = await callModel(step, 'auto', false);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
stopReason = abortReason();
|
|
90
|
+
if (stopReason)
|
|
91
|
+
break;
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
messages.push(result.message);
|
|
95
|
+
const calls = result.message.tool_calls ?? [];
|
|
96
|
+
const record = {
|
|
97
|
+
step,
|
|
98
|
+
final: false,
|
|
99
|
+
content: result.message.content,
|
|
100
|
+
tool_calls: [],
|
|
101
|
+
usage: result.usage,
|
|
102
|
+
};
|
|
103
|
+
steps.push(record);
|
|
104
|
+
if (calls.length === 0) {
|
|
105
|
+
answer = result.message.content ?? '';
|
|
106
|
+
stopReason = 'completed';
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
let failed = 0;
|
|
110
|
+
for (const call of calls) {
|
|
111
|
+
const callRecord = await executeToolCall(call, step, toolMap, toolContext, emit);
|
|
112
|
+
record.tool_calls.push(callRecord);
|
|
113
|
+
// 每个 tool_call 都必须有对应的 tool 消息,出错也一样
|
|
114
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content: callRecord.result });
|
|
115
|
+
if (!callRecord.ok)
|
|
116
|
+
failed++;
|
|
117
|
+
}
|
|
118
|
+
consecutiveToolErrors = failed === calls.length ? consecutiveToolErrors + 1 : 0;
|
|
119
|
+
}
|
|
120
|
+
if (FINALIZE_LABELS[stopReason] !== undefined) {
|
|
121
|
+
answer = await finalize(stopReason);
|
|
122
|
+
}
|
|
123
|
+
const duration_ms = Date.now() - startedAt;
|
|
124
|
+
emit({ type: 'run_end', run_id, stop_reason: stopReason, usage: { ...usage }, duration_ms });
|
|
125
|
+
return { run_id, answer, stop_reason: stopReason, steps, messages, usage, duration_ms };
|
|
126
|
+
async function finalize(reason) {
|
|
127
|
+
const last = messages.at(-1);
|
|
128
|
+
// 这条工具结果还没发给模型,附加说明不算修改历史,也不需要伪造一条 user 消息
|
|
129
|
+
if (last?.role === 'tool') {
|
|
130
|
+
messages[messages.length - 1] = { ...last, content: `${last.content}\n\n${finalizeNotice(reason)}` };
|
|
131
|
+
}
|
|
132
|
+
const step = steps.length + 1;
|
|
133
|
+
let result;
|
|
134
|
+
try {
|
|
135
|
+
result = await callModel(step, 'none', true);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (abortReason())
|
|
139
|
+
return '';
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
messages.push(result.message);
|
|
143
|
+
steps.push({ step, final: true, content: result.message.content, tool_calls: [], usage: result.usage });
|
|
144
|
+
return result.message.content ?? '';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function executeToolCall(call, step, toolMap, ctx, emit) {
|
|
148
|
+
const startedAt = Date.now();
|
|
149
|
+
const { name, arguments: rawArgs } = call.function;
|
|
150
|
+
let args = rawArgs;
|
|
151
|
+
let ok = false;
|
|
152
|
+
let result;
|
|
153
|
+
const tool = toolMap.get(name);
|
|
154
|
+
const parsed = parseArguments(rawArgs);
|
|
155
|
+
if (parsed.ok)
|
|
156
|
+
args = parsed.value;
|
|
157
|
+
emit({ type: 'tool_start', run_id: ctx.run_id, step, call_id: call.id, name, args });
|
|
158
|
+
if (!tool) {
|
|
159
|
+
const available = [...toolMap.keys()].join(', ') || '无';
|
|
160
|
+
result = `错误:不存在名为 "${name}" 的工具。可用工具:${available}`;
|
|
161
|
+
}
|
|
162
|
+
else if (!parsed.ok) {
|
|
163
|
+
result = `错误:${parsed.error}`;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
try {
|
|
167
|
+
result = await tool.execute(parsed.value, ctx);
|
|
168
|
+
ok = true;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
result =
|
|
172
|
+
error instanceof ToolArgumentError
|
|
173
|
+
? `错误:工具 ${name} 的参数不正确。\n${error.message}`
|
|
174
|
+
: `错误:工具 ${name} 执行失败:${error.message}`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const duration_ms = Date.now() - startedAt;
|
|
178
|
+
emit({ type: 'tool_end', run_id: ctx.run_id, step, call_id: call.id, name, ok, duration_ms });
|
|
179
|
+
return { call_id: call.id, name, args, ok, result, duration_ms };
|
|
180
|
+
}
|
|
181
|
+
function parseArguments(raw) {
|
|
182
|
+
let value;
|
|
183
|
+
try {
|
|
184
|
+
value = JSON.parse(raw.trim() === '' ? '{}' : raw);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return { ok: false, error: `参数不是合法的 JSON:${raw}` };
|
|
188
|
+
}
|
|
189
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
190
|
+
return { ok: false, error: `参数必须是 JSON 对象:${raw}` };
|
|
191
|
+
}
|
|
192
|
+
return { ok: true, value: value };
|
|
193
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RetryInfo } from '../llm/retry.js';
|
|
2
|
+
import type { ChatMessage, ChatResult, ToolChoice, ToolDefinition } from '../llm/types.js';
|
|
3
|
+
export interface AgentModelRequest {
|
|
4
|
+
messages: readonly ChatMessage[];
|
|
5
|
+
tools: readonly ToolDefinition[];
|
|
6
|
+
toolChoice: ToolChoice;
|
|
7
|
+
signal: AbortSignal;
|
|
8
|
+
/** 模型接口重试前调用,循环用它发出 model_retry 事件 */
|
|
9
|
+
onRetry?: (info: RetryInfo) => void;
|
|
10
|
+
}
|
|
11
|
+
/** Agent 循环只依赖这个接口,假模型和真实模型都实现它 */
|
|
12
|
+
export interface AgentModel {
|
|
13
|
+
complete(request: AgentModelRequest): Promise<ChatResult>;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { FeedbackItem } from '../dataset/types.js';
|
|
2
|
+
import { type DistinctiveTerm, type TermStat, type Tokenizer } from './keywords.js';
|
|
3
|
+
import { type Peak } from './timeline.js';
|
|
4
|
+
/** 说的是同一句话的弹幕 */
|
|
5
|
+
export interface DanmakuGroup {
|
|
6
|
+
/** 第一次出现的那条的原文 */
|
|
7
|
+
text: string;
|
|
8
|
+
count: number;
|
|
9
|
+
/** 第一条的 ID,引用时用它 */
|
|
10
|
+
id: string;
|
|
11
|
+
/** 第一次出现的视频时间 */
|
|
12
|
+
first_sec?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface DanmakuPeak extends Peak {
|
|
15
|
+
/** 这个时段里重复最多的弹幕 */
|
|
16
|
+
groups: DanmakuGroup[];
|
|
17
|
+
/** 这个时段相对其他时段特有的词 */
|
|
18
|
+
terms: DistinctiveTerm[];
|
|
19
|
+
}
|
|
20
|
+
export interface DanmakuSummary {
|
|
21
|
+
total: number;
|
|
22
|
+
/** 去重后的条数 */
|
|
23
|
+
unique: number;
|
|
24
|
+
/** 按重复次数从多到少 */
|
|
25
|
+
groups: DanmakuGroup[];
|
|
26
|
+
bin_sec: number;
|
|
27
|
+
/** 每段的弹幕数 */
|
|
28
|
+
timeline: number[];
|
|
29
|
+
peaks: DanmakuPeak[];
|
|
30
|
+
terms: TermStat[];
|
|
31
|
+
}
|
|
32
|
+
export declare function groupDanmaku(items: readonly FeedbackItem[]): DanmakuGroup[];
|
|
33
|
+
export interface DanmakuOptions {
|
|
34
|
+
binSec?: number;
|
|
35
|
+
peaks?: number;
|
|
36
|
+
groupsPerPeak?: number;
|
|
37
|
+
terms?: number;
|
|
38
|
+
}
|
|
39
|
+
export declare function summarizeDanmaku(items: readonly FeedbackItem[], tokenizer: Tokenizer, options?: DanmakuOptions): DanmakuSummary;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { countTerms, distinctiveTerms, topTerms } from './keywords.js';
|
|
2
|
+
import { dedupeKey } from './text.js';
|
|
3
|
+
import { binCounts, chooseBinSeconds, detectPeaks } from './timeline.js';
|
|
4
|
+
export function groupDanmaku(items) {
|
|
5
|
+
const groups = new Map();
|
|
6
|
+
for (const item of items) {
|
|
7
|
+
const key = dedupeKey(item.text);
|
|
8
|
+
if (!key)
|
|
9
|
+
continue;
|
|
10
|
+
const group = groups.get(key);
|
|
11
|
+
if (group) {
|
|
12
|
+
group.count++;
|
|
13
|
+
if (item.video_time_sec !== undefined && (group.first_sec === undefined || item.video_time_sec < group.first_sec)) {
|
|
14
|
+
group.first_sec = item.video_time_sec;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
groups.set(key, { text: item.text, count: 1, id: item.id, first_sec: item.video_time_sec });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return [...groups.values()].sort((a, b) => b.count - a.count || (a.first_sec ?? 0) - (b.first_sec ?? 0));
|
|
22
|
+
}
|
|
23
|
+
export function summarizeDanmaku(items, tokenizer, options = {}) {
|
|
24
|
+
const danmaku = items.filter((item) => item.kind === 'danmaku');
|
|
25
|
+
const groups = groupDanmaku(danmaku);
|
|
26
|
+
const timed = danmaku.filter((item) => item.video_time_sec !== undefined);
|
|
27
|
+
const duration = Math.max(0, ...timed.map((item) => item.video_time_sec));
|
|
28
|
+
const binSec = options.binSec ?? chooseBinSeconds(duration);
|
|
29
|
+
const timeline = binCounts(timed.map((item) => item.video_time_sec), binSec);
|
|
30
|
+
const docs = (list) => list.map((item) => ({ id: item.id, text: item.text }));
|
|
31
|
+
const peaks = detectPeaks(timeline, binSec, { limit: options.peaks ?? 5 }).map((peak) => {
|
|
32
|
+
const inside = (item) => item.video_time_sec >= peak.start_sec && item.video_time_sec < peak.end_sec;
|
|
33
|
+
const within = timed.filter(inside);
|
|
34
|
+
const outside = timed.filter((item) => !inside(item));
|
|
35
|
+
return {
|
|
36
|
+
...peak,
|
|
37
|
+
groups: groupDanmaku(within).slice(0, options.groupsPerPeak ?? 8),
|
|
38
|
+
terms: distinctiveTerms(countTerms(docs(within), tokenizer), countTerms(docs(outside), tokenizer), { limit: options.terms ?? 8 }),
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
total: danmaku.length,
|
|
43
|
+
unique: groups.length,
|
|
44
|
+
groups,
|
|
45
|
+
bin_sec: binSec,
|
|
46
|
+
timeline,
|
|
47
|
+
peaks,
|
|
48
|
+
terms: topTerms(countTerms(docs(danmaku), tokenizer), options.terms ?? 20),
|
|
49
|
+
};
|
|
50
|
+
}
|