Files
community-rule/app/(app)/create/hooks/useFacetRecommendations.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

177 lines
5.1 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets";
import {
buildFacetRecommendationRequestKey,
getCachedFacetScores,
loadFacetScores,
} from "../../../../lib/create/facetRecommendationsLoad";
import { useCreateFlow } from "../context/CreateFlowContext";
import { groupAnswersFromCreateFlowState } from "../../../../lib/create/groupAnswers";
import {
deckData,
recommendDeck,
} from "../../../../lib/create/deckRecommend";
import type { GroupAnswers } from "../../../../lib/create/deckTypes";
/**
* Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2).
* Same tuple as {@link METHOD_FACET_API_SECTION_IDS} (`CUSTOM_RULE_FACETS`, CR-92).
*/
export type RecommendationSection = MethodFacetApiSectionId;
export type FacetRecommendationsResult = {
/** `true` once the network call completes (or short-circuits with no facets). */
isReady: boolean;
/** `slug → score`; missing slug means `0`. */
scoresBySlug: Record<string, number>;
/**
* `true` iff the user has selected at least one community facet. When
* `false`, callers should preserve authoring order rather than reranking.
*/
hasAnyFacets: boolean;
};
const EMPTY_SCORES: Record<string, number> = {};
function initialFacetRecommendationsResult(
section: RecommendationSection,
queryString: string,
): FacetRecommendationsResult {
const hasAnyFacets = queryString.length > 0;
if (!hasAnyFacets) {
return {
isReady: true,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: false,
};
}
const requestKey = buildFacetRecommendationRequestKey(section, queryString);
const cached = getCachedFacetScores(requestKey);
if (cached) {
return {
isReady: true,
scoresBySlug: cached,
hasAnyFacets: true,
};
}
return {
isReady: false,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: true,
};
}
/**
* Calls `GET /api/create-flow/methods?section=<section>&facet.*=...` for the
* card-deck step `section` and returns a `slug → score` map for re-ranking
* the messages-file `methods[]` array (CR-88 §10).
*
* Returns `{ isReady: true, scoresBySlug: {} }` when the user has not selected
* any community facets — callers fall back to the authoring order.
*
* Network failures resolve to `scoresBySlug: {}` so the wizard is never
* blocked on the recommendation backend.
*/
export function useFacetRecommendations(
section: RecommendationSection,
): FacetRecommendationsResult {
const { state } = useCreateFlow();
const queryString = useMemo(
() => buildFacetQueryString(state),
[state],
);
const hasAnyFacets = queryString.length > 0;
const [result, setResult] = useState<FacetRecommendationsResult>(() =>
initialFacetRecommendationsResult(section, queryString),
);
useEffect(() => {
if (!hasAnyFacets) {
setResult({
isReady: true,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: false,
});
return;
}
const requestKey = buildFacetRecommendationRequestKey(section, queryString);
const cached = getCachedFacetScores(requestKey);
if (cached) {
setResult({
isReady: true,
scoresBySlug: cached,
hasAnyFacets: true,
});
return;
}
let cancelled = false;
setResult((prev) =>
prev.isReady && prev.hasAnyFacets
? { ...prev, isReady: false }
: { isReady: false, scoresBySlug: EMPTY_SCORES, hasAnyFacets: true },
);
void loadFacetScores(section, queryString).then((scoresBySlug) => {
if (cancelled) return;
setResult({ isReady: true, scoresBySlug, hasAnyFacets: true });
});
return () => {
cancelled = true;
};
}, [section, queryString, hasAnyFacets]);
return result;
}
/**
* One card per job, then the display rule for the top `limit` cards.
* Cards marked every-rule carry no Recommended tag. With no facets, the
* first `limit` cards in deck order show and nothing is tagged.
*/
export function deriveCompactCards(
section: RecommendationSection,
answers: GroupAnswers,
limit: number,
): {
compactCardIds: string[];
recommendedIds: Set<string>;
orderedIds: string[];
} {
const recommendation = recommendDeck(deckData(section), answers, { limit });
const orderedIds = recommendation.fallback
? recommendation.seeAll
: [
...recommendation.shown.map((card) => card.id),
...recommendation.seeAll,
];
if (recommendation.fallback) {
return {
compactCardIds: orderedIds.slice(0, limit),
recommendedIds: new Set<string>(),
orderedIds,
};
}
return {
compactCardIds: recommendation.shown.map((card) => card.id),
recommendedIds: new Set(
recommendation.recommended
.filter((card) => card.tag === "recommended")
.map((card) => card.id),
),
orderedIds,
};
}
export function groupAnswersForRecommendations(
state: Parameters<typeof groupAnswersFromCreateFlowState>[0],
): GroupAnswers {
return groupAnswersFromCreateFlowState(state);
}