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