Implement create custom recommendations
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "MethodFacet" (
|
||||
"id" TEXT NOT NULL,
|
||||
"section" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"group" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"matches" BOOLEAN NOT NULL,
|
||||
"weight" DOUBLE PRECISION,
|
||||
|
||||
CONSTRAINT "MethodFacet_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MethodFacet_section_slug_group_value_key" ON "MethodFacet"("section", "slug", "group", "value");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MethodFacet_section_idx" ON "MethodFacet"("section");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MethodFacet_group_value_matches_idx" ON "MethodFacet"("group", "value", "matches");
|
||||
@@ -71,3 +71,27 @@ model RuleTemplate {
|
||||
sortOrder Int @default(0)
|
||||
featured Boolean @default(false)
|
||||
}
|
||||
|
||||
/// Recommendation matrix (CR-88).
|
||||
/// JSON in `data/create/customRule/<section>.json` is canonical; this table is
|
||||
/// rebuilt from those files at `prisma db seed` time so the API can join.
|
||||
/// See `docs/guides/template-recommendation-matrix.md` §7.
|
||||
model MethodFacet {
|
||||
id String @id @default(cuid())
|
||||
/// One of "communication" | "membership" | "decisionApproaches" | "conflictManagement".
|
||||
section String
|
||||
/// Matches the `id` of an entry in `messages/en/create/customRule/<section>.json#/methods`.
|
||||
slug String
|
||||
/// One of "size" | "orgType" | "scale" | "maturity".
|
||||
group String
|
||||
/// Canonical facet value id, e.g. "workersCoop", "earlyStage".
|
||||
value String
|
||||
/// `true` iff the JSON marks this method as matching the facet (`✓` cell).
|
||||
matches Boolean
|
||||
/// Optional per-cell weight; reserved for a future weighted-rank pass (v1 ignores).
|
||||
weight Float?
|
||||
|
||||
@@unique([section, slug, group, value])
|
||||
@@index([section])
|
||||
@@index([group, value, matches])
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PrismaClient, type Prisma } from "@prisma/client";
|
||||
import { seedMethodFacets } from "./seed/methodFacets";
|
||||
|
||||
/**
|
||||
* Curated rule templates for GET /api/templates.
|
||||
@@ -387,6 +388,14 @@ async function main() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const facetSeed = await seedMethodFacets(prisma);
|
||||
// eslint-disable-next-line no-console -- seed CLI feedback
|
||||
console.log(
|
||||
`Seeded MethodFacet rows: ${Object.entries(facetSeed.rowsBySection)
|
||||
.map(([section, count]) => `${section}=${count}`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
FACET_GROUP_IDS,
|
||||
FACET_VALUE_IDS_BY_GROUP,
|
||||
SECTION_IDS,
|
||||
type SectionId,
|
||||
facetGroupsFileSchema,
|
||||
resolveFacetMatch,
|
||||
sectionFacetsSchema,
|
||||
} from "../../lib/server/validation/methodFacetsSchemas";
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
const DATA_DIR = path.join(REPO_ROOT, "data", "create", "customRule");
|
||||
|
||||
/**
|
||||
* Reads + Zod-validates `data/create/customRule/<section>.json`.
|
||||
* Throws on schema failures so the seed aborts before any DB write.
|
||||
*/
|
||||
async function loadSectionFacets(section: SectionId) {
|
||||
const file = path.join(DATA_DIR, `${section}.json`);
|
||||
const raw = await readFile(file, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const result = sectionFacetsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Invalid facet file ${file}: ${JSON.stringify(result.error.flatten(), null, 2)}`,
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function loadFacetGroups() {
|
||||
const file = path.join(DATA_DIR, "_facetGroups.json");
|
||||
const raw = await readFile(file, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const result = facetGroupsFileSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Invalid facet groups file ${file}: ${JSON.stringify(result.error.flatten(), null, 2)}`,
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
type MethodFacetRow = {
|
||||
section: string;
|
||||
slug: string;
|
||||
group: string;
|
||||
value: string;
|
||||
matches: boolean;
|
||||
weight: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattens `{ size: { oneMember: true, ... }, orgType: { ... } }` per slug
|
||||
* into one row per `(section, slug, group, value)`. Omitted groups/values
|
||||
* default to `false` so the table density is constant.
|
||||
*/
|
||||
function flattenSectionFacets(
|
||||
section: SectionId,
|
||||
facets: Awaited<ReturnType<typeof loadSectionFacets>>,
|
||||
): MethodFacetRow[] {
|
||||
const rows: MethodFacetRow[] = [];
|
||||
for (const [slug, perMethod] of Object.entries(facets)) {
|
||||
for (const group of FACET_GROUP_IDS) {
|
||||
const groupValues = perMethod[group];
|
||||
for (const value of FACET_VALUE_IDS_BY_GROUP[group]) {
|
||||
const cell = groupValues?.[value as keyof typeof groupValues];
|
||||
const { match, weight } = resolveFacetMatch(cell);
|
||||
rows.push({
|
||||
section,
|
||||
slug,
|
||||
group,
|
||||
value,
|
||||
matches: match,
|
||||
weight,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and re-seeds the `MethodFacet` table from the JSON files.
|
||||
* Per-section atomic swap so the table is never partially populated.
|
||||
*
|
||||
* `_facetGroups.json` is validated for schema correctness but not stored —
|
||||
* its only runtime purpose is the chip-id ↔ canonical-id lookup, which is
|
||||
* read directly from the JSON by the wizard ranker.
|
||||
*/
|
||||
export async function seedMethodFacets(prisma: PrismaClient): Promise<{
|
||||
rowsBySection: Record<SectionId, number>;
|
||||
}> {
|
||||
await loadFacetGroups();
|
||||
|
||||
const rowsBySection: Record<SectionId, number> = {
|
||||
communication: 0,
|
||||
membership: 0,
|
||||
decisionApproaches: 0,
|
||||
conflictManagement: 0,
|
||||
};
|
||||
|
||||
for (const section of SECTION_IDS) {
|
||||
const facets = await loadSectionFacets(section);
|
||||
const rows = flattenSectionFacets(section, facets);
|
||||
rowsBySection[section] = rows.length;
|
||||
await prisma.$transaction([
|
||||
prisma.methodFacet.deleteMany({ where: { section } }),
|
||||
prisma.methodFacet.createMany({ data: rows }),
|
||||
]);
|
||||
}
|
||||
|
||||
return { rowsBySection };
|
||||
}
|
||||
Reference in New Issue
Block a user