fix(小宝预警): 稳定风险证据与建议更新
关键改动: - 优化风险证据的日报工时与静默风险计算 - 稳定 AI 触发签名,避免证据细节变化造成重复请求 - 调整预警建议更新状态与相关测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -61,6 +61,7 @@ type EvidenceDraft = EvidenceItem & {
|
||||
sourceType: WorkActivity['sourceType'];
|
||||
action: WorkActivity['action'];
|
||||
category: WorkActivityCategory;
|
||||
metadata?: WorkActivity['metadata'];
|
||||
};
|
||||
|
||||
export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] {
|
||||
@@ -219,9 +220,7 @@ export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput)
|
||||
...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 activityHours = calcActivityHoursForDate(activityEvidence, versionActivities, itemMap, worklogTaskIds, today, now);
|
||||
const worklogHours = todayWorklogs.reduce((sum, worklog) => sum + worklog.hours, 0);
|
||||
|
||||
return {
|
||||
@@ -262,6 +261,7 @@ function activityToEvidenceDraft(activity: WorkActivity): EvidenceDraft {
|
||||
sourceType: activity.sourceType,
|
||||
action: activity.action,
|
||||
category: activity.category,
|
||||
metadata: activity.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -287,6 +287,7 @@ function evidenceDraftToActivity(item: EvidenceDraft): WorkActivity {
|
||||
title: item.title,
|
||||
summary: item.summary,
|
||||
occurredAt: item.occurredAt,
|
||||
metadata: item.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -427,20 +428,20 @@ function buildSilentRisks(
|
||||
...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt),
|
||||
]);
|
||||
const latestTouchAt = latestIso([latestUpdateAt, latestEvidenceAt]);
|
||||
const evidenceDays = daysSince(latestEvidenceAt, now);
|
||||
const evidenceDays = daysSince(latestEvidenceAt ?? latestUpdateAt, now);
|
||||
const updateDays = daysSince(latestUpdateAt, now);
|
||||
const touchDays = daysSince(latestTouchAt, now);
|
||||
|
||||
if (updateDays >= 5) {
|
||||
if (Number.isFinite(updateDays) && updateDays >= 5) {
|
||||
risks.push(makeSilentRisk('no_update', item, `No status update for ${Math.floor(updateDays)} days.`));
|
||||
}
|
||||
if (evidenceDays >= 8) {
|
||||
if (Number.isFinite(evidenceDays) && evidenceDays >= 8) {
|
||||
risks.push(makeSilentRisk('no_report', item, `No progress report for ${Math.floor(evidenceDays)} days.`));
|
||||
}
|
||||
if (evidenceDays >= 4) {
|
||||
if (Number.isFinite(evidenceDays) && evidenceDays >= 4) {
|
||||
risks.push(makeSilentRisk('no_activity', item, `No work activity for ${Math.floor(evidenceDays)} days.`));
|
||||
}
|
||||
if (isActiveUnfinished(item) && touchDays >= 3) {
|
||||
if (isActiveUnfinished(item) && Number.isFinite(touchDays) && touchDays >= 3) {
|
||||
risks.push(makeSilentRisk('unhandled', item, `Active work has been untouched for ${Math.floor(touchDays)} days.`));
|
||||
}
|
||||
}
|
||||
@@ -504,25 +505,155 @@ 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;
|
||||
function calcActivityHoursForDate(
|
||||
activities: EvidenceDraft[],
|
||||
allActivities: WorkActivity[],
|
||||
itemMap: Map<string, WorkItem>,
|
||||
excludedSourceIds: Set<string>,
|
||||
date: string,
|
||||
now: Date,
|
||||
): number {
|
||||
const recordScopedSourceIds = new Set<string>();
|
||||
const intervals = activities
|
||||
.filter((activity) => !excludedSourceIds.has(activity.sourceId))
|
||||
.map((activity) => {
|
||||
if (!isRecordScopedProgressActivity(activity)) return undefined;
|
||||
recordScopedSourceIds.add(activity.sourceId);
|
||||
const workStartedAt = getRecordScopedWorkStartedAt(activity, allActivities, itemMap, date);
|
||||
if (!workStartedAt) return undefined;
|
||||
return getActivityIntervalForDate(workStartedAt, activity.occurredAt, date);
|
||||
})
|
||||
.filter(isTimeInterval);
|
||||
|
||||
const sourceIds = Array.from(new Set(activities.map((activity) => activity.sourceId)));
|
||||
const fallbackIntervals = sourceIds
|
||||
.filter((sourceId) => !excludedSourceIds.has(sourceId))
|
||||
.filter((sourceId) => !recordScopedSourceIds.has(sourceId))
|
||||
.map((sourceId) => getWorkItemIntervalForDate(itemMap.get(sourceId), date, now))
|
||||
.filter(isTimeInterval);
|
||||
|
||||
return calcMergedIntervalHours([...intervals, ...fallbackIntervals]);
|
||||
}
|
||||
|
||||
function isPlanRecordProgressActivity(activity: Pick<WorkActivity, 'sourceType' | 'action'>): boolean {
|
||||
return activity.sourceType === 'version_plan'
|
||||
&& (activity.action === 'version_plan_requirement_progress'
|
||||
|| activity.action === 'version_plan_research_direction_progress');
|
||||
}
|
||||
|
||||
function isProgressNoteRecordActivity(activity: Pick<WorkActivity, 'action' | 'metadata'>): boolean {
|
||||
return activity.action === 'progress_note_added' && Boolean(asString(activity.metadata?.nextStartAt));
|
||||
}
|
||||
|
||||
function isRecordScopedProgressActivity(activity: Pick<WorkActivity, 'sourceType' | 'action' | 'metadata'>): boolean {
|
||||
return isPlanRecordProgressActivity(activity) || isProgressNoteRecordActivity(activity);
|
||||
}
|
||||
|
||||
function getRecordScopedWorkStartedAt(
|
||||
activity: EvidenceDraft,
|
||||
allActivities: WorkActivity[],
|
||||
itemMap: Map<string, WorkItem>,
|
||||
date: string,
|
||||
): string | undefined {
|
||||
if (isPlanRecordProgressActivity(activity)) {
|
||||
return asString(activity.metadata?.workStartedAt)
|
||||
?? getPreviousRecordNextStartAt(activity, allActivities)
|
||||
?? getSameDayWorkItemStartAt(itemMap.get(activity.sourceId), date);
|
||||
}
|
||||
|
||||
if (isProgressNoteRecordActivity(activity)) {
|
||||
return getPreviousRecordNextStartAt(activity, allActivities)
|
||||
?? getSameDayWorkItemStartAt(itemMap.get(activity.sourceId), date);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getPreviousRecordNextStartAt(activity: Pick<WorkActivity, 'sourceId' | 'occurredAt'>, allActivities: WorkActivity[]): string | undefined {
|
||||
const currentTime = new Date(activity.occurredAt).getTime();
|
||||
if (!Number.isFinite(currentTime)) return undefined;
|
||||
|
||||
return [...allActivities]
|
||||
.filter((item) => item.sourceId === activity.sourceId && item.occurredAt < activity.occurredAt)
|
||||
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt))
|
||||
.map((item) => asString(item.metadata?.nextStartAt))
|
||||
.find((nextStartAt) => {
|
||||
if (!nextStartAt) return false;
|
||||
const startTime = new Date(nextStartAt).getTime();
|
||||
return Number.isFinite(startTime) && startTime < currentTime;
|
||||
});
|
||||
}
|
||||
|
||||
function getSameDayWorkItemStartAt(item: WorkItem | undefined, date: string): string | undefined {
|
||||
if (!item) return undefined;
|
||||
const interval = getActualInterval(item, new Date());
|
||||
if (!interval) return undefined;
|
||||
if (!interval.start || !isWithinLocalDate(interval.start, date)) return undefined;
|
||||
return interval.start;
|
||||
}
|
||||
|
||||
function getActivityIntervalForDate(
|
||||
startAt: string,
|
||||
occurredAt: string,
|
||||
date: string,
|
||||
): { start: number; end: number } | undefined {
|
||||
const day = getLocalDateBounds(date);
|
||||
if (!day) return 0;
|
||||
if (!day) return undefined;
|
||||
|
||||
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 start = new Date(startAt).getTime();
|
||||
const end = new Date(occurredAt).getTime();
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return undefined;
|
||||
|
||||
const overlapStart = Math.max(start, day.start.getTime());
|
||||
const overlapEnd = Math.min(end, day.end.getTime());
|
||||
if (overlapEnd <= overlapStart) return 0;
|
||||
if (overlapEnd <= overlapStart) return undefined;
|
||||
|
||||
return calcActualElapsedHours(
|
||||
new Date(overlapStart).toISOString(),
|
||||
new Date(overlapEnd).toISOString(),
|
||||
);
|
||||
return { start: overlapStart, end: overlapEnd };
|
||||
}
|
||||
|
||||
function getWorkItemIntervalForDate(item: WorkItem | undefined, date: string, now: Date): { start: number; end: number } | undefined {
|
||||
const interval = getActualInterval(item, now);
|
||||
if (!interval) return undefined;
|
||||
|
||||
const day = getLocalDateBounds(date);
|
||||
if (!day) return undefined;
|
||||
|
||||
const start = new Date(interval.start).getTime();
|
||||
const end = new Date(interval.end).getTime();
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return undefined;
|
||||
|
||||
const overlapStart = Math.max(start, day.start.getTime());
|
||||
const overlapEnd = Math.min(end, day.end.getTime());
|
||||
if (overlapEnd <= overlapStart) return undefined;
|
||||
|
||||
return { start: overlapStart, end: overlapEnd };
|
||||
}
|
||||
|
||||
function isTimeInterval(interval: { start: number; end: number } | undefined): interval is { start: number; end: number } {
|
||||
return Boolean(interval);
|
||||
}
|
||||
|
||||
function calcMergedIntervalHours(intervals: Array<{ start: number; end: number }>): number {
|
||||
if (intervals.length === 0) return 0;
|
||||
|
||||
const sorted = [...intervals].sort((a, b) => a.start - b.start);
|
||||
const merged: Array<{ start: number; end: number }> = [];
|
||||
let current = { ...sorted[0] };
|
||||
|
||||
for (const interval of sorted.slice(1)) {
|
||||
if (interval.start <= current.end) {
|
||||
current.end = Math.max(current.end, interval.end);
|
||||
} else {
|
||||
merged.push(current);
|
||||
current = { ...interval };
|
||||
}
|
||||
}
|
||||
merged.push(current);
|
||||
|
||||
return merged.reduce((sum, interval) => sum + calcActualElapsedHours(
|
||||
new Date(interval.start).toISOString(),
|
||||
new Date(interval.end).toISOString(),
|
||||
), 0);
|
||||
}
|
||||
|
||||
function getActualInterval(item: WorkItem | undefined, now: Date): { start: string; end: string } | undefined {
|
||||
|
||||
Reference in New Issue
Block a user