ara-cli 0.1.9.69__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 (150) hide show
  1. ara_cli/__init__.py +18 -2
  2. ara_cli/__main__.py +248 -62
  3. ara_cli/ara_command_action.py +155 -86
  4. ara_cli/ara_config.py +226 -80
  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 +649 -68
  28. ara_cli/artefact_creator.py +8 -11
  29. ara_cli/artefact_deleter.py +2 -4
  30. ara_cli/artefact_fuzzy_search.py +22 -10
  31. ara_cli/artefact_link_updater.py +4 -4
  32. ara_cli/artefact_lister.py +29 -55
  33. ara_cli/artefact_models/artefact_data_retrieval.py +23 -0
  34. ara_cli/artefact_models/artefact_load.py +11 -3
  35. ara_cli/artefact_models/artefact_model.py +146 -39
  36. ara_cli/artefact_models/artefact_templates.py +70 -44
  37. ara_cli/artefact_models/businessgoal_artefact_model.py +23 -25
  38. ara_cli/artefact_models/epic_artefact_model.py +34 -26
  39. ara_cli/artefact_models/feature_artefact_model.py +203 -64
  40. ara_cli/artefact_models/keyfeature_artefact_model.py +21 -24
  41. ara_cli/artefact_models/serialize_helper.py +1 -1
  42. ara_cli/artefact_models/task_artefact_model.py +83 -15
  43. ara_cli/artefact_models/userstory_artefact_model.py +37 -27
  44. ara_cli/artefact_models/vision_artefact_model.py +23 -42
  45. ara_cli/artefact_reader.py +92 -91
  46. ara_cli/artefact_renamer.py +8 -4
  47. ara_cli/artefact_scan.py +66 -3
  48. ara_cli/chat.py +622 -162
  49. ara_cli/chat_agent/__init__.py +0 -0
  50. ara_cli/chat_agent/agent_communicator.py +62 -0
  51. ara_cli/chat_agent/agent_process_manager.py +211 -0
  52. ara_cli/chat_agent/agent_status_manager.py +73 -0
  53. ara_cli/chat_agent/agent_workspace_manager.py +76 -0
  54. ara_cli/commands/__init__.py +0 -0
  55. ara_cli/commands/command.py +7 -0
  56. ara_cli/commands/extract_command.py +15 -0
  57. ara_cli/commands/load_command.py +65 -0
  58. ara_cli/commands/load_image_command.py +34 -0
  59. ara_cli/commands/read_command.py +117 -0
  60. ara_cli/completers.py +144 -0
  61. ara_cli/directory_navigator.py +37 -4
  62. ara_cli/error_handler.py +134 -0
  63. ara_cli/file_classifier.py +6 -5
  64. ara_cli/file_lister.py +1 -1
  65. ara_cli/file_loaders/__init__.py +0 -0
  66. ara_cli/file_loaders/binary_file_loader.py +33 -0
  67. ara_cli/file_loaders/document_file_loader.py +34 -0
  68. ara_cli/file_loaders/document_reader.py +245 -0
  69. ara_cli/file_loaders/document_readers.py +233 -0
  70. ara_cli/file_loaders/file_loader.py +50 -0
  71. ara_cli/file_loaders/file_loaders.py +123 -0
  72. ara_cli/file_loaders/image_processor.py +89 -0
  73. ara_cli/file_loaders/markdown_reader.py +75 -0
  74. ara_cli/file_loaders/text_file_loader.py +187 -0
  75. ara_cli/global_file_lister.py +51 -0
  76. ara_cli/list_filter.py +1 -1
  77. ara_cli/output_suppressor.py +1 -1
  78. ara_cli/prompt_extractor.py +215 -88
  79. ara_cli/prompt_handler.py +521 -134
  80. ara_cli/prompt_rag.py +2 -2
  81. ara_cli/tag_extractor.py +83 -38
  82. ara_cli/template_loader.py +245 -0
  83. ara_cli/template_manager.py +18 -13
  84. ara_cli/templates/prompt-modules/commands/empty.commands.md +2 -12
  85. ara_cli/templates/prompt-modules/commands/extract_general.commands.md +12 -0
  86. ara_cli/templates/prompt-modules/commands/extract_markdown.commands.md +11 -0
  87. ara_cli/templates/prompt-modules/commands/extract_python.commands.md +13 -0
  88. ara_cli/templates/prompt-modules/commands/feature_add_or_modifiy_specified_behavior.commands.md +36 -0
  89. ara_cli/templates/prompt-modules/commands/feature_generate_initial_specified_bevahior.commands.md +53 -0
  90. ara_cli/templates/prompt-modules/commands/prompt_template_tech_stack_transformer.commands.md +95 -0
  91. ara_cli/templates/prompt-modules/commands/python_bug_fixing_code.commands.md +34 -0
  92. ara_cli/templates/prompt-modules/commands/python_generate_code.commands.md +27 -0
  93. ara_cli/templates/prompt-modules/commands/python_refactoring_code.commands.md +39 -0
  94. ara_cli/templates/prompt-modules/commands/python_step_definitions_generation_and_fixing.commands.md +40 -0
  95. ara_cli/templates/prompt-modules/commands/python_unittest_generation_and_fixing.commands.md +48 -0
  96. ara_cli/update_config_prompt.py +9 -3
  97. ara_cli/version.py +1 -1
  98. ara_cli-0.1.10.8.dist-info/METADATA +241 -0
  99. ara_cli-0.1.10.8.dist-info/RECORD +193 -0
  100. tests/test_ara_command_action.py +73 -59
  101. tests/test_ara_config.py +341 -36
  102. tests/test_artefact_autofix.py +1060 -0
  103. tests/test_artefact_link_updater.py +3 -3
  104. tests/test_artefact_lister.py +52 -132
  105. tests/test_artefact_renamer.py +2 -2
  106. tests/test_artefact_scan.py +327 -33
  107. tests/test_chat.py +2063 -498
  108. tests/test_file_classifier.py +24 -1
  109. tests/test_file_creator.py +3 -5
  110. tests/test_file_lister.py +1 -1
  111. tests/test_global_file_lister.py +131 -0
  112. tests/test_list_filter.py +2 -2
  113. tests/test_prompt_handler.py +746 -0
  114. tests/test_tag_extractor.py +19 -13
  115. tests/test_template_loader.py +192 -0
  116. tests/test_template_manager.py +5 -4
  117. tests/test_update_config_prompt.py +2 -2
  118. ara_cli/ara_command_parser.py +0 -327
  119. ara_cli/templates/prompt-modules/blueprints/complete_pytest_unittest.blueprint.md +0 -27
  120. ara_cli/templates/prompt-modules/blueprints/task_todo_list_implement_feature_BDD_way.blueprint.md +0 -30
  121. ara_cli/templates/prompt-modules/commands/artefact_classification.commands.md +0 -9
  122. ara_cli/templates/prompt-modules/commands/artefact_extension.commands.md +0 -17
  123. ara_cli/templates/prompt-modules/commands/artefact_formulation.commands.md +0 -14
  124. ara_cli/templates/prompt-modules/commands/behave_step_generation.commands.md +0 -102
  125. ara_cli/templates/prompt-modules/commands/code_generation_complex.commands.md +0 -20
  126. ara_cli/templates/prompt-modules/commands/code_generation_simple.commands.md +0 -13
  127. ara_cli/templates/prompt-modules/commands/error_fixing.commands.md +0 -20
  128. ara_cli/templates/prompt-modules/commands/feature_file_update.commands.md +0 -18
  129. ara_cli/templates/prompt-modules/commands/feature_formulation.commands.md +0 -43
  130. ara_cli/templates/prompt-modules/commands/js_code_generation_simple.commands.md +0 -13
  131. ara_cli/templates/prompt-modules/commands/refactoring.commands.md +0 -15
  132. ara_cli/templates/prompt-modules/commands/refactoring_analysis.commands.md +0 -9
  133. ara_cli/templates/prompt-modules/commands/reverse_engineer_feature_file.commands.md +0 -15
  134. ara_cli/templates/prompt-modules/commands/reverse_engineer_program_flow.commands.md +0 -19
  135. ara_cli/templates/template.businessgoal +0 -10
  136. ara_cli/templates/template.capability +0 -10
  137. ara_cli/templates/template.epic +0 -15
  138. ara_cli/templates/template.example +0 -6
  139. ara_cli/templates/template.feature +0 -26
  140. ara_cli/templates/template.issue +0 -14
  141. ara_cli/templates/template.keyfeature +0 -15
  142. ara_cli/templates/template.task +0 -6
  143. ara_cli/templates/template.userstory +0 -17
  144. ara_cli/templates/template.vision +0 -14
  145. ara_cli-0.1.9.69.dist-info/METADATA +0 -16
  146. ara_cli-0.1.9.69.dist-info/RECORD +0 -158
  147. tests/test_ara_autofix.py +0 -219
  148. {ara_cli-0.1.9.69.dist-info → ara_cli-0.1.10.8.dist-info}/WHEEL +0 -0
  149. {ara_cli-0.1.9.69.dist-info → ara_cli-0.1.10.8.dist-info}/entry_points.txt +0 -0
  150. {ara_cli-0.1.9.69.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
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
@@ -12,94 +6,351 @@ from re import findall
12
6
  import re
13
7
  import shutil
14
8
  import glob
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
15
21
 
16
22
 
17
23
  class LLMSingleton:
18
24
  _instance = None
19
- _model = None
25
+ _default_model = None
26
+ _extraction_model = None
27
+ langfuse = None
20
28
 
21
- def __init__(self, model_id):
29
+ def __init__(self, default_model_id, extraction_model_id):
22
30
  config = ConfigManager().get_config()
23
- llm_config = config.llm_config
24
- selected_config = llm_config[str(model_id)]
25
-
26
- if not selected_config:
27
- raise ValueError(f"No configuration found for the model: {model_id}")
28
-
29
- LLMSingleton._model = model_id
30
-
31
- self.config_parameters = selected_config
32
-
31
+ default_config_data = config.llm_config.get(str(default_model_id))
32
+
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)
38
+
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
+ )
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
33
72
  LLMSingleton._instance = self
34
73
 
35
74
  @classmethod
36
75
  def get_instance(cls):
37
76
  if cls._instance is None:
38
77
  config = ConfigManager().get_config()
39
- llm_config = config.llm_config
40
- model_to_use = next(iter(llm_config))
41
- default_llm = getattr(config, "default_llm", None)
42
- if default_llm and default_llm in llm_config:
43
- model_to_use = default_llm
44
- 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)
45
110
  return cls._instance
46
111
 
47
112
  @classmethod
48
- def set_model(cls, model_name):
49
- 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:
50
117
  return cls._instance
51
- cls(model_name)
52
- print(f"Language model switched to '{model_name}'")
118
+ cls(cls._default_model, model_name)
53
119
  return cls._instance
54
120
 
55
121
  @classmethod
56
- def get_model(cls):
122
+ def get_default_model(cls):
123
+ """Gets the default model name stored in the singleton instance."""
57
124
  if cls._instance is None:
58
125
  cls.get_instance()
59
- return cls._model
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."""
131
+ if cls._instance is None:
132
+ cls.get_instance()
133
+ return cls._extraction_model
60
134
 
61
135
 
62
136
  def write_string_to_file(filename, string, mode):
63
- with open(filename, mode, encoding='utf-8') as file:
137
+ with open(filename, mode, encoding="utf-8") as file:
64
138
  file.write(f"\n{string}\n")
65
139
  return file
66
140
 
67
141
 
68
142
  def read_string_from_file(path):
69
- with open(path, 'r', encoding='utf-8') as file:
143
+ with open(path, "r", encoding="utf-8") as file:
70
144
  text = file.read()
71
145
  return text
72
146
 
73
147
 
74
- def send_prompt(prompt):
75
- 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() != ""
76
157
 
77
- # remove provider from config parameters
78
- config_parameters = chat.config_parameters.copy()
79
- del config_parameters["provider"]
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
+ )
80
164
 
81
- completion = litellm.completion(
82
- **config_parameters,
83
- messages=prompt,
84
- stream=True,
85
- max_tokens=32768
86
- )
87
- for chunk in completion:
88
- yield chunk
165
+ return False
166
+
167
+
168
+ def _norm(p: str) -> str:
169
+ """Normalize slashes and collapse .. segments."""
170
+ return os.path.normpath(p) if p else p
171
+
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
+ }
247
+ )
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
89
338
 
90
339
 
91
340
  def append_headings(classifier, param, heading_name):
92
341
  sub_directory = Classifier.get_sub_directory(classifier)
93
342
 
94
- 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
+ )
95
346
 
96
347
  # Check if the file exists, and if not, create an empty file
97
348
  if not os.path.exists(artefact_data_path):
98
- with open(artefact_data_path, 'w', encoding='utf-8') as file:
349
+ with open(artefact_data_path, "w", encoding="utf-8") as file:
99
350
  file.write("")
100
351
 
101
352
  content = read_string_from_file(artefact_data_path)
102
- pattern = r'## {}_(\d+)'.format(heading_name)
353
+ pattern = r"## {}_(\d+)".format(heading_name)
103
354
  matches = findall(pattern, content)
104
355
 
105
356
  max_number = 1
@@ -107,27 +358,27 @@ def append_headings(classifier, param, heading_name):
107
358
  max_number = max(map(int, matches)) + 1
108
359
  heading = f"## {heading_name}_{max_number}"
109
360
 
110
- write_string_to_file(artefact_data_path, heading, 'a')
361
+ write_string_to_file(artefact_data_path, heading, "a")
111
362
 
112
363
 
113
364
  def write_prompt_result(classifier, param, text):
114
365
  sub_directory = Classifier.get_sub_directory(classifier)
115
-
116
- # TODO change absolute path to relative path with directory navigator
117
- artefact_data_path = f"ara/{sub_directory}/{param}.data/{classifier}.prompt_log.md"
118
- 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")
119
370
 
120
371
 
121
372
  def prompt_data_directory_creation(classifier, parameter):
122
373
  sub_directory = Classifier.get_sub_directory(classifier)
123
- prompt_data_path = f"ara/{sub_directory}/{parameter}.data/prompt.data"
374
+ prompt_data_path = _norm(f"ara/{sub_directory}/{parameter}.data/prompt.data")
124
375
  if not exists(prompt_data_path):
125
376
  makedirs(prompt_data_path)
126
377
  return prompt_data_path
127
378
 
128
379
 
129
380
  def get_file_content(path):
130
- with open(path, 'r', encoding='utf-8') as file:
381
+ with open(path, "r", encoding="utf-8") as file:
131
382
  return file.read()
132
383
 
133
384
 
@@ -135,14 +386,25 @@ def initialize_prompt_templates(classifier, parameter):
135
386
  prompt_data_path = prompt_data_directory_creation(classifier, parameter)
136
387
  prompt_log_path = os.path.dirname(prompt_data_path)
137
388
 
138
- template_path = os.path.join(os.path.dirname(__file__), 'templates')
389
+ template_path = os.path.join(os.path.dirname(__file__), "templates")
139
390
  artefact_creator = ArtefactCreator()
140
- 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
+ )
141
394
 
142
395
  generate_config_prompt_template_file(prompt_data_path, "config.prompt_templates.md")
143
396
 
144
397
  # Mark the relevant artefact in the givens list
145
- 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
+ )
146
408
 
147
409
 
148
410
  def write_template_files_to_config(template_type, config_file, base_template_path):
@@ -154,14 +416,14 @@ def write_template_files_to_config(template_type, config_file, base_template_pat
154
416
 
155
417
  def load_selected_prompt_templates(classifier, parameter):
156
418
  sub_directory = Classifier.get_sub_directory(classifier)
157
- prompt_data_path = f"ara/{sub_directory}/{parameter}.data/prompt.data"
419
+ prompt_data_path = _norm(f"ara/{sub_directory}/{parameter}.data/prompt.data")
158
420
  config_file_path = os.path.join(prompt_data_path, "config.prompt_templates.md")
159
421
 
160
422
  if not os.path.exists(config_file_path):
161
423
  print("WARNING: config.prompt_templates.md does not exist.")
162
424
  return
163
425
 
164
- 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:
165
427
  content = config_file.read()
166
428
 
167
429
  global_base_template_path = TemplatePathManager.get_template_base_path()
@@ -197,7 +459,9 @@ def find_files_with_endings(directory, endings):
197
459
  # Create an empty dictionary to store files according to their endings
198
460
  files_by_ending = {ending: [] for ending in endings}
199
461
 
200
- 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
+ ]
201
465
  # Walk through the files list
202
466
  for file in files:
203
467
  # Check each file to see if it ends with one of the specified endings
@@ -223,7 +487,7 @@ def move_and_copy_files(source_path, prompt_data_path, prompt_archive_path):
223
487
  file_name = os.path.basename(source_path)
224
488
 
225
489
  # Check the name ending and extension of source path
226
- endings = [".commands.md", ".rules.md", ".intention.md"]
490
+ endings = [".blueprint.md", ".commands.md", ".rules.md", ".intention.md"]
227
491
  if any(file_name.endswith(ext) for ext in endings):
228
492
  for ext in endings:
229
493
  if file_name.endswith(ext):
@@ -232,75 +496,114 @@ def move_and_copy_files(source_path, prompt_data_path, prompt_archive_path):
232
496
 
233
497
  # Move all existing files with the same ending to the prompt_archive_path
234
498
  for existing_file in glob.glob(glob_pattern):
235
- 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
+ )
236
502
  shutil.move(existing_file, archived_file_path)
237
- print(f"Moved existing prompt-module: {os.path.basename(existing_file)} to prompt.archive")
238
-
503
+ print(
504
+ f"Moved existing prompt-module: {os.path.basename(existing_file)} to prompt.archive"
505
+ )
506
+
239
507
  # Copy the source_path file to the prompt_data_path directory
240
508
  target_path = os.path.join(prompt_data_path, file_name)
241
509
  shutil.copy(source_path, target_path)
242
510
  print(f"Loaded new prompt-module: {os.path.basename(target_path)}")
243
511
 
244
512
  else:
245
- 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
+ )
246
516
  else:
247
517
  print(f"WARNING: template {source_path} does not exist.")
248
518
 
249
519
 
250
520
  def extract_and_load_markdown_files(md_prompt_file_path):
251
521
  """
252
- 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.
253
525
  """
254
526
  header_stack = []
255
527
  path_accumulator = []
256
- 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:
257
529
  for line in file:
258
- if line.strip().startswith('#'):
259
- level = line.count('#')
260
- header = line.strip().strip('#').strip()
530
+ if line.strip().startswith("#"):
531
+ level = line.count("#")
532
+ header = line.strip().strip("#").strip()
261
533
  # Adjust the stack based on the current header level
262
534
  current_depth = len(header_stack)
263
535
  if level <= current_depth:
264
- header_stack = header_stack[:level-1]
536
+ header_stack = header_stack[: level - 1]
265
537
  header_stack.append(header)
266
- elif '[x]' in line:
267
- relative_path = line.split(']')[-1].strip()
268
- full_path = os.path.join('/'.join(header_stack), relative_path)
269
- 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))
270
543
  return path_accumulator
271
544
 
272
545
 
273
546
  def load_givens(file_path):
274
- 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 = ""
275
554
  image_data_list = []
276
555
  markdown_items = extract_and_load_markdown_files(file_path)
277
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
+
278
564
  for item in markdown_items:
279
- if item.lower().endswith(('.png', '.jpeg', '.jpg')):
280
- 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:
281
574
  base64_image = base64.b64encode(image_file.read()).decode("utf-8")
282
- image_data_list.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64_image}"}})
283
- content += item + "\n"
284
- content += f'![{item}](data:image/png;base64,{base64_image})' + "\n"
285
- else:
286
- # Check if the item specifies line ranges
287
- # TODO item has currently no trailing [] see extraction and handover method in extract and load
288
- # item = f"[10:29] {item}"
289
- # print(f"found {item}, check for subsection")
290
- # TODO re.match can not split the item with [] correctly and extract the line numbers
291
- # TODO logic of subsections is not supported by the update algorithm of the config prompt givens updater
292
- # TODO extract in lines of *.md files potential images and add them to the image list
293
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"
294
595
  match = re.match(r".*?\[(\d+:\d+(?:,\s*\d+:\d+)*)\]\s+(.+)", item)
295
596
  if match:
296
597
  line_ranges, file_name = match.groups()
297
- content += file_name + "\n" + "```\n"
298
- 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"
299
601
  content += "```\n\n"
300
602
  else:
301
- content += item + "\n" + "```\n"
302
- content += get_file_content(item) + "\n"
603
+ content += "```\n"
604
+ content += get_file_content(resolved) + "\n"
303
605
  content += "```\n\n"
606
+
304
607
  return content, image_data_list
305
608
 
306
609
 
@@ -309,25 +612,25 @@ def get_partial_file_content(file_name, line_ranges):
309
612
  Reads specific lines from a file based on the line ranges provided.
310
613
 
311
614
  Args:
312
- file_name (str): The path to the file.
615
+ file_name (str): The path to the file (absolute or relative, already resolved by caller).
313
616
  line_ranges (str): A string representing the line ranges to read, e.g., '10:20,25:30'.
314
617
 
315
618
  Returns:
316
619
  str: The content of the specified lines.
317
620
  """
318
- line_ranges = line_ranges.strip('[]').split(',')
621
+ line_ranges = line_ranges.strip("[]").split(",")
319
622
  lines_to_read = []
320
623
  for line_range in line_ranges:
321
- start, end = map(int, line_range.split(':'))
624
+ start, end = map(int, line_range.split(":"))
322
625
  lines_to_read.extend(range(start, end + 1))
323
626
 
324
627
  partial_content = []
325
- with open(file_name, 'r', encoding='utf-8') as file:
628
+ with open(file_name, "r", encoding="utf-8") as file:
326
629
  for i, line in enumerate(file, 1):
327
630
  if i in lines_to_read:
328
631
  partial_content.append(line)
329
632
 
330
- return ''.join(partial_content)
633
+ return "".join(partial_content)
331
634
 
332
635
 
333
636
  def collect_file_content_by_extension(prompt_data_path, extensions):
@@ -337,7 +640,7 @@ def collect_file_content_by_extension(prompt_data_path, extensions):
337
640
  files = find_files_with_endings(prompt_data_path, [ext])
338
641
  for file_name in files:
339
642
  file_path = join(prompt_data_path, file_name)
340
- if ext == ".prompt_givens.md":
643
+ if ext in [".prompt_givens.md", ".prompt_global_givens.md"]:
341
644
  givens, image_data = load_givens(file_path)
342
645
  combined_content += givens
343
646
  image_data_list.extend(image_data)
@@ -347,93 +650,177 @@ def collect_file_content_by_extension(prompt_data_path, extensions):
347
650
 
348
651
 
349
652
  def prepend_system_prompt(message_list):
350
- system_prompt = {
351
- "role": "system",
352
- "content": "You are a helpful assistant that can process both text and images."
353
- }
354
- 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)
355
671
  return message_list
356
672
 
357
673
 
358
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
+ """
678
+ logger = logging.getLogger(__name__)
679
+
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
+ )
683
+
359
684
  if not image_data_list:
685
+ logger.debug("No images to append, returning original message")
360
686
  return message
361
- message_content = message["content"]
362
- message["content"] = message_content + image_data_list
687
+
688
+ message_content = message.get("content")
689
+ logger.debug(f"Original message content: {message_content}")
690
+
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
+
700
+ logger.debug(f"Updated message content with {len(image_data_list)} images")
363
701
 
364
702
  return message
365
703
 
366
704
 
367
705
  def create_and_send_custom_prompt(classifier, parameter):
368
706
  sub_directory = Classifier.get_sub_directory(classifier)
369
- prompt_data_path = f"ara/{sub_directory}/{parameter}.data/prompt.data"
707
+ prompt_data_path = _norm(f"ara/{sub_directory}/{parameter}.data/prompt.data")
370
708
  prompt_file_path_markdown = join(prompt_data_path, f"{classifier}.prompt.md")
371
709
 
372
- extensions = [".rules.md", ".prompt_givens.md", ".intention.md", ".commands.md"]
373
- 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
+ )
374
721
 
375
- 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:
376
723
  file.write(combined_content_markdown)
377
724
 
378
725
  prompt = read_string_from_file(prompt_file_path_markdown)
379
726
  append_headings(classifier, parameter, "prompt")
380
727
  write_prompt_result(classifier, parameter, prompt)
381
728
 
382
- message = {
383
- "role": "user",
384
- "content": combined_content_markdown
385
- }
386
-
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)
387
732
  message_list = [message]
388
733
 
389
- message_list = append_images_to_message(message_list, image_data_list)
390
734
  append_headings(classifier, parameter, "result")
391
735
 
392
- artefact_data_path = f"ara/{sub_directory}/{parameter}.data/{classifier}.prompt_log.md"
393
- 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:
394
740
  for chunk in send_prompt(message_list):
395
741
  chunk_content = chunk.choices[0].delta.content
396
742
  if not chunk_content:
397
743
  continue
398
744
  file.write(chunk_content)
399
745
  file.flush()
400
- # write_prompt_result(classifier, parameter, response)
401
746
 
402
747
 
403
- def generate_config_prompt_template_file(prompt_data_path, config_prompt_templates_name):
404
- config_prompt_templates_path = os.path.join(prompt_data_path, config_prompt_templates_name)
405
- 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()
406
756
  global_prompt_template_path = TemplatePathManager.get_template_base_path()
407
- dir_list = ["ara/.araconfig/custom-prompt-modules"] + [f"{os.path.join(global_prompt_template_path,'prompt-modules')}"]
408
- file_list = ['*.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"]
409
761
 
410
762
  print(f"used {dir_list} for prompt templates file listing")
411
763
  generate_markdown_listing(dir_list, file_list, config_prompt_templates_path)
412
764
 
413
765
 
414
- def generate_config_prompt_givens_file(prompt_data_path, config_prompt_givens_name, artefact_to_mark=None):
415
- config_prompt_givens_path = os.path.join(prompt_data_path, config_prompt_givens_name)
416
- config = ConfigManager.get_config()
417
- dir_list = ["ara"] + [item for ext in config.ext_code_dirs for key, item in ext.items()] + [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
+ )
418
779
 
419
780
  print(f"used {dir_list} for prompt givens file listing")
420
- 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
+ )
421
784
 
422
785
  # If an artefact is specified, mark it with [x]
423
786
  if artefact_to_mark:
424
- 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
+ )
425
790
 
426
791
  # Read the generated file content
427
- 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:
428
793
  markdown_listing = file.readlines()
429
794
 
430
795
  updated_listing = []
431
796
  for line in markdown_listing:
432
797
  # Use a regular expression to match the exact string
433
- 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):
434
799
  line = line.replace("[]", "[x]")
435
800
  updated_listing.append(line)
436
801
 
437
802
  # Write the updated listing back to the file
438
- with open(config_prompt_givens_path, 'w', encoding='utf-8') as file:
803
+ with open(config_prompt_givens_path, "w", encoding="utf-8") as file:
439
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
+ )