diff --git a/.project-docs/30-worklog/tasks/20260901-dashboard-historical-types-a7d3e4.md b/.project-docs/30-worklog/tasks/20260901-dashboard-historical-types-a7d3e4.md new file mode 100644 index 0000000..3f7fbe8 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-dashboard-historical-types-a7d3e4.md @@ -0,0 +1,59 @@ +# Task: Classify historical dashboard task types + +## Identity + +- Task ID: 20260901-dashboard-historical-types-a7d3e4 +- Mode: Feature +- Branch: main +- Worktree: /Users/inmanx/Documents/lwltAPI +- Base commit: 2bb58278674e628c3dbdcaaa8365237a7bb64ae6 +- Owner: codex +- Status: Ready for integration + +## Scope + +- Correct the leadership dashboard's historical task-type dimension without rewriting historical task records. +- Derive a stable, business-readable type from the registered route, original instruction, or stored business action. +- Make every derived type available to the same clickable type filter used by the dashboard. +- Preserve the dashboard's full-range aggregates and improve the default read path after historical classification was added. + +## Intent And Constraints + +- “Other tasks” must be a genuine residual category, not the default for older records that predate persisted route IDs. +- Dashboard labels must describe business work and must not expose action names, codes, payload structure, or implementation details. +- Historical records, original inputs, outcomes, authorization grants, and ERP state must remain unchanged; classification is read-only at presentation time. +- A type shown in the breakdown must remain touchable: selecting it must narrow the same task/person/input/output view. +- Whole-dashboard aggregates must not be truncated by the 2,000-item keyword-search safety bound. That bound applies only when a keyword requires decrypting and searching all candidate content. + +## Outcome + +- Added deterministic historical classification in this order: persisted registered type, exact original directive, stored business action, then a small set of business-readable dashboard fallback types. +- Added leadership-safe fallback types for historical order deletion, hotel arrangements, roster imports, and order updates. Their internal stored action names are never returned as visible labels. +- Added fallback types to the dashboard's type selector and API validation, so breakdown rows for those categories drill through to the matching task list. +- Reduced the current 30-day standard-database residual “其他任务” bucket from 306 of 359 tasks to 2 of 359 tasks without updating any task rows. +- Preserved all 359 tasks in the full-range summary. The overall dashboard no longer inherits the 2,000-item keyword-search bound; only keyword searches retain that guard. +- Split the read path into a lightweight full-range aggregate projection and a detailed page projection. Original encrypted input is loaded for the visible page and only for historical rows that require it to disambiguate type. +- Added the organization predicate required by the existing input-history index to both leadership input-count queries. +- Reduced the measured 359-task dashboard service read from about 21.2 seconds to about 5.5 seconds on the current standard database while retaining 50 detailed task rows. +- No task data, database schema, authorization, ERP state, runtime process, or external system was changed. + +## Verification + +- Real standard-database read-only smoke: 359 total tasks; “其他任务” 2; “删除订单” 43; 50 detailed page rows. +- Real standard-database touch-filter smoke and API option inspection confirmed the four historical fallback types are valid selector options and use the same type filter path. +- TypeScript check: passed. +- Focused account/leadership regression: 8/8 passed. +- `node --run check:repo`: 10/10 passed. +- `node --run test:control-plane`: 153/153 passed. +- `node --run test:legacy`: 256/256 passed. +- `node --run build`: passed. +- `node --check LianSyn-platform/app.js`: passed. +- `git diff --check`: passed. + +## Follow-ups + +- The running 8786 control-plane process still serves the previously loaded TypeScript module. This projection update requires a separately authorized service restart before the live browser shows the corrected historical type totals. + +## Promotion Candidates + +- None. The canonical dashboard contract already states that task type is a leadership dimension and that the surface is business-facing; this task corrects the implementation without changing that accepted contract. diff --git a/control-plane/src/task-service.ts b/control-plane/src/task-service.ts index 9e24b53..3da30d9 100644 --- a/control-plane/src/task-service.ts +++ b/control-plane/src/task-service.ts @@ -1842,10 +1842,93 @@ function operationsDashboardOutcomeLabel(kind: OperationsDashboardOutcomeKind): return '进行中'; } -function operationsDashboardBusiness(routeId: unknown): { id: string | null; label: string } { - const route = businessRouteById(routeId); - return route - ? { id: route.routeId, label: route.directive } +function operationsDashboardRouteFromInstruction(value: unknown) { + const instruction = text(value).normalize('NFKC').trim(); + if (!instruction) return null; + const resolved = resolveBusinessRoute(instruction).route; + if (resolved) return resolved; + const matches = BUSINESS_ROUTES.filter((route) => ( + [route.directive, ...route.aliases].some((directive) => { + if (!instruction.startsWith(directive)) return false; + const remainder = instruction.slice(directive.length); + return !remainder || /^[\s::;;]/u.test(remainder); + }) + )); + return matches.length === 1 ? matches[0] : null; +} + +function operationsDashboardOperationObject(value: unknown, fallbackText: unknown): Record { + const direct = jsonObject(value); + if (Object.keys(direct).length) return direct; + const candidate = text(fallbackText).trim(); + if (!candidate.startsWith('{')) return {}; + try { + return jsonObject(JSON.parse(candidate)); + } catch { + return {}; + } +} + +function operationsDashboardRouteFromOperation(value: unknown, fallbackText: unknown) { + const operation = operationsDashboardOperationObject(value, fallbackText); + const action = text(operation.action).trim(); + if (!action) return null; + const candidates = BUSINESS_ROUTES.filter((route) => route.action === action); + if (candidates.length === 1) return candidates[0]; + const data = jsonObject(operation.data); + if (action === 'passenger_list_import') { + const referenceKind = text(jsonObject(data.existing_refs).kind || data.target_kind).trim(); + if (referenceKind === 'independent_order') return businessRouteById('passenger_list_import_independent'); + if (referenceKind === 'shared_child_order') return businessRouteById('passenger_list_import_shared_child'); + } + if (action === 'arrangement_hotel') { + const mode = text(jsonObject(data.arrangement).mode || data.mode).trim(); + if (mode === 'create') return businessRouteById('arrangement_hotel_create'); + if (mode === 'update') return businessRouteById('arrangement_hotel_update'); + } + return null; +} + +const OPERATIONS_DASHBOARD_FALLBACK_TYPES = [ + { key: 'dashboard_order_delete', label: '删除订单', action: 'order_delete' }, + { key: 'dashboard_arrangement_hotel', label: '酒店安排', action: 'arrangement_hotel' }, + { key: 'dashboard_passenger_list_import', label: '名单导入', action: 'passenger_list_import' }, + { key: 'dashboard_order_update', label: '订单修改', action: 'order_update' } +] as const; + +function operationsDashboardFallbackType(value: unknown, fallbackText: unknown) { + const operation = operationsDashboardOperationObject(value, fallbackText); + const action = text(operation.action).trim(); + return OPERATIONS_DASHBOARD_FALLBACK_TYPES.find((candidate) => candidate.action === action) || null; +} + +function operationsDashboardTypeById(value: unknown) { + const key = text(value).trim(); + if (!key) return null; + const route = businessRouteById(key); + if (route) return { key: route.routeId, label: route.directive }; + return OPERATIONS_DASHBOARD_FALLBACK_TYPES.find((candidate) => candidate.key === key) || null; +} + +function operationsDashboardNeedsInstructionForType(routeId: unknown, operation: unknown): boolean { + if (businessRouteById(routeId)) return false; + if (operationsDashboardRouteFromOperation(operation, '')) return false; + const fallback = operationsDashboardFallbackType(operation, ''); + return !fallback || fallback.action === 'arrangement_hotel' || fallback.action === 'passenger_list_import'; +} + +function operationsDashboardBusiness( + routeId: unknown, + instruction?: unknown, + operation?: unknown +): { id: string | null; label: string } { + const route = businessRouteById(routeId) + || operationsDashboardRouteFromInstruction(instruction) + || operationsDashboardRouteFromOperation(operation, instruction); + if (route) return { id: route.routeId, label: route.directive }; + const fallback = operationsDashboardFallbackType(operation, instruction); + return fallback + ? { id: fallback.key, label: fallback.label } : { id: null, label: '其他任务' }; } @@ -1955,12 +2038,13 @@ function operationsDashboardTaskProjection( config: AppConfig, row: Record ): OperationsDashboardTaskProjection { - const instruction = operationsDashboardLeadershipInstructionText(decryptText( + const rawInstruction = decryptText( config, row.original_text_ciphertext as string | null | undefined - )); + ).trim(); + const instruction = operationsDashboardLeadershipInstructionText(rawInstruction); const outcome = operationsDashboardOutcome(row); - const business = operationsDashboardBusiness(row.business_route_id); + const business = operationsDashboardBusiness(row.business_route_id, rawInstruction, row.operation); const instructionPreview = instruction.replace(/\s+/g, ' ').trim(); const resultPreview = outcome.text.replace(/\s+/g, ' ').trim(); return { @@ -4608,14 +4692,8 @@ export class TaskService { where += ` AND t.created_by = $${params.length}`; } const businessRouteId = text(options.businessRouteId).trim(); - if (businessRouteId === 'unclassified') { - where += ' AND t.business_route_id IS NULL'; - } else if (businessRouteId) { - if (!businessRouteById(businessRouteId)) { - throw new TaskError('operations_dashboard_business_invalid', '看板业务类型无效。', 400); - } - params.push(businessRouteId); - where += ` AND t.business_route_id = $${params.length}`; + if (businessRouteId && businessRouteId !== 'unclassified' && !operationsDashboardTypeById(businessRouteId)) { + throw new TaskError('operations_dashboard_business_invalid', '看板业务类型无效。', 400); } const statusCondition = operationsStatusSql(status); if (statusCondition) where += ` AND ${statusCondition}`; @@ -4626,6 +4704,7 @@ export class TaskService { const pool = getPool(this.config); const businessOptions = [ ...BUSINESS_ROUTES.map((route) => ({ key: route.routeId, label: route.directive })), + ...OPERATIONS_DASHBOARD_FALLBACK_TYPES.map(({ key, label }) => ({ key, label })), { key: 'unclassified', label: '其他任务' } ]; const actorQuery = () => pool.query( @@ -4635,44 +4714,60 @@ export class TaskService { ORDER BY is_active DESC, username ASC`, [context.organizationId] ); - const taskSelect = `t.id, t.task_id, t.original_text_ciphertext, - t.status, t.message, t.business_route_id, t.archived_at, - t.created_at, t.updated_at, t.success_receipt, t.success_receipt_at, - t.error_summary, t.error_summary_at, t.execution_result, - t.operation, t.parse_response, + const taskBaseSelect = `t.id, t.task_id, t.status, t.business_route_id, t.archived_at, + t.created_at, t.updated_at, t.operation, creator.id AS actor_id, creator.username AS actor_username, creator.role AS actor_role, creator.is_active AS actor_is_active, - to_char(timezone('Asia/Shanghai', t.created_at), 'YYYY-MM-DD') AS business_day, + to_char(timezone('Asia/Shanghai', t.created_at), 'YYYY-MM-DD') AS business_day`; + const taskDetailSelect = `${taskBaseSelect}, + t.original_text_ciphertext, t.message, t.success_receipt, t.success_receipt_at, + t.error_summary, t.error_summary_at, t.execution_result, t.parse_response, (SELECT COUNT(*)::int FROM agent_session_messages input_message - WHERE input_message.task_id = t.id + WHERE input_message.organization_id = t.organization_id + AND input_message.task_id = t.id AND input_message.role = 'user' AND input_message.input_source IS DISTINCT FROM 'reparse') AS input_turn_count`; + const loadDetailedRows = async (taskRowIds: string[]) => { + if (!taskRowIds.length) return [] as Record[]; + const detailResult = await pool.query( + `SELECT ${taskDetailSelect} + FROM tasks t + LEFT JOIN users creator ON creator.id = t.created_by + WHERE t.organization_id = $1 + AND t.id = ANY($2::uuid[])`, + [context.organizationId, taskRowIds] + ); + return detailResult.rows as Record[]; + }; - if (search) { - const [candidateResult, actorResult] = await Promise.all([ - pool.query( - `SELECT ${taskSelect} - FROM tasks t - LEFT JOIN users creator ON creator.id = t.created_by - WHERE ${where} - ORDER BY t.created_at DESC, t.id DESC - LIMIT 2001`, - params - ), - actorQuery() - ]); - if (candidateResult.rows.length > 2_000) { - throw new TaskError( - 'operations_dashboard_search_scope_too_large', - '关键词查询范围超过 2000 条,请先缩短日期或选择人员、业务类型后再查询。', - 400 - ); - } - const candidateRows = candidateResult.rows as Record[]; + const [candidateResult, actorResult] = await Promise.all([ + pool.query( + `SELECT ${taskBaseSelect} + FROM tasks t + LEFT JOIN users creator ON creator.id = t.created_by + WHERE ${where} + ORDER BY t.created_at DESC, t.id DESC`, + params + ), + actorQuery() + ]); + if (search && candidateResult.rows.length > 2_000) { + throw new TaskError( + 'operations_dashboard_search_scope_too_large', + '关键词查询范围超过 2000 项任务,请先缩短日期或选择人员、任务类型后再查询。', + 400 + ); + } + + const candidateRows = candidateResult.rows as Record[]; + const searchValuesByTask = new Map(); + let projectionRows = candidateRows; + if (search && candidateRows.length) { const taskRowIds = candidateRows.map((row) => text(row.id)).filter(Boolean); - const messageResult = taskRowIds.length - ? await pool.query( + const [detailedRows, messageResult] = await Promise.all([ + loadDetailedRows(taskRowIds), + pool.query( `SELECT m.task_id, m.content_ciphertext, actor.username AS actor_username FROM agent_session_messages m LEFT JOIN users actor ON actor.id = m.actor_user_id @@ -4682,8 +4777,9 @@ export class TaskService { AND m.input_source IS DISTINCT FROM 'reparse'`, [context.organizationId, taskRowIds] ) - : { rows: [] as Record[] }; - const searchValuesByTask = new Map(); + ]); + const detailedById = new Map(detailedRows.map((row) => [text(row.id), row])); + projectionRows = candidateRows.map((row) => detailedById.get(text(row.id)) || row); for (const row of messageResult.rows as Record[]) { const key = text(row.task_id); const values = searchValuesByTask.get(key) || []; @@ -4695,201 +4791,90 @@ export class TaskService { ); searchValuesByTask.set(key, values); } - const matched = candidateRows.flatMap((row) => { - const projection = operationsDashboardTaskProjection(this.config, row); - const matches = operationsDashboardSearchMatches(search, [ - projection.task.task_id, - projection.task.creator.username, - projection.task.business_route_label, - projection.instruction, - projection.result, - ...(searchValuesByTask.get(text(row.id)) || []) - ]); - return matches ? [projection.task] : []; - }); - const actors = (actorResult.rows as Record[]).map((row) => ({ - id: text(row.id), - username: text(row.username), - role: taskRole(row.role), - is_active: databaseBoolean(row.is_active) - } satisfies PublicOperationsDashboardActor)); - const summary = operationsDashboardSummaryFromTasks(matched); - const days = operationsDashboardBreakdownsFromTasks( - matched, - (task) => ({ key: task.business_day, label: task.business_day }), - (left, right) => right.key.localeCompare(left.key) - ); - const businesses = operationsDashboardBreakdownsFromTasks( - matched, - (task) => ({ - key: task.business_route_id || 'unclassified', - label: task.business_route_label - }), - (left, right) => right.total - left.total || left.label.localeCompare(right.label, 'zh-CN') - ); - const pageTasks = matched.slice(boundedOffset, boundedOffset + boundedLimit); - return { - summary, - users: operationsDashboardUsersFromTasks(matched), - actors, - days, - businesses, - business_options: businessOptions, - tasks: pageTasks, - range: { from: from.toISOString(), to: to.toISOString() }, - total: matched.length, - offset: boundedOffset, - limit: boundedLimit, - has_more: boundedOffset + pageTasks.length < matched.length - }; + } else if (candidateRows.length) { + const classificationIds = candidateRows + .filter((row) => operationsDashboardNeedsInstructionForType(row.business_route_id, row.operation)) + .map((row) => text(row.id)) + .filter(Boolean); + if (classificationIds.length) { + const classificationResult = await pool.query( + `SELECT id, original_text_ciphertext + FROM tasks + WHERE organization_id = $1 + AND id = ANY($2::uuid[])`, + [context.organizationId, classificationIds] + ); + const instructionsById = new Map( + (classificationResult.rows as Record[]) + .map((row) => [text(row.id), row.original_text_ciphertext]) + ); + projectionRows = candidateRows.map((row) => ({ + ...row, + original_text_ciphertext: instructionsById.get(text(row.id)) + })); + } } - const listParams = [...params, boundedLimit, boundedOffset]; - const limitParam = params.length + 1; - const offsetParam = params.length + 2; - const [summaryResult, userResult, actorResult, dayResult, businessResult, listResult] = await Promise.all([ - pool.query( - `SELECT COUNT(*)::int AS total, - COUNT(*) FILTER (WHERE ${OPERATIONS_ACTIVE_SQL})::int AS active, - COUNT(*) FILTER (WHERE ${OPERATIONS_COMPLETED_SQL})::int AS completed, - COUNT(*) FILTER (WHERE ${OPERATIONS_ATTENTION_SQL})::int AS attention, - COUNT(*) FILTER (WHERE ${OPERATIONS_FAILED_SQL})::int AS failed, - COUNT(*) FILTER (WHERE t.status = 'cancelled')::int AS cancelled, - COUNT(*) FILTER (WHERE t.archived_at IS NOT NULL)::int AS archived, - COUNT(DISTINCT t.created_by)::int AS user_count - FROM tasks t - LEFT JOIN users creator ON creator.id = t.created_by - WHERE ${where}`, - params - ), - pool.query( - `SELECT creator.id AS actor_id, creator.username AS actor_username, - creator.role AS actor_role, creator.is_active AS actor_is_active, - COUNT(*)::int AS total, - COUNT(*) FILTER (WHERE ${OPERATIONS_ACTIVE_SQL})::int AS active, - COUNT(*) FILTER (WHERE ${OPERATIONS_COMPLETED_SQL})::int AS completed, - COUNT(*) FILTER (WHERE ${OPERATIONS_ATTENTION_SQL})::int AS attention, - COUNT(*) FILTER (WHERE ${OPERATIONS_FAILED_SQL})::int AS failed, - COUNT(*) FILTER (WHERE t.status = 'cancelled')::int AS cancelled, - COUNT(*) FILTER (WHERE t.archived_at IS NOT NULL)::int AS archived, - MAX(t.updated_at) AS last_activity_at - FROM tasks t - JOIN users creator ON creator.id = t.created_by - WHERE ${where} - GROUP BY creator.id, creator.username, creator.role, creator.is_active - ORDER BY total DESC, last_activity_at DESC, creator.username ASC`, - params - ), - actorQuery(), - pool.query( - `SELECT to_char(timezone('Asia/Shanghai', t.created_at), 'YYYY-MM-DD') AS dimension_key, - COUNT(*)::int AS total, - COUNT(*) FILTER (WHERE ${OPERATIONS_ACTIVE_SQL})::int AS active, - COUNT(*) FILTER (WHERE ${OPERATIONS_COMPLETED_SQL})::int AS completed, - COUNT(*) FILTER (WHERE ${OPERATIONS_ATTENTION_SQL})::int AS attention, - COUNT(*) FILTER (WHERE ${OPERATIONS_FAILED_SQL})::int AS failed, - COUNT(*) FILTER (WHERE t.status = 'cancelled')::int AS cancelled - FROM tasks t - LEFT JOIN users creator ON creator.id = t.created_by - WHERE ${where} - GROUP BY dimension_key - ORDER BY dimension_key DESC`, - params - ), - pool.query( - `SELECT coalesce(t.business_route_id, 'unclassified') AS dimension_key, - COUNT(*)::int AS total, - COUNT(*) FILTER (WHERE ${OPERATIONS_ACTIVE_SQL})::int AS active, - COUNT(*) FILTER (WHERE ${OPERATIONS_COMPLETED_SQL})::int AS completed, - COUNT(*) FILTER (WHERE ${OPERATIONS_ATTENTION_SQL})::int AS attention, - COUNT(*) FILTER (WHERE ${OPERATIONS_FAILED_SQL})::int AS failed, - COUNT(*) FILTER (WHERE t.status = 'cancelled')::int AS cancelled - FROM tasks t - LEFT JOIN users creator ON creator.id = t.created_by - WHERE ${where} - GROUP BY dimension_key - ORDER BY total DESC, dimension_key ASC`, - params - ), - pool.query( - `SELECT ${taskSelect} - FROM tasks t - LEFT JOIN users creator ON creator.id = t.created_by - WHERE ${where} - ORDER BY t.created_at DESC, t.id DESC - LIMIT $${limitParam} OFFSET $${offsetParam}`, - listParams - ) - ]); - - const summaryRow = summaryResult.rows[0] as Record | undefined; - const summary: PublicOperationsDashboardSummary = { - total: Number(summaryRow?.total || 0), - active: Number(summaryRow?.active || 0), - completed: Number(summaryRow?.completed || 0), - attention: Number(summaryRow?.attention || 0), - failed: Number(summaryRow?.failed || 0), - cancelled: Number(summaryRow?.cancelled || 0), - archived: Number(summaryRow?.archived || 0), - user_count: Number(summaryRow?.user_count || 0) - }; - const users = (userResult.rows as Record[]).map((row) => ({ - actor: { - id: text(row.actor_id), - username: text(row.actor_username), - role: taskRole(row.actor_role), - is_active: databaseBoolean(row.actor_is_active) - }, - total: Number(row.total || 0), - active: Number(row.active || 0), - completed: Number(row.completed || 0), - attention: Number(row.attention || 0), - failed: Number(row.failed || 0), - cancelled: Number(row.cancelled || 0), - archived: Number(row.archived || 0), - last_activity_at: row.last_activity_at - ? new Date(String(row.last_activity_at)).toISOString() - : null - } satisfies PublicOperationsDashboardUser)); + const matched = projectionRows.flatMap((row) => { + const projection = operationsDashboardTaskProjection(this.config, row); + const routeMatches = !businessRouteId + || (businessRouteId === 'unclassified' + ? !projection.task.business_route_id + : projection.task.business_route_id === businessRouteId); + if (!routeMatches) return []; + if (search && !operationsDashboardSearchMatches(search, [ + projection.task.creator.username, + projection.task.business_route_label, + projection.instruction, + projection.result, + ...(searchValuesByTask.get(text(row.id)) || []) + ])) return []; + return [{ row, task: projection.task }]; + }); + const matchedTasks = matched.map(({ task }) => task); const actors = (actorResult.rows as Record[]).map((row) => ({ id: text(row.id), username: text(row.username), role: taskRole(row.role), is_active: databaseBoolean(row.is_active) } satisfies PublicOperationsDashboardActor)); - const breakdown = (row: Record, label: string): PublicOperationsDashboardBreakdown => ({ - key: text(row.dimension_key), - label, - total: Number(row.total || 0), - active: Number(row.active || 0), - completed: Number(row.completed || 0), - attention: Number(row.attention || 0), - failed: Number(row.failed || 0), - cancelled: Number(row.cancelled || 0) - }); - const days = (dayResult.rows as Record[]).map((row) => ( - breakdown(row, text(row.dimension_key)) - )); - const businesses = (businessResult.rows as Record[]).map((row) => { - const key = text(row.dimension_key); - return breakdown(row, key === 'unclassified' ? '其他任务' : operationsDashboardBusiness(key).label); - }); - const tasks = (listResult.rows as Record[]).map((row) => ( - operationsDashboardTaskProjection(this.config, row).task - )); + const summary = operationsDashboardSummaryFromTasks(matchedTasks); + const days = operationsDashboardBreakdownsFromTasks( + matchedTasks, + (task) => ({ key: task.business_day, label: task.business_day }), + (left, right) => right.key.localeCompare(left.key) + ); + const businesses = operationsDashboardBreakdownsFromTasks( + matchedTasks, + (task) => ({ + key: task.business_route_id || 'unclassified', + label: task.business_route_label + }), + (left, right) => right.total - left.total || left.label.localeCompare(right.label, 'zh-CN') + ); + const pageMatches = matched.slice(boundedOffset, boundedOffset + boundedLimit); + let pageTasks = pageMatches.map(({ task }) => task); + if (!search && pageMatches.length) { + const detailedRows = await loadDetailedRows(pageMatches.map(({ row }) => text(row.id))); + const detailedById = new Map(detailedRows.map((row) => [text(row.id), row])); + pageTasks = pageMatches.map(({ row, task }) => { + const detailed = detailedById.get(text(row.id)); + return detailed ? operationsDashboardTaskProjection(this.config, detailed).task : task; + }); + } return { summary, - users, + users: operationsDashboardUsersFromTasks(matchedTasks), actors, days, businesses, business_options: businessOptions, - tasks, + tasks: pageTasks, range: { from: from.toISOString(), to: to.toISOString() }, - total: summary.total, + total: matchedTasks.length, offset: boundedOffset, limit: boundedLimit, - has_more: boundedOffset + tasks.length < summary.total + has_more: boundedOffset + pageTasks.length < matchedTasks.length }; } @@ -4909,7 +4894,8 @@ export class TaskService { to_char(timezone('Asia/Shanghai', t.created_at), 'YYYY-MM-DD') AS business_day, (SELECT COUNT(*)::int FROM agent_session_messages input_message - WHERE input_message.task_id = t.id + WHERE input_message.organization_id = t.organization_id + AND input_message.task_id = t.id AND input_message.role = 'user' AND input_message.input_source IS DISTINCT FROM 'reparse') AS input_turn_count FROM tasks t diff --git a/control-plane/test/account-authorization.test.ts b/control-plane/test/account-authorization.test.ts index 8342abc..0b9d5be 100644 --- a/control-plane/test/account-authorization.test.ts +++ b/control-plane/test/account-authorization.test.ts @@ -130,8 +130,17 @@ test('operations dashboard is leadership-gated, business-facing, searchable, and assert.match(tasks, /operations_dashboard_range_too_large/); assert.match(tasks, /businessRouteId\?: string/); assert.match(tasks, /operations_dashboard_business_invalid/); - assert.match(tasks, /LIMIT 2001/); assert.match(tasks, /operations_dashboard_search_scope_too_large/); + assert.match(tasks, /function operationsDashboardRouteFromInstruction/); + assert.match(tasks, /function operationsDashboardRouteFromOperation/); + assert.match(tasks, /function operationsDashboardNeedsInstructionForType/); + assert.match(tasks, /if \(search && candidateResult\.rows\.length > 2_000\)/); + assert.match(tasks, /OPERATIONS_DASHBOARD_FALLBACK_TYPES/); + assert.match(tasks, /dashboard_order_delete/); + assert.match(tasks, /dashboard_arrangement_hotel/); + assert.match(tasks, /dashboard_passenger_list_import/); + assert.match(tasks, /dashboard_order_update/); + assert.match(tasks, /const routeMatches = !businessRouteId/); assert.match(tasks, /content_ciphertext[\s\S]+decryptText\(this\.config, row\.content_ciphertext/); assert.match(tasks, /operationsDashboardSearchMatches[\s\S]+projection\.instruction[\s\S]+projection\.result/); assert.match(tasks, /function operationsDashboardLeadershipResultText/);