@chainlesschain/personal-data-hub 0.1.0 → 0.2.1
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/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
- package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
- package/__tests__/adapters/ai-chat-history.test.js +396 -0
- package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
- package/__tests__/adapters/ai-chat-vendors.test.js +874 -0
- package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
- package/__tests__/adapters/email-adapter.test.js +138 -1
- package/__tests__/adapters/email-classifier.test.js +347 -0
- package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
- package/__tests__/adapters/email-retry-progress.test.js +294 -0
- package/__tests__/adapters/email-templates.test.js +699 -0
- package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
- package/__tests__/adapters/system-data-adapter.test.js +440 -0
- package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
- package/__tests__/adapters/system-data-android.test.js +387 -0
- package/__tests__/adapters/system-data-disclosure.test.js +153 -0
- package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
- package/__tests__/adapters/wechat-env-probe.test.js +162 -0
- package/__tests__/adapters/wechat-frida-agent.test.js +191 -0
- package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
- package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
- package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
- package/__tests__/analysis-skills.test.js +556 -0
- package/__tests__/analysis.test.js +329 -1
- package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
- package/__tests__/e2e/full-user-journey.test.js +188 -0
- package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
- package/__tests__/entity-resolver-stages.test.js +411 -0
- package/__tests__/entity-resolver-vault.test.js +246 -0
- package/__tests__/entity-resolver.test.js +526 -0
- package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
- package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
- package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
- package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
- package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
- package/__tests__/longtail-adapters.test.js +217 -0
- package/__tests__/mobile-extractor.test.js +288 -0
- package/__tests__/registry.test.js +4 -2
- package/__tests__/shopping-adapters.test.js +296 -0
- package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
- package/__tests__/sidecar-supervisor.test.js +120 -0
- package/__tests__/social-adapters.test.js +206 -0
- package/__tests__/travel-adapters.test.js +325 -0
- package/__tests__/vault.test.js +3 -3
- package/__tests__/wechat-adapter.test.js +476 -0
- package/__tests__/whatsapp-adapter.test.js +135 -0
- package/lib/adapter-spec.js +12 -0
- package/lib/adapters/_python-sidecar-base.js +207 -0
- package/lib/adapters/ai-chat-history/ai-chat-adapter.js +374 -0
- package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
- package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
- package/lib/adapters/ai-chat-history/health-checker.js +210 -0
- package/lib/adapters/ai-chat-history/http-client.js +211 -0
- package/lib/adapters/ai-chat-history/index.js +28 -0
- package/lib/adapters/ai-chat-history/schema-map.js +258 -0
- package/lib/adapters/ai-chat-history/vendor-spec.js +86 -0
- package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
- package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
- package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
- package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
- package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
- package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
- package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
- package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
- package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
- package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
- package/lib/adapters/alipay-bill/alipay-bill-adapter.js +311 -0
- package/lib/adapters/alipay-bill/counterparty.js +129 -0
- package/lib/adapters/alipay-bill/csv-parser.js +217 -0
- package/lib/adapters/alipay-bill/index.js +41 -0
- package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
- package/lib/adapters/email-imap/classifier.js +495 -0
- package/lib/adapters/email-imap/email-adapter.js +419 -8
- package/lib/adapters/email-imap/index.js +42 -0
- package/lib/adapters/email-imap/pdf-extractor.js +192 -0
- package/lib/adapters/email-imap/templates/bill.js +232 -0
- package/lib/adapters/email-imap/templates/government.js +120 -0
- package/lib/adapters/email-imap/templates/index.js +78 -0
- package/lib/adapters/email-imap/templates/order.js +186 -0
- package/lib/adapters/email-imap/templates/other.js +114 -0
- package/lib/adapters/email-imap/templates/register.js +113 -0
- package/lib/adapters/email-imap/templates/travel.js +157 -0
- package/lib/adapters/email-imap/templates/utils.js +275 -0
- package/lib/adapters/email-imap/transactions.js +234 -0
- package/lib/adapters/messaging-qq/index.js +158 -0
- package/lib/adapters/messaging-telegram/index.js +142 -0
- package/lib/adapters/messaging-whatsapp/index.js +189 -0
- package/lib/adapters/shopping-base/index.js +208 -0
- package/lib/adapters/shopping-jd/index.js +150 -0
- package/lib/adapters/shopping-meituan/index.js +154 -0
- package/lib/adapters/shopping-taobao/index.js +176 -0
- package/lib/adapters/social-bilibili/index.js +171 -0
- package/lib/adapters/social-douyin/index.js +116 -0
- package/lib/adapters/social-kuaishou/index.js +237 -0
- package/lib/adapters/social-toutiao/index.js +236 -0
- package/lib/adapters/social-weibo/index.js +164 -0
- package/lib/adapters/social-xiaohongshu/index.js +96 -0
- package/lib/adapters/system-data/disclosure.js +166 -0
- package/lib/adapters/system-data/index.js +34 -0
- package/lib/adapters/system-data/system-data-adapter.js +344 -0
- package/lib/adapters/system-data-android/adapter.js +348 -0
- package/lib/adapters/system-data-android/index.js +76 -0
- package/lib/adapters/travel-12306/index.js +151 -0
- package/lib/adapters/travel-amap/index.js +164 -0
- package/lib/adapters/travel-baidu-map/index.js +162 -0
- package/lib/adapters/travel-base/index.js +240 -0
- package/lib/adapters/travel-ctrip/index.js +151 -0
- package/lib/adapters/wechat/bootstrap.js +146 -0
- package/lib/adapters/wechat/content-parser.js +326 -0
- package/lib/adapters/wechat/db-reader.js +209 -0
- package/lib/adapters/wechat/env-probe.js +218 -0
- package/lib/adapters/wechat/frida-agent/loader.js +67 -0
- package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +126 -0
- package/lib/adapters/wechat/index.js +37 -0
- package/lib/adapters/wechat/key-extractor.js +158 -0
- package/lib/adapters/wechat/key-providers/frida-key-provider.js +244 -0
- package/lib/adapters/wechat/key-providers/index.js +22 -0
- package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
- package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
- package/lib/adapters/wechat/normalize.js +220 -0
- package/lib/adapters/wechat/wechat-adapter.js +205 -0
- package/lib/analysis-skills/base.js +113 -0
- package/lib/analysis-skills/footprint.js +167 -0
- package/lib/analysis-skills/index.js +58 -0
- package/lib/analysis-skills/interests.js +161 -0
- package/lib/analysis-skills/relations.js +226 -0
- package/lib/analysis-skills/spending.js +219 -0
- package/lib/analysis-skills/timeline.js +167 -0
- package/lib/analysis.js +191 -2
- package/lib/entity-resolver/embedding-stage.js +198 -0
- package/lib/entity-resolver/entity-resolver.js +384 -0
- package/lib/entity-resolver/index.js +42 -0
- package/lib/entity-resolver/llm-stage.js +191 -0
- package/lib/entity-resolver/rule-stage.js +208 -0
- package/lib/entity-resolver/worker.js +149 -0
- package/lib/index.js +131 -0
- package/lib/migrations.js +73 -0
- package/lib/mobile-extractor/android.js +193 -0
- package/lib/mobile-extractor/index.js +9 -0
- package/lib/mobile-extractor/ios.js +223 -0
- package/lib/prompt-builder.js +11 -1
- package/lib/query-parser.js +7 -1
- package/lib/registry.js +42 -0
- package/lib/sidecar/index.js +15 -0
- package/lib/sidecar/supervisor.js +359 -0
- package/lib/vault.js +343 -0
- package/package.json +36 -3
- package/scripts/_make-fixture-all.js +126 -0
- package/scripts/_make-fixture-contacts.js +84 -0
- package/scripts/evaluate-entity-resolver.js +213 -0
- package/scripts/smoke-phase-5-5.js +196 -0
- package/scripts/smoke-phase-5-7.js +181 -0
- package/scripts/smoke-system-data-contacts.js +309 -0
- package/scripts/smoke-system-data.js +312 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 11 — internal analysis skills entry point.
|
|
3
|
+
*
|
|
4
|
+
* Each skill is a small focused class that the hub dispatches to via
|
|
5
|
+
* `runAnalysisSkill(name, options)`. Skills work over the vault +
|
|
6
|
+
* optional LLM and respect the same privacy gate as AnalysisEngine.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
const { AnalysisSkill } = require("./base");
|
|
12
|
+
const { SpendingSkill, SUPPORTED_DIMENSIONS: SPENDING_DIMENSIONS } = require("./spending");
|
|
13
|
+
const { RelationsSkill } = require("./relations");
|
|
14
|
+
const { FootprintSkill } = require("./footprint");
|
|
15
|
+
const { InterestsSkill } = require("./interests");
|
|
16
|
+
const { TimelineSkill } = require("./timeline");
|
|
17
|
+
|
|
18
|
+
const SKILL_REGISTRY = Object.freeze({
|
|
19
|
+
"analysis.spending": SpendingSkill,
|
|
20
|
+
"analysis.relations": RelationsSkill,
|
|
21
|
+
"analysis.footprint": FootprintSkill,
|
|
22
|
+
"analysis.interests": InterestsSkill,
|
|
23
|
+
"analysis.timeline": TimelineSkill,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const SKILL_NAMES = Object.freeze(Object.keys(SKILL_REGISTRY));
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run a single skill by name. Convenience over instantiating Skill
|
|
30
|
+
* classes directly — the same {vault, llm} pair gets reused.
|
|
31
|
+
*
|
|
32
|
+
* @param {{vault, llm?}} deps
|
|
33
|
+
* @param {string} skillName
|
|
34
|
+
* @param {object} options
|
|
35
|
+
*/
|
|
36
|
+
async function runAnalysisSkill(deps, skillName, options = {}) {
|
|
37
|
+
if (!deps || !deps.vault) throw new Error("runAnalysisSkill: deps.vault required");
|
|
38
|
+
const Cls = SKILL_REGISTRY[skillName];
|
|
39
|
+
if (!Cls) {
|
|
40
|
+
throw new Error(`unknown analysis skill: ${skillName}. Known: ${SKILL_NAMES.join(", ")}`);
|
|
41
|
+
}
|
|
42
|
+
const skill = new Cls({ vault: deps.vault, llm: deps.llm });
|
|
43
|
+
return await skill.run(options);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
AnalysisSkill,
|
|
48
|
+
SpendingSkill,
|
|
49
|
+
RelationsSkill,
|
|
50
|
+
FootprintSkill,
|
|
51
|
+
InterestsSkill,
|
|
52
|
+
TimelineSkill,
|
|
53
|
+
SKILL_REGISTRY,
|
|
54
|
+
SKILL_NAMES,
|
|
55
|
+
ANALYSIS_SKILL_NAMES: SKILL_NAMES,
|
|
56
|
+
SPENDING_DIMENSIONS,
|
|
57
|
+
runAnalysisSkill,
|
|
58
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 11 — analysis.interests skill.
|
|
3
|
+
*
|
|
4
|
+
* Extracts the user's interest profile from:
|
|
5
|
+
* - Topic entities (already-categorized by adapter)
|
|
6
|
+
* - Item entities (product / content names)
|
|
7
|
+
* - Event content.title from order/payment/visit events
|
|
8
|
+
*
|
|
9
|
+
* LLM is used to cluster + name interest categories. Without LLM,
|
|
10
|
+
* falls back to topic-frequency + most-purchased-item ranking (no
|
|
11
|
+
* generalization).
|
|
12
|
+
*
|
|
13
|
+
* Inputs:
|
|
14
|
+
* - timeWindow: optional; default all-time
|
|
15
|
+
* - topN: default 15
|
|
16
|
+
*
|
|
17
|
+
* Output:
|
|
18
|
+
* {
|
|
19
|
+
* topTopics: [{ name, eventCount, lastSeen }, ...],
|
|
20
|
+
* topItems: [{ name, occurrences, totalSpend }, ...],
|
|
21
|
+
* llmInterests?: [{ category, evidenceCount, examples }, ...],
|
|
22
|
+
* citations,
|
|
23
|
+
* llm_commentary,
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
"use strict";
|
|
28
|
+
|
|
29
|
+
const { AnalysisSkill } = require("./base");
|
|
30
|
+
|
|
31
|
+
class InterestsSkill extends AnalysisSkill {
|
|
32
|
+
constructor(opts) {
|
|
33
|
+
super({ ...opts, name: "analysis.interests" });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async run(options = {}) {
|
|
37
|
+
const { since, until } = this.resolveTimeWindow(options);
|
|
38
|
+
const topN = Number.isFinite(options.topN) && options.topN > 0 ? options.topN : 15;
|
|
39
|
+
|
|
40
|
+
const topTopics = this._topTopics(since, until, topN);
|
|
41
|
+
const topItems = this._topItems(since, until, topN);
|
|
42
|
+
const events = this._sampleEvents(since, until, 200);
|
|
43
|
+
const llmInterests = (options.commentary !== false && this.llm)
|
|
44
|
+
? await this._clusterInterests(topTopics, topItems, events, options)
|
|
45
|
+
: null;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
skill: "analysis.interests",
|
|
49
|
+
topTopics,
|
|
50
|
+
topItems,
|
|
51
|
+
llmInterests,
|
|
52
|
+
citations: events.slice(0, 50).map((e) => e.id),
|
|
53
|
+
llm_commentary: null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_topTopics(since, until, topN) {
|
|
58
|
+
// Topics are stored in their own table — eventCount is derived from
|
|
59
|
+
// the JSON `derived_from_events` array length; lastSeen is the
|
|
60
|
+
// topic's ingested_at (proxy until we add a real last_seen column).
|
|
61
|
+
let topics = [];
|
|
62
|
+
try {
|
|
63
|
+
const db = this.vault._requireOpen();
|
|
64
|
+
topics = db.prepare(
|
|
65
|
+
"SELECT id, name, derived_from_events, ingested_at FROM topics ORDER BY ingested_at DESC LIMIT ?"
|
|
66
|
+
).all(topN * 3);
|
|
67
|
+
} catch (_e) {
|
|
68
|
+
// Older vaults may not have topics; non-fatal.
|
|
69
|
+
}
|
|
70
|
+
const mapped = topics.map((t) => {
|
|
71
|
+
let eventCount = 0;
|
|
72
|
+
try {
|
|
73
|
+
const arr = t.derived_from_events ? JSON.parse(t.derived_from_events) : [];
|
|
74
|
+
if (Array.isArray(arr)) eventCount = arr.length;
|
|
75
|
+
} catch (_e) {}
|
|
76
|
+
return {
|
|
77
|
+
id: t.id,
|
|
78
|
+
name: t.name,
|
|
79
|
+
eventCount,
|
|
80
|
+
lastSeen: t.ingested_at || null,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
return mapped
|
|
84
|
+
.sort((a, b) => (b.eventCount - a.eventCount) || ((b.lastSeen || 0) - (a.lastSeen || 0)))
|
|
85
|
+
.slice(0, topN);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_topItems(since, until, topN) {
|
|
89
|
+
let items = [];
|
|
90
|
+
try {
|
|
91
|
+
const db = this.vault._requireOpen();
|
|
92
|
+
items = db.prepare(
|
|
93
|
+
"SELECT id, name FROM items ORDER BY ingested_at DESC LIMIT ?"
|
|
94
|
+
).all(topN * 3);
|
|
95
|
+
} catch (_e) {}
|
|
96
|
+
// Re-bucket by name (multiple Item rows often share the same product
|
|
97
|
+
// name across adapters). Phase 8 EntityResolver doesn't dedup items
|
|
98
|
+
// yet — that's Phase 9+.
|
|
99
|
+
const buckets = new Map();
|
|
100
|
+
for (const row of items) {
|
|
101
|
+
const item = this.vault.getItem ? this.vault.getItem(row.id) : null;
|
|
102
|
+
if (!item) continue;
|
|
103
|
+
const key = item.name || "(unknown)";
|
|
104
|
+
const cur = buckets.get(key) || { name: key, occurrences: 0, totalSpend: 0 };
|
|
105
|
+
cur.occurrences += 1;
|
|
106
|
+
if (item.price && Number.isFinite(item.price.value)) cur.totalSpend += item.price.value;
|
|
107
|
+
buckets.set(key, cur);
|
|
108
|
+
}
|
|
109
|
+
return Array.from(buckets.values())
|
|
110
|
+
.sort((a, b) => b.occurrences - a.occurrences)
|
|
111
|
+
.slice(0, topN)
|
|
112
|
+
.map((b) => ({ ...b, totalSpend: Math.round(b.totalSpend * 100) / 100 }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_sampleEvents(since, until, limit) {
|
|
116
|
+
const q = { limit };
|
|
117
|
+
if (since != null) q.since = since;
|
|
118
|
+
if (until != null) q.until = until;
|
|
119
|
+
return this.vault.queryEvents(q) || [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async _clusterInterests(topTopics, topItems, events, options) {
|
|
123
|
+
if (topTopics.length === 0 && topItems.length === 0) return null;
|
|
124
|
+
const userMsg = `用户的互动数据样本:
|
|
125
|
+
|
|
126
|
+
Topics (按出现频次):
|
|
127
|
+
${topTopics.slice(0, 10).map((t) => `- ${t.name} (${t.eventCount}次)`).join("\n") || "(无)"}
|
|
128
|
+
|
|
129
|
+
Items (购买/收到):
|
|
130
|
+
${topItems.slice(0, 10).map((i) => `- ${i.name} (${i.occurrences}次, ¥${i.totalSpend})`).join("\n") || "(无)"}
|
|
131
|
+
|
|
132
|
+
最近事件 titles (抽样):
|
|
133
|
+
${events.slice(0, 20).map((e) => `- ${e.content?.title || "(无标题)"}`).join("\n")}
|
|
134
|
+
|
|
135
|
+
请将以上抽 3-5 个兴趣类别(如"咖啡"、"科技阅读"、"户外旅行"),每个给出 1-2 个 evidence 引用。
|
|
136
|
+
输出 JSON 数组:[{"category": "类别名", "evidenceCount": N, "examples": ["..."]}, ...]
|
|
137
|
+
只输出 JSON,不要其它文字。`;
|
|
138
|
+
|
|
139
|
+
const resp = await this.callLlmCommentary([
|
|
140
|
+
{ role: "system", content: "你是一个克制的兴趣画像分析助手。基于明示数据归纳类别,不臆造。" },
|
|
141
|
+
{ role: "user", content: userMsg },
|
|
142
|
+
], { acceptNonLocal: options.acceptNonLocal });
|
|
143
|
+
|
|
144
|
+
if (!resp) return null;
|
|
145
|
+
// Parse JSON array (strict → fenced → regex)
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(resp.trim());
|
|
148
|
+
} catch (_e) {}
|
|
149
|
+
const fence = resp.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
|
|
150
|
+
if (fence) {
|
|
151
|
+
try { return JSON.parse(fence[1].trim()); } catch (_e) {}
|
|
152
|
+
}
|
|
153
|
+
const arrMatch = resp.match(/\[\s*\{[\s\S]*?\}\s*\]/);
|
|
154
|
+
if (arrMatch) {
|
|
155
|
+
try { return JSON.parse(arrMatch[0]); } catch (_e) {}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { InterestsSkill };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 11 — analysis.relations skill.
|
|
3
|
+
*
|
|
4
|
+
* Per-Person interaction profile. Either:
|
|
5
|
+
* - `personId`: scope to one specific Person (uses merge-group expansion
|
|
6
|
+
* so cross-source identities count together) — returns single
|
|
7
|
+
* person's interaction profile vs self.
|
|
8
|
+
* - no `personId`: ranks ALL Persons by total interaction count and
|
|
9
|
+
* returns the top-N.
|
|
10
|
+
*
|
|
11
|
+
* "Interaction" = any Event where the Person is actor or participant.
|
|
12
|
+
* Counts include payments, messages, emails — adapter-agnostic.
|
|
13
|
+
*
|
|
14
|
+
* Output:
|
|
15
|
+
* {
|
|
16
|
+
* mode: "single" | "ranked",
|
|
17
|
+
* personId: ...,
|
|
18
|
+
* profile?: { // single mode
|
|
19
|
+
* personId, names,
|
|
20
|
+
* totalInteractions, byAdapter, byMonth,
|
|
21
|
+
* outboundCount, inboundCount, outboundShare,
|
|
22
|
+
* totalSpend, totalIncome,
|
|
23
|
+
* firstInteraction, lastInteraction,
|
|
24
|
+
* },
|
|
25
|
+
* ranked?: [...], // ranked mode
|
|
26
|
+
* citations,
|
|
27
|
+
* llm_commentary,
|
|
28
|
+
* }
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
"use strict";
|
|
32
|
+
|
|
33
|
+
const { AnalysisSkill } = require("./base");
|
|
34
|
+
|
|
35
|
+
class RelationsSkill extends AnalysisSkill {
|
|
36
|
+
constructor(opts) {
|
|
37
|
+
super({ ...opts, name: "analysis.relations" });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async run(options = {}) {
|
|
41
|
+
if (typeof options.personId === "string" && options.personId.length > 0) {
|
|
42
|
+
return await this._runSingle(options);
|
|
43
|
+
}
|
|
44
|
+
return await this._runRanked(options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async _runSingle(options) {
|
|
48
|
+
const { since, until } = this.resolveTimeWindow(options);
|
|
49
|
+
const members = this.expandToMergeGroup(options.personId);
|
|
50
|
+
const memberSet = new Set(members);
|
|
51
|
+
|
|
52
|
+
const events = this._fetchAllRelevant({ since, until, memberSet });
|
|
53
|
+
const profile = this._buildProfile(options.personId, members, events);
|
|
54
|
+
const citations = events.slice(0, 50).map((e) => e.id);
|
|
55
|
+
|
|
56
|
+
let llmCommentary = null;
|
|
57
|
+
if (options.commentary !== false && this.llm) {
|
|
58
|
+
llmCommentary = await this._llmCommentary(profile, options);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
skill: "analysis.relations",
|
|
63
|
+
mode: "single",
|
|
64
|
+
personId: options.personId,
|
|
65
|
+
profile,
|
|
66
|
+
citations,
|
|
67
|
+
llm_commentary: llmCommentary,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async _runRanked(options) {
|
|
72
|
+
const { since, until } = this.resolveTimeWindow(options);
|
|
73
|
+
const topN = Number.isFinite(options.topN) && options.topN > 0 ? options.topN : 20;
|
|
74
|
+
|
|
75
|
+
// Pull all events in window then bucket by counterparty
|
|
76
|
+
const allEvents = this._fetchAllRelevant({ since, until, memberSet: null });
|
|
77
|
+
const buckets = new Map();
|
|
78
|
+
for (const e of allEvents) {
|
|
79
|
+
const ids = (e.participants || []).concat(e.actor ? [e.actor] : []);
|
|
80
|
+
for (const pid of new Set(ids)) {
|
|
81
|
+
if (pid === "person-self" || !pid) continue;
|
|
82
|
+
const cur = buckets.get(pid) || {
|
|
83
|
+
personId: pid, totalInteractions: 0, totalSpend: 0, totalIncome: 0,
|
|
84
|
+
byAdapter: {}, firstSeen: e.occurredAt, lastSeen: e.occurredAt,
|
|
85
|
+
};
|
|
86
|
+
cur.totalInteractions += 1;
|
|
87
|
+
if (e.content?.amount?.direction === "out") cur.totalSpend += e.content.amount.value;
|
|
88
|
+
if (e.content?.amount?.direction === "in") cur.totalIncome += e.content.amount.value;
|
|
89
|
+
const adapter = (e.source && e.source.adapter) || "unknown";
|
|
90
|
+
cur.byAdapter[adapter] = (cur.byAdapter[adapter] || 0) + 1;
|
|
91
|
+
if (e.occurredAt < cur.firstSeen) cur.firstSeen = e.occurredAt;
|
|
92
|
+
if (e.occurredAt > cur.lastSeen) cur.lastSeen = e.occurredAt;
|
|
93
|
+
buckets.set(pid, cur);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Resolve display names per top bucket; ignore self if it sneaks in
|
|
97
|
+
const ranked = Array.from(buckets.values())
|
|
98
|
+
.sort((a, b) => b.totalInteractions - a.totalInteractions)
|
|
99
|
+
.slice(0, topN)
|
|
100
|
+
.map((b) => ({
|
|
101
|
+
...b,
|
|
102
|
+
totalSpend: Math.round(b.totalSpend * 100) / 100,
|
|
103
|
+
totalIncome: Math.round(b.totalIncome * 100) / 100,
|
|
104
|
+
name: this._lookupName(b.personId),
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
skill: "analysis.relations",
|
|
109
|
+
mode: "ranked",
|
|
110
|
+
ranked,
|
|
111
|
+
citations: allEvents.slice(0, 50).map((e) => e.id),
|
|
112
|
+
llm_commentary: null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_fetchAllRelevant({ since, until, memberSet }) {
|
|
117
|
+
// No subtype filter — relations cares about ALL events. Limit guards
|
|
118
|
+
// memory for big vaults.
|
|
119
|
+
const q = { limit: 10_000 };
|
|
120
|
+
if (since != null) q.since = since;
|
|
121
|
+
if (until != null) q.until = until;
|
|
122
|
+
const events = this.vault.queryEvents(q) || [];
|
|
123
|
+
if (!memberSet) return events;
|
|
124
|
+
return events.filter((e) => {
|
|
125
|
+
if (memberSet.has(e.actor)) return true;
|
|
126
|
+
if (Array.isArray(e.participants) && e.participants.some((p) => memberSet.has(p))) return true;
|
|
127
|
+
return false;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_buildProfile(personId, members, events) {
|
|
132
|
+
let outboundCount = 0;
|
|
133
|
+
let inboundCount = 0;
|
|
134
|
+
let totalSpend = 0;
|
|
135
|
+
let totalIncome = 0;
|
|
136
|
+
let firstInteraction = Infinity;
|
|
137
|
+
let lastInteraction = -Infinity;
|
|
138
|
+
const byAdapter = {};
|
|
139
|
+
const byMonth = {};
|
|
140
|
+
const memberSet = new Set(members);
|
|
141
|
+
|
|
142
|
+
for (const e of events) {
|
|
143
|
+
const t = e.occurredAt || 0;
|
|
144
|
+
if (t < firstInteraction) firstInteraction = t;
|
|
145
|
+
if (t > lastInteraction) lastInteraction = t;
|
|
146
|
+
// Outbound = self → them (actor=self, target=them); inbound = them → self
|
|
147
|
+
if (e.actor === "person-self" || memberSet.has(e.actor)) {
|
|
148
|
+
if (e.actor === "person-self") outboundCount += 1;
|
|
149
|
+
else inboundCount += 1;
|
|
150
|
+
} else {
|
|
151
|
+
// Participant-only event; counts as both? Most adapters keep
|
|
152
|
+
// actor+participants consistent so this branch is rare.
|
|
153
|
+
outboundCount += 1;
|
|
154
|
+
}
|
|
155
|
+
if (e.content?.amount) {
|
|
156
|
+
if (e.content.amount.direction === "out") totalSpend += e.content.amount.value;
|
|
157
|
+
else if (e.content.amount.direction === "in") totalIncome += e.content.amount.value;
|
|
158
|
+
}
|
|
159
|
+
const adapter = (e.source && e.source.adapter) || "unknown";
|
|
160
|
+
byAdapter[adapter] = (byAdapter[adapter] || 0) + 1;
|
|
161
|
+
const d = new Date(t);
|
|
162
|
+
if (Number.isFinite(d.getTime())) {
|
|
163
|
+
const m = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
164
|
+
byMonth[m] = (byMonth[m] || 0) + 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const total = outboundCount + inboundCount;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
personId,
|
|
171
|
+
members,
|
|
172
|
+
names: this._lookupNames(members),
|
|
173
|
+
totalInteractions: total,
|
|
174
|
+
byAdapter,
|
|
175
|
+
byMonth,
|
|
176
|
+
outboundCount,
|
|
177
|
+
inboundCount,
|
|
178
|
+
outboundShare: total > 0 ? Math.round((outboundCount / total) * 100) / 100 : 0,
|
|
179
|
+
totalSpend: Math.round(totalSpend * 100) / 100,
|
|
180
|
+
totalIncome: Math.round(totalIncome * 100) / 100,
|
|
181
|
+
firstInteraction: firstInteraction === Infinity ? null : firstInteraction,
|
|
182
|
+
lastInteraction: lastInteraction === -Infinity ? null : lastInteraction,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_lookupName(personId) {
|
|
187
|
+
try {
|
|
188
|
+
const p = this.vault.getPerson ? this.vault.getPerson(personId) : null;
|
|
189
|
+
return (p && p.names && p.names[0]) || personId;
|
|
190
|
+
} catch (_e) {
|
|
191
|
+
return personId;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_lookupNames(personIds) {
|
|
196
|
+
const set = new Set();
|
|
197
|
+
for (const id of personIds) {
|
|
198
|
+
try {
|
|
199
|
+
const p = this.vault.getPerson ? this.vault.getPerson(id) : null;
|
|
200
|
+
if (p && Array.isArray(p.names)) {
|
|
201
|
+
for (const n of p.names) if (n) set.add(n);
|
|
202
|
+
}
|
|
203
|
+
} catch (_e) {}
|
|
204
|
+
}
|
|
205
|
+
return Array.from(set);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async _llmCommentary(profile, options) {
|
|
209
|
+
if (!profile.totalInteractions) return "No interactions found in this period.";
|
|
210
|
+
const userMsg = `分析与某人的关系:
|
|
211
|
+
- 姓名/别名: ${profile.names.join(", ") || profile.personId}
|
|
212
|
+
- 互动总数: ${profile.totalInteractions}
|
|
213
|
+
- 主动占比: ${(profile.outboundShare * 100).toFixed(0)}% (${profile.outboundCount} 主动 vs ${profile.inboundCount} 收到)
|
|
214
|
+
- 钱款来往: 支出 ¥${profile.totalSpend} / 收入 ¥${profile.totalIncome}
|
|
215
|
+
- 跨源: ${Object.keys(profile.byAdapter).join(", ")}
|
|
216
|
+
- 时间跨度: ${profile.firstInteraction ? new Date(profile.firstInteraction).toISOString().slice(0,10) : "?"} 到 ${profile.lastInteraction ? new Date(profile.lastInteraction).toISOString().slice(0,10) : "?"}
|
|
217
|
+
|
|
218
|
+
请用 2-3 句话总结关系特征(亲密 / 疏远 / 单向 / 平等)。中文回答。`;
|
|
219
|
+
return await this.callLlmCommentary([
|
|
220
|
+
{ role: "system", content: "你是一个克制的人际分析助手。仅基于提供的数据给出温和的描述性总结,不评价、不臆断情感。" },
|
|
221
|
+
{ role: "user", content: userMsg },
|
|
222
|
+
], { acceptNonLocal: options.acceptNonLocal });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { RelationsSkill };
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 11 — analysis.spending skill.
|
|
3
|
+
*
|
|
4
|
+
* Inputs:
|
|
5
|
+
* - timeWindow: { since, until } | { sinceDays N } | { sinceMonths N }
|
|
6
|
+
* - dimension: "merchant" | "category" | "counterparty" | "month"
|
|
7
|
+
* Default "merchant".
|
|
8
|
+
* - merchantFilter: optional substring (e.g. "美团" to scope to one
|
|
9
|
+
* merchant family)
|
|
10
|
+
* - personId: optional — scope to spending TO this person (uses
|
|
11
|
+
* merge-group expansion)
|
|
12
|
+
* - topN: default 10
|
|
13
|
+
*
|
|
14
|
+
* Output:
|
|
15
|
+
* {
|
|
16
|
+
* summary: {
|
|
17
|
+
* totalSpend, totalIncome, netFlow, currency,
|
|
18
|
+
* eventCount, uniqueCounterparties, period,
|
|
19
|
+
* },
|
|
20
|
+
* breakdown: [{ key, totalSpend, eventCount, percentOfTotal }, ...],
|
|
21
|
+
* trend: [{ monthKey, totalSpend, eventCount }, ...],
|
|
22
|
+
* citations: [eventId, ...],
|
|
23
|
+
* llm_commentary: "..." | null,
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
"use strict";
|
|
28
|
+
|
|
29
|
+
const { AnalysisSkill } = require("./base");
|
|
30
|
+
|
|
31
|
+
const SUPPORTED_DIMENSIONS = new Set(["merchant", "category", "counterparty", "month"]);
|
|
32
|
+
|
|
33
|
+
class SpendingSkill extends AnalysisSkill {
|
|
34
|
+
constructor(opts) {
|
|
35
|
+
super({ ...opts, name: "analysis.spending" });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async run(options = {}) {
|
|
39
|
+
const { since, until } = this.resolveTimeWindow(options);
|
|
40
|
+
const dimension = SUPPORTED_DIMENSIONS.has(options.dimension)
|
|
41
|
+
? options.dimension
|
|
42
|
+
: "merchant";
|
|
43
|
+
const topN = Number.isFinite(options.topN) && options.topN > 0 ? options.topN : 10;
|
|
44
|
+
|
|
45
|
+
// Pull events with subtype = payment / transfer / refund / utility /
|
|
46
|
+
// redenvelope / investment / income. These are the ones with content.amount.
|
|
47
|
+
const events = this._fetchPaymentEvents({ since, until });
|
|
48
|
+
const filtered = this._applyFilters(events, options);
|
|
49
|
+
|
|
50
|
+
const summary = this._summarize(filtered, since, until);
|
|
51
|
+
const breakdown = this._breakdown(filtered, dimension, topN);
|
|
52
|
+
const trend = this._monthlyTrend(filtered);
|
|
53
|
+
const citations = filtered.slice(0, 50).map((e) => e.id);
|
|
54
|
+
|
|
55
|
+
let llmCommentary = null;
|
|
56
|
+
if (options.commentary !== false && this.llm) {
|
|
57
|
+
llmCommentary = await this._llmCommentary(summary, breakdown, dimension, options);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
skill: "analysis.spending",
|
|
62
|
+
summary,
|
|
63
|
+
breakdown,
|
|
64
|
+
trend,
|
|
65
|
+
citations,
|
|
66
|
+
llm_commentary: llmCommentary,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_fetchPaymentEvents({ since, until }) {
|
|
71
|
+
const events = [];
|
|
72
|
+
// Phase 7 shopping adapters emit subtype="order" — must include so
|
|
73
|
+
// spending aggregates cover Taobao/JD/Meituan along with Alipay
|
|
74
|
+
// (payment/transfer) + Email (refund) etc.
|
|
75
|
+
const subtypes = ["payment", "transfer", "refund", "utility", "redenvelope", "investment", "income", "order"];
|
|
76
|
+
for (const subtype of subtypes) {
|
|
77
|
+
const q = { subtype, limit: 5000 };
|
|
78
|
+
if (since != null) q.since = since;
|
|
79
|
+
if (until != null) q.until = until;
|
|
80
|
+
const batch = this.vault.queryEvents(q) || [];
|
|
81
|
+
for (const e of batch) {
|
|
82
|
+
// queryEvents may strip extra; we already get full row from vault
|
|
83
|
+
if (e && e.content && e.content.amount && Number.isFinite(e.content.amount.value)) {
|
|
84
|
+
events.push(e);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return events;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_applyFilters(events, options) {
|
|
92
|
+
let out = events;
|
|
93
|
+
if (typeof options.merchantFilter === "string" && options.merchantFilter.length > 0) {
|
|
94
|
+
const needle = options.merchantFilter.toLowerCase();
|
|
95
|
+
out = out.filter((e) => {
|
|
96
|
+
const title = (e.content && e.content.title) || "";
|
|
97
|
+
const counterparty = (e.extra && e.extra.counterparty) || "";
|
|
98
|
+
return title.toLowerCase().includes(needle)
|
|
99
|
+
|| counterparty.toLowerCase().includes(needle);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (typeof options.personId === "string" && options.personId.length > 0) {
|
|
103
|
+
const memberSet = new Set(this.expandToMergeGroup(options.personId));
|
|
104
|
+
out = out.filter((e) => {
|
|
105
|
+
if (memberSet.has(e.actor)) return true;
|
|
106
|
+
if (Array.isArray(e.participants) && e.participants.some((p) => memberSet.has(p))) return true;
|
|
107
|
+
return false;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (options.direction === "out" || options.direction === "in") {
|
|
111
|
+
out = out.filter((e) => e.content.amount.direction === options.direction);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_summarize(events, since, until) {
|
|
117
|
+
let totalSpend = 0;
|
|
118
|
+
let totalIncome = 0;
|
|
119
|
+
const counterparties = new Set();
|
|
120
|
+
for (const e of events) {
|
|
121
|
+
const v = e.content.amount.value;
|
|
122
|
+
if (e.content.amount.direction === "in") totalIncome += v;
|
|
123
|
+
else if (e.content.amount.direction === "out") totalSpend += v;
|
|
124
|
+
// Identify counterparty for distinctness
|
|
125
|
+
const cp = (e.extra && e.extra.counterparty) || e.actor;
|
|
126
|
+
if (cp && cp !== "person-self") counterparties.add(cp);
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
totalSpend: Math.round(totalSpend * 100) / 100,
|
|
130
|
+
totalIncome: Math.round(totalIncome * 100) / 100,
|
|
131
|
+
netFlow: Math.round((totalIncome - totalSpend) * 100) / 100,
|
|
132
|
+
currency: events[0]?.content?.amount?.currency || "CNY",
|
|
133
|
+
eventCount: events.length,
|
|
134
|
+
uniqueCounterparties: counterparties.size,
|
|
135
|
+
period: { since: since || null, until: until || null },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_breakdown(events, dimension, topN) {
|
|
140
|
+
const buckets = new Map();
|
|
141
|
+
for (const e of events) {
|
|
142
|
+
// Only count "out" for spending breakdown — income tracked separately
|
|
143
|
+
if (e.content.amount.direction !== "out") continue;
|
|
144
|
+
const key = this._keyFor(e, dimension);
|
|
145
|
+
if (!key) continue;
|
|
146
|
+
const cur = buckets.get(key) || { key, totalSpend: 0, eventCount: 0 };
|
|
147
|
+
cur.totalSpend += e.content.amount.value;
|
|
148
|
+
cur.eventCount += 1;
|
|
149
|
+
buckets.set(key, cur);
|
|
150
|
+
}
|
|
151
|
+
const totalOut = Array.from(buckets.values()).reduce((s, b) => s + b.totalSpend, 0);
|
|
152
|
+
const sorted = Array.from(buckets.values())
|
|
153
|
+
.map((b) => ({
|
|
154
|
+
...b,
|
|
155
|
+
totalSpend: Math.round(b.totalSpend * 100) / 100,
|
|
156
|
+
percentOfTotal: totalOut > 0 ? Math.round((b.totalSpend / totalOut) * 1000) / 10 : 0,
|
|
157
|
+
}))
|
|
158
|
+
.sort((a, b) => b.totalSpend - a.totalSpend)
|
|
159
|
+
.slice(0, topN);
|
|
160
|
+
return sorted;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
_monthlyTrend(events) {
|
|
164
|
+
const buckets = new Map();
|
|
165
|
+
for (const e of events) {
|
|
166
|
+
if (e.content.amount.direction !== "out") continue;
|
|
167
|
+
const d = new Date(e.occurredAt);
|
|
168
|
+
if (!Number.isFinite(d.getTime())) continue;
|
|
169
|
+
const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
170
|
+
const cur = buckets.get(monthKey) || { monthKey, totalSpend: 0, eventCount: 0 };
|
|
171
|
+
cur.totalSpend += e.content.amount.value;
|
|
172
|
+
cur.eventCount += 1;
|
|
173
|
+
buckets.set(monthKey, cur);
|
|
174
|
+
}
|
|
175
|
+
return Array.from(buckets.values())
|
|
176
|
+
.map((b) => ({ ...b, totalSpend: Math.round(b.totalSpend * 100) / 100 }))
|
|
177
|
+
.sort((a, b) => a.monthKey.localeCompare(b.monthKey));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_keyFor(event, dimension) {
|
|
181
|
+
if (dimension === "merchant" || dimension === "counterparty") {
|
|
182
|
+
return (event.extra && event.extra.counterparty)
|
|
183
|
+
|| (event.content && event.content.title)
|
|
184
|
+
|| "(unknown)";
|
|
185
|
+
}
|
|
186
|
+
if (dimension === "category") {
|
|
187
|
+
return (event.extra && event.extra.category)
|
|
188
|
+
|| event.subtype
|
|
189
|
+
|| "(uncategorized)";
|
|
190
|
+
}
|
|
191
|
+
if (dimension === "month") {
|
|
192
|
+
const d = new Date(event.occurredAt);
|
|
193
|
+
if (!Number.isFinite(d.getTime())) return "(unknown date)";
|
|
194
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async _llmCommentary(summary, breakdown, dimension, options) {
|
|
200
|
+
if (summary.eventCount === 0) return "No spending events found in this period.";
|
|
201
|
+
const topItems = breakdown.slice(0, 5).map((b) => `${b.key} ¥${b.totalSpend} (${b.percentOfTotal}%)`).join(", ");
|
|
202
|
+
const periodStr = summary.period.since
|
|
203
|
+
? `${new Date(summary.period.since).toISOString().slice(0, 10)} 至 ${new Date(summary.period.until).toISOString().slice(0, 10)}`
|
|
204
|
+
: "全部时间";
|
|
205
|
+
const userMsg = `用户的消费数据:
|
|
206
|
+
- 期间:${periodStr}
|
|
207
|
+
- 总支出 ¥${summary.totalSpend} (${summary.currency}), 总收入 ¥${summary.totalIncome}, 净流 ¥${summary.netFlow}
|
|
208
|
+
- 共 ${summary.eventCount} 笔交易, ${summary.uniqueCounterparties} 个独特对方
|
|
209
|
+
- 按 ${dimension} 排名 top 5:${topItems}
|
|
210
|
+
|
|
211
|
+
请用 2-3 句话点评消费习惯,指出最大支出方向和异常(如有)。中文回答。`;
|
|
212
|
+
return await this.callLlmCommentary([
|
|
213
|
+
{ role: "system", content: "你是一个理性、克制的财务分析助手。基于事实给出简短结论,不夸张、不臆断。" },
|
|
214
|
+
{ role: "user", content: userMsg },
|
|
215
|
+
], { acceptNonLocal: options.acceptNonLocal });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { SpendingSkill, SUPPORTED_DIMENSIONS };
|