agent-workbench-db.js 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729
  1. const fs = require('fs');
  2. const path = require('path');
  3. const crypto = require('crypto');
  4. const { DatabaseSync } = require('node:sqlite');
  5. const now = () => new Date().toISOString();
  6. const makeId = prefix => `${prefix}_${crypto.randomUUID()}`;
  7. const json = value => JSON.stringify(value ?? null);
  8. const parse = (value, fallback) => {
  9. try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
  10. };
  11. const normalizeKeyPart = value => String(value || '')
  12. .trim()
  13. .toLowerCase()
  14. .replace(/[\s\p{P}\p{S}]+/gu, '_')
  15. .replace(/^_+|_+$/g, '')
  16. .slice(0, 160);
  17. function evidenceItems(value, fallbackText = '', sourceMessageId = null) {
  18. const rows = Array.isArray(value) ? value : [];
  19. const items = rows.map(item => typeof item === 'string'
  20. ? { text: item, sourceMessageId: null }
  21. : {
  22. text: String(item?.text || item?.evidence || '').trim(),
  23. sourceMessageId: item?.sourceMessageId || item?.source_message_id || null,
  24. createdAt: item?.createdAt || item?.created_at || null,
  25. }).filter(item => item.text);
  26. const text = String(fallbackText || '').trim();
  27. if (text) items.push({ text, sourceMessageId: sourceMessageId || null, createdAt: now() });
  28. const unique = new Map();
  29. for (const item of items) {
  30. const key = `${item.sourceMessageId || ''}\u0000${item.text}`;
  31. if (!unique.has(key)) unique.set(key, item);
  32. }
  33. return [...unique.values()].slice(-30);
  34. }
  35. class AgentWorkbenchDb {
  36. constructor(filePath, defaults = {}) {
  37. fs.mkdirSync(path.dirname(filePath), { recursive: true });
  38. this.filePath = filePath;
  39. this.db = new DatabaseSync(filePath);
  40. this.db.exec('PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;');
  41. this.init(defaults);
  42. }
  43. init(defaults) {
  44. this.db.exec(`
  45. CREATE TABLE IF NOT EXISTS settings (
  46. key TEXT PRIMARY KEY,
  47. value TEXT NOT NULL,
  48. updated_at TEXT NOT NULL
  49. );
  50. CREATE TABLE IF NOT EXISTS conversations (
  51. id TEXT PRIMARY KEY,
  52. contact_id TEXT NOT NULL UNIQUE,
  53. contact_name TEXT NOT NULL DEFAULT '',
  54. mode TEXT NOT NULL DEFAULT 'review' CHECK(mode IN ('review','auto','human','paused')),
  55. last_message_at TEXT,
  56. created_at TEXT NOT NULL,
  57. updated_at TEXT NOT NULL
  58. );
  59. CREATE TABLE IF NOT EXISTS messages (
  60. id TEXT PRIMARY KEY,
  61. conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  62. external_id TEXT UNIQUE,
  63. direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound')),
  64. sender_type TEXT NOT NULL CHECK(sender_type IN ('customer','agent','human','system')),
  65. content TEXT NOT NULL,
  66. status TEXT NOT NULL DEFAULT 'received',
  67. created_at TEXT NOT NULL,
  68. raw_json TEXT
  69. );
  70. CREATE TABLE IF NOT EXISTS customer_profiles (
  71. conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
  72. profile_json TEXT NOT NULL DEFAULT '{}',
  73. tags_json TEXT NOT NULL DEFAULT '[]',
  74. updated_at TEXT NOT NULL
  75. );
  76. CREATE TABLE IF NOT EXISTS customer_tasks (
  77. id TEXT PRIMARY KEY,
  78. conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  79. fingerprint TEXT NOT NULL,
  80. type TEXT NOT NULL DEFAULT 'follow_up',
  81. title TEXT NOT NULL,
  82. owner TEXT NOT NULL DEFAULT '',
  83. due_at TEXT NOT NULL DEFAULT '',
  84. priority TEXT NOT NULL DEFAULT 'medium',
  85. status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','in_progress','done','dismissed')),
  86. reason TEXT NOT NULL DEFAULT '',
  87. evidence TEXT NOT NULL DEFAULT '',
  88. source_message_id TEXT,
  89. created_at TEXT NOT NULL,
  90. updated_at TEXT NOT NULL,
  91. UNIQUE(conversation_id, fingerprint)
  92. );
  93. CREATE TABLE IF NOT EXISTS customer_alerts (
  94. id TEXT PRIMARY KEY,
  95. conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  96. fingerprint TEXT NOT NULL,
  97. type TEXT NOT NULL DEFAULT 'attention',
  98. severity TEXT NOT NULL DEFAULT 'medium',
  99. title TEXT NOT NULL,
  100. detail TEXT NOT NULL DEFAULT '',
  101. evidence TEXT NOT NULL DEFAULT '',
  102. recommended_action TEXT NOT NULL DEFAULT '',
  103. status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','acknowledged','resolved','dismissed')),
  104. source_message_id TEXT,
  105. created_at TEXT NOT NULL,
  106. updated_at TEXT NOT NULL,
  107. UNIQUE(conversation_id, fingerprint)
  108. );
  109. CREATE TABLE IF NOT EXISTS customer_recommendations (
  110. id TEXT PRIMARY KEY,
  111. conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  112. property_id TEXT NOT NULL,
  113. property_snapshot_json TEXT NOT NULL DEFAULT '{}',
  114. sources_json TEXT NOT NULL DEFAULT '[]',
  115. status TEXT NOT NULL DEFAULT 'recommended' CHECK(status IN ('candidate','recommended','interested','rejected','viewing','viewed','closed')),
  116. feedback_reason TEXT NOT NULL DEFAULT '',
  117. recommend_count INTEGER NOT NULL DEFAULT 1,
  118. first_recommended_at TEXT NOT NULL,
  119. last_recommended_at TEXT NOT NULL,
  120. updated_at TEXT NOT NULL,
  121. UNIQUE(conversation_id, property_id)
  122. );
  123. CREATE TABLE IF NOT EXISTS drafts (
  124. id TEXT PRIMARY KEY,
  125. conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  126. inbound_message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
  127. content TEXT NOT NULL,
  128. status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','sent','failed')),
  129. confidence REAL NOT NULL DEFAULT 0,
  130. intent TEXT NOT NULL DEFAULT '',
  131. reason TEXT NOT NULL DEFAULT '',
  132. requires_human INTEGER NOT NULL DEFAULT 1,
  133. citations_json TEXT NOT NULL DEFAULT '[]',
  134. tool_trace_json TEXT NOT NULL DEFAULT '[]',
  135. created_at TEXT NOT NULL,
  136. reviewed_at TEXT,
  137. reviewer TEXT,
  138. error TEXT,
  139. sent_message_id TEXT
  140. );
  141. CREATE TABLE IF NOT EXISTS audit_logs (
  142. id TEXT PRIMARY KEY,
  143. actor TEXT NOT NULL,
  144. action TEXT NOT NULL,
  145. conversation_id TEXT,
  146. entity_id TEXT,
  147. detail_json TEXT NOT NULL DEFAULT '{}',
  148. created_at TEXT NOT NULL
  149. );
  150. CREATE TABLE IF NOT EXISTS poll_state (
  151. key TEXT PRIMARY KEY,
  152. value TEXT NOT NULL,
  153. updated_at TEXT NOT NULL
  154. );
  155. CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
  156. CREATE INDEX IF NOT EXISTS idx_messages_content ON messages(conversation_id, direction, content, created_at);
  157. CREATE INDEX IF NOT EXISTS idx_drafts_status ON drafts(status, created_at);
  158. CREATE INDEX IF NOT EXISTS idx_customer_tasks_conversation ON customer_tasks(conversation_id, status, updated_at DESC);
  159. CREATE INDEX IF NOT EXISTS idx_customer_alerts_conversation ON customer_alerts(conversation_id, status, updated_at DESC);
  160. CREATE INDEX IF NOT EXISTS idx_customer_recommendations_conversation ON customer_recommendations(conversation_id, status, last_recommended_at DESC);
  161. CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at DESC);
  162. `);
  163. this.ensureColumn('customer_tasks', 'business_key', "business_key TEXT NOT NULL DEFAULT ''");
  164. this.ensureColumn('customer_tasks', 'managed_by', "managed_by TEXT NOT NULL DEFAULT 'agent'");
  165. this.ensureColumn('customer_tasks', 'evidence_json', "evidence_json TEXT NOT NULL DEFAULT '[]'");
  166. this.ensureColumn('customer_tasks', 'resolution_reason', "resolution_reason TEXT NOT NULL DEFAULT ''");
  167. this.ensureColumn('customer_tasks', 'official_todo_id', "official_todo_id TEXT NOT NULL DEFAULT ''");
  168. this.ensureColumn('customer_tasks', 'official_sync_status', "official_sync_status TEXT NOT NULL DEFAULT ''");
  169. this.ensureColumn('customer_tasks', 'official_synced_at', 'official_synced_at TEXT');
  170. this.ensureColumn('customer_alerts', 'business_key', "business_key TEXT NOT NULL DEFAULT ''");
  171. this.ensureColumn('customer_alerts', 'managed_by', "managed_by TEXT NOT NULL DEFAULT 'agent'");
  172. this.ensureColumn('customer_alerts', 'evidence_json', "evidence_json TEXT NOT NULL DEFAULT '[]'");
  173. this.ensureColumn('customer_alerts', 'resolution_reason', "resolution_reason TEXT NOT NULL DEFAULT ''");
  174. this.lastIntelligenceMigration = this.migrateCustomerIntelligenceRecords();
  175. this.db.exec(`
  176. CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_tasks_business_key ON customer_tasks(conversation_id, business_key);
  177. CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_alerts_business_key ON customer_alerts(conversation_id, business_key);
  178. `);
  179. this.setDefault('global_paused', defaults.globalPaused ? 'true' : 'false');
  180. this.setDefault('default_mode', defaults.defaultMode || 'review');
  181. this.setDefault('auto_send_confidence', String(defaults.autoSendConfidence ?? 0.88));
  182. this.setDefault('agent_cutover_at', defaults.cutoverAt || now());
  183. }
  184. close() { this.db.close(); }
  185. ensureColumn(table, column, definition) {
  186. const exists = this.db.prepare(`PRAGMA table_info(${table})`).all().some(item => item.name === column);
  187. if (!exists) this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
  188. }
  189. customerTaskBusinessKey(item = {}) {
  190. const title = String(item.title || '').trim();
  191. const type = normalizeKeyPart(item.type || 'follow_up');
  192. if (type === 'qualification' && /用途.*(购置)?时间/.test(title)) return 'qualification:purpose_and_timeline';
  193. if (type === 'recommendation' && /(筛选|发送).*(重点|方案)/.test(title)) return 'recommendation:shortlist';
  194. const explicit = item.businessKey || item.business_key || item.taskKey || item.key;
  195. if (explicit) return String(explicit).split(':').map(normalizeKeyPart).filter(Boolean).join(':');
  196. return `${type}:${normalizeKeyPart(title || 'task')}`;
  197. }
  198. customerAlertBusinessKey(item = {}) {
  199. const title = String(item.title || '').trim();
  200. const type = normalizeKeyPart(item.type || 'attention');
  201. if (type === 'high_intent' && /核心需求.*成形/.test(title)) return 'high_intent:core_demand_ready';
  202. if (type === 'complaint') return 'complaint:manual_takeover';
  203. if (type === 'time_sensitive') return 'time_sensitive:follow_up';
  204. const explicit = item.businessKey || item.business_key || item.alertKey || item.key;
  205. if (explicit) return String(explicit).split(':').map(normalizeKeyPart).filter(Boolean).join(':');
  206. return `${type}:${normalizeKeyPart(title || 'alert')}`;
  207. }
  208. migrateCustomerIntelligenceRecords() {
  209. const migrate = ({ table, keyFor, terminalStatus }) => {
  210. const rows = this.db.prepare(`SELECT * FROM ${table} ORDER BY created_at,id`).all();
  211. const groups = new Map();
  212. for (const row of rows) {
  213. const businessKey = keyFor.call(this, row);
  214. const groupKey = `${row.conversation_id}\u0000${businessKey}`;
  215. if (!groups.has(groupKey)) groups.set(groupKey, { businessKey, rows: [] });
  216. groups.get(groupKey).rows.push(row);
  217. }
  218. let removed = 0;
  219. for (const group of groups.values()) {
  220. const statusRank = terminalStatus === 'done'
  221. ? { dismissed: 0, open: 1, in_progress: 2, done: 3 }
  222. : { dismissed: 0, open: 1, acknowledged: 2, resolved: 3 };
  223. const canonical = [...group.rows].sort((a, b) =>
  224. (statusRank[b.status] || 0) - (statusRank[a.status] || 0) ||
  225. Date.parse(a.created_at) - Date.parse(b.created_at))[0];
  226. const evidence = evidenceItems(
  227. group.rows.flatMap(row => evidenceItems(parse(row.evidence_json, []), row.evidence, row.source_message_id)),
  228. );
  229. const duplicates = group.rows.filter(row => row.id !== canonical.id);
  230. for (const row of duplicates) {
  231. this.db.prepare(`DELETE FROM ${table} WHERE id=?`).run(row.id);
  232. removed += 1;
  233. }
  234. const fingerprint = this.intelligenceFingerprint(group.businessKey);
  235. this.db.prepare(`UPDATE ${table} SET business_key=?,fingerprint=?,evidence_json=?,evidence=?,updated_at=? WHERE id=?`)
  236. .run(group.businessKey, fingerprint, json(evidence), evidence.at(-1)?.text || canonical.evidence || '', canonical.updated_at || now(), canonical.id);
  237. }
  238. return { rows: rows.length, removed };
  239. };
  240. this.db.exec('BEGIN IMMEDIATE');
  241. try {
  242. const tasks = migrate({ table: 'customer_tasks', keyFor: this.customerTaskBusinessKey, terminalStatus: 'done' });
  243. const alerts = migrate({ table: 'customer_alerts', keyFor: this.customerAlertBusinessKey, terminalStatus: 'resolved' });
  244. this.db.exec('COMMIT');
  245. return { tasks, alerts };
  246. } catch (error) {
  247. this.db.exec('ROLLBACK');
  248. throw error;
  249. }
  250. }
  251. setDefault(key, value) {
  252. this.db.prepare('INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)').run(key, value, now());
  253. }
  254. getSetting(key, fallback = '') {
  255. return this.db.prepare('SELECT value FROM settings WHERE key=?').get(key)?.value ?? fallback;
  256. }
  257. setSetting(key, value) {
  258. this.db.prepare(`INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)
  259. ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`).run(key, String(value), now());
  260. }
  261. globalState() {
  262. return {
  263. paused: this.getSetting('global_paused', 'true') === 'true',
  264. defaultMode: this.getSetting('default_mode', 'review'),
  265. autoSendConfidence: Number(this.getSetting('auto_send_confidence', '0.88')),
  266. };
  267. }
  268. ensureConversation(contactId, contactName = '') {
  269. const existing = this.db.prepare('SELECT * FROM conversations WHERE contact_id=?').get(String(contactId));
  270. if (existing) {
  271. if (contactName && existing.contact_name !== contactName) {
  272. this.db.prepare('UPDATE conversations SET contact_name=?,updated_at=? WHERE id=?').run(contactName, now(), existing.id);
  273. }
  274. return this.getConversation(existing.id);
  275. }
  276. const conversationId = makeId('conv');
  277. const timestamp = now();
  278. this.db.prepare(`INSERT INTO conversations(id,contact_id,contact_name,mode,created_at,updated_at)
  279. VALUES(?,?,?,?,?,?)`).run(conversationId, String(contactId), contactName, this.getSetting('default_mode', 'review'), timestamp, timestamp);
  280. this.db.prepare('INSERT INTO customer_profiles(conversation_id,updated_at) VALUES(?,?)').run(conversationId, timestamp);
  281. return this.getConversation(conversationId);
  282. }
  283. getConversationByContactId(contactId) {
  284. return this.db.prepare('SELECT * FROM conversations WHERE contact_id=?').get(String(contactId)) || null;
  285. }
  286. getConversation(conversationId) {
  287. return this.db.prepare('SELECT * FROM conversations WHERE id=?').get(conversationId) || null;
  288. }
  289. listConversations() {
  290. return this.db.prepare(`SELECT c.*,
  291. (SELECT content FROM messages m WHERE m.conversation_id=c.id ORDER BY m.created_at DESC LIMIT 1) AS last_content,
  292. (SELECT COUNT(*) FROM drafts d WHERE d.conversation_id=c.id AND d.status='pending') AS pending_count
  293. FROM conversations c ORDER BY COALESCE(c.last_message_at,c.created_at) DESC`).all();
  294. }
  295. setConversationMode(conversationId, mode) {
  296. if (!['review', 'auto', 'human', 'paused'].includes(mode)) throw new Error('不支持的会话模式');
  297. const result = this.db.prepare('UPDATE conversations SET mode=?,updated_at=? WHERE id=?').run(mode, now(), conversationId);
  298. if (!result.changes) throw new Error('会话不存在');
  299. return this.getConversation(conversationId);
  300. }
  301. insertMessage({ conversationId, externalId = null, direction, senderType, content, status = 'received', createdAt = now(), raw = null }) {
  302. if (externalId) {
  303. const existing = this.db.prepare('SELECT * FROM messages WHERE external_id=?').get(externalId);
  304. if (existing) return { message: existing, created: false };
  305. }
  306. const messageId = makeId('msg');
  307. this.db.prepare(`INSERT INTO messages(id,conversation_id,external_id,direction,sender_type,content,status,created_at,raw_json)
  308. VALUES(?,?,?,?,?,?,?,?,?)`).run(messageId, conversationId, externalId, direction, senderType, String(content), status, createdAt, raw ? json(raw) : null);
  309. this.db.prepare('UPDATE conversations SET last_message_at=?,updated_at=? WHERE id=?').run(createdAt, now(), conversationId);
  310. return { message: this.getMessage(messageId), created: true };
  311. }
  312. getMessage(messageId) { return this.db.prepare('SELECT * FROM messages WHERE id=?').get(messageId) || null; }
  313. getLatestInbound(conversationId) {
  314. return this.db.prepare("SELECT * FROM messages WHERE conversation_id=? AND direction='inbound' ORDER BY created_at DESC LIMIT 1").get(conversationId) || null;
  315. }
  316. findRecentInboundDuplicate(conversationId, content, createdAt, windowSeconds = 60) {
  317. const rows = this.db.prepare("SELECT * FROM messages WHERE conversation_id=? AND direction='inbound' AND content=? ORDER BY created_at DESC LIMIT 20").all(conversationId, String(content));
  318. const timestamp = Date.parse(createdAt);
  319. return rows.find(row => Number.isFinite(timestamp) && Math.abs(timestamp - Date.parse(row.created_at)) <= windowSeconds * 1000) || null;
  320. }
  321. cleanupInboundContentDuplicates(windowSeconds = 60) {
  322. const rows = this.db.prepare("SELECT * FROM messages WHERE direction='inbound' ORDER BY conversation_id,created_at,id").all();
  323. const lastSeen = new Map();
  324. const duplicateIds = [];
  325. for (const row of rows) {
  326. const key = `${row.conversation_id}\u0000${row.content}`;
  327. const previous = lastSeen.get(key);
  328. const timestamp = Date.parse(row.created_at);
  329. if (previous && Number.isFinite(timestamp) && Math.abs(timestamp - previous.timestamp) <= windowSeconds * 1000) duplicateIds.push(row.id);
  330. else lastSeen.set(key, { timestamp, id: row.id });
  331. }
  332. const remove = this.db.prepare('DELETE FROM messages WHERE id=?');
  333. for (const messageId of duplicateIds) remove.run(messageId);
  334. return duplicateIds.length;
  335. }
  336. listMessages(conversationId, limit = 100) {
  337. return this.db.prepare(`SELECT * FROM (
  338. SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT ?
  339. ) ORDER BY created_at ASC`).all(conversationId, limit);
  340. }
  341. deleteImportedMessages(conversationId, source = 'manual_sync') {
  342. const pattern = `%\"source\":\"${String(source).replace(/[\"%]/g, '')}\"%`;
  343. const result = this.db.prepare('DELETE FROM messages WHERE conversation_id=? AND raw_json LIKE ?').run(conversationId, pattern);
  344. const latest = this.db.prepare('SELECT MAX(created_at) AS value FROM messages WHERE conversation_id=?').get(conversationId)?.value || null;
  345. this.db.prepare('UPDATE conversations SET last_message_at=?,updated_at=? WHERE id=?').run(latest, now(), conversationId);
  346. return Number(result.changes || 0);
  347. }
  348. getProfile(conversationId) {
  349. const row = this.db.prepare('SELECT * FROM customer_profiles WHERE conversation_id=?').get(conversationId);
  350. return row ? { profile: parse(row.profile_json, {}), tags: parse(row.tags_json, []), updatedAt: row.updated_at } : { profile: {}, tags: [] };
  351. }
  352. updateProfile(conversationId, profile, tags = []) {
  353. this.db.prepare(`INSERT INTO customer_profiles(conversation_id,profile_json,tags_json,updated_at) VALUES(?,?,?,?)
  354. ON CONFLICT(conversation_id) DO UPDATE SET profile_json=excluded.profile_json,tags_json=excluded.tags_json,updated_at=excluded.updated_at`)
  355. .run(conversationId, json(profile || {}), json(tags || []), now());
  356. return this.getProfile(conversationId);
  357. }
  358. mergeProfileByContactId(contactId, patch = {}, tags) {
  359. const conversation = this.getConversationByContactId(contactId);
  360. if (!conversation) return null;
  361. const current = this.getProfile(conversation.id);
  362. return this.updateProfile(
  363. conversation.id,
  364. { ...current.profile, ...(patch || {}) },
  365. tags === undefined ? current.tags : tags,
  366. );
  367. }
  368. intelligenceFingerprint(...parts) {
  369. return crypto.createHash('sha256').update(parts.map(item => String(item || '').trim().toLowerCase()).join('\u0000')).digest('hex').slice(0, 24);
  370. }
  371. upsertCustomerTasks(conversationId, tasks = [], sourceMessageId = null) {
  372. const results = [];
  373. const find = this.db.prepare('SELECT * FROM customer_tasks WHERE conversation_id=? AND business_key=?');
  374. const insert = this.db.prepare(`INSERT INTO customer_tasks(id,conversation_id,fingerprint,business_key,managed_by,type,title,owner,due_at,priority,status,reason,evidence,evidence_json,source_message_id,created_at,updated_at)
  375. VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
  376. const update = this.db.prepare(`UPDATE customer_tasks SET managed_by=?,type=?,title=?,owner=CASE WHEN owner='' THEN ? ELSE owner END,due_at=CASE WHEN due_at='' THEN ? ELSE due_at END,priority=?,reason=?,evidence=?,evidence_json=?,source_message_id=COALESCE(?,source_message_id),updated_at=? WHERE id=?`);
  377. for (const item of tasks) {
  378. const title = String(item?.title || '').trim();
  379. if (!title) continue;
  380. const type = String(item.type || 'follow_up').trim();
  381. const evidence = String(item.evidence || '').trim();
  382. const businessKey = this.customerTaskBusinessKey(item);
  383. const fingerprint = this.intelligenceFingerprint(businessKey);
  384. const managedBy = String(item.managedBy || item.managed_by || 'agent').trim();
  385. const itemSourceMessageId = Object.prototype.hasOwnProperty.call(item, 'sourceMessageId')
  386. ? item.sourceMessageId
  387. : sourceMessageId;
  388. const existing = find.get(conversationId, businessKey);
  389. if (existing) {
  390. const evidences = evidenceItems(parse(existing.evidence_json, []), itemSourceMessageId ? evidence : '', itemSourceMessageId);
  391. update.run(managedBy, type, title, String(item.owner || '').trim().replace(/^待分配$/, ''), String(item.dueAt || item.due_at || '').trim(), String(item.priority || 'medium').trim(), String(item.reason || '').trim(), evidence || existing.evidence || '', json(evidences), itemSourceMessageId, now(), existing.id);
  392. results.push(this.db.prepare('SELECT * FROM customer_tasks WHERE id=?').get(existing.id));
  393. } else {
  394. const id = makeId('task');
  395. const evidences = evidenceItems([], evidence, itemSourceMessageId);
  396. insert.run(id, conversationId, fingerprint, businessKey, managedBy, type, title, String(item.owner || '').trim().replace(/^待分配$/, ''), String(item.dueAt || item.due_at || '').trim(), String(item.priority || 'medium').trim(), 'open', String(item.reason || '').trim(), evidence, json(evidences), itemSourceMessageId, now(), now());
  397. results.push(this.db.prepare('SELECT * FROM customer_tasks WHERE id=?').get(id));
  398. }
  399. }
  400. return results;
  401. }
  402. reconcileCustomerTasks(conversationId, tasks = [], sourceMessageId = null) {
  403. const results = this.upsertCustomerTasks(conversationId, tasks, sourceMessageId);
  404. const activeRuleKeys = new Set(tasks
  405. .filter(item => String(item.managedBy || item.managed_by || 'agent') === 'rule')
  406. .map(item => this.customerTaskBusinessKey(item)));
  407. const existingRules = this.db.prepare("SELECT * FROM customer_tasks WHERE conversation_id=? AND managed_by='rule' AND status IN ('open','in_progress')").all(conversationId);
  408. const resolved = [];
  409. for (const task of existingRules) {
  410. if (activeRuleKeys.has(task.business_key)) continue;
  411. this.db.prepare(`UPDATE customer_tasks SET status='done',resolution_reason='profile_condition_resolved',
  412. official_sync_status=CASE WHEN official_todo_id<>'' THEN 'completion_pending' ELSE official_sync_status END,updated_at=? WHERE id=?`).run(now(), task.id);
  413. resolved.push(task.id);
  414. }
  415. return { tasks: results, resolved };
  416. }
  417. listCustomerTasks(conversationId, limit = 100) {
  418. return this.db.prepare(`SELECT * FROM customer_tasks WHERE conversation_id=?
  419. ORDER BY CASE status WHEN 'open' THEN 0 WHEN 'in_progress' THEN 1 ELSE 2 END,
  420. CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, updated_at DESC LIMIT ?`).all(conversationId, limit);
  421. }
  422. getCustomerTask(taskId) {
  423. return this.db.prepare('SELECT * FROM customer_tasks WHERE id=?').get(taskId) || null;
  424. }
  425. completeCustomerTaskByBusinessKey(conversationId, businessKey, reason = 'business_action_completed') {
  426. const key = this.customerTaskBusinessKey({ businessKey });
  427. const task = this.db.prepare('SELECT * FROM customer_tasks WHERE conversation_id=? AND business_key=?').get(conversationId, key);
  428. if (!task || !['open', 'in_progress'].includes(task.status)) return task || null;
  429. this.db.prepare(`UPDATE customer_tasks SET status='done',resolution_reason=?,
  430. official_sync_status=CASE WHEN official_todo_id<>'' THEN 'completion_pending' ELSE official_sync_status END,updated_at=? WHERE id=?`).run(reason, now(), task.id);
  431. return this.getCustomerTask(task.id);
  432. }
  433. updateCustomerTask(taskId, fields = {}) {
  434. const allowed = ['status', 'owner', 'due_at', 'priority', 'reason', 'resolution_reason', 'official_todo_id', 'official_sync_status', 'official_synced_at'];
  435. const entries = Object.entries(fields).filter(([key]) => allowed.includes(key));
  436. if (entries.length) {
  437. const assignments = entries.map(([key]) => `${key}=?`).join(',');
  438. this.db.prepare(`UPDATE customer_tasks SET ${assignments},updated_at=? WHERE id=?`).run(...entries.map(([, value]) => value), now(), taskId);
  439. }
  440. return this.db.prepare('SELECT * FROM customer_tasks WHERE id=?').get(taskId) || null;
  441. }
  442. upsertCustomerAlerts(conversationId, alerts = [], sourceMessageId = null) {
  443. const results = [];
  444. const find = this.db.prepare('SELECT * FROM customer_alerts WHERE conversation_id=? AND business_key=?');
  445. const insert = this.db.prepare(`INSERT INTO customer_alerts(id,conversation_id,fingerprint,business_key,managed_by,type,severity,title,detail,evidence,evidence_json,recommended_action,status,source_message_id,created_at,updated_at)
  446. VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
  447. const update = this.db.prepare(`UPDATE customer_alerts SET managed_by=?,type=?,severity=?,title=?,detail=?,evidence=?,evidence_json=?,recommended_action=?,source_message_id=COALESCE(?,source_message_id),updated_at=? WHERE id=?`);
  448. for (const item of alerts) {
  449. const title = String(item?.title || '').trim();
  450. if (!title) continue;
  451. const type = String(item.type || 'attention').trim();
  452. const evidence = String(item.evidence || '').trim();
  453. const businessKey = this.customerAlertBusinessKey(item);
  454. const fingerprint = this.intelligenceFingerprint(businessKey);
  455. const managedBy = String(item.managedBy || item.managed_by || 'agent').trim();
  456. const itemSourceMessageId = Object.prototype.hasOwnProperty.call(item, 'sourceMessageId')
  457. ? item.sourceMessageId
  458. : sourceMessageId;
  459. const existing = find.get(conversationId, businessKey);
  460. if (existing) {
  461. const evidences = evidenceItems(parse(existing.evidence_json, []), itemSourceMessageId ? evidence : '', itemSourceMessageId);
  462. update.run(managedBy, type, String(item.severity || 'medium').trim(), title, String(item.detail || '').trim(), evidence || existing.evidence || '', json(evidences), String(item.recommendedAction || item.recommended_action || '').trim(), itemSourceMessageId, now(), existing.id);
  463. results.push(this.db.prepare('SELECT * FROM customer_alerts WHERE id=?').get(existing.id));
  464. } else {
  465. const id = makeId('alert');
  466. const evidences = evidenceItems([], evidence, itemSourceMessageId);
  467. insert.run(id, conversationId, fingerprint, businessKey, managedBy, type, String(item.severity || 'medium').trim(), title, String(item.detail || '').trim(), evidence, json(evidences), String(item.recommendedAction || item.recommended_action || '').trim(), 'open', itemSourceMessageId, now(), now());
  468. results.push(this.db.prepare('SELECT * FROM customer_alerts WHERE id=?').get(id));
  469. }
  470. }
  471. return results;
  472. }
  473. reconcileCustomerAlerts(conversationId, alerts = [], sourceMessageId = null) {
  474. const results = this.upsertCustomerAlerts(conversationId, alerts, sourceMessageId);
  475. const activeRuleKeys = new Set(alerts
  476. .filter(item => String(item.managedBy || item.managed_by || 'agent') === 'rule')
  477. .map(item => this.customerAlertBusinessKey(item)));
  478. const existingRules = this.db.prepare("SELECT * FROM customer_alerts WHERE conversation_id=? AND managed_by='rule' AND status IN ('open','acknowledged')").all(conversationId);
  479. const resolved = [];
  480. for (const alert of existingRules) {
  481. if (activeRuleKeys.has(alert.business_key)) continue;
  482. this.db.prepare("UPDATE customer_alerts SET status='resolved',resolution_reason='profile_condition_resolved',updated_at=? WHERE id=?").run(now(), alert.id);
  483. resolved.push(alert.id);
  484. }
  485. return { alerts: results, resolved };
  486. }
  487. listCustomerAlerts(conversationId, limit = 100) {
  488. return this.db.prepare(`SELECT * FROM customer_alerts WHERE conversation_id=?
  489. ORDER BY CASE status WHEN 'open' THEN 0 WHEN 'acknowledged' THEN 1 ELSE 2 END,
  490. CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, updated_at DESC LIMIT ?`).all(conversationId, limit);
  491. }
  492. updateCustomerAlert(alertId, fields = {}) {
  493. const allowed = ['status', 'severity', 'recommended_action'];
  494. const entries = Object.entries(fields).filter(([key]) => allowed.includes(key));
  495. if (entries.length) {
  496. const assignments = entries.map(([key]) => `${key}=?`).join(',');
  497. this.db.prepare(`UPDATE customer_alerts SET ${assignments},updated_at=? WHERE id=?`).run(...entries.map(([, value]) => value), now(), alertId);
  498. }
  499. return this.db.prepare('SELECT * FROM customer_alerts WHERE id=?').get(alertId) || null;
  500. }
  501. hydrateCustomerRecommendation(row) {
  502. return row ? {
  503. ...row,
  504. property_snapshot: parse(row.property_snapshot_json, {}),
  505. sources: parse(row.sources_json, []),
  506. } : null;
  507. }
  508. upsertCustomerRecommendations(conversationId, items = [], source = {}) {
  509. const find = this.db.prepare('SELECT * FROM customer_recommendations WHERE conversation_id=? AND property_id=?');
  510. const insert = this.db.prepare(`INSERT INTO customer_recommendations(id,conversation_id,property_id,property_snapshot_json,sources_json,status,feedback_reason,recommend_count,first_recommended_at,last_recommended_at,updated_at)
  511. VALUES(?,?,?,?,?,?,?,?,?,?,?)`);
  512. const update = this.db.prepare(`UPDATE customer_recommendations SET property_snapshot_json=?,sources_json=?,status=CASE WHEN status IN ('interested','rejected','viewing','viewed','closed') THEN status ELSE ? END,recommend_count=?,last_recommended_at=?,updated_at=? WHERE id=?`);
  513. const results = [];
  514. for (const item of items) {
  515. const propertyId = String(item?.id || item?.propertyId || item?.property_id || '').trim();
  516. if (!propertyId) continue;
  517. const timestamp = String(source.createdAt || item.recommendedAt || now());
  518. const sourceItem = {
  519. type: String(source.type || 'unknown'),
  520. entityId: source.entityId || null,
  521. evidence: String(source.evidence || '').trim(),
  522. createdAt: timestamp,
  523. };
  524. const existing = find.get(conversationId, propertyId);
  525. const requestedStatus = ['candidate', 'recommended'].includes(String(source.status || '')) ? String(source.status) : 'recommended';
  526. if (!existing) {
  527. const id = makeId('recommendation');
  528. insert.run(id, conversationId, propertyId, json(item), json([sourceItem]), requestedStatus, '', 1, timestamp, timestamp, timestamp);
  529. results.push(this.hydrateCustomerRecommendation(this.db.prepare('SELECT * FROM customer_recommendations WHERE id=?').get(id)));
  530. continue;
  531. }
  532. const sources = parse(existing.sources_json, []);
  533. const sourceKey = `${sourceItem.type}\u0000${sourceItem.entityId || ''}`;
  534. const isNewSource = !sources.some(entry => `${entry.type || ''}\u0000${entry.entityId || ''}` === sourceKey);
  535. if (isNewSource) sources.push(sourceItem);
  536. update.run(json({ ...parse(existing.property_snapshot_json, {}), ...item }), json(sources.slice(-30)), requestedStatus, Number(existing.recommend_count || 0) + (isNewSource ? 1 : 0), isNewSource ? timestamp : existing.last_recommended_at, now(), existing.id);
  537. results.push(this.hydrateCustomerRecommendation(this.db.prepare('SELECT * FROM customer_recommendations WHERE id=?').get(existing.id)));
  538. }
  539. return results;
  540. }
  541. listCustomerRecommendations(conversationId, limit = 100) {
  542. return this.db.prepare(`SELECT * FROM customer_recommendations WHERE conversation_id=? ORDER BY last_recommended_at DESC LIMIT ?`).all(conversationId, limit).map(row => this.hydrateCustomerRecommendation(row));
  543. }
  544. updateCustomerRecommendation(conversationId, recommendationId, fields = {}) {
  545. const allowedStatuses = new Set(['candidate', 'recommended', 'interested', 'rejected', 'viewing', 'viewed', 'closed']);
  546. const status = String(fields.status || '');
  547. const entries = [];
  548. if (allowedStatuses.has(status)) entries.push(['status', status]);
  549. if (fields.feedback_reason !== undefined || fields.feedbackReason !== undefined) entries.push(['feedback_reason', String(fields.feedback_reason ?? fields.feedbackReason ?? '').trim()]);
  550. if (entries.length) {
  551. const assignments = entries.map(([key]) => `${key}=?`).join(',');
  552. this.db.prepare(`UPDATE customer_recommendations SET ${assignments},updated_at=? WHERE id=? AND conversation_id=?`).run(...entries.map(([, value]) => value), now(), recommendationId, conversationId);
  553. }
  554. return this.hydrateCustomerRecommendation(this.db.prepare('SELECT * FROM customer_recommendations WHERE id=? AND conversation_id=?').get(recommendationId, conversationId));
  555. }
  556. clearCustomerIntelligence() {
  557. const alerts = Number(this.db.prepare('DELETE FROM customer_alerts').run().changes || 0);
  558. const tasks = Number(this.db.prepare('DELETE FROM customer_tasks').run().changes || 0);
  559. return { tasks, alerts };
  560. }
  561. createDraft({ conversationId, inboundMessageId, content, confidence, intent, reason, requiresHuman, citations, toolTrace }) {
  562. const draftId = makeId('draft');
  563. this.db.prepare(`INSERT INTO drafts(id,conversation_id,inbound_message_id,content,confidence,intent,reason,requires_human,citations_json,tool_trace_json,created_at)
  564. VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(draftId, conversationId, inboundMessageId, String(content), Number(confidence) || 0, intent || '', reason || '', requiresHuman ? 1 : 0, json(citations || []), json(toolTrace || []), now());
  565. return this.getDraft(draftId);
  566. }
  567. getDraft(draftId) {
  568. const row = this.db.prepare('SELECT * FROM drafts WHERE id=?').get(draftId);
  569. return row ? this.hydrateDraft(row) : null;
  570. }
  571. hydrateDraft(row) {
  572. return { ...row, requires_human: Boolean(row.requires_human), citations: parse(row.citations_json, []), tool_trace: parse(row.tool_trace_json, []) };
  573. }
  574. listDrafts({ status = '', conversationId = '', limit = 100 } = {}) {
  575. let sql = 'SELECT * FROM drafts WHERE 1=1';
  576. const params = [];
  577. if (status) { sql += ' AND status=?'; params.push(status); }
  578. if (conversationId) { sql += ' AND conversation_id=?'; params.push(conversationId); }
  579. sql += ' ORDER BY created_at DESC LIMIT ?';
  580. params.push(limit);
  581. return this.db.prepare(sql).all(...params).map(row => this.hydrateDraft(row));
  582. }
  583. updateDraft(draftId, fields) {
  584. const allowed = ['content', 'status', 'reviewed_at', 'reviewer', 'error', 'sent_message_id'];
  585. const entries = Object.entries(fields).filter(([key]) => allowed.includes(key));
  586. if (!entries.length) return this.getDraft(draftId);
  587. const assignments = entries.map(([key]) => `${key}=?`).join(',');
  588. this.db.prepare(`UPDATE drafts SET ${assignments} WHERE id=?`).run(...entries.map(([, value]) => value), draftId);
  589. return this.getDraft(draftId);
  590. }
  591. audit({ actor = 'system', action, conversationId = null, entityId = null, detail = {} }) {
  592. const auditId = makeId('audit');
  593. this.db.prepare('INSERT INTO audit_logs(id,actor,action,conversation_id,entity_id,detail_json,created_at) VALUES(?,?,?,?,?,?,?)')
  594. .run(auditId, actor, action, conversationId, entityId, json(detail), now());
  595. return auditId;
  596. }
  597. listAudit(limit = 200, conversationId = '') {
  598. const rows = conversationId
  599. ? this.db.prepare('SELECT * FROM audit_logs WHERE conversation_id=? ORDER BY created_at DESC LIMIT ?').all(conversationId, limit)
  600. : this.db.prepare('SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT ?').all(limit);
  601. return rows.map(row => ({ ...row, detail: parse(row.detail_json, {}) }));
  602. }
  603. latestAgentOutcome(conversationId) {
  604. const row = this.db.prepare(`SELECT * FROM audit_logs
  605. WHERE conversation_id=? AND action IN ('draft_created','agent_failed','agent_not_configured','agent_no_reply_needed')
  606. ORDER BY created_at DESC LIMIT 1`).get(conversationId);
  607. if (!row) return null;
  608. const detail = parse(row.detail_json, {});
  609. return {
  610. action: row.action,
  611. message: detail.message || (row.action === 'agent_no_reply_needed' ? '客户消息无需回复' : 'Agent 上游不可用'),
  612. entityId: row.entity_id,
  613. createdAt: row.created_at,
  614. };
  615. }
  616. latestAgentState(conversationId) {
  617. const outcome = this.latestAgentOutcome(conversationId);
  618. if (!outcome || ['draft_created', 'agent_no_reply_needed'].includes(outcome.action)) return null;
  619. return outcome;
  620. }
  621. getPollState(key, fallback = '') {
  622. return this.db.prepare('SELECT value FROM poll_state WHERE key=?').get(key)?.value ?? fallback;
  623. }
  624. setPollState(key, value) {
  625. this.db.prepare(`INSERT INTO poll_state(key,value,updated_at) VALUES(?,?,?)
  626. ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`).run(key, String(value), now());
  627. }
  628. importCompatibleDatabase(sourcePath) {
  629. if (!sourcePath || !fs.existsSync(sourcePath) || path.resolve(sourcePath) === path.resolve(this.filePath)) return { imported: false, reason: 'source_missing' };
  630. if (this.listConversations().length) return { imported: false, reason: 'target_not_empty' };
  631. const source = new DatabaseSync(sourcePath, { readOnly: true });
  632. const tableOrder = ['settings', 'conversations', 'messages', 'customer_profiles', 'customer_tasks', 'customer_alerts', 'customer_recommendations', 'drafts', 'audit_logs', 'poll_state'];
  633. let rowsImported = 0;
  634. this.db.exec(`
  635. DROP INDEX IF EXISTS idx_customer_tasks_business_key;
  636. DROP INDEX IF EXISTS idx_customer_alerts_business_key;
  637. `);
  638. this.db.exec('BEGIN IMMEDIATE');
  639. try {
  640. for (const table of tableOrder) {
  641. const exists = source.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table);
  642. if (!exists) continue;
  643. const columns = source.prepare(`PRAGMA table_info(${table})`).all().map(item => item.name);
  644. if (!columns.length) continue;
  645. const placeholders = columns.map(() => '?').join(',');
  646. const insert = this.db.prepare(`INSERT OR IGNORE INTO ${table}(${columns.join(',')}) VALUES(${placeholders})`);
  647. for (const row of source.prepare(`SELECT ${columns.join(',')} FROM ${table}`).all()) {
  648. rowsImported += Number(insert.run(...columns.map(column => row[column])).changes || 0);
  649. }
  650. }
  651. this.db.exec('COMMIT');
  652. } catch (error) {
  653. this.db.exec('ROLLBACK');
  654. this.db.exec(`
  655. CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_tasks_business_key ON customer_tasks(conversation_id, business_key);
  656. CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_alerts_business_key ON customer_alerts(conversation_id, business_key);
  657. `);
  658. source.close();
  659. throw error;
  660. }
  661. source.close();
  662. const intelligenceMigration = this.migrateCustomerIntelligenceRecords();
  663. this.db.exec(`
  664. CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_tasks_business_key ON customer_tasks(conversation_id, business_key);
  665. CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_alerts_business_key ON customer_alerts(conversation_id, business_key);
  666. `);
  667. this.audit({ actor: 'migration', action: 'legacy_workbench_imported', detail: { source: path.basename(sourcePath), rowsImported, intelligenceMigration } });
  668. return { imported: true, rowsImported, intelligenceMigration };
  669. }
  670. }
  671. module.exports = { AgentWorkbenchDb };