agent-workbench-db.js 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020
  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 CONTACT_NAME_PLACEHOLDERS = new Set([
  12. '企微客户',
  13. '白名单企微联系人',
  14. '未知客户',
  15. '未命名客户',
  16. ]);
  17. function normalizeContactName(value) {
  18. return String(value || '').trim().slice(0, 160);
  19. }
  20. function isMeaningfulContactName(value) {
  21. const name = normalizeContactName(value);
  22. if (!name || CONTACT_NAME_PLACEHOLDERS.has(name) || name.includes('\uFFFD')) return false;
  23. return !/^[??\s\p{P}\p{S}]+$/u.test(name);
  24. }
  25. const normalizeKeyPart = value => String(value || '')
  26. .trim()
  27. .toLowerCase()
  28. .replace(/[\s\p{P}\p{S}]+/gu, '_')
  29. .replace(/^_+|_+$/g, '')
  30. .slice(0, 160);
  31. function evidenceItems(value, fallbackText = '', sourceMessageId = null) {
  32. const rows = Array.isArray(value) ? value : [];
  33. const items = rows.map(item => typeof item === 'string'
  34. ? { text: item, sourceMessageId: null }
  35. : {
  36. text: String(item?.text || item?.evidence || '').trim(),
  37. sourceMessageId: item?.sourceMessageId || item?.source_message_id || null,
  38. createdAt: item?.createdAt || item?.created_at || null,
  39. }).filter(item => item.text);
  40. const text = String(fallbackText || '').trim();
  41. if (text) items.push({ text, sourceMessageId: sourceMessageId || null, createdAt: now() });
  42. const unique = new Map();
  43. for (const item of items) {
  44. const key = `${item.sourceMessageId || ''}\u0000${item.text}`;
  45. if (!unique.has(key)) unique.set(key, item);
  46. }
  47. return [...unique.values()].slice(-30);
  48. }
  49. class AgentWorkbenchDb {
  50. constructor(filePath, defaults = {}) {
  51. fs.mkdirSync(path.dirname(filePath), { recursive: true });
  52. this.filePath = filePath;
  53. this.db = new DatabaseSync(filePath);
  54. this.db.exec('PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;');
  55. this.init(defaults);
  56. }
  57. init(defaults) {
  58. this.db.exec(`
  59. CREATE TABLE IF NOT EXISTS settings (
  60. key TEXT PRIMARY KEY,
  61. value TEXT NOT NULL,
  62. updated_at TEXT NOT NULL
  63. );
  64. CREATE TABLE IF NOT EXISTS conversations (
  65. id TEXT PRIMARY KEY,
  66. contact_id TEXT NOT NULL UNIQUE,
  67. contact_name TEXT NOT NULL DEFAULT '',
  68. mode TEXT NOT NULL DEFAULT 'review' CHECK(mode IN ('review','auto','human','paused')),
  69. last_message_at TEXT,
  70. created_at TEXT NOT NULL,
  71. updated_at TEXT NOT NULL
  72. );
  73. CREATE TABLE IF NOT EXISTS messages (
  74. id TEXT PRIMARY KEY,
  75. conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  76. external_id TEXT UNIQUE,
  77. direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound')),
  78. sender_type TEXT NOT NULL CHECK(sender_type IN ('customer','agent','human','system')),
  79. content TEXT NOT NULL,
  80. status TEXT NOT NULL DEFAULT 'received',
  81. created_at TEXT NOT NULL,
  82. raw_json TEXT
  83. );
  84. CREATE TABLE IF NOT EXISTS customer_profiles (
  85. conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
  86. profile_json TEXT NOT NULL DEFAULT '{}',
  87. tags_json TEXT NOT NULL DEFAULT '[]',
  88. updated_at TEXT NOT NULL
  89. );
  90. CREATE TABLE IF NOT EXISTS customer_tasks (
  91. id TEXT PRIMARY KEY,
  92. conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  93. fingerprint TEXT NOT NULL,
  94. type TEXT NOT NULL DEFAULT 'follow_up',
  95. title TEXT NOT NULL,
  96. owner TEXT NOT NULL DEFAULT '',
  97. due_at TEXT NOT NULL DEFAULT '',
  98. priority TEXT NOT NULL DEFAULT 'medium',
  99. status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','in_progress','done','dismissed')),
  100. reason TEXT NOT NULL DEFAULT '',
  101. evidence TEXT NOT NULL DEFAULT '',
  102. source_message_id TEXT,
  103. created_at TEXT NOT NULL,
  104. updated_at TEXT NOT NULL,
  105. UNIQUE(conversation_id, fingerprint)
  106. );
  107. CREATE TABLE IF NOT EXISTS customer_alerts (
  108. id TEXT PRIMARY KEY,
  109. conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  110. fingerprint TEXT NOT NULL,
  111. type TEXT NOT NULL DEFAULT 'attention',
  112. severity TEXT NOT NULL DEFAULT 'medium',
  113. title TEXT NOT NULL,
  114. detail TEXT NOT NULL DEFAULT '',
  115. evidence TEXT NOT NULL DEFAULT '',
  116. recommended_action TEXT NOT NULL DEFAULT '',
  117. status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','acknowledged','resolved','dismissed')),
  118. source_message_id TEXT,
  119. created_at TEXT NOT NULL,
  120. updated_at TEXT NOT NULL,
  121. UNIQUE(conversation_id, fingerprint)
  122. );
  123. CREATE TABLE IF NOT EXISTS customer_recommendations (
  124. id TEXT PRIMARY KEY,
  125. conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  126. property_id TEXT NOT NULL,
  127. property_snapshot_json TEXT NOT NULL DEFAULT '{}',
  128. sources_json TEXT NOT NULL DEFAULT '[]',
  129. status TEXT NOT NULL DEFAULT 'recommended' CHECK(status IN ('candidate','recommended','interested','rejected','viewing','viewed','closed')),
  130. feedback_reason TEXT NOT NULL DEFAULT '',
  131. recommend_count INTEGER NOT NULL DEFAULT 1,
  132. first_recommended_at TEXT NOT NULL,
  133. last_recommended_at TEXT NOT NULL,
  134. updated_at TEXT NOT NULL,
  135. UNIQUE(conversation_id, property_id)
  136. );
  137. CREATE TABLE IF NOT EXISTS customer_memory_items (
  138. id TEXT PRIMARY KEY,
  139. conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  140. memory_key TEXT NOT NULL,
  141. type TEXT NOT NULL DEFAULT 'fact' CHECK(type IN ('fact','preference','constraint','event','hypothesis')),
  142. content TEXT NOT NULL,
  143. confidence REAL NOT NULL DEFAULT 1,
  144. importance REAL NOT NULL DEFAULT 0.5,
  145. status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','superseded','rejected')),
  146. source_message_ids_json TEXT NOT NULL DEFAULT '[]',
  147. direction TEXT NOT NULL DEFAULT 'customer_to_agent',
  148. created_by TEXT NOT NULL DEFAULT 'rule',
  149. expires_at TEXT,
  150. created_at TEXT NOT NULL,
  151. updated_at TEXT NOT NULL,
  152. UNIQUE(conversation_id, memory_key)
  153. );
  154. CREATE TABLE IF NOT EXISTS customer_memory_snapshots (
  155. id TEXT PRIMARY KEY,
  156. conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  157. version INTEGER NOT NULL,
  158. compact_text TEXT NOT NULL DEFAULT '',
  159. content_hash TEXT NOT NULL,
  160. created_at TEXT NOT NULL,
  161. UNIQUE(conversation_id, version)
  162. );
  163. CREATE TABLE IF NOT EXISTS memory_extraction_jobs (
  164. id TEXT PRIMARY KEY,
  165. conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  166. message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
  167. profile_updates_json TEXT NOT NULL DEFAULT '{}',
  168. status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','processing','completed','failed')),
  169. attempts INTEGER NOT NULL DEFAULT 0,
  170. max_attempts INTEGER NOT NULL DEFAULT 3,
  171. next_attempt_at TEXT NOT NULL,
  172. error TEXT,
  173. result_json TEXT NOT NULL DEFAULT '{}',
  174. created_at TEXT NOT NULL,
  175. updated_at TEXT NOT NULL,
  176. completed_at TEXT,
  177. UNIQUE(conversation_id, message_id)
  178. );
  179. CREATE TABLE IF NOT EXISTS customer_memory_revisions (
  180. id TEXT PRIMARY KEY,
  181. memory_id TEXT NOT NULL REFERENCES customer_memory_items(id) ON DELETE CASCADE,
  182. conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  183. memory_key TEXT NOT NULL,
  184. reason TEXT NOT NULL,
  185. previous_json TEXT NOT NULL,
  186. next_json TEXT NOT NULL,
  187. created_at TEXT NOT NULL
  188. );
  189. CREATE TABLE IF NOT EXISTS drafts (
  190. id TEXT PRIMARY KEY,
  191. conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  192. inbound_message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
  193. content TEXT NOT NULL,
  194. status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','sent','failed')),
  195. confidence REAL NOT NULL DEFAULT 0,
  196. intent TEXT NOT NULL DEFAULT '',
  197. reason TEXT NOT NULL DEFAULT '',
  198. requires_human INTEGER NOT NULL DEFAULT 1,
  199. citations_json TEXT NOT NULL DEFAULT '[]',
  200. tool_trace_json TEXT NOT NULL DEFAULT '[]',
  201. created_at TEXT NOT NULL,
  202. reviewed_at TEXT,
  203. reviewer TEXT,
  204. error TEXT,
  205. sent_message_id TEXT
  206. );
  207. CREATE TABLE IF NOT EXISTS audit_logs (
  208. id TEXT PRIMARY KEY,
  209. actor TEXT NOT NULL,
  210. action TEXT NOT NULL,
  211. conversation_id TEXT,
  212. entity_id TEXT,
  213. detail_json TEXT NOT NULL DEFAULT '{}',
  214. created_at TEXT NOT NULL
  215. );
  216. CREATE TABLE IF NOT EXISTS poll_state (
  217. key TEXT PRIMARY KEY,
  218. value TEXT NOT NULL,
  219. updated_at TEXT NOT NULL
  220. );
  221. CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
  222. CREATE INDEX IF NOT EXISTS idx_messages_content ON messages(conversation_id, direction, content, created_at);
  223. CREATE INDEX IF NOT EXISTS idx_drafts_status ON drafts(status, created_at);
  224. CREATE INDEX IF NOT EXISTS idx_customer_tasks_conversation ON customer_tasks(conversation_id, status, updated_at DESC);
  225. CREATE INDEX IF NOT EXISTS idx_customer_alerts_conversation ON customer_alerts(conversation_id, status, updated_at DESC);
  226. CREATE INDEX IF NOT EXISTS idx_customer_recommendations_conversation ON customer_recommendations(conversation_id, status, last_recommended_at DESC);
  227. CREATE INDEX IF NOT EXISTS idx_customer_memory_conversation ON customer_memory_items(conversation_id, status, importance DESC, updated_at DESC);
  228. CREATE INDEX IF NOT EXISTS idx_customer_memory_snapshots ON customer_memory_snapshots(conversation_id, version DESC);
  229. CREATE INDEX IF NOT EXISTS idx_memory_extraction_jobs_pending ON memory_extraction_jobs(status, next_attempt_at, created_at);
  230. CREATE INDEX IF NOT EXISTS idx_customer_memory_revisions ON customer_memory_revisions(memory_id, created_at DESC);
  231. CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at DESC);
  232. `);
  233. this.db.prepare(`UPDATE memory_extraction_jobs SET status='pending',next_attempt_at=?,updated_at=? WHERE status='processing'`)
  234. .run(now(), now());
  235. this.ensureColumn('customer_tasks', 'business_key', "business_key TEXT NOT NULL DEFAULT ''");
  236. this.ensureColumn('customer_tasks', 'managed_by', "managed_by TEXT NOT NULL DEFAULT 'agent'");
  237. this.ensureColumn('customer_tasks', 'evidence_json', "evidence_json TEXT NOT NULL DEFAULT '[]'");
  238. this.ensureColumn('customer_tasks', 'resolution_reason', "resolution_reason TEXT NOT NULL DEFAULT ''");
  239. this.ensureColumn('customer_tasks', 'official_todo_id', "official_todo_id TEXT NOT NULL DEFAULT ''");
  240. this.ensureColumn('customer_tasks', 'official_sync_status', "official_sync_status TEXT NOT NULL DEFAULT ''");
  241. this.ensureColumn('customer_tasks', 'official_synced_at', 'official_synced_at TEXT');
  242. this.ensureColumn('customer_alerts', 'business_key', "business_key TEXT NOT NULL DEFAULT ''");
  243. this.ensureColumn('customer_alerts', 'managed_by', "managed_by TEXT NOT NULL DEFAULT 'agent'");
  244. this.ensureColumn('customer_alerts', 'evidence_json', "evidence_json TEXT NOT NULL DEFAULT '[]'");
  245. this.ensureColumn('customer_alerts', 'resolution_reason', "resolution_reason TEXT NOT NULL DEFAULT ''");
  246. this.lastIntelligenceMigration = this.migrateCustomerIntelligenceRecords();
  247. this.db.exec(`
  248. CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_tasks_business_key ON customer_tasks(conversation_id, business_key);
  249. CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_alerts_business_key ON customer_alerts(conversation_id, business_key);
  250. `);
  251. this.setDefault('global_paused', defaults.globalPaused ? 'true' : 'false');
  252. this.setDefault('default_mode', defaults.defaultMode || 'review');
  253. this.setDefault('auto_send_confidence', String(defaults.autoSendConfidence ?? 0.88));
  254. this.setDefault('agent_cutover_at', defaults.cutoverAt || now());
  255. }
  256. close() { this.db.close(); }
  257. ensureColumn(table, column, definition) {
  258. const exists = this.db.prepare(`PRAGMA table_info(${table})`).all().some(item => item.name === column);
  259. if (!exists) this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
  260. }
  261. customerTaskBusinessKey(item = {}) {
  262. const title = String(item.title || '').trim();
  263. const type = normalizeKeyPart(item.type || 'follow_up');
  264. if (type === 'qualification' && /用途.*(购置)?时间/.test(title)) return 'qualification:purpose_and_timeline';
  265. if (type === 'recommendation' && /(筛选|发送).*(重点|方案)/.test(title)) return 'recommendation:shortlist';
  266. const explicit = item.businessKey || item.business_key || item.taskKey || item.key;
  267. if (explicit) return String(explicit).split(':').map(normalizeKeyPart).filter(Boolean).join(':');
  268. return `${type}:${normalizeKeyPart(title || 'task')}`;
  269. }
  270. customerAlertBusinessKey(item = {}) {
  271. const title = String(item.title || '').trim();
  272. const type = normalizeKeyPart(item.type || 'attention');
  273. if (type === 'high_intent' && /核心需求.*成形/.test(title)) return 'high_intent:core_demand_ready';
  274. if (type === 'complaint') return 'complaint:manual_takeover';
  275. if (type === 'time_sensitive') return 'time_sensitive:follow_up';
  276. const explicit = item.businessKey || item.business_key || item.alertKey || item.key;
  277. if (explicit) return String(explicit).split(':').map(normalizeKeyPart).filter(Boolean).join(':');
  278. return `${type}:${normalizeKeyPart(title || 'alert')}`;
  279. }
  280. migrateCustomerIntelligenceRecords() {
  281. const migrate = ({ table, keyFor, terminalStatus }) => {
  282. const rows = this.db.prepare(`SELECT * FROM ${table} ORDER BY created_at,id`).all();
  283. const groups = new Map();
  284. for (const row of rows) {
  285. const businessKey = keyFor.call(this, row);
  286. const groupKey = `${row.conversation_id}\u0000${businessKey}`;
  287. if (!groups.has(groupKey)) groups.set(groupKey, { businessKey, rows: [] });
  288. groups.get(groupKey).rows.push(row);
  289. }
  290. let removed = 0;
  291. for (const group of groups.values()) {
  292. const statusRank = terminalStatus === 'done'
  293. ? { dismissed: 0, open: 1, in_progress: 2, done: 3 }
  294. : { dismissed: 0, open: 1, acknowledged: 2, resolved: 3 };
  295. const canonical = [...group.rows].sort((a, b) =>
  296. (statusRank[b.status] || 0) - (statusRank[a.status] || 0) ||
  297. Date.parse(a.created_at) - Date.parse(b.created_at))[0];
  298. const evidence = evidenceItems(
  299. group.rows.flatMap(row => evidenceItems(parse(row.evidence_json, []), row.evidence, row.source_message_id)),
  300. );
  301. const duplicates = group.rows.filter(row => row.id !== canonical.id);
  302. for (const row of duplicates) {
  303. this.db.prepare(`DELETE FROM ${table} WHERE id=?`).run(row.id);
  304. removed += 1;
  305. }
  306. const fingerprint = this.intelligenceFingerprint(group.businessKey);
  307. this.db.prepare(`UPDATE ${table} SET business_key=?,fingerprint=?,evidence_json=?,evidence=?,updated_at=? WHERE id=?`)
  308. .run(group.businessKey, fingerprint, json(evidence), evidence.at(-1)?.text || canonical.evidence || '', canonical.updated_at || now(), canonical.id);
  309. }
  310. return { rows: rows.length, removed };
  311. };
  312. this.db.exec('BEGIN IMMEDIATE');
  313. try {
  314. const tasks = migrate({ table: 'customer_tasks', keyFor: this.customerTaskBusinessKey, terminalStatus: 'done' });
  315. const alerts = migrate({ table: 'customer_alerts', keyFor: this.customerAlertBusinessKey, terminalStatus: 'resolved' });
  316. this.db.exec('COMMIT');
  317. return { tasks, alerts };
  318. } catch (error) {
  319. this.db.exec('ROLLBACK');
  320. throw error;
  321. }
  322. }
  323. setDefault(key, value) {
  324. this.db.prepare('INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)').run(key, value, now());
  325. }
  326. getSetting(key, fallback = '') {
  327. return this.db.prepare('SELECT value FROM settings WHERE key=?').get(key)?.value ?? fallback;
  328. }
  329. setSetting(key, value) {
  330. this.db.prepare(`INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)
  331. ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`).run(key, String(value), now());
  332. }
  333. globalState() {
  334. return {
  335. paused: this.getSetting('global_paused', 'true') === 'true',
  336. defaultMode: this.getSetting('default_mode', 'review'),
  337. autoSendConfidence: Number(this.getSetting('auto_send_confidence', '0.88')),
  338. };
  339. }
  340. ensureConversation(contactId, contactName = '') {
  341. const existing = this.db.prepare('SELECT * FROM conversations WHERE contact_id=?').get(String(contactId));
  342. const normalizedName = normalizeContactName(contactName);
  343. const safeName = isMeaningfulContactName(normalizedName) ? normalizedName : '';
  344. if (existing) {
  345. if (safeName && existing.contact_name !== safeName) {
  346. this.db.prepare('UPDATE conversations SET contact_name=?,updated_at=? WHERE id=?').run(safeName, now(), existing.id);
  347. }
  348. return this.getConversation(existing.id);
  349. }
  350. const conversationId = makeId('conv');
  351. const timestamp = now();
  352. this.db.prepare(`INSERT INTO conversations(id,contact_id,contact_name,mode,created_at,updated_at)
  353. VALUES(?,?,?,?,?,?)`).run(conversationId, String(contactId), safeName, this.getSetting('default_mode', 'review'), timestamp, timestamp);
  354. this.db.prepare('INSERT INTO customer_profiles(conversation_id,updated_at) VALUES(?,?)').run(conversationId, timestamp);
  355. return this.getConversation(conversationId);
  356. }
  357. getConversationByContactId(contactId) {
  358. return this.db.prepare('SELECT * FROM conversations WHERE contact_id=?').get(String(contactId)) || null;
  359. }
  360. getConversation(conversationId) {
  361. return this.db.prepare('SELECT * FROM conversations WHERE id=?').get(conversationId) || null;
  362. }
  363. listConversations() {
  364. return this.db.prepare(`SELECT c.*,
  365. (SELECT content FROM messages m WHERE m.conversation_id=c.id ORDER BY m.created_at DESC LIMIT 1) AS last_content,
  366. (SELECT COUNT(*) FROM drafts d WHERE d.conversation_id=c.id AND d.status='pending') AS pending_count
  367. FROM conversations c ORDER BY COALESCE(c.last_message_at,c.created_at) DESC`).all();
  368. }
  369. setConversationMode(conversationId, mode) {
  370. if (!['review', 'auto', 'human', 'paused'].includes(mode)) throw new Error('不支持的会话模式');
  371. const result = this.db.prepare('UPDATE conversations SET mode=?,updated_at=? WHERE id=?').run(mode, now(), conversationId);
  372. if (!result.changes) throw new Error('会话不存在');
  373. return this.getConversation(conversationId);
  374. }
  375. insertMessage({ conversationId, externalId = null, direction, senderType, content, status = 'received', createdAt = now(), raw = null }) {
  376. if (externalId) {
  377. const existing = this.db.prepare('SELECT * FROM messages WHERE external_id=?').get(externalId);
  378. if (existing) return { message: existing, created: false };
  379. }
  380. const messageId = makeId('msg');
  381. this.db.prepare(`INSERT INTO messages(id,conversation_id,external_id,direction,sender_type,content,status,created_at,raw_json)
  382. VALUES(?,?,?,?,?,?,?,?,?)`).run(messageId, conversationId, externalId, direction, senderType, String(content), status, createdAt, raw ? json(raw) : null);
  383. this.db.prepare('UPDATE conversations SET last_message_at=?,updated_at=? WHERE id=?').run(createdAt, now(), conversationId);
  384. return { message: this.getMessage(messageId), created: true };
  385. }
  386. getMessage(messageId) { return this.db.prepare('SELECT * FROM messages WHERE id=?').get(messageId) || null; }
  387. updateMessageRaw(messageId, raw) {
  388. const result = this.db.prepare('UPDATE messages SET raw_json=? WHERE id=?').run(json(raw || {}), messageId);
  389. if (!result.changes) throw new Error('消息不存在');
  390. return this.getMessage(messageId);
  391. }
  392. getLatestInbound(conversationId) {
  393. return this.db.prepare("SELECT * FROM messages WHERE conversation_id=? AND direction='inbound' ORDER BY created_at DESC LIMIT 1").get(conversationId) || null;
  394. }
  395. findRecentInboundDuplicate(conversationId, content, createdAt, windowSeconds = 60) {
  396. 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));
  397. const timestamp = Date.parse(createdAt);
  398. return rows.find(row => Number.isFinite(timestamp) && Math.abs(timestamp - Date.parse(row.created_at)) <= windowSeconds * 1000) || null;
  399. }
  400. cleanupInboundContentDuplicates(windowSeconds = 60) {
  401. const rows = this.db.prepare("SELECT * FROM messages WHERE direction='inbound' ORDER BY conversation_id,created_at,id").all();
  402. const lastSeen = new Map();
  403. const duplicateIds = [];
  404. for (const row of rows) {
  405. const key = `${row.conversation_id}\u0000${row.content}`;
  406. const previous = lastSeen.get(key);
  407. const timestamp = Date.parse(row.created_at);
  408. if (previous && Number.isFinite(timestamp) && Math.abs(timestamp - previous.timestamp) <= windowSeconds * 1000) duplicateIds.push(row.id);
  409. else lastSeen.set(key, { timestamp, id: row.id });
  410. }
  411. const remove = this.db.prepare('DELETE FROM messages WHERE id=?');
  412. for (const messageId of duplicateIds) remove.run(messageId);
  413. return duplicateIds.length;
  414. }
  415. listMessages(conversationId, limit = 100) {
  416. return this.db.prepare(`SELECT * FROM (
  417. SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT ?
  418. ) ORDER BY created_at ASC`).all(conversationId, limit);
  419. }
  420. deleteImportedMessages(conversationId, source = 'manual_sync') {
  421. const pattern = `%\"source\":\"${String(source).replace(/[\"%]/g, '')}\"%`;
  422. const result = this.db.prepare('DELETE FROM messages WHERE conversation_id=? AND raw_json LIKE ?').run(conversationId, pattern);
  423. const latest = this.db.prepare('SELECT MAX(created_at) AS value FROM messages WHERE conversation_id=?').get(conversationId)?.value || null;
  424. this.db.prepare('UPDATE conversations SET last_message_at=?,updated_at=? WHERE id=?').run(latest, now(), conversationId);
  425. return Number(result.changes || 0);
  426. }
  427. getProfile(conversationId) {
  428. const row = this.db.prepare('SELECT * FROM customer_profiles WHERE conversation_id=?').get(conversationId);
  429. return row ? { profile: parse(row.profile_json, {}), tags: parse(row.tags_json, []), updatedAt: row.updated_at } : { profile: {}, tags: [] };
  430. }
  431. updateProfile(conversationId, profile, tags = []) {
  432. this.db.prepare(`INSERT INTO customer_profiles(conversation_id,profile_json,tags_json,updated_at) VALUES(?,?,?,?)
  433. ON CONFLICT(conversation_id) DO UPDATE SET profile_json=excluded.profile_json,tags_json=excluded.tags_json,updated_at=excluded.updated_at`)
  434. .run(conversationId, json(profile || {}), json(tags || []), now());
  435. return this.getProfile(conversationId);
  436. }
  437. upsertCustomerMemory(conversationId, item = {}) {
  438. const memoryKey = String(item.memoryKey || item.memory_key || '').trim().slice(0, 240);
  439. const content = String(item.content || '').trim().slice(0, 1200);
  440. if (!memoryKey || !content) return null;
  441. const type = ['fact', 'preference', 'constraint', 'event', 'hypothesis'].includes(item.type) ? item.type : 'fact';
  442. const sourceMessageIds = [...new Set((item.sourceMessageIds || item.source_message_ids || []).map(String).filter(Boolean))].slice(-20);
  443. const existing = this.db.prepare('SELECT * FROM customer_memory_items WHERE conversation_id=? AND memory_key=?').get(conversationId, memoryKey);
  444. const timestamp = now();
  445. if (existing) {
  446. const mergedSources = [...new Set([...parse(existing.source_message_ids_json, []), ...sourceMessageIds])].slice(-20);
  447. const nextState = {
  448. ...existing,
  449. type,
  450. content,
  451. confidence: Math.max(0, Math.min(1, Number(item.confidence ?? existing.confidence ?? 1))),
  452. importance: Math.max(0, Math.min(1, Number(item.importance ?? existing.importance ?? 0.5))),
  453. status: 'active',
  454. source_message_ids_json: json(mergedSources),
  455. direction: String(item.direction || existing.direction || 'customer_to_agent'),
  456. created_by: String(item.createdBy || item.created_by || existing.created_by || 'rule'),
  457. expires_at: item.expiresAt || item.expires_at || null,
  458. };
  459. if (['type', 'content', 'confidence', 'importance', 'status', 'source_message_ids_json', 'expires_at'].some(key => String(existing[key] ?? '') !== String(nextState[key] ?? ''))) {
  460. this.recordCustomerMemoryRevision(existing, nextState, item.revisionReason || (existing.content !== content ? 'superseded_by_new_evidence' : 'evidence_or_metadata_updated'));
  461. }
  462. this.db.prepare(`UPDATE customer_memory_items SET type=?,content=?,confidence=?,importance=?,status='active',
  463. source_message_ids_json=?,direction=?,created_by=?,expires_at=?,updated_at=? WHERE id=?`)
  464. .run(nextState.type, nextState.content, nextState.confidence, nextState.importance, nextState.source_message_ids_json,
  465. nextState.direction, nextState.created_by, nextState.expires_at, timestamp, existing.id);
  466. return this.getCustomerMemory(existing.id);
  467. }
  468. const id = makeId('memory');
  469. this.db.prepare(`INSERT INTO customer_memory_items(id,conversation_id,memory_key,type,content,confidence,importance,status,
  470. source_message_ids_json,direction,created_by,expires_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
  471. .run(id, conversationId, memoryKey, type, content, Math.max(0, Math.min(1, Number(item.confidence ?? 1))),
  472. Math.max(0, Math.min(1, Number(item.importance ?? 0.5))), 'active', json(sourceMessageIds),
  473. String(item.direction || 'customer_to_agent'), String(item.createdBy || item.created_by || 'rule'),
  474. item.expiresAt || item.expires_at || null, timestamp, timestamp);
  475. return this.getCustomerMemory(id);
  476. }
  477. getCustomerMemory(memoryId) {
  478. const row = this.db.prepare('SELECT * FROM customer_memory_items WHERE id=?').get(memoryId);
  479. return row ? { ...row, source_message_ids: parse(row.source_message_ids_json, []) } : null;
  480. }
  481. getCustomerMemoryByKey(conversationId, memoryKey) {
  482. const row = this.db.prepare('SELECT * FROM customer_memory_items WHERE conversation_id=? AND memory_key=?').get(conversationId, String(memoryKey || ''));
  483. return row ? { ...row, source_message_ids: parse(row.source_message_ids_json, []) } : null;
  484. }
  485. listCustomerMemories(conversationId, { status = 'active', limit = 100 } = {}) {
  486. const rows = status
  487. ? this.db.prepare('SELECT * FROM customer_memory_items WHERE conversation_id=? AND status=? ORDER BY importance DESC,updated_at DESC LIMIT ?').all(conversationId, status, limit)
  488. : this.db.prepare('SELECT * FROM customer_memory_items WHERE conversation_id=? ORDER BY importance DESC,updated_at DESC LIMIT ?').all(conversationId, limit);
  489. return rows.map(row => ({ ...row, source_message_ids: parse(row.source_message_ids_json, []) }));
  490. }
  491. updateCustomerMemory(memoryId, fields = {}) {
  492. const current = this.getCustomerMemory(memoryId);
  493. if (!current) throw new Error('客户记忆不存在');
  494. const allowedTypes = new Set(['fact', 'preference', 'constraint', 'event', 'hypothesis']);
  495. const allowedStatuses = new Set(['active', 'superseded', 'rejected']);
  496. const updates = {
  497. content: fields.content === undefined ? current.content : String(fields.content || '').trim().slice(0, 1200),
  498. type: fields.type === undefined ? current.type : String(fields.type),
  499. status: fields.status === undefined ? current.status : String(fields.status),
  500. confidence: fields.confidence === undefined ? current.confidence : Math.max(0, Math.min(1, Number(fields.confidence))),
  501. importance: fields.importance === undefined ? current.importance : Math.max(0, Math.min(1, Number(fields.importance))),
  502. expiresAt: fields.expiresAt === undefined && fields.expires_at === undefined ? current.expires_at : (fields.expiresAt || fields.expires_at || null),
  503. createdBy: fields.createdBy === undefined && fields.created_by === undefined ? current.created_by : String(fields.createdBy || fields.created_by || 'human'),
  504. };
  505. if (!updates.content) throw new Error('客户记忆内容不能为空');
  506. if (!allowedTypes.has(updates.type)) throw new Error('不支持的客户记忆类型');
  507. if (!allowedStatuses.has(updates.status)) throw new Error('不支持的客户记忆状态');
  508. const nextState = {
  509. ...current,
  510. content: updates.content,
  511. type: updates.type,
  512. status: updates.status,
  513. confidence: updates.confidence,
  514. importance: updates.importance,
  515. expires_at: updates.expiresAt,
  516. created_by: updates.createdBy,
  517. };
  518. if (['content', 'type', 'status', 'confidence', 'importance', 'expires_at', 'created_by'].some(key => String(current[key] ?? '') !== String(nextState[key] ?? ''))) {
  519. this.recordCustomerMemoryRevision(current, nextState, fields.revisionReason || 'memory_updated');
  520. }
  521. this.db.prepare(`UPDATE customer_memory_items SET content=?,type=?,status=?,confidence=?,importance=?,expires_at=?,created_by=?,updated_at=? WHERE id=?`)
  522. .run(updates.content, updates.type, updates.status, updates.confidence, updates.importance, updates.expiresAt, updates.createdBy, now(), memoryId);
  523. return this.getCustomerMemory(memoryId);
  524. }
  525. forgetCustomerMemory(memoryId) {
  526. const current = this.getCustomerMemory(memoryId);
  527. if (!current) throw new Error('客户记忆不存在');
  528. this.db.prepare('DELETE FROM customer_memory_items WHERE id=?').run(memoryId);
  529. return current;
  530. }
  531. expireCustomerMemories(conversationId, timestamp = now()) {
  532. const expired = this.db.prepare(`SELECT * FROM customer_memory_items
  533. WHERE conversation_id=? AND status='active' AND expires_at IS NOT NULL AND expires_at<>'' AND expires_at<=?`)
  534. .all(conversationId, timestamp);
  535. for (const row of expired) {
  536. this.recordCustomerMemoryRevision(row, { ...row, status: 'superseded' }, 'expired');
  537. }
  538. if (expired.length) {
  539. this.db.prepare(`UPDATE customer_memory_items SET status='superseded',updated_at=?
  540. WHERE conversation_id=? AND status='active' AND expires_at IS NOT NULL AND expires_at<>'' AND expires_at<=?`)
  541. .run(timestamp, conversationId, timestamp);
  542. }
  543. return expired.length;
  544. }
  545. recordCustomerMemoryRevision(previous, next, reason = 'memory_updated') {
  546. if (!previous?.id) return null;
  547. const id = makeId('memory_revision');
  548. this.db.prepare(`INSERT INTO customer_memory_revisions(id,memory_id,conversation_id,memory_key,reason,previous_json,next_json,created_at)
  549. VALUES(?,?,?,?,?,?,?,?)`).run(id, previous.id, previous.conversation_id, previous.memory_key, String(reason || 'memory_updated'),
  550. json(previous), json(next || {}), now());
  551. return id;
  552. }
  553. listCustomerMemoryRevisions(memoryId, limit = 100) {
  554. return this.db.prepare('SELECT * FROM customer_memory_revisions WHERE memory_id=? ORDER BY created_at DESC LIMIT ?')
  555. .all(memoryId, limit).map(row => ({ ...row, previous: parse(row.previous_json, {}), next: parse(row.next_json, {}) }));
  556. }
  557. latestMemorySnapshot(conversationId) {
  558. return this.db.prepare('SELECT * FROM customer_memory_snapshots WHERE conversation_id=? ORDER BY version DESC LIMIT 1').get(conversationId) || null;
  559. }
  560. ensureMemorySnapshot(conversationId, compactText) {
  561. const text = String(compactText || '').trim();
  562. const contentHash = crypto.createHash('sha256').update(text).digest('hex');
  563. const latest = this.latestMemorySnapshot(conversationId);
  564. if (latest?.content_hash === contentHash) return latest;
  565. const version = Number(latest?.version || 0) + 1;
  566. const id = makeId('memory_snapshot');
  567. this.db.prepare('INSERT INTO customer_memory_snapshots(id,conversation_id,version,compact_text,content_hash,created_at) VALUES(?,?,?,?,?,?)')
  568. .run(id, conversationId, version, text, contentHash, now());
  569. return this.latestMemorySnapshot(conversationId);
  570. }
  571. enqueueMemoryExtraction({ conversationId, messageId, profileUpdates = {}, maxAttempts = 3 }) {
  572. const timestamp = now();
  573. const id = makeId('memory_job');
  574. this.db.prepare(`INSERT INTO memory_extraction_jobs(id,conversation_id,message_id,profile_updates_json,status,attempts,max_attempts,
  575. next_attempt_at,error,result_json,created_at,updated_at) VALUES(?,?,?,?, 'pending',0,?,?,?,?,?,?)
  576. ON CONFLICT(conversation_id,message_id) DO UPDATE SET profile_updates_json=excluded.profile_updates_json,
  577. status=CASE WHEN memory_extraction_jobs.status='completed' THEN 'completed' ELSE 'pending' END,
  578. next_attempt_at=excluded.next_attempt_at,error=NULL,updated_at=excluded.updated_at`)
  579. .run(id, conversationId, messageId, json(profileUpdates || {}), Math.max(1, Math.min(10, Number(maxAttempts) || 3)),
  580. timestamp, null, json({}), timestamp, timestamp);
  581. return this.db.prepare('SELECT * FROM memory_extraction_jobs WHERE conversation_id=? AND message_id=?').get(conversationId, messageId);
  582. }
  583. claimMemoryExtractionJob(timestamp = now()) {
  584. this.db.exec('BEGIN IMMEDIATE');
  585. try {
  586. const row = this.db.prepare(`SELECT * FROM memory_extraction_jobs WHERE status='pending' AND next_attempt_at<=?
  587. ORDER BY created_at ASC LIMIT 1`).get(timestamp);
  588. if (!row) {
  589. this.db.exec('COMMIT');
  590. return null;
  591. }
  592. this.db.prepare(`UPDATE memory_extraction_jobs SET status='processing',attempts=attempts+1,updated_at=? WHERE id=?`)
  593. .run(timestamp, row.id);
  594. this.db.exec('COMMIT');
  595. const claimed = this.db.prepare('SELECT * FROM memory_extraction_jobs WHERE id=?').get(row.id);
  596. return { ...claimed, profileUpdates: parse(claimed.profile_updates_json, {}) };
  597. } catch (error) {
  598. this.db.exec('ROLLBACK');
  599. throw error;
  600. }
  601. }
  602. completeMemoryExtractionJob(jobId, result = {}) {
  603. const timestamp = now();
  604. this.db.prepare(`UPDATE memory_extraction_jobs SET status='completed',result_json=?,error=NULL,completed_at=?,updated_at=? WHERE id=?`)
  605. .run(json(result || {}), timestamp, timestamp, jobId);
  606. return this.getMemoryExtractionJob(jobId);
  607. }
  608. failMemoryExtractionJob(jobId, error, retryDelayMs = 1000) {
  609. const current = this.getMemoryExtractionJob(jobId);
  610. if (!current) return null;
  611. const terminal = Number(current.attempts || 0) >= Number(current.max_attempts || 3);
  612. const timestamp = now();
  613. const nextAttemptAt = new Date(Date.now() + Math.max(100, Number(retryDelayMs) || 1000)).toISOString();
  614. this.db.prepare(`UPDATE memory_extraction_jobs SET status=?,next_attempt_at=?,error=?,updated_at=? WHERE id=?`)
  615. .run(terminal ? 'failed' : 'pending', nextAttemptAt, String(error?.message || error || '').slice(0, 500), timestamp, jobId);
  616. return this.getMemoryExtractionJob(jobId);
  617. }
  618. getMemoryExtractionJob(jobId) {
  619. const row = this.db.prepare('SELECT * FROM memory_extraction_jobs WHERE id=?').get(jobId);
  620. return row ? { ...row, profileUpdates: parse(row.profile_updates_json, {}), result: parse(row.result_json, {}) } : null;
  621. }
  622. listMemoryExtractionJobs({ status = '', limit = 100 } = {}) {
  623. const rows = status
  624. ? this.db.prepare('SELECT * FROM memory_extraction_jobs WHERE status=? ORDER BY created_at DESC LIMIT ?').all(status, limit)
  625. : this.db.prepare('SELECT * FROM memory_extraction_jobs ORDER BY created_at DESC LIMIT ?').all(limit);
  626. return rows.map(row => ({ ...row, profileUpdates: parse(row.profile_updates_json, {}), result: parse(row.result_json, {}) }));
  627. }
  628. mergeProfileByContactId(contactId, patch = {}, tags) {
  629. const conversation = this.getConversationByContactId(contactId);
  630. if (!conversation) return null;
  631. const current = this.getProfile(conversation.id);
  632. return this.updateProfile(
  633. conversation.id,
  634. { ...current.profile, ...(patch || {}) },
  635. tags === undefined ? current.tags : tags,
  636. );
  637. }
  638. intelligenceFingerprint(...parts) {
  639. return crypto.createHash('sha256').update(parts.map(item => String(item || '').trim().toLowerCase()).join('\u0000')).digest('hex').slice(0, 24);
  640. }
  641. upsertCustomerTasks(conversationId, tasks = [], sourceMessageId = null) {
  642. const results = [];
  643. const find = this.db.prepare('SELECT * FROM customer_tasks WHERE conversation_id=? AND business_key=?');
  644. 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)
  645. VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
  646. 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=?`);
  647. for (const item of tasks) {
  648. const title = String(item?.title || '').trim();
  649. if (!title) continue;
  650. const type = String(item.type || 'follow_up').trim();
  651. const evidence = String(item.evidence || '').trim();
  652. const businessKey = this.customerTaskBusinessKey(item);
  653. const fingerprint = this.intelligenceFingerprint(businessKey);
  654. const managedBy = String(item.managedBy || item.managed_by || 'agent').trim();
  655. const itemSourceMessageId = Object.prototype.hasOwnProperty.call(item, 'sourceMessageId')
  656. ? item.sourceMessageId
  657. : sourceMessageId;
  658. const existing = find.get(conversationId, businessKey);
  659. if (existing) {
  660. const evidences = evidenceItems(parse(existing.evidence_json, []), itemSourceMessageId ? evidence : '', itemSourceMessageId);
  661. 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);
  662. results.push(this.db.prepare('SELECT * FROM customer_tasks WHERE id=?').get(existing.id));
  663. } else {
  664. const id = makeId('task');
  665. const evidences = evidenceItems([], evidence, itemSourceMessageId);
  666. 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());
  667. results.push(this.db.prepare('SELECT * FROM customer_tasks WHERE id=?').get(id));
  668. }
  669. }
  670. return results;
  671. }
  672. reconcileCustomerTasks(conversationId, tasks = [], sourceMessageId = null) {
  673. const results = this.upsertCustomerTasks(conversationId, tasks, sourceMessageId);
  674. const activeRuleKeys = new Set(tasks
  675. .filter(item => String(item.managedBy || item.managed_by || 'agent') === 'rule')
  676. .map(item => this.customerTaskBusinessKey(item)));
  677. const existingRules = this.db.prepare("SELECT * FROM customer_tasks WHERE conversation_id=? AND managed_by='rule' AND status IN ('open','in_progress')").all(conversationId);
  678. const resolved = [];
  679. for (const task of existingRules) {
  680. if (activeRuleKeys.has(task.business_key)) continue;
  681. this.db.prepare(`UPDATE customer_tasks SET status='done',resolution_reason='profile_condition_resolved',
  682. official_sync_status=CASE WHEN official_todo_id<>'' THEN 'completion_pending' ELSE official_sync_status END,updated_at=? WHERE id=?`).run(now(), task.id);
  683. resolved.push(task.id);
  684. }
  685. return { tasks: results, resolved };
  686. }
  687. listCustomerTasks(conversationId, limit = 100) {
  688. return this.db.prepare(`SELECT * FROM customer_tasks WHERE conversation_id=?
  689. ORDER BY CASE status WHEN 'open' THEN 0 WHEN 'in_progress' THEN 1 ELSE 2 END,
  690. CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, updated_at DESC LIMIT ?`).all(conversationId, limit);
  691. }
  692. getCustomerTask(taskId) {
  693. return this.db.prepare('SELECT * FROM customer_tasks WHERE id=?').get(taskId) || null;
  694. }
  695. completeCustomerTaskByBusinessKey(conversationId, businessKey, reason = 'business_action_completed') {
  696. const key = this.customerTaskBusinessKey({ businessKey });
  697. const task = this.db.prepare('SELECT * FROM customer_tasks WHERE conversation_id=? AND business_key=?').get(conversationId, key);
  698. if (!task || !['open', 'in_progress'].includes(task.status)) return task || null;
  699. this.db.prepare(`UPDATE customer_tasks SET status='done',resolution_reason=?,
  700. 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);
  701. return this.getCustomerTask(task.id);
  702. }
  703. updateCustomerTask(taskId, fields = {}) {
  704. const allowed = ['status', 'owner', 'due_at', 'priority', 'reason', 'resolution_reason', 'official_todo_id', 'official_sync_status', 'official_synced_at'];
  705. const entries = Object.entries(fields).filter(([key]) => allowed.includes(key));
  706. if (entries.length) {
  707. const assignments = entries.map(([key]) => `${key}=?`).join(',');
  708. this.db.prepare(`UPDATE customer_tasks SET ${assignments},updated_at=? WHERE id=?`).run(...entries.map(([, value]) => value), now(), taskId);
  709. }
  710. return this.db.prepare('SELECT * FROM customer_tasks WHERE id=?').get(taskId) || null;
  711. }
  712. upsertCustomerAlerts(conversationId, alerts = [], sourceMessageId = null) {
  713. const results = [];
  714. const find = this.db.prepare('SELECT * FROM customer_alerts WHERE conversation_id=? AND business_key=?');
  715. 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)
  716. VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
  717. 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=?`);
  718. for (const item of alerts) {
  719. const title = String(item?.title || '').trim();
  720. if (!title) continue;
  721. const type = String(item.type || 'attention').trim();
  722. const evidence = String(item.evidence || '').trim();
  723. const businessKey = this.customerAlertBusinessKey(item);
  724. const fingerprint = this.intelligenceFingerprint(businessKey);
  725. const managedBy = String(item.managedBy || item.managed_by || 'agent').trim();
  726. const itemSourceMessageId = Object.prototype.hasOwnProperty.call(item, 'sourceMessageId')
  727. ? item.sourceMessageId
  728. : sourceMessageId;
  729. const existing = find.get(conversationId, businessKey);
  730. if (existing) {
  731. const evidences = evidenceItems(parse(existing.evidence_json, []), itemSourceMessageId ? evidence : '', itemSourceMessageId);
  732. 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);
  733. results.push(this.db.prepare('SELECT * FROM customer_alerts WHERE id=?').get(existing.id));
  734. } else {
  735. const id = makeId('alert');
  736. const evidences = evidenceItems([], evidence, itemSourceMessageId);
  737. 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());
  738. results.push(this.db.prepare('SELECT * FROM customer_alerts WHERE id=?').get(id));
  739. }
  740. }
  741. return results;
  742. }
  743. reconcileCustomerAlerts(conversationId, alerts = [], sourceMessageId = null) {
  744. const results = this.upsertCustomerAlerts(conversationId, alerts, sourceMessageId);
  745. const activeRuleKeys = new Set(alerts
  746. .filter(item => String(item.managedBy || item.managed_by || 'agent') === 'rule')
  747. .map(item => this.customerAlertBusinessKey(item)));
  748. const existingRules = this.db.prepare("SELECT * FROM customer_alerts WHERE conversation_id=? AND managed_by='rule' AND status IN ('open','acknowledged')").all(conversationId);
  749. const resolved = [];
  750. for (const alert of existingRules) {
  751. if (activeRuleKeys.has(alert.business_key)) continue;
  752. this.db.prepare("UPDATE customer_alerts SET status='resolved',resolution_reason='profile_condition_resolved',updated_at=? WHERE id=?").run(now(), alert.id);
  753. resolved.push(alert.id);
  754. }
  755. return { alerts: results, resolved };
  756. }
  757. listCustomerAlerts(conversationId, limit = 100) {
  758. return this.db.prepare(`SELECT * FROM customer_alerts WHERE conversation_id=?
  759. ORDER BY CASE status WHEN 'open' THEN 0 WHEN 'acknowledged' THEN 1 ELSE 2 END,
  760. CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, updated_at DESC LIMIT ?`).all(conversationId, limit);
  761. }
  762. updateCustomerAlert(alertId, fields = {}) {
  763. const allowed = ['status', 'severity', 'recommended_action'];
  764. const entries = Object.entries(fields).filter(([key]) => allowed.includes(key));
  765. if (entries.length) {
  766. const assignments = entries.map(([key]) => `${key}=?`).join(',');
  767. this.db.prepare(`UPDATE customer_alerts SET ${assignments},updated_at=? WHERE id=?`).run(...entries.map(([, value]) => value), now(), alertId);
  768. }
  769. return this.db.prepare('SELECT * FROM customer_alerts WHERE id=?').get(alertId) || null;
  770. }
  771. hydrateCustomerRecommendation(row) {
  772. return row ? {
  773. ...row,
  774. property_snapshot: parse(row.property_snapshot_json, {}),
  775. sources: parse(row.sources_json, []),
  776. } : null;
  777. }
  778. upsertCustomerRecommendations(conversationId, items = [], source = {}) {
  779. const find = this.db.prepare('SELECT * FROM customer_recommendations WHERE conversation_id=? AND property_id=?');
  780. 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)
  781. VALUES(?,?,?,?,?,?,?,?,?,?,?)`);
  782. 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=?`);
  783. const results = [];
  784. for (const item of items) {
  785. const propertyId = String(item?.id || item?.propertyId || item?.property_id || '').trim();
  786. if (!propertyId) continue;
  787. const timestamp = String(source.createdAt || item.recommendedAt || now());
  788. const sourceItem = {
  789. type: String(source.type || 'unknown'),
  790. entityId: source.entityId || null,
  791. evidence: String(source.evidence || '').trim(),
  792. createdAt: timestamp,
  793. };
  794. const existing = find.get(conversationId, propertyId);
  795. const requestedStatus = ['candidate', 'recommended'].includes(String(source.status || '')) ? String(source.status) : 'recommended';
  796. if (!existing) {
  797. const id = makeId('recommendation');
  798. insert.run(id, conversationId, propertyId, json(item), json([sourceItem]), requestedStatus, '', 1, timestamp, timestamp, timestamp);
  799. results.push(this.hydrateCustomerRecommendation(this.db.prepare('SELECT * FROM customer_recommendations WHERE id=?').get(id)));
  800. continue;
  801. }
  802. const sources = parse(existing.sources_json, []);
  803. const sourceKey = `${sourceItem.type}\u0000${sourceItem.entityId || ''}`;
  804. const isNewSource = !sources.some(entry => `${entry.type || ''}\u0000${entry.entityId || ''}` === sourceKey);
  805. if (isNewSource) sources.push(sourceItem);
  806. 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);
  807. results.push(this.hydrateCustomerRecommendation(this.db.prepare('SELECT * FROM customer_recommendations WHERE id=?').get(existing.id)));
  808. }
  809. return results;
  810. }
  811. listCustomerRecommendations(conversationId, limit = 100) {
  812. 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));
  813. }
  814. updateCustomerRecommendation(conversationId, recommendationId, fields = {}) {
  815. const allowedStatuses = new Set(['candidate', 'recommended', 'interested', 'rejected', 'viewing', 'viewed', 'closed']);
  816. const status = String(fields.status || '');
  817. const entries = [];
  818. if (allowedStatuses.has(status)) entries.push(['status', status]);
  819. if (fields.feedback_reason !== undefined || fields.feedbackReason !== undefined) entries.push(['feedback_reason', String(fields.feedback_reason ?? fields.feedbackReason ?? '').trim()]);
  820. if (entries.length) {
  821. const assignments = entries.map(([key]) => `${key}=?`).join(',');
  822. this.db.prepare(`UPDATE customer_recommendations SET ${assignments},updated_at=? WHERE id=? AND conversation_id=?`).run(...entries.map(([, value]) => value), now(), recommendationId, conversationId);
  823. }
  824. return this.hydrateCustomerRecommendation(this.db.prepare('SELECT * FROM customer_recommendations WHERE id=? AND conversation_id=?').get(recommendationId, conversationId));
  825. }
  826. clearCustomerIntelligence() {
  827. const alerts = Number(this.db.prepare('DELETE FROM customer_alerts').run().changes || 0);
  828. const tasks = Number(this.db.prepare('DELETE FROM customer_tasks').run().changes || 0);
  829. return { tasks, alerts };
  830. }
  831. createDraft({ conversationId, inboundMessageId, content, confidence, intent, reason, requiresHuman, citations, toolTrace }) {
  832. const draftId = makeId('draft');
  833. 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)
  834. VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(draftId, conversationId, inboundMessageId, String(content), Number(confidence) || 0, intent || '', reason || '', requiresHuman ? 1 : 0, json(citations || []), json(toolTrace || []), now());
  835. return this.getDraft(draftId);
  836. }
  837. getDraft(draftId) {
  838. const row = this.db.prepare('SELECT * FROM drafts WHERE id=?').get(draftId);
  839. return row ? this.hydrateDraft(row) : null;
  840. }
  841. hydrateDraft(row) {
  842. return { ...row, requires_human: Boolean(row.requires_human), citations: parse(row.citations_json, []), tool_trace: parse(row.tool_trace_json, []) };
  843. }
  844. listDrafts({ status = '', conversationId = '', limit = 100 } = {}) {
  845. let sql = 'SELECT * FROM drafts WHERE 1=1';
  846. const params = [];
  847. if (status) { sql += ' AND status=?'; params.push(status); }
  848. if (conversationId) { sql += ' AND conversation_id=?'; params.push(conversationId); }
  849. sql += ' ORDER BY created_at DESC LIMIT ?';
  850. params.push(limit);
  851. return this.db.prepare(sql).all(...params).map(row => this.hydrateDraft(row));
  852. }
  853. updateDraft(draftId, fields) {
  854. const allowed = ['content', 'status', 'reviewed_at', 'reviewer', 'error', 'sent_message_id'];
  855. const entries = Object.entries(fields).filter(([key]) => allowed.includes(key));
  856. if (!entries.length) return this.getDraft(draftId);
  857. const assignments = entries.map(([key]) => `${key}=?`).join(',');
  858. this.db.prepare(`UPDATE drafts SET ${assignments} WHERE id=?`).run(...entries.map(([, value]) => value), draftId);
  859. return this.getDraft(draftId);
  860. }
  861. audit({ actor = 'system', action, conversationId = null, entityId = null, detail = {} }) {
  862. const auditId = makeId('audit');
  863. this.db.prepare('INSERT INTO audit_logs(id,actor,action,conversation_id,entity_id,detail_json,created_at) VALUES(?,?,?,?,?,?,?)')
  864. .run(auditId, actor, action, conversationId, entityId, json(detail), now());
  865. return auditId;
  866. }
  867. listAudit(limit = 200, conversationId = '') {
  868. const rows = conversationId
  869. ? this.db.prepare('SELECT * FROM audit_logs WHERE conversation_id=? ORDER BY created_at DESC LIMIT ?').all(conversationId, limit)
  870. : this.db.prepare('SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT ?').all(limit);
  871. return rows.map(row => ({ ...row, detail: parse(row.detail_json, {}) }));
  872. }
  873. latestAgentOutcome(conversationId) {
  874. const row = this.db.prepare(`SELECT * FROM audit_logs
  875. WHERE conversation_id=? AND action IN ('draft_created','agent_failed','agent_not_configured','agent_no_reply_needed')
  876. ORDER BY created_at DESC LIMIT 1`).get(conversationId);
  877. if (!row) return null;
  878. const detail = parse(row.detail_json, {});
  879. return {
  880. action: row.action,
  881. message: detail.message || (row.action === 'agent_no_reply_needed' ? '客户消息无需回复' : 'Agent 上游不可用'),
  882. entityId: row.entity_id,
  883. createdAt: row.created_at,
  884. };
  885. }
  886. latestAgentState(conversationId) {
  887. const outcome = this.latestAgentOutcome(conversationId);
  888. if (!outcome || ['draft_created', 'agent_no_reply_needed'].includes(outcome.action)) return null;
  889. return outcome;
  890. }
  891. getPollState(key, fallback = '') {
  892. return this.db.prepare('SELECT value FROM poll_state WHERE key=?').get(key)?.value ?? fallback;
  893. }
  894. setPollState(key, value) {
  895. this.db.prepare(`INSERT INTO poll_state(key,value,updated_at) VALUES(?,?,?)
  896. ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`).run(key, String(value), now());
  897. }
  898. importCompatibleDatabase(sourcePath) {
  899. if (!sourcePath || !fs.existsSync(sourcePath) || path.resolve(sourcePath) === path.resolve(this.filePath)) return { imported: false, reason: 'source_missing' };
  900. if (this.listConversations().length) return { imported: false, reason: 'target_not_empty' };
  901. const source = new DatabaseSync(sourcePath, { readOnly: true });
  902. const tableOrder = ['settings', 'conversations', 'messages', 'customer_profiles', 'customer_tasks', 'customer_alerts', 'customer_recommendations', 'customer_memory_items', 'customer_memory_snapshots', 'memory_extraction_jobs', 'customer_memory_revisions', 'drafts', 'audit_logs', 'poll_state'];
  903. let rowsImported = 0;
  904. this.db.exec(`
  905. DROP INDEX IF EXISTS idx_customer_tasks_business_key;
  906. DROP INDEX IF EXISTS idx_customer_alerts_business_key;
  907. `);
  908. this.db.exec('BEGIN IMMEDIATE');
  909. try {
  910. for (const table of tableOrder) {
  911. const exists = source.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table);
  912. if (!exists) continue;
  913. const columns = source.prepare(`PRAGMA table_info(${table})`).all().map(item => item.name);
  914. if (!columns.length) continue;
  915. const placeholders = columns.map(() => '?').join(',');
  916. const insert = this.db.prepare(`INSERT OR IGNORE INTO ${table}(${columns.join(',')}) VALUES(${placeholders})`);
  917. for (const row of source.prepare(`SELECT ${columns.join(',')} FROM ${table}`).all()) {
  918. rowsImported += Number(insert.run(...columns.map(column => row[column])).changes || 0);
  919. }
  920. }
  921. this.db.exec('COMMIT');
  922. } catch (error) {
  923. this.db.exec('ROLLBACK');
  924. this.db.exec(`
  925. CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_tasks_business_key ON customer_tasks(conversation_id, business_key);
  926. CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_alerts_business_key ON customer_alerts(conversation_id, business_key);
  927. `);
  928. source.close();
  929. throw error;
  930. }
  931. source.close();
  932. const intelligenceMigration = this.migrateCustomerIntelligenceRecords();
  933. this.db.exec(`
  934. CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_tasks_business_key ON customer_tasks(conversation_id, business_key);
  935. CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_alerts_business_key ON customer_alerts(conversation_id, business_key);
  936. `);
  937. this.audit({ actor: 'migration', action: 'legacy_workbench_imported', detail: { source: path.basename(sourcePath), rowsImported, intelligenceMigration } });
  938. return { imported: true, rowsImported, intelligenceMigration };
  939. }
  940. }
  941. module.exports = { AgentWorkbenchDb, isMeaningfulContactName, normalizeContactName };