unified-task-service.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. const crypto = require('crypto');
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { outputsRoot } = require('../core/output-paths');
  5. const { okResult, errorResult } = require('../core/result-envelope');
  6. const { getConversations, updateCustomerTask } = require('./agent-service');
  7. const { completeTodoKnowledge } = require('./official-office-knowledge-service');
  8. const { qiweiGoalUpdateTask } = require('../tools/qiwei-goal-management-run');
  9. const ACTIVE_STATUSES = new Set(['open', 'in_progress', 'blocked']);
  10. const LOCAL_STATUSES = new Set(['open', 'in_progress', 'blocked', 'done', 'dismissed']);
  11. const DIAGNOSIS_OUTCOMES = new Set(['effective', 'partially_effective', 'ineffective', 'unknown']);
  12. function ensureDir(dirPath) {
  13. fs.mkdirSync(dirPath, { recursive: true });
  14. return dirPath;
  15. }
  16. function readJson(filePath, fallback) {
  17. try { return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); }
  18. catch { return fallback; }
  19. }
  20. function writeJsonAtomic(filePath, value) {
  21. ensureDir(path.dirname(filePath));
  22. const tempPath = `${filePath}.${process.pid}.tmp`;
  23. fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
  24. fs.renameSync(tempPath, filePath);
  25. }
  26. function stableId(...parts) {
  27. return crypto.createHash('sha256').update(parts.map(value => String(value || '')).join('\u0000')).digest('hex').slice(0, 20);
  28. }
  29. function normalizeDate(value) {
  30. const text = String(value || '').trim();
  31. if (!text) return '';
  32. const parsed = new Date(text.replace(' ', 'T'));
  33. return Number.isNaN(parsed.getTime()) ? text : parsed.toISOString();
  34. }
  35. function isOverdue(task, now = Date.now()) {
  36. if (!ACTIVE_STATUSES.has(task.status) || !task.dueAt) return false;
  37. const timestamp = Date.parse(task.dueAt);
  38. return Number.isFinite(timestamp) && timestamp < now;
  39. }
  40. function sourceMeta(source) {
  41. return ({
  42. customer: { label: '客户会话跟进', kind: 'formal', channel: 'local-agent' },
  43. official: { label: '企微官方待办', kind: 'formal', channel: 'official-cli' },
  44. goal: { label: '目标计划', kind: 'formal', channel: 'local-goal' },
  45. local: { label: '本地内部任务', kind: 'formal', channel: 'local-task' },
  46. 'document-candidate': { label: '文档待办候选', kind: 'candidate', channel: 'ai-analysis' },
  47. 'meeting-candidate': { label: '会议待办候选', kind: 'candidate', channel: 'ai-analysis' },
  48. 'diagnosis-candidate': { label: '经营诊断候选', kind: 'candidate', channel: 'business-diagnosis' },
  49. })[source] || { label: source || '其他来源', kind: 'formal', channel: 'local' };
  50. }
  51. function baseTask(input) {
  52. const meta = sourceMeta(input.source);
  53. const task = {
  54. id: String(input.id || ''),
  55. source: input.source,
  56. sourceLabel: meta.label,
  57. sourceKind: meta.kind,
  58. channel: meta.channel,
  59. title: String(input.title || '未命名任务'),
  60. owner: String(input.owner || ''),
  61. dueAt: normalizeDate(input.dueAt),
  62. priority: String(input.priority || 'medium'),
  63. status: String(input.status || 'open'),
  64. evidence: String(input.evidence || ''),
  65. sourceTitle: String(input.sourceTitle || ''),
  66. sourceId: String(input.sourceId || ''),
  67. updatedAt: String(input.updatedAt || ''),
  68. audience: input.audience === 'external' ? 'external' : 'internal',
  69. conversationId: String(input.conversationId || ''),
  70. contactName: String(input.contactName || ''),
  71. goalId: String(input.goalId || ''),
  72. officialTodoId: String(input.officialTodoId || ''),
  73. officialSyncStatus: String(input.officialSyncStatus || ''),
  74. confidence: input.confidence === undefined || input.confidence === null ? null : Number(input.confidence),
  75. promotedTo: String(input.promotedTo || ''),
  76. sourceRef: input.sourceRef && typeof input.sourceRef === 'object' ? input.sourceRef : null,
  77. diagnosisOutcome: input.diagnosisOutcome && typeof input.diagnosisOutcome === 'object' ? input.diagnosisOutcome : null,
  78. actions: input.actions || {},
  79. };
  80. task.overdue = isOverdue(task);
  81. return task;
  82. }
  83. function createUnifiedTaskService(dependencies = {}) {
  84. const root = path.resolve(dependencies.outputsDir || outputsRoot());
  85. const storeDir = path.resolve(dependencies.storeDir || path.join(root, 'knowledge', 'tasks'));
  86. const localIndexPath = path.join(storeDir, 'index.json');
  87. const paths = {
  88. official: path.resolve(dependencies.officialTodoPath || path.join(root, 'knowledge', 'todos', 'index.json')),
  89. goals: path.resolve(dependencies.goalPath || path.join(root, 'goals', 'plans.json')),
  90. documents: path.resolve(dependencies.documentPath || path.join(root, 'knowledge', 'docs', 'index.json')),
  91. meetings: path.resolve(dependencies.meetingPath || path.join(root, 'knowledge', 'meetings', 'index.json')),
  92. diagnoses: path.resolve(dependencies.diagnosisPath || path.join(root, 'knowledge', 'diagnoses', 'action-candidates.json')),
  93. };
  94. const conversationReader = dependencies.getConversations || getConversations;
  95. const customerUpdater = dependencies.updateCustomerTask || updateCustomerTask;
  96. const officialCompleter = dependencies.completeTodoKnowledge || completeTodoKnowledge;
  97. const goalUpdater = dependencies.qiweiGoalUpdateTask || qiweiGoalUpdateTask;
  98. function readLocalStore() {
  99. const store = readJson(localIndexPath, { version: 1, tasks: [], updatedAt: null });
  100. store.version ||= 1;
  101. store.tasks = Array.isArray(store.tasks) ? store.tasks : [];
  102. return store;
  103. }
  104. function persistLocalStore(store) {
  105. store.updatedAt = new Date().toISOString();
  106. writeJsonAtomic(localIndexPath, store);
  107. const lines = [
  108. '# 统一任务中心',
  109. '',
  110. '本目录保存经人工确认后形成的本地内部任务。客户会话、企微官方待办、目标计划、文档和会议候选会在运行时统一聚合,不复制凭据或原始敏感消息。',
  111. '',
  112. `最近更新:${store.updatedAt}`,
  113. '',
  114. ...store.tasks.map(task => `- [${task.status === 'done' ? 'x' : ' '}] ${task.title}(${task.owner || '待分配'})`),
  115. '',
  116. ];
  117. fs.writeFileSync(path.join(storeDir, 'README.md'), lines.join('\n'), 'utf8');
  118. }
  119. function readDiagnosisCandidates() {
  120. const store = readJson(paths.diagnoses, { version: 1, candidates: [], updatedAt: null });
  121. store.version ||= 1;
  122. store.candidates = Array.isArray(store.candidates) ? store.candidates : [];
  123. return store;
  124. }
  125. function persistDiagnosisCandidates(store) {
  126. store.updatedAt = new Date().toISOString();
  127. writeJsonAtomic(paths.diagnoses, store);
  128. }
  129. function appendDiagnosisLifecycle(candidate, event, details = {}) {
  130. candidate.lifecycle = Array.isArray(candidate.lifecycle) ? candidate.lifecycle : [];
  131. candidate.lifecycle.push({ event, at: new Date().toISOString(), ...details });
  132. candidate.lifecycle = candidate.lifecycle.slice(-50);
  133. }
  134. function findDiagnosisCandidate(store, sourceKey) {
  135. return store.candidates.find(item => item.sourceKey === sourceKey);
  136. }
  137. function collectCustomerTasks() {
  138. const result = conversationReader() || {};
  139. const conversations = result.data?.conversations || [];
  140. return conversations.flatMap(conversation => (conversation.customerIntelligence?.tasks || []).map(task => baseTask({
  141. id: task.id,
  142. source: 'customer',
  143. title: task.title,
  144. owner: task.owner,
  145. dueAt: task.dueAt,
  146. priority: task.priority,
  147. status: task.status,
  148. evidence: task.evidence,
  149. sourceTitle: `与 ${conversation.displayName || '客户'} 的真实企微会话`,
  150. sourceId: task.id,
  151. updatedAt: task.updatedAt,
  152. audience: 'external',
  153. conversationId: conversation.id,
  154. contactName: conversation.displayName,
  155. officialTodoId: task.officialTodoId,
  156. officialSyncStatus: task.officialSyncStatus,
  157. actions: {
  158. openConversation: true,
  159. complete: ['open', 'in_progress'].includes(task.status),
  160. dismiss: ['open', 'in_progress'].includes(task.status),
  161. syncOfficial: ['open', 'in_progress'].includes(task.status) && !task.officialTodoId,
  162. },
  163. })));
  164. }
  165. function collectOfficialTasks(linkedOfficialIds) {
  166. const index = readJson(paths.official, { todos: [] });
  167. const rows = Array.isArray(index) ? index : index.todos || [];
  168. return rows.filter(todo => !linkedOfficialIds.has(String(todo.id || ''))).map(todo => baseTask({
  169. id: String(todo.id || ''),
  170. source: 'official',
  171. title: todo.content,
  172. owner: (todo.followers || []).map(item => item.name || '企微成员').filter(Boolean).join('、'),
  173. dueAt: todo.endTime,
  174. status: Number(todo.status) === 0 ? 'done' : 'open',
  175. sourceTitle: '当前机器人创建并同步的企微官方待办',
  176. sourceId: todo.id,
  177. updatedAt: todo.syncedAt,
  178. officialTodoId: todo.id,
  179. actions: { complete: Number(todo.status) !== 0 },
  180. }));
  181. }
  182. function collectGoalTasks() {
  183. const store = readJson(paths.goals, { plans: [] });
  184. return (store.plans || []).flatMap(plan => (plan.milestones || []).flatMap(milestone => (milestone.tasks || []).map(task => baseTask({
  185. id: task.id,
  186. source: 'goal',
  187. title: task.title,
  188. owner: task.owner,
  189. dueAt: task.deadline,
  190. priority: task.priority,
  191. status: ({ planned: 'open', in_progress: 'in_progress', blocked: 'blocked', done: 'done', cancelled: 'dismissed' })[task.status] || 'open',
  192. evidence: task.acceptance || task.note,
  193. sourceTitle: `${plan.title} · ${milestone.title}`,
  194. sourceId: task.id,
  195. goalId: plan.id,
  196. updatedAt: task.updatedAt || plan.updatedAt,
  197. actions: { complete: !['done', 'cancelled'].includes(task.status), dismiss: !['done', 'cancelled'].includes(task.status) },
  198. }))));
  199. }
  200. function collectLocalTasks(store) {
  201. return store.tasks.map(task => baseTask({
  202. ...task,
  203. source: 'local',
  204. sourceId: task.id,
  205. actions: {
  206. complete: ACTIVE_STATUSES.has(task.status),
  207. dismiss: ACTIVE_STATUSES.has(task.status),
  208. diagnosisFeedback: task.status === 'done' && task.sourceRef?.type === 'diagnosis-candidate',
  209. },
  210. }));
  211. }
  212. function collectCandidates(localStore) {
  213. const promoted = new Map(localStore.tasks.filter(task => task.sourceRef?.key).map(task => [task.sourceRef.key, task.id]));
  214. const candidates = [];
  215. const documents = readJson(paths.documents, { documents: [] }).documents || [];
  216. for (const document of documents) {
  217. for (const [index, item] of (document.analysis?.actionItems || []).entries()) {
  218. const key = `document:${document.id}:${stableId(item.title, item.evidence, index)}`;
  219. candidates.push(baseTask({
  220. id: key,
  221. source: 'document-candidate',
  222. title: item.title,
  223. owner: item.owner,
  224. dueAt: item.dueDate,
  225. priority: item.priority,
  226. status: 'suggested',
  227. evidence: item.evidence,
  228. sourceTitle: document.title,
  229. sourceId: key,
  230. confidence: item.confidence,
  231. promotedTo: promoted.get(key),
  232. updatedAt: document.analysis?.analyzedAt || document.syncedAt,
  233. actions: { promote: !promoted.has(key) },
  234. }));
  235. }
  236. }
  237. const meetings = readJson(paths.meetings, { meetings: [] }).meetings || [];
  238. for (const meeting of meetings) {
  239. for (const [index, item] of (meeting.analysis?.actionItems || []).entries()) {
  240. const key = `meeting:${meeting.id}:${stableId(item.title, item.evidence, index)}`;
  241. candidates.push(baseTask({
  242. id: key,
  243. source: 'meeting-candidate',
  244. title: item.title,
  245. owner: item.owner,
  246. dueAt: item.dueDate,
  247. priority: item.priority,
  248. status: 'suggested',
  249. evidence: item.evidence,
  250. sourceTitle: meeting.title,
  251. sourceId: key,
  252. confidence: item.confidence,
  253. promotedTo: promoted.get(key),
  254. updatedAt: meeting.analysis?.analyzedAt || meeting.syncedAt,
  255. actions: { promote: !promoted.has(key) },
  256. }));
  257. }
  258. }
  259. const diagnosisStore = readDiagnosisCandidates();
  260. for (const item of diagnosisStore.candidates) {
  261. const key = String(item.sourceKey || `diagnosis:${item.id}`);
  262. candidates.push(baseTask({
  263. id: key,
  264. source: 'diagnosis-candidate',
  265. title: item.title,
  266. priority: item.priority,
  267. status: 'suggested',
  268. evidence: item.evidence,
  269. sourceTitle: item.sourceTitle || '经营诊断',
  270. sourceId: key,
  271. confidence: null,
  272. promotedTo: item.promotedTaskId || promoted.get(key),
  273. diagnosisOutcome: item.diagnosisOutcome,
  274. updatedAt: item.updatedAt || item.createdAt,
  275. actions: { promote: !(item.promotedTaskId || promoted.has(key)) },
  276. }));
  277. }
  278. return candidates;
  279. }
  280. function buildHub() {
  281. ensureDir(storeDir);
  282. const localStore = readLocalStore();
  283. const customerTasks = collectCustomerTasks();
  284. const linkedOfficialIds = new Set(customerTasks.map(task => task.officialTodoId).filter(Boolean));
  285. const formalTasks = [
  286. ...customerTasks,
  287. ...collectOfficialTasks(linkedOfficialIds),
  288. ...collectGoalTasks(),
  289. ...collectLocalTasks(localStore),
  290. ];
  291. const candidates = collectCandidates(localStore);
  292. const tasks = [...formalTasks, ...candidates].sort((a, b) => {
  293. const statusOrder = value => value.overdue ? 0 : ACTIVE_STATUSES.has(value.status) ? 1 : value.status === 'suggested' ? 2 : 3;
  294. return statusOrder(a) - statusOrder(b) || String(b.updatedAt).localeCompare(String(a.updatedAt));
  295. });
  296. const bySource = {};
  297. for (const task of tasks) bySource[task.source] = (bySource[task.source] || 0) + 1;
  298. const diagnosisCandidates = readDiagnosisCandidates().candidates;
  299. const diagnosisTaskBySource = new Map(localStore.tasks.filter(task => task.sourceRef?.type === 'diagnosis-candidate').map(task => [task.sourceRef.key, task]));
  300. const promotedDiagnosisCandidates = diagnosisCandidates.filter(item => item.promotedTaskId || diagnosisTaskBySource.has(item.sourceKey));
  301. const completedDiagnosisCandidates = diagnosisCandidates.filter(item => item.taskStatus === 'done' || diagnosisTaskBySource.get(item.sourceKey)?.status === 'done');
  302. const effectiveDiagnosisCandidates = diagnosisCandidates.filter(item => item.diagnosisOutcome?.value === 'effective');
  303. const partiallyEffectiveDiagnosisCandidates = diagnosisCandidates.filter(item => item.diagnosisOutcome?.value === 'partially_effective');
  304. const ineffectiveDiagnosisCandidates = diagnosisCandidates.filter(item => item.diagnosisOutcome?.value === 'ineffective');
  305. const verifiedDiagnosisCandidates = effectiveDiagnosisCandidates.length + partiallyEffectiveDiagnosisCandidates.length + ineffectiveDiagnosisCandidates.length;
  306. return okResult({
  307. assistantMessage: `统一任务中心已汇总 ${formalTasks.length} 条正式任务和 ${candidates.length} 条待人工确认候选。`,
  308. summary: {
  309. total: formalTasks.length,
  310. active: formalTasks.filter(task => ACTIVE_STATUSES.has(task.status)).length,
  311. completed: formalTasks.filter(task => task.status === 'done').length,
  312. overdue: formalTasks.filter(task => task.overdue).length,
  313. candidates: candidates.filter(task => !task.promotedTo).length,
  314. promotedCandidates: candidates.filter(task => task.promotedTo).length,
  315. deduplicatedOfficialCount: linkedOfficialIds.size,
  316. bySource,
  317. diagnosisFeedback: {
  318. candidates: diagnosisCandidates.length,
  319. promoted: promotedDiagnosisCandidates.length,
  320. completed: completedDiagnosisCandidates.length,
  321. effective: effectiveDiagnosisCandidates.length,
  322. partiallyEffective: partiallyEffectiveDiagnosisCandidates.length,
  323. ineffective: ineffectiveDiagnosisCandidates.length,
  324. adoptionRate: diagnosisCandidates.length ? promotedDiagnosisCandidates.length / diagnosisCandidates.length : null,
  325. effectivenessRate: verifiedDiagnosisCandidates ? effectiveDiagnosisCandidates.length / verifiedDiagnosisCandidates : null,
  326. },
  327. },
  328. data: {
  329. tasks,
  330. storeDir,
  331. policy: {
  332. internalTasksOnly: true,
  333. externalCustomerSafety: '客户会话任务只能转到智能会话,由 Agent 结合上下文拟定回复;系统不会把内部任务标题直接发送给客户。',
  334. candidateSafety: '文档、会议和经营诊断中的建议默认只是候选,必须人工确认后才会转为正式内部任务。',
  335. },
  336. },
  337. files: [localIndexPath].filter(fs.existsSync),
  338. });
  339. }
  340. function createLocalTask(input = {}) {
  341. const title = String(input.title || '').trim();
  342. if (!title) return errorResult('请输入任务标题。');
  343. const store = readLocalStore();
  344. const sourceKey = String(input.sourceKey || input.sourceId || '').trim();
  345. if (sourceKey) {
  346. const existing = store.tasks.find(task => task.sourceRef?.key === sourceKey);
  347. if (existing) return okResult({ assistantMessage: '该候选已转为正式内部任务,无需重复创建。', summary: { created: false }, data: { task: existing } });
  348. }
  349. const now = new Date().toISOString();
  350. const task = {
  351. id: `local_${crypto.randomUUID()}`,
  352. title,
  353. owner: String(input.owner || '').trim(),
  354. dueAt: normalizeDate(input.dueAt),
  355. priority: String(input.priority || 'medium'),
  356. status: 'open',
  357. evidence: String(input.evidence || '').trim(),
  358. sourceTitle: String(input.sourceTitle || '人工创建'),
  359. sourceRef: sourceKey ? { key: sourceKey, type: String(input.source || 'candidate') } : null,
  360. audience: 'internal',
  361. createdAt: now,
  362. updatedAt: now,
  363. };
  364. store.tasks.push(task);
  365. persistLocalStore(store);
  366. if (sourceKey && task.sourceRef?.type === 'diagnosis-candidate') {
  367. const diagnosisStore = readDiagnosisCandidates();
  368. const candidate = findDiagnosisCandidate(diagnosisStore, sourceKey);
  369. if (candidate) {
  370. candidate.promotedTaskId = task.id;
  371. candidate.promotedAt = now;
  372. candidate.taskStatus = task.status;
  373. candidate.updatedAt = now;
  374. appendDiagnosisLifecycle(candidate, 'promoted', { taskId: task.id });
  375. persistDiagnosisCandidates(diagnosisStore);
  376. }
  377. }
  378. return okResult({ assistantMessage: `已创建内部任务“${title}”。`, summary: { created: true }, data: { task }, files: [localIndexPath, path.join(storeDir, 'README.md')] });
  379. }
  380. function createDiagnosisCandidate(input = {}) {
  381. const question = String(input.question || '').trim().slice(0, 500);
  382. const actionInput = input.action && typeof input.action === 'object' ? input.action : input;
  383. const title = String(actionInput.label || actionInput.title || '').trim().slice(0, 200);
  384. if (!question) return errorResult('缺少经营诊断问题。');
  385. if (!title) return errorResult('缺少建议动作标题。');
  386. const actionId = String(actionInput.id || actionInput.actionId || title).trim();
  387. const scopeDate = String(input.scope?.date || input.date || '').trim();
  388. const sourceKey = `diagnosis:${stableId(question, actionId, scopeDate)}`;
  389. const store = readDiagnosisCandidates();
  390. const existing = store.candidates.find(item => item.sourceKey === sourceKey);
  391. if (existing) return okResult({ assistantMessage: '该诊断建议已在今日行动候选中,无需重复加入。', summary: { created: false, duplicate: true }, data: { candidate: existing }, files: [paths.diagnoses] });
  392. const evidenceItems = Array.isArray(input.evidence) ? input.evidence : [];
  393. const gapItems = Array.isArray(input.dataGaps) ? input.dataGaps : [];
  394. const evidenceText = evidenceItems.slice(0, 3).map(item => typeof item === 'string' ? item : `${item.label || '指标'} ${item.value ?? ''}`.trim()).filter(Boolean).join(';');
  395. const gapText = gapItems.slice(0, 2).map(item => item.label || item.code).filter(Boolean).join('、');
  396. const now = new Date().toISOString();
  397. const candidate = {
  398. id: `diagnosis_${crypto.randomUUID()}`,
  399. sourceKey,
  400. diagnosisType: String(input.diagnosisType || input.type || 'general'),
  401. question,
  402. actionId,
  403. title,
  404. description: String(actionInput.description || '').trim().slice(0, 500),
  405. route: String(actionInput.route || actionInput.hash || '').trim().slice(0, 100),
  406. priority: ['low', 'medium', 'high', 'urgent'].includes(actionInput.priority) ? actionInput.priority : 'medium',
  407. evidence: [String(actionInput.description || '').trim(), evidenceText, gapText ? `数据缺口:${gapText}` : ''].filter(Boolean).join(';').slice(0, 1200),
  408. sourceTitle: `经营诊断 · ${question}`.slice(0, 300),
  409. taskStatus: 'suggested',
  410. lifecycle: [{ event: 'created', at: now }],
  411. createdAt: now,
  412. updatedAt: now,
  413. };
  414. store.candidates.push(candidate);
  415. store.candidates = store.candidates.slice(-500);
  416. persistDiagnosisCandidates(store);
  417. return okResult({
  418. assistantMessage: `已将“${title}”加入今日行动候选,等待人工确认后转为正式任务。`,
  419. summary: { created: true, requiresPromotion: true, readOnlyDiagnosisPreserved: true },
  420. data: { candidate },
  421. files: [paths.diagnoses],
  422. nextActions: [{ id: 'review-candidate', label: '前往统一任务中心确认', route: '#knowledge', requiresConfirmation: false }]
  423. });
  424. }
  425. async function update(input = {}) {
  426. const source = String(input.source || '');
  427. const taskId = String(input.taskId || input.id || '');
  428. const status = String(input.status || '');
  429. if (!taskId || !status) return errorResult('更新任务需要 taskId 和 status。');
  430. if (source === 'local') {
  431. if (!LOCAL_STATUSES.has(status)) return errorResult('不支持的本地任务状态。');
  432. const store = readLocalStore();
  433. const task = store.tasks.find(item => item.id === taskId);
  434. if (!task) return errorResult('本地任务不存在。');
  435. task.status = status;
  436. task.updatedAt = new Date().toISOString();
  437. persistLocalStore(store);
  438. if (task.sourceRef?.type === 'diagnosis-candidate') {
  439. const diagnosisStore = readDiagnosisCandidates();
  440. const candidate = findDiagnosisCandidate(diagnosisStore, task.sourceRef.key);
  441. if (candidate) {
  442. candidate.promotedTaskId ||= task.id;
  443. candidate.taskStatus = status;
  444. candidate.updatedAt = task.updatedAt;
  445. if (status === 'done') candidate.completedAt = task.updatedAt;
  446. if (status === 'dismissed') candidate.dismissedAt = task.updatedAt;
  447. appendDiagnosisLifecycle(candidate, status === 'done' ? 'completed' : status === 'dismissed' ? 'dismissed' : 'status_updated', { taskId: task.id, status });
  448. persistDiagnosisCandidates(diagnosisStore);
  449. }
  450. }
  451. return okResult({ assistantMessage: `本地任务已更新为 ${status}。`, data: { task } });
  452. }
  453. if (source === 'customer') return customerUpdater(taskId, { status });
  454. if (source === 'official') {
  455. if (status !== 'done') return errorResult('企微官方待办当前只支持标记完成。');
  456. return officialCompleter(taskId);
  457. }
  458. if (source === 'goal') {
  459. const mapped = ({ open: 'planned', in_progress: 'in_progress', blocked: 'blocked', done: 'done', dismissed: 'cancelled' })[status];
  460. if (!mapped) return errorResult('不支持的目标任务状态。');
  461. return goalUpdater({ goalId: String(input.goalId || ''), taskId, status: mapped });
  462. }
  463. return errorResult('该来源的任务不能直接更新;AI 候选需先人工确认。');
  464. }
  465. function updateDiagnosisOutcome(input = {}) {
  466. const taskId = String(input.taskId || input.id || '').trim();
  467. const outcome = String(input.outcome || input.value || '').trim();
  468. const note = String(input.note || input.evidence || '').trim().slice(0, 1000);
  469. const actor = String(input.actor || 'dashboard-user').trim().slice(0, 100);
  470. if (!taskId) return errorResult('记录诊断行动反馈需要 taskId。');
  471. if (!DIAGNOSIS_OUTCOMES.has(outcome)) return errorResult('反馈结果必须是 effective、partially_effective、ineffective 或 unknown。');
  472. const localStore = readLocalStore();
  473. const task = localStore.tasks.find(item => item.id === taskId);
  474. if (!task) return errorResult('本地任务不存在。');
  475. if (task.sourceRef?.type !== 'diagnosis-candidate') return errorResult('只有经营诊断来源的正式任务可以记录效果反馈。');
  476. if (task.status !== 'done') return errorResult('请先将任务标记为已完成,再记录效果反馈。');
  477. const diagnosisStore = readDiagnosisCandidates();
  478. const candidate = findDiagnosisCandidate(diagnosisStore, task.sourceRef.key);
  479. if (!candidate) return errorResult('未找到该任务对应的经营诊断候选。');
  480. const recordedAt = new Date().toISOString();
  481. const diagnosisOutcome = { value: outcome, note, actor, recordedAt };
  482. task.diagnosisOutcome = diagnosisOutcome;
  483. task.updatedAt = recordedAt;
  484. candidate.diagnosisOutcome = diagnosisOutcome;
  485. candidate.feedbackAt = recordedAt;
  486. candidate.updatedAt = recordedAt;
  487. appendDiagnosisLifecycle(candidate, 'outcome_recorded', { taskId, outcome, actor });
  488. persistLocalStore(localStore);
  489. persistDiagnosisCandidates(diagnosisStore);
  490. return okResult({
  491. assistantMessage: '经营诊断行动效果已记录,仅用于内部复盘。',
  492. summary: { recorded: true, outcome, internalOnly: true, customerMessageSent: false },
  493. data: { task, candidate },
  494. files: [localIndexPath, paths.diagnoses],
  495. });
  496. }
  497. return { hub: buildHub, createLocalTask, createDiagnosisCandidate, updateDiagnosisOutcome, update, readLocalStore, readDiagnosisCandidates };
  498. }
  499. const service = createUnifiedTaskService();
  500. module.exports = {
  501. createUnifiedTaskService,
  502. getUnifiedTaskHub: service.hub,
  503. createUnifiedLocalTask: service.createLocalTask,
  504. createUnifiedDiagnosisCandidate: service.createDiagnosisCandidate,
  505. updateUnifiedDiagnosisOutcome: service.updateDiagnosisOutcome,
  506. updateUnifiedTask: service.update,
  507. __testing: { baseTask, stableId, isOverdue, sourceMeta },
  508. };