cfdpilot 0.1.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .env
8
+ *.bak
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: cfdpilot
3
+ Version: 0.1.11
4
+ Summary: AI agent for OpenFOAM — diagnose, fix, and relaunch cases from your terminal
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: python-dotenv>=1.0
8
+ Requires-Dist: rich>=13
9
+ Requires-Dist: typer>=0.12
10
+ Provides-Extra: dev
11
+ Requires-Dist: httpx; extra == 'dev'
12
+ Requires-Dist: pytest-asyncio; extra == 'dev'
13
+ Requires-Dist: pytest>=8; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # CFDpilot
17
+
18
+ **An AI agent for OpenFOAM — in your terminal.**
19
+
20
+ CFDpilot reads your OpenFOAM case directly from disk, diagnoses why it diverges or
21
+ gives wrong physics, proposes the exact fix as a colored diff, and — once you confirm —
22
+ patches the files and reruns the solver. No ZIP upload, no browser. It runs where you
23
+ already work: your laptop, your workstation, or an HPC node over SSH.
24
+
25
+ ```bash
26
+ pip install cfdpilot
27
+ cfdpilot login # authenticate once
28
+ cd path/to/your/case && cfdpilot
29
+ ```
30
+
31
+ Then just ask, in plain English:
32
+
33
+ > *"Why won't my rhoSimpleFoam case converge?"*
34
+
35
+ The agent maps your case, reads `fvSchemes`, `fvSolution`, your boundary conditions and
36
+ solver log, reasons from the actual numbers (Courant number, residuals, turbulence wall
37
+ treatment, scheme/switch consistency), and reports the bugs ranked by impact — with the
38
+ exact fix for each. Nothing is changed without showing you a diff first, and every patch
39
+ makes a `.bak` backup.
40
+
41
+ ## What it checks
42
+
43
+ Boundary-condition consistency, turbulence wall treatment (low-Re vs high-Re), Courant
44
+ number and time-step control, relaxation factors, discretisation schemes, solver-mode
45
+ switches (e.g. `transonic`), and solver/turbulence-model compatibility — across
46
+ incompressible, compressible, multiphase (VoF) and heat-transfer solvers.
47
+
48
+ ## Requirements
49
+
50
+ - Python 3.10+
51
+ - An OpenFOAM case directory (Foundation or ESI). OpenFOAM itself can run natively or in
52
+ Docker; the agent will use it to run the solver when you ask.
53
+
54
+ ## Access
55
+
56
+ CFDpilot is in **free early access** for the first engineers. Run `cfdpilot login` to get
57
+ started, or request access at [cfdpilot.com](https://cfdpilot.com).
@@ -0,0 +1,42 @@
1
+ # CFDpilot
2
+
3
+ **An AI agent for OpenFOAM — in your terminal.**
4
+
5
+ CFDpilot reads your OpenFOAM case directly from disk, diagnoses why it diverges or
6
+ gives wrong physics, proposes the exact fix as a colored diff, and — once you confirm —
7
+ patches the files and reruns the solver. No ZIP upload, no browser. It runs where you
8
+ already work: your laptop, your workstation, or an HPC node over SSH.
9
+
10
+ ```bash
11
+ pip install cfdpilot
12
+ cfdpilot login # authenticate once
13
+ cd path/to/your/case && cfdpilot
14
+ ```
15
+
16
+ Then just ask, in plain English:
17
+
18
+ > *"Why won't my rhoSimpleFoam case converge?"*
19
+
20
+ The agent maps your case, reads `fvSchemes`, `fvSolution`, your boundary conditions and
21
+ solver log, reasons from the actual numbers (Courant number, residuals, turbulence wall
22
+ treatment, scheme/switch consistency), and reports the bugs ranked by impact — with the
23
+ exact fix for each. Nothing is changed without showing you a diff first, and every patch
24
+ makes a `.bak` backup.
25
+
26
+ ## What it checks
27
+
28
+ Boundary-condition consistency, turbulence wall treatment (low-Re vs high-Re), Courant
29
+ number and time-step control, relaxation factors, discretisation schemes, solver-mode
30
+ switches (e.g. `transonic`), and solver/turbulence-model compatibility — across
31
+ incompressible, compressible, multiphase (VoF) and heat-transfer solvers.
32
+
33
+ ## Requirements
34
+
35
+ - Python 3.10+
36
+ - An OpenFOAM case directory (Foundation or ESI). OpenFOAM itself can run natively or in
37
+ Docker; the agent will use it to run the solver when you ask.
38
+
39
+ ## Access
40
+
41
+ CFDpilot is in **free early access** for the first engineers. Run `cfdpilot login` to get
42
+ started, or request access at [cfdpilot.com](https://cfdpilot.com).
File without changes
@@ -0,0 +1,331 @@
1
+ import json
2
+ from cfdpilot.client import CFDPilotClient
3
+ from cfdpilot.tools.filesystem import read_file, list_dir
4
+ from cfdpilot.tools.log_parser import parse_log
5
+ from cfdpilot.tools.patcher import patch_dict, generate_diff
6
+ from cfdpilot.tools.runner import run_command, run_solver
7
+
8
+ MAX_ATTEMPTS = 3
9
+ MAX_TOOL_ROUNDS = 18 # per user request: cap agentic tool-rounds → force a final answer instead of looping forever (prevents the "fix and stabilize" runaway burn)
10
+
11
+ TOOL_SCHEMAS = [
12
+ {
13
+ "name": "read_file",
14
+ "description": "Read a file from the OpenFOAM case directory",
15
+ "input_schema": {
16
+ "type": "object",
17
+ "properties": {"path": {"type": "string"}},
18
+ "required": ["path"]
19
+ }
20
+ },
21
+ {
22
+ "name": "list_dir",
23
+ "description": "List files in a directory",
24
+ "input_schema": {
25
+ "type": "object",
26
+ "properties": {"path": {"type": "string", "default": "."}},
27
+ "required": []
28
+ }
29
+ },
30
+ {
31
+ "name": "parse_log",
32
+ "description": "Parse an OpenFOAM log file. Returns structured JSON with residuals, Co number, divergence info.",
33
+ "input_schema": {
34
+ "type": "object",
35
+ "properties": {"log_path": {"type": "string", "description": "Path to log file, or omit to auto-detect"}},
36
+ "required": []
37
+ }
38
+ },
39
+ {
40
+ "name": "run_command",
41
+ "description": "Run an OpenFOAM command (blockMesh, checkMesh, foamDictionary, etc.). NOT for the main solver.",
42
+ "input_schema": {
43
+ "type": "object",
44
+ "properties": {"cmd": {"type": "string"}},
45
+ "required": ["cmd"]
46
+ }
47
+ },
48
+ {
49
+ "name": "run_solver",
50
+ "description": "Launch the OpenFOAM solver and stream residuals. Auto-stops on convergence or divergence.",
51
+ "input_schema": {
52
+ "type": "object",
53
+ "properties": {
54
+ "solver": {"type": "string"},
55
+ "max_iterations": {"type": "integer"}
56
+ },
57
+ "required": ["solver"]
58
+ }
59
+ },
60
+ {
61
+ "name": "show_diff",
62
+ "description": "Show a colored diff of proposed changes to a file. ALWAYS call this before patch_dict.",
63
+ "input_schema": {
64
+ "type": "object",
65
+ "properties": {
66
+ "file_path": {"type": "string"},
67
+ "proposed_content": {"type": "string"}
68
+ },
69
+ "required": ["file_path", "proposed_content"]
70
+ }
71
+ },
72
+ {
73
+ "name": "patch_dict",
74
+ "description": "Apply confirmed changes to an OpenFOAM dict file. Creates .bak backup automatically.",
75
+ "input_schema": {
76
+ "type": "object",
77
+ "properties": {
78
+ "file_path": {"type": "string"},
79
+ "changes": {"type": "object", "description": "key-value pairs to update"}
80
+ },
81
+ "required": ["file_path", "changes"]
82
+ }
83
+ },
84
+ {
85
+ "name": "search_knowledge",
86
+ "description": "Search the CFDpilot knowledge base: 1,000+ CFD-Online threads + OpenFOAM user guide. Use when you need community experience or documentation on a specific OF topic.",
87
+ "input_schema": {
88
+ "type": "object",
89
+ "properties": {"query": {"type": "string", "description": "What to search for, e.g. 'GAMG divergence kOmegaSST' or 'nOuterCorrectors pimpleFoam'"}},
90
+ "required": ["query"]
91
+ }
92
+ },
93
+ ]
94
+
95
+
96
+ def _dispatch_tool(name: str, inputs: dict, cwd: str, display, client=None) -> str:
97
+ """Execute a tool call and return result as JSON string."""
98
+ try:
99
+ if name == "read_file":
100
+ result = read_file(inputs["path"], cwd=cwd)
101
+ return json.dumps(result)
102
+
103
+ elif name == "list_dir":
104
+ result = list_dir(inputs.get("path", "."), cwd=cwd)
105
+ return json.dumps(result)
106
+
107
+ elif name == "parse_log":
108
+ result = parse_log(inputs.get("log_path"), cwd=cwd)
109
+ return json.dumps(result)
110
+
111
+ elif name == "run_command":
112
+ display.print_action(f"Running: {inputs['cmd']}")
113
+ result = run_command(inputs["cmd"], cwd=cwd)
114
+ return json.dumps(result)
115
+
116
+ elif name == "run_solver":
117
+ display.print_action(f"Launching {inputs['solver']}...")
118
+ result = run_solver(
119
+ inputs["solver"],
120
+ cwd=cwd,
121
+ max_iterations=inputs.get("max_iterations"),
122
+ on_line=display.print_solver_line
123
+ )
124
+ return json.dumps(result)
125
+
126
+ elif name == "show_diff":
127
+ diff = generate_diff(inputs["file_path"], inputs["proposed_content"])
128
+ display.print_diff(diff)
129
+ confirmed = display.confirm("Apply changes?")
130
+ return json.dumps({"diff": diff, "confirmed": confirmed})
131
+
132
+ elif name == "patch_dict":
133
+ display.print_action(f"Patching {inputs['file_path']}")
134
+ try:
135
+ result = patch_dict(inputs["file_path"], inputs["changes"])
136
+ display.print_action(f"Backup saved → {result['backup']}")
137
+ return json.dumps(result)
138
+ except FileNotFoundError as e:
139
+ return json.dumps({"success": False, "error": str(e)})
140
+
141
+ elif name == "search_knowledge":
142
+ query = inputs["query"]
143
+ display.print_action(f"Searching knowledge base: {query}")
144
+ results = client.search(query) if client else []
145
+ return json.dumps({"results": results, "count": len(results)})
146
+
147
+ return json.dumps({"error": f"Unknown tool: {name}"})
148
+
149
+ except KeyError as e:
150
+ return json.dumps({"error": f"Missing required input: {e}"})
151
+ except Exception as e:
152
+ return json.dumps({"error": f"Tool error: {type(e).__name__}: {e}"})
153
+
154
+
155
+ SESSION_TOKEN_CAP = 250_000
156
+
157
+
158
+ def _build_case_context(cwd: str, case_info: dict) -> str:
159
+ """Build a context block injected as the first user message."""
160
+ import os
161
+ lines = [f"Working directory: {cwd}"]
162
+ if case_info.get("flat_structure"):
163
+ files = ", ".join(case_info.get("fields", [])) or "unknown"
164
+ lines.append("Structure: FLAT (files dumped in one directory — no 0/constant/system hierarchy)")
165
+ lines.append(f"Files present: {files}")
166
+ lines.append("Note: read files directly by name (e.g. read_file('U'), read_file('fvSchemes'))")
167
+ else:
168
+ lines.append("Structure: standard OpenFOAM (0/ constant/ system/)")
169
+ if case_info.get("solver") and case_info["solver"] != "unknown":
170
+ lines.append(f"Solver: {case_info['solver']}")
171
+ if case_info.get("turbulence_model") and case_info["turbulence_model"] != "unknown":
172
+ lines.append(f"Turbulence: {case_info['turbulence_model']}")
173
+ if case_info.get("log_file"):
174
+ lines.append(f"Log file: {case_info['log_file']}")
175
+ else:
176
+ lines.append("Log file: none found")
177
+ return "\n".join(lines)
178
+
179
+
180
+ def run_agent_loop(token: str, cwd: str, display, case_info: dict = None) -> None:
181
+ """Main conversation loop. Runs until user exits."""
182
+ client = CFDPilotClient(token)
183
+ messages = []
184
+ session_tokens = 0
185
+
186
+ # Inject case context as a silent system message (not shown to user)
187
+ if case_info:
188
+ ctx = _build_case_context(cwd, case_info)
189
+ messages.append({
190
+ "role": "user",
191
+ "content": f"[CASE CONTEXT — read this before answering anything]\n{ctx}"
192
+ })
193
+ messages.append({
194
+ "role": "assistant",
195
+ "content": [{"type": "text", "text": "Case context noted. Ready."}]
196
+ })
197
+
198
+ display.print_welcome()
199
+
200
+ while True:
201
+ try:
202
+ user_input = display.get_input()
203
+ except (KeyboardInterrupt, EOFError):
204
+ display.print_goodbye()
205
+ break
206
+
207
+ if user_input.lower() in ("exit", "quit", "q"):
208
+ display.print_goodbye()
209
+ break
210
+
211
+ if not user_input.strip():
212
+ continue
213
+
214
+ messages.append({"role": "user", "content": user_input})
215
+ attempt_count = 0 # Reset per user turn
216
+ tool_rounds = 0 # Reset per user turn — caps the agentic loop
217
+
218
+ # Agentic inner loop: keep going until no more tool calls
219
+ while True:
220
+ if session_tokens >= SESSION_TOKEN_CAP:
221
+ display.print_error(
222
+ f"Session limit reached ({session_tokens:,} tokens). "
223
+ "Start a new session: cfdpilot"
224
+ )
225
+ return
226
+
227
+ try:
228
+ with display.thinking():
229
+ response = client.chat(messages, TOOL_SCHEMAS)
230
+ except RuntimeError as e:
231
+ display.print_error(str(e))
232
+ break
233
+
234
+ usage = response.get("usage", {})
235
+ # Count only genuinely NEW tokens. cache_read/cache_creation are the SAME
236
+ # context re-read across the many agentic sub-calls — counting them blew the
237
+ # cap after ~3 prompts ("session times out"). Cost is capped server-side anyway.
238
+ session_tokens += (
239
+ usage.get("input_tokens", 0)
240
+ + usage.get("output_tokens", 0)
241
+ )
242
+
243
+ content = response.get("content", [])
244
+ stop_reason = response.get("stop_reason", "end_turn")
245
+ credits = response.get("credits")
246
+
247
+ # Display text blocks
248
+ for block in content:
249
+ if block.get("type") == "text":
250
+ display.print_text(block["text"])
251
+
252
+ # Add assistant turn to history
253
+ messages.append({"role": "assistant", "content": content})
254
+
255
+ # No tool calls → turn done
256
+ if stop_reason != "tool_use":
257
+ display.print_credits(credits)
258
+ break
259
+
260
+ # Extract and execute tool calls
261
+ tool_results = []
262
+ stuck = False
263
+ for block in content:
264
+ if block.get("type") != "tool_use":
265
+ continue
266
+
267
+ tool_name = block["name"]
268
+ tool_inputs = block.get("input", {})
269
+ tool_id = block["id"]
270
+
271
+ display.print_action(f"● {tool_name}...")
272
+
273
+ # STUCK tracking
274
+ if tool_name == "patch_dict":
275
+ attempt_count += 1
276
+ if attempt_count > MAX_ATTEMPTS:
277
+ display.print_stuck(attempt_count)
278
+ tool_results.append({
279
+ "type": "tool_result",
280
+ "tool_use_id": tool_id,
281
+ "content": json.dumps({"error": "MAX_ATTEMPTS_REACHED"})
282
+ })
283
+ stuck = True
284
+ continue
285
+
286
+ result = _dispatch_tool(tool_name, tool_inputs, cwd, display, client)
287
+ tool_results.append({
288
+ "type": "tool_result",
289
+ "tool_use_id": tool_id,
290
+ "content": result # full result sent to model this turn
291
+ })
292
+
293
+ # Truncate tool results before storing in history to prevent context bloat.
294
+ # The model already processed the full content this turn.
295
+ MAX_TOOL_RESULT_CHARS = 1500
296
+ history_tool_results = []
297
+ for tr in tool_results:
298
+ content = tr["content"]
299
+ if len(content) > MAX_TOOL_RESULT_CHARS:
300
+ content = content[:MAX_TOOL_RESULT_CHARS] + "…[truncated]"
301
+ history_tool_results.append({**tr, "content": content})
302
+ tool_rounds += 1
303
+ cap_hit = tool_rounds >= MAX_TOOL_ROUNDS
304
+ if cap_hit and history_tool_results:
305
+ # Inject the wrap-up instruction INTO the last tool_result (stays one user turn,
306
+ # satisfies the API's tool_result requirement) rather than as a 2nd user message.
307
+ history_tool_results[-1] = {
308
+ **history_tool_results[-1],
309
+ "content": history_tool_results[-1]["content"]
310
+ + "\n\n[Step limit reached for this request — do NOT request more tools. "
311
+ "Give your best final answer now: confirmed issues + exact fixes, and note anything still unresolved.]"
312
+ }
313
+ messages.append({"role": "user", "content": history_tool_results})
314
+
315
+ if stuck:
316
+ break
317
+
318
+ if cap_hit:
319
+ display.print_action(f"Step limit ({MAX_TOOL_ROUNDS} tool rounds) reached — wrapping up with findings so far.")
320
+ try:
321
+ with display.thinking():
322
+ final = client.chat(messages, []) # no tools → model must answer in text
323
+ except RuntimeError as e:
324
+ display.print_error(str(e))
325
+ break
326
+ for fb in final.get("content", []):
327
+ if fb.get("type") == "text":
328
+ display.print_text(fb["text"])
329
+ messages.append({"role": "assistant", "content": final.get("content", [])})
330
+ display.print_credits(final.get("credits"))
331
+ break
@@ -0,0 +1,57 @@
1
+ import json
2
+ import time
3
+ import httpx
4
+ from pathlib import Path
5
+
6
+ TOKEN_PATH = Path.home() / ".cfdpilot" / "config"
7
+ API_BASE = "https://cfdpilot.com"
8
+ POLL_INTERVAL = 2
9
+ POLL_TIMEOUT = 600
10
+
11
+
12
+ def save_token(token: str) -> None:
13
+ TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
14
+ TOKEN_PATH.write_text(json.dumps({"token": token}))
15
+ TOKEN_PATH.chmod(0o600)
16
+
17
+
18
+ def load_token() -> str | None:
19
+ if not TOKEN_PATH.exists():
20
+ return None
21
+ try:
22
+ return json.loads(TOKEN_PATH.read_text()).get("token")
23
+ except Exception:
24
+ return None
25
+
26
+
27
+ def device_login() -> str:
28
+ """Run device flow. Blocks until authorized or timeout."""
29
+ try:
30
+ with httpx.Client(timeout=10) as client:
31
+ resp = client.post(f"{API_BASE}/device/code")
32
+ resp.raise_for_status()
33
+ data = resp.json()
34
+ except Exception as e:
35
+ raise ConnectionError(f"Could not reach cfdpilot.com: {e}") from e
36
+
37
+ code = data["code"]
38
+ url = data["url"]
39
+ print(f"\n Open this URL in your browser:\n {url}\n")
40
+ print(" Waiting for authentication", end="", flush=True)
41
+
42
+ deadline = time.time() + POLL_TIMEOUT
43
+ with httpx.Client() as client:
44
+ while time.time() < deadline:
45
+ time.sleep(POLL_INTERVAL)
46
+ print(".", end="", flush=True)
47
+ try:
48
+ resp = client.get(f"{API_BASE}/device/poll/{code}")
49
+ if resp.status_code == 200:
50
+ data = resp.json()
51
+ if data["status"] == "authorized":
52
+ print(" ✓")
53
+ return data["token"]
54
+ except Exception:
55
+ continue
56
+
57
+ raise TimeoutError("Authentication timed out. Run `cfdpilot login` again.")
@@ -0,0 +1,104 @@
1
+ import re
2
+ from pathlib import Path
3
+ from cfdpilot.tools.log_parser import _find_log
4
+
5
+ SOLVER_PATTERN = re.compile(r"application\s+(\w+);")
6
+ TURBULENCE_PATTERN = re.compile(r"RASModel\s+(\w+);|LESModel\s+(\w+);|model\s+(\w+);")
7
+ DELTA_T_PATTERN = re.compile(r"deltaT\s+([\d.eE+-]+);")
8
+ END_TIME_PATTERN = re.compile(r"endTime\s+([\d.eE+-]+);")
9
+
10
+ # OF11+ uses a generic runner (`application foamRun;`) with the real physics solver
11
+ # in the `solver` entry (e.g. `solver incompressibleVoF;`). Report both so the agent
12
+ # knows the physics (VoF/compressible/…) AND that the launch command is still foamRun.
13
+ SOLVER_MODULE_PATTERN = re.compile(r"^\s*solver\s+(\w+);", re.MULTILINE)
14
+ _GENERIC_RUNNERS = {"foamRun", "foamMultiRun"}
15
+
16
+
17
+ def _detect_solver(ctrl: str) -> str:
18
+ m = SOLVER_PATTERN.search(ctrl)
19
+ app = m.group(1) if m else None
20
+ if app in _GENERIC_RUNNERS:
21
+ sm = SOLVER_MODULE_PATTERN.search(ctrl)
22
+ if sm:
23
+ return f"{app} ({sm.group(1)})"
24
+ return app or "unknown"
25
+
26
+
27
+ _OF_FLAT_MARKERS = {"controlDict", "fvSchemes", "fvSolution", "turbulenceProperties", "momentumTransport"}
28
+ _OF_BC_FILES = {"U", "p", "T", "k", "omega", "epsilon", "nut", "alphat", "nuTilda", "p_rgh"}
29
+
30
+
31
+ def detect_case(cwd: str) -> dict:
32
+ base = Path(cwd)
33
+ required = [base / "system" / "controlDict", base / "constant", base / "0"]
34
+
35
+ # Standard OF structure
36
+ if all(p.exists() for p in required):
37
+ pass # proceed below
38
+ else:
39
+ # Flat structure: files dumped in one directory (forum thread, shared files)
40
+ flat_files = {f.name for f in base.iterdir() if f.is_file()}
41
+ has_markers = bool(flat_files & _OF_FLAT_MARKERS)
42
+ has_bc = bool(flat_files & _OF_BC_FILES)
43
+ if not (has_markers or has_bc):
44
+ return {"is_of_case": False}
45
+ # Flat case detected — build result directly
46
+ result = {"is_of_case": True, "cwd": cwd, "flat_structure": True}
47
+ ctrl_path = base / "controlDict"
48
+ if ctrl_path.exists():
49
+ ctrl = ctrl_path.read_text(errors="replace")
50
+ result["solver"] = _detect_solver(ctrl)
51
+ m = DELTA_T_PATTERN.search(ctrl)
52
+ result["deltaT"] = float(m.group(1)) if m else None
53
+ m = END_TIME_PATTERN.search(ctrl)
54
+ result["endTime"] = float(m.group(1)) if m else None
55
+ else:
56
+ result["solver"] = "unknown"
57
+ for turb_name in ("turbulenceProperties", "momentumTransport"):
58
+ turb_path = base / turb_name
59
+ if turb_path.exists():
60
+ turb = turb_path.read_text(errors="replace")
61
+ m = TURBULENCE_PATTERN.search(turb)
62
+ result["turbulence_model"] = next((g for g in m.groups() if g), "unknown") if m else "unknown"
63
+ result["simulation_type"] = "LES" if "LESModel" in turb else "RAS"
64
+ break
65
+ else:
66
+ result["turbulence_model"] = "unknown"
67
+ result["mesh_type"] = "unknown"
68
+ result["log_file"] = None
69
+ result["fields"] = sorted(flat_files & _OF_BC_FILES)
70
+ return result
71
+
72
+ result = {"is_of_case": True, "cwd": cwd}
73
+
74
+ ctrl = (base / "system" / "controlDict").read_text(errors="replace")
75
+ result["solver"] = _detect_solver(ctrl)
76
+
77
+ m = DELTA_T_PATTERN.search(ctrl)
78
+ result["deltaT"] = float(m.group(1)) if m else None
79
+
80
+ m = END_TIME_PATTERN.search(ctrl)
81
+ result["endTime"] = float(m.group(1)) if m else None
82
+
83
+ turb_file = base / "constant" / "turbulenceProperties"
84
+ if not turb_file.exists():
85
+ turb_file = base / "constant" / "momentumTransport"
86
+ result["turbulence_model"] = "unknown"
87
+ if turb_file.exists():
88
+ turb = turb_file.read_text(errors="replace")
89
+ m = TURBULENCE_PATTERN.search(turb)
90
+ if m:
91
+ result["turbulence_model"] = next(g for g in m.groups() if g)
92
+ result["simulation_type"] = "LES" if "LESModel" in turb else "RAS"
93
+
94
+ result["mesh_type"] = "blockMesh"
95
+ if (base / "system" / "snappyHexMeshDict").exists():
96
+ result["mesh_type"] = "snappyHexMesh"
97
+
98
+ log = _find_log(cwd)
99
+ result["log_file"] = str(log) if log else None
100
+
101
+ fields = list((base / "0").iterdir())
102
+ result["fields"] = [f.name for f in fields if f.is_file()]
103
+
104
+ return result
@@ -0,0 +1,65 @@
1
+ import json
2
+ import uuid
3
+ import httpx
4
+
5
+ API_BASE = "https://cfdpilot.com"
6
+
7
+
8
+ class CFDPilotClient:
9
+ def __init__(self, token: str):
10
+ self.token = token
11
+ self.session_id = str(uuid.uuid4())
12
+ self.headers = {
13
+ "Authorization": f"Bearer {token}",
14
+ "Content-Type": "application/json",
15
+ }
16
+
17
+ def search(self, query: str) -> list[dict]:
18
+ """GET /agent/search — query the RAG knowledge base."""
19
+ try:
20
+ with httpx.Client(timeout=30) as client:
21
+ response = client.get(
22
+ f"{API_BASE}/agent/search",
23
+ headers=self.headers,
24
+ params={"query": query},
25
+ )
26
+ response.raise_for_status()
27
+ return response.json().get("results", [])
28
+ except Exception:
29
+ return []
30
+
31
+ def chat(self, messages: list[dict], tools: list[dict]) -> dict:
32
+ """POST to /agent/chat, return the response dict."""
33
+ try:
34
+ with httpx.Client(timeout=120) as client:
35
+ response = client.post(
36
+ f"{API_BASE}/agent/chat",
37
+ headers=self.headers,
38
+ json={"messages": messages, "tools": tools, "session_id": self.session_id},
39
+ )
40
+ if response.status_code in (401, 402, 403, 429):
41
+ try:
42
+ detail = response.json().get("detail")
43
+ except Exception:
44
+ detail = None
45
+ raise RuntimeError(detail or "No beta access yet — email rayan@cfdpilot.com")
46
+ response.raise_for_status()
47
+ return response.json()
48
+ except httpx.TimeoutException:
49
+ raise RuntimeError("Request timed out. The backend may be overloaded.")
50
+ except httpx.HTTPStatusError as e:
51
+ raise RuntimeError(f"Backend error: HTTP {e.response.status_code}")
52
+ except httpx.NetworkError as e:
53
+ raise RuntimeError(f"Network error: {e}")
54
+
55
+ def report_error(self, error: str, tb: str) -> None:
56
+ """Best-effort: report a CLI crash to the backend so it's logged. Never raises."""
57
+ try:
58
+ with httpx.Client(timeout=10) as client:
59
+ client.post(
60
+ f"{API_BASE}/agent/error",
61
+ headers=self.headers,
62
+ json={"session_id": self.session_id, "error": error, "traceback": tb},
63
+ )
64
+ except Exception:
65
+ pass