Template recommendation implemented
This commit is contained in:
@@ -169,3 +169,72 @@ export async function scoreTemplatesByFacets(args: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slugs that have at least one `TemplateFacet` row (Template Composition
|
||||
* matrix, cols G–Y) — 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
@@ -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 G–Y) 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 G–Y). 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>;
|
||||
Reference in New Issue
Block a user