Ekain Arrieta commited on
Commit
38fd0a7
·
2 Parent(s): 15b5ec4 77a5171

Merge branch 'main' into demo_agent

Browse files
Files changed (4) hide show
  1. .gitignore +4 -0
  2. cli.py +57 -7
  3. gradio-ui.py +521 -0
  4. logging_utils.py +63 -0
.gitignore CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  /Non_Bids_Dataset
2
  .env
3
  __pycache__/
 
1
+ .env
2
+ __pycache__/
3
+ .venv
4
+ testing_structure.xml
5
  /Non_Bids_Dataset
6
  .env
7
  __pycache__/
cli.py CHANGED
@@ -1,17 +1,45 @@
1
  import argparse
 
2
  import os
3
  import re
4
  import sys
5
  from typing import List, Optional
 
 
6
 
7
  from agent import BIDSifierAgent
8
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  def _read_optional(path: Optional[str]) -> Optional[str]:
11
  if not path:
12
  return None
13
  if not os.path.isfile(path):
14
  raise FileNotFoundError(f"File not found: {path}")
 
 
 
15
  with open(path, "r", encoding="utf-8", errors="ignore") as f:
16
  return f.read()
17
 
@@ -58,12 +86,17 @@ def short_divider(title: str) -> None:
58
  print(title)
59
  print("=" * 80 + "\n")
60
 
61
- def enter_feedback_loop(agent: BIDSifierAgent, context: dict) -> dict:
62
  feedback = input("\nAny comments or corrections to the summary? (press Enter to skip): ").strip()
63
  while feedback:
 
 
64
  context["user_feedback"] += feedback
65
- agent_response = agent.run_query(feedback)
 
 
66
  print(agent_response)
 
67
  feedback = input("\nAny additional comments or corrections? (press Enter to skip): ").strip()
68
  return context
69
 
@@ -79,12 +112,17 @@ def main(argv: Optional[List[str]] = None) -> int:
79
  parser.add_argument("--output-root", dest="output_root", help="Target BIDS root directory", required=True)
80
  parser.add_argument("--provider", dest="provider", help="Provider name or identifier, default OpeanAI", required=False, default="openai")
81
  parser.add_argument("--model", dest="model", help="Model name to use", default=os.getenv("BIDSIFIER_MODEL", "gpt-4o-mini"))
 
82
  # Execution is intentionally disabled; we only display commands.
83
  # Keeping --dry-run for backward compatibility (no effect other than display).
84
  parser.add_argument("--dry-run", dest="dry_run", help="Display-only (default behavior)", action="store_true")
85
 
86
  args = parser.parse_args(argv)
87
 
 
 
 
 
88
  dataset_xml = _read_optional(args.dataset_xml_path)
89
  readme_text = _read_optional(args.readme_path)
90
  publication_text = _read_optional(args.publication_path)
@@ -112,8 +150,11 @@ def main(argv: Optional[List[str]] = None) -> int:
112
  short_divider("Step 1: Understand dataset")
113
  summary = agent.run_step("summary", context)
114
  print(summary)
115
- context = enter_feedback_loop(agent, context)
 
 
116
  if not prompt_yes_no("Proceed to create BIDS root?", default=True):
 
117
  return 0
118
 
119
  short_divider("Step 2: Propose commands to create metadata files")
@@ -121,8 +162,11 @@ def main(argv: Optional[List[str]] = None) -> int:
121
  print(meta_plan)
122
  cmds = parse_commands_from_markdown(meta_plan)
123
  _print_commands(cmds)
124
- context = enter_feedback_loop(agent, context)
 
 
125
  if not prompt_yes_no("Proceed to create empty BIDS structure?", default=True):
 
126
  return 0
127
 
128
  short_divider("Step 3: Propose commands to create dataset structure")
@@ -130,8 +174,11 @@ def main(argv: Optional[List[str]] = None) -> int:
130
  print(struct_plan)
131
  cmds = parse_commands_from_markdown(struct_plan)
132
  _print_commands(cmds)
133
- context = enter_feedback_loop(agent, context)
 
 
134
  if not prompt_yes_no("Proceed to propose renaming/moving?", default=True):
 
135
  return 0
136
 
137
  short_divider("Step 4: Propose commands to rename/move files")
@@ -139,9 +186,12 @@ def main(argv: Optional[List[str]] = None) -> int:
139
  print(move_plan)
140
  cmds = parse_commands_from_markdown(move_plan)
141
  _print_commands(cmds)
142
- context = enter_feedback_loop(agent, context)
 
 
143
 
144
  print("\nAll steps completed. Commands were only displayed - use them manually")
 
145
  return 0
146
 
147
 
 
1
  import argparse
2
+ import logging
3
  import os
4
  import re
5
  import sys
6
  from typing import List, Optional
7
+ from pathlib import Path
8
+ from logging_utils import setup_logging
9
 
10
  from agent import BIDSifierAgent
11
+ from prompts import _ctx
12
+
13
+
14
+ def _read_pdf(path: str) -> str:
15
+ """Extract text from a PDF file using pypdf."""
16
+ try:
17
+ from pypdf import PdfReader
18
+ except ImportError as e:
19
+ raise RuntimeError(
20
+ "Reading PDFs requires the 'pypdf' package. Install it with: pip install pypdf"
21
+ ) from e
22
+ text_parts: List[str] = []
23
+ with open(path, "rb") as f:
24
+ reader = PdfReader(f)
25
+ for i, page in enumerate(reader.pages):
26
+ try:
27
+ text = page.extract_text() or ""
28
+ except Exception:
29
+ text = ""
30
+ if text.strip():
31
+ # Add lightweight page markers to help the LLM
32
+ text_parts.append(f"\n\n=== Page {i+1} ===\n{text.strip()}")
33
+ return "\n".join(text_parts).strip()
34
 
35
  def _read_optional(path: Optional[str]) -> Optional[str]:
36
  if not path:
37
  return None
38
  if not os.path.isfile(path):
39
  raise FileNotFoundError(f"File not found: {path}")
40
+ ext = os.path.splitext(path)[1].lower()
41
+ if ext == ".pdf":
42
+ return _read_pdf(path)
43
  with open(path, "r", encoding="utf-8", errors="ignore") as f:
44
  return f.read()
45
 
 
86
  print(title)
87
  print("=" * 80 + "\n")
88
 
89
+ def enter_feedback_loop(agent: BIDSifierAgent, context: dict, last_model_reply: str, logger: Optional[logging.Logger] = None) -> dict:
90
  feedback = input("\nAny comments or corrections to the summary? (press Enter to skip): ").strip()
91
  while feedback:
92
+ if logger:
93
+ logger.info("User feedback: %s", feedback)
94
  context["user_feedback"] += feedback
95
+ ctx = f"\n{_ctx(context['dataset_xml'], context['readme_text'], context['publication_text'])}"
96
+ query = f"Tackle the user feedback. \n ### Context:### {ctx} \n ### Your previous message:### {last_model_reply} \n ### User feedback:### {feedback} \n ###Output:###"
97
+ agent_response = agent.run_query(query)
98
  print(agent_response)
99
+ last_model_reply = agent_response
100
  feedback = input("\nAny additional comments or corrections? (press Enter to skip): ").strip()
101
  return context
102
 
 
112
  parser.add_argument("--output-root", dest="output_root", help="Target BIDS root directory", required=True)
113
  parser.add_argument("--provider", dest="provider", help="Provider name or identifier, default OpeanAI", required=False, default="openai")
114
  parser.add_argument("--model", dest="model", help="Model name to use", default=os.getenv("BIDSIFIER_MODEL", "gpt-4o-mini"))
115
+ parser.add_argument("--project", dest="project", help="Project name for log file prefix", required=False)
116
  # Execution is intentionally disabled; we only display commands.
117
  # Keeping --dry-run for backward compatibility (no effect other than display).
118
  parser.add_argument("--dry-run", dest="dry_run", help="Display-only (default behavior)", action="store_true")
119
 
120
  args = parser.parse_args(argv)
121
 
122
+ project_name = args.project or Path(args.output_root).name or Path(os.getcwd()).name
123
+ logger, _listener = setup_logging(project_name=project_name)
124
+ logger.info("Initialized logging for project '%s'", project_name)
125
+
126
  dataset_xml = _read_optional(args.dataset_xml_path)
127
  readme_text = _read_optional(args.readme_path)
128
  publication_text = _read_optional(args.publication_path)
 
150
  short_divider("Step 1: Understand dataset")
151
  summary = agent.run_step("summary", context)
152
  print(summary)
153
+ logger.info(summary)
154
+ logger.info("Summary step completed (length=%d chars)", len(summary))
155
+ context = enter_feedback_loop(agent, context, logger)
156
  if not prompt_yes_no("Proceed to create BIDS root?", default=True):
157
+ logger.info("User aborted after summary step.")
158
  return 0
159
 
160
  short_divider("Step 2: Propose commands to create metadata files")
 
162
  print(meta_plan)
163
  cmds = parse_commands_from_markdown(meta_plan)
164
  _print_commands(cmds)
165
+ logger.info("Metadata plan produced %s", cmds)
166
+ logger.info("Metadata plan produced %d commands", len(cmds))
167
+ context = enter_feedback_loop(agent, context, logger)
168
  if not prompt_yes_no("Proceed to create empty BIDS structure?", default=True):
169
+ logger.info("User aborted after metadata plan.")
170
  return 0
171
 
172
  short_divider("Step 3: Propose commands to create dataset structure")
 
174
  print(struct_plan)
175
  cmds = parse_commands_from_markdown(struct_plan)
176
  _print_commands(cmds)
177
+ logger.info("Structure plan produced %s", cmds)
178
+ logger.info("Structure plan produced %d commands", len(cmds))
179
+ context = enter_feedback_loop(agent, context, logger)
180
  if not prompt_yes_no("Proceed to propose renaming/moving?", default=True):
181
+ logger.info("User aborted after structure plan.")
182
  return 0
183
 
184
  short_divider("Step 4: Propose commands to rename/move files")
 
186
  print(move_plan)
187
  cmds = parse_commands_from_markdown(move_plan)
188
  _print_commands(cmds)
189
+ logger.info("Rename/move plan produced %s", cmds)
190
+ logger.info("Rename/move plan produced %d commands", len(cmds))
191
+ context = enter_feedback_loop(agent, context, logger)
192
 
193
  print("\nAll steps completed. Commands were only displayed - use them manually")
194
+ logger.info("All steps completed successfully.")
195
  return 0
196
 
197
 
gradio-ui.py ADDED
@@ -0,0 +1,521 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Gradio demo UI for the BIDSifierAgent.
4
+
5
+ This wraps the existing CLI-style step-wise logic (prompts.py + agent.py)
6
+ into an interactive Gradio interface.
7
+
8
+ Requirements
9
+ ------------
10
+ pip install gradio bids_validator python-dotenv dspy-ai
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from bids_validator import BIDSValidator
17
+ import subprocess
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional, Tuple
20
+
21
+ import gradio as gr
22
+
23
+ from agent import BIDSifierAgent # your existing agent
24
+ from cli import parse_commands_from_markdown # reuse the CLI helper if available
25
+
26
+ # Step mapping: UI label -> agent step id
27
+
28
+ BIDSIFIER_STEPS: Dict[str, str] = {
29
+ "1. Summarize dataset": "summary",
30
+ "2. Propose metadata commands": "create_metadata",
31
+ "3. Propose structure commands": "create_structure",
32
+ "4. Propose rename/move commands": "rename_move",
33
+ }
34
+
35
+ STEP_LABELS = list(BIDSIFIER_STEPS.keys())
36
+ NUM_STEPS = len(STEP_LABELS)
37
+
38
+
39
+ # Helpers
40
+
41
+ def split_shell_commands(text: str) -> List[str]:
42
+ """
43
+ Split a multi-line shell script into individual commands.
44
+
45
+ Each non-empty line is treated as a separate command, except when a line
46
+ ends with a backslash (\\), in which case it is joined with the following
47
+ line(s) to form a single logical command.
48
+
49
+ Parameters
50
+ ----------
51
+ text : str
52
+ Multi-line string containing shell commands.
53
+
54
+ Returns
55
+ -------
56
+ list of str
57
+ The list of shell commands to execute.
58
+ """
59
+ commands: List[str] = []
60
+ current: str = ""
61
+
62
+ for raw_line in text.splitlines():
63
+ line = raw_line.strip()
64
+ if not line:
65
+ continue
66
+
67
+ if current:
68
+ # Continue an ongoing command
69
+ if line.endswith("\\"):
70
+ current += " " + line[:-1].rstrip()
71
+ else:
72
+ current += " " + line
73
+ commands.append(current)
74
+ current = ""
75
+ else:
76
+ # Start a new command
77
+ if line.endswith("\\"):
78
+ current = line[:-1].rstrip()
79
+ else:
80
+ commands.append(line)
81
+
82
+ if current:
83
+ commands.append(current)
84
+
85
+ return commands
86
+
87
+
88
+ def build_context(
89
+ dataset_xml: str,
90
+ readme_text: str,
91
+ publication_text: str,
92
+ output_root: str,
93
+ ) -> Dict[str, Any]:
94
+ """
95
+ Build the context dictionary expected by BIDSifierAgent.
96
+
97
+ Parameters
98
+ ----------
99
+ dataset_xml : str
100
+ Dataset XML content (or empty string).
101
+ readme_text : str
102
+ README text content (or empty string).
103
+ publication_text : str
104
+ Publication/notes content (or empty string).
105
+ output_root : str
106
+ Target BIDS root directory.
107
+
108
+ Returns
109
+ -------
110
+ dict
111
+ Context dictionary.
112
+ """
113
+ return {
114
+ "dataset_xml": dataset_xml or None,
115
+ "readme_text": readme_text or None,
116
+ "publication_text": publication_text or None,
117
+ "output_root": output_root or "./bids_output",
118
+ "user_feedback": "",
119
+ }
120
+
121
+
122
+ # Core callbacks
123
+
124
+ def call_bidsifier_step(
125
+ dataset_xml: str,
126
+ readme_text: str,
127
+ publication_text: str,
128
+ output_root: str,
129
+ provider: str,
130
+ model: str,
131
+ step_label: str,
132
+ manual_prompt: str,
133
+ ) -> Tuple[str, str, Dict[str, Any], int]:
134
+ """
135
+ Call BIDSifierAgent for a given step and return raw output + parsed commands.
136
+
137
+ Parameters
138
+ ----------
139
+ dataset_xml : str
140
+ Dataset XML content.
141
+ readme_text : str
142
+ README content.
143
+ publication_text : str
144
+ Publication/notes content.
145
+ output_root : str
146
+ Target BIDS root directory.
147
+ provider : str
148
+ LLM provider (e.g. "openai").
149
+ model : str
150
+ LLM model name (e.g. "gpt-5" or "gpt-4o-mini").
151
+ step_label : str
152
+ UI label of the selected step.
153
+ manual_prompt : str
154
+ Optional free-form user override; if non-empty we call `run_query`
155
+ instead of the structured `run_step`.
156
+
157
+ Returns
158
+ -------
159
+ llm_output : str
160
+ Raw text returned by the LLM.
161
+ commands_str : str
162
+ Commands extracted from the first fenced bash/sh code block.
163
+ state : dict
164
+ State capturing last call inputs, for potential reuse (e.g. retry).
165
+ step_index : int
166
+ Index of the current step (for progress updates).
167
+ """
168
+ if not output_root.strip():
169
+ return (
170
+ "⚠️ Please provide an output root before calling BIDSifier.",
171
+ "",
172
+ {},
173
+ 0,
174
+ )
175
+
176
+ if step_label not in BIDSIFIER_STEPS:
177
+ return (
178
+ "⚠️ Please select a valid BIDSifier step.",
179
+ "",
180
+ {},
181
+ 0,
182
+ )
183
+
184
+ step_id = BIDSIFIER_STEPS[step_label]
185
+ context = build_context(dataset_xml, readme_text, publication_text, output_root)
186
+
187
+ agent = BIDSifierAgent(provider=provider, model=model)
188
+
189
+ # Decide whether to use the structured step prompt or a free-form query:
190
+ if manual_prompt.strip():
191
+ llm_output = agent.run_query(manual_prompt)
192
+ else:
193
+ llm_output = agent.run_step(step_id, context)
194
+
195
+ # Extract bash commands from fenced block
196
+ commands = parse_commands_from_markdown(llm_output)
197
+ commands_str = "\n".join(commands) if commands else ""
198
+
199
+ # Step index for progress bar
200
+ try:
201
+ step_index = STEP_LABELS.index(step_label) + 1
202
+ except ValueError:
203
+ step_index = 0
204
+
205
+ state = {
206
+ "dataset_xml": dataset_xml,
207
+ "readme_text": readme_text,
208
+ "publication_text": publication_text,
209
+ "output_root": output_root,
210
+ "provider": provider,
211
+ "model": model,
212
+ "step_label": step_label,
213
+ "step_id": step_id,
214
+ "llm_output": llm_output,
215
+ "commands": commands,
216
+ }
217
+
218
+ return llm_output, commands_str, state, step_index
219
+
220
+
221
+ def confirm_commands(
222
+ last_state: Optional[Dict[str, Any]],
223
+ progress_value: int,
224
+ ) -> Tuple[str, int]:
225
+ """
226
+ Confirm and execute the commands proposed in the last LLM response.
227
+
228
+ Parameters
229
+ ----------
230
+ last_state : dict or None
231
+ State returned by `call_bidsifier_step`, containing `output_root` and
232
+ list of `commands`. If None, nothing can be executed.
233
+ progress_value : int
234
+ Current progress through steps.
235
+
236
+ Returns
237
+ -------
238
+ status_message : str
239
+ Combined stdout/stderr/exit codes of executed commands.
240
+ new_progress : int
241
+ Updated progress value.
242
+ """
243
+ if not last_state:
244
+ return "⚠️ No previous BIDSifier step to confirm.", progress_value
245
+
246
+ output_root = last_state.get("output_root", "").strip()
247
+ commands: List[str] = last_state.get("commands", [])
248
+
249
+ if not output_root:
250
+ return "⚠️ Output root is empty; cannot execute commands.", progress_value
251
+
252
+ if not commands:
253
+ return "⚠️ No commands detected in the last BIDSifier output.", progress_value
254
+
255
+ root = Path(output_root)
256
+ root.mkdir(parents=True, exist_ok=True)
257
+
258
+ all_details: List[str] = []
259
+
260
+ for raw_cmd in commands:
261
+ # Support multi-line shell with backslash continuations *within* a line,
262
+ # but commands already come one-per-line from `parse_commands_from_markdown`.
263
+ for cmd in split_shell_commands(raw_cmd):
264
+ proc = subprocess.run(
265
+ cmd,
266
+ shell=True,
267
+ cwd=str(root),
268
+ capture_output=True,
269
+ text=True,
270
+ )
271
+ all_details.append(
272
+ f"Executed: {cmd}\n"
273
+ f"Exit code: {proc.returncode}\n"
274
+ f"Stdout:\n{proc.stdout}\n"
275
+ f"Stderr:\n{proc.stderr}\n" + "-" * 40
276
+ )
277
+
278
+ status = "### Command execution log\n\n" + "\n\n".join(all_details)
279
+
280
+ # Heuristic: if we executed commands for this step, bump progress to max
281
+ # of current and the step index.
282
+ step_label = last_state.get("step_label")
283
+ try:
284
+ idx = STEP_LABELS.index(step_label)
285
+ new_progress = max(progress_value, idx + 1)
286
+ except (ValueError, TypeError):
287
+ new_progress = progress_value
288
+
289
+ return status, new_progress
290
+
291
+
292
+ def run_bids_validation(output_root: str) -> Tuple[str, str]:
293
+ """
294
+ Run the BIDS filename validator on all files under `output_root`.
295
+
296
+ Parameters
297
+ ----------
298
+ output_root : str
299
+ Root directory of the BIDS dataset.
300
+
301
+ Returns
302
+ -------
303
+ report : str
304
+ A Markdown report summarizing which files are BIDS-like and which are not.
305
+ status_token : str
306
+ "pass:<timestamp>" if all files are BIDS-compliant (at least one file),
307
+ otherwise "fail:<timestamp>". The timestamp ensures Gradio's .change
308
+ event fires every time.
309
+ """
310
+ if not output_root.strip():
311
+ return (
312
+ "⚠️ Please provide an output root before running the BIDS validator.",
313
+ f"fail:{time.time()}",
314
+ )
315
+
316
+ root = Path(output_root)
317
+ if not root.exists():
318
+ return (
319
+ f"⚠️ Output root `{output_root}` does not exist. Nothing to validate.",
320
+ f"fail:{time.time()}",
321
+ )
322
+
323
+ validator = BIDSValidator()
324
+
325
+ lines = []
326
+ valid_count = 0
327
+ invalid_count = 0
328
+
329
+ for path in sorted(root.rglob("*")):
330
+ if not path.is_file():
331
+ continue
332
+ rel = path.relative_to(root)
333
+ rel_str = "/" + rel.as_posix()
334
+ is_valid = validator.is_bids(rel_str)
335
+ if is_valid:
336
+ valid_count += 1
337
+ status = "OK"
338
+ else:
339
+ invalid_count += 1
340
+ status = "NOT BIDS"
341
+ lines.append(f"{rel_str}: {status}")
342
+
343
+ if not lines:
344
+ return (
345
+ f"Note: No files found under `{output_root}` to validate.",
346
+ f"fail:{time.time()}",
347
+ )
348
+
349
+ summary = (
350
+ f"Validated {valid_count + invalid_count} files: "
351
+ f"{valid_count} OK, {invalid_count} NOT BIDS."
352
+ )
353
+ bullet_lines = "\n".join(f"- `{line}`" for line in lines)
354
+
355
+ report = f"### BIDS Validator report\n\n{bullet_lines}\n\n**Summary:** {summary}"
356
+
357
+ status_flag = "pass" if invalid_count == 0 and valid_count > 0 else "fail"
358
+ status_token = f"{status_flag}:{time.time()}"
359
+ return report, status_token
360
+
361
+
362
+ # Gradio UI
363
+
364
+ with gr.Blocks(
365
+ title="BIDSifier Agent Interface",
366
+ theme=gr.themes.Citrus(),
367
+ head="""
368
+ <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js"></script>
369
+ """,
370
+ ) as demo:
371
+ gr.Image(
372
+ value="images/bh_logo.png",
373
+ show_label=False,
374
+ height=80,
375
+ elem_id="bh_logo",
376
+ )
377
+
378
+ gr.Markdown(
379
+ """
380
+ # BIDSifier Agent Demo
381
+
382
+ Interactive UI wrapping the **BIDSifierAgent** (CLI logic) to propose
383
+ shell commands for BIDS conversion, step by step.
384
+ Commands are extracted from fenced ```bash```/```sh``` blocks.
385
+ """
386
+ )
387
+
388
+ with gr.Row():
389
+ dataset_xml_input = gr.Textbox(
390
+ label="Dataset XML",
391
+ placeholder="Paste dataset_structure.xml content here (optional)",
392
+ lines=8,
393
+ )
394
+ readme_input = gr.Textbox(
395
+ label="README",
396
+ placeholder="Paste README.md content here (optional)",
397
+ lines=8,
398
+ )
399
+
400
+ publication_input = gr.Textbox(
401
+ label="Publication / Notes",
402
+ placeholder="Paste relevant publication snippets or notes here (optional)",
403
+ lines=6,
404
+ )
405
+
406
+ with gr.Accordion("LLM settings (advanced)", open=False):
407
+ provider_input = gr.Dropdown(
408
+ label="Provider",
409
+ choices=["openai"],
410
+ value="openai",
411
+ )
412
+ model_input = gr.Textbox(
413
+ label="Model",
414
+ value="gpt-4o-mini",
415
+ placeholder="e.g., gpt-4o-mini, gpt-5",
416
+ )
417
+
418
+ output_root_input = gr.Textbox(
419
+ label="Output root",
420
+ placeholder="brainmets-bids",
421
+ lines=1,
422
+ )
423
+
424
+ step_dropdown = gr.Dropdown(
425
+ label="BIDSifier step",
426
+ choices=STEP_LABELS,
427
+ value=STEP_LABELS[0],
428
+ info="Select the current logical step in the BIDSifier workflow.",
429
+ )
430
+
431
+ progress_bar = gr.Slider(
432
+ label="Progress through BIDSifier steps",
433
+ minimum=0,
434
+ maximum=NUM_STEPS,
435
+ step=1,
436
+ value=0,
437
+ interactive=False,
438
+ )
439
+
440
+ manual_prompt_input = gr.Textbox(
441
+ label="Override prompt / free-form query (optional)",
442
+ placeholder=(
443
+ "If non-empty, this free-form query will be sent to the agent instead "
444
+ "of the structured step prompt."
445
+ ),
446
+ lines=3,
447
+ )
448
+
449
+ call_button = gr.Button("Call BIDSifier", variant="primary")
450
+
451
+ llm_output_box = gr.Textbox(
452
+ label="Raw BIDSifier output",
453
+ lines=10,
454
+ interactive=True,
455
+ )
456
+
457
+ commands_box = gr.Textbox(
458
+ label="Parsed shell commands (from fenced bash block)",
459
+ lines=10,
460
+ interactive=True,
461
+ )
462
+
463
+ confirm_button = gr.Button("Confirm / Run commands", variant="primary")
464
+ bids_validator_button = gr.Button("Run BIDS Validator", variant="primary")
465
+
466
+ status_msg = gr.Markdown(label="Status / execution log")
467
+ validation_status = gr.Textbox(visible=False)
468
+
469
+ # State to store last agent call for Confirm
470
+ last_state = gr.State(value=None)
471
+
472
+ # Wiring
473
+
474
+ call_button.click(
475
+ fn=call_bidsifier_step,
476
+ inputs=[
477
+ dataset_xml_input,
478
+ readme_input,
479
+ publication_input,
480
+ output_root_input,
481
+ provider_input,
482
+ model_input,
483
+ step_dropdown,
484
+ manual_prompt_input,
485
+ ],
486
+ outputs=[llm_output_box, commands_box, last_state, progress_bar],
487
+ )
488
+
489
+ confirm_button.click(
490
+ fn=confirm_commands,
491
+ inputs=[last_state, progress_bar],
492
+ outputs=[status_msg, progress_bar],
493
+ )
494
+
495
+ bids_validator_button.click(
496
+ fn=run_bids_validation,
497
+ inputs=[output_root_input],
498
+ outputs=[status_msg, validation_status],
499
+ )
500
+
501
+ validation_status.change(
502
+ fn=None,
503
+ inputs=[validation_status],
504
+ outputs=[],
505
+ js="""
506
+ (value) => {
507
+ if (value && value.startsWith("pass") && window.confetti) {
508
+ window.confetti({
509
+ particleCount: 240,
510
+ spread: 70,
511
+ origin: { y: 0.6 }
512
+ });
513
+ }
514
+ return [];
515
+ }
516
+ """,
517
+ )
518
+
519
+
520
+ if __name__ == "__main__":
521
+ demo.launch()
logging_utils.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import logging.handlers
3
+ import multiprocessing
4
+ import datetime
5
+ from pathlib import Path
6
+ import atexit
7
+ from typing import Tuple, Optional
8
+
9
+ DEFAULT_LOGS_DIR = "logs"
10
+
11
+
12
+ def setup_logging(project_name: str, logs_dir: str = DEFAULT_LOGS_DIR, level: int = logging.INFO,
13
+ queue_logging: bool = True) -> Tuple[logging.Logger, Optional[logging.handlers.QueueListener]]:
14
+ """
15
+ Configure parallel-safe logging.
16
+
17
+ Creates a log file named ``{project_name}-{timestamp}.log`` inside ``logs_dir``.
18
+ If ``queue_logging`` is True, uses ``multiprocessing.Queue`` with ``QueueHandler``/``QueueListener``
19
+ for process-safe logging. Returns the application logger and the listener (if any).
20
+ Caller does not need to manage handlers individually; the listener is auto-stopped at exit.
21
+ """
22
+ timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
23
+ logs_path = Path(logs_dir)
24
+ logs_path.mkdir(parents=True, exist_ok=True)
25
+ logfile = logs_path / f"{project_name}-{timestamp}.log"
26
+
27
+ formatter = logging.Formatter(
28
+ fmt="%(asctime)s | %(levelname)s | %(processName)s | %(name)s | %(message)s",
29
+ datefmt="%Y-%m-%d %H:%M:%S"
30
+ )
31
+
32
+ logger = logging.getLogger("bidsifier")
33
+ logger.setLevel(level)
34
+
35
+ listener: Optional[logging.handlers.QueueListener] = None
36
+
37
+ if queue_logging:
38
+ log_queue: multiprocessing.Queue = multiprocessing.Queue(-1)
39
+ queue_handler = logging.handlers.QueueHandler(log_queue)
40
+ file_handler = logging.FileHandler(str(logfile), encoding="utf-8")
41
+ file_handler.setFormatter(formatter)
42
+ listener = logging.handlers.QueueListener(log_queue, file_handler)
43
+ listener.start()
44
+ logger.addHandler(queue_handler)
45
+
46
+ def _stop_listener() -> None:
47
+ try:
48
+ listener.stop()
49
+ except Exception:
50
+ pass
51
+ atexit.register(_stop_listener)
52
+ else:
53
+ file_handler = logging.FileHandler(str(logfile), encoding="utf-8")
54
+ file_handler.setFormatter(formatter)
55
+ logger.addHandler(file_handler)
56
+
57
+ # Also add a simple stderr stream handler for immediate feedback.
58
+ stream_handler = logging.StreamHandler()
59
+ stream_handler.setFormatter(formatter)
60
+ logger.addHandler(stream_handler)
61
+
62
+ logger.debug("Logging initialized: %s", logfile)
63
+ return logger, listener