vpp-ai-platform/packages/runtime/src/workflows/day-ahead-situation.ts

138 lines
6.4 KiB
TypeScript
Raw Normal View History

M3: Mastra runtime, safety chain, two agents, Case Desk v1 - packages/domain: safety-chain objects (ValidationResult, SimulationResult, EnvelopeMatch, StaleDenial, ExecutionReceipt, LineageRef/BidProposalDraft, RouterDecision, BidExportFile) + fixtures on both sides. - packages/services: proposal digest; PolicyEngine + hubei-spot-bidding pack (digest-valid, bid-format, price-limits, quantity-non-negative, ledger-consistency, lineage-integrity, originator-permission — each with pass/fail tests); EnvelopeService; AuthorityService (fresh check, permits, revoke, gateway validate); FileExportGateway (idempotent receipts); Memory/File EventBus; LineageRecorder + P2 assembler; RevenueScenario simulator; CaseDeskService; FsRepository; skill HTTP client moved here. - packages/runtime: createRuntime (LibSQL storage, per-runtime workflow factories), proposal-lifecycle (rule check → simulation → envelope gate with suspend/resume → fresh check + permit → release), day-ahead-situation, day-ahead-bid, TriggerService (scheduled/event/manual), LlmPort (Mastra/Scripted/Null), Case Desk HTTP API, dev entry point. - Tests: all eight docs/01 invariants, docs/07 06:00→08:30 end to end with LLM down, restart survival of a suspended approval, permit expiry and revocation, replay of a released proposal, trigger scheduling. 141 TS + 60 Python tests. - Known gaps: ledger not yet persisted (replayed on restart); STALE ends the run instead of looping to rule check; synthetic data stands in for historical replay. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoYoGYzHkFyv3ALenkRPhA
2026-09-02 06:55:55 -04:00
import { createStep, createWorkflow } from '@mastra/core/workflows'
import { z } from 'zod'
import type { ForecastBundle, ForecastKind, SituationReport, ToolCallRef } from '@vpp/domain'
import { ForecastBundle as ForecastBundleSchema, MarketDate, SituationReport as SituationReportSchema, ToolCallRef as ToolCallRefSchema } from '@vpp/domain'
import type { RuntimeContext } from '../context.js'
import { LlmUnavailable } from '../llm.js'
import { registerSkill } from '../skills.js'
/**
* D-1 06:00 日前态势 (docs/07): periodic trigger → forecasts (tools) →
* analysis agent writes findings around the numbers → SituationReport on
* the bus. Runs with the LLM down (I6): findings fall back to a template.
*/
export const SituationInput = z.object({ market_date: MarketDate })
export const SituationOutput = z.object({
report: SituationReportSchema,
report_ref: z.string(),
forecasts: z.object({ LOAD: ForecastBundleSchema, PV: ForecastBundleSchema, PRICE: ForecastBundleSchema }),
tool_calls: z.array(ToolCallRefSchema),
llm_backend: z.string(),
llm_used: z.boolean(),
})
const SERIES: Record<ForecastKind, { series: string; unit: 'mw' | 'yuan_per_mwh' }> = {
LOAD: { series: 'load:aggregate', unit: 'mw' },
PV: { series: 'pv:aggregate', unit: 'mw' },
PRICE: { series: 'price:da', unit: 'yuan_per_mwh' },
}
const Findings = z.object({
findings: z.array(z.object({ kind: z.enum(['TREND', 'ANOMALY', 'RISK', 'ATTRIBUTION']), summary: z.string().min(1) })).min(1).max(6),
})
export async function runForecasts(ctx: RuntimeContext, marketDate: string): Promise<{ forecasts: Record<ForecastKind, ForecastBundle>; calls: ToolCallRef[] }> {
const d = new Date(`${marketDate}T00:00:00Z`)
const from = new Date(d.getTime() - ctx.config.forecastWindowDays * 86_400_000).toISOString().slice(0, 10)
const to = new Date(d.getTime() - 86_400_000).toISOString().slice(0, 10)
const forecasts = {} as Record<ForecastKind, ForecastBundle>
const calls: ToolCallRef[] = []
for (const kind of ['LOAD', 'PV', 'PRICE'] as const) {
const history = ctx.timeseries.range(SERIES[kind].series, from, to).map((r) => r.curve)
if (history.length === 0) throw new Error(`no history for ${SERIES[kind].series} in [${from}, ${to}] — ingest data first`)
const featuresRef = ctx.snapshots.put({ series: SERIES[kind].series, from, to, revisions: history.length })
ctx.lineage.addDataRef(featuresRef)
const skill = registerSkill(
{ lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') },
{ id: `${kind.toLowerCase()}-forecast`, version: '1.0.0', invoke: (req: Parameters<typeof ctx.skills.forecast>[1]) => ctx.skills.forecast(kind, req) },
)
const { output, ref } = await skill.call({ kind, market_date: marketDate, unit: SERIES[kind].unit, history, exogenous: {}, features_snapshot_ref: featuresRef })
forecasts[kind] = output
calls.push(ref)
}
return { forecasts, calls }
}
/** Risk level is deterministic (it gates the abnormal-day protocol, docs/13 §1); the LLM only narrates. */
export function riskLevel(ctx: RuntimeContext, price: ForecastBundle): SituationReport['risk_level'] {
const ratio = Math.max(...price.quantiles.p90.values.map((v, i) => Number(v) / Math.max(Number(price.quantiles.p50.values[i]), 1e-9)))
const extreme = Number(ctx.config.extremeDayPriceRatio)
if (ratio >= extreme) return 'EXTREME'
if (ratio >= 1 + (extreme - 1) * 0.66) return 'HIGH'
if (ratio >= 1 + (extreme - 1) * 0.33) return 'MEDIUM'
return 'LOW'
}
export function createDayAheadSituation(ctx: RuntimeContext) {
const forecastStep = createStep({
id: 'forecast',
inputSchema: SituationInput,
outputSchema: z.object({ market_date: MarketDate, forecasts: SituationOutput.shape.forecasts, tool_calls: z.array(ToolCallRefSchema) }),
execute: async ({ inputData }) => {
return ctx.lineage.run(async () => {
const { forecasts, calls } = await runForecasts(ctx, inputData.market_date)
return { market_date: inputData.market_date, forecasts, tool_calls: calls }
})
},
})
const analyzeStep = createStep({
id: 'analyze',
inputSchema: forecastStep.outputSchema,
outputSchema: SituationOutput,
execute: async ({ inputData }) => {
const { forecasts, tool_calls } = inputData
const refs = tool_calls.map((t) => t.outputs_ref)
const level = riskLevel(ctx, forecasts.PRICE)
const peakPrice = Math.max(...forecasts.PRICE.quantiles.p50.values.map(Number))
const peakLoad = Math.max(...forecasts.LOAD.quantiles.p50.values.map(Number))
let findings: SituationReport['findings']
let llmUsed = false
const fallback: SituationReport['findings'] = [
{ kind: 'TREND', summary: `P50 peak load ${peakLoad.toFixed(1)} MW; P50 peak price ${peakPrice.toFixed(2)} yuan/MWh (template — LLM unavailable)`, refs },
{ kind: 'RISK', summary: `risk level ${level} from price band width`, refs },
]
try {
const prompt =
`Market date ${inputData.market_date}. Tool outputs (snapshot refs): ` +
tool_calls.map((t) => `${t.tool}→${t.outputs_ref.slice(0, 12)}`).join(', ') +
`. Deterministic risk level: ${level}. P50 peak load ${peakLoad.toFixed(1)} MW, P50 peak price ${peakPrice.toFixed(2)} yuan/MWh. ` +
'Write 2–4 findings (TREND/ANOMALY/RISK) that only restate these numbers.'
const out = await ctx.llm.structured('analysis-agent', prompt, Findings)
findings = out.findings.map((f) => ({ ...f, refs }))
llmUsed = true
} catch (e) {
if (!(e instanceof LlmUnavailable)) throw e
findings = fallback
}
const report: SituationReport = {
id: ctx.newId('sit'),
market_date: inputData.market_date,
risk_level: level,
findings,
forecast_refs: [forecasts.LOAD.id, forecasts.PV.id, forecasts.PRICE.id],
generated_at: ctx.clock(),
}
const report_ref = ctx.snapshots.put(report)
ctx.events.append({
event_type: 'SituationPublished',
payload: { report, report_ref, forecast_tool_calls: tool_calls },
correlation_id: `situation-${inputData.market_date}`,
})
return { report, report_ref, forecasts, tool_calls, llm_backend: ctx.llm.backend, llm_used: llmUsed }
},
})
return createWorkflow({
id: 'day-ahead-situation',
inputSchema: SituationInput,
outputSchema: SituationOutput,
})
.then(forecastStep)
.then(analyzeStep)
.commit()
}