diff --git a/apps/server/src/modules/ai/ai.service.spec.ts b/apps/server/src/modules/ai/ai.service.spec.ts index 670479b..725afcd 100644 --- a/apps/server/src/modules/ai/ai.service.spec.ts +++ b/apps/server/src/modules/ai/ai.service.spec.ts @@ -33,10 +33,13 @@ function createRiskRequest(patch: Partial = {}): Agen dailyEvidence: { todayDeliveries: [], todayProgress: [], + todayCreations: [], todayRisks: ['tests failed'], progressNotes: [], needsProgressItems: [], recentActivityCount: 1, + totalActivityCount: 1, + todayActualHours: 2, }, ...patch, }; @@ -87,10 +90,13 @@ describe('AiService', () => { dailyEvidence: { todayDeliveries: [], todayProgress: [], + todayCreations: [], todayRisks: ['测试不通过'], progressNotes: [], needsProgressItems: [], recentActivityCount: 1, + totalActivityCount: 1, + todayActualHours: 2, }, }); @@ -216,10 +222,13 @@ describe('AiService', () => { dailyEvidence: { todayDeliveries: [], todayProgress: [], + todayCreations: [], todayRisks: [123], progressNotes: [], needsProgressItems: [], recentActivityCount: 1, + totalActivityCount: 1, + todayActualHours: 2, }, }); diff --git a/apps/server/src/modules/ai/dto/risk-interpret.dto.ts b/apps/server/src/modules/ai/dto/risk-interpret.dto.ts index 093eeb0..e5352e6 100644 --- a/apps/server/src/modules/ai/dto/risk-interpret.dto.ts +++ b/apps/server/src/modules/ai/dto/risk-interpret.dto.ts @@ -36,6 +36,10 @@ class RiskDailyEvidenceDto { @IsString({ each: true }) todayProgress!: string[]; + @IsArray() + @IsString({ each: true }) + todayCreations!: string[]; + @IsArray() @IsString({ each: true }) todayRisks!: string[]; @@ -51,6 +55,12 @@ class RiskDailyEvidenceDto { @IsNumber() recentActivityCount!: number; + @IsNumber() + totalActivityCount!: number; + + @IsNumber() + todayActualHours!: number; + @IsOptional() @IsString() lastActivityAt?: string; diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index dcbdcb7..3c64c37 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -229,6 +229,7 @@ function XiaobaoWarningDetailPanel({ risk, onNavigate }: { risk: XiaobaoVersionR const evidenceItems = [ ...(evidence?.todayDeliveries ?? []), ...(evidence?.todayProgress ?? []), + ...(evidence?.todayCreations ?? []), ...(evidence?.todayRisks ?? []), ...(evidence?.progressNotes ?? []), ]; diff --git a/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx b/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx index 4fdebd9..60cc185 100644 --- a/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx +++ b/apps/web/components/xiaobao-warning/XiaobaoWarningDrawer.tsx @@ -18,6 +18,7 @@ export function XiaobaoWarningDrawer({ const evidenceItems = [ ...(evidence?.todayDeliveries ?? []), ...(evidence?.todayProgress ?? []), + ...(evidence?.todayCreations ?? []), ...(evidence?.todayRisks ?? []), ...(evidence?.progressNotes ?? []), ]; diff --git a/apps/web/lib/xiaobao-risk-ai.test.ts b/apps/web/lib/xiaobao-risk-ai.test.ts index 2565aea..d2d5bb5 100644 --- a/apps/web/lib/xiaobao-risk-ai.test.ts +++ b/apps/web/lib/xiaobao-risk-ai.test.ts @@ -53,10 +53,13 @@ function risk(patch: Partial = {}): XiaobaoVersionRisk { dailyEvidence: { todayDeliveries: [{ id: 'ev-1', title: 'Delivery', summary: 'Submitted core flow.', occurredAt: '2026-06-29T09:00:00.000Z' }], todayProgress: [{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T10:00:00.000Z' }], + todayCreations: [{ id: 'ev-create', title: 'Creation', summary: 'Created regression case.', occurredAt: '2026-06-29T10:30:00.000Z' }], todayRisks: [{ id: 'ev-3', title: 'Risk', summary: 'Regression failed.', occurredAt: '2026-06-29T11:00:00.000Z' }], progressNotes: [{ id: 'ev-4', title: 'Note', summary: 'Need QA retest.', occurredAt: '2026-06-29T12:00:00.000Z' }], needsProgressItems: [{ id: 'ev-5', title: 'Need update', summary: 'Backend task has no update.', occurredAt: '2026-06-29T13:00:00.000Z' }], recentActivityCount: 3, + totalActivityCount: 6, + todayActualHours: 4.5, lastActivityAt: '2026-06-29T13:00:00.000Z', }, signals: { @@ -437,7 +440,10 @@ test('buildRiskInterpretRequest compresses frontend risk evidence for the backen assert.equal(payload.reasons[0].severity, 'medium'); assert.equal(payload.reasons[1].severity, 'critical'); assert.deepEqual(payload.dailyEvidence.todayDeliveries, ['Submitted core flow.']); + assert.deepEqual(payload.dailyEvidence.todayCreations, ['Created regression case.']); assert.deepEqual(payload.dailyEvidence.needsProgressItems, ['Backend task has no update.']); + assert.equal(payload.dailyEvidence.totalActivityCount, 6); + assert.equal(payload.dailyEvidence.todayActualHours, 4.5); assert.equal(payload.silentRisks[0].detail, 'No update for 5 days.'); }); diff --git a/apps/web/lib/xiaobao-risk-ai.ts b/apps/web/lib/xiaobao-risk-ai.ts index ae6e6eb..fbca5aa 100644 --- a/apps/web/lib/xiaobao-risk-ai.ts +++ b/apps/web/lib/xiaobao-risk-ai.ts @@ -132,10 +132,13 @@ export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string { dailyEvidence: { todayDeliveries: normalizeEvidence(risk.dailyEvidence?.todayDeliveries), todayProgress: normalizeEvidence(risk.dailyEvidence?.todayProgress), + todayCreations: normalizeEvidence(risk.dailyEvidence?.todayCreations), todayRisks: normalizeEvidence(risk.dailyEvidence?.todayRisks), progressNotes: normalizeEvidence(risk.dailyEvidence?.progressNotes), needsProgressItems: normalizeEvidence(risk.dailyEvidence?.needsProgressItems), recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0, + totalActivityCount: risk.dailyEvidence?.totalActivityCount ?? 0, + todayActualHours: risk.dailyEvidence?.todayActualHours ?? 0, lastActivityAt: risk.dailyEvidence?.lastActivityAt ?? null, }, }); @@ -185,10 +188,13 @@ export function buildRiskInterpretRequest(risk: XiaobaoVersionRisk): AgentRiskIn dailyEvidence: { todayDeliveries: summarizeEvidence(risk.dailyEvidence?.todayDeliveries), todayProgress: summarizeEvidence(risk.dailyEvidence?.todayProgress), + todayCreations: summarizeEvidence(risk.dailyEvidence?.todayCreations), todayRisks: summarizeEvidence(risk.dailyEvidence?.todayRisks), progressNotes: summarizeEvidence(risk.dailyEvidence?.progressNotes), needsProgressItems: summarizeEvidence(risk.dailyEvidence?.needsProgressItems), recentActivityCount: risk.dailyEvidence?.recentActivityCount ?? 0, + totalActivityCount: risk.dailyEvidence?.totalActivityCount ?? 0, + todayActualHours: risk.dailyEvidence?.todayActualHours ?? 0, lastActivityAt: risk.dailyEvidence?.lastActivityAt, }, }; @@ -354,9 +360,12 @@ function buildDisplayCompatibilityKey(signature: string): string | undefined { dailyEvidence: { todayDeliveries: normalizeCompatibilityEvidence(dailyEvidence.todayDeliveries), todayProgress: normalizeCompatibilityEvidence(dailyEvidence.todayProgress), + todayCreations: normalizeCompatibilityEvidence(dailyEvidence.todayCreations), todayRisks: normalizeCompatibilityEvidence(dailyEvidence.todayRisks), progressNotes: normalizeCompatibilityEvidence(dailyEvidence.progressNotes), needsProgressItems: normalizeCompatibilityEvidence(dailyEvidence.needsProgressItems), + totalActivityCount: normalizeInteger(dailyEvidence.totalActivityCount), + todayActualHours: normalizeNumber(dailyEvidence.todayActualHours), }, }); } catch { @@ -427,6 +436,10 @@ function normalizeInteger(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null; } +function normalizeNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? Math.round(value * 10) / 10 : null; +} + function getTime(value: string): number { const time = new Date(value).getTime(); return Number.isFinite(time) ? time : Number.NaN; diff --git a/apps/web/lib/xiaobao-risk-evidence.test.ts b/apps/web/lib/xiaobao-risk-evidence.test.ts index 52dea07..fb44d9f 100644 --- a/apps/web/lib/xiaobao-risk-evidence.test.ts +++ b/apps/web/lib/xiaobao-risk-evidence.test.ts @@ -148,6 +148,80 @@ test('buildVersionDailyEvidence groups version activity and detects no activity/ assert.ok(evidence.silentRisks?.some((risk) => risk.key === 'no_activity' && risk.itemId === 'plan-1')); }); +test('buildVersionDailyEvidence mirrors daily report timestamp fallback evidence', () => { + const workItems = buildXiaobaoWorkItems({ + plans: [ + plan({ + id: 'plan-created', + title: '新建产品方案', + status: 'pending', + createdAt: '2026-06-29T01:00:00.000Z', + actualStartAt: undefined, + }), + ], + devTasks: [ + devTask({ + id: 'dev-started', + title: '开始开发风险列表', + status: 'in_progress', + actualStartAt: '2026-06-29T02:00:00.000Z', + createdAt: '2026-06-28T01:00:00.000Z', + updatedAt: '2026-06-29T02:00:00.000Z', + }), + devTask({ + id: 'dev-submitted', + title: '提交风险列表', + status: 'submitted', + actualStartAt: '2026-06-28T02:00:00.000Z', + actualEndAt: '2026-06-29T03:00:00.000Z', + createdAt: '2026-06-28T01:00:00.000Z', + updatedAt: '2026-06-29T03:00:00.000Z', + }), + ], + testCases: [ + testCase({ + id: 'tc-failed', + title: '验证风险列表失败态', + status: 'failed', + startedAt: '2026-06-29T03:30:00.000Z', + completedAt: '2026-06-29T04:00:00.000Z', + createdAt: '2026-06-28T01:00:00.000Z', + updatedAt: '2026-06-29T04:00:00.000Z', + }), + ], + bugs: [ + bug({ + id: 'bug-fixed', + title: '风险列表数量错误', + status: 'fixed', + createdAt: '2026-06-28T01:00:00.000Z', + resolvedAt: '2026-06-29T05:00:00.000Z', + updatedAt: '2026-06-29T05:00:00.000Z', + }), + ], + versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }], + requirementVersionMap: new Map([['req-1', 'ver-1']]), + }); + + const evidence = buildVersionDailyEvidence({ + versionId: 'ver-1', + workItems, + activities: [], + worklogs: [], + now: new Date('2026-06-29T08:00:00.000Z'), + }); + + assert.deepEqual(evidence.todayCreations.map((item) => item.summary), ['新建计划:新建产品方案']); + assert.ok(evidence.todayProgress.some((item) => item.summary === '开始开发:开始开发风险列表')); + assert.ok(evidence.todayDeliveries.some((item) => item.summary === '已提测开发任务:提交风险列表')); + assert.ok(evidence.todayDeliveries.some((item) => item.summary === '已修复 Bug:风险列表数量错误')); + assert.ok(evidence.todayProgress.some((item) => item.summary === '开始测试:验证风险列表失败态')); + assert.ok(evidence.todayRisks.some((item) => item.summary === '测试不通过:验证风险列表失败态')); + assert.equal(evidence.totalActivityCount, 6); + assert.equal(evidence.lastActivityAt, '2026-06-29T05:00:00.000Z'); + assert.ok(evidence.todayActualHours > 0); +}); + test('buildXiaobaoWorkItems keeps all version work without current-user filtering', () => { const items = buildXiaobaoWorkItems({ plans: [plan({ owner: 'pm-other' })], diff --git a/apps/web/lib/xiaobao-risk-evidence.ts b/apps/web/lib/xiaobao-risk-evidence.ts index 9fe0688..4375d01 100644 --- a/apps/web/lib/xiaobao-risk-evidence.ts +++ b/apps/web/lib/xiaobao-risk-evidence.ts @@ -4,8 +4,9 @@ import type { DevTask } from './dev-task'; import type { TaskWorklog } from './task-worklog'; import type { TestCase } from './test-case'; import type { VersionPlan } from './version-plan'; -import type { WorkActivity } from './work-activity'; +import type { WorkActivity, WorkActivityCategory } from './work-activity'; import type { WorkItem } from './workspace-engine'; +import { calcActualElapsedHours } from './work-hours'; export interface EvidenceItem { id: string; @@ -18,10 +19,13 @@ export interface EvidenceItem { export interface VersionDailyEvidence { todayDeliveries: EvidenceItem[]; todayProgress: EvidenceItem[]; + todayCreations: EvidenceItem[]; todayRisks: EvidenceItem[]; progressNotes: EvidenceItem[]; needsProgressItems: EvidenceItem[]; recentActivityCount: number; + totalActivityCount: number; + todayActualHours: number; lastActivityAt?: string; silentRisks?: SilentRisk[]; } @@ -52,6 +56,13 @@ export interface BuildXiaobaoWorkItemsInput { const ACTIVE_STATUSES = new Set(['in_progress', 'testing', 'running', 'open', 'fixing', 'fixed', 'verifying']); +type EvidenceDraft = EvidenceItem & { + sourceId: string; + sourceType: WorkActivity['sourceType']; + action: WorkActivity['action']; + category: WorkActivityCategory; +}; + export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] { const versionMap = new Map(input.versions.map((version) => [version.id, version])); const requirementVersionMap = normalizeRequirementVersionMap(input.requirementVersionMap); @@ -167,39 +178,66 @@ export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput) const versionWorklogs = (input.worklogs ?? []).filter((worklog) => versionItemIds.has(worklog.taskId)); const todayActivities = versionActivities.filter((activity) => activity.date === today); const todayWorklogs = versionWorklogs.filter((worklog) => worklog.date === today); + const activityKeys = new Set(todayActivities.map(getActivityKey)); + const evidenceSourceIds = new Set([ + ...todayActivities.map((activity) => activity.sourceId), + ...todayWorklogs.map((worklog) => worklog.taskId), + ]); + const fallbackEvidence = buildTimestampFallbackEvidence(versionItems, today, activityKeys, evidenceSourceIds); + const activityEvidence = [ + ...todayActivities.map(activityToEvidenceDraft), + ...fallbackEvidence, + ].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); + const evidenceActivities = [ + ...versionActivities, + ...fallbackEvidence.map(evidenceDraftToActivity), + ]; const recentSince = now.getTime() - 3 * 86_400_000; const recentActivityCount = [ - ...versionActivities.map((activity) => activity.occurredAt), + ...evidenceActivities.map((activity) => activity.occurredAt), ...versionWorklogs.map((worklog) => worklog.createdAt), ].filter((value) => { const date = parseDate(value); return Boolean(date && date.getTime() >= recentSince); }).length; const lastActivityAt = latestIso([ - ...versionActivities.map((activity) => activity.occurredAt), + ...evidenceActivities.map((activity) => activity.occurredAt), ...versionWorklogs.map((worklog) => worklog.createdAt), ]); const touchedTodayIds = new Set([ - ...todayActivities.map((activity) => activity.sourceId), + ...activityEvidence.map((activity) => activity.sourceId), ...todayWorklogs.map((worklog) => worklog.taskId), ]); + const todayDeliveries = activityEvidence.filter((activity) => activity.category === 'delivery').map(evidenceDraftToEvidenceItem); + const todayProgress = activityEvidence.filter((activity) => activity.category === 'progress').map(evidenceDraftToEvidenceItem); + const todayCreations = activityEvidence.filter((activity) => activity.category === 'creation').map(evidenceDraftToEvidenceItem); + const todayRisks = activityEvidence.filter((activity) => activity.category === 'risk').map(evidenceDraftToEvidenceItem); + const progressNotes = [ + ...activityEvidence.filter((activity) => activity.category === 'note').map(evidenceDraftToEvidenceItem), + ...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))), + ].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); + const worklogTaskIds = new Set(todayWorklogs.map((worklog) => worklog.taskId)); + const activityHours = Array.from(new Set(activityEvidence.map((activity) => activity.sourceId))) + .filter((sourceId) => !worklogTaskIds.has(sourceId)) + .reduce((sum, sourceId) => sum + calcWorkItemHoursForDate(itemMap.get(sourceId), today, now), 0); + const worklogHours = todayWorklogs.reduce((sum, worklog) => sum + worklog.hours, 0); return { - todayDeliveries: todayActivities.filter((activity) => activity.category === 'delivery').map(activityToEvidenceItem), - todayProgress: todayActivities.filter((activity) => activity.category === 'progress').map(activityToEvidenceItem), - todayRisks: todayActivities.filter((activity) => activity.category === 'risk').map(activityToEvidenceItem), - progressNotes: [ - ...todayActivities.filter((activity) => activity.category === 'note').map(activityToEvidenceItem), - ...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))), - ].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)), + todayDeliveries, + todayProgress, + todayCreations, + todayRisks, + progressNotes, needsProgressItems: versionItems - .filter((item) => shouldNeedProgress(item, touchedTodayIds, versionActivities, versionWorklogs, now)) + .filter((item) => shouldNeedProgress(item, touchedTodayIds, evidenceActivities, versionWorklogs, now)) .map(itemToEvidenceItem), recentActivityCount, + totalActivityCount: todayDeliveries.length + todayProgress.length + todayCreations.length + todayRisks.length + progressNotes.length, + todayActualHours: roundHalfHour(worklogHours + activityHours), lastActivityAt, - silentRisks: buildSilentRisks(versionItems, versionActivities, versionWorklogs, now), + silentRisks: buildSilentRisks(versionItems, evidenceActivities, versionWorklogs, now), }; } @@ -217,6 +255,41 @@ function activityToEvidenceItem(activity: WorkActivity): EvidenceItem { }; } +function activityToEvidenceDraft(activity: WorkActivity): EvidenceDraft { + return { + ...activityToEvidenceItem(activity), + sourceId: activity.sourceId, + sourceType: activity.sourceType, + action: activity.action, + category: activity.category, + }; +} + +function evidenceDraftToEvidenceItem(item: EvidenceDraft): EvidenceItem { + return { + id: item.id, + title: item.title, + summary: item.summary, + occurredAt: item.occurredAt, + actorId: item.actorId, + }; +} + +function evidenceDraftToActivity(item: EvidenceDraft): WorkActivity { + return { + id: item.id, + actorId: item.actorId ?? '', + date: item.occurredAt.slice(0, 10), + sourceType: item.sourceType, + sourceId: item.sourceId, + action: item.action, + category: item.category, + title: item.title, + summary: item.summary, + occurredAt: item.occurredAt, + }; +} + function worklogToEvidenceItem(worklog: TaskWorklog, item?: WorkItem): EvidenceItem { return { id: worklog.id, @@ -236,6 +309,109 @@ function itemToEvidenceItem(item: WorkItem): EvidenceItem { }; } +function buildTimestampFallbackEvidence( + items: WorkItem[], + date: string, + existingKeys: Set, + evidenceSourceIds: Set, +): EvidenceDraft[] { + const out: EvidenceDraft[] = []; + + for (const item of items) { + if (evidenceSourceIds.has(item.id)) continue; + for (const draft of getTimestampFallbackDrafts(item)) { + if (!isWithinLocalDate(draft.occurredAt, date)) continue; + const key = `${item.id}:${draft.action}`; + if (existingKeys.has(key)) continue; + existingKeys.add(key); + out.push({ + id: `fallback-${draft.action}-${item.id}`, + title: item.title, + sourceId: item.id, + sourceType: draft.sourceType, + action: draft.action, + category: draft.category, + summary: draft.summary, + occurredAt: draft.occurredAt, + }); + } + } + + return out; +} + +function getTimestampFallbackDrafts( + item: WorkItem, +): Array> { + const raw = getRaw(item); + const extra = item.extra ?? {}; + + if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') { + return [ + makeFallbackDraft('version_plan', 'version_plan_created', 'creation', asString(raw.createdAt), `新建计划:${item.title}`), + makeFallbackDraft('version_plan', 'version_plan_started', 'progress', asString(raw.actualStartAt) ?? asString(extra.actualStartAt), `开始计划:${item.title}`), + makeFallbackDraft('version_plan', 'version_plan_completed', 'delivery', asString(raw.completedAt), `完成计划:${item.title}`), + ].filter(isFallbackDraft); + } + + if (item.type === 'devTask') { + return [ + makeFallbackDraft('dev_task', 'dev_task_created', 'creation', asString(raw.createdAt), `新建开发任务:${item.title}`), + makeFallbackDraft('dev_task', 'dev_task_started', 'progress', asString(raw.actualStartAt) ?? asString(extra.actualStartAt), `开始开发:${item.title}`), + makeFallbackDraft('dev_task', 'dev_task_submitted', 'delivery', asString(raw.actualEndAt) ?? asString(extra.actualEndAt), `已提测开发任务:${item.title}`), + ].filter(isFallbackDraft); + } + + if (item.type === 'testCase') { + const completedAt = asString(raw.completedAt) ?? asString(extra.completedAt); + const terminal = getTestCaseTerminalFallback(item.status, item.title, completedAt); + return [ + makeFallbackDraft('test_case', 'test_case_created', 'creation', asString(raw.createdAt), `新建测试用例:${item.title}`), + makeFallbackDraft('test_case', 'test_case_started', 'progress', asString(raw.startedAt) ?? asString(extra.startedAt), `开始测试:${item.title}`), + terminal, + ].filter(isFallbackDraft); + } + + if (item.type === 'bug') { + return [ + makeFallbackDraft('bug', 'bug_created', 'creation', asString(raw.createdAt), `新建 Bug:${item.title}`), + makeFallbackDraft('bug', 'bug_fixing', 'progress', getBugFixingStartedAt(raw), `开始修复 Bug:${item.title}`), + makeFallbackDraft('bug', 'bug_fixed', 'delivery', asString(raw.resolvedAt), `已修复 Bug:${item.title}`), + makeFallbackDraft('bug', 'bug_closed', 'delivery', asString(raw.closedAt), `已关闭 Bug:${item.title}`), + ].filter(isFallbackDraft); + } + + return []; +} + +function makeFallbackDraft( + sourceType: WorkActivity['sourceType'], + action: WorkActivity['action'], + category: WorkActivityCategory, + occurredAt: string | undefined, + summary: string, +): Pick | undefined { + if (!occurredAt) return undefined; + return { sourceType, action, category, summary, occurredAt }; +} + +function isFallbackDraft( + draft: Pick | undefined, +): draft is Pick { + return Boolean(draft); +} + +function getTestCaseTerminalFallback( + status: string, + title: string, + completedAt: string | undefined, +): Pick | undefined { + if (status === 'passed') return makeFallbackDraft('test_case', 'test_case_passed', 'delivery', completedAt, `测试通过:${title}`); + if (status === 'failed') return makeFallbackDraft('test_case', 'test_case_failed', 'risk', completedAt, `测试不通过:${title}`); + if (status === 'blocked') return makeFallbackDraft('test_case', 'test_case_blocked', 'risk', completedAt, `测试阻塞:${title}`); + return undefined; +} + function buildSilentRisks( items: WorkItem[], activities: WorkActivity[], @@ -328,6 +504,102 @@ function getLatestItemTouchAt(item: WorkItem): string | undefined { ]); } +function calcWorkItemHoursForDate(item: WorkItem | undefined, date: string, now: Date): number { + const interval = getActualInterval(item, now); + if (!interval) return 0; + + const day = getLocalDateBounds(date); + if (!day) return 0; + + const start = new Date(interval.start).getTime(); + const end = new Date(interval.end).getTime(); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0; + + const overlapStart = Math.max(start, day.start.getTime()); + const overlapEnd = Math.min(end, day.end.getTime()); + if (overlapEnd <= overlapStart) return 0; + + return calcActualElapsedHours( + new Date(overlapStart).toISOString(), + new Date(overlapEnd).toISOString(), + ); +} + +function getActualInterval(item: WorkItem | undefined, now: Date): { start: string; end: string } | undefined { + if (!item) return undefined; + const raw = getRaw(item); + const extra = item.extra ?? {}; + const nowIso = now.toISOString(); + + if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') { + const start = asString(raw.actualStartAt) ?? asString(extra.actualStartAt); + const end = asString(raw.completedAt) ?? nowIso; + return start ? { start, end } : undefined; + } + + if (item.type === 'devTask') { + const start = asString(raw.actualStartAt) ?? asString(extra.actualStartAt); + const end = asString(raw.actualEndAt) ?? asString(extra.actualEndAt) ?? (item.status === 'submitted' ? asString(raw.updatedAt) : undefined) ?? nowIso; + return start ? { start, end } : undefined; + } + + if (item.type === 'testCase') { + const start = asString(raw.startedAt) ?? asString(extra.startedAt); + const isTerminal = ['passed', 'failed', 'blocked'].includes(item.status); + const end = asString(raw.completedAt) ?? asString(extra.completedAt) ?? (isTerminal ? asString(raw.updatedAt) : undefined) ?? nowIso; + return start ? { start, end } : undefined; + } + + if (item.type === 'bug') { + const start = getBugFixingStartedAt(raw) ?? asString(raw.createdAt) ?? asString(extra.createdAt); + const isTerminal = ['closed', 'rejected'].includes(item.status); + const end = asString(raw.closedAt) ?? asString(raw.resolvedAt) ?? (isTerminal ? asString(raw.updatedAt) : undefined) ?? nowIso; + return start ? { start, end } : undefined; + } + + return undefined; +} + +function getBugFixingStartedAt(raw: Record): string | undefined { + const logs = raw.logs; + if (!Array.isArray(logs)) return undefined; + const fixingLog = logs.find((log) => { + if (!log || typeof log !== 'object') return false; + const row = log as Record; + return row.action === 'status_change' && row.toValue === 'fixing' && typeof row.createdAt === 'string'; + }) as Record | undefined; + return asString(fixingLog?.createdAt); +} + +function getActivityKey(activity: Pick): string { + return `${activity.sourceId}:${activity.action}`; +} + +function getRaw(item: WorkItem): Record { + return ((item.raw ?? {}) as unknown) as Record; +} + +function getLocalDateBounds(date: string): { start: Date; end: Date } | undefined { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + if (!match) return undefined; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return undefined; + const start = new Date(year, month - 1, day, 0, 0, 0, 0); + const end = new Date(year, month - 1, day + 1, 0, 0, 0, 0); + if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) return undefined; + return { start, end }; +} + +function isWithinLocalDate(value: string, date: string): boolean { + const bounds = getLocalDateBounds(date); + if (!bounds) return false; + const time = new Date(value).getTime(); + if (!Number.isFinite(time)) return false; + return time >= bounds.start.getTime() && time < bounds.end.getTime(); +} + function latestIso(values: Array): string | undefined { let latest: string | undefined; let latestTime = Number.NEGATIVE_INFINITY; @@ -358,3 +630,8 @@ function parseDate(value: string | undefined): Date | undefined { function asString(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined; } + +function roundHalfHour(hours: number): number { + if (!Number.isFinite(hours) || hours <= 0) return 0; + return Math.round(hours * 2) / 2; +} diff --git a/apps/web/lib/xiaobao-risk.test.ts b/apps/web/lib/xiaobao-risk.test.ts index 9a2372e..e0aa52c 100644 --- a/apps/web/lib/xiaobao-risk.test.ts +++ b/apps/web/lib/xiaobao-risk.test.ts @@ -110,10 +110,13 @@ test('calcXiaobaoVersionRisk preserves daily evidence on the returned risk objec const dailyEvidence: VersionDailyEvidence = { todayDeliveries: [{ id: 'ev-1', title: '提交提测', summary: '完成核心流程', occurredAt: '2026-07-02T02:00:00.000Z' }], todayProgress: [], + todayCreations: [], todayRisks: [], progressNotes: [], needsProgressItems: [], recentActivityCount: 1, + totalActivityCount: 1, + todayActualHours: 2, lastActivityAt: '2026-07-02T02:00:00.000Z', }; @@ -140,10 +143,13 @@ test('calcXiaobaoVersionRisk uses silent risks from daily evidence', () => { const dailyEvidence: VersionDailyEvidence = { todayDeliveries: [], todayProgress: [], + todayCreations: [], todayRisks: [], progressNotes: [], needsProgressItems: [], recentActivityCount: 0, + totalActivityCount: 0, + todayActualHours: 0, silentRisks: [silentRisk], }; diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 4676b8f..1602f3d 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -147,10 +147,13 @@ export interface AgentRiskInterpretRequest { dailyEvidence: { todayDeliveries: string[]; todayProgress: string[]; + todayCreations: string[]; todayRisks: string[]; progressNotes: string[]; needsProgressItems: string[]; recentActivityCount: number; + totalActivityCount: number; + todayActualHours: number; lastActivityAt?: string; }; }