+ {result?.answer ? (
+
+
+ {createBenchProjectMessage ? {createBenchProjectMessage} : null}
+
+ ) : null}
diff --git a/frontend/src/hooks/useAskController.ts b/frontend/src/hooks/useAskController.ts
index 658b3a0..9c54572 100644
--- a/frontend/src/hooks/useAskController.ts
+++ b/frontend/src/hooks/useAskController.ts
@@ -1,6 +1,7 @@
import { FormEvent, useCallback, useEffect, useState } from "react";
-import { deleteConversation, getConversation, getConversations, getUserPreference, runQuery } from "../libs/api";
+import { createAssemblyPlanFromConversation, deleteConversation, getConversation, getConversations, getUserPreference, runQuery } from "../libs/api";
import { ASK_RETRIEVAL_PREFERENCE_KEY, resolveAskPreferences, type AskRetrievalPreference } from "../libs/askPreferences";
+import { rememberBenchPlanSelection } from "../libs/benchSelection";
import { errorMessage } from "../libs/errors";
import { formatElapsed } from "../libs/time";
import type { AppConfig, ChatTurn, ConversationSummary, QueryOptions, QueryResponse } from "../types";
@@ -60,6 +61,8 @@ export function useAskController({ config, isActive }: { config: AppConfig; isAc
const [conversationsBusy, setConversationsBusy] = useState(false);
const [result, setResult] = useState(null);
const [busy, setBusy] = useState(false);
+ const [promoteBusy, setPromoteBusy] = useState(false);
+ const [promoteMessage, setPromoteMessage] = useState("");
const [error, setError] = useState("");
const elapsedSeconds = useElapsedSeconds(busy);
@@ -113,6 +116,7 @@ export function useAskController({ config, isActive }: { config: AppConfig; isAc
}
setBusy(true);
setError("");
+ setPromoteMessage("");
try {
const response = await runQuery({
...options,
@@ -138,6 +142,7 @@ export function useAskController({ config, isActive }: { config: AppConfig; isAc
async function selectConversation(conversationId: string) {
setError("");
+ setPromoteMessage("");
setBusy(true);
try {
const response = await getConversation(conversationId);
@@ -157,6 +162,7 @@ export function useAskController({ config, isActive }: { config: AppConfig; isAc
async function removeConversation(conversationId: string) {
setError("");
+ setPromoteMessage("");
try {
await deleteConversation(conversationId);
if (conversationId === activeConversationId) {
@@ -174,6 +180,33 @@ export function useAskController({ config, isActive }: { config: AppConfig; isAc
setContextChatHistory([]);
setResult(null);
setQuestion("");
+ setPromoteMessage("");
+ }
+
+ async function createBenchProjectFromConversation(onCreated?: () => void) {
+ if (!activeConversationId || promoteBusy) {
+ return;
+ }
+ setPromoteBusy(true);
+ setError("");
+ setPromoteMessage("");
+ try {
+ const response = await createAssemblyPlanFromConversation({
+ conversationId: activeConversationId,
+ objective: result?.question || question
+ });
+ if (!response.plan) {
+ setError(response.error || "No Bench plan was created.");
+ return;
+ }
+ rememberBenchPlanSelection(response.plan.id);
+ setPromoteMessage(`Bench project created: ${response.plan.title}`);
+ onCreated?.();
+ } catch (err) {
+ setError(errorMessage(err, "Could not create Bench project"));
+ } finally {
+ setPromoteBusy(false);
+ }
}
return {
@@ -183,7 +216,10 @@ export function useAskController({ config, isActive }: { config: AppConfig; isAc
chatHistory,
conversations,
conversationsBusy,
+ createBenchProjectFromConversation,
error,
+ promoteBusy,
+ promoteMessage,
question,
result,
removeConversation,
diff --git a/frontend/src/libs/api/assembly.ts b/frontend/src/libs/api/assembly.ts
index 9f5d3c3..830dd94 100644
--- a/frontend/src/libs/api/assembly.ts
+++ b/frontend/src/libs/api/assembly.ts
@@ -1,4 +1,13 @@
-import type { AssemblyLearningSession, AssemblyPhotoCheck, AssemblyPlan, AssemblyPlanExport, AssemblyPlanSummary, AssemblyStepEvidence, BuildAssemblyPlanResponse } from "../../types";
+import type {
+ AssemblyLearningSession,
+ AssemblyPhotoCheck,
+ AssemblyPlan,
+ AssemblyPlanExport,
+ AssemblyPlanSummary,
+ AssemblyStepEvidence,
+ BuildAssemblyPlanResponse,
+ ConversationBenchPlanResponse,
+} from "../../types";
import { requestJson } from "./core";
export function getAssemblyPlans(): Promise<{ plans: AssemblyPlanSummary[] }> {
@@ -29,6 +38,16 @@ export function buildAssemblyPlan(payload: {
});
}
+export function createAssemblyPlanFromConversation(payload: {
+ conversationId: string;
+ objective?: string;
+}): Promise {
+ return requestJson("/api/assembly-plans/from-conversation", {
+ method: "POST",
+ body: JSON.stringify(payload)
+ });
+}
+
export function updateAssemblyStep(planId: string, stepId: string, completed: boolean): Promise<{ plan: AssemblyPlan }> {
return requestJson<{ plan: AssemblyPlan }>(
`/api/assembly-plans/${encodeURIComponent(planId)}/steps/${encodeURIComponent(stepId)}`,
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 288baf1..5cce963 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -807,6 +807,20 @@ nav {
"history sources";
}
+.answer-actions {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.answer-actions span {
+ color: var(--success);
+ font-size: 0.88rem;
+ font-weight: 800;
+}
+
.bench-grid {
min-height: calc(100vh - 48px);
display: grid;
diff --git a/frontend/src/types/assembly.ts b/frontend/src/types/assembly.ts
index 4bc89b5..c73aae2 100644
--- a/frontend/src/types/assembly.ts
+++ b/frontend/src/types/assembly.ts
@@ -152,3 +152,19 @@ export interface BuildAssemblyPlanResponse {
averageQueryTime?: number | null;
error?: string;
}
+
+export interface ConversationBenchPlanResponse extends BuildAssemblyPlanResponse {
+ source?: string;
+ aiReview?: {
+ provider?: string;
+ model?: string | null;
+ useful?: boolean;
+ confidence?: number | null;
+ summary?: string;
+ warnings?: string[];
+ escalateToOpenAI?: boolean;
+ reason?: string;
+ estimatedCost?: number | null;
+ paidBy?: string | null;
+ } | null;
+}
diff --git a/tests/test_circuit_graph.py b/tests/test_circuit_graph.py
index 1b88172..11f87d6 100644
--- a/tests/test_circuit_graph.py
+++ b/tests/test_circuit_graph.py
@@ -126,6 +126,7 @@ def get(self, plan_id: str, user_id: int | None = None):
create_router(
deps,
assembly_plan_store=Store(),
+ conversation_store=None,
bench_tools=SimpleNamespace(build_assembly_export=lambda plan, format: {}),
openai_assist_service=None,
get_rag_response=lambda **kwargs: None,
diff --git a/tests/test_conversation_bench_plan_service.py b/tests/test_conversation_bench_plan_service.py
new file mode 100644
index 0000000..3cf06ff
--- /dev/null
+++ b/tests/test_conversation_bench_plan_service.py
@@ -0,0 +1,208 @@
+from __future__ import annotations
+
+from backend.services.conversation_bench_plan_service import ConversationBenchPlanService, extract_source_payload
+
+
+def conversation_with_snapshot(build_card=None):
+ snapshot = {
+ "sources": [
+ {
+ "source": "training/ne555.pdf",
+ "displayName": "NE555 datasheet",
+ "pages": [3],
+ "chunkCount": 2,
+ "chunks": [{"page": 3, "section": "Pinout", "preview": "Pin 1 GND, pin 8 VCC"}],
+ }
+ ],
+ "buildCard": build_card,
+ }
+ return {
+ "id": "conv-1",
+ "title": "555 timer project",
+ "turns": [
+ {
+ "id": "turn-1",
+ "ordinal": 1,
+ "question": "Build a 555 LED flasher",
+ "answer": "Use the 555 output to drive an LED.",
+ "confidence": 0.82,
+ "responseSnapshot": snapshot,
+ }
+ ],
+ }
+
+
+def valid_card():
+ return {
+ "title": "555 LED flasher",
+ "componentName": "NE555",
+ "componentType": "timer",
+ "summary": "Blink an LED with a 555 timer.",
+ "confidence": 0.78,
+ "parts": [{"name": "NE555 timer IC", "detail": "DIP package"}],
+ "power": ["Use a regulated 5V supply."],
+ "wiring": [
+ {"from": "Pin 1 GND", "to": "Ground rail", "note": "Common ground", "page": 3},
+ {"from": "Pin 8 VCC", "to": "5V rail", "note": "Power input", "page": 3},
+ ],
+ "checks": ["Verify polarity."],
+ "warnings": ["Confirm datasheet pinout."],
+ "sourceNotes": [{"source": "training/ne555.pdf", "pages": [3], "chunks": 2}],
+ }
+
+
+class ConversationStore:
+ def __init__(self, conversation):
+ self.conversation = conversation
+
+ def get(self, conversation_id, user_id):
+ assert conversation_id == "conv-1"
+ assert user_id == 7
+ return self.conversation
+
+
+class AssemblyStore:
+ def __init__(self):
+ self.created = []
+
+ def create_from_card(self, **kwargs):
+ self.created.append(kwargs)
+ return {"id": "plan-1", "title": kwargs["card"]["title"], "parts": [], "steps": []}
+
+
+class AiStore:
+ def __init__(self):
+ self.events = []
+
+ def record_ai_assist_event(self, **kwargs):
+ self.events.append(kwargs)
+
+
+class OpenAi:
+ def __init__(self, result=None):
+ self.result = result
+ self.calls = []
+
+ def synthesize_conversation_bench_plan(self, **kwargs):
+ self.calls.append(kwargs)
+ return self.result
+
+
+def make_service(conversation, *, local_response=None, openai=None, ai_store=None):
+ assembly_store = AssemblyStore()
+ ai_store = ai_store or AiStore()
+
+ def local_llm(_prompt, _model, **kwargs):
+ assert kwargs["gpu_resource_class"] == "local_llm"
+ return local_response or "{}"
+
+ return (
+ ConversationBenchPlanService(
+ conversation_store=ConversationStore(conversation),
+ assembly_plan_store=assembly_store,
+ ai_provider_store=ai_store,
+ openai_assist_service=openai,
+ query_local_llm=local_llm if local_response is not None else None,
+ local_model_name="electronics-helper",
+ ),
+ assembly_store,
+ ai_store,
+ )
+
+
+def test_extract_source_payload_dedupes_snapshot_sources():
+ payload = extract_source_payload(conversation_with_snapshot(valid_card()))
+
+ assert payload[0]["source"] == "training/ne555.pdf"
+ assert payload[0]["pages"] == [3]
+ assert payload[0]["chunkCount"] == 2
+ assert payload[0]["chunks"][0]["section"] == "Pinout"
+
+
+def test_conversation_build_card_snapshot_creates_plan_without_ai():
+ service, assembly_store, ai_store = make_service(conversation_with_snapshot(valid_card()))
+
+ result = service.create_plan(
+ conversation_id="conv-1",
+ user_id=7,
+ username="tester",
+ entity_id=5,
+ )
+
+ assert result["ok"] is True
+ assert result["source"] == "conversation_build_card"
+ assert assembly_store.created[0]["card"]["componentName"] == "NE555"
+ assert ai_store.events == []
+
+
+def test_local_ai_synthesis_creates_plan_and_records_usage():
+ service, assembly_store, ai_store = make_service(
+ conversation_with_snapshot(build_card=None),
+ local_response=(
+ '{"title":"555 LED flasher","componentName":"NE555","componentType":"timer",'
+ '"summary":"Blink an LED.","confidence":0.74,'
+ '"parts":[{"name":"NE555 timer IC","detail":"DIP"}],'
+ '"power":["Use 5V."],'
+ '"wiring":[{"from":"Pin 1 GND","to":"Ground rail","note":"Common ground","page":3},'
+ '{"from":"Pin 8 VCC","to":"5V rail","note":"Power","page":3}],'
+ '"checks":["Verify polarity"],"warnings":["Confirm pinout"],'
+ '"sourceNotes":[{"source":"training/ne555.pdf","pages":[3],"chunks":2}],'
+ '"useful":true,"escalateToOpenAI":false,"reason":"grounded"}'
+ ),
+ )
+
+ result = service.create_plan(
+ conversation_id="conv-1",
+ user_id=7,
+ username="tester",
+ entity_id=5,
+ )
+
+ assert result["ok"] is True
+ assert result["source"] == "local_ai"
+ assert assembly_store.created[0]["card"]["componentName"] == "NE555"
+ assert "Generated from Ask conversation" in " ".join(assembly_store.created[0]["card"]["warnings"])
+ assert ai_store.events[0]["provider"] == "ollama"
+ assert ai_store.events[0]["task_type"] == "assembly_plan"
+ assert ai_store.events[0]["context_type"] == "conversation_to_bench"
+
+
+def test_low_evidence_conversation_is_rejected_without_openai_request():
+ service, assembly_store, _ai_store = make_service(
+ conversation_with_snapshot(build_card=None),
+ local_response='{"useful":false,"confidence":0.2,"reason":"not enough wiring","escalateToOpenAI":false}',
+ )
+
+ result = service.create_plan(
+ conversation_id="conv-1",
+ user_id=7,
+ username="tester",
+ entity_id=5,
+ )
+
+ assert result["ok"] is False
+ assert result["status"] == 422
+ assert assembly_store.created == []
+ assert result["validation"]["issues"] == ["bench_plan_not_grounded"]
+
+
+def test_openai_escalation_runs_after_low_confidence_local_review():
+ openai = OpenAi(result={**valid_card(), "provider": "openai", "model": "gpt-5-chat-latest", "useful": True})
+ service, assembly_store, _ai_store = make_service(
+ conversation_with_snapshot(build_card=None),
+ local_response='{"useful":true,"confidence":0.31,"reason":"ambiguous","escalateToOpenAI":true}',
+ openai=openai,
+ )
+
+ result = service.create_plan(
+ conversation_id="conv-1",
+ user_id=7,
+ username="tester",
+ entity_id=None,
+ )
+
+ assert result["ok"] is True
+ assert result["source"] == "openai"
+ assert len(openai.calls) == 1
+ assert openai.calls[0]["enabled"] is True
+ assert assembly_store.created[0]["card"]["componentName"] == "NE555"
diff --git a/tests/test_openai_assist_service.py b/tests/test_openai_assist_service.py
index 5bef472..049ce8d 100644
--- a/tests/test_openai_assist_service.py
+++ b/tests/test_openai_assist_service.py
@@ -249,3 +249,44 @@ def test_bench_photo_verification_records_photo_check_usage(monkeypatch):
assert store.events[0]["task_type"] == "photo_check"
assert store.events[0]["context_type"] == "bench_photo_verification"
assert store.events[0]["estimated_cost"] == 0.0123
+
+
+def test_conversation_bench_plan_synthesis_records_assembly_plan_usage(monkeypatch):
+ store = IngestionAssistStore()
+ service = OpenAIAssistService(store)
+
+ monkeypatch.setattr(
+ service.tasks,
+ "_create_response",
+ lambda **_kwargs: {
+ "output_text": (
+ '{"title":"555 LED flasher","componentName":"NE555","componentType":"timer",'
+ '"summary":"Blink an LED.","confidence":0.74,'
+ '"parts":[{"name":"NE555 timer IC","detail":"DIP"}],'
+ '"power":["Use 5V."],'
+ '"wiring":[{"from":"Pin 1 GND","to":"Ground rail","note":"Common ground","page":3},'
+ '{"from":"Pin 8 VCC","to":"5V rail","note":"Power","page":3}],'
+ '"checks":["Verify polarity"],"warnings":["Confirm pinout"],'
+ '"sourceNotes":[{"source":"training/ne555.pdf","pages":[3],"chunks":2}],'
+ '"useful":true,"escalateToOpenAI":false,"reason":"grounded"}'
+ ),
+ "usage": {"input_tokens": 130, "output_tokens": 45, "input_tokens_details": {"cached_tokens": 6}},
+ },
+ )
+
+ result = service.synthesize_conversation_bench_plan(
+ objective="Build a 555 LED flasher",
+ conversation={"id": "conv-1", "title": "555", "turns": [{"question": "build it", "answer": "use pins"}]},
+ source_payload=[{"source": "training/ne555.pdf", "displayName": "NE555", "pages": [3], "chunkCount": 2}],
+ local_review={"useful": True, "confidence": 0.31},
+ entity_id=4,
+ user_id=7,
+ enabled=True,
+ decision_reason="local Ask-to-Bench synthesis requested OpenAI escalation",
+ )
+
+ assert result["componentName"] == "NE555"
+ assert result["estimatedCost"] == 0.0123
+ assert store.events[0]["task_type"] == "assembly_plan"
+ assert store.events[0]["context_type"] == "conversation_to_bench"
+ assert store.events[0]["context_id"] == "conv-1"