The real-group profiles stay as a test, so a change that moves a pick fails. Co-authored-by: Cursor <cursoragent@cursor.com>
226 lines
7.1 KiB
TypeScript
226 lines
7.1 KiB
TypeScript
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;
|
||
}
|
||
}
|