feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI
- Automation rules propose suggestions instead of creating tasks; notify per rule config; corrected varroa measurements refresh or resolve their suggestion - Inspection values can be taken over into the colony card (occupiedFrames 0-40, strength deprecated); warning counts use the same visibility rule as the notification list - Biotech chains: swarm check after queen cells, queen acceptance check, split schedules (raise own, queen cell, queen) - Season autopilot core with A1 inspection due, A2 varroa measurement, A3 recheck after treatment; colony sync and daily step - Frontend: suggestions page with groups, bulk accept, undo, paging, focus management and permission gating - Frontend: varroa traffic light from seasonal bands, sample size, one range request for the colony chart - Notifications: mark-read answers 404 for rows the user may no longer see Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
64a2b7dad9
commit
02eb16924e
142 files changed
+11847
-1251
No files matched your search
@@ -0,0 +1,184 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import { addDays } from '../common/dates';
|
||||
import { utcDay } from '../domain-events/domain-event-types';
|
||||
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import type { SeasonState } from '../season/season-types';
|
||||
import { SeasonService } from '../season/season.service';
|
||||
import { SuggestionsService } from '../suggestions/suggestions.service';
|
||||
import { suggestionDedupeKey } from '../suggestions/suggestion.types';
|
||||
import { loadAutopilotInputIn } from './autopilot-input';
|
||||
import { evaluateAutopilot } from './autopilot-rules';
|
||||
import type {
|
||||
AutopilotCandidate,
|
||||
AutopilotConfigs,
|
||||
AutopilotSyncResult,
|
||||
} from './autopilot.types';
|
||||
|
||||
/** Rules and seasons a daily step reads once for all its colonies. */
|
||||
export interface AutopilotPreload {
|
||||
configs: AutopilotConfigs;
|
||||
seasons: ReadonlyMap<string, SeasonState>;
|
||||
}
|
||||
|
||||
const NOTHING: AutopilotSyncResult = { opened: 0, refreshed: 0, resolved: 0 };
|
||||
|
||||
/**
|
||||
* A suggestion lives suggestionTtlDays from its due day, from today if it is
|
||||
* already overdue: a condition that has lasted longer than the ttl would
|
||||
* otherwise expire the day it is proposed and never be seen.
|
||||
*/
|
||||
function expiresOn(dueDate: string, today: string, ttlDays: number): string {
|
||||
return addDays(dueDate > today ? dueDate : today, ttlDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles the open autopilot suggestions of one colony with what the
|
||||
* rules say today. It writes suggestions only, never tasks or colony data,
|
||||
* and leaves rule and chain suggestions alone.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AutopilotService {
|
||||
private readonly logger = new Logger(AutopilotService.name);
|
||||
|
||||
constructor(
|
||||
private readonly suggestions: SuggestionsService,
|
||||
private readonly seasons: SeasonService,
|
||||
) {}
|
||||
|
||||
/** The rules and the season of the given apiaries, read once for many colonies. */
|
||||
async preloadFor(
|
||||
db: DbClient,
|
||||
apiaryIds: readonly string[],
|
||||
today: string,
|
||||
): Promise<AutopilotPreload> {
|
||||
return {
|
||||
configs: await this.readConfigs(db),
|
||||
seasons: await this.seasons.statesFor(db, apiaryIds, today),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks the colony row, then: ended colony - nothing; autopilot off - all
|
||||
* open autopilot suggestions RESOLVED; otherwise suggestions whose key is
|
||||
* gone are RESOLVED, those with the same key refreshed, new candidates
|
||||
* proposed (in the order A1...A10, so concurrent syncs take the proposal
|
||||
* locks in the same order). A candidate is dropped by `proposeIn` when an
|
||||
* open suggestion or task of the same intent exists. A colony that has
|
||||
* ended is skipped here; its open suggestions become OBSOLETE through the
|
||||
* handler `suggestions.colony-ended`. `maxStepsPerColony` is not applied:
|
||||
* it limits the next-steps view, not what is proposed. `preload` spares a
|
||||
* daily step the rule and season reads per colony.
|
||||
*/
|
||||
async syncColonyIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
colonyId: string,
|
||||
today: string,
|
||||
preload?: AutopilotPreload,
|
||||
): Promise<AutopilotSyncResult> {
|
||||
assertTransactionClient(tx, 'AutopilotService.syncColonyIn');
|
||||
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
|
||||
return NOTHING;
|
||||
}
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
select: { status: true, apiaryId: true },
|
||||
});
|
||||
if (colony === null || !isLivingColonyStatus(colony.status)) {
|
||||
return NOTHING;
|
||||
}
|
||||
const configs = preload?.configs ?? (await this.readConfigs(tx));
|
||||
const autopilot = configs.autopilot;
|
||||
const open = await tx.suggestion.findMany({
|
||||
where: { colonyId, source: 'AUTOPILOT', status: 'OPEN' },
|
||||
select: { id: true, dedupeKey: true, expiresAt: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (!autopilot.active) {
|
||||
const resolved = await this.suggestions.resolveIn(
|
||||
tx,
|
||||
open.map((suggestion) => suggestion.id),
|
||||
'Autopilot aus',
|
||||
);
|
||||
return { ...NOTHING, resolved };
|
||||
}
|
||||
const season = (
|
||||
preload?.seasons ??
|
||||
(await this.seasons.statesFor(tx, [colony.apiaryId], today))
|
||||
).get(colony.apiaryId);
|
||||
if (season === undefined) {
|
||||
this.logger.warn(`No season state for the apiary of colony ${colonyId}`);
|
||||
return NOTHING;
|
||||
}
|
||||
const input = await loadAutopilotInputIn(tx, colonyId, season);
|
||||
if (input === null) {
|
||||
this.logger.warn(`Colony ${colonyId} vanished during the autopilot sync`);
|
||||
return NOTHING;
|
||||
}
|
||||
const candidates = evaluateAutopilot(input, configs, today);
|
||||
const keyOf = (candidate: AutopilotCandidate): string =>
|
||||
suggestionDedupeKey(candidate.ruleKey, colonyId, candidate.anchor);
|
||||
const currentKeys = new Set(candidates.map(keyOf));
|
||||
const resolved = await this.suggestions.resolveIn(
|
||||
tx,
|
||||
open
|
||||
.filter((s) => s.dedupeKey === null || !currentKeys.has(s.dedupeKey))
|
||||
.map((s) => s.id),
|
||||
'Bedingung entfällt',
|
||||
);
|
||||
const openByKey = new Map(
|
||||
open.flatMap((s) => (s.dedupeKey === null ? [] : [[s.dedupeKey, s]])),
|
||||
);
|
||||
const ttlDays = autopilot.config.suggestionTtlDays;
|
||||
let opened = 0;
|
||||
let refreshed = 0;
|
||||
for (const candidate of candidates) {
|
||||
const existing = openByKey.get(keyOf(candidate));
|
||||
if (existing !== undefined) {
|
||||
await this.suggestions.refreshIn(tx, existing.id, {
|
||||
reasonCode: candidate.reasonCode,
|
||||
evidence: candidate.evidence,
|
||||
dueDate: candidate.dueDate,
|
||||
priority: candidate.priority,
|
||||
expiresAt:
|
||||
existing.expiresAt === null ? null : utcDay(existing.expiresAt),
|
||||
});
|
||||
refreshed += 1;
|
||||
continue;
|
||||
}
|
||||
const created = await this.suggestions.proposeIn(tx, {
|
||||
ruleKey: candidate.ruleKey,
|
||||
source: 'AUTOPILOT',
|
||||
mode: candidate.mode,
|
||||
intent: candidate.intent,
|
||||
priority: candidate.priority,
|
||||
relatedType: 'COLONY',
|
||||
relatedId: colonyId,
|
||||
colonyId,
|
||||
reasonCode: candidate.reasonCode,
|
||||
evidence: candidate.evidence,
|
||||
requiredPermission: candidate.requiredPermission,
|
||||
dueDate: candidate.dueDate,
|
||||
expiresAt: expiresOn(candidate.dueDate, today, ttlDays),
|
||||
anchor: candidate.anchor,
|
||||
notify: false,
|
||||
});
|
||||
if (created !== null) {
|
||||
opened += 1;
|
||||
}
|
||||
}
|
||||
return { opened, refreshed, resolved };
|
||||
}
|
||||
|
||||
private async readConfigs(db: DbClient): Promise<AutopilotConfigs> {
|
||||
return {
|
||||
autopilot: await readRule(db, 'SEASON_AUTOPILOT'),
|
||||
interval: await readRule(db, 'INSPECTION_INTERVAL'),
|
||||
varroa: await readRule(db, 'VARROA_THRESHOLD'),
|
||||
phenology: (await readRule(db, 'PHENOLOGY')).config,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user