diff --git a/backend/api/assembly_plans.py b/backend/api/assembly_plans.py index f72cb2e..38a3780 100644 --- a/backend/api/assembly_plans.py +++ b/backend/api/assembly_plans.py @@ -16,6 +16,7 @@ from backend.services.assembly_plan_build_service import AssemblyPlanBuildService from backend.services.circuit_graph import build_circuit_graph from backend.services.circuit_graph_ai import CircuitGraphAiEnrichmentService +from backend.services.conversation_bench_plan_service import ConversationBenchPlanService from backend.services.ingestion_ai_review_service import estimate_local_tokens from backend.services.openai_assist_utils import parse_json_object @@ -29,6 +30,11 @@ class AssemblyBuildRequest(BaseModel): strategy: str = "Vector + CrossEncoder" +class ConversationBenchPlanRequest(BaseModel): + conversationId: str = "" + objective: str = "" + + class StepUpdateRequest(BaseModel): completed: bool = False @@ -47,6 +53,7 @@ def create_router( deps: ApiDependencies, *, assembly_plan_store: Any, + conversation_store: Any, bench_tools: Any, openai_assist_service: Any | None, get_rag_response: Callable[..., Any], @@ -57,6 +64,7 @@ def create_router( recovery_system_prompt: str, default_model: str, username_for_user: Callable[[Any], str | None], + trace_logger: Any = None, ) -> APIRouter: router = APIRouter() build_service = AssemblyPlanBuildService( @@ -73,6 +81,16 @@ def create_router( openai_assist_service=openai_assist_service, query_local_llm=query_ollama_chat_with_retry, local_model_name=default_model, + trace_logger=trace_logger, + ) + conversation_bench_service = ConversationBenchPlanService( + conversation_store=conversation_store, + assembly_plan_store=assembly_plan_store, + ai_provider_store=deps.ai_provider_store, + openai_assist_service=openai_assist_service, + query_local_llm=query_ollama_chat_with_retry, + local_model_name=default_model, + trace_logger=trace_logger, ) @router.get("/api/assembly-plans") @@ -148,6 +166,39 @@ async def assembly_plan_build(req: Request, payload: AssemblyBuildRequest): "validation": result.get("validation"), } + @router.post("/api/assembly-plans/from-conversation") + async def assembly_plan_from_conversation(req: Request, payload: ConversationBenchPlanRequest): + user, entity, error = deps.require_entity_member(req) + if error: + return error + conversation_id = payload.conversationId.strip() + if not conversation_id: + return JSONResponse({"error": "Conversation id is required."}, status_code=400) + user_id = deps.user_id_for_user(user) + result = await run_in_threadpool( + conversation_bench_service.create_plan, + conversation_id=conversation_id, + user_id=user_id, + username=username_for_user(user), + entity_id=entity.entity_id, + objective_override=payload.objective, + ) + if not result.get("ok"): + return JSONResponse( + { + "error": result.get("error") or "Conversation could not be converted into a Bench plan.", + "aiReview": result.get("aiReview"), + "validation": result.get("validation"), + }, + status_code=int(result.get("status") or 422), + ) + return { + "plan": result.get("plan"), + "source": result.get("source"), + "aiReview": result.get("aiReview"), + "validation": result.get("validation"), + } + @router.patch("/api/assembly-plans/{plan_id}/steps/{step_id}") async def assembly_step_update(plan_id: str, step_id: str, req: Request, payload: StepUpdateRequest): user, error = deps.require_authenticated_user(req) diff --git a/backend/app_factory.py b/backend/app_factory.py index c79ece5..b98410e 100644 --- a/backend/app_factory.py +++ b/backend/app_factory.py @@ -125,6 +125,7 @@ def register_api_routes( app.include_router(assembly_plans_api.create_router( api_dependencies, assembly_plan_store=assembly_plan_store, + conversation_store=conversation_store, bench_tools=bench_tools, openai_assist_service=openai_assist_service, get_rag_response=get_rag_response, @@ -135,6 +136,7 @@ def register_api_routes( recovery_system_prompt=recovery_system_prompt, default_model=default_model, username_for_user=username_for_user, + trace_logger=trace_logger, )) app.include_router(documents_api.create_router( api_dependencies, diff --git a/backend/services/conversation_bench_plan_service.py b/backend/services/conversation_bench_plan_service.py new file mode 100644 index 0000000..641c130 --- /dev/null +++ b/backend/services/conversation_bench_plan_service.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +import json +import time +from collections import OrderedDict +from typing import Any, Callable + +from backend.services.circuit_build_recovery import parse_recovered_build_card +from backend.services.ingestion_ai_review_service import estimate_local_tokens, optional_float +from backend.services.openai_assist_prompts import ( + LOCAL_CONVERSATION_BENCH_PLAN_SYSTEM_PROMPT, + build_conversation_bench_plan_prompt, +) +from backend.services.openai_assist_utils import parse_json_object + + +class ConversationBenchPlanService: + def __init__( + self, + *, + conversation_store: Any, + assembly_plan_store: Any, + ai_provider_store: Any, + openai_assist_service: Any | None, + query_local_llm: Callable[..., str] | None, + local_model_name: str | None, + trace_logger: Any = None, + ): + self.conversation_store = conversation_store + self.assembly_plan_store = assembly_plan_store + self.ai_provider_store = ai_provider_store + self.openai_assist_service = openai_assist_service + self.query_local_llm = query_local_llm + self.local_model_name = local_model_name + self.trace_logger = trace_logger + + def create_plan( + self, + *, + conversation_id: str, + user_id: int, + username: str | None, + entity_id: int | None, + objective_override: str = "", + ) -> dict[str, Any]: + conversation = self.conversation_store.get(conversation_id, user_id) + if not conversation: + return {"ok": False, "status": 404, "error": "Conversation not found."} + if not conversation.get("turns"): + return { + "ok": False, + "status": 422, + "error": "Conversation has no Ask turns to convert into a Bench plan.", + "validation": _validation("no_conversation_turns", "Ask a concrete circuit question before creating a Bench project."), + } + + objective = _objective_for_conversation(conversation, objective_override) + source_payload = extract_source_payload(conversation) + card, ai_review = self._card_from_existing_snapshot(conversation, source_payload) + source = "conversation_build_card" + if not card: + local_review = self._run_local_synthesis(objective=objective, conversation=conversation, source_payload=source_payload) + if local_review: + self._record_local_event( + local_review, + conversation_id=conversation_id, + entity_id=entity_id, + user_id=user_id, + decision_reason=f"Local Ask-to-Bench synthesis ran for conversation {conversation_id}.", + ) + card = self._card_from_ai_review(local_review, source_payload) + ai_review = normalize_conversation_plan_review(local_review) + source = "local_ai" + if not card and self._should_escalate_to_openai(local_review): + openai_review = self._run_openai_synthesis( + objective=objective, + conversation=conversation, + source_payload=source_payload, + local_review=ai_review, + entity_id=entity_id, + user_id=user_id, + ) + card = self._card_from_ai_review(openai_review, source_payload) + ai_review = normalize_conversation_plan_review(openai_review) + source = "openai" + + if not card: + return { + "ok": False, + "status": 422, + "error": "Conversation does not contain enough grounded build detail for a Bench plan.", + "aiReview": ai_review, + "validation": _validation( + "bench_plan_not_grounded", + "Ask for exact parts, values, power, and pin-by-pin wiring or add sources before converting this conversation.", + ), + } + + card = ensure_conversation_card_warnings(card, source) + plan = self.assembly_plan_store.create_from_card( + question=objective, + card=card, + user_id=user_id, + created_by=username, + ) + return { + "ok": True, + "plan": plan, + "source": source, + "aiReview": ai_review, + "validation": { + "enabled": True, + "ran": True, + "useful": True, + "changed": False, + "confidence": card.get("confidence"), + "issues": card.get("warnings") or [], + "notes": [f"Bench plan created from Ask conversation {conversation_id}."], + "elapsedMs": 0, + "model": ai_review.get("model") if isinstance(ai_review, dict) else None, + }, + } + + def _card_from_existing_snapshot(self, conversation: dict[str, Any], source_payload: list[dict[str, Any]]) -> tuple[dict | None, dict | None]: + for turn in reversed(conversation.get("turns") or []): + snapshot = turn.get("responseSnapshot") or {} + build_card = snapshot.get("buildCard") + if isinstance(build_card, dict): + card = parse_recovered_build_card(json.dumps(build_card), source_payload) + if card: + return card, { + "provider": "snapshot", + "useful": True, + "confidence": card.get("confidence"), + "summary": "Used build card already produced by Ask.", + "reason": "conversation snapshot contained a valid build card", + } + return None, None + + def _run_local_synthesis( + self, + *, + objective: str, + conversation: dict[str, Any], + source_payload: list[dict[str, Any]], + ) -> dict[str, Any] | None: + if not self.query_local_llm or not self.local_model_name: + return None + system_prompt = LOCAL_CONVERSATION_BENCH_PLAN_SYSTEM_PROMPT + prompt = build_conversation_bench_plan_prompt( + objective=objective, + conversation=conversation, + source_payload=source_payload, + ) + started_at = time.time() + try: + raw = self.query_local_llm( + prompt, + self.local_model_name, + chat_history=[], + system_prompt=system_prompt, + gpu_priority=85, + gpu_owner="ask-to-bench", + gpu_resource_class="local_llm", + keep_alive=0, + ) + parsed = parse_json_object(raw) + parsed["raw"] = str(raw or "")[:4000] + parsed["_inputTokenEstimate"] = estimate_local_tokens(system_prompt) + estimate_local_tokens(prompt) + parsed["_outputTokenEstimate"] = estimate_local_tokens(raw) + parsed["_latencyMs"] = int((time.time() - started_at) * 1000) + parsed["_success"] = True + parsed["provider"] = "ollama" + parsed["model"] = self.local_model_name + return parsed + except Exception as exc: + if self.trace_logger: + self.trace_logger.warning(f"Local Ask-to-Bench synthesis failed: {exc}") + return { + "useful": False, + "confidence": 0.0, + "warnings": ["Local AI could not synthesize a Bench plan."], + "escalateToOpenAI": True, + "reason": "local Ask-to-Bench synthesis failed", + "raw": "", + "_inputTokenEstimate": 0, + "_outputTokenEstimate": 0, + "_latencyMs": int((time.time() - started_at) * 1000), + "_success": False, + "_errorMessage": str(exc)[:240], + "provider": "ollama", + "model": self.local_model_name, + } + + def _record_local_event( + self, + result: dict[str, Any], + *, + conversation_id: str, + entity_id: int | None, + user_id: int | None, + decision_reason: str, + ) -> None: + if not self.ai_provider_store or not hasattr(self.ai_provider_store, "record_ai_assist_event"): + return + paid_by = "entity" if entity_id is not None else ("user" if user_id is not None else "unknown") + self.ai_provider_store.record_ai_assist_event( + entity_id=entity_id, + user_id=user_id, + provider="ollama", + task_type="assembly_plan", + model_name=self.local_model_name or "local", + context_type="conversation_to_bench", + context_id=conversation_id, + input_tokens=int(result.get("_inputTokenEstimate") or 0), + output_tokens=int(result.get("_outputTokenEstimate") or 0), + estimated_cost=0.0, + paid_by=paid_by, + success=bool(result.get("_success", True)), + error_message=result.get("_errorMessage"), + decision_reason=decision_reason, + latency_ms=int(result.get("_latencyMs") or 0), + ) + + def _run_openai_synthesis( + self, + *, + objective: str, + conversation: dict[str, Any], + source_payload: list[dict[str, Any]], + local_review: dict[str, Any] | None, + entity_id: int | None, + user_id: int | None, + ) -> dict[str, Any] | None: + if not self.openai_assist_service or not hasattr(self.openai_assist_service, "synthesize_conversation_bench_plan"): + return None + return self.openai_assist_service.synthesize_conversation_bench_plan( + objective=objective, + conversation=conversation, + source_payload=source_payload, + local_review=local_review, + entity_id=entity_id, + user_id=user_id, + enabled=True, + decision_reason="Local Ask-to-Bench synthesis could not produce a grounded plan.", + ) + + def _card_from_ai_review(self, review: dict[str, Any] | None, source_payload: list[dict[str, Any]]) -> dict | None: + if not isinstance(review, dict) or review.get("useful") is False: + return None + return parse_recovered_build_card(json.dumps(review), source_payload) + + @staticmethod + def _should_escalate_to_openai(local_review: dict[str, Any] | None) -> bool: + if not local_review: + return False + confidence = optional_float(local_review.get("confidence")) or 0.0 + return bool(local_review.get("escalateToOpenAI")) or (bool(local_review.get("useful", True)) and confidence < 0.65) + + +def extract_source_payload(conversation: dict[str, Any]) -> list[dict[str, Any]]: + sources: OrderedDict[str, dict[str, Any]] = OrderedDict() + for turn in conversation.get("turns") or []: + snapshot = turn.get("responseSnapshot") or {} + for source in snapshot.get("sources") or []: + key = str(source.get("source") or source.get("displayName") or "") + if not key: + continue + existing = sources.get(key) + if not existing: + sources[key] = { + "source": source.get("source") or key, + "displayName": source.get("displayName") or key, + "pages": [], + "chunkCount": int(source.get("chunkCount") or source.get("chunks") or 0), + "chunks": [], + } + existing = sources[key] + for page in source.get("pages") or []: + try: + page_number = int(page) + except (TypeError, ValueError): + continue + if page_number > 0 and page_number not in existing["pages"]: + existing["pages"].append(page_number) + for chunk in source.get("chunks") or []: + if isinstance(chunk, dict) and len(existing["chunks"]) < 6: + existing["chunks"].append(chunk) + existing["chunkCount"] = max(existing["chunkCount"], int(source.get("chunkCount") or source.get("chunks") or 0)) + return list(sources.values()) + + +def ensure_conversation_card_warnings(card: dict[str, Any], source: str) -> dict[str, Any]: + card = dict(card) + warnings = list(card.get("warnings") or []) + if source != "conversation_build_card": + warnings.append("Generated from Ask conversation; verify source evidence before powering the circuit.") + if len(card.get("wiring") or []) < 2: + warnings.append("Bench plan has limited wiring detail; ask for pin-by-pin steps before building.") + card["warnings"] = _dedupe_strings(warnings) + return card + + +def normalize_conversation_plan_review(raw: dict[str, Any] | None) -> dict[str, Any] | None: + if not isinstance(raw, dict): + return None + return { + "provider": str(raw.get("provider") or ""), + "model": raw.get("model"), + "useful": bool(raw.get("useful", True)), + "confidence": max(0.0, min(optional_float(raw.get("confidence")) or 0.0, 1.0)), + "summary": str(raw.get("summary") or "")[:800], + "warnings": [str(item)[:500] for item in (raw.get("warnings") or [])[:20]], + "escalateToOpenAI": bool(raw.get("escalateToOpenAI", False)), + "reason": str(raw.get("reason") or "")[:600], + "estimatedCost": raw.get("estimatedCost"), + "paidBy": raw.get("paidBy"), + } + + +def _objective_for_conversation(conversation: dict[str, Any], objective_override: str) -> str: + cleaned = " ".join(str(objective_override or "").split()) + if cleaned: + return cleaned[:600] + turns = conversation.get("turns") or [] + for turn in reversed(turns): + question = " ".join(str(turn.get("question") or "").split()) + if question: + return question[:600] + return str(conversation.get("title") or "Ask conversation Bench project")[:600] + + +def _validation(code: str, message: str) -> dict[str, Any]: + return { + "enabled": True, + "ran": True, + "useful": False, + "changed": False, + "confidence": 0.0, + "issues": [code], + "notes": [message], + "elapsedMs": 0, + "model": None, + } + + +def _dedupe_strings(items: list[str]) -> list[str]: + result = [] + seen = set() + for item in items: + text = " ".join(str(item or "").split()) + if text and text not in seen: + result.append(text) + seen.add(text) + return result[:12] diff --git a/backend/services/openai_assist_prompts.py b/backend/services/openai_assist_prompts.py index 77b552d..85635ef 100644 --- a/backend/services/openai_assist_prompts.py +++ b/backend/services/openai_assist_prompts.py @@ -64,6 +64,17 @@ "Use only the provided assembly plan and graph. Do not invent unsupported electronics facts." ) +LOCAL_CONVERSATION_BENCH_PLAN_SYSTEM_PROMPT = ( + "You are CircuitShelf's local Ask-to-Bench plan synthesizer. Return strict JSON only. " + "Use only the conversation transcript and retrieved source summaries. Be conservative: " + "do not invent pins, voltages, component values, or safety-critical steps." +) + +CONVERSATION_BENCH_PLAN_INSTRUCTIONS = ( + "You are CircuitShelf's Ask-to-Bench plan synthesizer. Return strict JSON only. " + "Use only the conversation transcript and retrieved source summaries. Do not invent unsupported electronics facts." +) + LOCAL_PROJECT_FINDER_TRIAGE_SYSTEM_PROMPT = ( "You are CircuitShelf's local Project Finder triage reviewer. Return compact JSON only. " "Review whether low-confidence electronics project candidates are useful and grounded. " @@ -270,3 +281,74 @@ def build_circuit_graph_enrichment_prompt( f"Current graph: {json.dumps(compact_graph, sort_keys=True)[:12000]}\n\n" f"Local review: {json.dumps(local_review or {}, sort_keys=True)[:5000]}" ) + + +def build_conversation_bench_plan_prompt( + *, + objective: str, + conversation: dict[str, Any], + source_payload: list[dict[str, Any]], + local_review: dict[str, Any] | None = None, +) -> str: + turns = [] + for turn in (conversation.get("turns") or [])[-10:]: + turns.append( + { + "ordinal": turn.get("ordinal"), + "question": str(turn.get("question") or "")[:1800], + "answer": str(turn.get("answer") or "")[:2400], + "confidence": turn.get("confidence"), + } + ) + sources = [] + for source in (source_payload or [])[:12]: + chunks = [] + for chunk in source.get("chunks") or []: + chunks.append( + { + "page": chunk.get("page"), + "section": chunk.get("section"), + "preview": str(chunk.get("preview") or "")[:420], + } + ) + sources.append( + { + "source": source.get("source"), + "displayName": source.get("displayName"), + "pages": source.get("pages") or [], + "chunkCount": source.get("chunkCount") or source.get("chunks") or 0, + "chunks": chunks[:4], + } + ) + payload = { + "objective": objective, + "conversationTitle": conversation.get("title"), + "turns": turns, + "sources": sources, + "localReview": local_review or {}, + "schema": { + "title": "short bench project title", + "componentName": "main component or project family", + "componentType": "component category", + "summary": "one paragraph", + "confidence": 0.0, + "parts": [{"name": "part", "detail": "why/value/package"}], + "power": ["power note"], + "wiring": [{"from": "pin/component/rail", "to": "pin/component/rail", "note": "specific instruction", "page": None}], + "checks": ["verification step"], + "warnings": ["safety, evidence, or uncertainty warning"], + "sourceNotes": [{"source": "source path/name", "pages": [1], "chunks": 1}], + "useful": True, + "escalateToOpenAI": False, + "reason": "short reason", + }, + } + return ( + "Create one CircuitShelf Bench assembly plan JSON object from this Ask conversation. " + "Only produce a plan when the conversation contains a concrete low-voltage electronics project or wiring objective. " + "Prefer pin-by-pin wiring only when the conversation or source evidence supports it. " + "If required pins, component values, or safety-critical information are missing, include warnings and checks. " + "If a useful plan cannot be formed, return JSON with useful=false, escalateToOpenAI as appropriate, and reason. " + "Do not use a fixed recipe for any specific chip unless the conversation calls for it.\n\n" + f"{json.dumps(payload, ensure_ascii=False)}" + ) diff --git a/backend/services/openai_assist_service.py b/backend/services/openai_assist_service.py index b12881c..301ced5 100644 --- a/backend/services/openai_assist_service.py +++ b/backend/services/openai_assist_service.py @@ -32,3 +32,6 @@ def triage_project_finder(self, **kwargs): def enrich_circuit_graph(self, **kwargs): return self.tasks.enrich_circuit_graph(**kwargs) + + def synthesize_conversation_bench_plan(self, **kwargs): + return self.tasks.synthesize_conversation_bench_plan(**kwargs) diff --git a/backend/services/openai_assist_tasks.py b/backend/services/openai_assist_tasks.py index 2dc1c21..1f8416e 100644 --- a/backend/services/openai_assist_tasks.py +++ b/backend/services/openai_assist_tasks.py @@ -8,6 +8,7 @@ ANSWER_VALIDATION_INSTRUCTIONS, BENCH_PHOTO_VERIFICATION_INSTRUCTIONS, CIRCUIT_GRAPH_ENRICHMENT_INSTRUCTIONS, + CONVERSATION_BENCH_PLAN_INSTRUCTIONS, DATASHEET_REPAIR_INSTRUCTIONS, FALLBACK_ANSWER_INSTRUCTIONS, INGESTION_REVIEW_INSTRUCTIONS, @@ -15,6 +16,7 @@ PROJECT_FINDER_TRIAGE_INSTRUCTIONS, build_bench_photo_verification_prompt, build_circuit_graph_enrichment_prompt, + build_conversation_bench_plan_prompt, build_datasheet_repair_prompt, build_fallback_answer_prompt, build_ingestion_review_prompt, @@ -570,6 +572,64 @@ def enrich_circuit_graph( self.logger.warning(f"OpenAI circuit graph enrichment failed: {message}") return None + def synthesize_conversation_bench_plan( + self, + *, + objective: str, + conversation: dict[str, Any], + source_payload: list[dict[str, Any]], + local_review: dict[str, Any] | None, + entity_id: int | None, + user_id: int | None, + enabled: bool, + decision_reason: str, + ) -> dict[str, Any] | None: + if not enabled: + return None + settings = self.ai_provider_store.resolve_openai_assist(entity_id=entity_id, user_id=user_id) + if not settings or not settings.get("apiKey") or settings.get("assistMode") == "off": + return None + event_base = self._assist_event_base( + settings=settings, + entity_id=entity_id, + user_id=user_id, + task_type="assembly_plan", + context_type="conversation_to_bench", + context_id=str(conversation.get("id") or ""), + decision_reason=decision_reason, + ) + if self._record_budget_block_if_needed(event_base, settings): + return None + started_at = time.time() + try: + data = self._create_response( + api_key=settings["apiKey"], + model=settings["modelName"], + instructions=CONVERSATION_BENCH_PLAN_INSTRUCTIONS, + input_text=build_conversation_bench_plan_prompt( + objective=objective, + conversation=conversation, + source_payload=source_payload, + local_review=local_review, + ), + max_output_tokens=1800, + ) + usage = extract_usage(data) + estimated_cost = self._estimate_openai_cost(settings, usage) + parsed = parse_json_object(extract_response_text(data)) + self._record_ai_success(event_base, usage, estimated_cost, latency_ms=int((time.time() - started_at) * 1000)) + parsed["provider"] = "openai" + parsed["model"] = settings["modelName"] + parsed["paidBy"] = settings["paidBy"] + parsed["estimatedCost"] = estimated_cost + return parsed + except Exception as exc: + message = safe_error_message(exc) + self._record_ai_failure(event_base, message, latency_ms=int((time.time() - started_at) * 1000)) + if self.logger: + self.logger.warning(f"OpenAI Ask-to-Bench synthesis failed: {message}") + return None + def _create_response( self, *, diff --git a/frontend/src/components/AppViewRouter.tsx b/frontend/src/components/AppViewRouter.tsx index 4465a99..ce4ed1e 100644 --- a/frontend/src/components/AppViewRouter.tsx +++ b/frontend/src/components/AppViewRouter.tsx @@ -53,7 +53,7 @@ export function AppViewRouter({ return ( <> - +

Loading view...

}> diff --git a/frontend/src/components/AskView.tsx b/frontend/src/components/AskView.tsx index fd22ba5..f7c30ed 100644 --- a/frontend/src/components/AskView.tsx +++ b/frontend/src/components/AskView.tsx @@ -1,11 +1,11 @@ import { useAskController } from "../hooks/useAskController"; -import type { AppConfig } from "../types"; +import type { AppConfig, View } from "../types"; import { AskAnswerPanel } from "./ask/AskAnswerPanel"; import { AskConversationHistoryPanel } from "./ask/AskConversationHistoryPanel"; import { AskQuestionPanel } from "./ask/AskQuestionPanel"; import { AskSourcesPanel } from "./ask/AskSourcesPanel"; -export function AskView({ config, isActive }: { config: AppConfig; isActive: boolean }) { +export function AskView({ config, isActive, setActiveView }: { config: AppConfig; isActive: boolean; setActiveView: (view: View) => void }) { const ask = useAskController({ config, isActive }); return ( @@ -25,7 +25,13 @@ export function AskView({ config, isActive }: { config: AppConfig; isActive: boo onSubmit={(event) => void ask.submit(event)} /> - + void ask.createBenchProjectFromConversation(() => setActiveView("bench"))} + /> diff --git a/frontend/src/components/ask/AskAnswerPanel.tsx b/frontend/src/components/ask/AskAnswerPanel.tsx index a13d1bd..9533769 100644 --- a/frontend/src/components/ask/AskAnswerPanel.tsx +++ b/frontend/src/components/ask/AskAnswerPanel.tsx @@ -5,13 +5,33 @@ import { BuildCard } from "../BuildCard"; import { ResponseValidationPanel } from "../ResponseValidationPanel"; import { SectionHeader } from "../SectionHeader"; -export function AskAnswerPanel({ result }: { result: QueryResponse | null }) { +export function AskAnswerPanel({ + result, + canCreateBenchProject, + creatingBenchProject, + createBenchProjectMessage, + onCreateBenchProject +}: { + result: QueryResponse | null; + canCreateBenchProject: boolean; + creatingBenchProject: boolean; + createBenchProjectMessage: string; + onCreateBenchProject: () => void; +}) { return (
+ {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"