Implement create custom recommendations

This commit is contained in:
adilallo
2026-04-20 12:41:10 -06:00
parent e9dab04b34
commit 45bbbb8a35
75 changed files with 6403 additions and 1452 deletions
+17
View File
@@ -118,6 +118,23 @@ async function errorBodyMessage(res: Response): Promise<string> {
return "Save failed";
}
/**
* Wipe the signed-in user's saved draft. Fire-and-forget: any non-2xx (including
* the sync-flag-off `503` and the unauthenticated `401`) is swallowed because
* callers only invoke this on already-published / explicitly-discarded flows
* where a leftover server draft is acceptable.
*/
export async function deleteServerDraft(): Promise<void> {
try {
await fetch("/api/drafts/me", {
method: "DELETE",
credentials: "include",
});
} catch {
/* ignore — server draft cleanup is best-effort */
}
}
export async function saveDraftToServer(
state: CreateFlowState,
): Promise<SaveDraftResult> {
+171
View File
@@ -0,0 +1,171 @@
import { prisma } from "./db";
import { isDatabaseConfigured } from "./env";
import {
type RequestedFacets,
type SectionId,
flattenRequestedFacets,
} from "./validation/methodFacetsSchemas";
/**
* Per-method ranking output (CR-88, §9.2).
*
* `score` = number of requested `(group, value)` pairs that this method's
* `MethodFacet { matches: true }` rows cover. `matchedFacets` is the
* deduped list of `"<group>:<value>"` keys that contributed — useful for
* an eventual "Why this method?" UI tooltip.
*/
export type MethodRanking = {
slug: string;
matches: { score: number; matchedFacets: string[] };
};
export type ListMethodRecommendationsResult = {
/** Ordered slug list, ranked highest-`score`-first; absent slugs scored `0`. */
rankedSlugs: string[];
/** Per-slug match data; missing entries should be treated as `score = 0`. */
matchesBySlug: Record<string, MethodRanking["matches"]>;
};
/**
* Returns the per-method match scores for `section`, given `facets`.
* Returns `null` so callers can fall back to messages-file order when DB
* is unavailable or the query fails.
*
* Notes:
* - Empty facets ⇒ `rankedSlugs: []`, `matchesBySlug: {}` (caller falls back
* to authoring order).
* - Sort is `score` desc only — re-stabilising into authoring order is the
* caller's job (the wizard already iterates the on-disk `methods[]` array).
*/
export async function listMethodRecommendations(args: {
section: SectionId;
facets: RequestedFacets;
}): Promise<ListMethodRecommendationsResult | null> {
if (!isDatabaseConfigured()) return null;
const requested = flattenRequestedFacets(args.facets);
if (requested.length === 0) {
return { rankedSlugs: [], matchesBySlug: {} };
}
try {
const rows = await prisma.methodFacet.findMany({
where: {
section: args.section,
matches: true,
OR: requested.map(({ group, value }) => ({ group, value })),
},
select: { slug: true, group: true, value: true },
});
const matchesBySlug: Record<string, MethodRanking["matches"]> = {};
for (const row of rows) {
const key = `${row.group}:${row.value}`;
const entry =
matchesBySlug[row.slug] ??
(matchesBySlug[row.slug] = { score: 0, matchedFacets: [] });
if (!entry.matchedFacets.includes(key)) {
entry.matchedFacets.push(key);
entry.score += 1;
}
}
const rankedSlugs = Object.entries(matchesBySlug)
.sort(([, a], [, b]) => b.score - a.score)
.map(([slug]) => slug);
return { rankedSlugs, matchesBySlug };
} catch {
return null;
}
}
/**
* Score every template by joining its composed `(section, slug)` pairs
* against `MethodFacet`. Returns a per-slug score map keyed by template slug
* and a per-template breakdown of which method-level matches contributed.
*
* `templateMethods` enumerates the method slugs each template composes;
* derived from `RuleTemplate.body` by the caller.
*/
export type TemplateRanking = {
templateSlug: string;
score: number;
matchedFacets: string[];
};
export async function scoreTemplatesByFacets(args: {
templateMethods: ReadonlyArray<{
templateSlug: string;
methods: ReadonlyArray<{ section: SectionId; slug: string }>;
}>;
facets: RequestedFacets;
}): Promise<TemplateRanking[] | null> {
if (!isDatabaseConfigured()) return null;
const requested = flattenRequestedFacets(args.facets);
if (requested.length === 0) {
return args.templateMethods.map((t) => ({
templateSlug: t.templateSlug,
score: 0,
matchedFacets: [],
}));
}
// Collect distinct (section, slug) pairs across all templates so we make
// exactly one query.
const sectionSlugSet = new Set<string>();
for (const t of args.templateMethods) {
for (const m of t.methods) {
sectionSlugSet.add(`${m.section}:${m.slug}`);
}
}
const sectionSlugPairs = Array.from(sectionSlugSet).map((key) => {
const [section, slug] = key.split(":");
return { section, slug };
});
if (sectionSlugPairs.length === 0) {
return args.templateMethods.map((t) => ({
templateSlug: t.templateSlug,
score: 0,
matchedFacets: [],
}));
}
try {
const rows = await prisma.methodFacet.findMany({
where: {
matches: true,
AND: [
{ OR: sectionSlugPairs },
{ OR: requested.map(({ group, value }) => ({ group, value })) },
],
},
select: { section: true, slug: true, group: true, value: true },
});
// Build a lookup: (section,slug) -> Set of "<group>:<value>" matches.
const matchesByMethod = new Map<string, Set<string>>();
for (const row of rows) {
const k = `${row.section}:${row.slug}`;
const set = matchesByMethod.get(k) ?? new Set<string>();
set.add(`${row.group}:${row.value}`);
matchesByMethod.set(k, set);
}
return args.templateMethods.map((t) => {
let score = 0;
const matched: string[] = [];
for (const m of t.methods) {
const set = matchesByMethod.get(`${m.section}:${m.slug}`);
if (!set) continue;
for (const key of set) {
score += 1;
matched.push(`${m.section}:${m.slug}:${key}`);
}
}
return { templateSlug: t.templateSlug, score, matchedFacets: matched };
});
} catch {
return null;
}
}
+95 -11
View File
@@ -1,6 +1,27 @@
import type { RuleTemplateDto } from "../create/fetchTemplates";
import { prisma } from "./db";
import { isDatabaseConfigured } from "./env";
import { scoreTemplatesByFacets } from "./methodRecommendations";
import { templateMethodsFromBody } from "./templateMethods";
import type { RequestedFacets } from "./validation/methodFacetsSchemas";
import { flattenRequestedFacets } from "./validation/methodFacetsSchemas";
const TEMPLATE_SELECT = {
id: true,
slug: true,
title: true,
category: true,
description: true,
body: true,
sortOrder: true,
featured: true,
} as const;
const CURATED_ORDER_BY = [
{ featured: "desc" as const },
{ sortOrder: "asc" as const },
{ title: "asc" as const },
];
/**
* Curated templates for public list UIs (same query as GET /api/templates).
@@ -12,19 +33,82 @@ export async function listRuleTemplatesFromDb(): Promise<RuleTemplateDto[]> {
}
try {
return await prisma.ruleTemplate.findMany({
orderBy: [{ featured: "desc" }, { sortOrder: "asc" }, { title: "asc" }],
select: {
id: true,
slug: true,
title: true,
category: true,
description: true,
body: true,
sortOrder: true,
featured: true,
},
orderBy: CURATED_ORDER_BY,
select: TEMPLATE_SELECT,
});
} catch {
return [];
}
}
export type TemplateScore = {
score: number;
matchedFacets: string[];
};
export type RankedTemplatesResult = {
templates: RuleTemplateDto[];
/** Per-template-slug score map; absent slugs scored `0`. */
scores: Record<string, TemplateScore>;
};
/**
* Curated templates ranked by how many of `facets` each composed method
* matches (§9.1). When `facets` is empty, returns the curated ordering with
* an empty `scores` map (caller can omit it from the API response).
*
* Ties (and zero-score templates) fall back to the curated
* `(featured, sortOrder, title)` order so no-facets and zero-match cases
* produce identical output to `listRuleTemplatesFromDb`.
*/
export async function listRankedRuleTemplatesFromDb(
facets: RequestedFacets,
): Promise<RankedTemplatesResult> {
if (!isDatabaseConfigured()) {
return { templates: [], scores: {} };
}
const requested = flattenRequestedFacets(facets);
if (requested.length === 0) {
const templates = await listRuleTemplatesFromDb();
return { templates, scores: {} };
}
let templates: RuleTemplateDto[];
try {
templates = await prisma.ruleTemplate.findMany({
orderBy: CURATED_ORDER_BY,
select: TEMPLATE_SELECT,
});
} catch {
return { templates: [], scores: {} };
}
const templateMethods = templates.map((t) => ({
templateSlug: t.slug,
methods: templateMethodsFromBody(t.body),
}));
const ranked = await scoreTemplatesByFacets({ templateMethods, facets });
if (!ranked) {
return { templates, scores: {} };
}
const scores: Record<string, TemplateScore> = {};
for (const r of ranked) {
scores[r.templateSlug] = {
score: r.score,
matchedFacets: r.matchedFacets,
};
}
// Stable sort: scoreDesc, then preserve curated index order.
const indexBySlug = new Map(templates.map((t, i) => [t.slug, i]));
const sorted = [...templates].sort((a, b) => {
const sa = scores[a.slug]?.score ?? 0;
const sb = scores[b.slug]?.score ?? 0;
if (sa !== sb) return sb - sa;
return (indexBySlug.get(a.slug) ?? 0) - (indexBySlug.get(b.slug) ?? 0);
});
return { templates: sorted, scores };
}
+76
View File
@@ -0,0 +1,76 @@
import type { SectionId } from "./validation/methodFacetsSchemas";
/**
* Extracts the `(section, slug)` pairs that a curated `RuleTemplate.body`
* composes. Used by `/api/templates` to score templates by facet match
* (CR-88, §9.1).
*
* `body.sections[].categoryName` is mapped to the canonical recommendation
* `section` id; `entries[].title` is slugified the same way the messages
* ingest produced `methods[].id` (kebab-case, ASCII-folded, lowercase) so
* the slugs line up with `MethodFacet.slug`.
*
* "Values" entries are intentionally skipped — values are out of scope for
* the facet matrix (§11).
*/
const CATEGORY_NAME_TO_SECTION: Record<string, SectionId> = {
Communication: "communication",
Membership: "membership",
"Decision-making": "decisionApproaches",
"Conflict management": "conflictManagement",
};
export function methodSlugFromTitle(title: string): string {
// Match the slugify rules of the one-time messages ingest: NFKD-normalize,
// strip diacritics, drop apostrophes/brackets, collapse non-alphanumerics
// to single hyphens, trim leading/trailing hyphens.
const folded = title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
const stripped = folded
.toLowerCase()
.replace(/['`()\[\]]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return stripped;
}
type RuleTemplateBodySection = {
categoryName?: unknown;
entries?: unknown;
};
type RuleTemplateBody = { sections?: unknown };
export type TemplateMethodRef = { section: SectionId; slug: string };
export function templateMethodsFromBody(
body: unknown,
): TemplateMethodRef[] {
if (!body || typeof body !== "object") return [];
const sections = (body as RuleTemplateBody).sections;
if (!Array.isArray(sections)) return [];
const out: TemplateMethodRef[] = [];
const seen = new Set<string>();
for (const raw of sections) {
if (!raw || typeof raw !== "object") continue;
const sec = raw as RuleTemplateBodySection;
const categoryName =
typeof sec.categoryName === "string" ? sec.categoryName : null;
if (!categoryName) continue;
const section = CATEGORY_NAME_TO_SECTION[categoryName];
if (!section) continue; // Values, or any future category we don't score.
if (!Array.isArray(sec.entries)) continue;
for (const entry of sec.entries) {
if (!entry || typeof entry !== "object") continue;
const title = (entry as { title?: unknown }).title;
if (typeof title !== "string" || title.trim() === "") continue;
const slug = methodSlugFromTitle(title);
if (!slug) continue;
const key = `${section}:${slug}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ section, slug });
}
}
return out;
}
@@ -0,0 +1,273 @@
import { z } from "zod";
/**
* Zod schemas for the recommendation matrix (CR-88).
*
* Source of truth at runtime is `data/create/customRule/<section>.json` plus
* `data/create/customRule/_facetGroups.json`. These schemas validate those
* files at seed time and in the parity test (`tests/unit/methodFacets.test.ts`).
* They are also reused by the API request shapes for `/api/templates` and
* `/api/create-flow/methods` so a single set of canonical ids drives both
* the on-disk JSON and the query-string contract.
*
* See `docs/guides/template-recommendation-matrix.md` §2 (canonical 19
* facet values), §6 (JSON shape), §7 (`MethodFacet` schema), §9 (API).
*/
export const SECTION_IDS = [
"communication",
"membership",
"decisionApproaches",
"conflictManagement",
] as const;
export type SectionId = (typeof SECTION_IDS)[number];
export const sectionIdSchema = z.enum(SECTION_IDS);
export const FACET_GROUP_IDS = [
"size",
"orgType",
"scale",
"maturity",
] as const;
export type FacetGroupId = (typeof FACET_GROUP_IDS)[number];
export const facetGroupIdSchema = z.enum(FACET_GROUP_IDS);
export const SIZE_VALUE_IDS = [
"oneMember",
"twoToFive",
"sixToTwelve",
"thirteenToOneHundred",
"oneHundredToOneHundredK",
] as const;
export const ORG_TYPE_VALUE_IDS = [
"dao",
"forProfit",
"nonprofit",
"openSource",
"mutualAid",
"workersCoop",
] as const;
export const SCALE_VALUE_IDS = [
"global",
"national",
"regional",
"local",
] as const;
export const MATURITY_VALUE_IDS = [
"earlyStage",
"growthStage",
"established",
"enterprise",
] as const;
export type SizeValueId = (typeof SIZE_VALUE_IDS)[number];
export type OrgTypeValueId = (typeof ORG_TYPE_VALUE_IDS)[number];
export type ScaleValueId = (typeof SCALE_VALUE_IDS)[number];
export type MaturityValueId = (typeof MATURITY_VALUE_IDS)[number];
export const FACET_VALUE_IDS_BY_GROUP: Record<
FacetGroupId,
readonly string[]
> = {
size: SIZE_VALUE_IDS,
orgType: ORG_TYPE_VALUE_IDS,
scale: SCALE_VALUE_IDS,
maturity: MATURITY_VALUE_IDS,
};
const sizeValueIdSchema = z.enum(SIZE_VALUE_IDS);
const orgTypeValueIdSchema = z.enum(ORG_TYPE_VALUE_IDS);
const scaleValueIdSchema = z.enum(SCALE_VALUE_IDS);
const maturityValueIdSchema = z.enum(MATURITY_VALUE_IDS);
/**
* Per-cell shape: bare boolean, or an object with optional `weight`.
* The object form is reserved for a future weighted-rank pass (v1 ignores
* `weight`; see §9.1 "Notes").
*/
const facetMatchSchema = z.union([
z.boolean(),
z
.object({
match: z.boolean(),
weight: z.number().finite().optional(),
})
.strict(),
]);
export type FacetMatch = z.infer<typeof facetMatchSchema>;
/**
* Builds a Zod object schema for a facet group where every canonical value id
* is optional. Omitted keys default to `false` (see §6 "Bulk shorthand").
*/
function partialGroupSchema<Values extends readonly [string, ...string[]]>(
values: Values,
) {
const enumSchema = z.enum(values);
return z.record(enumSchema, facetMatchSchema);
}
const sizeFacetsSchema = partialGroupSchema(SIZE_VALUE_IDS);
const orgTypeFacetsSchema = partialGroupSchema(ORG_TYPE_VALUE_IDS);
const scaleFacetsSchema = partialGroupSchema(SCALE_VALUE_IDS);
const maturityFacetsSchema = partialGroupSchema(MATURITY_VALUE_IDS);
/**
* Per-method facet entry. All four groups are optional; an omitted group
* defaults to "all false" at seed time (see `flattenSectionFacets` in
* `prisma/seed/methodFacets.ts`).
*/
export const methodFacetsSchema = z
.object({
size: sizeFacetsSchema.optional(),
orgType: orgTypeFacetsSchema.optional(),
scale: scaleFacetsSchema.optional(),
maturity: maturityFacetsSchema.optional(),
})
.strict();
export type MethodFacets = z.infer<typeof methodFacetsSchema>;
/**
* Whole-section file shape: object keyed by method slug
* (`messages/en/create/customRule/<section>.json#/methods[].id`).
*/
export const sectionFacetsSchema = z.record(z.string(), methodFacetsSchema);
export type SectionFacetsFile = z.infer<typeof sectionFacetsSchema>;
/**
* `_facetGroups.json` shape: positional chip id ↔ canonical facet value id
* mapping (see §2). Validated alongside the section files so chip drift in a
* messages file fails CI loudly.
*/
const facetGroupValueEntrySchema = z
.object({
chipId: z.string().min(1),
})
.strict();
const facetGroupBlockSchema = z
.object({
source: z.string().min(1),
values: z.record(z.string(), facetGroupValueEntrySchema),
})
.strict();
export const facetGroupsFileSchema = z
.object({
size: facetGroupBlockSchema,
orgType: facetGroupBlockSchema,
scale: facetGroupBlockSchema,
maturity: facetGroupBlockSchema,
})
.strict()
.superRefine((data, ctx) => {
for (const group of FACET_GROUP_IDS) {
const expected = new Set(FACET_VALUE_IDS_BY_GROUP[group]);
const actual = new Set(Object.keys(data[group].values));
for (const v of expected) {
if (!actual.has(v)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [group, "values"],
message: `Missing canonical value ${v}`,
});
}
}
for (const v of actual) {
if (!expected.has(v)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [group, "values", v],
message: `Unknown facet value ${v} for group ${group}`,
});
}
}
}
});
export type FacetGroupsFile = z.infer<typeof facetGroupsFileSchema>;
/**
* Resolve a `FacetMatch` value to its boolean (the shape can be either a bare
* boolean or `{ match, weight? }`). Used by both the seed flattener and the
* scoring helpers.
*/
export function resolveFacetMatch(
v: FacetMatch | undefined,
): { match: boolean; weight: number | null } {
if (v === undefined) return { match: false, weight: null };
if (typeof v === "boolean") return { match: v, weight: null };
return { match: v.match, weight: v.weight ?? null };
}
// ---------------------------------------------------------------------------
// API request shapes (used by /api/templates and /api/create-flow/methods)
// ---------------------------------------------------------------------------
/**
* Generic facet-id-array shape, scoped per group. URLSearchParams produces
* either a single string or repeated values; both flatten to `string[]`.
*/
export const requestedFacetsSchema = z
.object({
size: z.array(sizeValueIdSchema).max(SIZE_VALUE_IDS.length).optional(),
orgType: z
.array(orgTypeValueIdSchema)
.max(ORG_TYPE_VALUE_IDS.length)
.optional(),
scale: z.array(scaleValueIdSchema).max(SCALE_VALUE_IDS.length).optional(),
maturity: z
.array(maturityValueIdSchema)
.max(MATURITY_VALUE_IDS.length)
.optional(),
})
.strict();
export type RequestedFacets = z.infer<typeof requestedFacetsSchema>;
/** Flattened `(group, value)` tuple for scoring. */
export type RequestedFacetPair = { group: FacetGroupId; value: string };
export function flattenRequestedFacets(
facets: RequestedFacets,
): RequestedFacetPair[] {
const out: RequestedFacetPair[] = [];
for (const group of FACET_GROUP_IDS) {
const values = facets[group];
if (!values) continue;
for (const value of values) {
out.push({ group, value });
}
}
return out;
}
/**
* Parse `?facet.size=oneMember&facet.orgType=dao&facet.orgType=nonprofit` into
* a typed `RequestedFacets`. Unknown groups and unknown values are dropped
* silently — the API "never errors on partial facets" (§9.3).
*/
export function parseRequestedFacetsFromSearchParams(
search: URLSearchParams,
): RequestedFacets {
const collected: Record<FacetGroupId, Set<string>> = {
size: new Set(),
orgType: new Set(),
scale: new Set(),
maturity: new Set(),
};
for (const [rawKey, rawVal] of search.entries()) {
if (!rawKey.startsWith("facet.")) continue;
const group = rawKey.slice("facet.".length) as FacetGroupId;
if (!FACET_GROUP_IDS.includes(group)) continue;
const allowed = new Set(FACET_VALUE_IDS_BY_GROUP[group]);
if (allowed.has(rawVal)) {
collected[group].add(rawVal);
}
}
const out: RequestedFacets = {};
for (const group of FACET_GROUP_IDS) {
if (collected[group].size > 0) {
out[group] = Array.from(collected[group]) as never;
}
}
return out;
}