ugc-lens 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +44 -4
  2. package/dist/agent/loop.js +1 -1
  3. package/dist/ask/index.js +2 -2
  4. package/dist/chat/session.js +4 -4
  5. package/dist/cli/analyze.js +2 -2
  6. package/dist/cli/ask.js +2 -2
  7. package/dist/cli/common.d.ts +4 -0
  8. package/dist/cli/common.js +3 -0
  9. package/dist/cli/index.js +1 -1
  10. package/dist/dataset/types.d.ts +6 -2
  11. package/dist/dataset/types.js +1 -1
  12. package/dist/index.d.ts +3 -2
  13. package/dist/index.js +2 -1
  14. package/dist/ingest/cache.d.ts +6 -0
  15. package/dist/ingest/errors.d.ts +7 -0
  16. package/dist/ingest/errors.js +9 -0
  17. package/dist/ingest/formats.d.ts +18 -0
  18. package/dist/ingest/formats.js +221 -0
  19. package/dist/ingest/index.d.ts +3 -8
  20. package/dist/ingest/index.js +26 -41
  21. package/dist/ingest/infer.js +7 -5
  22. package/dist/ingest/mapping.d.ts +3 -0
  23. package/dist/ingest/mapping.js +1 -1
  24. package/dist/mcp/server.js +7 -7
  25. package/dist/pipeline/analyze.js +2 -2
  26. package/dist/pipeline/chunk.js +5 -5
  27. package/dist/pipeline/report.js +11 -9
  28. package/dist/pipeline/summarize.d.ts +2 -0
  29. package/dist/pipeline/summarize.js +8 -4
  30. package/dist/sentiment/index.d.ts +98 -0
  31. package/dist/sentiment/index.js +158 -0
  32. package/dist/tools/data/analyze_sentiment.d.ts +3 -0
  33. package/dist/tools/data/analyze_sentiment.js +33 -0
  34. package/dist/tools/data/format.js +1 -0
  35. package/dist/tools/data/get_dataset_stats.js +4 -4
  36. package/dist/tools/data/index.d.ts +1 -1
  37. package/dist/tools/data/index.js +3 -1
  38. package/dist/tools/data/shared.d.ts +2 -0
  39. package/package.json +2 -2
  40. package/playbook/default.md +4 -2
  41. package/skills/ugc-lens/SKILL.md +9 -2
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # ugc-lens
2
2
 
3
- 面向中文社媒用户互动内容(评论、回复、弹幕等)的轻量分析 Agent。直接启动后用自然语言描述目标,它会选择工具导入数据、生成带原文引用的报告、连续追问或监听目录。以 npm 包形式提供命令行和代码调用,也提供 stdio MCP 服务供宿主调用。
3
+ 面向中文文本记录和社媒用户互动内容(问卷、工单、访谈、评论、回复、弹幕等)的轻量分析 Agent。直接启动后用自然语言描述目标,它会选择工具导入数据、生成带原文引用的报告、连续追问或监听目录。以 npm 包形式提供命令行和代码调用,也提供 stdio MCP 服务供宿主调用。
4
4
 
5
- > 状态:0.0.x 试用版。目前只支持 JSON 文件,接口和报告格式还可能调整。
5
+ > 状态:0.0.x 试用版。支持 JSON、JSONL/NDJSON、CSV/TSV、TXT、Markdown 和日志文本,接口和报告格式还可能调整。
6
6
 
7
7
  ## 安装
8
8
 
@@ -81,6 +81,35 @@ ugc-lens <命令> --help # 查看全部选项
81
81
  - 统计、去重、关键词、引用校验都由代码完成,模型只负责分块总结和合并,一个 10 万条评论的文件大约用 6~7 万 token。
82
82
  - `watch` 的任务状态存在 `.ugc-lens/tasks.json`,按 Ctrl+C 停止后再启动会继续没做完的任务。
83
83
 
84
+ ## 输入格式
85
+
86
+ 所有入口共用导入器:命令行、持续对话、MCP 和代码 API 都支持下列格式。
87
+
88
+ | 格式 | 读取方式 |
89
+ |---|---|
90
+ | JSON | 保留原有任意结构识别;字符串数组可直接导入 |
91
+ | JSONL / NDJSON | 每个非空行一个 JSON 对象或字符串,对象可以包含嵌套字段;同一文件保持类型一致 |
92
+ | CSV | 首个非空记录为表头,支持逗号或分号分隔、引号内换行和双引号转义 |
93
+ | TSV | 首个非空记录为表头,制表符分隔,支持带引号的字段 |
94
+ | TXT / LOG | 每个非空行作为一条记录,ID 对应原始行号,例如 `line_3` |
95
+ | MD / Markdown | 用空行分隔段落,保留段内换行,ID 对应段落起始行 |
96
+
97
+ CSV/TSV 列由模型识别,规则会缓存;列名或顺序变化后重新识别。前导零 ID、日期等单元格保留为字符串,再按映射转换已知元数据字段。表头需要唯一且非空,损坏的 JSONL 行、CSV 引号或列数会明确报错。TXT、LOG、Markdown 和字符串数组用内置映射,无需额外调用模型识别格式;生成报告和问答仍使用配置的模型。
98
+
99
+ 通用数据使用 `record` 类型,可分析商品评价、问卷开放题、客服工单、访谈等。报告使用“文本记录”等名称,默认方向为主要主题、反复出现的问题、需求与建议、差异与分歧,也可用 `--focus` 指定方向。短答案如“好”“无”会保留,不套用评论的低信息过滤。长记录也不采用评论的 500 字截断;超过分块软上限时独立成块,仍受 `--chars` 总预算限制,放不下的记录会体现在报告覆盖率里。
100
+
101
+ ```bash
102
+ ugc-lens analyze survey.csv --focus 主要问题,改进建议
103
+ ugc-lens analyze feedback.jsonl
104
+ ugc-lens ask notes.txt "有哪些反复出现的问题?"
105
+ ugc-lens analyze interviews.md
106
+ ugc-lens watch ./data --pattern "*.jsonl,*.ndjson,*.csv,*.tsv,*.txt,*.md,*.log"
107
+ # 在源码目录测试,无需构建:
108
+ node src/cli/index.ts analyze survey.csv
109
+ ```
110
+
111
+ 监听默认仍只匹配 `*_all.json`;其他格式通过 `--pattern` 指定。文件上限 100 MB,支持 UTF-8(含 BOM)和带 BOM 的 UTF-16 LE/BE。GBK 等编码需先转为 UTF-8;Excel 工作簿、PDF、Word 等二进制文件不属于此次支持范围。CSV 主要用于文本记录,暂不提供数值表格分析或执行公式。
112
+
84
113
  ## 分析手册与经验
85
114
 
86
115
  分析规则放在 Markdown 手册里,按“项目 `.ugc-lens/playbook.md` → 全局 `~/.ugc-lens/playbook.md` → 包内默认”选择。`init` 首次创建全局副本,已有内容会保留。
@@ -101,12 +130,22 @@ ugc-lens playbook --reject <ID> # 丢弃待审修改
101
130
 
102
131
  代码 API 默认使用包内规则,不写经验文件。传入 `memory: { cwd: process.cwd() }` 启用项目规则和记录;也可将 `loadPlaybook(...)` 返回的快照通过 `playbook` 传给 `askDataset` / `analyzeDataset`。
103
132
 
133
+ ## 情感分析与采样
134
+
135
+ 在持续对话或 `ask` 中直接说“分析这份数据的情感分数和正负面比例”,模型会调用 `analyze_sentiment`。它分批让 AI 对每条原文给出 -1~1 的分数及理由,再由代码汇总,最后让对话模型解读。可指定评价对象、`kinds` 和 `content_id`;不指定对象时评估文本整体情绪,情绪不等于对某个选择的支持/反对。
136
+
137
+ 每条记录等权,不按点赞加权。分数 >0.2 为正面、<-0.2 为负面,其余中性;无法判断单独计数。比例以已评估条数(含无法判断)为分母,均值仅使用有效数值;同时返回导入样本内的实际评估覆盖率。完整逐条评分保存在当前工作目录 `.ugc-lens/sentiment/*.json`。默认独立 token 预算 30000,达到预算后返回已完成部分;预算为软上限,失败、遗漏和超长未处理项均不会算作中性。此用量在工具结果中单独返回,不包含在外层对话的 token 统计中。当前 `analyze` 固定报告流水线不会自动增加情感调用。
138
+
139
+ 采样数据可以分析样本内主题和情绪,搜集占平台全量的比例并不等于采样完成率。缺少采样设计元数据时,不推测置信度或整体误差保证,也不把样本内比例说成平台全量比例。默认手册已更新;已有项目或全局 `playbook.md` 保留原文,需要手动同步上述采样和情感规则,新对话才会加载。
140
+
141
+ 代码调用可使用 `analyzeSentiment({ dataset, model, target?, kinds?, contentId?, tokenBudget? })`,返回完整逐条评分和汇总,不自动写文件。
142
+
104
143
  ## MCP 接入
105
144
 
106
145
  先用 `ugc-lens init` 配置模型。MCP 由宿主启动,不需要自己开一个服务终端。Claude Code 中可以添加:
107
146
 
108
147
  ```bash
109
- claude mcp add --transport stdio ugc-lens -- npx -y ugc-lens@0.0.5 mcp --cwd /绝对路径/数据项目
148
+ claude mcp add --transport stdio ugc-lens -- npx -y ugc-lens@0.0.6 mcp --cwd /绝对路径/数据项目
110
149
  ```
111
150
 
112
151
  测试本地源码时:
@@ -124,7 +163,7 @@ claude mcp add --transport stdio ugc-lens -- node /绝对路径/ugc-lens/src/cli
124
163
  "mcpServers": {
125
164
  "ugc-lens": {
126
165
  "command": "npx",
127
- "args": ["-y", "ugc-lens@0.0.5", "mcp", "--cwd", "/绝对路径/数据项目"]
166
+ "args": ["-y", "ugc-lens@0.0.6", "mcp", "--cwd", "/绝对路径/数据项目"]
128
167
  }
129
168
  }
130
169
  }
@@ -137,6 +176,7 @@ claude mcp add --transport stdio ugc-lens -- node /绝对路径/ugc-lens/src/cli
137
176
  | `get_dataset_stats` | 查看数据概况 |
138
177
  | `search_feedback` | 按关键词、类型、点赞等筛选并分页 |
139
178
  | `get_feedback` | 查看全文和回复链 |
179
+ | `analyze_sentiment` | 分批逐条情感评分,返回汇总、示例和完整结果路径 |
140
180
  | `analyze` | 自动导入并生成完整报告,返回正文和保存路径 |
141
181
  | `ask` | 调用配置的模型回答,返回引用核对结果及追问 ID |
142
182
 
@@ -15,7 +15,7 @@ const FINALIZE_LABELS = {
15
15
  };
16
16
  export function finalizeNotice(reason) {
17
17
  const label = FINALIZE_LABELS[reason] ?? reason;
18
- return `【系统说明】已达到${label},不能再调用工具。请基于以上已有信息直接回答用户的问题,并说明哪些信息还不足。`;
18
+ return `【系统说明】已达到${label},不能再调用工具。请基于已核对的信息回答,并说明尚未完成的分析。运行限制不代表源数据缺失或采样无效;只说明影响当前结论的具体限制,不机械生成“数据不足”。`;
19
19
  }
20
20
  export async function runAgent(options) {
21
21
  const limits = { ...DEFAULT_LIMITS, ...options.limits };
package/dist/ask/index.js CHANGED
@@ -6,7 +6,7 @@ import { createMemoryTools } from '../memory/tools.js';
6
6
  import { recordOutcome } from '../memory/learnings.js';
7
7
  import { createDataTools } from '../tools/data/index.js';
8
8
  export function buildAskPrompt(playbook) {
9
- return `你是用户互动内容(评论、回复、弹幕、回答)的分析助手。用户已经导入了数据集,你通过工具查询数据来回答问题。
9
+ return `你是文本记录与用户互动内容(问卷、工单、访谈、评论、回复等)的分析助手。用户已经导入了数据集,你通过工具查询数据来回答问题。
10
10
 
11
11
  ${playbookRules(playbook, 'ask')}
12
12
 
@@ -22,7 +22,7 @@ export async function askDataset(options) {
22
22
  const run = await runAgent({
23
23
  model: options.model,
24
24
  messages: [...history, { role: 'user', content: options.question }],
25
- tools: [...createDataTools(store, options.tools), ...(options.memory ? createMemoryTools(store, options.memory, playbook) : [])],
25
+ tools: [...createDataTools(store, { ...options.tools, sentimentModel: options.tools?.sentimentModel ?? options.model }), ...(options.memory ? createMemoryTools(store, options.memory, playbook) : [])],
26
26
  limits: { ...ASK_LIMITS, ...options.limits },
27
27
  signal: options.signal,
28
28
  onEvent: options.onEvent,
@@ -15,7 +15,7 @@ import { resolveDataset } from '../tools/data/shared.js';
15
15
  import { defineTool } from '../tools/define.js';
16
16
  import { watchDirectory } from '../watch/index.js';
17
17
  const SESSION_ACTIONS = `你也负责完成用户的操作目标。用户不需要选择子命令:
18
- - 可以先用 list_files 查找 JSON 数据;位置或目标不明确才询问,不擅自批量分析所有文件。
18
+ - 可以先用 list_files 查找 支持格式的数据;位置或目标不明确才询问,不擅自批量分析所有文件。
19
19
  - load_dataset 导入后直接用数据查询工具回答,后续追问复用数据。多个数据集时指定 dataset_id。
20
20
  - 需要完整报告时用 analyze_dataset,可直接提供 file;报告由固定流水线生成,说明保存位置。
21
21
  - 用户要求持续监听时用 start_watch,之后仍能对话;用 watch_status 查进度,stop_watch 停止。
@@ -60,7 +60,7 @@ export function createSession(options) {
60
60
  active = undefined;
61
61
  };
62
62
  const tools = [
63
- ...createDataTools(store),
63
+ ...createDataTools(store, { sentimentModel: model }),
64
64
  ...(options.memory ? createMemoryTools(store, options.memory, playbook) : []),
65
65
  defineTool({
66
66
  name: 'list_files',
@@ -73,7 +73,7 @@ export function createSession(options) {
73
73
  },
74
74
  }),
75
75
  defineTool({
76
- name: 'load_dataset', description: '导入用户指定的 JSON 数据文件,返回数据集 ID,之后用数据工具查询并连续追问。',
76
+ name: 'load_dataset', description: '导入用户指定的 JSON/JSONL/CSV/TSV/TXT/Markdown/LOG 数据文件,返回数据集 ID,之后用数据工具查询并连续追问。',
77
77
  parameters: z.object({ file: z.string().min(1) }),
78
78
  async execute({ file }, ctx) {
79
79
  const dataset = await importFile(file, ctx.signal);
@@ -90,7 +90,7 @@ export function createSession(options) {
90
90
  },
91
91
  }),
92
92
  defineTool({
93
- name: 'start_watch', description: '后台监听目录,新 JSON 文件写入完成后生成报告;不阻塞后续对话。默认只处理新增 *_all.json。一次只监听一个目录。',
93
+ name: 'start_watch', description: '后台监听目录,新匹配文件写入完成后生成报告;不阻塞后续对话。默认只处理新增 *_all.json。一次只监听一个目录。',
94
94
  parameters: z.object({ dir: z.string().min(1), existing: z.boolean().default(false), patterns: z.array(z.string().min(1)).min(1).optional(), directions: z.array(z.string().min(1)).min(1).optional() }),
95
95
  async execute({ dir, existing, patterns, directions }, ctx) {
96
96
  ctx.signal.throwIfAborted();
@@ -3,7 +3,7 @@ import { parseArgs } from 'node:util';
3
3
  import { loadLlmConfig } from '../config.js';
4
4
  import { loadDataset } from '../ingest/index.js';
5
5
  import { analyzeDataset } from '../pipeline/analyze.js';
6
- import { abortOnInterrupt, analysisModel, formatAnalyzeProgress, positiveNumber, splitList, UsageError } from './common.js';
6
+ import { mappingSource, abortOnInterrupt, analysisModel, formatAnalyzeProgress, positiveNumber, splitList, UsageError } from './common.js';
7
7
  export const ANALYZE_USAGE = `用法:ugc-lens analyze <文件> [选项]
8
8
 
9
9
  分析一个数据文件,Markdown 报告打印到标准输出,并保存到 .ugc-lens/reports/。
@@ -45,7 +45,7 @@ export async function runAnalyze(argv) {
45
45
  const model = analysisModel(config);
46
46
  const { signal } = abortOnInterrupt('正在取消');
47
47
  const dataset = await loadDataset(args.file, { model, signal });
48
- console.error(`已导入 ${dataset.items.length} 条(文件结构${dataset.mapping.from_cache ? '规则来自缓存' : '由模型识别'})`);
48
+ console.error(`已导入 ${dataset.items.length} 条(文件结构${mappingSource(dataset.mapping)})`);
49
49
  const result = await analyzeDataset({
50
50
  memory: { cwd: process.cwd(), onWarning: (message) => console.error(message) },
51
51
  model,
package/dist/cli/ask.js CHANGED
@@ -2,7 +2,7 @@ import { parseArgs } from 'node:util';
2
2
  import { askDataset } from '../ask/index.js';
3
3
  import { loadLlmConfig } from '../config.js';
4
4
  import { loadDataset } from '../ingest/index.js';
5
- import { abortOnInterrupt, askModel, UsageError } from './common.js';
5
+ import { mappingSource, abortOnInterrupt, askModel, UsageError } from './common.js';
6
6
  import { formatEvent } from './format_event.js';
7
7
  export const ASK_USAGE = `用法:ugc-lens ask <文件> <问题> [追问...]
8
8
 
@@ -29,7 +29,7 @@ export async function runAsk(argv) {
29
29
  const model = askModel(config);
30
30
  const { signal } = abortOnInterrupt('正在取消');
31
31
  const dataset = await loadDataset(args.file, { model, signal });
32
- console.error(`已导入 ${dataset.items.length} 条(文件结构${dataset.mapping.from_cache ? '规则来自缓存' : '由模型识别'})`);
32
+ console.error(`已导入 ${dataset.items.length} 条(文件结构${mappingSource(dataset.mapping)})`);
33
33
  let history;
34
34
  for (const [i, question] of args.questions.entries()) {
35
35
  if (args.questions.length > 1)
@@ -14,3 +14,7 @@ export declare function askModel(config: LlmConfig): AgentModel;
14
14
  export declare function formatAnalyzeProgress(event: AnalyzeProgress): string;
15
15
  /** 第一次 Ctrl+C 取消,第二次直接退出 */
16
16
  export declare function abortOnInterrupt(message: string): AbortController;
17
+ export declare function mappingSource(mapping: {
18
+ from_cache: boolean;
19
+ builtin?: boolean;
20
+ }): string;
@@ -42,3 +42,6 @@ export function abortOnInterrupt(message) {
42
42
  });
43
43
  return controller;
44
44
  }
45
+ export function mappingSource(mapping) {
46
+ return mapping.builtin ? '由内置规则读取' : mapping.from_cache ? '规则来自缓存' : '由模型识别';
47
+ }
package/dist/cli/index.js CHANGED
@@ -22,7 +22,7 @@ const COMMANDS = {
22
22
  ask: { run: runAsk, usage: ASK_USAGE },
23
23
  watch: { run: runWatch, usage: WATCH_USAGE },
24
24
  };
25
- const MAIN_USAGE = `ugc-lens:分析评论、弹幕等用户互动内容
25
+ const MAIN_USAGE = `ugc-lens:分析文本记录、评论、弹幕等内容
26
26
 
27
27
  用法:
28
28
  ugc-lens 持续对话,自动选择导入、查询、分析和监听工具
@@ -1,7 +1,7 @@
1
1
  /** 平台名,由模型根据文件内容判断,如 bilibili、xhs;判断不了时为 unknown */
2
2
  export type Platform = string;
3
- export type FeedbackKind = 'comment' | 'reply' | 'danmaku' | 'answer';
4
- export declare const FEEDBACK_KINDS: readonly ["comment", "reply", "danmaku", "answer"];
3
+ export type FeedbackKind = 'comment' | 'reply' | 'danmaku' | 'answer' | 'record';
4
+ export declare const FEEDBACK_KINDS: readonly ["comment", "reply", "danmaku", "answer", "record"];
5
5
  export interface FeedbackAuthor {
6
6
  id?: string;
7
7
  name?: string;
@@ -81,9 +81,13 @@ export interface Dataset {
81
81
  contents: ContentInfo[];
82
82
  items: FeedbackItem[];
83
83
  imported_at: string;
84
+ source?: {
85
+ format: string;
86
+ };
84
87
  mapping: {
85
88
  fingerprint: string;
86
89
  from_cache: boolean;
90
+ builtin?: boolean;
87
91
  };
88
92
  report: ImportReport;
89
93
  }
@@ -1 +1 @@
1
- export const FEEDBACK_KINDS = ['comment', 'reply', 'danmaku', 'answer'];
1
+ export const FEEDBACK_KINDS = ['comment', 'reply', 'danmaku', 'answer', 'record'];
package/dist/index.d.ts CHANGED
@@ -8,14 +8,15 @@ export type { AgentModel, AgentModelRequest } from './agent/model.js';
8
8
  export { createFakeModel, type FakeModel, type FakeReply, type FakeScript } from './agent/fake_model.js';
9
9
  export { runAgent, DEFAULT_LIMITS, type AgentLimits, type AgentRunResult, type RunAgentOptions, type StopReason } from './agent/loop.js';
10
10
  export type { RunEvent, RunEventListener } from './agent/events.js';
11
- export { loadDataset, datasetId, ImportError, type ImportErrorCode, type LoadDatasetOptions } from './ingest/index.js';
11
+ export { loadDataset, datasetId, ImportError, type ImportErrorCode, type LoadDatasetOptions, type InputFormat } from './ingest/index.js';
12
12
  export type { ContentInfo, Dataset, FeedbackAuthor, FeedbackItem, FeedbackKind, ImportReport } from './dataset/types.js';
13
13
  export { computeStats, type DatasetStats } from './dataset/stats.js';
14
14
  export { findCitations, getFeedback, getReplies, searchFeedback, type FeedbackPage, type FeedbackQuery } from './dataset/query.js';
15
15
  export { analyzeDataset, type AnalyzeOptions, type AnalyzeProgress, type AnalyzeResult } from './pipeline/analyze.js';
16
- export { DEFAULT_DIRECTIONS, type Finding, type FindingRef } from './pipeline/summarize.js';
16
+ export { DEFAULT_DIRECTIONS, RECORD_DIRECTIONS, type Finding, type FindingRef } from './pipeline/summarize.js';
17
17
  export type { Theme } from './pipeline/merge.js';
18
18
  export { askDataset, type AskOptions, type AskResult } from './ask/index.js';
19
+ export { analyzeSentiment, sentimentStats, type SentimentOptions, type SentimentRecord, type SentimentResult } from './sentiment/index.js';
19
20
  export { openTaskStore, defaultTasksPath, type Task, type TaskStatus, type TaskStore } from './store/tasks.js';
20
21
  export { watchDirectory, type AnalyzeOutcome, type WatchEvent, type WatchOptions } from './watch/index.js';
21
22
  export { DEFAULT_PATTERNS } from './watch/pattern.js';
package/dist/index.js CHANGED
@@ -9,8 +9,9 @@ export { loadDataset, datasetId, ImportError } from './ingest/index.js';
9
9
  export { computeStats } from './dataset/stats.js';
10
10
  export { findCitations, getFeedback, getReplies, searchFeedback } from './dataset/query.js';
11
11
  export { analyzeDataset } from './pipeline/analyze.js';
12
- export { DEFAULT_DIRECTIONS } from './pipeline/summarize.js';
12
+ export { DEFAULT_DIRECTIONS, RECORD_DIRECTIONS } from './pipeline/summarize.js';
13
13
  export { askDataset } from './ask/index.js';
14
+ export { analyzeSentiment, sentimentStats } from './sentiment/index.js';
14
15
  export { openTaskStore, defaultTasksPath } from './store/tasks.js';
15
16
  export { watchDirectory } from './watch/index.js';
16
17
  export { DEFAULT_PATTERNS } from './watch/pattern.js';
@@ -32,6 +32,7 @@ declare const ruleSchema: z.ZodObject<{
32
32
  answer: "answer";
33
33
  comment: "comment";
34
34
  danmaku: "danmaku";
35
+ record: "record";
35
36
  reply: "reply";
36
37
  }>;
37
38
  path: z.ZodString;
@@ -55,6 +56,7 @@ declare const ruleSchema: z.ZodObject<{
55
56
  answer: "answer";
56
57
  comment: "comment";
57
58
  danmaku: "danmaku";
59
+ record: "record";
58
60
  reply: "reply";
59
61
  }>>;
60
62
  path: z.ZodString;
@@ -79,6 +81,7 @@ declare const ruleSchema: z.ZodObject<{
79
81
  answer: "answer";
80
82
  comment: "comment";
81
83
  danmaku: "danmaku";
84
+ record: "record";
82
85
  reply: "reply";
83
86
  }>>;
84
87
  path: z.ZodString;
@@ -144,6 +147,7 @@ declare const entrySchema: z.ZodObject<{
144
147
  answer: "answer";
145
148
  comment: "comment";
146
149
  danmaku: "danmaku";
150
+ record: "record";
147
151
  reply: "reply";
148
152
  }>;
149
153
  path: z.ZodString;
@@ -167,6 +171,7 @@ declare const entrySchema: z.ZodObject<{
167
171
  answer: "answer";
168
172
  comment: "comment";
169
173
  danmaku: "danmaku";
174
+ record: "record";
170
175
  reply: "reply";
171
176
  }>>;
172
177
  path: z.ZodString;
@@ -191,6 +196,7 @@ declare const entrySchema: z.ZodObject<{
191
196
  answer: "answer";
192
197
  comment: "comment";
193
198
  danmaku: "danmaku";
199
+ record: "record";
194
200
  reply: "reply";
195
201
  }>>;
196
202
  path: z.ZodString;
@@ -0,0 +1,7 @@
1
+ export type ImportErrorCode = 'read_failed' | 'too_large' | 'not_json' | 'not_jsonl' | 'not_csv' | 'empty_data' | 'unsupported_format' | 'invalid_encoding' | 'not_feedback' | 'no_model' | 'inference_failed';
2
+ export declare class ImportError extends Error {
3
+ name: string;
4
+ readonly code: ImportErrorCode;
5
+ constructor(code: ImportErrorCode, message: string);
6
+ }
7
+ export declare const MAX_FILE_BYTES: number;
@@ -0,0 +1,9 @@
1
+ export class ImportError extends Error {
2
+ name = 'ImportError';
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ }
8
+ }
9
+ export const MAX_FILE_BYTES = 100 * 1024 * 1024;
@@ -0,0 +1,18 @@
1
+ import type { Mapping } from './mapping.js';
2
+ export type InputFormat = 'json' | 'jsonl' | 'csv' | 'tsv' | 'txt' | 'markdown' | 'log';
3
+ export interface ParsedInput {
4
+ data: unknown;
5
+ format: InputFormat;
6
+ builtin?: Mapping;
7
+ }
8
+ export declare function parseJsonLines(text: string): ParsedInput;
9
+ interface DelimitedRow {
10
+ cells: string[];
11
+ line: number;
12
+ }
13
+ /** 状态机保留引号内的换行和分隔符,不能按行或逗号直接 split。 */
14
+ export declare function parseDelimited(text: string, delimiter: string): DelimitedRow[];
15
+ export declare function parseTable(text: string, format: 'csv' | 'tsv'): ParsedInput;
16
+ export declare function parseInput(text: string, format: InputFormat): ParsedInput;
17
+ export declare function readInput(filePath: string, signal?: AbortSignal): Promise<ParsedInput>;
18
+ export {};
@@ -0,0 +1,221 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { extname } from 'node:path';
3
+ import { ImportError, MAX_FILE_BYTES } from './errors.js';
4
+ const TEXT_MAPPING = { platform: 'unknown', collections: [{ kind: 'record', path: '$', fields: { text: 'text', id: 'source_id' } }] };
5
+ function textRecords(rows, format) {
6
+ const data = rows.filter((row) => row.text.trim()).map((row) => ({ source_id: `line_${row.line}`, text: row.text }));
7
+ if (!data.length)
8
+ throw new ImportError('empty_data', '文件中没有非空文本记录');
9
+ return { data, format, builtin: TEXT_MAPPING };
10
+ }
11
+ export function parseJsonLines(text) {
12
+ const rows = [];
13
+ for (const [index, line] of text.split(/\r\n|\n|\r/).entries()) {
14
+ if (!line.trim())
15
+ continue;
16
+ let value;
17
+ try {
18
+ value = JSON.parse(line);
19
+ }
20
+ catch {
21
+ throw new ImportError('not_jsonl', `JSONL 第 ${index + 1} 行不是有效 JSON`);
22
+ }
23
+ if (typeof value !== 'string' && (value === null || typeof value !== 'object' || Array.isArray(value)))
24
+ throw new ImportError('not_jsonl', `JSONL 第 ${index + 1} 行需要对象或字符串`);
25
+ rows.push({ value, line: index + 1 });
26
+ }
27
+ if (!rows.length)
28
+ throw new ImportError('empty_data', 'JSONL 中没有非空记录');
29
+ if (rows.every((row) => typeof row.value === 'string'))
30
+ return textRecords(rows.map((row) => ({ text: row.value, line: row.line })), 'jsonl');
31
+ if (rows.some((row) => typeof row.value === 'string'))
32
+ throw new ImportError('not_jsonl', 'JSONL 不支持混合对象和字符串,请统一记录结构');
33
+ return { data: rows.map((row) => row.value), format: 'jsonl' };
34
+ }
35
+ /** 状态机保留引号内的换行和分隔符,不能按行或逗号直接 split。 */
36
+ export function parseDelimited(text, delimiter) {
37
+ const rows = [];
38
+ let cells = [];
39
+ let value = '';
40
+ let quoted = false;
41
+ let closed = false;
42
+ let line = 1;
43
+ let start = 1;
44
+ const fail = (reason) => { throw new ImportError('not_csv', `表格第 ${line} 行:${reason}`); };
45
+ const finishRow = () => {
46
+ if (cells.length || value.trim() || closed)
47
+ rows.push({ cells: [...cells, value], line: start });
48
+ cells = [];
49
+ value = '';
50
+ closed = false;
51
+ };
52
+ for (let i = 0; i < text.length; i++) {
53
+ const char = text[i];
54
+ if (quoted) {
55
+ if (char === '"') {
56
+ if (text[i + 1] === '"') {
57
+ value += '"';
58
+ i++;
59
+ }
60
+ else {
61
+ quoted = false;
62
+ closed = true;
63
+ }
64
+ }
65
+ else {
66
+ value += char;
67
+ if (char === '\n' || (char === '\r' && text[i + 1] !== '\n'))
68
+ line++;
69
+ }
70
+ continue;
71
+ }
72
+ if (char === delimiter) {
73
+ cells.push(value);
74
+ value = '';
75
+ closed = false;
76
+ }
77
+ else if (char === '\r' || char === '\n') {
78
+ finishRow();
79
+ if (char === '\r' && text[i + 1] === '\n')
80
+ i++;
81
+ line++;
82
+ start = line;
83
+ }
84
+ else if (char === '"') {
85
+ if (value || closed)
86
+ fail('双引号必须位于字段开头');
87
+ quoted = true;
88
+ }
89
+ else {
90
+ if (closed)
91
+ fail('结束引号后只能是分隔符或换行');
92
+ value += char;
93
+ }
94
+ }
95
+ if (quoted)
96
+ fail(`从第 ${start} 行开始的引号未闭合`);
97
+ finishRow();
98
+ return rows;
99
+ }
100
+ function csvDelimiter(text) {
101
+ const counts = new Map([[',', 0], [';', 0]]);
102
+ let quoted = false;
103
+ for (let i = 0; i < text.length; i++) {
104
+ const char = text[i];
105
+ if (char === '"') {
106
+ if (quoted && text[i + 1] === '"')
107
+ i++;
108
+ else
109
+ quoted = !quoted;
110
+ }
111
+ else if (!quoted) {
112
+ if (char === '\n' || char === '\r') {
113
+ if ([...counts.values()].some((n) => n > 0))
114
+ break;
115
+ }
116
+ else if (counts.has(char))
117
+ counts.set(char, counts.get(char) + 1);
118
+ }
119
+ }
120
+ return counts.get(';') > counts.get(',') ? ';' : ',';
121
+ }
122
+ export function parseTable(text, format) {
123
+ const rows = parseDelimited(text, format === 'tsv' ? '\t' : csvDelimiter(text));
124
+ const headers = rows.shift()?.cells.map((cell) => cell.trim());
125
+ if (!headers)
126
+ throw new ImportError('empty_data', '表格为空');
127
+ if (headers.some((h) => !h) || new Set(headers).size !== headers.length)
128
+ throw new ImportError('not_csv', '表头不能为空或重复,请给每一列唯一名称');
129
+ if (!rows.length)
130
+ throw new ImportError('empty_data', '表格只有表头,没有数据记录');
131
+ const keys = headers.map((_, i) => `column_${i + 1}`);
132
+ const data = rows.map((row) => {
133
+ if (row.cells.length !== headers.length)
134
+ throw new ImportError('not_csv', `表格第 ${row.line} 行有 ${row.cells.length} 列,表头有 ${headers.length} 列`);
135
+ return Object.fromEntries([['source_id', `line_${row.line}`], ...keys.map((key, i) => [key, row.cells[i]])]);
136
+ });
137
+ return { data: { columns: Object.fromEntries(keys.map((key, i) => [key, headers[i]])), rows: data }, format };
138
+ }
139
+ export function parseInput(text, format) {
140
+ if (!text.trim())
141
+ throw new ImportError('empty_data', '文件为空或只有空白字符');
142
+ if (format === 'csv' || format === 'tsv')
143
+ return parseTable(text, format);
144
+ if (format === 'jsonl')
145
+ return parseJsonLines(text);
146
+ if (format === 'json') {
147
+ let data;
148
+ try {
149
+ data = JSON.parse(text);
150
+ }
151
+ catch {
152
+ throw new ImportError('not_json', '文件不是有效的 JSON');
153
+ }
154
+ if (Array.isArray(data) && data.every((entry) => typeof entry === 'string'))
155
+ return textRecords(data.map((entry, i) => ({ text: entry, line: i + 1 })), format);
156
+ return { data, format };
157
+ }
158
+ const lines = text.split(/\r\n|\n|\r/);
159
+ if (format !== 'markdown')
160
+ return textRecords(lines.map((line, i) => ({ text: line, line: i + 1 })), format);
161
+ const paragraphs = [];
162
+ let start = 1;
163
+ let buffer = [];
164
+ for (const [i, line] of lines.entries()) {
165
+ if (!line.trim()) {
166
+ if (buffer.length)
167
+ paragraphs.push({ text: buffer.join('\n'), line: start });
168
+ buffer = [];
169
+ }
170
+ else {
171
+ if (!buffer.length)
172
+ start = i + 1;
173
+ buffer.push(line);
174
+ }
175
+ }
176
+ if (buffer.length)
177
+ paragraphs.push({ text: buffer.join('\n'), line: start });
178
+ return textRecords(paragraphs, format);
179
+ }
180
+ const EXTENSIONS = { '.json': 'json', '.jsonl': 'jsonl', '.ndjson': 'jsonl', '.csv': 'csv', '.tsv': 'tsv', '.txt': 'txt', '.md': 'markdown', '.markdown': 'markdown', '.log': 'log' };
181
+ export async function readInput(filePath, signal) {
182
+ signal?.throwIfAborted();
183
+ let bytes;
184
+ try {
185
+ const info = await stat(filePath);
186
+ if (!info.isFile())
187
+ throw new ImportError('read_failed', `不是文件:${filePath}`);
188
+ if (info.size > MAX_FILE_BYTES)
189
+ throw new ImportError('too_large', `文件超过 ${MAX_FILE_BYTES / 1024 / 1024} MB 上限:${filePath}`);
190
+ bytes = await readFile(filePath, { signal });
191
+ }
192
+ catch (error) {
193
+ if (error instanceof ImportError || signal?.aborted)
194
+ throw error;
195
+ throw new ImportError('read_failed', `无法读取 ${filePath}:${error.message}`);
196
+ }
197
+ if (bytes.length > MAX_FILE_BYTES)
198
+ throw new ImportError('too_large', `文件超过 ${MAX_FILE_BYTES / 1024 / 1024} MB 上限:${filePath}`);
199
+ let text;
200
+ try {
201
+ const encoding = bytes[0] === 0xff && bytes[1] === 0xfe ? 'utf-16le' : bytes[0] === 0xfe && bytes[1] === 0xff ? 'utf-16be' : 'utf-8';
202
+ text = new TextDecoder(encoding, { fatal: true }).decode(bytes);
203
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/u.test(text))
204
+ throw new Error('binary');
205
+ }
206
+ catch {
207
+ throw new ImportError('invalid_encoding', '文件不是有效文本;请使用 UTF-8 或带 BOM 的 UTF-16 文本文件');
208
+ }
209
+ signal?.throwIfAborted();
210
+ // 无后缀和历史自定义后缀仍可作为 JSON 读入,避免破坏原有 API。
211
+ const format = EXTENSIONS[extname(filePath).toLowerCase()];
212
+ if (!format) {
213
+ try {
214
+ return parseInput(text, 'json');
215
+ }
216
+ catch {
217
+ throw new ImportError('unsupported_format', '不支持此文件格式;可用 JSON、JSONL、CSV、TSV、TXT、Markdown 或 LOG');
218
+ }
219
+ }
220
+ return parseInput(text, format);
221
+ }
@@ -1,13 +1,8 @@
1
1
  import type { RunEventListener } from '../agent/events.js';
2
2
  import type { AgentModel } from '../agent/model.js';
3
3
  import type { Dataset } from '../dataset/types.js';
4
- export type ImportErrorCode = 'read_failed' | 'too_large' | 'not_json' | 'not_feedback' | 'no_model' | 'inference_failed';
5
- export declare class ImportError extends Error {
6
- name: string;
7
- readonly code: ImportErrorCode;
8
- constructor(code: ImportErrorCode, message: string);
9
- }
10
- export declare const MAX_FILE_BYTES: number;
4
+ export { ImportError, MAX_FILE_BYTES, type ImportErrorCode } from './errors.js';
5
+ export type { InputFormat } from './formats.js';
11
6
  export interface LoadDatasetOptions {
12
7
  /** 缓存里没有可用规则时,用它推断映射规则 */
13
8
  model?: AgentModel;
@@ -20,7 +15,7 @@ export interface LoadDatasetOptions {
20
15
  }
21
16
  export declare function datasetId(filePath: string): string;
22
17
  export declare function defaultCacheDir(cwd?: string): string;
23
- /** 读入任意 JSON 文件,转换成统一格式。流程见 docs/architecture.md 第 5.3 节 */
18
+ /** 读入结构化文件或文本记录,转换成统一格式。流程见 docs/architecture.md 第 5.3 节 */
24
19
  export declare function loadDataset(filePath: string, options?: LoadDatasetOptions): Promise<Dataset>;
25
20
  export { describeShape, type Shape } from './shape.js';
26
21
  export { applyMapping, formatReport } from './apply.js';