ara-cli 0.1.9.77__py3-none-any.whl → 0.1.10.8__py3-none-any.whl

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.

Potentially problematic release.


This version of ara-cli might be problematic. Click here for more details.

Files changed (122) hide show
  1. ara_cli/__init__.py +18 -2
  2. ara_cli/__main__.py +245 -66
  3. ara_cli/ara_command_action.py +128 -63
  4. ara_cli/ara_config.py +201 -177
  5. ara_cli/ara_subcommands/__init__.py +0 -0
  6. ara_cli/ara_subcommands/autofix.py +26 -0
  7. ara_cli/ara_subcommands/chat.py +27 -0
  8. ara_cli/ara_subcommands/classifier_directory.py +16 -0
  9. ara_cli/ara_subcommands/common.py +100 -0
  10. ara_cli/ara_subcommands/create.py +75 -0
  11. ara_cli/ara_subcommands/delete.py +22 -0
  12. ara_cli/ara_subcommands/extract.py +22 -0
  13. ara_cli/ara_subcommands/fetch_templates.py +14 -0
  14. ara_cli/ara_subcommands/list.py +65 -0
  15. ara_cli/ara_subcommands/list_tags.py +25 -0
  16. ara_cli/ara_subcommands/load.py +48 -0
  17. ara_cli/ara_subcommands/prompt.py +136 -0
  18. ara_cli/ara_subcommands/read.py +47 -0
  19. ara_cli/ara_subcommands/read_status.py +20 -0
  20. ara_cli/ara_subcommands/read_user.py +20 -0
  21. ara_cli/ara_subcommands/reconnect.py +27 -0
  22. ara_cli/ara_subcommands/rename.py +22 -0
  23. ara_cli/ara_subcommands/scan.py +14 -0
  24. ara_cli/ara_subcommands/set_status.py +22 -0
  25. ara_cli/ara_subcommands/set_user.py +22 -0
  26. ara_cli/ara_subcommands/template.py +16 -0
  27. ara_cli/artefact_autofix.py +214 -28
  28. ara_cli/artefact_creator.py +5 -8
  29. ara_cli/artefact_deleter.py +2 -4
  30. ara_cli/artefact_fuzzy_search.py +13 -6
  31. ara_cli/artefact_lister.py +29 -55
  32. ara_cli/artefact_models/artefact_data_retrieval.py +23 -0
  33. ara_cli/artefact_models/artefact_model.py +106 -25
  34. ara_cli/artefact_models/artefact_templates.py +23 -13
  35. ara_cli/artefact_models/epic_artefact_model.py +11 -2
  36. ara_cli/artefact_models/feature_artefact_model.py +56 -1
  37. ara_cli/artefact_models/userstory_artefact_model.py +15 -3
  38. ara_cli/artefact_reader.py +4 -5
  39. ara_cli/artefact_renamer.py +6 -2
  40. ara_cli/artefact_scan.py +2 -2
  41. ara_cli/chat.py +594 -219
  42. ara_cli/chat_agent/__init__.py +0 -0
  43. ara_cli/chat_agent/agent_communicator.py +62 -0
  44. ara_cli/chat_agent/agent_process_manager.py +211 -0
  45. ara_cli/chat_agent/agent_status_manager.py +73 -0
  46. ara_cli/chat_agent/agent_workspace_manager.py +76 -0
  47. ara_cli/commands/__init__.py +0 -0
  48. ara_cli/commands/command.py +7 -0
  49. ara_cli/commands/extract_command.py +15 -0
  50. ara_cli/commands/load_command.py +65 -0
  51. ara_cli/commands/load_image_command.py +34 -0
  52. ara_cli/commands/read_command.py +117 -0
  53. ara_cli/completers.py +144 -0
  54. ara_cli/directory_navigator.py +37 -4
  55. ara_cli/error_handler.py +134 -0
  56. ara_cli/file_classifier.py +3 -2
  57. ara_cli/file_loaders/__init__.py +0 -0
  58. ara_cli/file_loaders/binary_file_loader.py +33 -0
  59. ara_cli/file_loaders/document_file_loader.py +34 -0
  60. ara_cli/file_loaders/document_reader.py +245 -0
  61. ara_cli/file_loaders/document_readers.py +233 -0
  62. ara_cli/file_loaders/file_loader.py +50 -0
  63. ara_cli/file_loaders/file_loaders.py +123 -0
  64. ara_cli/file_loaders/image_processor.py +89 -0
  65. ara_cli/file_loaders/markdown_reader.py +75 -0
  66. ara_cli/file_loaders/text_file_loader.py +187 -0
  67. ara_cli/global_file_lister.py +51 -0
  68. ara_cli/prompt_extractor.py +214 -87
  69. ara_cli/prompt_handler.py +508 -146
  70. ara_cli/tag_extractor.py +54 -24
  71. ara_cli/template_loader.py +245 -0
  72. ara_cli/template_manager.py +14 -4
  73. ara_cli/templates/prompt-modules/commands/empty.commands.md +2 -12
  74. ara_cli/templates/prompt-modules/commands/extract_general.commands.md +12 -0
  75. ara_cli/templates/prompt-modules/commands/extract_markdown.commands.md +11 -0
  76. ara_cli/templates/prompt-modules/commands/extract_python.commands.md +13 -0
  77. ara_cli/templates/prompt-modules/commands/feature_add_or_modifiy_specified_behavior.commands.md +36 -0
  78. ara_cli/templates/prompt-modules/commands/feature_generate_initial_specified_bevahior.commands.md +53 -0
  79. ara_cli/templates/prompt-modules/commands/prompt_template_tech_stack_transformer.commands.md +95 -0
  80. ara_cli/templates/prompt-modules/commands/python_bug_fixing_code.commands.md +34 -0
  81. ara_cli/templates/prompt-modules/commands/python_generate_code.commands.md +27 -0
  82. ara_cli/templates/prompt-modules/commands/python_refactoring_code.commands.md +39 -0
  83. ara_cli/templates/prompt-modules/commands/python_step_definitions_generation_and_fixing.commands.md +40 -0
  84. ara_cli/templates/prompt-modules/commands/python_unittest_generation_and_fixing.commands.md +48 -0
  85. ara_cli/update_config_prompt.py +7 -1
  86. ara_cli/version.py +1 -1
  87. ara_cli-0.1.10.8.dist-info/METADATA +241 -0
  88. {ara_cli-0.1.9.77.dist-info → ara_cli-0.1.10.8.dist-info}/RECORD +104 -59
  89. tests/test_ara_command_action.py +66 -52
  90. tests/test_ara_config.py +200 -279
  91. tests/test_artefact_autofix.py +361 -5
  92. tests/test_artefact_lister.py +52 -132
  93. tests/test_artefact_scan.py +1 -1
  94. tests/test_chat.py +2009 -603
  95. tests/test_file_classifier.py +23 -0
  96. tests/test_file_creator.py +3 -5
  97. tests/test_global_file_lister.py +131 -0
  98. tests/test_prompt_handler.py +746 -0
  99. tests/test_tag_extractor.py +19 -13
  100. tests/test_template_loader.py +192 -0
  101. tests/test_template_manager.py +5 -4
  102. ara_cli/ara_command_parser.py +0 -536
  103. ara_cli/templates/prompt-modules/blueprints/complete_pytest_unittest.blueprint.md +0 -27
  104. ara_cli/templates/prompt-modules/blueprints/task_todo_list_implement_feature_BDD_way.blueprint.md +0 -30
  105. ara_cli/templates/prompt-modules/commands/artefact_classification.commands.md +0 -9
  106. ara_cli/templates/prompt-modules/commands/artefact_extension.commands.md +0 -17
  107. ara_cli/templates/prompt-modules/commands/artefact_formulation.commands.md +0 -14
  108. ara_cli/templates/prompt-modules/commands/behave_step_generation.commands.md +0 -102
  109. ara_cli/templates/prompt-modules/commands/code_generation_complex.commands.md +0 -20
  110. ara_cli/templates/prompt-modules/commands/code_generation_simple.commands.md +0 -13
  111. ara_cli/templates/prompt-modules/commands/error_fixing.commands.md +0 -20
  112. ara_cli/templates/prompt-modules/commands/feature_file_update.commands.md +0 -18
  113. ara_cli/templates/prompt-modules/commands/feature_formulation.commands.md +0 -43
  114. ara_cli/templates/prompt-modules/commands/js_code_generation_simple.commands.md +0 -13
  115. ara_cli/templates/prompt-modules/commands/refactoring.commands.md +0 -15
  116. ara_cli/templates/prompt-modules/commands/refactoring_analysis.commands.md +0 -9
  117. ara_cli/templates/prompt-modules/commands/reverse_engineer_feature_file.commands.md +0 -15
  118. ara_cli/templates/prompt-modules/commands/reverse_engineer_program_flow.commands.md +0 -19
  119. ara_cli-0.1.9.77.dist-info/METADATA +0 -18
  120. {ara_cli-0.1.9.77.dist-info → ara_cli-0.1.10.8.dist-info}/WHEEL +0 -0
  121. {ara_cli-0.1.9.77.dist-info → ara_cli-0.1.10.8.dist-info}/entry_points.txt +0 -0
  122. {ara_cli-0.1.9.77.dist-info → ara_cli-0.1.10.8.dist-info}/top_level.txt +0 -0
ara_cli/prompt_handler.py CHANGED
@@ -1,10 +1,4 @@
1
1
  import base64
2
- import litellm
3
- from ara_cli.classifier import Classifier
4
- from ara_cli.artefact_creator import ArtefactCreator
5
- from ara_cli.template_manager import TemplatePathManager
6
- from ara_cli.ara_config import ConfigManager, LLMConfigItem
7
- from ara_cli.file_lister import generate_markdown_listing
8
2
  from os.path import exists, join
9
3
  import os
10
4
  from os import makedirs
@@ -13,109 +7,350 @@ import re
13
7
  import shutil
14
8
  import glob
15
9
  import logging
10
+ import warnings
11
+ from io import StringIO
12
+ from contextlib import redirect_stderr
13
+ from langfuse import Langfuse
14
+ from langfuse.api.resources.commons.errors import Error as LangfuseError, NotFoundError
15
+ import litellm
16
+ from ara_cli.classifier import Classifier
17
+ from ara_cli.artefact_creator import ArtefactCreator
18
+ from ara_cli.template_manager import TemplatePathManager
19
+ from ara_cli.ara_config import ConfigManager
20
+ from ara_cli.file_lister import generate_markdown_listing
16
21
 
17
22
 
18
23
  class LLMSingleton:
19
24
  _instance = None
20
- _model = None
25
+ _default_model = None
26
+ _extraction_model = None
27
+ langfuse = None
21
28
 
22
- def __init__(self, model_id):
29
+ def __init__(self, default_model_id, extraction_model_id):
23
30
  config = ConfigManager().get_config()
24
- selected_config = config.llm_config.get(str(model_id))
31
+ default_config_data = config.llm_config.get(str(default_model_id))
25
32
 
26
- if not selected_config:
27
- raise ValueError(f"No configuration found for the model: {model_id}")
33
+ if not default_config_data:
34
+ raise ValueError(
35
+ f"No configuration found for the default model: {default_model_id}"
36
+ )
37
+ self.default_config_params = default_config_data.model_dump(exclude_none=True)
28
38
 
29
- LLMSingleton._model = model_id
30
-
31
- # Typesafe for None values inside the config.
32
- self.config_parameters = selected_config.model_dump(exclude_none=True)
39
+ extraction_config_data = config.llm_config.get(str(extraction_model_id))
40
+ if not extraction_config_data:
41
+ raise ValueError(
42
+ f"No configuration found for the extraction model: {extraction_model_id}"
43
+ )
44
+ self.extraction_config_params = extraction_config_data.model_dump(
45
+ exclude_none=True
46
+ )
47
+
48
+ langfuse_public_key = os.getenv("ARA_CLI_LANGFUSE_PUBLIC_KEY")
49
+ langfuse_secret_key = os.getenv("ARA_CLI_LANGFUSE_SECRET_KEY")
50
+ langfuse_host = os.getenv("LANGFUSE_HOST")
51
+
52
+ captured_stderr = StringIO()
53
+ with redirect_stderr(captured_stderr):
54
+ self.langfuse = Langfuse(
55
+ public_key=langfuse_public_key,
56
+ secret_key=langfuse_secret_key,
57
+ host=langfuse_host,
58
+ )
33
59
 
60
+ # Check if there was an authentication error
61
+ stderr_output = captured_stderr.getvalue()
62
+ if "Authentication error" in stderr_output:
63
+ warnings.warn(
64
+ "Invalid Langfuse credentials - prompt tracing disabled and using default prompts. "
65
+ "Set environment variables 'ARA_CLI_LANGFUSE_PUBLIC_KEY', 'ARA_CLI_LANGFUSE_SECRET_KEY', "
66
+ "'LANGFUSE_HOST' and restart application to use Langfuse capabilities",
67
+ UserWarning,
68
+ )
69
+
70
+ LLMSingleton._default_model = default_model_id
71
+ LLMSingleton._extraction_model = extraction_model_id
34
72
  LLMSingleton._instance = self
35
73
 
36
74
  @classmethod
37
75
  def get_instance(cls):
38
76
  if cls._instance is None:
39
77
  config = ConfigManager().get_config()
40
- llm_config = config.llm_config
41
- model_to_use = next(iter(llm_config))
42
- default_llm = getattr(config, "default_llm", None)
43
- if default_llm and default_llm in llm_config:
44
- model_to_use = default_llm
45
- cls(model_to_use)
78
+ default_model = config.default_llm
79
+ if not default_model:
80
+ if not config.llm_config:
81
+ raise ValueError(
82
+ "No LLM configurations are defined in the configuration file."
83
+ )
84
+ default_model = next(iter(config.llm_config))
85
+
86
+ extraction_model = getattr(config, "extraction_llm", default_model)
87
+ if not extraction_model:
88
+ extraction_model = default_model
89
+
90
+ cls(default_model, extraction_model)
91
+ return cls._instance
92
+
93
+ @classmethod
94
+ def get_config_by_purpose(cls, purpose="default"):
95
+ """
96
+ purpose= 'default' or 'extraction'
97
+ """
98
+ instance = cls.get_instance()
99
+ if purpose == "extraction":
100
+ return instance.extraction_config_params.copy()
101
+ return instance.default_config_params.copy()
102
+
103
+ @classmethod
104
+ def set_default_model(cls, model_name):
105
+ """Sets the default language model for the current session."""
106
+ cls.get_instance()
107
+ if model_name == cls._default_model:
108
+ return cls._instance
109
+ cls(model_name, cls._extraction_model)
46
110
  return cls._instance
47
111
 
48
112
  @classmethod
49
- def set_model(cls, model_name):
50
- if model_name == cls._model:
113
+ def set_extraction_model(cls, model_name):
114
+ """Sets the extraction language model for the current session."""
115
+ cls.get_instance()
116
+ if model_name == cls._extraction_model:
51
117
  return cls._instance
52
- cls(model_name)
53
- print(f"Language model switched to '{model_name}'")
118
+ cls(cls._default_model, model_name)
54
119
  return cls._instance
55
120
 
56
121
  @classmethod
57
- def get_model(cls):
122
+ def get_default_model(cls):
123
+ """Gets the default model name stored in the singleton instance."""
124
+ if cls._instance is None:
125
+ cls.get_instance()
126
+ return cls._default_model
127
+
128
+ @classmethod
129
+ def get_extraction_model(cls):
130
+ """Gets the extraction model name stored in the singleton instance."""
58
131
  if cls._instance is None:
59
132
  cls.get_instance()
60
- return cls._model
133
+ return cls._extraction_model
61
134
 
62
135
 
63
136
  def write_string_to_file(filename, string, mode):
64
- with open(filename, mode, encoding='utf-8') as file:
137
+ with open(filename, mode, encoding="utf-8") as file:
65
138
  file.write(f"\n{string}\n")
66
139
  return file
67
140
 
68
141
 
69
142
  def read_string_from_file(path):
70
- with open(path, 'r', encoding='utf-8') as file:
143
+ with open(path, "r", encoding="utf-8") as file:
71
144
  text = file.read()
72
145
  return text
73
146
 
74
147
 
75
- def send_prompt(prompt):
76
- chat = LLMSingleton.get_instance()
148
+ def _is_valid_message(message: dict) -> bool:
149
+ """
150
+ Checks if a message in a prompt is valid (i.e., not empty).
151
+ It handles both string content and list content (for multimodal inputs).
152
+ """
153
+ content = message.get("content")
154
+
155
+ if isinstance(content, str):
156
+ return content.strip() != ""
157
+
158
+ if isinstance(content, list):
159
+ # For multimodal content, check if there's at least one non-empty text part.
160
+ return any(
161
+ item.get("type") == "text" and item.get("text", "").strip() != ""
162
+ for item in content
163
+ )
164
+
165
+ return False
166
+
77
167
 
78
- config_parameters = chat.config_parameters.copy()
79
- if "provider" in config_parameters:
80
- del config_parameters["provider"]
168
+ def _norm(p: str) -> str:
169
+ """Normalize slashes and collapse .. segments."""
170
+ return os.path.normpath(p) if p else p
81
171
 
82
- filtered_prompt = list(filter(
83
- lambda msg: (
84
- msg.get('content') and
85
- isinstance(msg['content'], list) and
86
- any(
87
- item.get('type') == 'text' and
88
- item.get('text', '').strip() != ''
89
- for item in msg['content']
172
+
173
+ def resolve_existing_path(rel_or_abs_path: str, anchor_dir: str) -> str:
174
+ """
175
+ Resolve a potentially relative path to an existing absolute path.
176
+
177
+ Strategy:
178
+ - If already absolute and exists -> return it.
179
+ - Else, try from the anchor_dir.
180
+ - Else, walk up parent directories from anchor_dir and try joining at each level.
181
+ - If nothing is found, return the normalized original (will fail later with clear message).
182
+ """
183
+ if not rel_or_abs_path:
184
+ return rel_or_abs_path
185
+
186
+ candidate = _norm(rel_or_abs_path)
187
+
188
+ if os.path.isabs(candidate) and os.path.exists(candidate):
189
+ return candidate
190
+
191
+ anchor_dir = os.path.abspath(anchor_dir or os.getcwd())
192
+
193
+ # Try from anchor dir directly
194
+ direct = _norm(os.path.join(anchor_dir, candidate))
195
+ if os.path.exists(direct):
196
+ return direct
197
+
198
+ # Walk parents
199
+ cur = anchor_dir
200
+ prev = None
201
+ while cur and cur != prev:
202
+ test = _norm(os.path.join(cur, candidate))
203
+ if os.path.exists(test):
204
+ return test
205
+ prev = cur
206
+ cur = os.path.dirname(cur)
207
+
208
+ # Give back normalized candidate; open() will raise, but at least path is clean
209
+ return candidate
210
+
211
+
212
+ def send_prompt(prompt, purpose="default"):
213
+ """Prepares and sends a prompt to the LLM, streaming the response."""
214
+ chat_instance = LLMSingleton.get_instance()
215
+ config_parameters = chat_instance.get_config_by_purpose(purpose)
216
+ model_info = config_parameters.get("model", "unknown_model")
217
+
218
+ with LLMSingleton.get_instance().langfuse.start_as_current_span(
219
+ name="send_prompt"
220
+ ) as span:
221
+ span.update_trace(
222
+ input={"prompt": prompt, "purpose": purpose, "model": model_info}
223
+ )
224
+
225
+ config_parameters.pop("provider", None)
226
+
227
+ filtered_prompt = [msg for msg in prompt if _is_valid_message(msg)]
228
+
229
+ completion = litellm.completion(
230
+ **config_parameters, messages=filtered_prompt, stream=True
231
+ )
232
+ response_text = ""
233
+ try:
234
+ for chunk in completion:
235
+ chunk_content = chunk.choices[0].delta.content
236
+ if chunk_content:
237
+ response_text += chunk_content
238
+ yield chunk
239
+
240
+ # Update Langfuse span with success output
241
+ span.update(
242
+ output={
243
+ "success": True,
244
+ "response_length": len(response_text),
245
+ "response": response_text,
246
+ }
90
247
  )
91
- ) or (
92
- isinstance(msg.get('content'), str) and
93
- msg['content'].strip() != ''
94
- ),
95
- prompt
96
- ))
97
-
98
- completion = litellm.completion(
99
- **config_parameters,
100
- messages=filtered_prompt,
101
- stream=True
102
- )
103
- for chunk in completion:
104
- yield chunk
248
+
249
+ except Exception as e:
250
+ # Update Langfuse span with error details
251
+ span.update(output={"error": str(e)}, level="ERROR")
252
+ raise
253
+
254
+
255
+ def describe_image(image_path: str) -> str:
256
+ """
257
+ Send an image to the LLM and get a text description.
258
+
259
+ Args:
260
+ image_path: Path to the image file
261
+
262
+ Returns:
263
+ Text description of the image
264
+ """
265
+ with LLMSingleton.get_instance().langfuse.start_as_current_span(
266
+ name="ara-cli/describe-image"
267
+ ) as span:
268
+ span.update_trace(input={"image_path": image_path})
269
+
270
+ try:
271
+ langfuse_prompt = LLMSingleton.get_instance().langfuse.get_prompt(
272
+ "ara-cli/describe-image"
273
+ )
274
+ describe_image_prompt = (
275
+ langfuse_prompt.prompt if langfuse_prompt.prompt else None
276
+ )
277
+ except (LangfuseError, NotFoundError, Exception) as e:
278
+ logging.info(f"Could not fetch Langfuse prompt: {e}")
279
+ describe_image_prompt = None
280
+
281
+ # Fallback to default prompt if Langfuse prompt is not available
282
+ if not describe_image_prompt:
283
+ logging.info("Using default describe-image prompt.")
284
+ describe_image_prompt = (
285
+ "Please describe this image in detail. If it contains text, transcribe it exactly. "
286
+ "If it's a diagram or chart, explain its structure and content. If it's a photo or illustration, "
287
+ "describe what you see."
288
+ )
289
+
290
+ # Resolve and read the image
291
+ resolved_image_path = resolve_existing_path(image_path, os.getcwd())
292
+ with open(resolved_image_path, "rb") as image_file:
293
+ base64_image = base64.b64encode(image_file.read()).decode("utf-8")
294
+
295
+ # Determine image type
296
+ image_extension = os.path.splitext(resolved_image_path)[1].lower()
297
+ mime_type = {
298
+ ".png": "image/png",
299
+ ".jpg": "image/jpeg",
300
+ ".jpeg": "image/jpeg",
301
+ ".gif": "image/gif",
302
+ ".bmp": "image/bmp",
303
+ }.get(image_extension, "image/png")
304
+
305
+ # Create message with image
306
+ message = {
307
+ "role": "user",
308
+ "content": [
309
+ {
310
+ "type": "text",
311
+ "text": describe_image_prompt,
312
+ },
313
+ {
314
+ "type": "image_url",
315
+ "image_url": {"url": f"data:{mime_type};base64,{base64_image}"},
316
+ },
317
+ ],
318
+ }
319
+
320
+ # Get response from LLM using the extraction model purpose
321
+ response_text = ""
322
+ for chunk in send_prompt([message], purpose="extraction"):
323
+ chunk_content = chunk.choices[0].delta.content
324
+ if chunk_content:
325
+ response_text += chunk_content
326
+
327
+ response_text = response_text.strip()
328
+
329
+ span.update(
330
+ output={
331
+ "success": True,
332
+ "description_length": len(response_text),
333
+ "response": response_text,
334
+ }
335
+ )
336
+
337
+ return response_text
105
338
 
106
339
 
107
340
  def append_headings(classifier, param, heading_name):
108
341
  sub_directory = Classifier.get_sub_directory(classifier)
109
342
 
110
- artefact_data_path = f"ara/{sub_directory}/{param}.data/{classifier}.prompt_log.md"
343
+ artefact_data_path = _norm(
344
+ f"ara/{sub_directory}/{param}.data/{classifier}.prompt_log.md"
345
+ )
111
346
 
112
347
  # Check if the file exists, and if not, create an empty file
113
348
  if not os.path.exists(artefact_data_path):
114
- with open(artefact_data_path, 'w', encoding='utf-8') as file:
349
+ with open(artefact_data_path, "w", encoding="utf-8") as file:
115
350
  file.write("")
116
351
 
117
352
  content = read_string_from_file(artefact_data_path)
118
- pattern = r'## {}_(\d+)'.format(heading_name)
353
+ pattern = r"## {}_(\d+)".format(heading_name)
119
354
  matches = findall(pattern, content)
120
355
 
121
356
  max_number = 1
@@ -123,27 +358,27 @@ def append_headings(classifier, param, heading_name):
123
358
  max_number = max(map(int, matches)) + 1
124
359
  heading = f"## {heading_name}_{max_number}"
125
360
 
126
- write_string_to_file(artefact_data_path, heading, 'a')
361
+ write_string_to_file(artefact_data_path, heading, "a")
127
362
 
128
363
 
129
364
  def write_prompt_result(classifier, param, text):
130
365
  sub_directory = Classifier.get_sub_directory(classifier)
131
-
132
- # TODO change absolute path to relative path with directory navigator
133
- artefact_data_path = f"ara/{sub_directory}/{param}.data/{classifier}.prompt_log.md"
134
- write_string_to_file(artefact_data_path, text, 'a')
366
+ artefact_data_path = _norm(
367
+ f"ara/{sub_directory}/{param}.data/{classifier}.prompt_log.md"
368
+ )
369
+ write_string_to_file(artefact_data_path, text, "a")
135
370
 
136
371
 
137
372
  def prompt_data_directory_creation(classifier, parameter):
138
373
  sub_directory = Classifier.get_sub_directory(classifier)
139
- prompt_data_path = f"ara/{sub_directory}/{parameter}.data/prompt.data"
374
+ prompt_data_path = _norm(f"ara/{sub_directory}/{parameter}.data/prompt.data")
140
375
  if not exists(prompt_data_path):
141
376
  makedirs(prompt_data_path)
142
377
  return prompt_data_path
143
378
 
144
379
 
145
380
  def get_file_content(path):
146
- with open(path, 'r', encoding='utf-8') as file:
381
+ with open(path, "r", encoding="utf-8") as file:
147
382
  return file.read()
148
383
 
149
384
 
@@ -151,14 +386,25 @@ def initialize_prompt_templates(classifier, parameter):
151
386
  prompt_data_path = prompt_data_directory_creation(classifier, parameter)
152
387
  prompt_log_path = os.path.dirname(prompt_data_path)
153
388
 
154
- template_path = os.path.join(os.path.dirname(__file__), 'templates')
389
+ template_path = os.path.join(os.path.dirname(__file__), "templates")
155
390
  artefact_creator = ArtefactCreator()
156
- artefact_creator.create_artefact_prompt_files(prompt_log_path, template_path, classifier)
391
+ artefact_creator.create_artefact_prompt_files(
392
+ prompt_log_path, template_path, classifier
393
+ )
157
394
 
158
395
  generate_config_prompt_template_file(prompt_data_path, "config.prompt_templates.md")
159
396
 
160
397
  # Mark the relevant artefact in the givens list
161
- generate_config_prompt_givens_file(prompt_data_path, "config.prompt_givens.md", artefact_to_mark=f"{parameter}.{classifier}")
398
+ generate_config_prompt_givens_file(
399
+ prompt_data_path,
400
+ "config.prompt_givens.md",
401
+ artefact_to_mark=f"{parameter}.{classifier}",
402
+ )
403
+
404
+ # Only once (was duplicated before)
405
+ generate_config_prompt_global_givens_file(
406
+ prompt_data_path, "config.prompt_global_givens.md"
407
+ )
162
408
 
163
409
 
164
410
  def write_template_files_to_config(template_type, config_file, base_template_path):
@@ -170,14 +416,14 @@ def write_template_files_to_config(template_type, config_file, base_template_pat
170
416
 
171
417
  def load_selected_prompt_templates(classifier, parameter):
172
418
  sub_directory = Classifier.get_sub_directory(classifier)
173
- prompt_data_path = f"ara/{sub_directory}/{parameter}.data/prompt.data"
419
+ prompt_data_path = _norm(f"ara/{sub_directory}/{parameter}.data/prompt.data")
174
420
  config_file_path = os.path.join(prompt_data_path, "config.prompt_templates.md")
175
421
 
176
422
  if not os.path.exists(config_file_path):
177
423
  print("WARNING: config.prompt_templates.md does not exist.")
178
424
  return
179
425
 
180
- with open(config_file_path, 'r', encoding='utf-8') as config_file:
426
+ with open(config_file_path, "r", encoding="utf-8") as config_file:
181
427
  content = config_file.read()
182
428
 
183
429
  global_base_template_path = TemplatePathManager.get_template_base_path()
@@ -213,7 +459,9 @@ def find_files_with_endings(directory, endings):
213
459
  # Create an empty dictionary to store files according to their endings
214
460
  files_by_ending = {ending: [] for ending in endings}
215
461
 
216
- files = [f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f))]
462
+ files = [
463
+ f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f))
464
+ ]
217
465
  # Walk through the files list
218
466
  for file in files:
219
467
  # Check each file to see if it ends with one of the specified endings
@@ -248,75 +496,114 @@ def move_and_copy_files(source_path, prompt_data_path, prompt_archive_path):
248
496
 
249
497
  # Move all existing files with the same ending to the prompt_archive_path
250
498
  for existing_file in glob.glob(glob_pattern):
251
- archived_file_path = os.path.join(prompt_archive_path, os.path.basename(existing_file))
499
+ archived_file_path = os.path.join(
500
+ prompt_archive_path, os.path.basename(existing_file)
501
+ )
252
502
  shutil.move(existing_file, archived_file_path)
253
- print(f"Moved existing prompt-module: {os.path.basename(existing_file)} to prompt.archive")
254
-
503
+ print(
504
+ f"Moved existing prompt-module: {os.path.basename(existing_file)} to prompt.archive"
505
+ )
506
+
255
507
  # Copy the source_path file to the prompt_data_path directory
256
508
  target_path = os.path.join(prompt_data_path, file_name)
257
509
  shutil.copy(source_path, target_path)
258
510
  print(f"Loaded new prompt-module: {os.path.basename(target_path)}")
259
511
 
260
512
  else:
261
- print(f"File name {file_name} does not end with one of the specified patterns, skipping move and copy.")
513
+ print(
514
+ f"File name {file_name} does not end with one of the specified patterns, skipping move and copy."
515
+ )
262
516
  else:
263
517
  print(f"WARNING: template {source_path} does not exist.")
264
518
 
265
519
 
266
520
  def extract_and_load_markdown_files(md_prompt_file_path):
267
521
  """
268
- Extracts markdown files paths based on checked items and constructs proper paths respecting markdown header hierarchy.
522
+ Extracts markdown files paths based on checked items and constructs proper paths
523
+ respecting markdown header hierarchy. **Returns normalized relative paths**
524
+ (not resolved), and resolution happens later relative to the config file dir.
269
525
  """
270
526
  header_stack = []
271
527
  path_accumulator = []
272
- with open(md_prompt_file_path, 'r', encoding='utf-8') as file:
528
+ with open(md_prompt_file_path, "r", encoding="utf-8") as file:
273
529
  for line in file:
274
- if line.strip().startswith('#'):
275
- level = line.count('#')
276
- header = line.strip().strip('#').strip()
530
+ if line.strip().startswith("#"):
531
+ level = line.count("#")
532
+ header = line.strip().strip("#").strip()
277
533
  # Adjust the stack based on the current header level
278
534
  current_depth = len(header_stack)
279
535
  if level <= current_depth:
280
- header_stack = header_stack[:level-1]
536
+ header_stack = header_stack[: level - 1]
281
537
  header_stack.append(header)
282
- elif '[x]' in line:
283
- relative_path = line.split(']')[-1].strip()
284
- full_path = os.path.join('/'.join(header_stack), relative_path)
285
- path_accumulator.append(full_path)
538
+ elif "[x]" in line:
539
+ relative_path = line.split("]")[-1].strip()
540
+ # Use os.path.join for OS-safe joining, then normalize
541
+ full_rel_path = os.path.join(*header_stack, relative_path) if header_stack else relative_path
542
+ path_accumulator.append(_norm(full_rel_path))
286
543
  return path_accumulator
287
544
 
288
545
 
289
546
  def load_givens(file_path):
290
- content = "### GIVENS\n\n"
547
+ """
548
+ Reads marked givens from a config markdown and returns:
549
+ - combined markdown content (including code fences / images)
550
+ - a list of image data dicts for the multimodal message
551
+ Paths inside the markdown are resolved robustly relative to the config file directory (and its parents).
552
+ """
553
+ content = ""
291
554
  image_data_list = []
292
555
  markdown_items = extract_and_load_markdown_files(file_path)
293
556
 
557
+ if not markdown_items:
558
+ return "", []
559
+
560
+ content = "### GIVENS\n\n"
561
+
562
+ anchor_dir = os.path.dirname(os.path.abspath(file_path))
563
+
294
564
  for item in markdown_items:
295
- if item.lower().endswith(('.png', '.jpeg', '.jpg')):
296
- with open(item, "rb") as image_file:
565
+ resolved = resolve_existing_path(item, anchor_dir)
566
+ # Keep the listing line readable, show the original relative item
567
+ content += item + "\n"
568
+
569
+ ext = os.path.splitext(resolved)[1].lower()
570
+
571
+ # Image branch
572
+ if ext in (".png", ".jpeg", ".jpg", ".gif", ".bmp"):
573
+ with open(resolved, "rb") as image_file:
297
574
  base64_image = base64.b64encode(image_file.read()).decode("utf-8")
298
- image_data_list.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64_image}"}})
299
- content += item + "\n"
300
- content += f'![{item}](data:image/png;base64,{base64_image})' + "\n"
301
- else:
302
- # Check if the item specifies line ranges
303
- # TODO item has currently no trailing [] see extraction and handover method in extract and load
304
- # item = f"[10:29] {item}"
305
- # print(f"found {item}, check for subsection")
306
- # TODO re.match can not split the item with [] correctly and extract the line numbers
307
- # TODO logic of subsections is not supported by the update algorithm of the config prompt givens updater
308
- # TODO extract in lines of *.md files potential images and add them to the image list
309
575
 
576
+ mime_type = {
577
+ ".png": "image/png",
578
+ ".jpg": "image/jpeg",
579
+ ".jpeg": "image/jpeg",
580
+ ".gif": "image/gif",
581
+ ".bmp": "image/bmp",
582
+ }.get(ext, "image/png")
583
+
584
+ image_data_list.append(
585
+ {
586
+ "type": "image_url",
587
+ "image_url": {"url": f"data:{mime_type};base64,{base64_image}"},
588
+ }
589
+ )
590
+ # Also embed inline for the prompt markdown (use png as a neutral default for data URI)
591
+ content += f"![{item}](data:{mime_type};base64,{base64_image})\n"
592
+
593
+ else:
594
+ # Check if the item specifies line ranges: e.g. "[10:20,25:30] filePath"
310
595
  match = re.match(r".*?\[(\d+:\d+(?:,\s*\d+:\d+)*)\]\s+(.+)", item)
311
596
  if match:
312
597
  line_ranges, file_name = match.groups()
313
- content += file_name + "\n" + "```\n"
314
- content += get_partial_file_content(file_name, line_ranges) + "\n"
598
+ resolved_sub = resolve_existing_path(file_name, anchor_dir)
599
+ content += "```\n"
600
+ content += get_partial_file_content(resolved_sub, line_ranges) + "\n"
315
601
  content += "```\n\n"
316
602
  else:
317
- content += item + "\n" + "```\n"
318
- content += get_file_content(item) + "\n"
603
+ content += "```\n"
604
+ content += get_file_content(resolved) + "\n"
319
605
  content += "```\n\n"
606
+
320
607
  return content, image_data_list
321
608
 
322
609
 
@@ -325,25 +612,25 @@ def get_partial_file_content(file_name, line_ranges):
325
612
  Reads specific lines from a file based on the line ranges provided.
326
613
 
327
614
  Args:
328
- file_name (str): The path to the file.
615
+ file_name (str): The path to the file (absolute or relative, already resolved by caller).
329
616
  line_ranges (str): A string representing the line ranges to read, e.g., '10:20,25:30'.
330
617
 
331
618
  Returns:
332
619
  str: The content of the specified lines.
333
620
  """
334
- line_ranges = line_ranges.strip('[]').split(',')
621
+ line_ranges = line_ranges.strip("[]").split(",")
335
622
  lines_to_read = []
336
623
  for line_range in line_ranges:
337
- start, end = map(int, line_range.split(':'))
624
+ start, end = map(int, line_range.split(":"))
338
625
  lines_to_read.extend(range(start, end + 1))
339
626
 
340
627
  partial_content = []
341
- with open(file_name, 'r', encoding='utf-8') as file:
628
+ with open(file_name, "r", encoding="utf-8") as file:
342
629
  for i, line in enumerate(file, 1):
343
630
  if i in lines_to_read:
344
631
  partial_content.append(line)
345
632
 
346
- return ''.join(partial_content)
633
+ return "".join(partial_content)
347
634
 
348
635
 
349
636
  def collect_file_content_by_extension(prompt_data_path, extensions):
@@ -353,7 +640,7 @@ def collect_file_content_by_extension(prompt_data_path, extensions):
353
640
  files = find_files_with_endings(prompt_data_path, [ext])
354
641
  for file_name in files:
355
642
  file_path = join(prompt_data_path, file_name)
356
- if ext == ".prompt_givens.md":
643
+ if ext in [".prompt_givens.md", ".prompt_global_givens.md"]:
357
644
  givens, image_data = load_givens(file_path)
358
645
  combined_content += givens
359
646
  image_data_list.extend(image_data)
@@ -363,27 +650,53 @@ def collect_file_content_by_extension(prompt_data_path, extensions):
363
650
 
364
651
 
365
652
  def prepend_system_prompt(message_list):
366
- system_prompt = {
367
- "role": "system",
368
- "content": "You are a helpful assistant that can process both text and images."
369
- }
370
- message_list.insert(0, system_prompt)
653
+ try:
654
+ langfuse_prompt = LLMSingleton.get_instance().langfuse.get_prompt(
655
+ "ara-cli/system-prompt"
656
+ )
657
+ system_prompt = langfuse_prompt.prompt if langfuse_prompt.prompt else None
658
+ except (LangfuseError, NotFoundError, Exception) as e:
659
+ logging.info(f"Could not fetch Langfuse system prompt: {e}")
660
+ system_prompt = None
661
+
662
+ # Fallback to default prompt if Langfuse prompt is not available
663
+ if not system_prompt:
664
+ logging.info("Using default system prompt.")
665
+ system_prompt = "You are a helpful assistant that can process both text and images."
666
+
667
+ # Prepend the system prompt
668
+ system_prompt_message = {"role": "system", "content": system_prompt}
669
+
670
+ message_list.insert(0, system_prompt_message)
371
671
  return message_list
372
672
 
373
673
 
374
674
  def append_images_to_message(message, image_data_list):
675
+ """
676
+ Appends image data list to a single message dict (NOT to a list).
677
+ """
375
678
  logger = logging.getLogger(__name__)
376
679
 
377
- logger.debug(f"append_images_to_message called with image_data_list length: {len(image_data_list) if image_data_list else 0}")
680
+ logger.debug(
681
+ f"append_images_to_message called with image_data_list length: {len(image_data_list) if image_data_list else 0}"
682
+ )
378
683
 
379
684
  if not image_data_list:
380
685
  logger.debug("No images to append, returning original message")
381
686
  return message
382
687
 
383
- message_content = message["content"]
688
+ message_content = message.get("content")
384
689
  logger.debug(f"Original message content: {message_content}")
385
690
 
386
- message["content"] = message_content + image_data_list
691
+ if isinstance(message_content, str):
692
+ message["content"] = [{"type": "text", "text": message_content}]
693
+
694
+ if isinstance(message["content"], list):
695
+ message["content"].extend(image_data_list)
696
+ else:
697
+ # If somehow content is not list or str, coerce to list
698
+ message["content"] = [{"type": "text", "text": str(message_content)}] + image_data_list
699
+
387
700
  logger.debug(f"Updated message content with {len(image_data_list)} images")
388
701
 
389
702
  return message
@@ -391,74 +704,123 @@ def append_images_to_message(message, image_data_list):
391
704
 
392
705
  def create_and_send_custom_prompt(classifier, parameter):
393
706
  sub_directory = Classifier.get_sub_directory(classifier)
394
- prompt_data_path = f"ara/{sub_directory}/{parameter}.data/prompt.data"
707
+ prompt_data_path = _norm(f"ara/{sub_directory}/{parameter}.data/prompt.data")
395
708
  prompt_file_path_markdown = join(prompt_data_path, f"{classifier}.prompt.md")
396
709
 
397
- extensions = [".blueprint.md", ".rules.md", ".prompt_givens.md", ".intention.md", ".commands.md"]
398
- combined_content_markdown, image_data_list = collect_file_content_by_extension(prompt_data_path, extensions)
710
+ extensions = [
711
+ ".blueprint.md",
712
+ ".rules.md",
713
+ ".prompt_givens.md",
714
+ ".prompt_global_givens.md",
715
+ ".intention.md",
716
+ ".commands.md",
717
+ ]
718
+ combined_content_markdown, image_data_list = collect_file_content_by_extension(
719
+ prompt_data_path, extensions
720
+ )
399
721
 
400
- with open(prompt_file_path_markdown, 'w', encoding='utf-8') as file:
722
+ with open(prompt_file_path_markdown, "w", encoding="utf-8") as file:
401
723
  file.write(combined_content_markdown)
402
724
 
403
725
  prompt = read_string_from_file(prompt_file_path_markdown)
404
726
  append_headings(classifier, parameter, "prompt")
405
727
  write_prompt_result(classifier, parameter, prompt)
406
728
 
407
- message = {
408
- "role": "user",
409
- "content": combined_content_markdown
410
- }
411
-
729
+ # Build message and append images correctly (fixed)
730
+ message = {"role": "user", "content": combined_content_markdown}
731
+ message = append_images_to_message(message, image_data_list)
412
732
  message_list = [message]
413
733
 
414
- message_list = append_images_to_message(message_list, image_data_list)
415
734
  append_headings(classifier, parameter, "result")
416
735
 
417
- artefact_data_path = f"ara/{sub_directory}/{parameter}.data/{classifier}.prompt_log.md"
418
- with open(artefact_data_path, 'a', encoding='utf-8') as file:
736
+ artefact_data_path = _norm(
737
+ f"ara/{sub_directory}/{parameter}.data/{classifier}.prompt_log.md"
738
+ )
739
+ with open(artefact_data_path, "a", encoding="utf-8") as file:
419
740
  for chunk in send_prompt(message_list):
420
741
  chunk_content = chunk.choices[0].delta.content
421
742
  if not chunk_content:
422
743
  continue
423
744
  file.write(chunk_content)
424
745
  file.flush()
425
- # write_prompt_result(classifier, parameter, response)
426
746
 
427
747
 
428
- def generate_config_prompt_template_file(prompt_data_path, config_prompt_templates_name):
429
- config_prompt_templates_path = os.path.join(prompt_data_path, config_prompt_templates_name)
430
- config = ConfigManager.get_config()
748
+ def generate_config_prompt_template_file(
749
+ prompt_data_path, config_prompt_templates_name
750
+ ):
751
+ config_prompt_templates_path = os.path.join(
752
+ prompt_data_path, config_prompt_templates_name
753
+ )
754
+ # Use instance method consistently
755
+ config = ConfigManager().get_config()
431
756
  global_prompt_template_path = TemplatePathManager.get_template_base_path()
432
- dir_list = ["ara/.araconfig/custom-prompt-modules"] + [f"{os.path.join(global_prompt_template_path,'prompt-modules')}"]
433
- file_list = ['*.blueprint.md','*.rules.md','*.intention.md', '*.commands.md']
757
+ dir_list = ["ara/.araconfig/custom-prompt-modules"] + [
758
+ f"{os.path.join(global_prompt_template_path,'prompt-modules')}"
759
+ ]
760
+ file_list = ["*.blueprint.md", "*.rules.md", "*.intention.md", "*.commands.md"]
434
761
 
435
762
  print(f"used {dir_list} for prompt templates file listing")
436
763
  generate_markdown_listing(dir_list, file_list, config_prompt_templates_path)
437
764
 
438
765
 
439
- def generate_config_prompt_givens_file(prompt_data_path, config_prompt_givens_name, artefact_to_mark=None):
440
- config_prompt_givens_path = os.path.join(prompt_data_path, config_prompt_givens_name)
441
- config = ConfigManager.get_config()
442
- dir_list = ["ara"] + [ext.source_dir for ext in config.ext_code_dirs] + [config.doc_dir] + [config.glossary_dir]
766
+ def generate_config_prompt_givens_file(
767
+ prompt_data_path, config_prompt_givens_name, artefact_to_mark=None
768
+ ):
769
+ config_prompt_givens_path = os.path.join(
770
+ prompt_data_path, config_prompt_givens_name
771
+ )
772
+ config = ConfigManager().get_config()
773
+ dir_list = (
774
+ ["ara"]
775
+ + [path for d in config.ext_code_dirs for path in d.values()]
776
+ + [config.doc_dir]
777
+ + [config.glossary_dir]
778
+ )
443
779
 
444
780
  print(f"used {dir_list} for prompt givens file listing")
445
- generate_markdown_listing(dir_list, config.ara_prompt_given_list_includes, config_prompt_givens_path)
781
+ generate_markdown_listing(
782
+ dir_list, config.ara_prompt_given_list_includes, config_prompt_givens_path
783
+ )
446
784
 
447
785
  # If an artefact is specified, mark it with [x]
448
786
  if artefact_to_mark:
449
- print(f"artefact {artefact_to_mark} marked in related config.prompt_givens.md per default")
787
+ print(
788
+ f"artefact {artefact_to_mark} marked in related config.prompt_givens.md per default"
789
+ )
450
790
 
451
791
  # Read the generated file content
452
- with open(config_prompt_givens_path, 'r', encoding='utf-8') as file:
792
+ with open(config_prompt_givens_path, "r", encoding="utf-8") as file:
453
793
  markdown_listing = file.readlines()
454
794
 
455
795
  updated_listing = []
456
796
  for line in markdown_listing:
457
797
  # Use a regular expression to match the exact string
458
- if re.search(r'\b' + re.escape(artefact_to_mark) + r'\b', line):
798
+ if re.search(r"\b" + re.escape(artefact_to_mark) + r"\b", line):
459
799
  line = line.replace("[]", "[x]")
460
800
  updated_listing.append(line)
461
801
 
462
802
  # Write the updated listing back to the file
463
- with open(config_prompt_givens_path, 'w', encoding='utf-8') as file:
464
- file.write("".join(updated_listing))
803
+ with open(config_prompt_givens_path, "w", encoding="utf-8") as file:
804
+ file.write("".join(updated_listing))
805
+
806
+
807
+ def generate_config_prompt_global_givens_file(
808
+ prompt_data_path, config_prompt_givens_name, artefact_to_mark=None
809
+ ):
810
+ from ara_cli.global_file_lister import generate_global_markdown_listing
811
+
812
+ config_prompt_givens_path = os.path.join(
813
+ prompt_data_path, config_prompt_givens_name
814
+ )
815
+ config = ConfigManager().get_config()
816
+
817
+ if not hasattr(config, "global_dirs") or not config.global_dirs:
818
+ return
819
+
820
+ dir_list = [path for d in config.global_dirs for path in d.values()]
821
+ print(
822
+ f"used {dir_list} for global prompt givens file listing with absolute paths"
823
+ )
824
+ generate_global_markdown_listing(
825
+ dir_list, config.ara_prompt_given_list_includes, config_prompt_givens_path
826
+ )