diff --git a/system-block-ui/README.md b/system-block-ui/README.md index e0dafbb..ed00a6a 100644 --- a/system-block-ui/README.md +++ b/system-block-ui/README.md @@ -34,9 +34,16 @@ Worker at . No TI credentials are stored in this application. To use a locally running proxy instead, copy `.env.example` to `.env.local` before starting Vite. -In the subcircuit picker, click a recommended TI portfolio part to reveal its -description. Recommendation descriptions stay collapsed until their part is -clicked. +The subcircuit picker shows a **Recommended** badge on matching local +subcircuits and lists other returned parts under **TI portfolio recommendations**. +Click a portfolio part to reveal its description. Click **MCP response** to +expand the complete, formatted JSON returned by TI's MCP tool; both the response +and descriptions stay collapsed until clicked. + +The proxy is queried with `category` only. The picker reads the MCP payload from +`conversation.response`, deduplicates product findings by part number, and keeps +the full response available even when no parts can be extracted. Legacy proxy +responses containing a `recommendations` array are also supported. To build the production application and serve that build locally: diff --git a/system-block-ui/src/components/SubcircuitPickerModal.tsx b/system-block-ui/src/components/SubcircuitPickerModal.tsx index c775de6..a3d63f7 100644 --- a/system-block-ui/src/components/SubcircuitPickerModal.tsx +++ b/system-block-ui/src/components/SubcircuitPickerModal.tsx @@ -63,6 +63,10 @@ export function SubcircuitPickerModal({ const [expandedPartNumber, setExpandedPartNumber] = useState( null, ); + const [mcpResponseJson, setMcpResponseJson] = useState(null); + const [recommendationError, setRecommendationError] = useState( + null, + ); const [isFetchingRecommendations, setIsFetchingRecommendations] = useState(false); const widerPortfolioParts = useMemo( @@ -82,6 +86,8 @@ export function SubcircuitPickerModal({ setRecommendedIds(new Set()); setRecommendedParts([]); setExpandedPartNumber(null); + setMcpResponseJson(null); + setRecommendationError(null); if (candidates.length === 0) { setIsFetchingRecommendations(false); return; @@ -95,10 +101,17 @@ export function SubcircuitPickerModal({ if (!active) return; setRecommendedIds(recommendations.definitionIds); setRecommendedParts(recommendations.parts); + setMcpResponseJson( + JSON.stringify(recommendations.mcpResponse, null, 2), + ); setIsFetchingRecommendations(false); }, () => { - if (active) setIsFetchingRecommendations(false); + if (!active) return; + setRecommendationError( + "TI recommendations could not be loaded. Available subcircuits are still selectable.", + ); + setIsFetchingRecommendations(false); }, ); return () => { @@ -163,6 +176,24 @@ export function SubcircuitPickerModal({
+ {recommendationError && ( +

+ {recommendationError} +

+ )} + {mcpResponseJson !== null && ( +
+ + MCP response JSON + +
+                  {mcpResponseJson}
+                
+
+ )} {widerPortfolioParts.length > 0 && (
({ + id, + title, + category: "Power", + componentName: id, + importPath: "@tsci/tscircuit.ti", + sourcePath: `lib/subcircuits/${id}.circuit.tsx`, + ports: [], +}); + +const finding = (partNumber: string) => ({ + type: "finding", + data: { + part_number: partNumber, + product_family: "Power management", + parameters: [{ name: "Output voltage", value: "3.3 V" }], + }, +}); + +afterEach(() => mock.restore()); + +describe("TI MCP recommendation responses", () => { + test("extracts each distinct finding and retains the complete decoded MCP JSON", () => { + const response = { + finding_events: [ + finding("TPS62840"), + finding("TPS62840"), + finding(" tps62840 "), + finding("TPS7A02"), + { type: "citations", data: ["https://www.ti.com/product/TPS62840"] }, + ], + filter_events: [{ mutations: { family_gpns: ["UNRELATED123"] } }], + is_cache_hit: true, + }; + const result = parseTiRecommendationResponse({ + category: "Power", + conversation: { + request: "Recommend power parts", + response: JSON.stringify(response), + tool: "product_features_applications", + }, + source: "ti-mcp", + }); + + expect(result.parts).toEqual([ + { + partNumber: "TPS62840", + name: "TPS62840", + description: "Power management", + }, + { + partNumber: "TPS7A02", + name: "TPS7A02", + description: "Power management", + }, + ]); + expect(result.mcpResponse).toEqual(response); + }); + + test("keeps all returned parts after deduplicating parameter findings", () => { + const partNumbers = Array.from( + { length: 7 }, + (_, index) => `TPS123${index}`, + ); + const result = parseTiRecommendationResponse({ + conversation: { + response: { + finding_events: partNumbers.flatMap((part) => [ + finding(part), + finding(part), + ]), + }, + }, + }); + expect(result.parts.map((part) => part.partNumber)).toEqual(partNumbers); + }); + + test("supports legacy recommendation arrays and snake-case MCP product details", () => { + const payload = { + recommendations: [ + { + partNumber: " TPS62840 ", + name: " Buck regulator ", + description: " Low power. ", + }, + { + part_number: "TPS7A02", + product_name: "Low-IQ LDO", + description: "Linear regulator.", + }, + { partNumber: "TPS7A03", name: " " }, + null, + "not a part", + { partNumber: 123 }, + { partNumber: " " }, + ], + metadata: { source: "ti-mcp" }, + }; + const result = parseTiRecommendationResponse(payload); + expect(result.parts).toEqual([ + { + partNumber: "TPS62840", + name: "Buck regulator", + description: "Low power.", + }, + { + partNumber: "TPS7A02", + name: "Low-IQ LDO", + description: "Linear regulator.", + }, + { partNumber: "TPS7A03", name: "TPS7A03", description: "" }, + ]); + expect(result.mcpResponse).toBe(payload); + }); + + test("reads structured recommendations inside the MCP response", () => { + const response = { + recommendations: [ + { part_number: "TMP117", product_name: "Temperature sensor" }, + ], + }; + expect( + parseTiRecommendationResponse({ conversation: { response } }).parts, + ).toEqual([ + { partNumber: "TMP117", name: "Temperature sensor", description: "" }, + ]); + }); + + test("keeps empty, unexpected, and non-JSON responses inspectable without inventing parts", () => { + for (const response of [ + null, + [], + { finding_events: [null, {}, { type: "finding", data: null }] }, + "No matching parts found.", + ]) { + const result = parseTiRecommendationResponse({ + conversation: { response }, + }); + expect(result.parts).toEqual([]); + expect(result.mcpResponse).toEqual(response); + } + }); +}); + +describe("TI recommendation badges", () => { + test("matches local part numbers while preserving package suffix support", () => { + const definitions = [ + definition("buck", "TPS62840 Buck Converter"), + definition("ldo", "TPS7A02 LDO"), + definition("other", "LM5050 Input Protection"), + ]; + expect([ + ...matchTiRecommendedDefinitionIds( + ["tps62840dlcr", "TPS7A02"], + definitions, + ), + ]).toEqual(["buck", "ldo"]); + }); + + test("does not form a false match by joining different recommended part numbers", () => { + expect( + matchTiRecommendedDefinitionIds( + ["TPS7", "A02"], + [definition("ldo", "TPS7A02 LDO")], + ).size, + ).toBe(0); + }); +}); + +describe("TI recommendation requests", () => { + test("uses only category and caches both parts and JSON while rematching local definitions", async () => { + const response = { finding_events: [finding("TPS62840")] }; + const fetchMock = spyOn(globalThis, "fetch").mockResolvedValue( + Response.json({ + conversation: { response: JSON.stringify(response) }, + }), + ); + const [first, second] = await Promise.all([ + getTiRecommendations("test-cache", [ + definition("first", "TPS62840 Buck"), + ]), + getTiRecommendations("test-cache", [ + definition("second", "TPS62840 Buck"), + ]), + ]); + const cached = await getTiRecommendations("test-cache", []); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const request = new URL(String(fetchMock.mock.calls[0]?.[0])); + expect([...request.searchParams.entries()]).toEqual([ + ["category", "test-cache"], + ]); + expect([...first.definitionIds]).toEqual(["first"]); + expect([...second.definitionIds]).toEqual(["second"]); + expect(first.mcpResponse).toEqual(response); + expect(cached.mcpResponse).toBe(first.mcpResponse); + expect(cached.parts).toBe(first.parts); + expect(cached.definitionIds.size).toBe(0); + }); + + test("does not reuse another category's response", async () => { + const fetchMock = spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + Response.json({ recommendations: [{ partNumber: "TPS62840" }] }), + ) + .mockResolvedValueOnce( + Response.json({ recommendations: [{ partNumber: "TMP117" }] }), + ); + const power = await getTiRecommendations("test-power", []); + const sensors = await getTiRecommendations("test-sensors", []); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(power.parts[0]?.partNumber).toBe("TPS62840"); + expect(sensors.parts[0]?.partNumber).toBe("TMP117"); + }); + + test("failed HTTP requests can be retried", async () => { + const fetchMock = spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("Unavailable", { status: 503 })) + .mockResolvedValueOnce(Response.json({ recommendations: [] })); + await expect(getTiRecommendations("test-retry-http", [])).rejects.toThrow( + "HTTP 503", + ); + expect((await getTiRecommendations("test-retry-http", [])).parts).toEqual( + [], + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("malformed HTTP JSON does not poison the cache", async () => { + const fetchMock = spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("not JSON")) + .mockResolvedValueOnce(Response.json({ recommendations: [] })); + await expect(getTiRecommendations("test-retry-json", [])).rejects.toThrow(); + expect((await getTiRecommendations("test-retry-json", [])).parts).toEqual( + [], + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/system-block-ui/src/ti-recommendations.ts b/system-block-ui/src/ti-recommendations.ts index a35d90c..b81445f 100644 --- a/system-block-ui/src/ti-recommendations.ts +++ b/system-block-ui/src/ti-recommendations.ts @@ -1,9 +1,5 @@ import type { SubcircuitDefinition } from "./model"; -interface TiRecommendationResponse { - recommendations?: unknown; -} - const TI_RECOMMENDATIONS_URL = import.meta.env.VITE_TI_RECOMMENDATIONS_URL?.trim() || "https://ti-mcp-cache-proxy.seve.workers.dev/api/ti-recommendations"; @@ -14,15 +10,79 @@ export interface TiRecommendedPart { partNumber: string; } -export interface TiRecommendations { - definitionIds: ReadonlySet; +interface TiRecommendationData { + mcpResponse: unknown; parts: readonly TiRecommendedPart[]; } -const recommendationCache = new Map< - string, - Promise ->(); +export interface TiRecommendations extends TiRecommendationData { + definitionIds: ReadonlySet; +} + +const recommendationCache = new Map>(); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +export function parseTiRecommendationResponse( + payload: unknown, +): TiRecommendationData { + const conversation = isRecord(payload) ? payload.conversation : undefined; + let mcpResponse = + isRecord(conversation) && "response" in conversation + ? conversation.response + : payload; + if (typeof mcpResponse === "string") { + try { + mcpResponse = JSON.parse(mcpResponse); + } catch { + // Keep non-JSON MCP responses available in the viewer, too. + } + } + + let recommendations: unknown[] = []; + if (isRecord(payload) && Array.isArray(payload.recommendations)) { + recommendations = payload.recommendations; + } else if (isRecord(mcpResponse)) { + if (Array.isArray(mcpResponse.recommendations)) { + recommendations = mcpResponse.recommendations; + } else if (Array.isArray(mcpResponse.finding_events)) { + // Each product can have many findings (one per parameter). Only findings + // are recommendations; filter_events also contain unrelated family parts. + recommendations = mcpResponse.finding_events + .filter((event) => isRecord(event) && event.type === "finding") + .map((event) => event.data); + } + } + + const parts = new Map(); + for (const recommendation of recommendations) { + if (!isRecord(recommendation)) continue; + const partNumber = + readString(recommendation.partNumber) || + readString(recommendation.part_number); + if (!partNumber) continue; + const key = normalizePartText(partNumber); + if (!key || parts.has(key)) continue; + parts.set(key, { + partNumber, + name: + readString(recommendation.name) || + readString(recommendation.product_name) || + partNumber, + description: + readString(recommendation.description) || + readString(recommendation.product_family), + }); + } + + return { mcpResponse, parts: [...parts.values()] }; +} function normalizePartText(value: string): string { return value @@ -42,18 +102,17 @@ export function matchTiRecommendedDefinitionIds( partNumbers: readonly string[], definitions: readonly SubcircuitDefinition[], ): ReadonlySet { - const normalizedRecommendation = normalizePartText(partNumbers.join(" ")); + const normalizedRecommendations = partNumbers.map(normalizePartText); const matches = definitions .filter((definition) => { const normalizedTitle = normalizePartText(definition.title); - if ( - normalizedTitle.length >= 8 && - normalizedRecommendation.includes(normalizedTitle) - ) { - return true; - } - return partNumberTokens(definition.title).some((token) => - normalizedRecommendation.includes(token), + return normalizedRecommendations.some( + (recommendation) => + (normalizedTitle.length >= 8 && + recommendation.includes(normalizedTitle)) || + partNumberTokens(definition.title).some((token) => + recommendation.includes(token), + ), ); }) .map((definition) => definition.id); @@ -64,13 +123,10 @@ export function getTiRecommendations( category: string, definitions: readonly SubcircuitDefinition[], ): Promise { - let parts = recommendationCache.get(category); - if (!parts) { + let data = recommendationCache.get(category); + if (!data) { const request = (async () => { - const query = new URLSearchParams({ - category, - format: "details", - }); + const query = new URLSearchParams({ category }); const endpoint = new URL(TI_RECOMMENDATIONS_URL); endpoint.search = query.toString(); const response = await fetch(endpoint); @@ -79,36 +135,9 @@ export function getTiRecommendations( `TI recommendations failed with HTTP ${response.status}.`, ); } - const payload = (await response.json()) as TiRecommendationResponse; - if (Array.isArray(payload.recommendations)) { - return payload.recommendations - .filter((recommendation): recommendation is Record => - Boolean(recommendation && typeof recommendation === "object"), - ) - .map((recommendation) => ({ - description: - typeof recommendation.description === "string" - ? recommendation.description.trim() - : "", - name: - typeof recommendation.name === "string" - ? recommendation.name.trim() - : "", - partNumber: - typeof recommendation.partNumber === "string" - ? recommendation.partNumber.trim() - : "", - })) - .filter((recommendation) => recommendation.partNumber) - .map((recommendation) => ({ - ...recommendation, - name: recommendation.name || recommendation.partNumber, - })) - .slice(0, 5); - } - return []; + return parseTiRecommendationResponse(await response.json()); })(); - parts = request; + data = request; recommendationCache.set(category, request); void request.catch(() => { if (recommendationCache.get(category) === request) { @@ -117,11 +146,11 @@ export function getTiRecommendations( }); } - return parts.then((resolvedParts) => ({ + return data.then((recommendations) => ({ + ...recommendations, definitionIds: matchTiRecommendedDefinitionIds( - resolvedParts.map((part) => part.partNumber), + recommendations.parts.map((part) => part.partNumber), definitions, ), - parts: resolvedParts, })); }