Files
community-rule/lib/server/ruleTemplates.ts
T
2026-04-29 19:24:50 -06:00

151 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { RuleTemplateDto } from "../create/fetchTemplates";
import { prisma } from "./db";
import { isDatabaseConfigured } from "./env";
import {
getTemplateFacetSlugSet,
scoreTemplatesByFacets,
scoreTemplatesByTemplateFacets,
} 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).
* Returns [] when the database is not configured or on query failure.
*/
export async function listRuleTemplatesFromDb(): Promise<RuleTemplateDto[]> {
if (!isDatabaseConfigured()) {
return [];
}
try {
return await prisma.ruleTemplate.findMany({
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 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
* `(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 slugs = templates.map((t) => t.slug);
const templateMethods = templates.map((t) => ({
templateSlug: t.slug,
methods: templateMethodsFromBody(t.body),
}));
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 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.
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 };
}