Skip to content

Commit 9f9dd09

Browse files
committed
matching taxonomy bug fixes
1 parent 10e6f05 commit 9f9dd09

10 files changed

Lines changed: 461 additions & 202 deletions

File tree

.repo-map.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,8 @@ Total Python files: 307
511511
│ │ │ def _extract_required_processes_from_manifest()
512512
│ │ │ def _collect_matched_processes_from_solutions()
513513
│ │ │ def _collect_matched_processes_from_trees()
514+
│ │ │ def _taxonomy_id_for_process()
515+
│ │ │ def _processes_match()
514516
│ │ │ def _build_match_summary()
515517
│ │ │ def render_match_summary()
516518
│ │ │ def _build_optional_human_summary()
@@ -527,9 +529,10 @@ Total Python files: 307
527529
│ │ │ class RFQGenerateResponse
528530
│ │ │ def _rfq_number()
529531
│ │ │ def _extract_location()
530-
│ │ │ def _extract_contact()
531-
│ │ │ def _extract_processes()
532-
│ │ │ def _extract_materials()
532+
│ │ │ def _extract_contact_block()
533+
│ │ │ def _cap_label()
534+
│ │ │ def _extract_processes_from_manifest()
535+
│ │ │ def _extract_matched_capabilities_block()
533536
│ │ │ def _extract_manifest_extras()
534537
│ │ │ def _render_rfq()
535538
│ │ ├── rules.py

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ cd supply-graph-ai
5050
# 2. Create your environment file
5151
cp env.template .env
5252

53-
# 3. Install all dependencies (uv downloads Python 3.12 automatically if needed)
53+
# 3. Install all dependenciesif needed)
5454
uv sync
5555

5656
# 4. Activate the virtual environment
@@ -60,7 +60,7 @@ source .venv/bin/activate # macOS / Linux
6060
# 5. Verify the CLI is available
6161
ohm --help
6262

63-
# 6. Start the API server (still uses Docker)
63+
# 6. Start the API server
6464
docker compose up -d ohm-api
6565
```
6666

frontend/src/features/match/MatchResultCard.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import type { FacilityDetail, MatchSolution } from "../../types/match";
33

44
interface Props {
55
solution: MatchSolution;
6+
/** Stable row id for selection (may differ from `facility_id` when the API returns duplicate facilities). */
7+
selectionId: string;
68
isExpanded: boolean;
79
onToggle: () => void;
810
solutionId?: string;
911
isSelected?: boolean;
10-
onSelect?: (id: string, checked: boolean) => void;
12+
onSelect?: (rowId: string, checked: boolean) => void;
1113
}
1214

1315
/**
@@ -107,6 +109,7 @@ function CompositeFacilityPanel({ detail }: { detail: FacilityDetail }) {
107109

108110
export function MatchResultCard({
109111
solution,
112+
selectionId,
110113
isExpanded,
111114
onToggle,
112115
isSelected = false,
@@ -160,7 +163,7 @@ export function MatchResultCard({
160163
<input
161164
type="checkbox"
162165
checked={isSelected}
163-
onChange={(e) => onSelect(solution.facility_id, e.target.checked)}
166+
onChange={(e) => onSelect(selectionId, e.target.checked)}
164167
className="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800"
165168
aria-label={`Select ${solution.facility_name}`}
166169
/>

frontend/src/features/match/MatchView.tsx

Lines changed: 61 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useCallback, useEffect, useRef } from "react";
1+
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
22
import { useNavigate } from "react-router-dom";
33
import { useQuery } from "@tanstack/react-query";
44
import { useMatch } from "./useMatch";
@@ -9,15 +9,33 @@ import { LoadingSpinner } from "../../components/ui/LoadingSpinner";
99
import { ErrorMessage } from "../../components/ui/ErrorMessage";
1010
import { EmptyState } from "../../components/ui/EmptyState";
1111
import { fetchOkhDetail } from "../../api/okh";
12+
import type { MatchSolution } from "../../types/match";
1213
import type { RfqNavigationState } from "../../types/rfq";
13-
14-
const KEY_SELECTED = "ohm_v1_match_selected";
14+
import { MATCH_SESSION } from "./matchSessionKeys";
15+
import { solutionRowId } from "./solutionRowId";
1516

1617
interface Props {
1718
okhId?: string;
1819
autoRun?: boolean;
1920
}
2021

22+
function dedupeSolutionsByFacility(input: MatchSolution[]): MatchSolution[] {
23+
const byFacility = new Map<string, MatchSolution>();
24+
for (const sol of input) {
25+
const key = sol.facility_id || sol.facility_name || solutionRowId(sol);
26+
const existing = byFacility.get(key);
27+
if (!existing) {
28+
byFacility.set(key, sol);
29+
continue;
30+
}
31+
// Keep the strongest candidate; stable fallback to earlier rank.
32+
if (sol.score > existing.score || (sol.score === existing.score && sol.rank < existing.rank)) {
33+
byFacility.set(key, sol);
34+
}
35+
}
36+
return Array.from(byFacility.values()).sort((a, b) => a.rank - b.rank);
37+
}
38+
2139
function SessionRestoredBanner({
2240
savedAt,
2341
onClear,
@@ -53,7 +71,7 @@ export function MatchView({ okhId, autoRun = false }: Props) {
5371
// selectedIds: local state seeded from sessionStorage
5472
const [selectedIds, setSelectedIdsState] = useState<Set<string>>(() => {
5573
try {
56-
const raw = sessionStorage.getItem(KEY_SELECTED);
74+
const raw = sessionStorage.getItem(MATCH_SESSION.selected);
5775
return raw ? new Set(JSON.parse(raw) as string[]) : new Set<string>();
5876
} catch {
5977
return new Set<string>();
@@ -63,11 +81,12 @@ export function MatchView({ okhId, autoRun = false }: Props) {
6381
const setSelectedIds = useCallback((ids: Set<string>) => {
6482
setSelectedIdsState(ids);
6583
try {
66-
sessionStorage.setItem(KEY_SELECTED, JSON.stringify(Array.from(ids)));
84+
sessionStorage.setItem(MATCH_SESSION.selected, JSON.stringify(Array.from(ids)));
6785
} catch { /* quota exceeded */ }
6886
}, []);
6987

70-
const autoRunFired = useRef(false);
88+
/** Prevents duplicate autorun for the same URL okh_id (e.g. React StrictMode remount). */
89+
const autorunTargetRef = useRef<string | null>(null);
7190
// Keep a stable ref so the autoRun effect doesn't re-fire on every render
7291
// when `trigger` (recreated each render) changes reference.
7392
const triggerRef = useRef<typeof trigger | null>(null);
@@ -87,13 +106,13 @@ export function MatchView({ okhId, autoRun = false }: Props) {
87106
processingTime,
88107
expandedRank,
89108
toggleExpanded,
90-
matchOkhId: _sessionOkhId,
91109
savedAt,
92110
reset,
93-
} = useMatch();
111+
} = useMatch(okhId);
94112

95113
// Keep ref in sync so autoRun effect always calls the latest trigger.
96114
triggerRef.current = trigger;
115+
const displaySolutions = useMemo(() => dedupeSolutionsByFacility(solutions), [solutions]);
97116

98117
const { data: okhDetail } = useQuery({
99118
queryKey: ["okh-detail-for-rfq", okhId],
@@ -103,10 +122,10 @@ export function MatchView({ okhId, autoRun = false }: Props) {
103122
});
104123

105124
const handleSelect = useCallback(
106-
(facilityId: string, checked: boolean) => {
125+
(rowId: string, checked: boolean) => {
107126
const next = new Set(selectedIds);
108-
if (checked) next.add(facilityId);
109-
else next.delete(facilityId);
127+
if (checked) next.add(rowId);
128+
else next.delete(rowId);
110129
setSelectedIds(next);
111130
},
112131
[selectedIds, setSelectedIds]
@@ -118,22 +137,27 @@ export function MatchView({ okhId, autoRun = false }: Props) {
118137
setSelectedIds(new Set<string>());
119138
}, [reset, setSelectedIds]);
120139

140+
// Reset autorun latch when switching designs so each okh_id can autorun once.
141+
useEffect(() => {
142+
autorunTargetRef.current = null;
143+
}, [okhId]);
144+
121145
// Auto-trigger match when navigated here from "Run Match ⚡".
122146
// Skip if we already have session results for this exact OKH — the user
123147
// can explicitly re-run via the "Re-run Match" button if they want fresh results.
124148
// `trigger` is intentionally accessed via ref so this effect is NOT re-run
125149
// every render (trigger changes reference on every render).
126150
useEffect(() => {
127-
if (autoRun && okhId && !autoRunFired.current && !hasResult) {
128-
autoRunFired.current = true;
129-
triggerRef.current?.(okhId);
130-
}
151+
if (!autoRun || !okhId || hasResult) return;
152+
if (autorunTargetRef.current === okhId) return;
153+
autorunTargetRef.current = okhId;
154+
triggerRef.current?.(okhId);
131155
// eslint-disable-next-line react-hooks/exhaustive-deps
132156
}, [autoRun, okhId, hasResult]);
133157

134158
const handleGenerateRfq = useCallback(() => {
135-
const selectedSolutions = solutions.filter((s) =>
136-
selectedIds.has(s.facility_id)
159+
const selectedSolutions = displaySolutions.filter((s) =>
160+
selectedIds.has(solutionRowId(s))
137161
);
138162
const state: RfqNavigationState = {
139163
okhId: okhId!,
@@ -143,7 +167,16 @@ export function MatchView({ okhId, autoRun = false }: Props) {
143167
solutions: selectedSolutions,
144168
};
145169
navigate("/rfq", { state });
146-
}, [solutions, selectedIds, okhId, okhDetail, navigate]);
170+
}, [displaySolutions, selectedIds, okhId, okhDetail, navigate]);
171+
172+
// Prune selected ids when a newer run returns fewer/different unique rows.
173+
useEffect(() => {
174+
const valid = new Set(displaySolutions.map((s) => solutionRowId(s)));
175+
const next = new Set(Array.from(selectedIds).filter((id) => valid.has(id)));
176+
if (next.size !== selectedIds.size) {
177+
setSelectedIds(next);
178+
}
179+
}, [displaySolutions, selectedIds, setSelectedIds]);
147180

148181
if (!okhId) {
149182
return (
@@ -282,40 +315,41 @@ export function MatchView({ okhId, autoRun = false }: Props) {
282315
<div>
283316
<div className="mb-3 flex items-center justify-between">
284317
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
285-
{solutions.length} candidate{solutions.length !== 1 ? "s" : ""} — tick to select, click to expand
318+
{displaySolutions.length} candidate{displaySolutions.length !== 1 ? "s" : ""} — tick to select, click to expand
286319
</h2>
287-
{solutions.length > 0 && (
320+
{displaySolutions.length > 0 && (
288321
<button
289322
onClick={() => {
290-
if (selectedIds.size === solutions.length) {
323+
if (selectedIds.size === displaySolutions.length) {
291324
setSelectedIds(new Set<string>());
292325
} else {
293-
setSelectedIds(new Set(solutions.map((s) => s.facility_id)));
326+
setSelectedIds(new Set(displaySolutions.map((s) => solutionRowId(s))));
294327
}
295328
}}
296329
className="text-xs text-indigo-600 hover:underline dark:text-indigo-400"
297330
>
298-
{selectedIds.size === solutions.length ? "Deselect all" : "Select all"}
331+
{selectedIds.size === displaySolutions.length ? "Deselect all" : "Select all"}
299332
</button>
300333
)}
301334
</div>
302335

303-
{solutions.length === 0 ? (
336+
{displaySolutions.length === 0 ? (
304337
<EmptyState
305338
icon="🔍"
306339
heading="No results returned"
307340
body="The match completed but found no candidate solutions."
308341
/>
309342
) : (
310343
<div className="space-y-3">
311-
{solutions.map((sol) => (
344+
{displaySolutions.map((sol) => (
312345
<MatchResultCard
313-
key={sol.facility_id}
346+
key={solutionRowId(sol)}
314347
solution={sol}
348+
selectionId={solutionRowId(sol)}
315349
isExpanded={expandedRank === sol.rank}
316350
onToggle={() => toggleExpanded(sol.rank)}
317351
solutionId={solutionId}
318-
isSelected={selectedIds.has(sol.facility_id)}
352+
isSelected={selectedIds.has(solutionRowId(sol))}
319353
onSelect={handleSelect}
320354
/>
321355
))}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/** sessionStorage keys for match workflow (keep in sync across hooks). */
2+
export const MATCH_SESSION = {
3+
result: "ohm_v1_match_result",
4+
okhId: "ohm_v1_match_okh_id",
5+
savedAt: "ohm_v1_match_saved_at",
6+
selected: "ohm_v1_match_selected",
7+
} as const;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { MatchSolution } from "../../types/match";
2+
3+
/**
4+
* Unique row identity for list keys and checkbox selection.
5+
* The API can return several solutions with the same `facility_id` (e.g. alternate
6+
* trees); using only `facility_id` collides in React and breaks per-row selection.
7+
*/
8+
export function solutionRowId(sol: MatchSolution): string {
9+
const tid = sol.tree?.id;
10+
if (tid != null && String(tid).trim() !== "") return String(tid);
11+
return `${sol.facility_id}#${sol.rank}`;
12+
}

0 commit comments

Comments
 (0)