Sprint 2 engine: TCO, investment, ramp-aware cash flow, scenarios, planner overrides, shelfware, KB curation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Assessment, AssessmentSummary, Catalog, KnowledgeBase, Survey } from "./types";
|
||||
import type { Assessment, AssessmentSummary, Branding, Catalog, KnowledgeBase, Snapshot, SnapshotMeta, Survey } from "./types";
|
||||
import { DEFAULT_WEIGHTS, rollup } from "./scoring";
|
||||
import { evaluate } from "./finance";
|
||||
import { forgetAssessment, learnFromAssessment } from "./kb";
|
||||
import { isSurveyToken } from "./survey";
|
||||
|
||||
@@ -11,6 +12,8 @@ const CATALOG_PATH = path.join(DATA_DIR, "catalog.json");
|
||||
const KB_PATH = path.join(DATA_DIR, "knowledge-base.json");
|
||||
const ASSESSMENT_DIR = path.join(DATA_DIR, "assessments");
|
||||
const SURVEY_DIR = path.join(DATA_DIR, "surveys");
|
||||
const SNAPSHOT_DIR = path.join(DATA_DIR, "snapshots");
|
||||
const SETTINGS_PATH = path.join(DATA_DIR, "settings.json");
|
||||
|
||||
async function readJson<T>(file: string): Promise<T> {
|
||||
return JSON.parse(await fs.readFile(file, "utf8")) as T;
|
||||
@@ -73,6 +76,7 @@ export async function listAssessments(): Promise<AssessmentSummary[]> {
|
||||
preparedBy: a.preparedBy,
|
||||
createdAt: a.createdAt,
|
||||
updatedAt: a.updatedAt,
|
||||
status: a.status ?? "open",
|
||||
candidateCount: a.candidates.length,
|
||||
annualSpend: r.addressableSpend,
|
||||
annualSavings: r.totalAnnualSavings,
|
||||
@@ -114,6 +118,7 @@ export async function deleteAssessment(id: string) {
|
||||
await fs.rm(assessmentPath(id), { force: true });
|
||||
|
||||
for (const sv of await listSurveys(id)) await fs.rm(surveyPath(sv.id), { force: true });
|
||||
await fs.rm(path.join(SNAPSHOT_DIR, id), { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
const kb = await getKnowledgeBase();
|
||||
@@ -236,3 +241,71 @@ export async function mutateSurvey(id: string, fn: (s: Survey) => void | string)
|
||||
export async function deleteSurvey(id: string) {
|
||||
await fs.rm(surveyPath(id), { force: true });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ branding */
|
||||
|
||||
export const DEFAULT_BRANDING: Branding = { companyName: "", accent: "#005C8A", logo: "" };
|
||||
|
||||
export async function getBranding(): Promise<Branding> {
|
||||
try {
|
||||
return { ...DEFAULT_BRANDING, ...(await readJson<Partial<Branding>>(SETTINGS_PATH)) };
|
||||
} catch {
|
||||
return { ...DEFAULT_BRANDING };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBranding(b: Branding): Promise<Branding> {
|
||||
await writeJson(SETTINGS_PATH, b);
|
||||
return b;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ snapshots */
|
||||
|
||||
const snapshotDir = (assessmentId: string) => {
|
||||
if (!isSafeId(assessmentId)) throw new Error(`Invalid assessment id: ${assessmentId}`);
|
||||
return path.join(SNAPSHOT_DIR, assessmentId);
|
||||
};
|
||||
|
||||
export async function listSnapshots(assessmentId: string): Promise<SnapshotMeta[]> {
|
||||
const dir = snapshotDir(assessmentId);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const out: SnapshotMeta[] = [];
|
||||
for (const f of await fs.readdir(dir)) {
|
||||
if (!f.endsWith(".json")) continue;
|
||||
try {
|
||||
const { assessment: _a, ...meta } = await readJson<Snapshot>(path.join(dir, f));
|
||||
out.push(meta);
|
||||
} catch {
|
||||
// Skip a damaged snapshot rather than hiding the rest.
|
||||
}
|
||||
}
|
||||
return out.sort((x, y) => y.createdAt.localeCompare(x.createdAt));
|
||||
}
|
||||
|
||||
export async function getSnapshot(assessmentId: string, id: string): Promise<Snapshot | null> {
|
||||
if (!/^[a-z0-9-]{1,40}$/.test(id)) return null;
|
||||
try {
|
||||
return await readJson<Snapshot>(path.join(snapshotDir(assessmentId), `${id}.json`));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Freezes the assessment as it stands, with its headline, under a label. */
|
||||
export async function createSnapshot(a: Assessment, label: string, trigger: SnapshotMeta["trigger"]): Promise<SnapshotMeta> {
|
||||
const catalog = await getCatalog();
|
||||
const e = evaluate(a, catalog);
|
||||
const createdAt = new Date().toISOString();
|
||||
const snap: Snapshot = {
|
||||
id: `${createdAt.replace(/[^0-9]/g, "").slice(0, 14)}-${crypto.randomBytes(3).toString("hex")}`,
|
||||
assessmentId: a.id,
|
||||
label: label.trim().slice(0, 120) || "Snapshot",
|
||||
trigger,
|
||||
createdAt,
|
||||
headline: { netAnnual: e.cash.runRateNet, horizonNet: e.cash.horizonNet, tools: e.r.toolsRetired },
|
||||
assessment: structuredClone(a),
|
||||
};
|
||||
await writeJson(path.join(snapshotDir(a.id), `${snap.id}.json`), snap);
|
||||
const { assessment: _a, ...meta } = snap;
|
||||
return meta;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user