Template recommendation implemented

This commit is contained in:
adilallo
2026-04-29 19:24:50 -06:00
parent c4c74ecdb4
commit a4f0c4bf27
20 changed files with 899 additions and 82 deletions
+60
View File
@@ -0,0 +1,60 @@
import facetGroups from "../../data/create/customRule/_facetGroups.json";
import type { CreateFlowState } from "../../app/(app)/create/types";
const FACET_GROUPS = ["size", "orgType", "scale", "maturity"] as const;
type FacetGroupId = (typeof FACET_GROUPS)[number];
const CHIP_TO_VALUE_BY_GROUP: Record<FacetGroupId, Record<string, string>> =
(() => {
const out: Record<FacetGroupId, Record<string, string>> = {
size: {},
orgType: {},
scale: {},
maturity: {},
};
for (const group of FACET_GROUPS) {
const block = (facetGroups as Record<string, unknown>)[group];
if (block && typeof block === "object" && "values" in block) {
const values = (block as { values: Record<string, { chipId: string }> })
.values;
for (const [valueId, entry] of Object.entries(values)) {
out[group][entry.chipId] = valueId;
}
}
}
return out;
})();
const STATE_KEY_BY_GROUP: Record<FacetGroupId, keyof CreateFlowState> = {
size: "selectedCommunitySizeIds",
orgType: "selectedOrganizationTypeIds",
scale: "selectedScaleIds",
maturity: "selectedMaturityIds",
};
function readChipIds(
state: CreateFlowState,
group: FacetGroupId,
): string[] {
const value = state[STATE_KEY_BY_GROUP[group]];
return Array.isArray(value) ? (value as string[]) : [];
}
/**
* Build `facet.size=…&facet.orgType=…` query string from Create Community
* chip selections. Shared by `/api/create-flow/methods` and
* `GET /api/templates` ranking (CR-88).
*/
export function buildFacetQueryString(state: CreateFlowState): string {
const params = new URLSearchParams();
for (const group of FACET_GROUPS) {
const valuesById = CHIP_TO_VALUE_BY_GROUP[group];
for (const chipId of readChipIds(state, group)) {
const valueId = valuesById[chipId];
if (valueId) {
params.append(`facet.${group}`, valueId);
}
}
}
return params.toString();
}
+55 -1
View File
@@ -13,7 +13,21 @@ export type RuleTemplateDto = {
featured: boolean;
};
type TemplatesResponse = { templates?: RuleTemplateDto[] };
type TemplatesResponse = {
templates?: RuleTemplateDto[];
scores?: Record<string, TemplateFacetScoreDto>;
};
/** Matches `listRankedRuleTemplatesFromDb` / GET `/api/templates` with facet params. */
export type TemplateFacetScoreDto = {
score: number;
matchedFacets: string[];
};
export type RankedTemplatesFetchResult = {
templates: RuleTemplateDto[];
scores: Record<string, TemplateFacetScoreDto>;
};
export type FetchTemplatesOptions = {
signal?: AbortSignal;
@@ -57,6 +71,46 @@ export async function fetchTemplates(
}
}
/**
* Facet-ranked list + per-template scores (CR-88). Query must be non-empty
* `facet.size=…&…` from {@link buildFacetQueryString}.
*/
export async function fetchRankedTemplatesByFacets(options: {
facetQuery: string;
signal?: AbortSignal;
}): Promise<RankedTemplatesFetchResult | { error: string }> {
if (options.facetQuery.length === 0) {
return { error: "Could not load templates" };
}
try {
const res = await fetch(`/api/templates?${options.facetQuery}`, {
credentials: "include",
signal: options.signal,
});
const data = (await res.json()) as TemplatesResponse & { error?: string };
if (!res.ok) {
return {
error:
typeof data.error === "string"
? data.error
: "Could not load templates",
};
}
const templates = Array.isArray(data.templates) ? data.templates : [];
const raw = data.scores;
const scores: Record<string, TemplateFacetScoreDto> =
raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, TemplateFacetScoreDto>)
: {};
return { templates, scores };
} catch (e) {
if (isAbortError(e)) {
throw e;
}
return { error: "Could not load templates" };
}
}
export async function fetchTemplateBySlug(
slug: string,
options?: FetchTemplatesOptions,
+69
View File
@@ -169,3 +169,72 @@ export async function scoreTemplatesByFacets(args: {
return null;
}
}
/**
* Slugs that have at least one `TemplateFacet` row (Template Composition
* matrix, cols GY) — use {@link scoreTemplatesByTemplateFacets} for these;
* others use {@link scoreTemplatesByFacets}.
*/
export async function getTemplateFacetSlugSet(): Promise<Set<string> | null> {
if (!isDatabaseConfigured()) return null;
try {
const rows = await prisma.templateFacet.findMany({
where: { matches: true },
distinct: ["templateSlug"],
select: { templateSlug: true },
});
return new Set(rows.map((r) => r.templateSlug));
} catch {
return null;
}
}
/**
* Per-template score from the `TemplateFacet` table: one point per
* user-requested `(group, value)` that exists for that `templateSlug`.
* Same counting semantics as the pre-DB matrix JSON path.
*/
export async function scoreTemplatesByTemplateFacets(args: {
templateSlugs: ReadonlyArray<string>;
facets: RequestedFacets;
}): Promise<TemplateRanking[] | null> {
if (!isDatabaseConfigured()) return null;
const requested = flattenRequestedFacets(args.facets);
if (requested.length === 0) {
return args.templateSlugs.map((templateSlug) => ({
templateSlug,
score: 0,
matchedFacets: [] as string[],
}));
}
if (args.templateSlugs.length === 0) {
return [];
}
try {
const rows = await prisma.templateFacet.findMany({
where: {
matches: true,
templateSlug: { in: [...args.templateSlugs] },
OR: requested.map(({ group, value }) => ({ group, value })),
},
select: { templateSlug: true, group: true, value: true },
});
const pairSet = new Set(
rows.map((r) => `${r.templateSlug}\0${r.group}\0${r.value}` as const),
);
return args.templateSlugs.map((templateSlug) => {
const matched: string[] = [];
for (const { group, value } of requested) {
if (pairSet.has(`${templateSlug}\0${group}\0${value}`)) {
matched.push(`${group}:${value}`);
}
}
return { templateSlug, score: matched.length, matchedFacets: matched };
});
} catch {
return null;
}
}
+46 -10
View File
@@ -1,7 +1,11 @@
import type { RuleTemplateDto } from "../create/fetchTemplates";
import { prisma } from "./db";
import { isDatabaseConfigured } from "./env";
import { scoreTemplatesByFacets } from "./methodRecommendations";
import {
getTemplateFacetSlugSet,
scoreTemplatesByFacets,
scoreTemplatesByTemplateFacets,
} from "./methodRecommendations";
import { templateMethodsFromBody } from "./templateMethods";
import type { RequestedFacets } from "./validation/methodFacetsSchemas";
import { flattenRequestedFacets } from "./validation/methodFacetsSchemas";
@@ -53,8 +57,11 @@ export type RankedTemplatesResult = {
};
/**
* Curated templates ranked by how many of `facets` each composed method
* matches (§9.1). When `facets` is empty, returns the curated ordering with
* Curated templates ranked by facet match. Templates with a row in
* `TemplateFacet` (seeded from `data/templates/templateFacet.json`, Template
* Composition-2, cols GY) use that matrix; others fall back to
* composed-method × `MethodFacet`
* scoring (§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
@@ -83,22 +90,51 @@ export async function listRankedRuleTemplatesFromDb(
return { templates: [], scores: {} };
}
const slugs = templates.map((t) => t.slug);
const templateMethods = templates.map((t) => ({
templateSlug: t.slug,
methods: templateMethodsFromBody(t.body),
}));
const ranked = await scoreTemplatesByFacets({ templateMethods, facets });
if (!ranked) {
const [matrixRanked, facetSlugSet, methodRanked] = await Promise.all([
scoreTemplatesByTemplateFacets({ templateSlugs: slugs, facets }),
getTemplateFacetSlugSet(),
scoreTemplatesByFacets({ templateMethods, facets }),
]);
if (!methodRanked) {
return { templates, scores: {} };
}
const matrixBySlug =
matrixRanked == null
? new Map()
: new Map(matrixRanked.map((r) => [r.templateSlug, r] as const));
const methodBySlug = new Map(
methodRanked.map((r) => [r.templateSlug, r] as const),
);
const scores: Record<string, TemplateScore> = {};
for (const r of ranked) {
scores[r.templateSlug] = {
score: r.score,
matchedFacets: r.matchedFacets,
};
for (const t of templates) {
const useMatrix =
matrixRanked != null && (facetSlugSet?.has(t.slug) ?? false);
if (useMatrix) {
const m = matrixBySlug.get(t.slug);
if (m) {
scores[t.slug] = {
score: m.score,
matchedFacets: m.matchedFacets,
};
}
} else {
const m = methodBySlug.get(t.slug);
if (m) {
scores[t.slug] = {
score: m.score,
matchedFacets: m.matchedFacets,
};
}
}
}
// Stable sort: scoreDesc, then preserve curated index order.
@@ -0,0 +1,33 @@
import { z } from "zod";
import {
MATURITY_VALUE_IDS,
ORG_TYPE_VALUE_IDS,
SCALE_VALUE_IDS,
SIZE_VALUE_IDS,
} from "./methodFacetsSchemas";
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-template row for Template Composition-2 (spreadsheet cols GY). Each
* array lists canonical facet `value` ids that are a fit (✓) for that
* community dimension.
*/
export const templateFacetRowSchema = z
.object({
size: z.array(sizeValueIdSchema),
orgType: z.array(orgTypeValueIdSchema),
scale: z.array(scaleValueIdSchema),
maturity: z.array(maturityValueIdSchema),
})
.strict();
export const templateFacetFileSchema = z.record(
z.string().min(1),
templateFacetRowSchema,
);
export type TemplateFacetFile = z.infer<typeof templateFacetFileSchema>;
+65 -1
View File
@@ -1,4 +1,4 @@
import type { RuleTemplateDto } from "../create/fetchTemplates";
import type { RuleTemplateDto, TemplateFacetScoreDto } from "../create/fetchTemplates";
import { templateSummaryFromBody } from "../create/templateReviewMapping";
import type { GovernanceTemplateCatalogEntry } from "./governanceTemplateCatalog";
import {
@@ -47,6 +47,70 @@ export function ruleTemplateToGridEntry(template: RuleTemplateDto): TemplateGrid
};
}
/**
* Max templates that show the “RECOMMENDED” tag when facet-ranked. Within the
* **top score tier** only: we do not pad with lower-scoring templates (e.g. two
* at score 4 and three at 3 → recommend the two 4s only), but if the top tier
* exceeds this cap we still take the first `limit` in API order.
*/
export const TEMPLATE_GRID_COMPACT_RECOMMENDED_LIMIT = 5;
/**
* Among templates in **API rank order** (score desc) with `score > 0`, mark
* only those in the **maximum-score tier** (no lower tiers), at most `limit`
* (API order is the tie-break when many tie for first place).
*/
export function deriveRecommendedTemplateSlugs(
templatesInRankOrder: ReadonlyArray<{ slug: string }>,
scores: Record<string, { score?: number } | undefined>,
limit: number,
): Set<string> {
if (limit <= 0) return new Set();
const matched = templatesInRankOrder.filter(
(t) => (scores[t.slug]?.score ?? 0) > 0,
);
if (matched.length === 0) return new Set();
let maxScore = 0;
for (const t of matched) {
const s = scores[t.slug]?.score ?? 0;
if (s > maxScore) maxScore = s;
}
const topTier = matched.filter(
(t) => (scores[t.slug]?.score ?? 0) === maxScore,
);
return new Set(topTier.slice(0, limit).map((t) => t.slug));
}
export type GridEntriesWithFacetScoresOptions = {
/** Default {@link TEMPLATE_GRID_COMPACT_RECOMMENDED_LIMIT}. */
compactRecommendedLimit?: number;
};
/**
* After `GET /api/templates?facet.*` with `scores`, mark `recommended` only
* for the top facet matches (see {@link deriveRecommendedTemplateSlugs}).
*/
export function gridEntriesWithFacetScores(
templates: RuleTemplateDto[],
scores: Record<string, TemplateFacetScoreDto>,
options?: GridEntriesWithFacetScoresOptions,
): TemplateGridCardEntry[] {
const cap =
options?.compactRecommendedLimit ?? TEMPLATE_GRID_COMPACT_RECOMMENDED_LIMIT;
const recommendedSlugs = deriveRecommendedTemplateSlugs(
templates,
scores,
cap,
);
return templates.map((t) => {
const base = ruleTemplateToGridEntry(t);
return {
...base,
recommended: recommendedSlugs.has(t.slug),
};
});
}
const bySlug = (templates: RuleTemplateDto[]) =>
new Map(templates.map((t) => [t.slug, t] as const));