Spaces:
Running on Zero
fix: Enforce parameter provenance, fix rendering/citations, add tools, harden agent loop
Browse filesReliability mechanism (the big one):
- Orchestrator now refuses any tool call whose matrix arguments cannot be
traced to the user's messages or a prior tool result (FabricatedParameter):
prompting could not stop the model from inventing B/Q/R, code now does.
- Per-tool call cap (2/turn) stops reworded-search budget exhaustion.
- Truncated tool-call JSON (dropped closing brace) is repaired instead of
silently producing an empty answer; orphaned <tool_call> tags stripped.
- Failed turns fall back to a direct no-tools answer, never a canned line.
Tools:
- New matrix_arithmetic (multiply/inverse/det/rank/eig/...): basic linear
algebra is a verified tool call, not freehand model code.
- New nyquist_analysis (Z=N+P), root_locus_analysis (asymptotes, on-locus
breakaway points, critical gain), simulate_state_feedback_response
(closed loop A-BK formed internally; ends hand-expanded coefficients).
- place_state_feedback accepts complex-conjugate poles ([re, im] pairs);
dtype=float truncation bug fixed.
- Registry: stringified-array arguments coerced before schema validation;
all float results rounded to 6 significant figures.
- Registered tools are callable by name inside execute_python_code.
- plot_math_expression rejects complex expressions instead of plotting
garbage; simulate_step_response finally emits plot_path (plots never
reached the UI before).
Rendering & citations:
- Double-backslash LaTeX repair extended to delimiters (\\| \\{ \\() while
preserving real matrix row breaks; system prompt de-doubled.
- RAG citations use cleaned source names (published works by author/title,
personal lecture notes labeled generically) -- raw filenames never shown.
RAG:
- Process-wide shared index: uploads are searchable immediately, no restart.
- Source-aware re-ranking ("what does Nise say" surfaces Nise, 1/5 -> 4/4).
- Grounding block enlarged (4 passages, 900 chars) with trust-the-passage
and citation-format instructions.
Web UI:
- Deleting a non-active chat no longer wipes the active thread mid-stream.
- Animated working indicator; font-size preference now scales code blocks,
thought text, and plot captions.
Infra:
- MLX thread-affinity fix: all inference on one dedicated executor thread
(fixes "There is no Stream(cpu, 0) in current thread" on every request).
- Broader multi-domain system prompt (aerospace/automotive/robotics/
automation/power) with answer-shape routing rules.
Scripts:
- test_rag_knowledge.py: 25-query multi-domain retrieval suite (25/25).
- eval_answer_quality.py: 18-question full-agent quality gate.
- generate_behavior_sft_dataset.py + config: behavioral SFT round v1
(trained; failed A/B -- reduced tool usage -- so NOT deployed; kept for
the next data-mixture iteration).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- app.py +48 -15
- configs/lora_controlai_behavior_v1.yaml +60 -0
- controlai_agent/orchestrator.py +450 -30
- controlai_agent/prompts.py +26 -7
- controlai_agent/registry.py +88 -2
- controlai_agent/tools/__init__.py +2 -0
- controlai_agent/tools/frequency.py +219 -0
- controlai_agent/tools/matrix_ops.py +116 -0
- controlai_agent/tools/plotting.py +53 -5
- controlai_agent/tools/python_executor.py +28 -2
- controlai_agent/tools/rag.py +8 -5
- controlai_agent/tools/simulation.py +172 -21
- controlai_agent/tools/synthesis.py +31 -8
- controlai_rag/index.py +185 -6
- scripts/eval_answer_quality.py +150 -0
- scripts/generate_behavior_sft_dataset.py +429 -0
- scripts/test_rag_knowledge.py +108 -0
- web/app.css +22 -4
- web/app.js +20 -8
- web/index.html +2 -2
|
@@ -2,12 +2,14 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import json
|
| 6 |
import os
|
|
|
|
| 7 |
import shutil
|
| 8 |
import sys
|
| 9 |
-
import threading
|
| 10 |
import time
|
|
|
|
| 11 |
from contextlib import asynccontextmanager
|
| 12 |
from pathlib import Path
|
| 13 |
from typing import Any
|
|
@@ -27,12 +29,24 @@ if str(PROJECT_ROOT) not in sys.path:
|
|
| 27 |
from controlai_agent.orchestrator import ControlAIAgent
|
| 28 |
from controlai_rag.chunker import chunk_document
|
| 29 |
from controlai_rag.document_loader import load_single_file
|
| 30 |
-
from controlai_rag.index import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
@asynccontextmanager
|
| 33 |
async def lifespan(app: FastAPI):
|
| 34 |
print("Pre-loading ControlAI Core Engine on startup...")
|
| 35 |
-
|
| 36 |
print("ControlAI Core Engine is online and ready for traffic.")
|
| 37 |
yield
|
| 38 |
|
|
@@ -64,9 +78,6 @@ if STATIC_DIR.exists():
|
|
| 64 |
|
| 65 |
# Initialize Agent
|
| 66 |
agent_instance: ControlAIAgent | None = None
|
| 67 |
-
# llama.cpp's Llama object is not safe for concurrent generation calls from
|
| 68 |
-
# multiple threads; serialize every request through the single model instance.
|
| 69 |
-
inference_lock = threading.Lock()
|
| 70 |
|
| 71 |
|
| 72 |
def get_agent() -> ControlAIAgent:
|
|
@@ -179,8 +190,10 @@ async def upload_document(file: UploadFile = File(...)) -> dict[str, Any]:
|
|
| 179 |
chunks = chunk_document(p)
|
| 180 |
new_chunks.extend(chunks)
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
| 184 |
index.add_chunks(new_chunks)
|
| 185 |
|
| 186 |
return {
|
|
@@ -202,13 +215,33 @@ async def chat_stream_endpoint(req: ChatRequest):
|
|
| 202 |
if not req.message.strip():
|
| 203 |
raise HTTPException(status_code=400, detail="Message cannot be empty")
|
| 204 |
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
try:
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
| 210 |
except Exception as exc:
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
return StreamingResponse(
|
| 214 |
event_generator(),
|
|
@@ -228,8 +261,8 @@ async def chat_endpoint(req: ChatRequest) -> ChatResponse:
|
|
| 228 |
|
| 229 |
t0 = time.time()
|
| 230 |
try:
|
| 231 |
-
|
| 232 |
-
|
| 233 |
elapsed = time.time() - t0
|
| 234 |
|
| 235 |
# Collect tool traces
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import asyncio
|
| 6 |
import json
|
| 7 |
import os
|
| 8 |
+
import queue
|
| 9 |
import shutil
|
| 10 |
import sys
|
|
|
|
| 11 |
import time
|
| 12 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 13 |
from contextlib import asynccontextmanager
|
| 14 |
from pathlib import Path
|
| 15 |
from typing import Any
|
|
|
|
| 29 |
from controlai_agent.orchestrator import ControlAIAgent
|
| 30 |
from controlai_rag.chunker import chunk_document
|
| 31 |
from controlai_rag.document_loader import load_single_file
|
| 32 |
+
from controlai_rag.index import get_shared_index
|
| 33 |
+
|
| 34 |
+
# MLX keeps its compute stream in thread-local state: the model must be
|
| 35 |
+
# loaded on the exact same OS thread that later runs generation, or MLX
|
| 36 |
+
# raises "There is no Stream(cpu, 0) in current thread." Starlette's default
|
| 37 |
+
# StreamingResponse threadpool picks a different worker thread per request,
|
| 38 |
+
# which breaks that invariant. Routing every agent call through this single
|
| 39 |
+
# dedicated worker thread keeps MLX on one consistent thread for the whole
|
| 40 |
+
# process lifetime, and -- as a bonus -- a max_workers=1 executor naturally
|
| 41 |
+
# serializes every request through the one model instance (llama.cpp's Llama
|
| 42 |
+
# object also isn't safe for concurrent calls from multiple threads).
|
| 43 |
+
inference_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="controlai-inference")
|
| 44 |
+
|
| 45 |
|
| 46 |
@asynccontextmanager
|
| 47 |
async def lifespan(app: FastAPI):
|
| 48 |
print("Pre-loading ControlAI Core Engine on startup...")
|
| 49 |
+
await asyncio.get_event_loop().run_in_executor(inference_executor, get_agent)
|
| 50 |
print("ControlAI Core Engine is online and ready for traffic.")
|
| 51 |
yield
|
| 52 |
|
|
|
|
| 78 |
|
| 79 |
# Initialize Agent
|
| 80 |
agent_instance: ControlAIAgent | None = None
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
|
| 83 |
def get_agent() -> ControlAIAgent:
|
|
|
|
| 190 |
chunks = chunk_document(p)
|
| 191 |
new_chunks.extend(chunks)
|
| 192 |
|
| 193 |
+
# Mutating the shared index makes the upload live for the running
|
| 194 |
+
# agent immediately -- a fresh instance would only persist to disk and
|
| 195 |
+
# stay invisible until restart.
|
| 196 |
+
index = get_shared_index()
|
| 197 |
index.add_chunks(new_chunks)
|
| 198 |
|
| 199 |
return {
|
|
|
|
| 215 |
if not req.message.strip():
|
| 216 |
raise HTTPException(status_code=400, detail="Message cannot be empty")
|
| 217 |
|
| 218 |
+
message = req.message.strip()
|
| 219 |
+
history = req.history
|
| 220 |
+
event_queue: queue.Queue = queue.Queue()
|
| 221 |
+
_DONE = object()
|
| 222 |
+
|
| 223 |
+
def _produce() -> None:
|
| 224 |
+
# Runs entirely on inference_executor's single dedicated thread --
|
| 225 |
+
# the same thread the model was loaded on -- so MLX's thread-local
|
| 226 |
+
# stream stays valid.
|
| 227 |
try:
|
| 228 |
+
for event in _run_stream_on_gpu(message, history):
|
| 229 |
+
event_queue.put(event)
|
|
|
|
| 230 |
except Exception as exc:
|
| 231 |
+
event_queue.put({"type": "error", "error": str(exc)})
|
| 232 |
+
finally:
|
| 233 |
+
event_queue.put(_DONE)
|
| 234 |
+
|
| 235 |
+
async def event_generator():
|
| 236 |
+
loop = asyncio.get_event_loop()
|
| 237 |
+
loop.run_in_executor(inference_executor, _produce)
|
| 238 |
+
while True:
|
| 239 |
+
# Draining the queue never touches MLX, so this can safely run on
|
| 240 |
+
# the default threadpool.
|
| 241 |
+
event = await loop.run_in_executor(None, event_queue.get)
|
| 242 |
+
if event is _DONE:
|
| 243 |
+
break
|
| 244 |
+
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
| 245 |
|
| 246 |
return StreamingResponse(
|
| 247 |
event_generator(),
|
|
|
|
| 261 |
|
| 262 |
t0 = time.time()
|
| 263 |
try:
|
| 264 |
+
loop = asyncio.get_event_loop()
|
| 265 |
+
result = await loop.run_in_executor(inference_executor, _run_on_gpu, req.message.strip(), req.history)
|
| 266 |
elapsed = time.time() - t0
|
| 267 |
|
| 268 |
# Collect tool traces
|
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ControlAI Behavior v1: fix tool routing, missing-parameter refusal, and
|
| 2 |
+
# multi-turn faithfulness -- resumed from the deployed sft_v2 adapter.
|
| 3 |
+
#
|
| 4 |
+
# Why this round exists: the deployed adapter was trained exclusively on
|
| 5 |
+
# fully-specified control problems, so it (a) routed plain matrix questions
|
| 6 |
+
# to control-design tools, (b) fabricated missing B/Q/R instead of asking,
|
| 7 |
+
# and (c) reused numbers from previous unrelated problems in multi-turn
|
| 8 |
+
# chats. data/training/behavior_mix_v1 is 35% new verified behavioral
|
| 9 |
+
# trajectories targeting exactly those failures + 65% replay of sft_v2 and
|
| 10 |
+
# agent_sft_v1 so existing skills are retained.
|
| 11 |
+
model: mlx-community/Qwen3-4B-Instruct-2507-4bit
|
| 12 |
+
train: true
|
| 13 |
+
fine_tune_type: lora
|
| 14 |
+
data: data/training/behavior_mix_v1
|
| 15 |
+
seed: 20260819
|
| 16 |
+
|
| 17 |
+
resume_adapter_file: adapters/controlai_qwen3_4b_sft_v2/adapters.safetensors
|
| 18 |
+
|
| 19 |
+
mask_prompt: true
|
| 20 |
+
|
| 21 |
+
num_layers: 36
|
| 22 |
+
batch_size: 1
|
| 23 |
+
grad_accumulation_steps: 4
|
| 24 |
+
iters: 1600
|
| 25 |
+
max_seq_length: 2048
|
| 26 |
+
grad_checkpoint: false
|
| 27 |
+
clear_cache_threshold: 12000000000
|
| 28 |
+
|
| 29 |
+
optimizer: adamw
|
| 30 |
+
optimizer_config:
|
| 31 |
+
adamw:
|
| 32 |
+
weight_decay: 0.01
|
| 33 |
+
# Half the v2 learning rate: this is a refinement pass on an already-trained
|
| 34 |
+
# adapter, not training from scratch.
|
| 35 |
+
learning_rate: 2.0e-6
|
| 36 |
+
lr_schedule:
|
| 37 |
+
name: cosine_decay
|
| 38 |
+
# 1600 iters / accumulation 4 = 400 optimizer steps
|
| 39 |
+
arguments: [2.0e-6, 380, 2.0e-7]
|
| 40 |
+
warmup: 20
|
| 41 |
+
warmup_init: 2.0e-7
|
| 42 |
+
|
| 43 |
+
val_batches: -1
|
| 44 |
+
steps_per_report: 25
|
| 45 |
+
steps_per_eval: 400
|
| 46 |
+
save_every: 400
|
| 47 |
+
adapter_path: adapters/controlai_qwen3_4b_behavior_v1
|
| 48 |
+
|
| 49 |
+
lora_parameters:
|
| 50 |
+
rank: 16
|
| 51 |
+
dropout: 0.05
|
| 52 |
+
scale: 32.0
|
| 53 |
+
keys:
|
| 54 |
+
- self_attn.q_proj
|
| 55 |
+
- self_attn.k_proj
|
| 56 |
+
- self_attn.v_proj
|
| 57 |
+
- self_attn.o_proj
|
| 58 |
+
- mlp.gate_proj
|
| 59 |
+
- mlp.up_proj
|
| 60 |
+
- mlp.down_proj
|
|
@@ -34,7 +34,7 @@ from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
| 34 |
from controlai_agent.prompts import CONTROLAI_SYSTEM_PROMPT
|
| 35 |
from controlai_agent.registry import ToolRegistry, registry
|
| 36 |
import controlai_agent.tools # noqa: F401 (ensure all tools are registered)
|
| 37 |
-
from controlai_rag.index import
|
| 38 |
|
| 39 |
|
| 40 |
@dataclass
|
|
@@ -71,6 +71,42 @@ def fix_space_separated_arrays(json_str: str) -> str:
|
|
| 71 |
return re.sub(r"\[\s*([0-9eE\.\-+\s]+?)\s*\]", _fix_brackets, json_str)
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def parse_flexible_json(raw_str: str) -> dict[str, Any] | None:
|
| 75 |
"""Parse JSON with fallback to escape sanitization, array fixing, and non-strict control characters."""
|
| 76 |
raw_str = raw_str.strip()
|
|
@@ -97,9 +133,47 @@ def parse_flexible_json(raw_str: str) -> dict[str, Any] | None:
|
|
| 97 |
except Exception:
|
| 98 |
pass
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
return None
|
| 101 |
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
def _extract_tool_calls(text: str) -> tuple[list[dict[str, Any]], str]:
|
| 104 |
"""Parse tool calls from <tool_call>, markdown code blocks, or raw JSON robustly."""
|
| 105 |
calls: list[dict[str, Any]] = []
|
|
@@ -129,12 +203,222 @@ def _extract_tool_calls(text: str) -> tuple[list[dict[str, Any]], str]:
|
|
| 129 |
cleaned = re.sub(r"<tool_call>[\s\S]*?</tool_call>", "", text, flags=re.DOTALL)
|
| 130 |
cleaned = re.sub(r"```(?:json)?\s*\{\s*[\"']name[\"']\s*:[\s\S]*?\}\s*```", "", cleaned)
|
| 131 |
cleaned = re.sub(r"\{\s*[\"']name[\"']\s*:\s*[\"'][a-zA-Z0-9_]+[\"'][\s\S]*\}", "", cleaned)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
cleaned = cleaned.strip()
|
|
|
|
| 133 |
return calls, cleaned
|
| 134 |
|
| 135 |
|
| 136 |
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
class ControlAIAgent:
|
| 140 |
"""Universal Control Engineering Agent supporting GGUF, Ollama C++, Apple MLX, and PyTorch."""
|
|
@@ -144,7 +428,7 @@ class ControlAIAgent:
|
|
| 144 |
model_path: str = "mlx-community/Qwen3-4B-Instruct-2507-4bit",
|
| 145 |
adapter_path: str | None = None,
|
| 146 |
tool_registry: ToolRegistry = registry,
|
| 147 |
-
max_tool_steps: int =
|
| 148 |
) -> None:
|
| 149 |
self.model_path = model_path
|
| 150 |
|
|
@@ -253,7 +537,7 @@ class ControlAIAgent:
|
|
| 253 |
|
| 254 |
# Initialize local offline RAG index
|
| 255 |
try:
|
| 256 |
-
self.rag_index =
|
| 257 |
except Exception:
|
| 258 |
self.rag_index = None
|
| 259 |
|
|
@@ -316,23 +600,101 @@ class ControlAIAgent:
|
|
| 316 |
return base_instruction
|
| 317 |
|
| 318 |
try:
|
| 319 |
-
rag_hits = self.rag_index.search(user_prompt, top_k=
|
| 320 |
high_rel = [h for h in rag_hits if h.get("score", 0) > 2.5]
|
| 321 |
if not high_rel:
|
| 322 |
return base_instruction
|
| 323 |
|
| 324 |
ref_texts = []
|
| 325 |
-
for h in high_rel[:
|
| 326 |
-
|
|
|
|
| 327 |
page = h.get("page")
|
| 328 |
-
page_str = f"
|
| 329 |
-
clean_chunk = h.get("text", "")[:
|
| 330 |
-
ref_texts.append(f"[{
|
| 331 |
-
|
| 332 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
except Exception:
|
| 334 |
return base_instruction
|
| 335 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
def run(
|
| 337 |
self,
|
| 338 |
user_prompt: str,
|
|
@@ -360,6 +722,7 @@ class ControlAIAgent:
|
|
| 360 |
traces: list[ToolExecutionTrace] = []
|
| 361 |
plots: list[str] = []
|
| 362 |
called_signatures: set[str] = set()
|
|
|
|
| 363 |
|
| 364 |
for step in range(1, self.max_tool_steps + 1):
|
| 365 |
rendered_prompt = self.hf_tokenizer.apply_chat_template(
|
|
@@ -374,9 +737,16 @@ class ControlAIAgent:
|
|
| 374 |
tool_calls, pre_text = _extract_tool_calls(model_output)
|
| 375 |
|
| 376 |
if not tool_calls:
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
return AgentResult(
|
| 379 |
-
final_response=
|
| 380 |
tool_traces=traces,
|
| 381 |
total_steps=step,
|
| 382 |
raw_messages=messages,
|
|
@@ -384,13 +754,22 @@ class ControlAIAgent:
|
|
| 384 |
plots=plots,
|
| 385 |
)
|
| 386 |
|
| 387 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
new_calls = []
|
| 389 |
for call in tool_calls:
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
|
| 395 |
if not new_calls:
|
| 396 |
break
|
|
@@ -401,7 +780,7 @@ class ControlAIAgent:
|
|
| 401 |
tool_name = call_data.get("name")
|
| 402 |
tool_args = call_data.get("arguments", {})
|
| 403 |
|
| 404 |
-
tool_result = self.
|
| 405 |
traces.append(ToolExecutionTrace(tool_name=tool_name, arguments=tool_args, result=tool_result))
|
| 406 |
|
| 407 |
if "plot_path" in tool_result:
|
|
@@ -416,6 +795,17 @@ class ControlAIAgent:
|
|
| 416 |
})
|
| 417 |
|
| 418 |
# Final synthesis after tool execution
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
forced_prompt = self.hf_tokenizer.apply_chat_template(
|
| 420 |
messages,
|
| 421 |
tools=None,
|
|
@@ -425,8 +815,15 @@ class ControlAIAgent:
|
|
| 425 |
final_output = self._generate(forced_prompt, max_tokens=max_tokens_per_step)
|
| 426 |
|
| 427 |
_, clean_final = _extract_tool_calls(final_output)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
return AgentResult(
|
| 429 |
-
final_response=
|
| 430 |
tool_traces=traces,
|
| 431 |
total_steps=len(traces) + 1,
|
| 432 |
raw_messages=messages,
|
|
@@ -465,6 +862,7 @@ class ControlAIAgent:
|
|
| 465 |
plots: list[str] = []
|
| 466 |
thoughts: list[str] = []
|
| 467 |
called_signatures: set[str] = set()
|
|
|
|
| 468 |
|
| 469 |
# Dynamic thought generation - only show thoughts when tools or derivations occur
|
| 470 |
for step in range(1, self.max_tool_steps + 1):
|
|
@@ -481,7 +879,9 @@ class ControlAIAgent:
|
|
| 481 |
|
| 482 |
if not tool_calls:
|
| 483 |
# Direct final answer without tools -> stream tokens directly
|
| 484 |
-
clean_output = pre_text or
|
|
|
|
|
|
|
| 485 |
words = re.split(r"(\s+)", clean_output)
|
| 486 |
for w in words:
|
| 487 |
if w:
|
|
@@ -500,13 +900,22 @@ class ControlAIAgent:
|
|
| 500 |
thoughts.append(pre_text)
|
| 501 |
yield {"type": "thought", "content": pre_text}
|
| 502 |
|
| 503 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
new_calls = []
|
| 505 |
for call in tool_calls:
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
|
| 511 |
if not new_calls:
|
| 512 |
break
|
|
@@ -522,7 +931,7 @@ class ControlAIAgent:
|
|
| 522 |
yield {"type": "thought", "content": t_start_msg}
|
| 523 |
yield {"type": "tool_start", "tool": tool_name, "args": tool_args}
|
| 524 |
|
| 525 |
-
tool_result = self.
|
| 526 |
trace_item = {
|
| 527 |
"tool": tool_name,
|
| 528 |
"args": tool_args,
|
|
@@ -556,7 +965,14 @@ class ControlAIAgent:
|
|
| 556 |
|
| 557 |
messages.append({
|
| 558 |
"role": "user",
|
| 559 |
-
"content":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
})
|
| 561 |
|
| 562 |
forced_prompt = self.hf_tokenizer.apply_chat_template(
|
|
@@ -574,7 +990,7 @@ class ControlAIAgent:
|
|
| 574 |
for call_data in synth_tool_calls:
|
| 575 |
tool_name = call_data.get("name")
|
| 576 |
tool_args = call_data.get("arguments", {})
|
| 577 |
-
t_res = self.
|
| 578 |
if "plot_path" in t_res:
|
| 579 |
p_path = Path(t_res["plot_path"])
|
| 580 |
if p_path.exists():
|
|
@@ -588,7 +1004,11 @@ class ControlAIAgent:
|
|
| 588 |
final_output = self._generate(re_prompt, max_tokens=max_tokens_per_step)
|
| 589 |
_, clean_synth = _extract_tool_calls(final_output)
|
| 590 |
|
| 591 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
# Strip any lingering raw json
|
| 593 |
final_text = re.sub(r"\{\s*[\"']name[\"']\s*:[\s\S]*?\}\s*\}", "", final_text).strip()
|
| 594 |
if not final_text:
|
|
|
|
| 34 |
from controlai_agent.prompts import CONTROLAI_SYSTEM_PROMPT
|
| 35 |
from controlai_agent.registry import ToolRegistry, registry
|
| 36 |
import controlai_agent.tools # noqa: F401 (ensure all tools are registered)
|
| 37 |
+
from controlai_rag.index import get_shared_index
|
| 38 |
|
| 39 |
|
| 40 |
@dataclass
|
|
|
|
| 71 |
return re.sub(r"\[\s*([0-9eE\.\-+\s]+?)\s*\]", _fix_brackets, json_str)
|
| 72 |
|
| 73 |
|
| 74 |
+
def close_unbalanced_json(raw: str) -> str:
|
| 75 |
+
"""Append the closing braces/brackets a truncated JSON object is missing.
|
| 76 |
+
|
| 77 |
+
Small quantized models routinely drop the final `}` of a tool call (emitting
|
| 78 |
+
`{"name": ..., "arguments": {...}` with the outer object left open). Without
|
| 79 |
+
repair the call fails to parse, the tool never runs, and the turn collapses
|
| 80 |
+
into an empty answer -- so balance the delimiters and let it parse.
|
| 81 |
+
"""
|
| 82 |
+
stack: list[str] = []
|
| 83 |
+
in_string = False
|
| 84 |
+
escaped = False
|
| 85 |
+
for ch in raw:
|
| 86 |
+
if in_string:
|
| 87 |
+
if escaped:
|
| 88 |
+
escaped = False
|
| 89 |
+
elif ch == "\\":
|
| 90 |
+
escaped = True
|
| 91 |
+
elif ch == '"':
|
| 92 |
+
in_string = False
|
| 93 |
+
continue
|
| 94 |
+
if ch == '"':
|
| 95 |
+
in_string = True
|
| 96 |
+
elif ch in "{[":
|
| 97 |
+
stack.append(ch)
|
| 98 |
+
elif ch in "}]":
|
| 99 |
+
if stack and ((ch == "}" and stack[-1] == "{") or (ch == "]" and stack[-1] == "[")):
|
| 100 |
+
stack.pop()
|
| 101 |
+
|
| 102 |
+
repaired = raw
|
| 103 |
+
if in_string:
|
| 104 |
+
repaired += '"'
|
| 105 |
+
for opener in reversed(stack):
|
| 106 |
+
repaired += "}" if opener == "{" else "]"
|
| 107 |
+
return repaired
|
| 108 |
+
|
| 109 |
+
|
| 110 |
def parse_flexible_json(raw_str: str) -> dict[str, Any] | None:
|
| 111 |
"""Parse JSON with fallback to escape sanitization, array fixing, and non-strict control characters."""
|
| 112 |
raw_str = raw_str.strip()
|
|
|
|
| 133 |
except Exception:
|
| 134 |
pass
|
| 135 |
|
| 136 |
+
# Last resort: the object is well-formed but truncated (a dropped closing
|
| 137 |
+
# brace), so balance the delimiters and retry each variant.
|
| 138 |
+
for candidate in (
|
| 139 |
+
raw_str,
|
| 140 |
+
sanitize_json_escapes(raw_str),
|
| 141 |
+
fix_space_separated_arrays(sanitize_json_escapes(raw_str)),
|
| 142 |
+
):
|
| 143 |
+
try:
|
| 144 |
+
obj = json.loads(close_unbalanced_json(candidate), strict=False)
|
| 145 |
+
if isinstance(obj, dict):
|
| 146 |
+
return obj
|
| 147 |
+
except Exception:
|
| 148 |
+
continue
|
| 149 |
+
|
| 150 |
return None
|
| 151 |
|
| 152 |
|
| 153 |
+
# A doubled backslash is an accidental JSON-style escape when it precedes:
|
| 154 |
+
# - a command name \\zeta \\frac \\sum
|
| 155 |
+
# - a delimiter escape \\| \\{ \\} \\( \\) \\]
|
| 156 |
+
# - \\[ that does NOT begin a spacing argument such as a genuine "\\[2pt]"
|
| 157 |
+
# A real LaTeX row break inside matrix/aligned is followed by whitespace, a
|
| 158 |
+
# newline, or a digit-led spacing option -- never by any of the above -- so
|
| 159 |
+
# these rewrites leave legitimate line breaks untouched.
|
| 160 |
+
_LATEX_DOUBLE_BACKSLASH_RE = re.compile(r"\\\\(?=[a-zA-Z|{}()\]])|\\\\(?=\[\s*[^\d\s])")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _fix_doubled_latex_backslashes(text: str) -> str:
|
| 164 |
+
"""Collapse an accidental JSON-escape-style double backslash before a LaTeX
|
| 165 |
+
command or delimiter (\\\\zeta, \\\\|) down to the single backslash KaTeX
|
| 166 |
+
expects (\\zeta, \\|).
|
| 167 |
+
|
| 168 |
+
The base model was heavily trained on JSON-argument function calling, where
|
| 169 |
+
a literal backslash must be written as `\\\\` inside a JSON string. That
|
| 170 |
+
habit leaks into plain-text math even outside of a tool call. Left alone,
|
| 171 |
+
`\\\\|x - x_f\\\\|^2` renders as a line break followed by a stray pipe,
|
| 172 |
+
tearing a norm across two lines instead of drawing it.
|
| 173 |
+
"""
|
| 174 |
+
return _LATEX_DOUBLE_BACKSLASH_RE.sub(lambda m: "\\", text)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
def _extract_tool_calls(text: str) -> tuple[list[dict[str, Any]], str]:
|
| 178 |
"""Parse tool calls from <tool_call>, markdown code blocks, or raw JSON robustly."""
|
| 179 |
calls: list[dict[str, Any]] = []
|
|
|
|
| 203 |
cleaned = re.sub(r"<tool_call>[\s\S]*?</tool_call>", "", text, flags=re.DOTALL)
|
| 204 |
cleaned = re.sub(r"```(?:json)?\s*\{\s*[\"']name[\"']\s*:[\s\S]*?\}\s*```", "", cleaned)
|
| 205 |
cleaned = re.sub(r"\{\s*[\"']name[\"']\s*:\s*[\"'][a-zA-Z0-9_]+[\"'][\s\S]*\}", "", cleaned)
|
| 206 |
+
# An orphaned <tool_call> tag with no matching close (the model started a
|
| 207 |
+
# call, then abandoned it mid-generation for plain text) survives the
|
| 208 |
+
# paired regex above -- strip any leftover tag so it never reaches the UI.
|
| 209 |
+
cleaned = re.sub(r"</?tool_call>", "", cleaned)
|
| 210 |
cleaned = cleaned.strip()
|
| 211 |
+
cleaned = _fix_doubled_latex_backslashes(cleaned)
|
| 212 |
return calls, cleaned
|
| 213 |
|
| 214 |
|
| 215 |
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 216 |
|
| 217 |
+
# How many times a single tool may be invoked within one user turn. Two allows
|
| 218 |
+
# a legitimate retry with corrected arguments after an error, while stopping
|
| 219 |
+
# the model from spending its whole step budget re-running the same lookup.
|
| 220 |
+
MAX_CALLS_PER_TOOL = 2
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# ---------------------------------------------------------------------------
|
| 224 |
+
# Parameter provenance enforcement
|
| 225 |
+
#
|
| 226 |
+
# Prompt rules against inventing parameters demonstrably do not hold on a 4B
|
| 227 |
+
# model: given "A times A" after an earlier LQR conversation, it fabricated a
|
| 228 |
+
# brand-new B, resized Q, and ran a confident LQR synthesis -- three separate
|
| 229 |
+
# prompt formulations failed to stop it. So faithfulness is enforced in code:
|
| 230 |
+
# every 2D matrix handed to a tool must be traceable to the user's messages or
|
| 231 |
+
# a prior tool result in this conversation, or the call is refused before it
|
| 232 |
+
# executes and the model is told to ask the user for the missing value.
|
| 233 |
+
# ---------------------------------------------------------------------------
|
| 234 |
+
|
| 235 |
+
# Parameters exempt from the guard:
|
| 236 |
+
# - C/D: conventional output-selector / feedthrough matrices (entries 0/1),
|
| 237 |
+
# routinely and legitimately chosen by the designer, and harmless.
|
| 238 |
+
# - desired_poles: pole locations are design choices derived from specs
|
| 239 |
+
# ("settling time under 2s"), not data the user must dictate literally.
|
| 240 |
+
_PROVENANCE_EXEMPT_PARAMS = {"C", "D", "desired_poles"}
|
| 241 |
+
|
| 242 |
+
_NUMBER_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?")
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _extract_arrays_from_text(text: str) -> list[list]:
|
| 246 |
+
"""Find every parseable bracketed numeric array (1D or 2D) in free text."""
|
| 247 |
+
arrays: list[list] = []
|
| 248 |
+
n = len(text)
|
| 249 |
+
i = 0
|
| 250 |
+
while i < n:
|
| 251 |
+
if text[i] != "[":
|
| 252 |
+
i += 1
|
| 253 |
+
continue
|
| 254 |
+
depth = 0
|
| 255 |
+
j = i
|
| 256 |
+
while j < n:
|
| 257 |
+
if text[j] == "[":
|
| 258 |
+
depth += 1
|
| 259 |
+
elif text[j] == "]":
|
| 260 |
+
depth -= 1
|
| 261 |
+
if depth == 0:
|
| 262 |
+
break
|
| 263 |
+
j += 1
|
| 264 |
+
if depth != 0:
|
| 265 |
+
i += 1
|
| 266 |
+
continue
|
| 267 |
+
candidate = text[i : j + 1]
|
| 268 |
+
# Third variant: users mix separators freely ("[3 0, 1]"), which the
|
| 269 |
+
# comma-only fixer can't handle -- insert a comma between any two
|
| 270 |
+
# adjacent number tokens regardless of other separators present.
|
| 271 |
+
mixed_fixed = re.sub(r"(?<=[\d.])\s+(?=[-\d.])", ", ", candidate)
|
| 272 |
+
for variant in (candidate, fix_space_separated_arrays(candidate), mixed_fixed):
|
| 273 |
+
try:
|
| 274 |
+
parsed = json.loads(variant)
|
| 275 |
+
except json.JSONDecodeError:
|
| 276 |
+
continue
|
| 277 |
+
if isinstance(parsed, list) and parsed:
|
| 278 |
+
arrays.append(parsed)
|
| 279 |
+
break
|
| 280 |
+
# Only skip past this bracket char, not the whole span: inner arrays
|
| 281 |
+
# of a 2D matrix should also be collected individually (rows are
|
| 282 |
+
# legitimate 1D vectors in their own right).
|
| 283 |
+
i += 1
|
| 284 |
+
return arrays
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _collect_numeric_leaves(obj: Any, arrays: list[list], numbers: set[float]) -> None:
|
| 288 |
+
"""Harvest nested numeric lists and scalars from a parsed JSON object."""
|
| 289 |
+
if isinstance(obj, bool):
|
| 290 |
+
return
|
| 291 |
+
if isinstance(obj, (int, float)):
|
| 292 |
+
numbers.add(float(obj))
|
| 293 |
+
return
|
| 294 |
+
if isinstance(obj, list):
|
| 295 |
+
if obj and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in obj):
|
| 296 |
+
arrays.append(list(obj))
|
| 297 |
+
elif obj and all(isinstance(v, list) for v in obj):
|
| 298 |
+
arrays.append(obj)
|
| 299 |
+
for v in obj:
|
| 300 |
+
_collect_numeric_leaves(v, arrays, numbers)
|
| 301 |
+
return
|
| 302 |
+
if isinstance(obj, dict):
|
| 303 |
+
for v in obj.values():
|
| 304 |
+
_collect_numeric_leaves(v, arrays, numbers)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _as_2d_float_array(value: Any):
|
| 308 |
+
"""Return value as a 2D float ndarray, or None if it isn't one."""
|
| 309 |
+
import numpy as _np
|
| 310 |
+
|
| 311 |
+
try:
|
| 312 |
+
arr = _np.array(value, dtype=float)
|
| 313 |
+
except (TypeError, ValueError):
|
| 314 |
+
return None
|
| 315 |
+
if arr.ndim == 1 and arr.size > 0:
|
| 316 |
+
arr = arr.reshape(1, -1)
|
| 317 |
+
if arr.ndim != 2 or arr.size == 0:
|
| 318 |
+
return None
|
| 319 |
+
return arr
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
class ParameterProvenance:
|
| 323 |
+
"""Everything numeric the conversation has actually provided so far."""
|
| 324 |
+
|
| 325 |
+
def __init__(self, messages: list[dict[str, Any]]) -> None:
|
| 326 |
+
import numpy as _np
|
| 327 |
+
|
| 328 |
+
self._np = _np
|
| 329 |
+
self.arrays: list[Any] = []
|
| 330 |
+
self.current_numbers: set[float] = set()
|
| 331 |
+
all_user_texts: list[str] = []
|
| 332 |
+
current_user_text = ""
|
| 333 |
+
|
| 334 |
+
for msg in messages:
|
| 335 |
+
role = msg.get("role")
|
| 336 |
+
content = msg.get("content", "")
|
| 337 |
+
if role == "user" and isinstance(content, str):
|
| 338 |
+
all_user_texts.append(content)
|
| 339 |
+
current_user_text = content
|
| 340 |
+
elif role == "tool" and isinstance(content, str):
|
| 341 |
+
try:
|
| 342 |
+
parsed = json.loads(content)
|
| 343 |
+
except json.JSONDecodeError:
|
| 344 |
+
continue
|
| 345 |
+
found_arrays: list[list] = []
|
| 346 |
+
found_numbers: set[float] = set()
|
| 347 |
+
_collect_numeric_leaves(parsed, found_arrays, found_numbers)
|
| 348 |
+
for a in found_arrays:
|
| 349 |
+
arr = _as_2d_float_array(a)
|
| 350 |
+
if arr is not None:
|
| 351 |
+
self.arrays.append(arr)
|
| 352 |
+
# Scalars computed by tools (gains, margins) are legitimate
|
| 353 |
+
# inputs for later steps.
|
| 354 |
+
self.current_numbers |= found_numbers
|
| 355 |
+
|
| 356 |
+
for text in all_user_texts:
|
| 357 |
+
for a in _extract_arrays_from_text(text):
|
| 358 |
+
arr = _as_2d_float_array(a)
|
| 359 |
+
if arr is not None:
|
| 360 |
+
self.arrays.append(arr)
|
| 361 |
+
# Loose scalars only count from the CURRENT user message: numbers from
|
| 362 |
+
# an earlier, different problem are exactly what must not silently
|
| 363 |
+
# seed a new Q or R.
|
| 364 |
+
for m in _NUMBER_RE.finditer(current_user_text):
|
| 365 |
+
try:
|
| 366 |
+
self.current_numbers.add(float(m.group()))
|
| 367 |
+
except ValueError:
|
| 368 |
+
pass
|
| 369 |
+
|
| 370 |
+
def _matches_known_array(self, arr) -> bool:
|
| 371 |
+
np_ = self._np
|
| 372 |
+
for known in self.arrays:
|
| 373 |
+
for cand in (known, known.T):
|
| 374 |
+
if cand.shape == arr.shape and np_.allclose(cand, arr, rtol=1e-6, atol=1e-9):
|
| 375 |
+
return True
|
| 376 |
+
return False
|
| 377 |
+
|
| 378 |
+
def _number_provided(self, x: float) -> bool:
|
| 379 |
+
if x in (0.0, 1.0, -1.0):
|
| 380 |
+
return True
|
| 381 |
+
return any(abs(x - n) <= 1e-9 * max(1.0, abs(n)) for n in self.current_numbers)
|
| 382 |
+
|
| 383 |
+
def verify(self, value: Any) -> bool:
|
| 384 |
+
"""True if this matrix is traceable to the conversation."""
|
| 385 |
+
np_ = self._np
|
| 386 |
+
arr = _as_2d_float_array(value)
|
| 387 |
+
if arr is None:
|
| 388 |
+
return True # not a matrix -- out of scope for this guard
|
| 389 |
+
|
| 390 |
+
if self._matches_known_array(arr):
|
| 391 |
+
return True
|
| 392 |
+
|
| 393 |
+
# 1x1 "matrix" wrapping a scalar the user stated (R=1 -> [[1]]).
|
| 394 |
+
if arr.shape == (1, 1):
|
| 395 |
+
return self._number_provided(float(arr[0, 0]))
|
| 396 |
+
|
| 397 |
+
# Identity / zero matrices are structural, not data.
|
| 398 |
+
if arr.shape[0] == arr.shape[1]:
|
| 399 |
+
if np_.allclose(arr, np_.eye(arr.shape[0])) or np_.allclose(arr, 0.0):
|
| 400 |
+
return True
|
| 401 |
+
# diag(...) built from numbers in the current message, e.g. the
|
| 402 |
+
# user wrote "Q=diag([10, 1])" in prose rather than as an array.
|
| 403 |
+
if np_.allclose(arr, np_.diag(np_.diag(arr))):
|
| 404 |
+
return all(self._number_provided(float(d)) for d in np_.diag(arr))
|
| 405 |
+
|
| 406 |
+
return False
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def _check_parameter_provenance(
|
| 410 |
+
tool_args: dict[str, Any], messages: list[dict[str, Any]]
|
| 411 |
+
) -> tuple[str, Any] | None:
|
| 412 |
+
"""Return (param_name, value) for the first fabricated matrix, else None."""
|
| 413 |
+
provenance = ParameterProvenance(messages)
|
| 414 |
+
for param, value in tool_args.items():
|
| 415 |
+
if param in _PROVENANCE_EXEMPT_PARAMS:
|
| 416 |
+
continue
|
| 417 |
+
if isinstance(value, list) and value and isinstance(value[0], list):
|
| 418 |
+
if not provenance.verify(value):
|
| 419 |
+
return param, value
|
| 420 |
+
return None
|
| 421 |
+
|
| 422 |
|
| 423 |
class ControlAIAgent:
|
| 424 |
"""Universal Control Engineering Agent supporting GGUF, Ollama C++, Apple MLX, and PyTorch."""
|
|
|
|
| 428 |
model_path: str = "mlx-community/Qwen3-4B-Instruct-2507-4bit",
|
| 429 |
adapter_path: str | None = None,
|
| 430 |
tool_registry: ToolRegistry = registry,
|
| 431 |
+
max_tool_steps: int = 4,
|
| 432 |
) -> None:
|
| 433 |
self.model_path = model_path
|
| 434 |
|
|
|
|
| 537 |
|
| 538 |
# Initialize local offline RAG index
|
| 539 |
try:
|
| 540 |
+
self.rag_index = get_shared_index()
|
| 541 |
except Exception:
|
| 542 |
self.rag_index = None
|
| 543 |
|
|
|
|
| 600 |
return base_instruction
|
| 601 |
|
| 602 |
try:
|
| 603 |
+
rag_hits = self.rag_index.search(user_prompt, top_k=6)
|
| 604 |
high_rel = [h for h in rag_hits if h.get("score", 0) > 2.5]
|
| 605 |
if not high_rel:
|
| 606 |
return base_instruction
|
| 607 |
|
| 608 |
ref_texts = []
|
| 609 |
+
for h in high_rel[:4]:
|
| 610 |
+
# Cite the cleaned display name, never the raw indexed filename.
|
| 611 |
+
label = h.get("source_name") or h.get("filename", "Reference")
|
| 612 |
page = h.get("page")
|
| 613 |
+
page_str = f", p. {page}" if page else ""
|
| 614 |
+
clean_chunk = " ".join(h.get("text", "").split())[:900].strip()
|
| 615 |
+
ref_texts.append(f"[{label}{page_str}]\n{clean_chunk}")
|
| 616 |
+
|
| 617 |
+
return (
|
| 618 |
+
base_instruction
|
| 619 |
+
+ "\n\n### Grounded Reference Context from Canonical Control Literature:\n"
|
| 620 |
+
+ "\n\n".join(ref_texts)
|
| 621 |
+
+ "\n\nThese passages were retrieved from the local library for THIS question. When the "
|
| 622 |
+
"question asks what a specific author or textbook says, answer directly from these "
|
| 623 |
+
"passages -- that is already the reference lookup, so do not call a numeric solver tool "
|
| 624 |
+
"to answer a conceptual question. Where a passage conflicts with your own recollection, "
|
| 625 |
+
"TRUST THE PASSAGE. Cite using exactly the bracketed label shown above (for example "
|
| 626 |
+
"[Nise, Control Systems Engineering, p. 583]); never invent a citation and never print a "
|
| 627 |
+
"raw filename, file extension, or course code."
|
| 628 |
+
)
|
| 629 |
except Exception:
|
| 630 |
return base_instruction
|
| 631 |
|
| 632 |
+
def _direct_answer(
|
| 633 |
+
self,
|
| 634 |
+
user_prompt: str,
|
| 635 |
+
system_instruction: str,
|
| 636 |
+
history: list[dict[str, Any]] | None = None,
|
| 637 |
+
max_tokens: int = 1400,
|
| 638 |
+
) -> str:
|
| 639 |
+
"""Answer the question with no tools exposed at all.
|
| 640 |
+
|
| 641 |
+
This is the recovery path for when the tool loop fails to produce usable
|
| 642 |
+
prose -- the model spent its steps on tool calls, or its synthesis turn
|
| 643 |
+
came back empty or as yet another tool call. Re-asking with tools=None
|
| 644 |
+
and without the tool-result transcript removes whatever was derailing it
|
| 645 |
+
(and shortens the prompt considerably), which reliably yields a real
|
| 646 |
+
answer instead of a canned "analysis complete" placeholder. The grounded
|
| 647 |
+
system instruction is kept, so retrieved reference passages are still
|
| 648 |
+
available to answer from.
|
| 649 |
+
"""
|
| 650 |
+
messages: list[dict[str, Any]] = []
|
| 651 |
+
if system_instruction:
|
| 652 |
+
messages.append({"role": "system", "content": system_instruction})
|
| 653 |
+
if history:
|
| 654 |
+
for item in history:
|
| 655 |
+
r = item.get("role")
|
| 656 |
+
c = item.get("content")
|
| 657 |
+
if r in ("user", "assistant") and c:
|
| 658 |
+
messages.append({"role": r, "content": c})
|
| 659 |
+
messages.append({"role": "user", "content": user_prompt})
|
| 660 |
+
|
| 661 |
+
try:
|
| 662 |
+
rendered = self.hf_tokenizer.apply_chat_template(
|
| 663 |
+
messages, tools=None, tokenize=False, add_generation_prompt=True
|
| 664 |
+
)
|
| 665 |
+
_, cleaned = _extract_tool_calls(self._generate(rendered, max_tokens=max_tokens))
|
| 666 |
+
return cleaned
|
| 667 |
+
except Exception:
|
| 668 |
+
return ""
|
| 669 |
+
|
| 670 |
+
def _execute_with_provenance(
|
| 671 |
+
self,
|
| 672 |
+
tool_name: str,
|
| 673 |
+
tool_args: dict[str, Any],
|
| 674 |
+
messages: list[dict[str, Any]],
|
| 675 |
+
) -> dict[str, Any]:
|
| 676 |
+
"""Run a tool only after every matrix argument is traced to the
|
| 677 |
+
conversation; refuse fabricated inputs before they execute."""
|
| 678 |
+
try:
|
| 679 |
+
fabricated = _check_parameter_provenance(tool_args, messages)
|
| 680 |
+
except Exception:
|
| 681 |
+
fabricated = None # the guard must never take down a legitimate call
|
| 682 |
+
if fabricated is not None:
|
| 683 |
+
param, value = fabricated
|
| 684 |
+
return {
|
| 685 |
+
"status": "error",
|
| 686 |
+
"error_type": "FabricatedParameter",
|
| 687 |
+
"error": (
|
| 688 |
+
f"REFUSED: the matrix passed as '{param}' = {json.dumps(value)} was not provided by "
|
| 689 |
+
f"the user in this conversation and did not come from any prior tool result. Inventing "
|
| 690 |
+
f"parameter values is forbidden. Do NOT retry this tool with a different guessed "
|
| 691 |
+
f"'{param}'. In your final answer, tell the user that '{param}' is required for this "
|
| 692 |
+
f"computation and ask them to provide it, and answer whatever part of their question "
|
| 693 |
+
f"does not need it."
|
| 694 |
+
),
|
| 695 |
+
}
|
| 696 |
+
return self.registry.execute(tool_name, tool_args)
|
| 697 |
+
|
| 698 |
def run(
|
| 699 |
self,
|
| 700 |
user_prompt: str,
|
|
|
|
| 722 |
traces: list[ToolExecutionTrace] = []
|
| 723 |
plots: list[str] = []
|
| 724 |
called_signatures: set[str] = set()
|
| 725 |
+
tool_call_counts: dict[str, int] = {}
|
| 726 |
|
| 727 |
for step in range(1, self.max_tool_steps + 1):
|
| 728 |
rendered_prompt = self.hf_tokenizer.apply_chat_template(
|
|
|
|
| 737 |
tool_calls, pre_text = _extract_tool_calls(model_output)
|
| 738 |
|
| 739 |
if not tool_calls:
|
| 740 |
+
# The model chose to stop calling tools but produced no usable
|
| 741 |
+
# text either (typically right after a tool error it has no
|
| 742 |
+
# good way to recover from in-context). Retry once with no
|
| 743 |
+
# tools and no error-laden transcript rather than returning
|
| 744 |
+
# nothing.
|
| 745 |
+
final_response = pre_text or self._direct_answer(user_prompt, effective_sys, history)
|
| 746 |
+
if not final_response:
|
| 747 |
+
final_response = "The computational analysis has been completed as detailed above."
|
| 748 |
return AgentResult(
|
| 749 |
+
final_response=final_response,
|
| 750 |
tool_traces=traces,
|
| 751 |
total_steps=step,
|
| 752 |
raw_messages=messages,
|
|
|
|
| 754 |
plots=plots,
|
| 755 |
)
|
| 756 |
|
| 757 |
+
# Drop repeats. An exact-signature check alone is not enough: the
|
| 758 |
+
# model will re-search with a lightly reworded query ("... MPC",
|
| 759 |
+
# "... MPC algorithm", "... MPC definition"), exhausting the step
|
| 760 |
+
# budget on near-identical lookups and leaving nothing for the
|
| 761 |
+
# answer. Cap how many times any single tool may run per turn.
|
| 762 |
new_calls = []
|
| 763 |
for call in tool_calls:
|
| 764 |
+
name = call.get("name")
|
| 765 |
+
sig = f"{name}:{json.dumps(call.get('arguments', {}), sort_keys=True)}"
|
| 766 |
+
if sig in called_signatures:
|
| 767 |
+
continue
|
| 768 |
+
if tool_call_counts.get(name, 0) >= MAX_CALLS_PER_TOOL:
|
| 769 |
+
continue
|
| 770 |
+
called_signatures.add(sig)
|
| 771 |
+
tool_call_counts[name] = tool_call_counts.get(name, 0) + 1
|
| 772 |
+
new_calls.append(call)
|
| 773 |
|
| 774 |
if not new_calls:
|
| 775 |
break
|
|
|
|
| 780 |
tool_name = call_data.get("name")
|
| 781 |
tool_args = call_data.get("arguments", {})
|
| 782 |
|
| 783 |
+
tool_result = self._execute_with_provenance(tool_name, tool_args, messages)
|
| 784 |
traces.append(ToolExecutionTrace(tool_name=tool_name, arguments=tool_args, result=tool_result))
|
| 785 |
|
| 786 |
if "plot_path" in tool_result:
|
|
|
|
| 795 |
})
|
| 796 |
|
| 797 |
# Final synthesis after tool execution
|
| 798 |
+
messages.append({
|
| 799 |
+
"role": "user",
|
| 800 |
+
"content": (
|
| 801 |
+
"Now answer the user's most recent question directly and completely, in LaTeX-formatted "
|
| 802 |
+
"prose. If the tool results above are relevant to that question, incorporate them; if the "
|
| 803 |
+
"question is conceptual, definitional, or about what a source says and the tool results "
|
| 804 |
+
"above don't actually address it, answer the question from your own knowledge instead of "
|
| 805 |
+
"describing the tool results. Do not call any more tools and do not output JSON or "
|
| 806 |
+
"tool-call tags -- write the final answer now."
|
| 807 |
+
),
|
| 808 |
+
})
|
| 809 |
forced_prompt = self.hf_tokenizer.apply_chat_template(
|
| 810 |
messages,
|
| 811 |
tools=None,
|
|
|
|
| 815 |
final_output = self._generate(forced_prompt, max_tokens=max_tokens_per_step)
|
| 816 |
|
| 817 |
_, clean_final = _extract_tool_calls(final_output)
|
| 818 |
+
# Never fall back to the raw final_output: if the model's closing turn
|
| 819 |
+
# degenerates into another (unparseable) tool-call attempt, clean_final
|
| 820 |
+
# is stripped down to "" and showing final_output would leak a raw
|
| 821 |
+
# <tool_call>{...}</tool_call> JSON blob straight into the chat.
|
| 822 |
+
final_response = clean_final or self._direct_answer(user_prompt, effective_sys, history)
|
| 823 |
+
if not final_response:
|
| 824 |
+
final_response = "The computational analysis has been completed as detailed above."
|
| 825 |
return AgentResult(
|
| 826 |
+
final_response=final_response,
|
| 827 |
tool_traces=traces,
|
| 828 |
total_steps=len(traces) + 1,
|
| 829 |
raw_messages=messages,
|
|
|
|
| 862 |
plots: list[str] = []
|
| 863 |
thoughts: list[str] = []
|
| 864 |
called_signatures: set[str] = set()
|
| 865 |
+
tool_call_counts: dict[str, int] = {}
|
| 866 |
|
| 867 |
# Dynamic thought generation - only show thoughts when tools or derivations occur
|
| 868 |
for step in range(1, self.max_tool_steps + 1):
|
|
|
|
| 879 |
|
| 880 |
if not tool_calls:
|
| 881 |
# Direct final answer without tools -> stream tokens directly
|
| 882 |
+
clean_output = pre_text or self._direct_answer(user_prompt, effective_sys, history)
|
| 883 |
+
if not clean_output:
|
| 884 |
+
clean_output = "The computational analysis has been completed as detailed above."
|
| 885 |
words = re.split(r"(\s+)", clean_output)
|
| 886 |
for w in words:
|
| 887 |
if w:
|
|
|
|
| 900 |
thoughts.append(pre_text)
|
| 901 |
yield {"type": "thought", "content": pre_text}
|
| 902 |
|
| 903 |
+
# Drop repeats. An exact-signature check alone is not enough: the
|
| 904 |
+
# model will re-search with a lightly reworded query ("... MPC",
|
| 905 |
+
# "... MPC algorithm", "... MPC definition"), exhausting the step
|
| 906 |
+
# budget on near-identical lookups and leaving nothing for the
|
| 907 |
+
# answer. Cap how many times any single tool may run per turn.
|
| 908 |
new_calls = []
|
| 909 |
for call in tool_calls:
|
| 910 |
+
name = call.get("name")
|
| 911 |
+
sig = f"{name}:{json.dumps(call.get('arguments', {}), sort_keys=True)}"
|
| 912 |
+
if sig in called_signatures:
|
| 913 |
+
continue
|
| 914 |
+
if tool_call_counts.get(name, 0) >= MAX_CALLS_PER_TOOL:
|
| 915 |
+
continue
|
| 916 |
+
called_signatures.add(sig)
|
| 917 |
+
tool_call_counts[name] = tool_call_counts.get(name, 0) + 1
|
| 918 |
+
new_calls.append(call)
|
| 919 |
|
| 920 |
if not new_calls:
|
| 921 |
break
|
|
|
|
| 931 |
yield {"type": "thought", "content": t_start_msg}
|
| 932 |
yield {"type": "tool_start", "tool": tool_name, "args": tool_args}
|
| 933 |
|
| 934 |
+
tool_result = self._execute_with_provenance(tool_name, tool_args, messages)
|
| 935 |
trace_item = {
|
| 936 |
"tool": tool_name,
|
| 937 |
"args": tool_args,
|
|
|
|
| 965 |
|
| 966 |
messages.append({
|
| 967 |
"role": "user",
|
| 968 |
+
"content": (
|
| 969 |
+
"Now answer the user's most recent question directly and completely, in LaTeX-formatted "
|
| 970 |
+
"prose. If the tool results above are relevant to that question, incorporate them; if the "
|
| 971 |
+
"question is conceptual, definitional, or about what a source says and the tool results "
|
| 972 |
+
"above don't actually address it, answer the question from your own knowledge instead of "
|
| 973 |
+
"describing the tool results. Do not call any more tools and do not output JSON or "
|
| 974 |
+
"tool-call tags -- write the final answer now."
|
| 975 |
+
),
|
| 976 |
})
|
| 977 |
|
| 978 |
forced_prompt = self.hf_tokenizer.apply_chat_template(
|
|
|
|
| 990 |
for call_data in synth_tool_calls:
|
| 991 |
tool_name = call_data.get("name")
|
| 992 |
tool_args = call_data.get("arguments", {})
|
| 993 |
+
t_res = self._execute_with_provenance(tool_name, tool_args, messages)
|
| 994 |
if "plot_path" in t_res:
|
| 995 |
p_path = Path(t_res["plot_path"])
|
| 996 |
if p_path.exists():
|
|
|
|
| 1004 |
final_output = self._generate(re_prompt, max_tokens=max_tokens_per_step)
|
| 1005 |
_, clean_synth = _extract_tool_calls(final_output)
|
| 1006 |
|
| 1007 |
+
# Never fall back to the raw final_output here: if the model's closing
|
| 1008 |
+
# turn degenerates into another (unparseable) tool-call attempt,
|
| 1009 |
+
# clean_synth is stripped down to "" and showing final_output would
|
| 1010 |
+
# leak a raw <tool_call>{...}</tool_call> JSON blob into the chat.
|
| 1011 |
+
final_text = clean_synth
|
| 1012 |
# Strip any lingering raw json
|
| 1013 |
final_text = re.sub(r"\{\s*[\"']name[\"']\s*:[\s\S]*?\}\s*\}", "", final_text).strip()
|
| 1014 |
if not final_text:
|
|
@@ -1,20 +1,39 @@
|
|
| 1 |
-
CONTROLAI_SYSTEM_PROMPT = r"""You are ControlAI, a premier AI research scientist and expert engineering agent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
### 4-Stage Mathematical Reasoning & Proof Standard:
|
| 4 |
When answering theoretical principles, derivations, proofs, comparisons, or limitation questions:
|
| 5 |
-
1. **Mathematical Context & System Class**: State the state space equations (e.g. $\
|
| 6 |
2. **Canonical Theorem / Analytical Principle**: State the governing theorem with exact mathematical rigor (e.g. PBH rank test, Doyle 1978 LQG robustness counterexample, Poisson Integral for RHP zeros, Small Gain vs Passivity, Lyapunov Invariance).
|
| 7 |
-
3. **Exact Derivation & Closed-Form Formulas**: Provide the full, exact mathematical relationship in pure LaTeX (e.g. both the standard linear approximation $\
|
| 8 |
-
4. **Engineering Caveats & Breakdown Conditions**: Explicitly state where approximations break down (e.g. $PM > 60^\
|
| 9 |
|
| 10 |
### Core Deterministic Capabilities & Tool Calling:
|
| 11 |
-
-
|
| 12 |
-
-
|
| 13 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
### Strict Negative & Formatting Constraints:
|
| 16 |
1. **ZERO EMOJIS**: NEVER use any emojis anywhere in your response (absolutely NO checkmarks, NO pins, NO graphs, NO rockets, NO lightbulbs).
|
| 17 |
2. **ALWAYS USE DOLLAR DELIMITERS FOR MATH**: ALWAYS enclose EVERY mathematical formula, transfer function, variable, fraction, and Greek letter in dollar signs: `$ ... $` for inline math or `$$ ... $$` for centered equations. Example: `$$G(s) = \frac{1}{s(s+1)(s+2)}$$` and `$\omega \to \infty$`. NEVER write raw LaTeX keywords (`\frac`, `\omega`, `\to`) without `$` or `$$` delimiters!
|
|
|
|
| 18 |
3. **NO RAW LATEX DOCUMENT TAGS**: NEVER output `\begin{figure}`, `\includegraphics`, `\caption`, `\centering`, `\section*`, or `\end{figure}`. Use standard markdown headers (e.g. `### Section Title`).
|
| 19 |
4. **MANDATORY TOOL CALL FOR PLOTS**: When asked to plot, visualize, or simulate (Nyquist plot, Bode diagram, Root Locus, Step Response, Phase Portrait), NEVER hallucinate a fake image filename. You MUST explicitly call `execute_python_code` (using `control as ct` or `matplotlib.pyplot`) or `simulate_step_response` to generate and display the real plot!
|
| 20 |
5. **Deterministic Grounding**: When a tool executes successfully, present the exact numerical results and generated plot.
|
|
|
|
| 1 |
+
CONTROLAI_SYSTEM_PROMPT = r"""You are ControlAI, a premier AI research scientist and expert engineering agent for control systems engineering in ALL of its application domains -- aerospace and flight control, automotive and vehicle dynamics, robotics and motion control, industrial automation and PLC/process control, mechatronics, power and energy systems, marine and space vehicles -- together with the underlying applied mathematics, system identification, estimation, and dynamical systems theory.
|
| 2 |
+
|
| 3 |
+
Treat every control question as in scope regardless of the industry it comes from, and answer it in the vocabulary of that domain: an autopilot question deserves flight-dynamics language (short-period mode, actuator rate limits, gain scheduling on airspeed), a vehicle question deserves automotive language (yaw rate, tire slip, ESP/ABS intervention), a robot question deserves manipulator/mobile-robot language (Jacobians, impedance control, odometry drift), and an automation question deserves plant language (loop tuning, valve saturation, cascade and ratio control, sensor filtering).
|
| 4 |
+
|
| 5 |
+
### Matching the Answer to the Question:
|
| 6 |
+
- **Design / applied / "how does X work" questions**: lead with the engineering answer -- the architecture, the loop structure, the tuning trade-off, the sensors and actuators involved, the failure modes and safety interlocks. Use mathematics where it clarifies, not as ceremony.
|
| 7 |
+
- **Numerical questions**: call the deterministic tools and report exact computed values.
|
| 8 |
+
- **Theory, derivation, proof, comparison, and limitation questions**: use the 4-stage standard below.
|
| 9 |
+
- **Definitional or "what does <author/textbook> say" questions**: answer from the retrieved reference passages and cite them; do not run a numeric solver.
|
| 10 |
|
| 11 |
### 4-Stage Mathematical Reasoning & Proof Standard:
|
| 12 |
When answering theoretical principles, derivations, proofs, comparisons, or limitation questions:
|
| 13 |
+
1. **Mathematical Context & System Class**: State the state space equations (e.g. $\dot{x} = Ax + Bu, y = Cx + Du$ or $\dot{x} = f(x) + g(x)u$), signal spaces ($\mathcal{L}_2, \mathcal{H}_\infty$), domain definitions, and underlying assumptions.
|
| 14 |
2. **Canonical Theorem / Analytical Principle**: State the governing theorem with exact mathematical rigor (e.g. PBH rank test, Doyle 1978 LQG robustness counterexample, Poisson Integral for RHP zeros, Small Gain vs Passivity, Lyapunov Invariance).
|
| 15 |
+
3. **Exact Derivation & Closed-Form Formulas**: Provide the full, exact mathematical relationship in pure LaTeX (e.g. both the standard linear approximation $\zeta \approx PM/100$ and the exact non-linear relationship $PM = \arctan\left(\frac{2\zeta}{\sqrt{\sqrt{1+4\zeta^4}-2\zeta^2}}\right)$).
|
| 16 |
+
4. **Engineering Caveats & Breakdown Conditions**: Explicitly state where approximations break down (e.g. $PM > 60^\circ$ or high-frequency non-dominant dynamics), numerical ill-conditioning (e.g. high-order Kalman matrices vs PBH), and physical conservation trade-offs (e.g. Bode sensitivity integral / waterbed effect).
|
| 17 |
|
| 18 |
### Core Deterministic Capabilities & Tool Calling:
|
| 19 |
+
- **State-feedback simulation (`simulate_state_feedback_response`)**: THE tool for "design a controller and simulate it". Pass the original `A`, `B` and the gain `K` returned by `continuous_lqr` / `discrete_lqr` / `place_state_feedback`; it forms the closed loop $A - BK$ internally and returns poles, damping, the exact closed-loop transfer function, transient metrics, and a plot. NEVER expand closed-loop polynomial coefficients by hand and feed them to `simulate_step_response` -- that algebra is the single most common source of silently wrong answers. Add `normalize_dc_gain: true` when the response should track a unit step.
|
| 20 |
+
- **Transfer-function step response (`simulate_step_response`)**: only when the system is genuinely *given* as $G(s) = num/den$.
|
| 21 |
+
- **Frequency domain**: `stability_margins` (GM/PM/crossovers), `bode_analysis`, `nyquist_analysis` (encirclements and the $Z = N + P$ criterion), `root_locus_analysis` (asymptotes, breakaway points, critical gain at instability).
|
| 22 |
+
- Python Code Execution (`execute_python_code`): Write valid Python scripts using `scipy.signal`, `scipy.linalg`, `control` (`import control as ct`), `numpy`, `matplotlib.pyplot`. (Note: `T` in `signal.step` must be a 1D array like `np.linspace(...)`). Prefer a dedicated tool above when one fits -- it is verified and cannot be mis-transcribed. Only reach for this when the question actually requires a number, a simulation, or a plot for a SPECIFIC system. A "design considerations for X", "how does X compare to Y", or "explain X" question with no concrete numbers in it does not need code -- answer in prose. Writing code for a question that doesn't need it only risks a wasted tool step on a shape/dimension mistake with nothing to show for it.
|
| 23 |
+
- **Plain matrix math (`matrix_arithmetic`)**: "A times B", inverse, determinant, rank, transpose, eigenvalues of a given matrix. A request to multiply or invert matrices is a LINEAR ALGEBRA question -- it is not an invitation to design a controller, and it needs no B, Q, or R.
|
| 24 |
+
- Deterministic Math Tools: `continuous_lqr`, `discrete_lqr`, `place_state_feedback`, `exact_zoh`, `eigen_analysis`, `controllability_analysis`, `observability_analysis`, `solve_lyapunov`, `mpc_solve_qp`, `kalman_*`.
|
| 25 |
+
- `plot_math_expression` plots a REAL function of one real variable ($t$ or $x$) only. Never pass a transfer function, a Laplace-domain expression, or anything containing the imaginary unit to it.
|
| 26 |
+
- Reference Lookup (`search_control_references`): Use this -- not a numeric tool -- for conceptual, definitional, or "how does textbook/author X explain Y" questions. A question about what a concept means or how a source presents it is never a reason to (re-)run stability_margins, bode_analysis, routh_hurwitz_analysis, or any other numeric solver. If retrieved reference passages are already provided in this system prompt, answer straight from them and cite `[filename, p. N]`.
|
| 27 |
+
- **One lookup is enough**: after a reference search returns passages, ANSWER from them. Do not re-run the same search with a reworded query ("X", then "X algorithm", then "X definition") -- repeated lookups consume the step budget and leave nothing for the answer itself. If the retrieved passages are thin, answer from your own knowledge and say what is uncertain.
|
| 28 |
+
- **Don't recompute what's already in the conversation**: if a transfer function's margins, poles, or response were already computed earlier in this conversation, reuse those results instead of calling the same numeric tool again for a follow-up question about something else. Answer the question that was actually asked.
|
| 29 |
+
- **Coefficient care**: when you must expand a factored transfer function like $s(s+1)(s+5)$ into polynomial form, expand one factor at a time and re-check each coefficient -- the deterministic tools verify their own arithmetic, not the coefficients you hand them.
|
| 30 |
+
- **NEVER invent a missing parameter, ever -- not a reused one, not a new one, not a "reasonable-looking" one.** Every number you pass to a tool -- every matrix, gain, coefficient -- must come from the user's CURRENT message, from a tool result already in this conversation, or from a standard formula you can name. If a computation needs a parameter (e.g. B, Q, R for LQR) that is not present anywhere and not derivable, STOP. Do not guess a plausible value. Do not carry one over, resized or not, from a different problem earlier in the conversation. Do not make one up because a shape needs to match. Tell the user exactly which parameter is missing and ask for it, or answer only the part of the question you actually can with what was given. A short, honest "I don't have B for this system -- what is it?" is the correct answer. A confident computation built on an invented number is not a partial answer, it is a fabricated one, and it is the single worst thing you can do in this domain. If you catch yourself about to write a matrix or number that did not come from the message, the history, or a named formula, that is the signal to stop and ask instead of proceeding.
|
| 31 |
+
- **A confusing or malformed message is a request for clarification, not a license to substitute a different, cleaner-looking problem.** If the user's wording is ambiguous (e.g. it could mean matrix multiplication, or could mean a controller design, and you cannot tell which), say what you think they might mean and ask, rather than silently picking one interpretation and inventing whatever inputs that interpretation requires.
|
| 32 |
|
| 33 |
### Strict Negative & Formatting Constraints:
|
| 34 |
1. **ZERO EMOJIS**: NEVER use any emojis anywhere in your response (absolutely NO checkmarks, NO pins, NO graphs, NO rockets, NO lightbulbs).
|
| 35 |
2. **ALWAYS USE DOLLAR DELIMITERS FOR MATH**: ALWAYS enclose EVERY mathematical formula, transfer function, variable, fraction, and Greek letter in dollar signs: `$ ... $` for inline math or `$$ ... $$` for centered equations. Example: `$$G(s) = \frac{1}{s(s+1)(s+2)}$$` and `$\omega \to \infty$`. NEVER write raw LaTeX keywords (`\frac`, `\omega`, `\to`) without `$` or `$$` delimiters!
|
| 36 |
+
2b. **SINGLE BACKSLASH ONLY**: Every LaTeX command starts with exactly one backslash character, as in `\zeta`, `\left(`, `\sqrt{}`, `\sin`. Count the backslashes before you write a command and stop at one.
|
| 37 |
3. **NO RAW LATEX DOCUMENT TAGS**: NEVER output `\begin{figure}`, `\includegraphics`, `\caption`, `\centering`, `\section*`, or `\end{figure}`. Use standard markdown headers (e.g. `### Section Title`).
|
| 38 |
4. **MANDATORY TOOL CALL FOR PLOTS**: When asked to plot, visualize, or simulate (Nyquist plot, Bode diagram, Root Locus, Step Response, Phase Portrait), NEVER hallucinate a fake image filename. You MUST explicitly call `execute_python_code` (using `control as ct` or `matplotlib.pyplot`) or `simulate_step_response` to generate and display the real plot!
|
| 39 |
5. **Deterministic Grounding**: When a tool executes successfully, present the exact numerical results and generated plot.
|
|
@@ -3,11 +3,86 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
|
|
|
|
|
|
| 6 |
from collections.abc import Callable
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
import jsonschema
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
class ToolRegistry:
|
| 13 |
"""Registry for deterministic mathematical control tools with strict JSON Schema validation."""
|
|
@@ -43,6 +118,16 @@ class ToolRegistry:
|
|
| 43 |
def get_tool_schemas(self) -> list[dict[str, Any]]:
|
| 44 |
return list(self._schemas.values())
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
| 47 |
if name not in self._tools:
|
| 48 |
return {
|
|
@@ -50,8 +135,9 @@ class ToolRegistry:
|
|
| 50 |
"error": f"Tool '{name}' is not registered. Available tools: {sorted(self._tools.keys())}",
|
| 51 |
}
|
| 52 |
|
| 53 |
-
# 1. Strict JSON Schema Validation
|
| 54 |
param_schema = self._param_schemas[name]
|
|
|
|
| 55 |
try:
|
| 56 |
jsonschema.validate(instance=arguments, schema=param_schema)
|
| 57 |
except jsonschema.ValidationError as schema_err:
|
|
@@ -68,7 +154,7 @@ class ToolRegistry:
|
|
| 68 |
result = func(**arguments)
|
| 69 |
if "status" not in result:
|
| 70 |
result["status"] = "success"
|
| 71 |
-
return result
|
| 72 |
except Exception as exc:
|
| 73 |
return {
|
| 74 |
"status": "error",
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
| 6 |
+
import math
|
| 7 |
+
import re
|
| 8 |
from collections.abc import Callable
|
| 9 |
from typing import Any
|
| 10 |
|
| 11 |
import jsonschema
|
| 12 |
|
| 13 |
+
# Every tool computes internally at full double precision -- this only
|
| 14 |
+
# affects what gets reported back. 6 significant figures is well past any
|
| 15 |
+
# real sensor/actuator precision, so nothing engineering-relevant is lost,
|
| 16 |
+
# while `K = [1.7416573867739407, 0.6719633404417155]` in a chat answer
|
| 17 |
+
# clearly is: raw float64 repr in prose reads as noise, not rigor.
|
| 18 |
+
RESULT_SIGNIFICANT_FIGURES = 6
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _round_significant(x: float, sig: int = RESULT_SIGNIFICANT_FIGURES) -> float:
|
| 22 |
+
if x == 0 or not math.isfinite(x):
|
| 23 |
+
return x
|
| 24 |
+
digits = sig - int(math.floor(math.log10(abs(x)))) - 1
|
| 25 |
+
return round(x, digits)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _round_floats(obj: Any, sig: int = RESULT_SIGNIFICANT_FIGURES) -> Any:
|
| 29 |
+
"""Recursively round every float in a tool result to `sig` significant
|
| 30 |
+
figures, leaving ints, bools, strings, and structure untouched."""
|
| 31 |
+
if isinstance(obj, bool):
|
| 32 |
+
return obj
|
| 33 |
+
if isinstance(obj, float):
|
| 34 |
+
return _round_significant(obj, sig)
|
| 35 |
+
if isinstance(obj, dict):
|
| 36 |
+
return {k: _round_floats(v, sig) for k, v in obj.items()}
|
| 37 |
+
if isinstance(obj, (list, tuple)):
|
| 38 |
+
return type(obj)(_round_floats(v, sig) for v in obj)
|
| 39 |
+
return obj
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _parse_stringified_array(raw: str) -> Any:
|
| 43 |
+
"""Best-effort parse of a numeric array the model wrote as a JSON string.
|
| 44 |
+
|
| 45 |
+
The model sometimes emits `"numerator": "[10]"` or even
|
| 46 |
+
`"denominator": "[1 6 5 0]"` -- a string containing array-shaped text,
|
| 47 |
+
including MATLAB/Numpy space-separated form, instead of an actual JSON
|
| 48 |
+
array. Schema validation correctly rejects that as type "string" where
|
| 49 |
+
"array" is required, and a perfectly usable numeric tool call is lost
|
| 50 |
+
over pure formatting. Recover the intended array where unambiguous.
|
| 51 |
+
"""
|
| 52 |
+
try:
|
| 53 |
+
return json.loads(raw)
|
| 54 |
+
except (json.JSONDecodeError, TypeError):
|
| 55 |
+
pass
|
| 56 |
+
stripped = raw.strip()
|
| 57 |
+
if stripped.startswith("[") and stripped.endswith("]"):
|
| 58 |
+
spaced = re.sub(r"(?<=[\d\.\]])\s+(?=[\-\d\.\[])", ", ", stripped)
|
| 59 |
+
try:
|
| 60 |
+
return json.loads(spaced)
|
| 61 |
+
except json.JSONDecodeError:
|
| 62 |
+
pass
|
| 63 |
+
return raw
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _coerce_array_arguments(arguments: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
|
| 67 |
+
"""Recursively repair string-typed values against `"type": "array"` schema
|
| 68 |
+
properties (including nested arrays, e.g. matrix parameters) before
|
| 69 |
+
validation, so a stringified array no longer fails a tool call outright.
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
def _coerce(value: Any, node: dict[str, Any]) -> Any:
|
| 73 |
+
node_type = node.get("type")
|
| 74 |
+
if node_type == "array" and isinstance(value, str):
|
| 75 |
+
value = _parse_stringified_array(value)
|
| 76 |
+
if node_type == "array" and isinstance(value, list) and "items" in node:
|
| 77 |
+
return [_coerce(v, node["items"]) for v in value]
|
| 78 |
+
return value
|
| 79 |
+
|
| 80 |
+
props = schema.get("properties", {})
|
| 81 |
+
return {
|
| 82 |
+
key: (_coerce(val, props[key]) if key in props else val)
|
| 83 |
+
for key, val in arguments.items()
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
|
| 87 |
class ToolRegistry:
|
| 88 |
"""Registry for deterministic mathematical control tools with strict JSON Schema validation."""
|
|
|
|
| 118 |
def get_tool_schemas(self) -> list[dict[str, Any]]:
|
| 119 |
return list(self._schemas.values())
|
| 120 |
|
| 121 |
+
def get_callables(self, exclude: set[str] = frozenset()) -> dict[str, Callable[..., dict[str, Any]]]:
|
| 122 |
+
"""Name -> underlying function for every registered tool except `exclude`.
|
| 123 |
+
|
| 124 |
+
Used to expose the deterministic tools as plain callables inside the
|
| 125 |
+
execute_python_code sandbox, since the model naturally expects a tool
|
| 126 |
+
it knows by name (e.g. place_state_feedback) to be usable directly in
|
| 127 |
+
code it writes, not only through the separate tool-call protocol.
|
| 128 |
+
"""
|
| 129 |
+
return {name: fn for name, fn in self._tools.items() if name not in exclude}
|
| 130 |
+
|
| 131 |
def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
| 132 |
if name not in self._tools:
|
| 133 |
return {
|
|
|
|
| 135 |
"error": f"Tool '{name}' is not registered. Available tools: {sorted(self._tools.keys())}",
|
| 136 |
}
|
| 137 |
|
| 138 |
+
# 1. Strict JSON Schema Validation (after repairing stringified arrays)
|
| 139 |
param_schema = self._param_schemas[name]
|
| 140 |
+
arguments = _coerce_array_arguments(arguments, param_schema)
|
| 141 |
try:
|
| 142 |
jsonschema.validate(instance=arguments, schema=param_schema)
|
| 143 |
except jsonschema.ValidationError as schema_err:
|
|
|
|
| 154 |
result = func(**arguments)
|
| 155 |
if "status" not in result:
|
| 156 |
result["status"] = "success"
|
| 157 |
+
return _round_floats(result)
|
| 158 |
except Exception as exc:
|
| 159 |
return {
|
| 160 |
"status": "error",
|
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from controlai_agent.tools import (
|
| 4 |
linear,
|
|
|
|
| 5 |
frequency,
|
| 6 |
synthesis,
|
| 7 |
estimation,
|
|
@@ -16,6 +17,7 @@ from controlai_agent.tools import (
|
|
| 16 |
|
| 17 |
__all__ = [
|
| 18 |
"linear",
|
|
|
|
| 19 |
"frequency",
|
| 20 |
"synthesis",
|
| 21 |
"estimation",
|
|
|
|
| 2 |
|
| 3 |
from controlai_agent.tools import (
|
| 4 |
linear,
|
| 5 |
+
matrix_ops,
|
| 6 |
frequency,
|
| 7 |
synthesis,
|
| 8 |
estimation,
|
|
|
|
| 17 |
|
| 18 |
__all__ = [
|
| 19 |
"linear",
|
| 20 |
+
"matrix_ops",
|
| 21 |
"frequency",
|
| 22 |
"synthesis",
|
| 23 |
"estimation",
|
|
@@ -3,13 +3,19 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import math
|
|
|
|
| 6 |
from typing import Any
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
import numpy as np
|
| 9 |
from scipy import signal
|
| 10 |
|
| 11 |
from controlai_agent.registry import registry
|
| 12 |
|
|
|
|
|
|
|
| 13 |
|
| 14 |
@registry.register(
|
| 15 |
name="bode_analysis",
|
|
@@ -154,3 +160,216 @@ def routh_hurwitz_analysis(coefficients: list[float]) -> dict[str, Any]:
|
|
| 154 |
"is_hurwitz_stable": is_hurwitz,
|
| 155 |
"roots": [[float(r.real), float(r.imag)] for r in roots],
|
| 156 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import math
|
| 6 |
+
from pathlib import Path
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
+
import matplotlib
|
| 10 |
+
matplotlib.use("Agg")
|
| 11 |
+
import matplotlib.pyplot as plt
|
| 12 |
import numpy as np
|
| 13 |
from scipy import signal
|
| 14 |
|
| 15 |
from controlai_agent.registry import registry
|
| 16 |
|
| 17 |
+
ARTIFACT_DIR = Path("outputs/plots")
|
| 18 |
+
|
| 19 |
|
| 20 |
@registry.register(
|
| 21 |
name="bode_analysis",
|
|
|
|
| 160 |
"is_hurwitz_stable": is_hurwitz,
|
| 161 |
"roots": [[float(r.real), float(r.imag)] for r in roots],
|
| 162 |
}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@registry.register(
|
| 166 |
+
name="nyquist_analysis",
|
| 167 |
+
description=(
|
| 168 |
+
"Compute the Nyquist plot of an open-loop transfer function L(s) = num(s)/den(s), count "
|
| 169 |
+
"encirclements of the critical point -1+0j, and apply the Nyquist stability criterion "
|
| 170 |
+
"Z = N + P to determine closed-loop stability. Saves a PNG Nyquist diagram."
|
| 171 |
+
),
|
| 172 |
+
parameters_schema={
|
| 173 |
+
"type": "object",
|
| 174 |
+
"properties": {
|
| 175 |
+
"numerator": {
|
| 176 |
+
"type": "array",
|
| 177 |
+
"items": {"type": "number"},
|
| 178 |
+
"description": "Open-loop numerator coefficients in descending powers",
|
| 179 |
+
},
|
| 180 |
+
"denominator": {
|
| 181 |
+
"type": "array",
|
| 182 |
+
"items": {"type": "number"},
|
| 183 |
+
"description": "Open-loop denominator coefficients in descending powers",
|
| 184 |
+
},
|
| 185 |
+
"omega_max": {"type": "number", "default": 100.0, "description": "Maximum frequency in rad/s"},
|
| 186 |
+
},
|
| 187 |
+
"required": ["numerator", "denominator"],
|
| 188 |
+
},
|
| 189 |
+
)
|
| 190 |
+
def nyquist_analysis(
|
| 191 |
+
numerator: list[float],
|
| 192 |
+
denominator: list[float],
|
| 193 |
+
omega_max: float = 100.0,
|
| 194 |
+
) -> dict[str, Any]:
|
| 195 |
+
num = np.array(numerator, dtype=float)
|
| 196 |
+
den = np.array(denominator, dtype=float)
|
| 197 |
+
|
| 198 |
+
# Open-loop poles: P is the count in the open right-half plane. Poles on
|
| 199 |
+
# the imaginary axis (e.g. an integrator at the origin) are excluded --
|
| 200 |
+
# the standard Nyquist contour indents around them.
|
| 201 |
+
ol_poles = np.roots(den) if len(den) > 1 else np.array([])
|
| 202 |
+
P = int(np.sum(np.real(ol_poles) > 1e-9))
|
| 203 |
+
n_origin = int(np.sum(np.abs(ol_poles) < 1e-9))
|
| 204 |
+
|
| 205 |
+
w = np.logspace(-3, np.log10(max(omega_max, 1e-2)), 4000)
|
| 206 |
+
_, H = signal.freqresp(signal.TransferFunction(num, den), w=w)
|
| 207 |
+
|
| 208 |
+
# Z (closed-loop RHP poles) and P (open-loop RHP poles) are both exact
|
| 209 |
+
# root counts, so the encirclement count follows exactly as N = Z - P.
|
| 210 |
+
# Numerically integrating the winding of L(jw)+1 instead is unreliable for
|
| 211 |
+
# systems with poles on the imaginary axis (a type-1 integrator here),
|
| 212 |
+
# where the Nyquist contour must indent around the origin -- that shortcut
|
| 213 |
+
# yields impossible results such as N = -1 with Z = -1.
|
| 214 |
+
closed_loop_poles = (
|
| 215 |
+
np.roots(np.polyadd(den, np.pad(num, (len(den) - len(num), 0))))
|
| 216 |
+
if len(den) >= len(num)
|
| 217 |
+
else np.array([])
|
| 218 |
+
)
|
| 219 |
+
Z = int(np.sum(np.real(closed_loop_poles) > 1e-9)) if closed_loop_poles.size else 0
|
| 220 |
+
N = Z - P
|
| 221 |
+
|
| 222 |
+
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
|
| 223 |
+
plot_path = ARTIFACT_DIR / f"nyquist_{abs(hash((str(numerator), str(denominator)))) % 10**8:08d}.png"
|
| 224 |
+
|
| 225 |
+
fig, ax = plt.subplots(figsize=(6.5, 6.0), dpi=140)
|
| 226 |
+
ax.plot(H.real, H.imag, color="#58a6ff", linewidth=1.8, label="$L(j\\omega)$, $\\omega > 0$")
|
| 227 |
+
ax.plot(H.real, -H.imag, color="#58a6ff", linewidth=1.0, linestyle="--", alpha=0.6, label="$\\omega < 0$ (mirror)")
|
| 228 |
+
ax.plot(-1.0, 0.0, "x", color="#f85149", markersize=11, markeredgewidth=2.5, label="Critical point $-1+0j$")
|
| 229 |
+
ax.axhline(0, color="gray", linewidth=0.7, alpha=0.5)
|
| 230 |
+
ax.axvline(0, color="gray", linewidth=0.7, alpha=0.5)
|
| 231 |
+
ax.set_title("Nyquist Diagram", fontsize=12, fontweight="bold")
|
| 232 |
+
ax.set_xlabel("Real Axis")
|
| 233 |
+
ax.set_ylabel("Imaginary Axis")
|
| 234 |
+
ax.grid(True, linestyle=":", alpha=0.45)
|
| 235 |
+
lim = float(min(max(3.0, np.percentile(np.abs(H), 92)), 25.0))
|
| 236 |
+
ax.set_xlim(-lim, lim)
|
| 237 |
+
ax.set_ylim(-lim, lim)
|
| 238 |
+
ax.set_aspect("equal", adjustable="box")
|
| 239 |
+
ax.legend(loc="best", fontsize=8)
|
| 240 |
+
fig.tight_layout()
|
| 241 |
+
fig.savefig(plot_path)
|
| 242 |
+
plt.close(fig)
|
| 243 |
+
|
| 244 |
+
return {
|
| 245 |
+
"status": "success",
|
| 246 |
+
"encirclements_N": N,
|
| 247 |
+
"open_loop_rhp_poles_P": P,
|
| 248 |
+
"open_loop_poles_at_origin": n_origin,
|
| 249 |
+
"closed_loop_rhp_poles_Z": Z,
|
| 250 |
+
"closed_loop_poles": [[float(p.real), float(p.imag)] for p in closed_loop_poles],
|
| 251 |
+
"is_closed_loop_stable": bool(Z == 0),
|
| 252 |
+
"criterion": "Z = N + P (Z = closed-loop RHP poles, N = clockwise encirclements of -1, P = open-loop RHP poles)",
|
| 253 |
+
"plot_path": str(plot_path),
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
@registry.register(
|
| 258 |
+
name="root_locus_analysis",
|
| 259 |
+
description=(
|
| 260 |
+
"Compute the root locus of the closed-loop characteristic equation 1 + k*L(s) = 0 as the gain "
|
| 261 |
+
"k sweeps from 0 to infinity, for open-loop L(s) = num(s)/den(s). Returns open-loop poles and "
|
| 262 |
+
"zeros, asymptote angles and centroid, real-axis breakaway points, the imaginary-axis crossing "
|
| 263 |
+
"gain (critical gain for stability), and a PNG root locus plot."
|
| 264 |
+
),
|
| 265 |
+
parameters_schema={
|
| 266 |
+
"type": "object",
|
| 267 |
+
"properties": {
|
| 268 |
+
"numerator": {
|
| 269 |
+
"type": "array",
|
| 270 |
+
"items": {"type": "number"},
|
| 271 |
+
"description": "Open-loop numerator coefficients in descending powers",
|
| 272 |
+
},
|
| 273 |
+
"denominator": {
|
| 274 |
+
"type": "array",
|
| 275 |
+
"items": {"type": "number"},
|
| 276 |
+
"description": "Open-loop denominator coefficients in descending powers",
|
| 277 |
+
},
|
| 278 |
+
"k_max": {"type": "number", "default": 100.0, "description": "Maximum gain k to sweep"},
|
| 279 |
+
},
|
| 280 |
+
"required": ["numerator", "denominator"],
|
| 281 |
+
},
|
| 282 |
+
)
|
| 283 |
+
def root_locus_analysis(
|
| 284 |
+
numerator: list[float],
|
| 285 |
+
denominator: list[float],
|
| 286 |
+
k_max: float = 100.0,
|
| 287 |
+
) -> dict[str, Any]:
|
| 288 |
+
num = np.array(numerator, dtype=float)
|
| 289 |
+
den = np.array(denominator, dtype=float)
|
| 290 |
+
|
| 291 |
+
ol_zeros = np.roots(num) if len(num) > 1 else np.array([])
|
| 292 |
+
ol_poles = np.roots(den) if len(den) > 1 else np.array([])
|
| 293 |
+
n_p, n_z = len(ol_poles), len(ol_zeros)
|
| 294 |
+
|
| 295 |
+
# Asymptotes for the n_p - n_z branches heading to infinity
|
| 296 |
+
excess = n_p - n_z
|
| 297 |
+
asymptote_angles, centroid = [], None
|
| 298 |
+
if excess > 0:
|
| 299 |
+
centroid = float((np.sum(ol_poles).real - np.sum(ol_zeros).real) / excess)
|
| 300 |
+
asymptote_angles = [float((180.0 * (2 * i + 1)) / excess) for i in range(excess)]
|
| 301 |
+
|
| 302 |
+
# Sweep gain and collect closed-loop roots of den + k*num
|
| 303 |
+
gains = np.concatenate([[0.0], np.logspace(-3, np.log10(max(k_max, 1e-2)), 600)])
|
| 304 |
+
locus: list[np.ndarray] = []
|
| 305 |
+
for k in gains:
|
| 306 |
+
poly = np.polyadd(den, k * np.pad(num, (max(0, len(den) - len(num)), 0)))
|
| 307 |
+
locus.append(np.roots(poly))
|
| 308 |
+
|
| 309 |
+
# Imaginary-axis crossing: first gain where any root's real part turns >= 0
|
| 310 |
+
k_critical, w_crossing = None, None
|
| 311 |
+
for k, roots in zip(gains, locus):
|
| 312 |
+
if roots.size and np.any(np.real(roots) > 1e-9):
|
| 313 |
+
k_critical = float(k)
|
| 314 |
+
crossing = roots[np.argmax(np.real(roots))]
|
| 315 |
+
w_crossing = float(abs(crossing.imag))
|
| 316 |
+
break
|
| 317 |
+
|
| 318 |
+
# Breakaway/break-in points: real roots of d/ds[-den/num] = 0. Only those
|
| 319 |
+
# lying ON the locus count -- a real point belongs to the locus iff an odd
|
| 320 |
+
# number of real poles and zeros lie strictly to its right, so the
|
| 321 |
+
# remaining stationary points must be discarded.
|
| 322 |
+
real_singularities = [float(p.real) for p in ol_poles if abs(p.imag) < 1e-8]
|
| 323 |
+
real_singularities += [float(z.real) for z in ol_zeros if abs(z.imag) < 1e-8]
|
| 324 |
+
|
| 325 |
+
def _on_real_axis_locus(sigma: float) -> bool:
|
| 326 |
+
to_right = sum(1 for v in real_singularities if v > sigma + 1e-9)
|
| 327 |
+
return to_right % 2 == 1
|
| 328 |
+
|
| 329 |
+
breakaway: list[float] = []
|
| 330 |
+
try:
|
| 331 |
+
dnum, dden = np.polyder(num), np.polyder(den)
|
| 332 |
+
crit = np.polysub(np.polymul(dden, num), np.polymul(den, dnum))
|
| 333 |
+
for r in np.roots(crit):
|
| 334 |
+
if abs(r.imag) < 1e-8 and _on_real_axis_locus(float(r.real)):
|
| 335 |
+
breakaway.append(round(float(r.real), 6))
|
| 336 |
+
except Exception:
|
| 337 |
+
pass
|
| 338 |
+
|
| 339 |
+
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
|
| 340 |
+
plot_path = ARTIFACT_DIR / f"root_locus_{abs(hash((str(numerator), str(denominator)))) % 10**8:08d}.png"
|
| 341 |
+
|
| 342 |
+
fig, ax = plt.subplots(figsize=(7.0, 5.5), dpi=140)
|
| 343 |
+
max_branches = max((r.size for r in locus), default=0)
|
| 344 |
+
for b in range(max_branches):
|
| 345 |
+
pts = np.array([r[b] for r in locus if r.size > b])
|
| 346 |
+
ax.plot(pts.real, pts.imag, color="#58a6ff", linewidth=1.0, alpha=0.85)
|
| 347 |
+
if n_p:
|
| 348 |
+
ax.plot(ol_poles.real, ol_poles.imag, "x", color="#f85149", markersize=10, markeredgewidth=2.2, label="Open-loop poles")
|
| 349 |
+
if n_z:
|
| 350 |
+
ax.plot(ol_zeros.real, ol_zeros.imag, "o", mfc="none", color="#3fb950", markersize=9, markeredgewidth=2.0, label="Open-loop zeros")
|
| 351 |
+
ax.axhline(0, color="gray", linewidth=0.7, alpha=0.5)
|
| 352 |
+
ax.axvline(0, color="gray", linewidth=0.7, alpha=0.5)
|
| 353 |
+
ax.set_title("Root Locus", fontsize=12, fontweight="bold")
|
| 354 |
+
ax.set_xlabel("Real Axis")
|
| 355 |
+
ax.set_ylabel("Imaginary Axis")
|
| 356 |
+
ax.grid(True, linestyle=":", alpha=0.45)
|
| 357 |
+
if n_p or n_z:
|
| 358 |
+
ax.legend(loc="best", fontsize=8)
|
| 359 |
+
fig.tight_layout()
|
| 360 |
+
fig.savefig(plot_path)
|
| 361 |
+
plt.close(fig)
|
| 362 |
+
|
| 363 |
+
return {
|
| 364 |
+
"status": "success",
|
| 365 |
+
"open_loop_poles": [[float(p.real), float(p.imag)] for p in ol_poles],
|
| 366 |
+
"open_loop_zeros": [[float(z.real), float(z.imag)] for z in ol_zeros],
|
| 367 |
+
"num_asymptotes": excess,
|
| 368 |
+
"asymptote_centroid": centroid,
|
| 369 |
+
"asymptote_angles_deg": asymptote_angles,
|
| 370 |
+
"breakaway_points_real_axis": sorted(set(breakaway)),
|
| 371 |
+
"critical_gain_k_at_instability": k_critical,
|
| 372 |
+
"imaginary_axis_crossing_freq_rad_s": w_crossing,
|
| 373 |
+
"is_stable_for_all_swept_gains": bool(k_critical is None),
|
| 374 |
+
"plot_path": str(plot_path),
|
| 375 |
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic linear-algebra tool: the basic matrix operations every other
|
| 2 |
+
analysis builds on. Direct requests like "A times B", "invert this matrix", or
|
| 3 |
+
"eigenvalues of A" must never depend on model-written freehand code -- they get
|
| 4 |
+
one schema-validated tool call."""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
from controlai_agent.registry import registry
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@registry.register(
|
| 16 |
+
name="matrix_arithmetic",
|
| 17 |
+
description=(
|
| 18 |
+
"Exact matrix arithmetic on one or two numeric matrices. THE tool for any direct matrix "
|
| 19 |
+
"computation the user states explicitly: 'A times B' / 'A*B' (operation='multiply'), "
|
| 20 |
+
"addition, subtraction, transpose, inverse, determinant, rank, trace, eigenvalues, or "
|
| 21 |
+
"elementwise multiply. Use this instead of writing Python code for plain matrix math. "
|
| 22 |
+
"This is NOT a controller-design tool: a request to multiply or invert matrices is a "
|
| 23 |
+
"linear-algebra question, not an LQR/pole-placement problem."
|
| 24 |
+
),
|
| 25 |
+
parameters_schema={
|
| 26 |
+
"type": "object",
|
| 27 |
+
"properties": {
|
| 28 |
+
"operation": {
|
| 29 |
+
"type": "string",
|
| 30 |
+
"enum": [
|
| 31 |
+
"multiply",
|
| 32 |
+
"add",
|
| 33 |
+
"subtract",
|
| 34 |
+
"elementwise_multiply",
|
| 35 |
+
"transpose",
|
| 36 |
+
"inverse",
|
| 37 |
+
"determinant",
|
| 38 |
+
"rank",
|
| 39 |
+
"trace",
|
| 40 |
+
"eigenvalues",
|
| 41 |
+
],
|
| 42 |
+
"description": "Which operation to perform. Binary operations use matrix_a (op) matrix_b in that order.",
|
| 43 |
+
},
|
| 44 |
+
"matrix_a": {
|
| 45 |
+
"type": "array",
|
| 46 |
+
"items": {"type": "array", "items": {"type": "number"}},
|
| 47 |
+
"description": "First matrix (2D, row-major). For unary operations this is the only operand.",
|
| 48 |
+
},
|
| 49 |
+
"matrix_b": {
|
| 50 |
+
"type": "array",
|
| 51 |
+
"items": {"type": "array", "items": {"type": "number"}},
|
| 52 |
+
"description": "Second matrix, required for multiply / add / subtract / elementwise_multiply.",
|
| 53 |
+
},
|
| 54 |
+
},
|
| 55 |
+
"required": ["operation", "matrix_a"],
|
| 56 |
+
},
|
| 57 |
+
)
|
| 58 |
+
def matrix_arithmetic(
|
| 59 |
+
operation: str,
|
| 60 |
+
matrix_a: list[list[float]],
|
| 61 |
+
matrix_b: list[list[float]] | None = None,
|
| 62 |
+
) -> dict[str, Any]:
|
| 63 |
+
A = np.array(matrix_a, dtype=float)
|
| 64 |
+
binary_ops = {"multiply", "add", "subtract", "elementwise_multiply"}
|
| 65 |
+
|
| 66 |
+
if operation in binary_ops:
|
| 67 |
+
if matrix_b is None:
|
| 68 |
+
return {"status": "error", "error": f"operation '{operation}' requires matrix_b."}
|
| 69 |
+
B = np.array(matrix_b, dtype=float)
|
| 70 |
+
if operation == "multiply":
|
| 71 |
+
if A.shape[1] != B.shape[0]:
|
| 72 |
+
return {
|
| 73 |
+
"status": "error",
|
| 74 |
+
"error": f"Cannot multiply: matrix_a is {A.shape[0]}x{A.shape[1]} but matrix_b is {B.shape[0]}x{B.shape[1]} (inner dimensions must match).",
|
| 75 |
+
}
|
| 76 |
+
result = A @ B
|
| 77 |
+
elif operation == "elementwise_multiply":
|
| 78 |
+
if A.shape != B.shape:
|
| 79 |
+
return {"status": "error", "error": f"Elementwise multiply needs equal shapes, got {A.shape} and {B.shape}."}
|
| 80 |
+
result = A * B
|
| 81 |
+
else:
|
| 82 |
+
if A.shape != B.shape:
|
| 83 |
+
return {"status": "error", "error": f"'{operation}' needs equal shapes, got {A.shape} and {B.shape}."}
|
| 84 |
+
result = A + B if operation == "add" else A - B
|
| 85 |
+
return {
|
| 86 |
+
"operation": operation,
|
| 87 |
+
"shape_a": list(A.shape),
|
| 88 |
+
"shape_b": list(B.shape),
|
| 89 |
+
"result": result.tolist(),
|
| 90 |
+
"result_shape": list(result.shape),
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
# Unary operations
|
| 94 |
+
out: dict[str, Any] = {"operation": operation, "shape_a": list(A.shape)}
|
| 95 |
+
if operation == "transpose":
|
| 96 |
+
out["result"] = A.T.tolist()
|
| 97 |
+
elif operation == "rank":
|
| 98 |
+
out["result"] = int(np.linalg.matrix_rank(A))
|
| 99 |
+
elif operation in ("inverse", "determinant", "trace", "eigenvalues"):
|
| 100 |
+
if A.shape[0] != A.shape[1]:
|
| 101 |
+
return {"status": "error", "error": f"'{operation}' requires a square matrix, got {A.shape[0]}x{A.shape[1]}."}
|
| 102 |
+
if operation == "inverse":
|
| 103 |
+
det = float(np.linalg.det(A))
|
| 104 |
+
if abs(det) < 1e-12:
|
| 105 |
+
return {"status": "error", "error": f"Matrix is singular (determinant = {det:g}); no inverse exists."}
|
| 106 |
+
out["result"] = np.linalg.inv(A).tolist()
|
| 107 |
+
out["determinant"] = det
|
| 108 |
+
elif operation == "determinant":
|
| 109 |
+
out["result"] = float(np.linalg.det(A))
|
| 110 |
+
elif operation == "trace":
|
| 111 |
+
out["result"] = float(np.trace(A))
|
| 112 |
+
else: # eigenvalues
|
| 113 |
+
eig = np.linalg.eigvals(A)
|
| 114 |
+
out["result"] = [[float(v.real), float(v.imag)] for v in eig]
|
| 115 |
+
out["spectral_radius"] = float(np.max(np.abs(eig)))
|
| 116 |
+
return out
|
|
@@ -3,6 +3,7 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import hashlib
|
|
|
|
| 6 |
import time
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Any
|
|
@@ -49,6 +50,20 @@ SAFE_MATH_ENV: dict[str, Any] = {
|
|
| 49 |
}
|
| 50 |
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
@registry.register(
|
| 53 |
name="plot_math_expression",
|
| 54 |
description="Plot any mathematical function, curve, or signal f(t) or f(x) (e.g. 'sin(t)', 'exp(-t)*cos(2*t)', 'sin(x)', 't**2 - 3*t') and save the plot figure.",
|
|
@@ -116,28 +131,53 @@ def plot_math_expression(
|
|
| 116 |
if np.isscalar(y_vals):
|
| 117 |
y_vals = np.full_like(t_vals, float(y_vals))
|
| 118 |
else:
|
| 119 |
-
y_vals = np.asarray(y_vals
|
| 120 |
except Exception as exc:
|
| 121 |
return {
|
| 122 |
"status": "error",
|
| 123 |
"error": f"Failed to evaluate expression '{expression}': {exc}",
|
| 124 |
}
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
# Plot styling
|
| 127 |
fig, ax = plt.subplots(figsize=(8, 4.2), dpi=120)
|
| 128 |
fig.patch.set_facecolor("#151b23")
|
| 129 |
ax.set_facecolor("#0f1217")
|
| 130 |
|
| 131 |
line_color = "#58a6ff"
|
| 132 |
-
|
|
|
|
| 133 |
ax.axhline(0, color="#484f58", linestyle="--", linewidth=0.8, alpha=0.7)
|
| 134 |
ax.axvline(0, color="#484f58", linestyle="--", linewidth=0.8, alpha=0.7)
|
| 135 |
|
| 136 |
-
plot_title = title or f"Plot of $f({var_name}) = {
|
| 137 |
plot_xlabel = xlabel or var_name
|
| 138 |
plot_ylabel = ylabel or f"f({var_name})"
|
| 139 |
|
| 140 |
-
ax.set_title(plot_title, color="#f0f6fc", fontsize=12, pad=10, fontweight="bold")
|
| 141 |
ax.set_xlabel(plot_xlabel, color="#8b949e", fontsize=10)
|
| 142 |
ax.set_ylabel(plot_ylabel, color="#8b949e", fontsize=10)
|
| 143 |
ax.tick_params(colors="#8b949e", labelsize=9)
|
|
@@ -152,7 +192,15 @@ def plot_math_expression(
|
|
| 152 |
# Save unique plot
|
| 153 |
hash_str = hashlib.md5(f"{clean_expr}_{t_start}_{t_end}_{time.time()}".encode()).hexdigest()[:8]
|
| 154 |
out_file = PLOTS_DIR / f"plot_{hash_str}.png"
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
plt.close(fig)
|
| 157 |
|
| 158 |
return {
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import hashlib
|
| 6 |
+
import re
|
| 7 |
import time
|
| 8 |
from pathlib import Path
|
| 9 |
from typing import Any
|
|
|
|
| 50 |
}
|
| 51 |
|
| 52 |
|
| 53 |
+
def _expr_to_mathtext(expr: str) -> str:
|
| 54 |
+
"""Best-effort conversion of a Python-style expression into valid matplotlib mathtext.
|
| 55 |
+
|
| 56 |
+
Model-generated expressions use Python syntax (`**` for power, bare `*` for
|
| 57 |
+
multiplication) which mathtext does not understand -- it renders the raw
|
| 58 |
+
asterisks literally (e.g. "t * *2 - 3 * t") instead of a clean formula.
|
| 59 |
+
"""
|
| 60 |
+
text = expr.replace("**", "^")
|
| 61 |
+
text = re.sub(r"\^\(([^()]+)\)", r"^{\1}", text)
|
| 62 |
+
text = re.sub(r"\^([a-zA-Z0-9_.]{2,})", r"^{\1}", text)
|
| 63 |
+
text = text.replace("*", r" \cdot ")
|
| 64 |
+
return text
|
| 65 |
+
|
| 66 |
+
|
| 67 |
@registry.register(
|
| 68 |
name="plot_math_expression",
|
| 69 |
description="Plot any mathematical function, curve, or signal f(t) or f(x) (e.g. 'sin(t)', 'exp(-t)*cos(2*t)', 'sin(x)', 't**2 - 3*t') and save the plot figure.",
|
|
|
|
| 131 |
if np.isscalar(y_vals):
|
| 132 |
y_vals = np.full_like(t_vals, float(y_vals))
|
| 133 |
else:
|
| 134 |
+
y_vals = np.asarray(y_vals)
|
| 135 |
except Exception as exc:
|
| 136 |
return {
|
| 137 |
"status": "error",
|
| 138 |
"error": f"Failed to evaluate expression '{expression}': {exc}",
|
| 139 |
}
|
| 140 |
|
| 141 |
+
# A complex result means the expression was not a real-valued signal --
|
| 142 |
+
# most often a Laplace/transfer-function expression containing `1j` that
|
| 143 |
+
# was mistakenly passed to a time-domain plotter. Silently casting it to
|
| 144 |
+
# float discards the imaginary part and yields a meaningless curve, so
|
| 145 |
+
# reject it and tell the model what to do instead.
|
| 146 |
+
if np.iscomplexobj(y_vals):
|
| 147 |
+
return {
|
| 148 |
+
"status": "error",
|
| 149 |
+
"error": (
|
| 150 |
+
f"Expression '{expression}' evaluates to complex values, so it is not a real "
|
| 151 |
+
"time-domain signal that can be plotted. Do not pass transfer functions or "
|
| 152 |
+
"expressions containing the imaginary unit here -- to simulate a system's response "
|
| 153 |
+
"use simulate_step_response (transfer function) or simulate_state_feedback_response "
|
| 154 |
+
"(state-space with optional gain K)."
|
| 155 |
+
),
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
y_vals = np.asarray(y_vals, dtype=float)
|
| 159 |
+
if not np.any(np.isfinite(y_vals)):
|
| 160 |
+
return {
|
| 161 |
+
"status": "error",
|
| 162 |
+
"error": f"Expression '{expression}' produced no finite values over the range [{t_start}, {t_end}].",
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
# Plot styling
|
| 166 |
fig, ax = plt.subplots(figsize=(8, 4.2), dpi=120)
|
| 167 |
fig.patch.set_facecolor("#151b23")
|
| 168 |
ax.set_facecolor("#0f1217")
|
| 169 |
|
| 170 |
line_color = "#58a6ff"
|
| 171 |
+
mathtext_expr = _expr_to_mathtext(expression)
|
| 172 |
+
(line,) = ax.plot(t_vals, y_vals, color=line_color, linewidth=2, label=f"${mathtext_expr}$")
|
| 173 |
ax.axhline(0, color="#484f58", linestyle="--", linewidth=0.8, alpha=0.7)
|
| 174 |
ax.axvline(0, color="#484f58", linestyle="--", linewidth=0.8, alpha=0.7)
|
| 175 |
|
| 176 |
+
plot_title = title or f"Plot of $f({var_name}) = {mathtext_expr}$"
|
| 177 |
plot_xlabel = xlabel or var_name
|
| 178 |
plot_ylabel = ylabel or f"f({var_name})"
|
| 179 |
|
| 180 |
+
title_obj = ax.set_title(plot_title, color="#f0f6fc", fontsize=12, pad=10, fontweight="bold")
|
| 181 |
ax.set_xlabel(plot_xlabel, color="#8b949e", fontsize=10)
|
| 182 |
ax.set_ylabel(plot_ylabel, color="#8b949e", fontsize=10)
|
| 183 |
ax.tick_params(colors="#8b949e", labelsize=9)
|
|
|
|
| 192 |
# Save unique plot
|
| 193 |
hash_str = hashlib.md5(f"{clean_expr}_{t_start}_{t_end}_{time.time()}".encode()).hexdigest()[:8]
|
| 194 |
out_file = PLOTS_DIR / f"plot_{hash_str}.png"
|
| 195 |
+
try:
|
| 196 |
+
plt.savefig(str(out_file), facecolor=fig.get_facecolor(), edgecolor="none", dpi=120)
|
| 197 |
+
except Exception:
|
| 198 |
+
# Mathtext couldn't parse the expression (rare, malformed LaTeX-ish input)
|
| 199 |
+
# -- fall back to plain, non-math text labels rather than losing the plot.
|
| 200 |
+
line.set_label(expression)
|
| 201 |
+
title_obj.set_text(title or f"Plot of f({var_name}) = {expression}")
|
| 202 |
+
ax.legend(loc="best", facecolor="#151b23", edgecolor="#30363d", labelcolor="#f0f6fc", fontsize=9)
|
| 203 |
+
plt.savefig(str(out_file), facecolor=fig.get_facecolor(), edgecolor="none", dpi=120)
|
| 204 |
plt.close(fig)
|
| 205 |
|
| 206 |
return {
|
|
@@ -29,7 +29,27 @@ PLOTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
| 29 |
|
| 30 |
@registry.register(
|
| 31 |
name="execute_python_code",
|
| 32 |
-
description=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
parameters_schema={
|
| 34 |
"type": "object",
|
| 35 |
"properties": {
|
|
@@ -59,7 +79,7 @@ def execute_python_code(code: str) -> dict[str, Any]:
|
|
| 59 |
plt.rcParams["grid.linestyle"] = ":"
|
| 60 |
plt.rcParams["font.sans-serif"] = ["DejaVu Sans", "Helvetica", "Arial"]
|
| 61 |
|
| 62 |
-
exec_globals = {
|
| 63 |
"np": np,
|
| 64 |
"numpy": np,
|
| 65 |
"scipy": scipy,
|
|
@@ -70,6 +90,12 @@ def execute_python_code(code: str) -> dict[str, Any]:
|
|
| 70 |
"ct": ct,
|
| 71 |
"control": ct,
|
| 72 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
saved_plots = []
|
| 75 |
|
|
|
|
| 29 |
|
| 30 |
@registry.register(
|
| 31 |
name="execute_python_code",
|
| 32 |
+
description=(
|
| 33 |
+
"Execute Python code for control engineering, numerical simulations, differential equations, "
|
| 34 |
+
"optimization, and signal plotting (similar to a Jupyter Notebook cell). Can use numpy (np), "
|
| 35 |
+
"scipy (scipy), scipy.signal (signal), scipy.linalg (linalg), control (ct, control), and "
|
| 36 |
+
"matplotlib.pyplot (plt). Captures stdout and any generated Matplotlib figures. "
|
| 37 |
+
"IMPORTANT -- the `control` package (ct) takes POSITIONAL arguments only, never num=/den= "
|
| 38 |
+
"or sys1=/sys2= keywords (they raise 'Needs 1, 2, or 3 arguments'): "
|
| 39 |
+
"ct.tf(num, den) not ct.tf(num=num, den=den); "
|
| 40 |
+
"ct.feedback(sys1, sys2=1, sign=-1) for a closed loop; "
|
| 41 |
+
"ct.series(sys1, sys2) and ct.parallel(sys1, sys2); "
|
| 42 |
+
"ct.step_response(sys, T=t_array) returns (T, yout); "
|
| 43 |
+
"ct.poles(sys) and ct.zeros(sys) for pole/zero locations; "
|
| 44 |
+
"ct.bode(sys) / ct.bode_plot(sys) is PLOT-ONLY and does not return (mag, phase, omega) arrays "
|
| 45 |
+
"-- for numeric Bode data use resp = ct.frequency_response(sys, omega); "
|
| 46 |
+
"resp.magnitude, resp.phase (radians), resp.omega. "
|
| 47 |
+
"Every other registered tool (continuous_lqr, discrete_lqr, place_state_feedback, "
|
| 48 |
+
"stability_margins, exact_zoh, etc.) is ALSO directly callable here by its exact name with "
|
| 49 |
+
"its normal arguments -- e.g. `result = place_state_feedback(A=A, B=B, desired_poles=poles)`. "
|
| 50 |
+
"Each returns a dict just like the standalone tool call does, so pull out the field you need, "
|
| 51 |
+
"e.g. `K = np.array(result['K'])`, before using it in further computation."
|
| 52 |
+
),
|
| 53 |
parameters_schema={
|
| 54 |
"type": "object",
|
| 55 |
"properties": {
|
|
|
|
| 79 |
plt.rcParams["grid.linestyle"] = ":"
|
| 80 |
plt.rcParams["font.sans-serif"] = ["DejaVu Sans", "Helvetica", "Arial"]
|
| 81 |
|
| 82 |
+
exec_globals: dict[str, Any] = {
|
| 83 |
"np": np,
|
| 84 |
"numpy": np,
|
| 85 |
"scipy": scipy,
|
|
|
|
| 90 |
"ct": ct,
|
| 91 |
"control": ct,
|
| 92 |
}
|
| 93 |
+
# Every other registered deterministic tool (continuous_lqr, place_state_feedback,
|
| 94 |
+
# stability_margins, ...) is also callable directly by name here, with its
|
| 95 |
+
# normal keyword arguments and dict return value -- the model otherwise
|
| 96 |
+
# reasonably expects a tool it knows to be usable in code it writes, not
|
| 97 |
+
# only through the separate tool-call protocol, and hits a NameError.
|
| 98 |
+
exec_globals.update(registry.get_callables(exclude={"execute_python_code"}))
|
| 99 |
|
| 100 |
saved_plots = []
|
| 101 |
|
|
@@ -5,9 +5,7 @@ from __future__ import annotations
|
|
| 5 |
from typing import Any
|
| 6 |
|
| 7 |
from controlai_agent.registry import registry
|
| 8 |
-
from controlai_rag.index import
|
| 9 |
-
|
| 10 |
-
rag_index = ControlRAGIndex()
|
| 11 |
|
| 12 |
|
| 13 |
@registry.register(
|
|
@@ -40,7 +38,9 @@ def search_control_references(
|
|
| 40 |
top_k: int = 3,
|
| 41 |
source_filter: str | None = None,
|
| 42 |
) -> dict[str, Any]:
|
| 43 |
-
|
|
|
|
|
|
|
| 44 |
if not hits:
|
| 45 |
return {
|
| 46 |
"query": query,
|
|
@@ -51,7 +51,10 @@ def search_control_references(
|
|
| 51 |
formatted_passages = []
|
| 52 |
for hit in hits:
|
| 53 |
formatted_passages.append({
|
| 54 |
-
"citation":
|
|
|
|
|
|
|
|
|
|
| 55 |
"source_path": hit["source"],
|
| 56 |
"relevance_score": hit["score"],
|
| 57 |
"content": hit["text"][:600],
|
|
|
|
| 5 |
from typing import Any
|
| 6 |
|
| 7 |
from controlai_agent.registry import registry
|
| 8 |
+
from controlai_rag.index import get_shared_index
|
|
|
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
@registry.register(
|
|
|
|
| 38 |
top_k: int = 3,
|
| 39 |
source_filter: str | None = None,
|
| 40 |
) -> dict[str, Any]:
|
| 41 |
+
# Resolved per call (not at import) so newly uploaded documents are visible
|
| 42 |
+
# immediately, without restarting the server.
|
| 43 |
+
hits = get_shared_index().search(query=query, top_k=top_k, source_filter=source_filter)
|
| 44 |
if not hits:
|
| 45 |
return {
|
| 46 |
"query": query,
|
|
|
|
| 51 |
formatted_passages = []
|
| 52 |
for hit in hits:
|
| 53 |
formatted_passages.append({
|
| 54 |
+
"citation": (
|
| 55 |
+
f"[{hit.get('source_name') or hit['filename']}"
|
| 56 |
+
+ (f", p. {hit['page']}]" if hit.get("page") else "]")
|
| 57 |
+
),
|
| 58 |
"source_path": hit["source"],
|
| 59 |
"relevance_score": hit["score"],
|
| 60 |
"content": hit["text"][:600],
|
|
@@ -17,9 +17,171 @@ from controlai_agent.registry import registry
|
|
| 17 |
ARTIFACT_DIR = Path("outputs/plots")
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
@registry.register(
|
| 21 |
name="simulate_step_response",
|
| 22 |
-
description=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
parameters_schema={
|
| 24 |
"type": "object",
|
| 25 |
"properties": {
|
|
@@ -50,21 +212,10 @@ def simulate_step_response(
|
|
| 50 |
sys = signal.TransferFunction(numerator, denominator)
|
| 51 |
t = np.linspace(0, sim_time, 1000)
|
| 52 |
t_out, y_out = signal.step(sys, T=t)
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
y_final =
|
| 56 |
-
y_peak = float(np.max(y_out))
|
| 57 |
-
t_peak = float(t_out[int(np.argmax(y_out))])
|
| 58 |
-
overshoot_pct = float(max(0.0, (y_peak - y_final) / abs(y_final) * 100.0)) if abs(y_final) > 1e-6 else 0.0
|
| 59 |
-
|
| 60 |
-
# 10% to 90% Rise Time
|
| 61 |
-
idx_10 = np.where(y_out >= 0.1 * y_final)[0]
|
| 62 |
-
idx_90 = np.where(y_out >= 0.9 * y_final)[0]
|
| 63 |
-
rise_time = float(t_out[idx_90[0]] - t_out[idx_10[0]]) if len(idx_10) > 0 and len(idx_90) > 0 else None
|
| 64 |
-
|
| 65 |
-
# 2% Settling Time
|
| 66 |
-
settled_indices = np.where(np.abs(y_out - y_final) > 0.02 * abs(y_final))[0]
|
| 67 |
-
settling_time = float(t_out[settled_indices[-1]]) if len(settled_indices) > 0 else 0.0
|
| 68 |
|
| 69 |
# Generate Matplotlib PNG Plot
|
| 70 |
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -85,11 +236,11 @@ def simulate_step_response(
|
|
| 85 |
plt.close(fig)
|
| 86 |
|
| 87 |
return {
|
| 88 |
-
"
|
| 89 |
-
|
| 90 |
-
"
|
| 91 |
-
"
|
| 92 |
-
|
| 93 |
-
"
|
| 94 |
"plot_artifact_path": str(plot_path),
|
| 95 |
}
|
|
|
|
| 17 |
ARTIFACT_DIR = Path("outputs/plots")
|
| 18 |
|
| 19 |
|
| 20 |
+
def _step_metrics(t_out: np.ndarray, y_out: np.ndarray) -> dict[str, Any]:
|
| 21 |
+
"""Standard transient response metrics shared by the simulation tools."""
|
| 22 |
+
y_final = float(y_out[-1])
|
| 23 |
+
y_peak = float(np.max(y_out))
|
| 24 |
+
t_peak = float(t_out[int(np.argmax(y_out))])
|
| 25 |
+
overshoot_pct = (
|
| 26 |
+
float(max(0.0, (y_peak - y_final) / abs(y_final) * 100.0)) if abs(y_final) > 1e-6 else 0.0
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
idx_10 = np.where(y_out >= 0.1 * y_final)[0]
|
| 30 |
+
idx_90 = np.where(y_out >= 0.9 * y_final)[0]
|
| 31 |
+
rise_time = float(t_out[idx_90[0]] - t_out[idx_10[0]]) if len(idx_10) > 0 and len(idx_90) > 0 else None
|
| 32 |
+
|
| 33 |
+
settled = np.where(np.abs(y_out - y_final) > 0.02 * abs(y_final))[0]
|
| 34 |
+
settling_time = float(t_out[settled[-1]]) if len(settled) > 0 else 0.0
|
| 35 |
+
|
| 36 |
+
return {
|
| 37 |
+
"final_value": y_final,
|
| 38 |
+
"peak_value": y_peak,
|
| 39 |
+
"peak_time_seconds": t_peak,
|
| 40 |
+
"overshoot_percentage": overshoot_pct,
|
| 41 |
+
"rise_time_seconds": rise_time,
|
| 42 |
+
"settling_time_2pct_seconds": settling_time,
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@registry.register(
|
| 47 |
+
name="simulate_state_feedback_response",
|
| 48 |
+
description=(
|
| 49 |
+
"Simulate the step response of a state-space system directly from matrices, optionally under "
|
| 50 |
+
"state feedback u = -Kx. USE THIS (never a hand-derived transfer function) whenever you have "
|
| 51 |
+
"A, B and a gain K from continuous_lqr, discrete_lqr, or place_state_feedback: it builds the "
|
| 52 |
+
"closed-loop system A - B*K internally, so you never have to expand closed-loop polynomial "
|
| 53 |
+
"coefficients by hand. Returns the closed-loop matrix, poles, damping, the exact closed-loop "
|
| 54 |
+
"transfer function coefficients, transient metrics, and a PNG plot."
|
| 55 |
+
),
|
| 56 |
+
parameters_schema={
|
| 57 |
+
"type": "object",
|
| 58 |
+
"properties": {
|
| 59 |
+
"A": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "Open-loop state matrix A"},
|
| 60 |
+
"B": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "Input matrix B"},
|
| 61 |
+
"K": {
|
| 62 |
+
"type": "array",
|
| 63 |
+
"items": {"type": "array", "items": {"type": "number"}},
|
| 64 |
+
"description": "Optional state feedback gain K (from LQR/pole placement). If given, simulates closed loop A - B*K. Omit for open-loop.",
|
| 65 |
+
},
|
| 66 |
+
"C": {
|
| 67 |
+
"type": "array",
|
| 68 |
+
"items": {"type": "array", "items": {"type": "number"}},
|
| 69 |
+
"description": "Optional output matrix C. Defaults to [[1, 0, ..., 0]] (measures the first state).",
|
| 70 |
+
},
|
| 71 |
+
"D": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "Optional feedthrough matrix D (defaults to zero)."},
|
| 72 |
+
"normalize_dc_gain": {
|
| 73 |
+
"type": "boolean",
|
| 74 |
+
"default": False,
|
| 75 |
+
"description": "If true, scale the input by a precompensator so the step response settles at 1.0 (removes steady-state offset inherent to pure state feedback).",
|
| 76 |
+
},
|
| 77 |
+
"sim_time": {"type": "number", "default": 10.0, "description": "Total simulation time in seconds"},
|
| 78 |
+
"plot_title": {"type": "string", "default": "Closed-Loop Step Response", "description": "Title for the saved plot"},
|
| 79 |
+
},
|
| 80 |
+
"required": ["A", "B"],
|
| 81 |
+
},
|
| 82 |
+
)
|
| 83 |
+
def simulate_state_feedback_response(
|
| 84 |
+
A: list[list[float]],
|
| 85 |
+
B: list[list[float]],
|
| 86 |
+
K: list[list[float]] | None = None,
|
| 87 |
+
C: list[list[float]] | None = None,
|
| 88 |
+
D: list[list[float]] | None = None,
|
| 89 |
+
normalize_dc_gain: bool = False,
|
| 90 |
+
sim_time: float = 10.0,
|
| 91 |
+
plot_title: str = "Closed-Loop Step Response",
|
| 92 |
+
) -> dict[str, Any]:
|
| 93 |
+
A_mat = np.atleast_2d(np.array(A, dtype=float))
|
| 94 |
+
B_mat = np.array(B, dtype=float)
|
| 95 |
+
if B_mat.ndim == 1:
|
| 96 |
+
B_mat = B_mat.reshape(-1, 1)
|
| 97 |
+
|
| 98 |
+
n = A_mat.shape[0]
|
| 99 |
+
if A_mat.shape[0] != A_mat.shape[1]:
|
| 100 |
+
return {"status": "error", "error": f"A must be square, got shape {A_mat.shape}."}
|
| 101 |
+
if B_mat.shape[0] != n:
|
| 102 |
+
return {"status": "error", "error": f"B row count {B_mat.shape[0]} does not match A dimension {n}."}
|
| 103 |
+
|
| 104 |
+
# Closed loop under u = -Kx (the step is then applied as the reference input)
|
| 105 |
+
K_mat = None
|
| 106 |
+
if K is not None:
|
| 107 |
+
K_mat = np.atleast_2d(np.array(K, dtype=float))
|
| 108 |
+
if K_mat.shape[1] != n:
|
| 109 |
+
return {"status": "error", "error": f"K must have {n} columns to match the state dimension, got {K_mat.shape}."}
|
| 110 |
+
A_eff = A_mat - B_mat @ K_mat
|
| 111 |
+
else:
|
| 112 |
+
A_eff = A_mat
|
| 113 |
+
|
| 114 |
+
C_mat = np.atleast_2d(np.array(C, dtype=float)) if C is not None else np.eye(1, n)
|
| 115 |
+
D_mat = np.atleast_2d(np.array(D, dtype=float)) if D is not None else np.zeros((C_mat.shape[0], B_mat.shape[1]))
|
| 116 |
+
|
| 117 |
+
poles = np.linalg.eigvals(A_eff)
|
| 118 |
+
|
| 119 |
+
# Optional precompensator so the closed loop actually tracks a unit step
|
| 120 |
+
dc_scale = 1.0
|
| 121 |
+
if normalize_dc_gain:
|
| 122 |
+
try:
|
| 123 |
+
dc = float(-(C_mat @ np.linalg.solve(A_eff, B_mat) - D_mat).ravel()[0])
|
| 124 |
+
if abs(dc) > 1e-12:
|
| 125 |
+
dc_scale = 1.0 / dc
|
| 126 |
+
except np.linalg.LinAlgError:
|
| 127 |
+
dc_scale = 1.0
|
| 128 |
+
|
| 129 |
+
sys = signal.StateSpace(A_eff, B_mat * dc_scale, C_mat, D_mat)
|
| 130 |
+
t = np.linspace(0, sim_time, 1000)
|
| 131 |
+
t_out, y_out = signal.step(sys, T=t)
|
| 132 |
+
y_out = np.asarray(y_out, dtype=float).ravel()
|
| 133 |
+
|
| 134 |
+
num, den = signal.ss2tf(A_eff, B_mat, C_mat, D_mat)
|
| 135 |
+
|
| 136 |
+
metrics = _step_metrics(t_out, y_out)
|
| 137 |
+
|
| 138 |
+
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
|
| 139 |
+
plot_path = ARTIFACT_DIR / f"state_feedback_step_{abs(hash((str(A), str(B), str(K), sim_time))) % 10**8:08d}.png"
|
| 140 |
+
|
| 141 |
+
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=150)
|
| 142 |
+
ax.plot(t_out, y_out, color="#58a6ff", linewidth=2.0, label="Response $y(t)$")
|
| 143 |
+
y_final = metrics["final_value"]
|
| 144 |
+
ax.axhline(y_final, color="#f85149", linestyle="--", alpha=0.8, label=f"Final Value ({y_final:.4f})")
|
| 145 |
+
ax.axhline(y_final * 1.02, color="gray", linestyle=":", alpha=0.5)
|
| 146 |
+
ax.axhline(y_final * 0.98, color="gray", linestyle=":", alpha=0.5, label="2% Settling Band")
|
| 147 |
+
ax.set_title(plot_title, fontsize=12, fontweight="bold")
|
| 148 |
+
ax.set_xlabel("Time [seconds]", fontsize=10)
|
| 149 |
+
ax.set_ylabel("Output Amplitude", fontsize=10)
|
| 150 |
+
ax.grid(True, linestyle="--", alpha=0.4)
|
| 151 |
+
ax.legend(loc="best")
|
| 152 |
+
fig.tight_layout()
|
| 153 |
+
fig.savefig(plot_path)
|
| 154 |
+
plt.close(fig)
|
| 155 |
+
|
| 156 |
+
wn = [float(abs(p)) for p in poles]
|
| 157 |
+
zeta = [float(-np.real(p) / abs(p)) if abs(p) > 1e-12 else 0.0 for p in poles]
|
| 158 |
+
|
| 159 |
+
return {
|
| 160 |
+
"status": "success",
|
| 161 |
+
"mode": "closed_loop_state_feedback" if K_mat is not None else "open_loop",
|
| 162 |
+
"closed_loop_A": A_eff.tolist(),
|
| 163 |
+
"poles": [[float(p.real), float(p.imag)] for p in poles],
|
| 164 |
+
"natural_frequencies_rad_s": wn,
|
| 165 |
+
"damping_ratios": zeta,
|
| 166 |
+
"is_stable": bool(np.all(np.real(poles) < 0)),
|
| 167 |
+
"closed_loop_tf_numerator": np.asarray(num).ravel().tolist(),
|
| 168 |
+
"closed_loop_tf_denominator": np.asarray(den).ravel().tolist(),
|
| 169 |
+
"dc_precompensator_applied": dc_scale if normalize_dc_gain else None,
|
| 170 |
+
**metrics,
|
| 171 |
+
"plot_path": str(plot_path),
|
| 172 |
+
"plot_artifact_path": str(plot_path),
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
@registry.register(
|
| 177 |
name="simulate_step_response",
|
| 178 |
+
description=(
|
| 179 |
+
"Simulate the unit step response of a continuous transfer function G(s) = num(s)/den(s), compute "
|
| 180 |
+
"rise time, overshoot, settling time, and save a high-resolution PNG plot artifact. Only use this "
|
| 181 |
+
"when the system is genuinely given to you as a transfer function -- if you have state-space "
|
| 182 |
+
"matrices A, B and/or a feedback gain K, call simulate_state_feedback_response instead rather "
|
| 183 |
+
"than deriving closed-loop coefficients yourself."
|
| 184 |
+
),
|
| 185 |
parameters_schema={
|
| 186 |
"type": "object",
|
| 187 |
"properties": {
|
|
|
|
| 212 |
sys = signal.TransferFunction(numerator, denominator)
|
| 213 |
t = np.linspace(0, sim_time, 1000)
|
| 214 |
t_out, y_out = signal.step(sys, T=t)
|
| 215 |
+
y_out = np.asarray(y_out, dtype=float).ravel()
|
| 216 |
|
| 217 |
+
metrics = _step_metrics(t_out, y_out)
|
| 218 |
+
y_final = metrics["final_value"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
|
| 220 |
# Generate Matplotlib PNG Plot
|
| 221 |
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 236 |
plt.close(fig)
|
| 237 |
|
| 238 |
return {
|
| 239 |
+
"status": "success",
|
| 240 |
+
**metrics,
|
| 241 |
+
# "plot_path" is the key the orchestrator looks for when surfacing
|
| 242 |
+
# generated figures to the UI; "plot_artifact_path" is kept for
|
| 243 |
+
# backward compatibility with existing benchmark/eval artifacts.
|
| 244 |
+
"plot_path": str(plot_path),
|
| 245 |
"plot_artifact_path": str(plot_path),
|
| 246 |
}
|
|
@@ -87,7 +87,12 @@ def discrete_lqr(
|
|
| 87 |
|
| 88 |
@registry.register(
|
| 89 |
name="place_state_feedback",
|
| 90 |
-
description=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
parameters_schema={
|
| 92 |
"type": "object",
|
| 93 |
"properties": {
|
|
@@ -95,28 +100,46 @@ def discrete_lqr(
|
|
| 95 |
"B": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
|
| 96 |
"desired_poles": {
|
| 97 |
"type": "array",
|
| 98 |
-
"items": {
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
},
|
| 101 |
},
|
| 102 |
"required": ["A", "B", "desired_poles"],
|
| 103 |
},
|
| 104 |
)
|
| 105 |
def place_state_feedback(
|
| 106 |
-
A: list[list[float]], B: list[list[float]], desired_poles: list[float]
|
| 107 |
) -> dict[str, Any]:
|
| 108 |
A_mat = np.array(A, dtype=float)
|
| 109 |
B_mat = np.array(B, dtype=float)
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
placed = signal.place_poles(A_mat, B_mat, des)
|
| 112 |
-
K
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
closed_poles = np.linalg.eigvals(A_mat - B_mat @ K)
|
| 114 |
|
| 115 |
-
v_report = verifier.verify_pole_placement(A_mat, B_mat, K,
|
| 116 |
return {
|
| 117 |
"K": K.tolist(),
|
| 118 |
"closed_loop_poles": [[float(p.real), float(p.imag)] for p in closed_poles],
|
| 119 |
-
"target_poles":
|
| 120 |
"verification": v_report,
|
| 121 |
}
|
| 122 |
|
|
|
|
| 87 |
|
| 88 |
@registry.register(
|
| 89 |
name="place_state_feedback",
|
| 90 |
+
description=(
|
| 91 |
+
"Compute state feedback gain matrix K such that eig(A - B*K) matches target desired poles. "
|
| 92 |
+
"Each entry of desired_poles is either a real number (a real pole) or a [real, imag] pair "
|
| 93 |
+
"(one half of a complex-conjugate pair -- e.g. targeting a damping ratio/natural frequency "
|
| 94 |
+
"needs poles like [-2, 3] and [-2, -3], both halves listed explicitly)."
|
| 95 |
+
),
|
| 96 |
parameters_schema={
|
| 97 |
"type": "object",
|
| 98 |
"properties": {
|
|
|
|
| 100 |
"B": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
|
| 101 |
"desired_poles": {
|
| 102 |
"type": "array",
|
| 103 |
+
"items": {
|
| 104 |
+
"oneOf": [
|
| 105 |
+
{"type": "number"},
|
| 106 |
+
{"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
|
| 107 |
+
]
|
| 108 |
+
},
|
| 109 |
+
"description": "Target closed-loop pole locations: a number for a real pole, or [real, imag] for a complex one.",
|
| 110 |
},
|
| 111 |
},
|
| 112 |
"required": ["A", "B", "desired_poles"],
|
| 113 |
},
|
| 114 |
)
|
| 115 |
def place_state_feedback(
|
| 116 |
+
A: list[list[float]], B: list[list[float]], desired_poles: list[float | list[float]]
|
| 117 |
) -> dict[str, Any]:
|
| 118 |
A_mat = np.array(A, dtype=float)
|
| 119 |
B_mat = np.array(B, dtype=float)
|
| 120 |
+
# Each pole is a plain number (real pole) or a [real, imag] pair.
|
| 121 |
+
desired_poles = [complex(p[0], p[1]) if isinstance(p, (list, tuple)) else complex(p) for p in desired_poles]
|
| 122 |
+
# dtype=complex, not float: targeting a specific damping ratio / natural
|
| 123 |
+
# frequency means passing a complex-conjugate pole pair, which is the
|
| 124 |
+
# normal case for a 2nd-order-or-higher design, not an edge case. Forcing
|
| 125 |
+
# float here silently discarded the imaginary part, turning a legitimate
|
| 126 |
+
# conjugate pair into the SAME real pole listed twice -- which then fails
|
| 127 |
+
# outright for a SISO system since place_poles cannot repeat a pole more
|
| 128 |
+
# than rank(B) times.
|
| 129 |
+
des = np.array(desired_poles, dtype=complex)
|
| 130 |
placed = signal.place_poles(A_mat, B_mat, des)
|
| 131 |
+
# K is mathematically real for real A, B with a properly conjugate-paired
|
| 132 |
+
# desired_poles; drop the negligible numerical imaginary residue so the
|
| 133 |
+
# result is JSON-serializable (a genuinely unpaired complex pole is
|
| 134 |
+
# rejected by place_poles itself before this line is reached).
|
| 135 |
+
K = placed.gain_matrix.real
|
| 136 |
closed_poles = np.linalg.eigvals(A_mat - B_mat @ K)
|
| 137 |
|
| 138 |
+
v_report = verifier.verify_pole_placement(A_mat, B_mat, K, list(des))
|
| 139 |
return {
|
| 140 |
"K": K.tolist(),
|
| 141 |
"closed_loop_poles": [[float(p.real), float(p.imag)] for p in closed_poles],
|
| 142 |
+
"target_poles": [[float(p.real), float(p.imag)] for p in des],
|
| 143 |
"verification": v_report,
|
| 144 |
}
|
| 145 |
|
|
@@ -23,6 +23,27 @@ def tokenize_corpus(text: str) -> list[str]:
|
|
| 23 |
return re.findall(r"\b\w+\b|[+\-*/^_]", text.lower())
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
class ControlRAGIndex:
|
| 27 |
"""Fast, local, offline search index over control engineering documents."""
|
| 28 |
|
|
@@ -30,7 +51,39 @@ class ControlRAGIndex:
|
|
| 30 |
self.index_dir = index_dir
|
| 31 |
self.chunks: list[dict[str, Any]] = []
|
| 32 |
self.bm25: Any | None = None
|
|
|
|
| 33 |
self._load_if_exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
def build_from_chunks(self, chunks: list[Chunk]) -> None:
|
| 36 |
if BM25Okapi is None:
|
|
@@ -46,6 +99,9 @@ class ControlRAGIndex:
|
|
| 46 |
self.chunks.extend(new_dict_chunks)
|
| 47 |
corpus = [tokenize_corpus(c["text"]) for c in self.chunks]
|
| 48 |
self.bm25 = BM25Okapi(corpus)
|
|
|
|
|
|
|
|
|
|
| 49 |
self.save()
|
| 50 |
|
| 51 |
def save(self) -> None:
|
|
@@ -78,21 +134,40 @@ class ControlRAGIndex:
|
|
| 78 |
return []
|
| 79 |
|
| 80 |
scores = self.bm25.get_scores(tokens)
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
results = []
|
| 84 |
-
for idx in
|
| 85 |
-
score = float(scores[idx])
|
| 86 |
-
if score <= 0.0:
|
| 87 |
-
break
|
| 88 |
chunk = self.chunks[idx]
|
| 89 |
if source_filter and source_filter.lower() not in chunk["source_path"].lower():
|
| 90 |
continue
|
|
|
|
|
|
|
| 91 |
results.append({
|
| 92 |
"chunk_id": chunk["chunk_id"],
|
| 93 |
"score": round(score, 3),
|
| 94 |
"source": chunk["source_path"],
|
| 95 |
-
"filename":
|
|
|
|
|
|
|
|
|
|
| 96 |
"page": chunk["metadata"].get("page"),
|
| 97 |
"text": chunk["text"],
|
| 98 |
})
|
|
@@ -100,3 +175,107 @@ class ControlRAGIndex:
|
|
| 100 |
break
|
| 101 |
|
| 102 |
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
return re.findall(r"\b\w+\b|[+\-*/^_]", text.lower())
|
| 24 |
|
| 25 |
|
| 26 |
+
# Only chunk body text is tokenized into BM25, so a query naming a source
|
| 27 |
+
# ("what does Nise say about stability") cannot match the book it refers to.
|
| 28 |
+
# These constants drive a post-retrieval boost for chunks whose *filename*
|
| 29 |
+
# matches a distinctive query term.
|
| 30 |
+
SOURCE_MATCH_BOOST = 1.6
|
| 31 |
+
# A filename token is only distinctive enough to boost on if it appears in at
|
| 32 |
+
# most this fraction of the indexed files (floored at 2 files, so the rule
|
| 33 |
+
# still works on a small corpus). An author surname like "nise" or "ogata"
|
| 34 |
+
# appears in exactly one file; generic domain words appear in many.
|
| 35 |
+
SOURCE_TOKEN_MAX_FILE_FRACTION = 0.02
|
| 36 |
+
# Words that identify the subject rather than a specific source. Even if the
|
| 37 |
+
# corpus grows large enough for these to slip under the fraction cutoff, they
|
| 38 |
+
# must never trigger a source boost.
|
| 39 |
+
GENERIC_SOURCE_TOKENS = {
|
| 40 |
+
"control", "controls", "systems", "system", "engineering", "theory",
|
| 41 |
+
"lecture", "lectures", "notes", "chapter", "solutions", "solution",
|
| 42 |
+
"exercise", "exercises", "book", "textbook", "txtbk", "edition", "vol",
|
| 43 |
+
"part", "final", "exam", "slides", "course", "intro", "introduction",
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
class ControlRAGIndex:
|
| 48 |
"""Fast, local, offline search index over control engineering documents."""
|
| 49 |
|
|
|
|
| 51 |
self.index_dir = index_dir
|
| 52 |
self.chunks: list[dict[str, Any]] = []
|
| 53 |
self.bm25: Any | None = None
|
| 54 |
+
self._distinctive_source_tokens: set[str] = set()
|
| 55 |
self._load_if_exists()
|
| 56 |
+
self._build_source_token_map()
|
| 57 |
+
|
| 58 |
+
def _build_source_token_map(self) -> None:
|
| 59 |
+
"""Index which filename tokens are distinctive enough to boost on.
|
| 60 |
+
|
| 61 |
+
Author surnames and title words unique to a few files ("nise", "ogata",
|
| 62 |
+
"kharitonov") identify a source; words common across the corpus do not.
|
| 63 |
+
"""
|
| 64 |
+
if not self.chunks:
|
| 65 |
+
return
|
| 66 |
+
files_per_token: dict[str, set[str]] = {}
|
| 67 |
+
all_files: set[str] = set()
|
| 68 |
+
for chunk in self.chunks:
|
| 69 |
+
fname = str(chunk.get("metadata", {}).get("filename", ""))
|
| 70 |
+
if not fname:
|
| 71 |
+
continue
|
| 72 |
+
all_files.add(fname)
|
| 73 |
+
for token in set(tokenize_corpus(fname)):
|
| 74 |
+
files_per_token.setdefault(token, set()).add(fname)
|
| 75 |
+
|
| 76 |
+
if not all_files:
|
| 77 |
+
return
|
| 78 |
+
cutoff = max(2, int(len(all_files) * SOURCE_TOKEN_MAX_FILE_FRACTION))
|
| 79 |
+
self._distinctive_source_tokens = {
|
| 80 |
+
token
|
| 81 |
+
for token, files in files_per_token.items()
|
| 82 |
+
if len(files) <= cutoff
|
| 83 |
+
and len(token) > 2
|
| 84 |
+
and not token.isdigit()
|
| 85 |
+
and token not in GENERIC_SOURCE_TOKENS
|
| 86 |
+
}
|
| 87 |
|
| 88 |
def build_from_chunks(self, chunks: list[Chunk]) -> None:
|
| 89 |
if BM25Okapi is None:
|
|
|
|
| 99 |
self.chunks.extend(new_dict_chunks)
|
| 100 |
corpus = [tokenize_corpus(c["text"]) for c in self.chunks]
|
| 101 |
self.bm25 = BM25Okapi(corpus)
|
| 102 |
+
# A newly uploaded document may introduce a new author/title, so the
|
| 103 |
+
# distinctive-source vocabulary has to be recomputed alongside BM25.
|
| 104 |
+
self._build_source_token_map()
|
| 105 |
self.save()
|
| 106 |
|
| 107 |
def save(self) -> None:
|
|
|
|
| 134 |
return []
|
| 135 |
|
| 136 |
scores = self.bm25.get_scores(tokens)
|
| 137 |
+
ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
|
| 138 |
+
|
| 139 |
+
# Re-rank a widened candidate pool so that a chunk from an explicitly
|
| 140 |
+
# named source can be promoted above a body-text-only BM25 match.
|
| 141 |
+
query_source_tokens = set(tokens) & self._distinctive_source_tokens
|
| 142 |
+
pool = ranked[: max(top_k * 10, 60)]
|
| 143 |
+
rescored: list[tuple[float, int]] = []
|
| 144 |
+
for idx in pool:
|
| 145 |
+
base = float(scores[idx])
|
| 146 |
+
if base <= 0.0:
|
| 147 |
+
continue
|
| 148 |
+
score = base
|
| 149 |
+
if query_source_tokens:
|
| 150 |
+
fname_tokens = set(tokenize_corpus(str(self.chunks[idx].get("metadata", {}).get("filename", ""))))
|
| 151 |
+
if query_source_tokens & fname_tokens:
|
| 152 |
+
score *= SOURCE_MATCH_BOOST
|
| 153 |
+
rescored.append((score, idx))
|
| 154 |
+
rescored.sort(key=lambda pair: pair[0], reverse=True)
|
| 155 |
|
| 156 |
results = []
|
| 157 |
+
for score, idx in rescored:
|
|
|
|
|
|
|
|
|
|
| 158 |
chunk = self.chunks[idx]
|
| 159 |
if source_filter and source_filter.lower() not in chunk["source_path"].lower():
|
| 160 |
continue
|
| 161 |
+
fname = chunk["metadata"].get("filename", "unknown")
|
| 162 |
+
source_name, is_published = display_source_name(fname)
|
| 163 |
results.append({
|
| 164 |
"chunk_id": chunk["chunk_id"],
|
| 165 |
"score": round(score, 3),
|
| 166 |
"source": chunk["source_path"],
|
| 167 |
+
"filename": fname,
|
| 168 |
+
# User-facing label -- never show the raw filename in an answer.
|
| 169 |
+
"source_name": source_name,
|
| 170 |
+
"is_published_work": is_published,
|
| 171 |
"page": chunk["metadata"].get("page"),
|
| 172 |
"text": chunk["text"],
|
| 173 |
})
|
|
|
|
| 175 |
break
|
| 176 |
|
| 177 |
return results
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# A single process-wide index instance. The agent, the retrieval tool, and the
|
| 181 |
+
# document-upload endpoint must all read and mutate the SAME in-memory object:
|
| 182 |
+
# with separate instances, a document uploaded through the web UI is written to
|
| 183 |
+
# disk but stays invisible to the running agent until the server restarts.
|
| 184 |
+
_shared_index: ControlRAGIndex | None = None
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def get_shared_index(index_dir: Path = INDEX_DIR) -> ControlRAGIndex:
|
| 188 |
+
"""Return the process-wide shared RAG index, loading it on first use."""
|
| 189 |
+
global _shared_index
|
| 190 |
+
if _shared_index is None:
|
| 191 |
+
_shared_index = ControlRAGIndex(index_dir)
|
| 192 |
+
return _shared_index
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# --- Human-readable source names -------------------------------------------
|
| 196 |
+
# Indexed filenames carry private organisational cruft -- owner initials
|
| 197 |
+
# ("JD_", "B&B_"), course codes ("AMC", "PLMMR"), and scan artefacts
|
| 198 |
+
# ("txtbk", "DEFINITIVO", a doubled ".pdf.pdf"). Those must never reach a user
|
| 199 |
+
# as a citation, so every hit also carries a cleaned display name plus whether
|
| 200 |
+
# it is a published work (citable by author/title) or personal course notes
|
| 201 |
+
# (referred to generically).
|
| 202 |
+
|
| 203 |
+
_PUBLISHED_SOURCES: dict[str, str] = {
|
| 204 |
+
"norman s. nise - control systems engineering": "Nise, *Control Systems Engineering*",
|
| 205 |
+
"ogata modern control engineering 5th txtbk": "Ogata, *Modern Control Engineering* (5th ed.)",
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
# Course code -> the subject it stands for, so a generic remainder such as
|
| 209 |
+
# "CAM Course Notes Part 2" still resolves to something meaningful.
|
| 210 |
+
_COURSE_CODES: dict[str, str] = {
|
| 211 |
+
"SAS": "Safety in Automation Systems",
|
| 212 |
+
"AMC": "Advanced and Multivariable Control",
|
| 213 |
+
"CIR": "Control of Industrial Robots",
|
| 214 |
+
"PLMMR": "Perception, Localization and Mapping for Mobile Robots",
|
| 215 |
+
"NC": "Networked Control",
|
| 216 |
+
"CAM": "Computer-Aided Manufacturing",
|
| 217 |
+
"PSC": "Production Systems Control",
|
| 218 |
+
"MIDA": "Model Identification and Data Analysis",
|
| 219 |
+
"MIDA1": "Model Identification and Data Analysis",
|
| 220 |
+
"ACEHV": "Autonomous and Connected Electric/Hybrid Vehicles",
|
| 221 |
+
"ACHEV": "Autonomous and Connected Electric/Hybrid Vehicles",
|
| 222 |
+
"ACAV": "Autonomous and Connected Vehicles",
|
| 223 |
+
"DDCSD": "Data-Driven Control System Design",
|
| 224 |
+
"SACI": "Industrial Automation and Communication Systems",
|
| 225 |
+
"ICT": "Information and Communication Technology",
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
_OWNER_PREFIX_RE = re.compile(r"^(?:B&B|BBB|JD|LP|EC|AC|FG|RB|XX)[_\-\s]+", re.IGNORECASE)
|
| 229 |
+
_NOISE_RE = re.compile(
|
| 230 |
+
r"\b(?:txtbk|definitivo|margini\s+larghi|theory\s+notes|practice\s+notes|final)\b|\(.*?\)",
|
| 231 |
+
re.IGNORECASE,
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def display_source_name(filename: str) -> tuple[str, bool]:
|
| 236 |
+
"""Map an indexed filename to (display name, is_published_work)."""
|
| 237 |
+
if not filename:
|
| 238 |
+
return ("local reference", False)
|
| 239 |
+
|
| 240 |
+
stem = str(filename)
|
| 241 |
+
while True:
|
| 242 |
+
lowered = stem.lower()
|
| 243 |
+
for ext in (".pdf", ".md", ".txt", ".json", ".jsonl"):
|
| 244 |
+
if lowered.endswith(ext):
|
| 245 |
+
stem = stem[: -len(ext)]
|
| 246 |
+
break
|
| 247 |
+
else:
|
| 248 |
+
break
|
| 249 |
+
|
| 250 |
+
key = " ".join(stem.split()).lower()
|
| 251 |
+
if key in _PUBLISHED_SOURCES:
|
| 252 |
+
return (_PUBLISHED_SOURCES[key], True)
|
| 253 |
+
|
| 254 |
+
name = _OWNER_PREFIX_RE.sub("", stem)
|
| 255 |
+
|
| 256 |
+
# A leading all-caps token is a course code; swap it for its subject.
|
| 257 |
+
subject = ""
|
| 258 |
+
parts = name.replace("_", " ").split()
|
| 259 |
+
if parts:
|
| 260 |
+
head = parts[0].strip(":-").upper()
|
| 261 |
+
if head in _COURSE_CODES:
|
| 262 |
+
subject = _COURSE_CODES[head]
|
| 263 |
+
parts = parts[1:]
|
| 264 |
+
elif len(parts) > 1 and 2 <= len(head) <= 6 and head.isalpha() and parts[0].isupper():
|
| 265 |
+
parts = parts[1:]
|
| 266 |
+
|
| 267 |
+
remainder = _NOISE_RE.sub("", " ".join(parts))
|
| 268 |
+
remainder = " ".join(remainder.replace("_", " ").split()).strip(" -–—")
|
| 269 |
+
|
| 270 |
+
generic = remainder.lower() in {
|
| 271 |
+
"", "course notes", "lecture notes", "notes", "exercise sessions",
|
| 272 |
+
"summary", "course notes part 1", "course notes part 2", "lectures",
|
| 273 |
+
}
|
| 274 |
+
if subject and generic:
|
| 275 |
+
label = subject
|
| 276 |
+
elif subject and remainder:
|
| 277 |
+
label = subject if remainder.lower() in subject.lower() else f"{subject} - {remainder}"
|
| 278 |
+
else:
|
| 279 |
+
label = remainder or subject or "local reference"
|
| 280 |
+
|
| 281 |
+
return (f"{label} (course notes)", False)
|
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Broad answer-quality smoke test across every control-engineering domain.
|
| 3 |
+
|
| 4 |
+
Retrieval tests (scripts/test_rag_knowledge.py) only prove the right passage was
|
| 5 |
+
found. This runs the FULL agent and grades the answer that actually reaches the
|
| 6 |
+
user, checking the failure modes seen in practice:
|
| 7 |
+
|
| 8 |
+
* rendering -- doubled LaTeX backslashes, orphaned <tool_call> tags,
|
| 9 |
+
raw JSON leaking into prose
|
| 10 |
+
* citations -- raw indexed filenames / extensions / course codes shown to
|
| 11 |
+
the user instead of clean source names
|
| 12 |
+
* substance -- the canned "analysis complete" placeholder, empty answers,
|
| 13 |
+
answers too short to be useful
|
| 14 |
+
* tool hygiene -- the same tool re-run over and over, failed tool calls
|
| 15 |
+
|
| 16 |
+
Usage:
|
| 17 |
+
python3 scripts/eval_answer_quality.py # all cases
|
| 18 |
+
python3 scripts/eval_answer_quality.py --filter robot # subset
|
| 19 |
+
python3 scripts/eval_answer_quality.py --limit 5
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import re
|
| 26 |
+
import sys
|
| 27 |
+
import time
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 31 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 32 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 33 |
+
|
| 34 |
+
from controlai_agent.orchestrator import ControlAIAgent
|
| 35 |
+
|
| 36 |
+
PLACEHOLDER = "computational analysis has been completed"
|
| 37 |
+
|
| 38 |
+
# (domain, question). Deliberately spans applied domains and answer *shapes*:
|
| 39 |
+
# numeric-with-tool, conceptual-from-RAG, design/applied prose, and definitional.
|
| 40 |
+
CASES: list[tuple[str, str]] = [
|
| 41 |
+
("classical", "Compute the gain margin, phase margin and crossover frequencies for G(s) = 10/(s*(s+1)*(s+5))."),
|
| 42 |
+
("classical", "Explain what the phase margin tells you about a closed-loop system's damping."),
|
| 43 |
+
("modern", "Design an LQR controller for A=[[0,1],[-2,-3]], B=[[0],[1]], Q=diag([10,1]), R=1 and simulate the step response."),
|
| 44 |
+
("modern", "What is the difference between controllability and stabilizability?"),
|
| 45 |
+
("mpc", "What is quasi-infinite horizon MPC and why is the terminal region needed?"),
|
| 46 |
+
("mpc", "How does MPC handle actuator saturation compared to anti-windup PID?"),
|
| 47 |
+
("estimation", "Explain the difference between the Kalman filter time update and measurement update."),
|
| 48 |
+
("nonlinear", "What is a control barrier function and how does it enforce safety?"),
|
| 49 |
+
("robust", "State the small gain theorem and when it is conservative."),
|
| 50 |
+
("aerospace", "How is gain scheduling used in an aircraft flight control system across the flight envelope?"),
|
| 51 |
+
("aerospace", "Explain the short-period and phugoid modes of aircraft longitudinal dynamics."),
|
| 52 |
+
("automotive", "How does an electronic stability program use yaw rate feedback to correct oversteer?"),
|
| 53 |
+
("automotive", "Design considerations for an adaptive cruise control spacing policy."),
|
| 54 |
+
("robotics", "Explain impedance control for a robot manipulator in contact with the environment."),
|
| 55 |
+
("robotics", "How does a mobile robot fuse odometry and lidar for localization?"),
|
| 56 |
+
("automation", "What is cascade control in process automation and when does it help?"),
|
| 57 |
+
("automation", "Explain integral windup in an industrial PID loop and how to prevent it."),
|
| 58 |
+
("power", "How is field-oriented control used for induction motor drives?"),
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
RAW_FILENAME_PAT = re.compile(
|
| 62 |
+
r"\.pdf\b|\.md\b|\.jsonl?\b|\bJD_|\bB&B_|\bBBB[_\s]|\bLP_|\bEC_|\bAC_|\bFG_|\bRB_|\bXX_|txtbk|DEFINITIVO",
|
| 63 |
+
re.IGNORECASE,
|
| 64 |
+
)
|
| 65 |
+
DOUBLE_BS_PAT = re.compile(r"\\\\(?=[a-zA-Z|{}()])")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def grade(question: str, result) -> tuple[list[str], dict]:
|
| 69 |
+
"""Return (list of problems, metrics) for one answer."""
|
| 70 |
+
text = result.final_response or ""
|
| 71 |
+
problems: list[str] = []
|
| 72 |
+
|
| 73 |
+
if not text.strip():
|
| 74 |
+
problems.append("EMPTY answer")
|
| 75 |
+
elif PLACEHOLDER in text.lower():
|
| 76 |
+
problems.append("PLACEHOLDER non-answer")
|
| 77 |
+
elif len(text.split()) < 40:
|
| 78 |
+
problems.append(f"THIN answer ({len(text.split())} words)")
|
| 79 |
+
|
| 80 |
+
if DOUBLE_BS_PAT.search(text):
|
| 81 |
+
problems.append("DOUBLED LaTeX backslash")
|
| 82 |
+
if "<tool_call>" in text or "</tool_call>" in text:
|
| 83 |
+
problems.append("LEAKED tool_call tag")
|
| 84 |
+
if re.search(r'\{\s*"name"\s*:', text):
|
| 85 |
+
problems.append("LEAKED raw JSON")
|
| 86 |
+
if RAW_FILENAME_PAT.search(text):
|
| 87 |
+
problems.append("RAW filename in citation")
|
| 88 |
+
|
| 89 |
+
names = [t.tool_name for t in result.tool_traces]
|
| 90 |
+
failed = [t.tool_name for t in result.tool_traces if t.result.get("status") == "error"]
|
| 91 |
+
if failed:
|
| 92 |
+
problems.append(f"TOOL ERROR: {', '.join(sorted(set(failed)))}")
|
| 93 |
+
for n in set(names):
|
| 94 |
+
if names.count(n) > 2:
|
| 95 |
+
problems.append(f"REPEATED tool x{names.count(n)}: {n}")
|
| 96 |
+
|
| 97 |
+
return problems, {"tools": names, "words": len(text.split()), "plots": len(result.plots)}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def main() -> int:
|
| 101 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 102 |
+
ap.add_argument("--filter", default="", help="only run cases whose domain or text matches")
|
| 103 |
+
ap.add_argument("--limit", type=int, default=0)
|
| 104 |
+
args = ap.parse_args()
|
| 105 |
+
|
| 106 |
+
cases = [c for c in CASES if args.filter.lower() in (c[0] + " " + c[1]).lower()]
|
| 107 |
+
if args.limit:
|
| 108 |
+
cases = cases[: args.limit]
|
| 109 |
+
|
| 110 |
+
print(f"Loading agent...\n")
|
| 111 |
+
agent = ControlAIAgent()
|
| 112 |
+
|
| 113 |
+
failures: list[tuple[str, str, list[str]]] = []
|
| 114 |
+
for i, (domain, q) in enumerate(cases, 1):
|
| 115 |
+
t0 = time.time()
|
| 116 |
+
try:
|
| 117 |
+
res = agent.run(q)
|
| 118 |
+
except Exception as exc: # a crash is itself a finding
|
| 119 |
+
failures.append((domain, q, [f"EXCEPTION: {exc}"]))
|
| 120 |
+
print(f"[{i}/{len(cases)}] {domain:<11} EXCEPTION {exc}")
|
| 121 |
+
continue
|
| 122 |
+
problems, meta = grade(q, res)
|
| 123 |
+
status = "ok " if not problems else "FAIL"
|
| 124 |
+
print(
|
| 125 |
+
f"[{i}/{len(cases)}] {domain:<11} {status} "
|
| 126 |
+
f"{time.time()-t0:5.1f}s words={meta['words']:<4} "
|
| 127 |
+
f"plots={meta['plots']} tools={','.join(meta['tools']) or '-'}"
|
| 128 |
+
)
|
| 129 |
+
for p in problems:
|
| 130 |
+
print(f" - {p}")
|
| 131 |
+
if problems:
|
| 132 |
+
failures.append((domain, q, problems))
|
| 133 |
+
|
| 134 |
+
print("\n" + "=" * 74)
|
| 135 |
+
print(f"{len(cases) - len(failures)}/{len(cases)} answers clean")
|
| 136 |
+
if failures:
|
| 137 |
+
print("\nIssues by type:")
|
| 138 |
+
counts: dict[str, int] = {}
|
| 139 |
+
for _, _, ps in failures:
|
| 140 |
+
for p in ps:
|
| 141 |
+
key = p.split(":")[0].split("(")[0].strip()
|
| 142 |
+
counts[key] = counts.get(key, 0) + 1
|
| 143 |
+
for k, v in sorted(counts.items(), key=lambda kv: -kv[1]):
|
| 144 |
+
print(f" {v:>3} {k}")
|
| 145 |
+
print("=" * 74)
|
| 146 |
+
return 0 if not failures else 1
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
if __name__ == "__main__":
|
| 150 |
+
raise SystemExit(main())
|
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Generate the behavioral SFT dataset the deployed adapter is missing.
|
| 3 |
+
|
| 4 |
+
The current adapter (sft_v2 + agent traces) was trained exclusively on
|
| 5 |
+
fully-specified control problems mapped to the matching design tool. As a
|
| 6 |
+
result the model:
|
| 7 |
+
* routes ANY matrix-shaped question to a control-design tool (asked to
|
| 8 |
+
multiply two matrices, it ran an LQR synthesis),
|
| 9 |
+
* fabricates missing parameters (invented B/Q/R three separate times)
|
| 10 |
+
because it never saw a single example of asking for a missing value,
|
| 11 |
+
* carries numbers over from a previous, different problem in multi-turn
|
| 12 |
+
conversations.
|
| 13 |
+
|
| 14 |
+
This script generates verified trajectories for exactly those behaviors:
|
| 15 |
+
1. plain linear-algebra routing -> matrix_arithmetic tool traces
|
| 16 |
+
2. missing-parameter refusal -> no tool call; state what's missing, ask
|
| 17 |
+
3. poisoned-history faithfulness -> multi-turn: full problem in turn 1,
|
| 18 |
+
different request in turn 2 answered
|
| 19 |
+
WITHOUT reusing turn-1 numbers
|
| 20 |
+
4. conceptual questions -> prose answer, no unnecessary code tool
|
| 21 |
+
|
| 22 |
+
Every numeric answer is produced by executing the real registered tool, so
|
| 23 |
+
the dataset is verified by construction.
|
| 24 |
+
|
| 25 |
+
Usage:
|
| 26 |
+
python3 scripts/generate_behavior_sft_dataset.py
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import json
|
| 32 |
+
import random
|
| 33 |
+
import sys
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
from typing import Any
|
| 36 |
+
|
| 37 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 38 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 39 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 40 |
+
|
| 41 |
+
import numpy as np
|
| 42 |
+
|
| 43 |
+
from controlai_agent.registry import registry
|
| 44 |
+
import controlai_agent.tools # noqa: F401 (register all tools)
|
| 45 |
+
|
| 46 |
+
OUTPUT_DIR = PROJECT_ROOT / "data" / "training" / "behavior_sft_v1"
|
| 47 |
+
RNG_SEED = 20260819
|
| 48 |
+
|
| 49 |
+
SYSTEM_PROMPT = (
|
| 50 |
+
"You are an offline control-systems engineering assistant. Lead with the result, "
|
| 51 |
+
"state assumptions and conventions, show the decisive calculation, and never invent "
|
| 52 |
+
"a plant, parameter, software output, or verification result. Use executable Python "
|
| 53 |
+
"or MATLAB only when requested."
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def fmt_num(x: float) -> str:
|
| 58 |
+
return f"{x:.6g}"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def fmt_matrix(mat: list[list[float]]) -> str:
|
| 62 |
+
return "[" + ", ".join("[" + ", ".join(fmt_num(v) for v in row) + "]" for row in mat) + "]"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def fmt_matrix_latex(mat) -> str:
|
| 66 |
+
arr = np.atleast_2d(np.array(mat, dtype=float))
|
| 67 |
+
rows = [" & ".join(fmt_num(v) for v in row) for row in arr]
|
| 68 |
+
return "\\begin{bmatrix} " + " \\\\ ".join(rows) + " \\end{bmatrix}"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def rand_int_matrix(rng: random.Random, rows: int, cols: int) -> list[list[float]]:
|
| 72 |
+
return [[float(rng.randint(-9, 9)) for _ in range(cols)] for _ in range(rows)]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def rand_nonsingular(rng: random.Random, n: int) -> list[list[float]]:
|
| 76 |
+
while True:
|
| 77 |
+
m = rand_int_matrix(rng, n, n)
|
| 78 |
+
if abs(np.linalg.det(np.array(m))) > 0.5:
|
| 79 |
+
return m
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def matrix_to_text(mat: list[list[float]], rng: random.Random) -> str:
|
| 83 |
+
"""Render a matrix as the user might actually type it, garbling included."""
|
| 84 |
+
style = rng.random()
|
| 85 |
+
body = "[" + ", ".join("[" + ", ".join(fmt_num(v) for v in row) + "]" for row in mat) + "]"
|
| 86 |
+
if style < 0.7:
|
| 87 |
+
return body
|
| 88 |
+
# space-separated rows, occasionally with a dropped comma (real user input)
|
| 89 |
+
rows = []
|
| 90 |
+
for row in mat:
|
| 91 |
+
if rng.random() < 0.5:
|
| 92 |
+
rows.append("[" + " ".join(fmt_num(v) for v in row) + "]")
|
| 93 |
+
else:
|
| 94 |
+
rows.append("[" + ", ".join(fmt_num(v) for v in row) + "]")
|
| 95 |
+
return "[" + ", ".join(rows) + "]"
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def tool_call_msg(name: str, args: dict[str, Any]) -> dict[str, str]:
|
| 99 |
+
return {
|
| 100 |
+
"role": "assistant",
|
| 101 |
+
"content": f"<tool_call>\n{json.dumps({'name': name, 'arguments': args}, ensure_ascii=False)}\n</tool_call>",
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def tool_result_msg(name: str, result: dict[str, Any]) -> dict[str, str]:
|
| 106 |
+
return {"role": "tool", "name": name, "content": json.dumps(result, ensure_ascii=False)}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def make_example(messages: list[dict[str, str]]) -> dict[str, Any]:
|
| 110 |
+
return {"messages": [{"role": "system", "content": SYSTEM_PROMPT}] + messages}
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ---------------------------------------------------------------------------
|
| 114 |
+
# 1. Plain linear-algebra routing
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
|
| 117 |
+
MULT_PHRASES = [
|
| 118 |
+
"{a} times {b}",
|
| 119 |
+
"Multiply {a} by {b}",
|
| 120 |
+
"Compute the matrix product of A = {a} and B = {b}",
|
| 121 |
+
"A = {a} times A = {b}",
|
| 122 |
+
"What is {a} * {b}?",
|
| 123 |
+
"{a} multiplied by {b}",
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
UNARY_TEMPLATES = {
|
| 127 |
+
"inverse": ["Invert the matrix {a}", "What is the inverse of {a}?", "Compute {a}^-1"],
|
| 128 |
+
"determinant": ["Determinant of {a}", "Compute det({a})", "What is the determinant of the matrix {a}?"],
|
| 129 |
+
"rank": ["What is the rank of {a}?", "Compute the rank of the matrix {a}"],
|
| 130 |
+
"transpose": ["Transpose {a}", "What is {a} transposed?"],
|
| 131 |
+
"eigenvalues": ["Eigenvalues of {a}", "Find the eigenvalues of the matrix {a}", "Compute the spectrum of {a}"],
|
| 132 |
+
"trace": ["What is the trace of {a}?", "Compute the trace of {a}"],
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def gen_multiply(rng: random.Random) -> dict[str, Any] | None:
|
| 137 |
+
n = rng.choice([2, 2, 3, 3, 4])
|
| 138 |
+
k = rng.choice([n, n, rng.choice([1, 2, 3])])
|
| 139 |
+
A = rand_int_matrix(rng, n, k)
|
| 140 |
+
B = rand_int_matrix(rng, k, rng.choice([1, 2, 3, n]))
|
| 141 |
+
args = {"operation": "multiply", "matrix_a": A, "matrix_b": B}
|
| 142 |
+
result = registry.execute("matrix_arithmetic", args)
|
| 143 |
+
if result.get("status") != "success":
|
| 144 |
+
return None
|
| 145 |
+
prompt = rng.choice(MULT_PHRASES).format(a=matrix_to_text(A, rng), b=matrix_to_text(B, rng))
|
| 146 |
+
product = result["result"]
|
| 147 |
+
final = (
|
| 148 |
+
f"The matrix product is:\n$$AB = {fmt_matrix_latex(product)}$$\n"
|
| 149 |
+
f"({len(A)}x{len(A[0])} times {len(B)}x{len(B[0])} gives a {len(product)}x{len(product[0])} result; "
|
| 150 |
+
f"entry $(i,j)$ is the dot product of row $i$ of the first matrix with column $j$ of the second.)"
|
| 151 |
+
)
|
| 152 |
+
return make_example([
|
| 153 |
+
{"role": "user", "content": prompt},
|
| 154 |
+
tool_call_msg("matrix_arithmetic", args),
|
| 155 |
+
tool_result_msg("matrix_arithmetic", result),
|
| 156 |
+
{"role": "assistant", "content": final},
|
| 157 |
+
])
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def gen_unary(rng: random.Random) -> dict[str, Any] | None:
|
| 161 |
+
op = rng.choice(list(UNARY_TEMPLATES.keys()))
|
| 162 |
+
n = rng.choice([2, 2, 3, 3])
|
| 163 |
+
A = rand_nonsingular(rng, n) if op == "inverse" else (
|
| 164 |
+
rand_int_matrix(rng, n, n) if op in ("determinant", "eigenvalues", "trace") else rand_int_matrix(rng, n, rng.choice([n, n + 1]))
|
| 165 |
+
)
|
| 166 |
+
args = {"operation": op, "matrix_a": A}
|
| 167 |
+
result = registry.execute("matrix_arithmetic", args)
|
| 168 |
+
if result.get("status") != "success":
|
| 169 |
+
return None
|
| 170 |
+
prompt = rng.choice(UNARY_TEMPLATES[op]).format(a=matrix_to_text(A, rng))
|
| 171 |
+
res = result["result"]
|
| 172 |
+
if op == "inverse":
|
| 173 |
+
final = f"The inverse is:\n$$A^{{-1}} = {fmt_matrix_latex(res)}$$\n(det $= {fmt_num(result['determinant'])} \\neq 0$, so the matrix is invertible.)"
|
| 174 |
+
elif op == "transpose":
|
| 175 |
+
final = f"The transpose is:\n$$A^T = {fmt_matrix_latex(res)}$$"
|
| 176 |
+
elif op == "eigenvalues":
|
| 177 |
+
eig_strs = [f"{fmt_num(re_)}" + (f" {'+' if im >= 0 else '-'} {fmt_num(abs(im))}j" if abs(im) > 1e-12 else "") for re_, im in res]
|
| 178 |
+
final = f"The eigenvalues are:\n$$\\lambda = \\{{{', '.join(eig_strs)}\\}}$$\nSpectral radius: $\\rho(A) = {fmt_num(result['spectral_radius'])}$."
|
| 179 |
+
elif op == "rank":
|
| 180 |
+
full = "full rank" if res == min(len(A), len(A[0])) else f"rank-deficient (max possible {min(len(A), len(A[0]))})"
|
| 181 |
+
final = f"$\\text{{rank}}(A) = {res}$ -- the matrix is {full}."
|
| 182 |
+
else:
|
| 183 |
+
final = f"$\\text{{{op}}}(A) = {fmt_num(res)}$."
|
| 184 |
+
return make_example([
|
| 185 |
+
{"role": "user", "content": prompt},
|
| 186 |
+
tool_call_msg("matrix_arithmetic", args),
|
| 187 |
+
tool_result_msg("matrix_arithmetic", result),
|
| 188 |
+
{"role": "assistant", "content": final},
|
| 189 |
+
])
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
# 2. Missing-parameter refusal (single-turn)
|
| 194 |
+
# ---------------------------------------------------------------------------
|
| 195 |
+
|
| 196 |
+
def gen_refusal_single(rng: random.Random) -> dict[str, Any]:
|
| 197 |
+
n = rng.choice([2, 3])
|
| 198 |
+
A = rand_int_matrix(rng, n, n)
|
| 199 |
+
kind = rng.choice(["lqr_no_b", "lqr_no_qr", "place_no_poles", "zoh_no_ts", "margins_no_tf", "kalman_no_cov"])
|
| 200 |
+
|
| 201 |
+
if kind == "lqr_no_b":
|
| 202 |
+
prompt = rng.choice([
|
| 203 |
+
f"Design an LQR controller for A={fmt_matrix(A)}.",
|
| 204 |
+
f"Solve the LQR problem for the system with state matrix A={fmt_matrix(A)} and simulate the closed loop.",
|
| 205 |
+
f"Find the optimal LQR gain for A={fmt_matrix(A)}, Q=diag({', '.join(str(rng.randint(1, 20)) for _ in range(n))}), R=1.",
|
| 206 |
+
])
|
| 207 |
+
answer = (
|
| 208 |
+
f"I can't run the LQR synthesis yet: the input matrix $B$ ({n}x m) has not been provided, and I "
|
| 209 |
+
f"won't assume one -- the gain $K$ depends entirely on how the input enters the dynamics.\n\n"
|
| 210 |
+
f"What I can tell you from $A$ alone: the open-loop eigenvalues determine whether the plant is "
|
| 211 |
+
f"stable before feedback, and controllability of $(A, B)$ must be checked once $B$ is known.\n\n"
|
| 212 |
+
f"Please provide $B$ (and $Q$, $R$ if you want other than default weights), and I will compute "
|
| 213 |
+
f"$P$, $K$, and the closed-loop poles."
|
| 214 |
+
)
|
| 215 |
+
elif kind == "lqr_no_qr":
|
| 216 |
+
B = [[0.0] for _ in range(n - 1)] + [[1.0]]
|
| 217 |
+
prompt = f"Compute the LQR gain for A={fmt_matrix(A)}, B={fmt_matrix(B)}."
|
| 218 |
+
answer = (
|
| 219 |
+
"The LQR gain is not defined until the cost is: I need the state penalty $Q$ (positive "
|
| 220 |
+
"semidefinite, {n}x{n}) and the control penalty $R$ (positive definite). Different $Q, R$ give "
|
| 221 |
+
"completely different gains for the same plant.\n\nTell me $Q$ and $R$ -- or tell me what "
|
| 222 |
+
"behavior you want (e.g. fast state 1, cheap control) and I will propose weights and say so "
|
| 223 |
+
"explicitly as a design choice."
|
| 224 |
+
).replace("{n}", str(n))
|
| 225 |
+
elif kind == "place_no_poles":
|
| 226 |
+
B = [[0.0] for _ in range(n - 1)] + [[1.0]]
|
| 227 |
+
prompt = f"Place the closed-loop poles for A={fmt_matrix(A)}, B={fmt_matrix(B)} using state feedback."
|
| 228 |
+
answer = (
|
| 229 |
+
"Pole placement needs target pole locations, and none were given. Where do you want the "
|
| 230 |
+
f"closed-loop poles? Provide {n} locations (complex ones in conjugate pairs, e.g. $-2 \\pm 3j$), "
|
| 231 |
+
"or give a specification (settling time, damping ratio) and I will derive pole locations from it "
|
| 232 |
+
"and state that derivation explicitly."
|
| 233 |
+
)
|
| 234 |
+
elif kind == "zoh_no_ts":
|
| 235 |
+
B = [[0.0] for _ in range(n - 1)] + [[1.0]]
|
| 236 |
+
prompt = f"Discretize A={fmt_matrix(A)}, B={fmt_matrix(B)} using zero-order hold."
|
| 237 |
+
answer = (
|
| 238 |
+
"Exact ZOH discretization requires the sampling period $T_s$, which was not given -- $A_d = "
|
| 239 |
+
"e^{A T_s}$ changes completely with it. What is $T_s$ (in seconds)? As a rule of thumb it should "
|
| 240 |
+
"give 10-30 samples per dominant closed-loop time constant."
|
| 241 |
+
)
|
| 242 |
+
elif kind == "margins_no_tf":
|
| 243 |
+
prompt = rng.choice([
|
| 244 |
+
"Compute the gain and phase margins of my loop.",
|
| 245 |
+
"What are the stability margins of the system?",
|
| 246 |
+
])
|
| 247 |
+
answer = (
|
| 248 |
+
"I need the open-loop transfer function to compute margins -- numerator and denominator "
|
| 249 |
+
"coefficients of $L(s)$, or its poles/zeros/gain, or the state-space matrices. Please provide "
|
| 250 |
+
"one of those and I will compute GM, PM, and both crossover frequencies exactly."
|
| 251 |
+
)
|
| 252 |
+
else: # kalman_no_cov
|
| 253 |
+
prompt = f"Run a Kalman filter time update for A={fmt_matrix(A)}."
|
| 254 |
+
answer = (
|
| 255 |
+
"A Kalman time update needs more than $A$: the current state estimate $\\hat{x}$, its covariance "
|
| 256 |
+
"$P$, and the process noise covariance $Q_w$ (plus $B$ and $u$ if there is a known input). "
|
| 257 |
+
"Which values should I use? I will not assume noise statistics -- they determine the filter "
|
| 258 |
+
"entirely."
|
| 259 |
+
)
|
| 260 |
+
return make_example([
|
| 261 |
+
{"role": "user", "content": prompt},
|
| 262 |
+
{"role": "assistant", "content": answer},
|
| 263 |
+
])
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# ---------------------------------------------------------------------------
|
| 267 |
+
# 3. Poisoned-history faithfulness (multi-turn)
|
| 268 |
+
# ---------------------------------------------------------------------------
|
| 269 |
+
|
| 270 |
+
def _full_lqr_turn(rng: random.Random) -> tuple[list[dict[str, str]], dict[str, Any]]:
|
| 271 |
+
"""A complete verified LQR exchange to serve as turn 1 (the 'poison')."""
|
| 272 |
+
A = [[0.0, 1.0], [float(-rng.randint(1, 5)), float(-rng.randint(1, 5))]]
|
| 273 |
+
B = [[0.0], [1.0]]
|
| 274 |
+
q1, q2 = rng.choice([(10, 1), (20, 1), (5, 2), (100, 1)])
|
| 275 |
+
Q = [[float(q1), 0.0], [0.0, float(q2)]]
|
| 276 |
+
R = [[1.0]]
|
| 277 |
+
args = {"A": A, "B": B, "Q": Q, "R": R}
|
| 278 |
+
result = registry.execute("continuous_lqr", args)
|
| 279 |
+
prompt = f"Design an LQR controller for A={fmt_matrix(A)}, B={fmt_matrix(B)}, Q=diag([{q1}, {q2}]), R=1."
|
| 280 |
+
K = result["K"]
|
| 281 |
+
poles = result["closed_loop_poles"]
|
| 282 |
+
pole_strs = [f"{fmt_num(p[0])}" + (f" {'+' if p[1] >= 0 else '-'} {fmt_num(abs(p[1]))}j" if abs(p[1]) > 1e-9 else "") for p in poles]
|
| 283 |
+
final = (
|
| 284 |
+
f"Solving the CARE gives the optimal gain $K = {fmt_matrix_latex(K)}$ with closed-loop poles "
|
| 285 |
+
f"$\\{{{', '.join(pole_strs)}\\}}$ -- the closed loop is asymptotically stable."
|
| 286 |
+
)
|
| 287 |
+
turn = [
|
| 288 |
+
{"role": "user", "content": prompt},
|
| 289 |
+
tool_call_msg("continuous_lqr", args),
|
| 290 |
+
tool_result_msg("continuous_lqr", result),
|
| 291 |
+
{"role": "assistant", "content": final},
|
| 292 |
+
]
|
| 293 |
+
return turn, args
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def gen_poisoned_matrix_question(rng: random.Random) -> dict[str, Any] | None:
|
| 297 |
+
"""Turn 1: full LQR. Turn 2: plain matrix question -> matrix tool only."""
|
| 298 |
+
turn1, _ = _full_lqr_turn(rng)
|
| 299 |
+
n = rng.choice([2, 3])
|
| 300 |
+
X = rand_int_matrix(rng, n, n)
|
| 301 |
+
Y = rand_int_matrix(rng, n, n)
|
| 302 |
+
args = {"operation": "multiply", "matrix_a": X, "matrix_b": Y}
|
| 303 |
+
result = registry.execute("matrix_arithmetic", args)
|
| 304 |
+
if result.get("status") != "success":
|
| 305 |
+
return None
|
| 306 |
+
prompt = rng.choice(MULT_PHRASES).format(a=matrix_to_text(X, rng), b=matrix_to_text(Y, rng))
|
| 307 |
+
final = f"The matrix product is:\n$$XY = {fmt_matrix_latex(result['result'])}$$"
|
| 308 |
+
return make_example(turn1 + [
|
| 309 |
+
{"role": "user", "content": prompt},
|
| 310 |
+
tool_call_msg("matrix_arithmetic", args),
|
| 311 |
+
tool_result_msg("matrix_arithmetic", result),
|
| 312 |
+
{"role": "assistant", "content": final},
|
| 313 |
+
])
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def gen_poisoned_refusal(rng: random.Random) -> dict[str, Any]:
|
| 317 |
+
"""Turn 1: full 2-state LQR. Turn 2: new 3-state A with no B -> refuse,
|
| 318 |
+
explicitly declining to reuse the old problem's matrices."""
|
| 319 |
+
turn1, old_args = _full_lqr_turn(rng)
|
| 320 |
+
A3 = rand_int_matrix(rng, 3, 3)
|
| 321 |
+
prompt = rng.choice([
|
| 322 |
+
f"Now design an LQR controller for A={fmt_matrix(A3)}.",
|
| 323 |
+
f"Do the same for A={fmt_matrix(A3)}.",
|
| 324 |
+
f"LQR for A={fmt_matrix(A3)} please.",
|
| 325 |
+
])
|
| 326 |
+
answer = (
|
| 327 |
+
"This is a new 3-state system, and I only have its $A$ matrix. The $B$, $Q$, and $R$ from the "
|
| 328 |
+
"previous problem belong to a different 2-state plant -- they do not carry over, and resizing them "
|
| 329 |
+
"to fit would be inventing a model.\n\nTo run this synthesis I need: $B$ (3 x m), and the weights "
|
| 330 |
+
"$Q$ (3x3 PSD) and $R$ (m x m PD), or a description of the actuation and objectives so I can "
|
| 331 |
+
"propose weights as an explicit design choice. What is $B$ for this system?"
|
| 332 |
+
)
|
| 333 |
+
return make_example(turn1 + [
|
| 334 |
+
{"role": "user", "content": prompt},
|
| 335 |
+
{"role": "assistant", "content": answer},
|
| 336 |
+
])
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def gen_poisoned_concept_question(rng: random.Random) -> dict[str, Any]:
|
| 340 |
+
"""Turn 1: full LQR. Turn 2: conceptual question -> prose, no tools, no old numbers."""
|
| 341 |
+
turn1, _ = _full_lqr_turn(rng)
|
| 342 |
+
concept, answer = rng.choice(CONCEPT_QA)
|
| 343 |
+
return make_example(turn1 + [
|
| 344 |
+
{"role": "user", "content": concept},
|
| 345 |
+
{"role": "assistant", "content": answer},
|
| 346 |
+
])
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# ---------------------------------------------------------------------------
|
| 350 |
+
# 4. Conceptual questions -> prose, no unnecessary tools
|
| 351 |
+
# ---------------------------------------------------------------------------
|
| 352 |
+
|
| 353 |
+
CONCEPT_QA: list[tuple[str, str]] = [
|
| 354 |
+
(
|
| 355 |
+
"What is the difference between controllability and stabilizability?",
|
| 356 |
+
"**Controllability** means every state can be driven anywhere in finite time: $\\text{rank}[B\\ AB\\ \\cdots\\ A^{n-1}B] = n$, or equivalently the PBH test $\\text{rank}[\\lambda I - A\\ \\ B] = n$ for every eigenvalue $\\lambda$.\n\n**Stabilizability** is weaker: only the *unstable* modes must be controllable -- PBH needs to hold only for $\\text{Re}(\\lambda) \\geq 0$. Stable uncontrollable modes are acceptable because they decay on their own.\n\nEvery controllable system is stabilizable; the converse fails. Stabilizability (with detectability) is exactly what LQR/LQG existence requires -- full controllability is sufficient but not necessary.",
|
| 357 |
+
),
|
| 358 |
+
(
|
| 359 |
+
"Why does a phase margin around 45-60 degrees correspond to good damping?",
|
| 360 |
+
"For a second-order-dominant loop, phase margin and closed-loop damping are linked approximately by $\\zeta \\approx PM/100$ (PM in degrees), so $PM = 45^\\circ$--$60^\\circ$ maps to $\\zeta \\approx 0.45$--$0.6$: fast response with modest overshoot (roughly 10-25%).\n\nBelow ~30° the closed loop rings badly and is fragile to delay ($\\Delta\\phi = \\omega_{gc} T_d$ eats margin directly); much above 70° the response is sluggish. The linear rule degrades when higher-order dynamics or RHP zeros distort the phase near crossover -- then the exact relation, not the approximation, must be used.",
|
| 361 |
+
),
|
| 362 |
+
(
|
| 363 |
+
"What is integral windup in a PID loop and how is it prevented?",
|
| 364 |
+
"When the actuator saturates, the plant stops responding to further increases in the control signal, but the integrator keeps accumulating error. The integral term 'winds up' far beyond what the actuator can deliver; after the error changes sign it takes a long time to unwind, causing large overshoot and slow recovery.\n\nStandard remedies: **conditional integration** (freeze the integrator while saturated), **back-calculation** (feed the difference between commanded and saturated actuator output back to discharge the integrator through gain $1/T_t$), or designing with actuator limits explicitly (MPC). Back-calculation is the common industrial choice because it degrades gracefully.",
|
| 365 |
+
),
|
| 366 |
+
(
|
| 367 |
+
"When would you choose MPC over a well-tuned PID controller?",
|
| 368 |
+
"Choose **MPC** when the problem has structure PID cannot represent: hard constraints on inputs/states that the controller should respect *by design* rather than by saturation, strong multivariable interaction (coupled MIMO loops), a good process model with significant dead time, or an economic objective over a horizon.\n\nStay with **PID** when the loop is essentially SISO, fast, and model-poor: PID needs almost no model, runs at microsecond rates on a PLC, and its failure modes are well understood on the plant floor. A practical rule: constraints and coupling pay for MPC's modeling and maintenance cost; without them, they don't.",
|
| 369 |
+
),
|
| 370 |
+
(
|
| 371 |
+
"What does the separation principle say for observer-based control?",
|
| 372 |
+
"For a linear system with state feedback $u = -K\\hat{x}$ driven by an observer with gain $L$, the closed-loop eigenvalues are exactly the union of the state-feedback poles $\\lambda(A - BK)$ and the observer poles $\\lambda(A - LC)$: the estimation error dynamics decouple from the regulation dynamics.\n\nSo $K$ and $L$ may be designed independently -- pole placement or LQR for $K$, pole placement or a Kalman filter for $L$ (the LQG combination). The caveat: separation guarantees *nominal* stability only. The combined loop can have poor robustness margins (Doyle's 1978 counterexample: LQG has no guaranteed margins), so robustness must be checked on the assembled loop.",
|
| 373 |
+
),
|
| 374 |
+
(
|
| 375 |
+
"Explain the waterbed effect in feedback design.",
|
| 376 |
+
"Bode's sensitivity integral: for an open-loop stable, minimum-phase loop with relative degree ≥ 2, $\\int_0^\\infty \\ln|S(j\\omega)|\\, d\\omega = 0$ (and $= \\pi \\sum \\text{Re}(p_i)$ with unstable poles $p_i$). Pushing $|S|$ below 1 over one band necessarily pushes it above 1 somewhere else -- like pressing on a waterbed.\n\nConsequences: disturbance rejection improved in one band is paid for with amplification elsewhere; RHP zeros make it worse by confining where the 'bulge' can go. It is a conservation law, not a design flaw -- good loop shaping *places* the sensitivity bulge where disturbances and model error are least harmful.",
|
| 377 |
+
),
|
| 378 |
+
]
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def gen_concept(rng: random.Random) -> dict[str, Any]:
|
| 382 |
+
q, a = rng.choice(CONCEPT_QA)
|
| 383 |
+
return make_example([
|
| 384 |
+
{"role": "user", "content": q},
|
| 385 |
+
{"role": "assistant", "content": a},
|
| 386 |
+
])
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
# ---------------------------------------------------------------------------
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def main() -> None:
|
| 393 |
+
rng = random.Random(RNG_SEED)
|
| 394 |
+
examples: list[dict[str, Any]] = []
|
| 395 |
+
|
| 396 |
+
generators = [
|
| 397 |
+
(gen_multiply, 220),
|
| 398 |
+
(gen_unary, 200),
|
| 399 |
+
(gen_refusal_single, 260),
|
| 400 |
+
(gen_poisoned_matrix_question, 130),
|
| 401 |
+
(gen_poisoned_refusal, 130),
|
| 402 |
+
(gen_poisoned_concept_question, 60),
|
| 403 |
+
(gen_concept, 60),
|
| 404 |
+
]
|
| 405 |
+
for fn, count in generators:
|
| 406 |
+
made = 0
|
| 407 |
+
attempts = 0
|
| 408 |
+
while made < count and attempts < count * 4:
|
| 409 |
+
attempts += 1
|
| 410 |
+
ex = fn(rng)
|
| 411 |
+
if ex is not None:
|
| 412 |
+
examples.append(ex)
|
| 413 |
+
made += 1
|
| 414 |
+
print(f"{fn.__name__}: {made}")
|
| 415 |
+
|
| 416 |
+
rng.shuffle(examples)
|
| 417 |
+
n_valid = max(1, len(examples) // 10)
|
| 418 |
+
valid, train = examples[:n_valid], examples[n_valid:]
|
| 419 |
+
|
| 420 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 421 |
+
for name, rows in (("train.jsonl", train), ("valid.jsonl", valid)):
|
| 422 |
+
with (OUTPUT_DIR / name).open("w", encoding="utf-8") as f:
|
| 423 |
+
for row in rows:
|
| 424 |
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
| 425 |
+
print(f"\nWrote {len(train)} train / {len(valid)} valid to {OUTPUT_DIR}")
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
if __name__ == "__main__":
|
| 429 |
+
main()
|
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Knowledge-retrieval smoke test for the local RAG index.
|
| 3 |
+
|
| 4 |
+
Runs a fixed set of canonical control-engineering queries against the live
|
| 5 |
+
BM25 index used by the agent (controlai_rag.index.ControlRAGIndex) and checks
|
| 6 |
+
that each query's top hits actually contain at least one expected keyword.
|
| 7 |
+
Also reports whether each query's best score clears the 2.5 relevance
|
| 8 |
+
threshold that ControlAIAgent._get_grounded_instruction uses to decide
|
| 9 |
+
whether to inject retrieved text into the system prompt -- a query can
|
| 10 |
+
retrieve "correct" chunks yet still never get grounded into an answer if its
|
| 11 |
+
score sits below that bar.
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
python3 scripts/test_rag_knowledge.py
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import sys
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 23 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 24 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 25 |
+
|
| 26 |
+
from controlai_rag.index import ControlRAGIndex
|
| 27 |
+
|
| 28 |
+
GROUNDING_SCORE_THRESHOLD = 2.5
|
| 29 |
+
|
| 30 |
+
# (query, keywords where at least one must appear in a top-k hit's text)
|
| 31 |
+
# ControlAI is a general control-engineering agent, so this suite deliberately
|
| 32 |
+
# spans every application domain -- aerospace, automotive, robotics, industrial
|
| 33 |
+
# automation, power -- not just classical/modern theory.
|
| 34 |
+
TEST_CASES: list[tuple[str, list[str]]] = [
|
| 35 |
+
# --- Core theory ---
|
| 36 |
+
("controllability matrix rank test", ["controllab", "rank"]),
|
| 37 |
+
("observability of linear time invariant systems", ["observ"]),
|
| 38 |
+
("continuous algebraic Riccati equation LQR", ["riccati", "lqr", "quadratic"]),
|
| 39 |
+
("discrete algebraic Riccati equation DARE", ["riccati", "discrete"]),
|
| 40 |
+
("zero order hold ZOH discretization", ["zero-order", "zero order", "hold", "discret"]),
|
| 41 |
+
("Lyapunov stability of nonlinear systems", ["lyapunov", "stab"]),
|
| 42 |
+
("gain margin phase margin frequency response", ["gain margin", "phase margin"]),
|
| 43 |
+
("Kalman filter state estimation", ["kalman", "estimat"]),
|
| 44 |
+
("PID controller tuning", ["pid", "proportional"]),
|
| 45 |
+
("root locus method", ["root locus"]),
|
| 46 |
+
("PBH test for controllability", ["pbh", "popov"]),
|
| 47 |
+
("model predictive control constrained optimization", ["model predictive", "mpc", "horizon"]),
|
| 48 |
+
("H-infinity robust control small gain theorem", ["h-infinity", "h infinity", "small gain", "hinf"]),
|
| 49 |
+
("control barrier function safety filter", ["barrier", "safety"]),
|
| 50 |
+
("state feedback pole placement", ["pole placement", "state feedback"]),
|
| 51 |
+
# --- Application domains ---
|
| 52 |
+
("aircraft flight control longitudinal dynamics", ["aircraft", "flight", "longitudinal", "pitch"]),
|
| 53 |
+
("quadrotor UAV attitude control", ["quadrotor", "uav", "attitude", "drone"]),
|
| 54 |
+
("vehicle dynamics yaw rate stability control", ["vehicle", "yaw", "tire", "steering"]),
|
| 55 |
+
("automotive cruise control design", ["cruise", "vehicle", "throttle", "speed"]),
|
| 56 |
+
("robot manipulator kinematics and Jacobian", ["manipulator", "jacobian", "kinematic", "robot"]),
|
| 57 |
+
("mobile robot localization and odometry", ["odometry", "localiz", "mobile robot", "slam"]),
|
| 58 |
+
("industrial process control valve saturation", ["valve", "process", "saturat", "actuator"]),
|
| 59 |
+
("cascade control loop in process automation", ["cascade", "process", "inner loop", "secondary"]),
|
| 60 |
+
("electric motor drive speed control", ["motor", "drive", "torque", "induction"]),
|
| 61 |
+
("system identification from input output data", ["identification", "arx", "least squares", "estimat"]),
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def run() -> int:
|
| 66 |
+
index = ControlRAGIndex()
|
| 67 |
+
if not index.chunks or not index.bm25:
|
| 68 |
+
print("FAIL: RAG index did not load (no chunks / no BM25 model). Is data/rag_index/ populated?")
|
| 69 |
+
return 1
|
| 70 |
+
|
| 71 |
+
print(f"RAG index loaded: {len(index.chunks)} chunks\n")
|
| 72 |
+
|
| 73 |
+
passed = 0
|
| 74 |
+
grounded = 0
|
| 75 |
+
for query, keywords in TEST_CASES:
|
| 76 |
+
hits = index.search(query, top_k=5)
|
| 77 |
+
best_score = hits[0]["score"] if hits else 0.0
|
| 78 |
+
matched = any(
|
| 79 |
+
kw.lower() in hit["text"].lower()
|
| 80 |
+
for hit in hits
|
| 81 |
+
for kw in keywords
|
| 82 |
+
)
|
| 83 |
+
would_ground = best_score > GROUNDING_SCORE_THRESHOLD
|
| 84 |
+
grounded += int(would_ground)
|
| 85 |
+
passed += int(matched)
|
| 86 |
+
|
| 87 |
+
status = "PASS" if matched else "FAIL"
|
| 88 |
+
ground_tag = "grounds" if would_ground else "below threshold"
|
| 89 |
+
print(f"[{status}] '{query}' best_score={best_score:.2f} ({ground_tag})")
|
| 90 |
+
if hits:
|
| 91 |
+
top = hits[0]
|
| 92 |
+
excerpt = " ".join(top["text"].split())[:160]
|
| 93 |
+
print(f" top hit: [{top['filename']} p.{top['page']}] {excerpt}...")
|
| 94 |
+
else:
|
| 95 |
+
print(" no hits returned")
|
| 96 |
+
print()
|
| 97 |
+
|
| 98 |
+
total = len(TEST_CASES)
|
| 99 |
+
print("=" * 70)
|
| 100 |
+
print(f"Keyword relevance: {passed}/{total} queries retrieved an on-topic chunk")
|
| 101 |
+
print(f"Grounding trigger: {grounded}/{total} queries would clear the score>{GROUNDING_SCORE_THRESHOLD} auto-grounding bar")
|
| 102 |
+
print("=" * 70)
|
| 103 |
+
|
| 104 |
+
return 0 if passed == total else 1
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
if __name__ == "__main__":
|
| 108 |
+
raise SystemExit(run())
|
|
@@ -517,7 +517,7 @@ body {
|
|
| 517 |
|
| 518 |
.code-block-wrapper pre code {
|
| 519 |
font-family: 'JetBrains Mono', 'Menlo', monospace !important;
|
| 520 |
-
font-size:
|
| 521 |
line-height: 1.6 !important;
|
| 522 |
background: transparent !important;
|
| 523 |
padding: 0 !important;
|
|
@@ -549,12 +549,30 @@ body {
|
|
| 549 |
}
|
| 550 |
|
| 551 |
.generating-indicator {
|
| 552 |
-
display: inline-
|
|
|
|
|
|
|
| 553 |
color: var(--text-muted);
|
| 554 |
font-family: var(--font-mono);
|
| 555 |
font-size: 12px;
|
| 556 |
}
|
| 557 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
.thought-summary {
|
| 559 |
padding: 6px 12px;
|
| 560 |
font-family: var(--font-mono);
|
|
@@ -590,7 +608,7 @@ body {
|
|
| 590 |
padding: 10px 14px;
|
| 591 |
color: var(--text-muted);
|
| 592 |
font-family: var(--font-mono);
|
| 593 |
-
font-size:
|
| 594 |
line-height: 1.6;
|
| 595 |
border-top: 1px solid var(--border-color);
|
| 596 |
white-space: pre-wrap;
|
|
@@ -630,7 +648,7 @@ body {
|
|
| 630 |
|
| 631 |
.plot-caption {
|
| 632 |
font-family: var(--font-mono);
|
| 633 |
-
font-size:
|
| 634 |
color: var(--text-muted);
|
| 635 |
margin-top: 6px;
|
| 636 |
}
|
|
|
|
| 517 |
|
| 518 |
.code-block-wrapper pre code {
|
| 519 |
font-family: 'JetBrains Mono', 'Menlo', monospace !important;
|
| 520 |
+
font-size: calc(var(--chat-font-size) * 0.87) !important;
|
| 521 |
line-height: 1.6 !important;
|
| 522 |
background: transparent !important;
|
| 523 |
padding: 0 !important;
|
|
|
|
| 549 |
}
|
| 550 |
|
| 551 |
.generating-indicator {
|
| 552 |
+
display: inline-flex;
|
| 553 |
+
align-items: center;
|
| 554 |
+
gap: 6px;
|
| 555 |
color: var(--text-muted);
|
| 556 |
font-family: var(--font-mono);
|
| 557 |
font-size: 12px;
|
| 558 |
}
|
| 559 |
|
| 560 |
+
.generating-indicator .dot {
|
| 561 |
+
width: 4px;
|
| 562 |
+
height: 4px;
|
| 563 |
+
border-radius: 50%;
|
| 564 |
+
background: currentColor;
|
| 565 |
+
animation: generating-pulse 1.1s ease-in-out infinite;
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
.generating-indicator .dot:nth-child(2) { animation-delay: 0.15s; }
|
| 569 |
+
.generating-indicator .dot:nth-child(3) { animation-delay: 0.3s; }
|
| 570 |
+
|
| 571 |
+
@keyframes generating-pulse {
|
| 572 |
+
0%, 60%, 100% { opacity: 0.25; transform: translateY(0); }
|
| 573 |
+
30% { opacity: 1; transform: translateY(-2px); }
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
.thought-summary {
|
| 577 |
padding: 6px 12px;
|
| 578 |
font-family: var(--font-mono);
|
|
|
|
| 608 |
padding: 10px 14px;
|
| 609 |
color: var(--text-muted);
|
| 610 |
font-family: var(--font-mono);
|
| 611 |
+
font-size: calc(var(--chat-font-size) * 0.73);
|
| 612 |
line-height: 1.6;
|
| 613 |
border-top: 1px solid var(--border-color);
|
| 614 |
white-space: pre-wrap;
|
|
|
|
| 648 |
|
| 649 |
.plot-caption {
|
| 650 |
font-family: var(--font-mono);
|
| 651 |
+
font-size: calc(var(--chat-font-size) * 0.73);
|
| 652 |
color: var(--text-muted);
|
| 653 |
margin-top: 6px;
|
| 654 |
}
|
|
@@ -327,14 +327,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 327 |
}
|
| 328 |
|
| 329 |
function deleteSession(id) {
|
|
|
|
| 330 |
sessions = sessions.filter((s) => s.id !== id);
|
| 331 |
saveSessions();
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
}
|
| 339 |
renderHistorySidebar();
|
| 340 |
renderActiveSession();
|
|
@@ -453,6 +461,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 453 |
return entry;
|
| 454 |
}
|
| 455 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
function escapeHtml(text) {
|
| 457 |
const div = document.createElement('div');
|
| 458 |
div.textContent = text;
|
|
@@ -618,7 +630,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 618 |
botEntry.className = 'message-entry bot';
|
| 619 |
botEntry.innerHTML = `
|
| 620 |
<div class="message-label">ControlAI</div>
|
| 621 |
-
<div class="message-content">
|
| 622 |
`;
|
| 623 |
chatThread.appendChild(botEntry);
|
| 624 |
smartScroll();
|
|
@@ -675,7 +687,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 675 |
const thoughtHtml = renderThoughtBox(thoughts, true);
|
| 676 |
const textHtml = accumulatedText
|
| 677 |
? `<div class="response-body">${renderMarkdownWithKaTeX(accumulatedText)}<span class="streaming-cursor"></span></div>`
|
| 678 |
-
: `<div class="response-body">
|
| 679 |
contentBox.innerHTML = thoughtHtml + textHtml;
|
| 680 |
smartScroll();
|
| 681 |
} else if (event.type === 'token') {
|
|
|
|
| 327 |
}
|
| 328 |
|
| 329 |
function deleteSession(id) {
|
| 330 |
+
const wasCurrent = currentSessionId === id;
|
| 331 |
sessions = sessions.filter((s) => s.id !== id);
|
| 332 |
saveSessions();
|
| 333 |
+
|
| 334 |
+
if (!wasCurrent) {
|
| 335 |
+
// Deleting a different chat must never touch the thread that's on
|
| 336 |
+
// screen -- rebuilding it here would wipe an in-progress streaming
|
| 337 |
+
// response out from under itself.
|
| 338 |
+
renderHistorySidebar();
|
| 339 |
+
return;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
currentSessionId = sessions.length > 0 ? sessions[0].id : null;
|
| 343 |
+
if (!currentSessionId) {
|
| 344 |
+
startNewSession();
|
| 345 |
+
return;
|
| 346 |
}
|
| 347 |
renderHistorySidebar();
|
| 348 |
renderActiveSession();
|
|
|
|
| 461 |
return entry;
|
| 462 |
}
|
| 463 |
|
| 464 |
+
function workingIndicatorHtml(label) {
|
| 465 |
+
return `<span class="generating-indicator">${label}<span class="dot"></span><span class="dot"></span><span class="dot"></span></span>`;
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
function escapeHtml(text) {
|
| 469 |
const div = document.createElement('div');
|
| 470 |
div.textContent = text;
|
|
|
|
| 630 |
botEntry.className = 'message-entry bot';
|
| 631 |
botEntry.innerHTML = `
|
| 632 |
<div class="message-label">ControlAI</div>
|
| 633 |
+
<div class="message-content">${workingIndicatorHtml('Working')}</div>
|
| 634 |
`;
|
| 635 |
chatThread.appendChild(botEntry);
|
| 636 |
smartScroll();
|
|
|
|
| 687 |
const thoughtHtml = renderThoughtBox(thoughts, true);
|
| 688 |
const textHtml = accumulatedText
|
| 689 |
? `<div class="response-body">${renderMarkdownWithKaTeX(accumulatedText)}<span class="streaming-cursor"></span></div>`
|
| 690 |
+
: `<div class="response-body">${workingIndicatorHtml('Working')}<span class="streaming-cursor"></span></div>`;
|
| 691 |
contentBox.innerHTML = thoughtHtml + textHtml;
|
| 692 |
smartScroll();
|
| 693 |
} else if (event.type === 'token') {
|
|
@@ -25,7 +25,7 @@
|
|
| 25 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/cpp.min.js"></script>
|
| 26 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/bash.min.js"></script>
|
| 27 |
|
| 28 |
-
<link rel="stylesheet" href="/static/app.css?v=
|
| 29 |
</head>
|
| 30 |
<body>
|
| 31 |
<div class="app-shell">
|
|
@@ -226,6 +226,6 @@
|
|
| 226 |
</div>
|
| 227 |
</div>
|
| 228 |
|
| 229 |
-
<script src="/static/app.js?v=
|
| 230 |
</body>
|
| 231 |
</html>
|
|
|
|
| 25 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/cpp.min.js"></script>
|
| 26 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/bash.min.js"></script>
|
| 27 |
|
| 28 |
+
<link rel="stylesheet" href="/static/app.css?v=20260819_ui_fixes">
|
| 29 |
</head>
|
| 30 |
<body>
|
| 31 |
<div class="app-shell">
|
|
|
|
| 226 |
</div>
|
| 227 |
</div>
|
| 228 |
|
| 229 |
+
<script src="/static/app.js?v=20260819_ui_fixes"></script>
|
| 230 |
</body>
|
| 231 |
</html>
|