Files
community-rule/lib/server/methodRecommendations.ts
T
adilalloandCursor bcbc6f30d9 Import the remade decks on stable ids and recommend one card per job, so a saved draft still loads after a chip is renamed.
The real-group profiles stay as a test, so a change that moves a pick fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 14:22:54 -07:00

226 lines
7.1 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 { groupAnswersFromRequestedFacets } from "../create/groupAnswers";
import { deckData, recommendDeck } from "../create/deckRecommend";
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"]>;
};
/**
* Ranks one deck with the job rule. Empty facets return no scores so callers
* keep deck order. The first maturity value is the only one that scores.
* Scores decrease along the engine's display order so a score sort matches it.
*/
export async function listMethodRecommendations(args: {
section: SectionId;
facets: RequestedFacets;
}): Promise<ListMethodRecommendationsResult | null> {
const requested = flattenRequestedFacets(args.facets);
if (requested.length === 0) {
return { rankedSlugs: [], matchesBySlug: {} };
}
const answers = groupAnswersFromRequestedFacets(args.facets);
const recommendation = recommendDeck(deckData(args.section), answers);
const orderedIds = recommendation.fallback
? recommendation.seeAll
: [
...recommendation.shown.map((card) => card.id),
...recommendation.seeAll,
];
const matchesBySlug: Record<string, MethodRanking["matches"]> = {};
orderedIds.forEach((id, index) => {
const card = recommendation.recommended.find((row) => row.id === id);
const matched =
card?.reason.kind === "fit" || card?.reason.kind === "opened"
? card.reason.matched
: [];
matchesBySlug[id] = {
score: orderedIds.length - index,
matchedFacets: matched,
};
});
return { rankedSlugs: orderedIds, matchesBySlug };
}
/**
* 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 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;
}
}