const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { outputsRoot } = require('../core/output-paths'); const { okResult, errorResult } = require('../core/result-envelope'); const { getConversations, updateCustomerTask } = require('./agent-service'); const { completeTodoKnowledge } = require('./official-office-knowledge-service'); const { qiweiGoalUpdateTask } = require('../tools/qiwei-goal-management-run'); const ACTIVE_STATUSES = new Set(['open', 'in_progress', 'blocked']); const LOCAL_STATUSES = new Set(['open', 'in_progress', 'blocked', 'done', 'dismissed']); const DIAGNOSIS_OUTCOMES = new Set(['effective', 'partially_effective', 'ineffective', 'unknown']); function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); return dirPath; } function readJson(filePath, fallback) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); } catch { return fallback; } } function writeJsonAtomic(filePath, value) { ensureDir(path.dirname(filePath)); const tempPath = `${filePath}.${process.pid}.tmp`; fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); fs.renameSync(tempPath, filePath); } function stableId(...parts) { return crypto.createHash('sha256').update(parts.map(value => String(value || '')).join('\u0000')).digest('hex').slice(0, 20); } function normalizeDate(value) { const text = String(value || '').trim(); if (!text) return ''; const parsed = new Date(text.replace(' ', 'T')); return Number.isNaN(parsed.getTime()) ? text : parsed.toISOString(); } function isOverdue(task, now = Date.now()) { if (!ACTIVE_STATUSES.has(task.status) || !task.dueAt) return false; const timestamp = Date.parse(task.dueAt); return Number.isFinite(timestamp) && timestamp < now; } function sourceMeta(source) { return ({ customer: { label: '客户会话跟进', kind: 'formal', channel: 'local-agent' }, official: { label: '企微官方待办', kind: 'formal', channel: 'official-cli' }, goal: { label: '目标计划', kind: 'formal', channel: 'local-goal' }, local: { label: '本地内部任务', kind: 'formal', channel: 'local-task' }, 'document-candidate': { label: '文档待办候选', kind: 'candidate', channel: 'ai-analysis' }, 'meeting-candidate': { label: '会议待办候选', kind: 'candidate', channel: 'ai-analysis' }, 'diagnosis-candidate': { label: '经营诊断候选', kind: 'candidate', channel: 'business-diagnosis' }, })[source] || { label: source || '其他来源', kind: 'formal', channel: 'local' }; } function baseTask(input) { const meta = sourceMeta(input.source); const task = { id: String(input.id || ''), source: input.source, sourceLabel: meta.label, sourceKind: meta.kind, channel: meta.channel, title: String(input.title || '未命名任务'), owner: String(input.owner || ''), dueAt: normalizeDate(input.dueAt), priority: String(input.priority || 'medium'), status: String(input.status || 'open'), evidence: String(input.evidence || ''), sourceTitle: String(input.sourceTitle || ''), sourceId: String(input.sourceId || ''), updatedAt: String(input.updatedAt || ''), audience: input.audience === 'external' ? 'external' : 'internal', conversationId: String(input.conversationId || ''), contactName: String(input.contactName || ''), goalId: String(input.goalId || ''), officialTodoId: String(input.officialTodoId || ''), officialSyncStatus: String(input.officialSyncStatus || ''), confidence: input.confidence === undefined || input.confidence === null ? null : Number(input.confidence), promotedTo: String(input.promotedTo || ''), sourceRef: input.sourceRef && typeof input.sourceRef === 'object' ? input.sourceRef : null, diagnosisOutcome: input.diagnosisOutcome && typeof input.diagnosisOutcome === 'object' ? input.diagnosisOutcome : null, actions: input.actions || {}, }; task.overdue = isOverdue(task); return task; } function createUnifiedTaskService(dependencies = {}) { const root = path.resolve(dependencies.outputsDir || outputsRoot()); const storeDir = path.resolve(dependencies.storeDir || path.join(root, 'knowledge', 'tasks')); const localIndexPath = path.join(storeDir, 'index.json'); const paths = { official: path.resolve(dependencies.officialTodoPath || path.join(root, 'knowledge', 'todos', 'index.json')), goals: path.resolve(dependencies.goalPath || path.join(root, 'goals', 'plans.json')), documents: path.resolve(dependencies.documentPath || path.join(root, 'knowledge', 'docs', 'index.json')), meetings: path.resolve(dependencies.meetingPath || path.join(root, 'knowledge', 'meetings', 'index.json')), diagnoses: path.resolve(dependencies.diagnosisPath || path.join(root, 'knowledge', 'diagnoses', 'action-candidates.json')), }; const conversationReader = dependencies.getConversations || getConversations; const customerUpdater = dependencies.updateCustomerTask || updateCustomerTask; const officialCompleter = dependencies.completeTodoKnowledge || completeTodoKnowledge; const goalUpdater = dependencies.qiweiGoalUpdateTask || qiweiGoalUpdateTask; function readLocalStore() { const store = readJson(localIndexPath, { version: 1, tasks: [], updatedAt: null }); store.version ||= 1; store.tasks = Array.isArray(store.tasks) ? store.tasks : []; return store; } function persistLocalStore(store) { store.updatedAt = new Date().toISOString(); writeJsonAtomic(localIndexPath, store); const lines = [ '# 统一任务中心', '', '本目录保存经人工确认后形成的本地内部任务。客户会话、企微官方待办、目标计划、文档和会议候选会在运行时统一聚合,不复制凭据或原始敏感消息。', '', `最近更新:${store.updatedAt}`, '', ...store.tasks.map(task => `- [${task.status === 'done' ? 'x' : ' '}] ${task.title}(${task.owner || '待分配'})`), '', ]; fs.writeFileSync(path.join(storeDir, 'README.md'), lines.join('\n'), 'utf8'); } function readDiagnosisCandidates() { const store = readJson(paths.diagnoses, { version: 1, candidates: [], updatedAt: null }); store.version ||= 1; store.candidates = Array.isArray(store.candidates) ? store.candidates : []; return store; } function persistDiagnosisCandidates(store) { store.updatedAt = new Date().toISOString(); writeJsonAtomic(paths.diagnoses, store); } function appendDiagnosisLifecycle(candidate, event, details = {}) { candidate.lifecycle = Array.isArray(candidate.lifecycle) ? candidate.lifecycle : []; candidate.lifecycle.push({ event, at: new Date().toISOString(), ...details }); candidate.lifecycle = candidate.lifecycle.slice(-50); } function findDiagnosisCandidate(store, sourceKey) { return store.candidates.find(item => item.sourceKey === sourceKey); } function collectCustomerTasks() { const result = conversationReader() || {}; const conversations = result.data?.conversations || []; return conversations.flatMap(conversation => (conversation.customerIntelligence?.tasks || []).map(task => baseTask({ id: task.id, source: 'customer', title: task.title, owner: task.owner, dueAt: task.dueAt, priority: task.priority, status: task.status, evidence: task.evidence, sourceTitle: `与 ${conversation.displayName || '客户'} 的真实企微会话`, sourceId: task.id, updatedAt: task.updatedAt, audience: 'external', conversationId: conversation.id, contactName: conversation.displayName, officialTodoId: task.officialTodoId, officialSyncStatus: task.officialSyncStatus, actions: { openConversation: true, complete: ['open', 'in_progress'].includes(task.status), dismiss: ['open', 'in_progress'].includes(task.status), syncOfficial: ['open', 'in_progress'].includes(task.status) && !task.officialTodoId, }, }))); } function collectOfficialTasks(linkedOfficialIds) { const index = readJson(paths.official, { todos: [] }); const rows = Array.isArray(index) ? index : index.todos || []; return rows.filter(todo => !linkedOfficialIds.has(String(todo.id || ''))).map(todo => baseTask({ id: String(todo.id || ''), source: 'official', title: todo.content, owner: (todo.followers || []).map(item => item.name || '企微成员').filter(Boolean).join('、'), dueAt: todo.endTime, status: Number(todo.status) === 0 ? 'done' : 'open', sourceTitle: '当前机器人创建并同步的企微官方待办', sourceId: todo.id, updatedAt: todo.syncedAt, officialTodoId: todo.id, actions: { complete: Number(todo.status) !== 0 }, })); } function collectGoalTasks() { const store = readJson(paths.goals, { plans: [] }); return (store.plans || []).flatMap(plan => (plan.milestones || []).flatMap(milestone => (milestone.tasks || []).map(task => baseTask({ id: task.id, source: 'goal', title: task.title, owner: task.owner, dueAt: task.deadline, priority: task.priority, status: ({ planned: 'open', in_progress: 'in_progress', blocked: 'blocked', done: 'done', cancelled: 'dismissed' })[task.status] || 'open', evidence: task.acceptance || task.note, sourceTitle: `${plan.title} · ${milestone.title}`, sourceId: task.id, goalId: plan.id, updatedAt: task.updatedAt || plan.updatedAt, actions: { complete: !['done', 'cancelled'].includes(task.status), dismiss: !['done', 'cancelled'].includes(task.status) }, })))); } function collectLocalTasks(store) { return store.tasks.map(task => baseTask({ ...task, source: 'local', sourceId: task.id, actions: { complete: ACTIVE_STATUSES.has(task.status), dismiss: ACTIVE_STATUSES.has(task.status), diagnosisFeedback: task.status === 'done' && task.sourceRef?.type === 'diagnosis-candidate', }, })); } function collectCandidates(localStore) { const promoted = new Map(localStore.tasks.filter(task => task.sourceRef?.key).map(task => [task.sourceRef.key, task.id])); const candidates = []; const documents = readJson(paths.documents, { documents: [] }).documents || []; for (const document of documents) { for (const [index, item] of (document.analysis?.actionItems || []).entries()) { const key = `document:${document.id}:${stableId(item.title, item.evidence, index)}`; candidates.push(baseTask({ id: key, source: 'document-candidate', title: item.title, owner: item.owner, dueAt: item.dueDate, priority: item.priority, status: 'suggested', evidence: item.evidence, sourceTitle: document.title, sourceId: key, confidence: item.confidence, promotedTo: promoted.get(key), updatedAt: document.analysis?.analyzedAt || document.syncedAt, actions: { promote: !promoted.has(key) }, })); } } const meetings = readJson(paths.meetings, { meetings: [] }).meetings || []; for (const meeting of meetings) { for (const [index, item] of (meeting.analysis?.actionItems || []).entries()) { const key = `meeting:${meeting.id}:${stableId(item.title, item.evidence, index)}`; candidates.push(baseTask({ id: key, source: 'meeting-candidate', title: item.title, owner: item.owner, dueAt: item.dueDate, priority: item.priority, status: 'suggested', evidence: item.evidence, sourceTitle: meeting.title, sourceId: key, confidence: item.confidence, promotedTo: promoted.get(key), updatedAt: meeting.analysis?.analyzedAt || meeting.syncedAt, actions: { promote: !promoted.has(key) }, })); } } const diagnosisStore = readDiagnosisCandidates(); for (const item of diagnosisStore.candidates) { const key = String(item.sourceKey || `diagnosis:${item.id}`); candidates.push(baseTask({ id: key, source: 'diagnosis-candidate', title: item.title, priority: item.priority, status: 'suggested', evidence: item.evidence, sourceTitle: item.sourceTitle || '经营诊断', sourceId: key, confidence: null, promotedTo: item.promotedTaskId || promoted.get(key), diagnosisOutcome: item.diagnosisOutcome, updatedAt: item.updatedAt || item.createdAt, actions: { promote: !(item.promotedTaskId || promoted.has(key)) }, })); } return candidates; } function buildHub() { ensureDir(storeDir); const localStore = readLocalStore(); const customerTasks = collectCustomerTasks(); const linkedOfficialIds = new Set(customerTasks.map(task => task.officialTodoId).filter(Boolean)); const formalTasks = [ ...customerTasks, ...collectOfficialTasks(linkedOfficialIds), ...collectGoalTasks(), ...collectLocalTasks(localStore), ]; const candidates = collectCandidates(localStore); const tasks = [...formalTasks, ...candidates].sort((a, b) => { const statusOrder = value => value.overdue ? 0 : ACTIVE_STATUSES.has(value.status) ? 1 : value.status === 'suggested' ? 2 : 3; return statusOrder(a) - statusOrder(b) || String(b.updatedAt).localeCompare(String(a.updatedAt)); }); const bySource = {}; for (const task of tasks) bySource[task.source] = (bySource[task.source] || 0) + 1; const diagnosisCandidates = readDiagnosisCandidates().candidates; const diagnosisTaskBySource = new Map(localStore.tasks.filter(task => task.sourceRef?.type === 'diagnosis-candidate').map(task => [task.sourceRef.key, task])); const promotedDiagnosisCandidates = diagnosisCandidates.filter(item => item.promotedTaskId || diagnosisTaskBySource.has(item.sourceKey)); const completedDiagnosisCandidates = diagnosisCandidates.filter(item => item.taskStatus === 'done' || diagnosisTaskBySource.get(item.sourceKey)?.status === 'done'); const effectiveDiagnosisCandidates = diagnosisCandidates.filter(item => item.diagnosisOutcome?.value === 'effective'); const partiallyEffectiveDiagnosisCandidates = diagnosisCandidates.filter(item => item.diagnosisOutcome?.value === 'partially_effective'); const ineffectiveDiagnosisCandidates = diagnosisCandidates.filter(item => item.diagnosisOutcome?.value === 'ineffective'); const verifiedDiagnosisCandidates = effectiveDiagnosisCandidates.length + partiallyEffectiveDiagnosisCandidates.length + ineffectiveDiagnosisCandidates.length; return okResult({ assistantMessage: `统一任务中心已汇总 ${formalTasks.length} 条正式任务和 ${candidates.length} 条待人工确认候选。`, summary: { total: formalTasks.length, active: formalTasks.filter(task => ACTIVE_STATUSES.has(task.status)).length, completed: formalTasks.filter(task => task.status === 'done').length, overdue: formalTasks.filter(task => task.overdue).length, candidates: candidates.filter(task => !task.promotedTo).length, promotedCandidates: candidates.filter(task => task.promotedTo).length, deduplicatedOfficialCount: linkedOfficialIds.size, bySource, diagnosisFeedback: { candidates: diagnosisCandidates.length, promoted: promotedDiagnosisCandidates.length, completed: completedDiagnosisCandidates.length, effective: effectiveDiagnosisCandidates.length, partiallyEffective: partiallyEffectiveDiagnosisCandidates.length, ineffective: ineffectiveDiagnosisCandidates.length, adoptionRate: diagnosisCandidates.length ? promotedDiagnosisCandidates.length / diagnosisCandidates.length : null, effectivenessRate: verifiedDiagnosisCandidates ? effectiveDiagnosisCandidates.length / verifiedDiagnosisCandidates : null, }, }, data: { tasks, storeDir, policy: { internalTasksOnly: true, externalCustomerSafety: '客户会话任务只能转到智能会话,由 Agent 结合上下文拟定回复;系统不会把内部任务标题直接发送给客户。', candidateSafety: '文档、会议和经营诊断中的建议默认只是候选,必须人工确认后才会转为正式内部任务。', }, }, files: [localIndexPath].filter(fs.existsSync), }); } function createLocalTask(input = {}) { const title = String(input.title || '').trim(); if (!title) return errorResult('请输入任务标题。'); const store = readLocalStore(); const sourceKey = String(input.sourceKey || input.sourceId || '').trim(); if (sourceKey) { const existing = store.tasks.find(task => task.sourceRef?.key === sourceKey); if (existing) return okResult({ assistantMessage: '该候选已转为正式内部任务,无需重复创建。', summary: { created: false }, data: { task: existing } }); } const now = new Date().toISOString(); const task = { id: `local_${crypto.randomUUID()}`, title, owner: String(input.owner || '').trim(), dueAt: normalizeDate(input.dueAt), priority: String(input.priority || 'medium'), status: 'open', evidence: String(input.evidence || '').trim(), sourceTitle: String(input.sourceTitle || '人工创建'), sourceRef: sourceKey ? { key: sourceKey, type: String(input.source || 'candidate') } : null, audience: 'internal', createdAt: now, updatedAt: now, }; store.tasks.push(task); persistLocalStore(store); if (sourceKey && task.sourceRef?.type === 'diagnosis-candidate') { const diagnosisStore = readDiagnosisCandidates(); const candidate = findDiagnosisCandidate(diagnosisStore, sourceKey); if (candidate) { candidate.promotedTaskId = task.id; candidate.promotedAt = now; candidate.taskStatus = task.status; candidate.updatedAt = now; appendDiagnosisLifecycle(candidate, 'promoted', { taskId: task.id }); persistDiagnosisCandidates(diagnosisStore); } } return okResult({ assistantMessage: `已创建内部任务“${title}”。`, summary: { created: true }, data: { task }, files: [localIndexPath, path.join(storeDir, 'README.md')] }); } function createDiagnosisCandidate(input = {}) { const question = String(input.question || '').trim().slice(0, 500); const actionInput = input.action && typeof input.action === 'object' ? input.action : input; const title = String(actionInput.label || actionInput.title || '').trim().slice(0, 200); if (!question) return errorResult('缺少经营诊断问题。'); if (!title) return errorResult('缺少建议动作标题。'); const actionId = String(actionInput.id || actionInput.actionId || title).trim(); const scopeDate = String(input.scope?.date || input.date || '').trim(); const sourceKey = `diagnosis:${stableId(question, actionId, scopeDate)}`; const store = readDiagnosisCandidates(); const existing = store.candidates.find(item => item.sourceKey === sourceKey); if (existing) return okResult({ assistantMessage: '该诊断建议已在今日行动候选中,无需重复加入。', summary: { created: false, duplicate: true }, data: { candidate: existing }, files: [paths.diagnoses] }); const evidenceItems = Array.isArray(input.evidence) ? input.evidence : []; const gapItems = Array.isArray(input.dataGaps) ? input.dataGaps : []; const evidenceText = evidenceItems.slice(0, 3).map(item => typeof item === 'string' ? item : `${item.label || '指标'} ${item.value ?? ''}`.trim()).filter(Boolean).join(';'); const gapText = gapItems.slice(0, 2).map(item => item.label || item.code).filter(Boolean).join('、'); const now = new Date().toISOString(); const candidate = { id: `diagnosis_${crypto.randomUUID()}`, sourceKey, diagnosisType: String(input.diagnosisType || input.type || 'general'), question, actionId, title, description: String(actionInput.description || '').trim().slice(0, 500), route: String(actionInput.route || actionInput.hash || '').trim().slice(0, 100), priority: ['low', 'medium', 'high', 'urgent'].includes(actionInput.priority) ? actionInput.priority : 'medium', evidence: [String(actionInput.description || '').trim(), evidenceText, gapText ? `数据缺口:${gapText}` : ''].filter(Boolean).join(';').slice(0, 1200), sourceTitle: `经营诊断 · ${question}`.slice(0, 300), taskStatus: 'suggested', lifecycle: [{ event: 'created', at: now }], createdAt: now, updatedAt: now, }; store.candidates.push(candidate); store.candidates = store.candidates.slice(-500); persistDiagnosisCandidates(store); return okResult({ assistantMessage: `已将“${title}”加入今日行动候选,等待人工确认后转为正式任务。`, summary: { created: true, requiresPromotion: true, readOnlyDiagnosisPreserved: true }, data: { candidate }, files: [paths.diagnoses], nextActions: [{ id: 'review-candidate', label: '前往统一任务中心确认', route: '#knowledge', requiresConfirmation: false }] }); } async function update(input = {}) { const source = String(input.source || ''); const taskId = String(input.taskId || input.id || ''); const status = String(input.status || ''); if (!taskId || !status) return errorResult('更新任务需要 taskId 和 status。'); if (source === 'local') { if (!LOCAL_STATUSES.has(status)) return errorResult('不支持的本地任务状态。'); const store = readLocalStore(); const task = store.tasks.find(item => item.id === taskId); if (!task) return errorResult('本地任务不存在。'); task.status = status; task.updatedAt = new Date().toISOString(); persistLocalStore(store); if (task.sourceRef?.type === 'diagnosis-candidate') { const diagnosisStore = readDiagnosisCandidates(); const candidate = findDiagnosisCandidate(diagnosisStore, task.sourceRef.key); if (candidate) { candidate.promotedTaskId ||= task.id; candidate.taskStatus = status; candidate.updatedAt = task.updatedAt; if (status === 'done') candidate.completedAt = task.updatedAt; if (status === 'dismissed') candidate.dismissedAt = task.updatedAt; appendDiagnosisLifecycle(candidate, status === 'done' ? 'completed' : status === 'dismissed' ? 'dismissed' : 'status_updated', { taskId: task.id, status }); persistDiagnosisCandidates(diagnosisStore); } } return okResult({ assistantMessage: `本地任务已更新为 ${status}。`, data: { task } }); } if (source === 'customer') return customerUpdater(taskId, { status }); if (source === 'official') { if (status !== 'done') return errorResult('企微官方待办当前只支持标记完成。'); return officialCompleter(taskId); } if (source === 'goal') { const mapped = ({ open: 'planned', in_progress: 'in_progress', blocked: 'blocked', done: 'done', dismissed: 'cancelled' })[status]; if (!mapped) return errorResult('不支持的目标任务状态。'); return goalUpdater({ goalId: String(input.goalId || ''), taskId, status: mapped }); } return errorResult('该来源的任务不能直接更新;AI 候选需先人工确认。'); } function updateDiagnosisOutcome(input = {}) { const taskId = String(input.taskId || input.id || '').trim(); const outcome = String(input.outcome || input.value || '').trim(); const note = String(input.note || input.evidence || '').trim().slice(0, 1000); const actor = String(input.actor || 'dashboard-user').trim().slice(0, 100); if (!taskId) return errorResult('记录诊断行动反馈需要 taskId。'); if (!DIAGNOSIS_OUTCOMES.has(outcome)) return errorResult('反馈结果必须是 effective、partially_effective、ineffective 或 unknown。'); const localStore = readLocalStore(); const task = localStore.tasks.find(item => item.id === taskId); if (!task) return errorResult('本地任务不存在。'); if (task.sourceRef?.type !== 'diagnosis-candidate') return errorResult('只有经营诊断来源的正式任务可以记录效果反馈。'); if (task.status !== 'done') return errorResult('请先将任务标记为已完成,再记录效果反馈。'); const diagnosisStore = readDiagnosisCandidates(); const candidate = findDiagnosisCandidate(diagnosisStore, task.sourceRef.key); if (!candidate) return errorResult('未找到该任务对应的经营诊断候选。'); const recordedAt = new Date().toISOString(); const diagnosisOutcome = { value: outcome, note, actor, recordedAt }; task.diagnosisOutcome = diagnosisOutcome; task.updatedAt = recordedAt; candidate.diagnosisOutcome = diagnosisOutcome; candidate.feedbackAt = recordedAt; candidate.updatedAt = recordedAt; appendDiagnosisLifecycle(candidate, 'outcome_recorded', { taskId, outcome, actor }); persistLocalStore(localStore); persistDiagnosisCandidates(diagnosisStore); return okResult({ assistantMessage: '经营诊断行动效果已记录,仅用于内部复盘。', summary: { recorded: true, outcome, internalOnly: true, customerMessageSent: false }, data: { task, candidate }, files: [localIndexPath, paths.diagnoses], }); } return { hub: buildHub, createLocalTask, createDiagnosisCandidate, updateDiagnosisOutcome, update, readLocalStore, readDiagnosisCandidates }; } const service = createUnifiedTaskService(); module.exports = { createUnifiedTaskService, getUnifiedTaskHub: service.hub, createUnifiedLocalTask: service.createLocalTask, createUnifiedDiagnosisCandidate: service.createDiagnosisCandidate, updateUnifiedDiagnosisOutcome: service.updateDiagnosisOutcome, updateUnifiedTask: service.update, __testing: { baseTask, stableId, isOverdue, sourceMeta }, };