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

241 lines
7.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 { 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;
}
}
/**
* 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;
}
}