result-companion 0.0.1__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.
Files changed (37) hide show
  1. result_companion/__init__.py +8 -0
  2. result_companion/core/analizers/__init__.py +0 -0
  3. result_companion/core/analizers/common.py +58 -0
  4. result_companion/core/analizers/factory_common.py +104 -0
  5. result_companion/core/analizers/local/__init__.py +0 -0
  6. result_companion/core/analizers/local/ollama_exceptions.py +10 -0
  7. result_companion/core/analizers/local/ollama_install.py +279 -0
  8. result_companion/core/analizers/local/ollama_runner.py +124 -0
  9. result_companion/core/analizers/local/ollama_server_manager.py +185 -0
  10. result_companion/core/analizers/models.py +17 -0
  11. result_companion/core/analizers/remote/__init__.py +0 -0
  12. result_companion/core/analizers/remote/custom_endpoint.py +0 -0
  13. result_companion/core/analizers/remote/openai.py +0 -0
  14. result_companion/core/chunking/chunking.py +113 -0
  15. result_companion/core/chunking/utils.py +114 -0
  16. result_companion/core/configs/default_config.yaml +85 -0
  17. result_companion/core/html/__init__.py +0 -0
  18. result_companion/core/html/html_creator.py +179 -0
  19. result_companion/core/html/llm_injector.py +20 -0
  20. result_companion/core/parsers/__init__.py +0 -0
  21. result_companion/core/parsers/config.py +256 -0
  22. result_companion/core/parsers/result_parser.py +101 -0
  23. result_companion/core/results/__init__.py +0 -0
  24. result_companion/core/results/visitors.py +34 -0
  25. result_companion/core/utils/__init__.py +0 -0
  26. result_companion/core/utils/log_levels.py +23 -0
  27. result_companion/core/utils/logging_config.py +115 -0
  28. result_companion/core/utils/progress.py +61 -0
  29. result_companion/entrypoints/__init__.py +0 -0
  30. result_companion/entrypoints/cli/__init__.py +0 -0
  31. result_companion/entrypoints/cli/cli_app.py +266 -0
  32. result_companion/entrypoints/run_rc.py +171 -0
  33. result_companion-0.0.1.dist-info/METADATA +216 -0
  34. result_companion-0.0.1.dist-info/RECORD +37 -0
  35. result_companion-0.0.1.dist-info/WHEEL +4 -0
  36. result_companion-0.0.1.dist-info/entry_points.txt +3 -0
  37. result_companion-0.0.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,185 @@
1
+ import atexit
2
+ import os
3
+ import subprocess
4
+ import time
5
+ from typing import Optional, Type, TypeVar, Union
6
+
7
+ import requests
8
+
9
+ from result_companion.core.analizers.local.ollama_exceptions import (
10
+ OllamaNotInstalled,
11
+ OllamaServerNotRunning,
12
+ )
13
+ from result_companion.core.utils.logging_config import logger
14
+
15
+
16
+ class OllamaServerManager:
17
+ """
18
+ Manages the lifecycle of an Ollama server.
19
+
20
+ Can be used as a context manager to ensure proper server initialization and cleanup:
21
+
22
+ with OllamaServerManager() as server:
23
+ # Code that requires the Ollama server to be running
24
+ ...
25
+ # Server will be automatically cleaned up when exiting the with block
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ server_url: str = "http://localhost:11434",
31
+ start_timeout: int = 30,
32
+ wait_for_start: int = 1,
33
+ start_cmd: list = ["ollama", "serve"],
34
+ ):
35
+ self.server_url = server_url
36
+ self.start_timeout = start_timeout
37
+ self._process: Optional[subprocess.Popen] = None
38
+ self.wait_for_start = wait_for_start
39
+ self.start_cmd = start_cmd
40
+ atexit.register(self.cleanup)
41
+ self._server_started_by_manager = False
42
+
43
+ def __enter__(self):
44
+ """
45
+ Context manager entry point. Ensures the server is running before proceeding.
46
+ """
47
+ if not self.is_running(skip_logs=True):
48
+ self.start()
49
+ self._server_started_by_manager = True
50
+ return self
51
+
52
+ def __exit__(self, exc_type, exc_val, exc_tb):
53
+ """
54
+ Context manager exit point. Cleans up the server if it was started by this manager.
55
+ """
56
+ if self._server_started_by_manager:
57
+ self.cleanup()
58
+ return False # Propagate any exceptions
59
+
60
+ def is_running(self, skip_logs: bool = False) -> bool:
61
+ """Checks if the Ollama server is running."""
62
+ if not skip_logs:
63
+ logger.debug(
64
+ f"Checking if Ollama server is running at {self.server_url}..."
65
+ )
66
+ try:
67
+ response = requests.get(self.server_url, timeout=5)
68
+ return response.status_code == 200 and "Ollama is running" in response.text
69
+ except requests.exceptions.RequestException:
70
+ return False
71
+
72
+ def _check_process_alive(self) -> None:
73
+ """
74
+ Check if the managed process is still alive and raise an exception if it died.
75
+
76
+ Raises:
77
+ OllamaServerNotRunning: If the process has terminated unexpectedly.
78
+ """
79
+ if self._process is None:
80
+ return
81
+
82
+ if self._process.poll() is not None:
83
+ try:
84
+ _, stderr = self._process.communicate(timeout=1)
85
+ error_msg = (
86
+ stderr.decode().strip()
87
+ if stderr
88
+ else "Process terminated unexpectedly"
89
+ )
90
+ except subprocess.TimeoutExpired:
91
+ error_msg = "Process terminated unexpectedly"
92
+ except Exception as e:
93
+ error_msg = (
94
+ f"Process terminated unexpectedly (error reading output: {e})"
95
+ )
96
+
97
+ raise OllamaServerNotRunning(f"Ollama server process died: {error_msg}")
98
+
99
+ def start(self) -> None:
100
+ """
101
+ Starts the Ollama server if it is not running.
102
+ Raises:
103
+ OllamaNotInstalled: If the 'ollama' command is not found.
104
+ OllamaServerNotRunning: If the server fails to start within the timeout.
105
+ """
106
+ if self.is_running(skip_logs=True):
107
+ logger.debug("Ollama server is already running.")
108
+ return
109
+
110
+ logger.info("Ollama server is not running. Attempting to start it...")
111
+ try:
112
+ self._process = subprocess.Popen(
113
+ self.start_cmd,
114
+ stdout=subprocess.PIPE,
115
+ stderr=subprocess.PIPE,
116
+ preexec_fn=os.setsid if os.name != "nt" else None, # Unix only
117
+ )
118
+ except FileNotFoundError:
119
+ raise OllamaNotInstalled(
120
+ "Ollama command not found. Ensure it is installed and in your PATH."
121
+ )
122
+
123
+ logger.info(f"Launched 'ollama serve' process with PID: {self._process.pid}")
124
+
125
+ # Check if process died immediately after launch
126
+ time.sleep(0.1) # Brief pause to let process initialize
127
+ self._check_process_alive()
128
+
129
+ start_time = time.time()
130
+ while time.time() - start_time < self.start_timeout:
131
+ if self.is_running(skip_logs=True):
132
+ logger.info("Ollama server started successfully.")
133
+ return
134
+
135
+ # Check if process died during startup wait
136
+ self._check_process_alive()
137
+
138
+ time.sleep(self.wait_for_start)
139
+
140
+ # If the server did not start, clean up and raise an error.
141
+ self.cleanup()
142
+ raise OllamaServerNotRunning(
143
+ f"Failed to start Ollama server within {self.start_timeout}s timeout."
144
+ )
145
+
146
+ def cleanup(self) -> None:
147
+ """
148
+ Gracefully terminates the Ollama server, or kills it if necessary.
149
+ """
150
+ if self._process is not None:
151
+ logger.debug(
152
+ f"Cleaning up Ollama server process with PID: {self._process.pid}"
153
+ )
154
+ try:
155
+ self._process.terminate()
156
+ self._process.wait(timeout=5)
157
+ logger.debug("Ollama server terminated gracefully.")
158
+ except subprocess.TimeoutExpired:
159
+ self._process.kill()
160
+ logger.debug("Ollama server killed forcefully.")
161
+ except Exception as exc:
162
+ logger.warning(f"Error during Ollama server cleanup: {exc}")
163
+ self._process = None
164
+ self._server_started_by_manager = False
165
+
166
+
167
+ T = TypeVar("T", bound="OllamaServerManager")
168
+
169
+
170
+ def resolve_server_manager(server_manager: Union[Optional[T], Type[T]], **kwargs) -> T:
171
+ """
172
+ Resolve a server manager parameter that can be either a class or an instance.
173
+
174
+ Args:
175
+ server_manager: Either an OllamaServerManager instance, a subclass of OllamaServerManager,
176
+ or None (in which case a default OllamaServerManager will be created).
177
+ **kwargs: Additional arguments to pass to the constructor if a new instance is created.
178
+
179
+ Returns:
180
+ An instance of OllamaServerManager or one of its subclasses.
181
+ """
182
+ if isinstance(server_manager, type):
183
+ return server_manager(**kwargs)
184
+
185
+ return server_manager or OllamaServerManager(**kwargs)
@@ -0,0 +1,17 @@
1
+ from typing import Callable, Tuple
2
+
3
+ from langchain_anthropic import ChatAnthropic
4
+ from langchain_aws import BedrockLLM
5
+ from langchain_google_genai import ChatGoogleGenerativeAI
6
+ from langchain_ollama.llms import OllamaLLM
7
+ from langchain_openai import AzureChatOpenAI, ChatOpenAI
8
+
9
+ MODELS = Tuple[
10
+ OllamaLLM
11
+ | AzureChatOpenAI
12
+ | BedrockLLM
13
+ | ChatGoogleGenerativeAI
14
+ | ChatOpenAI
15
+ | ChatAnthropic,
16
+ Callable,
17
+ ]
File without changes
File without changes
@@ -0,0 +1,113 @@
1
+ import asyncio
2
+ from typing import Tuple
3
+
4
+ from langchain.prompts import PromptTemplate
5
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
6
+ from langchain_core.output_parsers import StrOutputParser
7
+ from langchain_core.runnables import RunnableSerializable
8
+
9
+ from result_companion.core.analizers.models import MODELS
10
+ from result_companion.core.chunking.utils import Chunking
11
+ from result_companion.core.utils.logging_config import get_progress_logger
12
+
13
+ logger = get_progress_logger("Chunking")
14
+
15
+
16
+ def build_sumarization_chain(
17
+ prompt: PromptTemplate, model: MODELS
18
+ ) -> RunnableSerializable:
19
+ return prompt | model | StrOutputParser()
20
+
21
+
22
+ def split_text_into_chunks_using_text_splitter(
23
+ text: str, chunk_size: int, overlap: int
24
+ ) -> list:
25
+ splitter = RecursiveCharacterTextSplitter(
26
+ chunk_size=chunk_size,
27
+ chunk_overlap=overlap,
28
+ length_function=len,
29
+ is_separator_regex=False,
30
+ )
31
+ return splitter.split_text(text)
32
+
33
+
34
+ async def accumulate_llm_results_for_summarizaton_chain(
35
+ test_case: dict,
36
+ chunk_analysis_prompt: str,
37
+ final_synthesis_prompt: str,
38
+ chunking_strategy: Chunking,
39
+ llm: MODELS,
40
+ chunk_concurrency: int = 1,
41
+ ) -> Tuple[str, str, list]:
42
+ chunks = split_text_into_chunks_using_text_splitter(
43
+ str(test_case), chunking_strategy.chunk_size, chunking_strategy.chunk_size // 10
44
+ )
45
+ return await summarize_test_case(
46
+ test_case,
47
+ chunks,
48
+ llm,
49
+ chunk_analysis_prompt,
50
+ final_synthesis_prompt,
51
+ chunk_concurrency,
52
+ )
53
+
54
+
55
+ async def summarize_test_case(
56
+ test_case: dict,
57
+ chunks: list,
58
+ llm: MODELS,
59
+ chunk_analysis_prompt: str,
60
+ final_synthesis_prompt: str,
61
+ chunk_concurrency: int = 1,
62
+ ) -> Tuple[str, str, list]:
63
+ """Summarizes large test case by analyzing chunks and synthesizing results.
64
+
65
+ Args:
66
+ test_case: Test case dictionary with name and data.
67
+ chunks: List of text chunks to analyze.
68
+ llm: Language model instance.
69
+ chunk_analysis_prompt: Template for analyzing chunks.
70
+ final_synthesis_prompt: Template for final synthesis.
71
+ chunk_concurrency: Chunks to process concurrently.
72
+
73
+ Returns:
74
+ Tuple of (final_analysis, test_name, chunks).
75
+ """
76
+ logger.info(f"### For test case {test_case['name']}, {len(chunks)=}")
77
+
78
+ summarization_prompt = PromptTemplate(
79
+ input_variables=["text"],
80
+ template=chunk_analysis_prompt,
81
+ )
82
+
83
+ summarization_chain = build_sumarization_chain(summarization_prompt, llm)
84
+ semaphore = asyncio.Semaphore(chunk_concurrency)
85
+ test_name = test_case["name"]
86
+ total_chunks = len(chunks)
87
+
88
+ async def process_with_limit(chunk: str, chunk_idx: int) -> str:
89
+ async with semaphore:
90
+ logger.debug(
91
+ f"[{test_name}] Processing chunk {chunk_idx + 1}/{total_chunks}, length {len(chunk)}"
92
+ )
93
+ return await summarization_chain.ainvoke({"text": chunk})
94
+
95
+ chunk_tasks = [process_with_limit(chunk, i) for i, chunk in enumerate(chunks)]
96
+ summaries = await asyncio.gather(*chunk_tasks)
97
+
98
+ aggregated_summary = "\n\n---\n\n".join(
99
+ [
100
+ f"### Chunk {i+1}/{total_chunks}\n{summary}"
101
+ for i, summary in enumerate(summaries)
102
+ ]
103
+ )
104
+
105
+ final_prompt = PromptTemplate(
106
+ input_variables=["summary"],
107
+ template=final_synthesis_prompt,
108
+ )
109
+
110
+ final_analysis_chain = build_sumarization_chain(final_prompt, llm)
111
+ final_result = await final_analysis_chain.ainvoke({"summary": aggregated_summary})
112
+
113
+ return final_result, test_case["name"], chunks
@@ -0,0 +1,114 @@
1
+ import math
2
+ from dataclasses import dataclass
3
+
4
+ import tiktoken
5
+
6
+ from result_companion.core.parsers.config import TokenizerModel
7
+ from result_companion.core.utils.logging_config import logger
8
+
9
+
10
+ @dataclass
11
+ class Chunking:
12
+ chunk_size: int
13
+ number_of_chunks: int
14
+ raw_text_len: int
15
+ tokens_from_raw_text: int
16
+ tokenized_chunks: int
17
+
18
+
19
+ def azure_openai_tokenizer(text: str) -> int:
20
+ """Tokenizer for Azure OpenAI models using tiktoken."""
21
+ # TODO: check if not starting something on import
22
+ encoding = tiktoken.encoding_for_model("gpt-3.5-turbo")
23
+ return len(encoding.encode(text))
24
+
25
+
26
+ def ollama_tokenizer(text: str) -> int:
27
+ """Placeholder tokenizer for Ollama (custom implementation required)."""
28
+ return len(text) // 4
29
+
30
+
31
+ def bedrock_tokenizer(text: str) -> int:
32
+ """Placeholder tokenizer for Bedrock LLM (custom implementation required)."""
33
+ return len(text) // 5
34
+
35
+
36
+ def google_tokenizer(text: str) -> int:
37
+ """Tokenizer for Google Generative AI models.
38
+
39
+ Google's tokenization is approximately 4 characters per token on average,
40
+ but we use the tiktoken cl100k_base encoding which is close to Google's tokenization.
41
+ """
42
+ try:
43
+ # Use cl100k_base encoding which is similar to Google's tokenization
44
+ encoding = tiktoken.get_encoding("cl100k_base")
45
+ return len(encoding.encode(text))
46
+ except Exception as e:
47
+ logger.warning(f"Failed to use cl100k_base encoding for Google tokenizer: {e}")
48
+ # Fallback to approximate tokenization (4 chars per token)
49
+ return len(text) // 4
50
+
51
+
52
+ def anthropic_tokenizer(text: str) -> int:
53
+ """Tokenizer for Anthropic Claude models.
54
+
55
+ Uses cl100k_base encoding as approximation for Claude tokenization.
56
+ """
57
+ try:
58
+ encoding = tiktoken.get_encoding("cl100k_base")
59
+ return len(encoding.encode(text))
60
+ except Exception as e:
61
+ logger.warning(
62
+ f"Failed to use cl100k_base encoding for Anthropic tokenizer: {e}"
63
+ )
64
+ return len(text) // 4
65
+
66
+
67
+ tokenizer_mappings = {
68
+ "azure_openai_tokenizer": azure_openai_tokenizer,
69
+ "ollama_tokenizer": ollama_tokenizer,
70
+ "bedrock_tokenizer": bedrock_tokenizer,
71
+ "google_tokenizer": google_tokenizer,
72
+ "openai_tokenizer": azure_openai_tokenizer, # Same tokenization as Azure
73
+ "anthropic_tokenizer": anthropic_tokenizer,
74
+ }
75
+
76
+
77
+ def calculate_overall_chunk_size(
78
+ raw_text: str, actual_tokens_from_text: int, max_tokens_acceptable: int
79
+ ) -> Chunking:
80
+ raw_text_len = len(raw_text)
81
+ N_tokenized_chunks = math.ceil(actual_tokens_from_text / max_tokens_acceptable)
82
+ if max_tokens_acceptable > actual_tokens_from_text:
83
+ return Chunking(
84
+ chunk_size=0,
85
+ number_of_chunks=0,
86
+ raw_text_len=raw_text_len,
87
+ tokens_from_raw_text=actual_tokens_from_text,
88
+ tokenized_chunks=N_tokenized_chunks,
89
+ )
90
+ chunk_size = raw_text_len / N_tokenized_chunks
91
+ logger.info(
92
+ f"Chunk size: {chunk_size}, Number of chunks: {N_tokenized_chunks}, Raw text length: {raw_text_len}"
93
+ )
94
+ return Chunking(
95
+ chunk_size=chunk_size,
96
+ number_of_chunks=N_tokenized_chunks,
97
+ raw_text_len=raw_text_len,
98
+ tokens_from_raw_text=actual_tokens_from_text,
99
+ tokenized_chunks=N_tokenized_chunks,
100
+ )
101
+
102
+
103
+ def calculate_chunk_size(
104
+ test_case: dict, system_prompt: str, tokenizer_from_config: TokenizerModel
105
+ ) -> Chunking:
106
+ LLM_fed_text = str(test_case) + system_prompt
107
+ tokenizer = tokenizer_mappings[tokenizer_from_config.tokenizer]
108
+ max_content_tokens = tokenizer_from_config.max_content_tokens
109
+ text_to_tokens = tokenizer(LLM_fed_text)
110
+ return calculate_overall_chunk_size(
111
+ actual_tokens_from_text=text_to_tokens,
112
+ max_tokens_acceptable=max_content_tokens,
113
+ raw_text=LLM_fed_text,
114
+ )
@@ -0,0 +1,85 @@
1
+ version: 1.0
2
+
3
+ test_filter:
4
+ include_tags: []
5
+ exclude_tags: []
6
+ include_passing: false
7
+
8
+ llm_config:
9
+ question_prompt: |
10
+ You analyze Robot Framework test failures in JSON format.
11
+ Structure: test → keywords (recursive) → name, args, messages, status.
12
+
13
+ ANALYSIS PRIORITY:
14
+ 1. Find keywords with status="FAIL" - start there
15
+ 2. Check preceding PASS keywords for setup issues
16
+ 3. Look for: tracebacks, exceptions, assertion errors, timeouts
17
+ 4. Cascading failures: find the FIRST failure, not symptoms
18
+
19
+ RESPOND EXACTLY IN THIS FORMAT (be terse):
20
+
21
+ **Flow**
22
+ - [Only keywords leading to failure. Max 5 bullets]
23
+
24
+ **Root Cause**
25
+ [Keyword name in quotes. Error type. Why it failed. Max 2-3 sentences]
26
+ Confidence: HIGH/MEDIUM/LOW
27
+
28
+ **Fix**
29
+ - [Actionable fix for test code or environment. Max 1-2 bullets]
30
+
31
+ NO PREAMBLE. NO REASONING. DIRECT ANSWER ONLY.
32
+
33
+ chunking:
34
+ chunk_analysis_prompt: |
35
+ EXTRACT failure-relevant info from this Robot Framework test chunk.
36
+
37
+ OUTPUT FORMAT (use exactly):
38
+ ERRORS: [any exceptions, tracebacks, assertion failures - quote exact text]
39
+ FAIL_KEYWORDS: [keyword names with status=FAIL, in order]
40
+ SUSPECT_KEYWORDS: [PASS keywords with warnings/errors in messages]
41
+ NOTES: [one line only - anything else relevant, or "none"]
42
+
43
+ If chunk has no errors/failures: respond only "CLEAN: no issues"
44
+
45
+ {text}
46
+
47
+ final_synthesis_prompt: |
48
+ NO PREAMBLE. NO REASONING. DIRECT OUTPUT ONLY.
49
+
50
+ Synthesize chunk summaries into final analysis.
51
+ Ignore CLEAN chunks. Deduplicate repeated errors.
52
+ Find the FIRST failure in execution order - that's the root cause.
53
+
54
+ **Flow**
55
+ - [Only failure-path keywords from all chunks. Max 5 bullets]
56
+
57
+ **Root Cause**
58
+ [First failing keyword in quotes. Error type. Why. Max 2-3 sentences]
59
+ Confidence: HIGH/MEDIUM/LOW
60
+
61
+ **Fix**
62
+ - [Actionable fix. Max 1-2 bullets]
63
+
64
+ {summary}
65
+
66
+ prompt_template: |
67
+ Question: {question}
68
+
69
+ Answer the question based on the following context: {context}
70
+
71
+ llm_factory:
72
+ model_type: "OllamaLLM" # AzureChatOpenAI OllamaLLM BedrockLLM
73
+ parameters:
74
+ model: "deepseek-r1:1.5b"
75
+ strategy:
76
+ parameters:
77
+ model_name: "deepseek-r1"
78
+
79
+ tokenizer:
80
+ tokenizer: ollama_tokenizer
81
+ max_content_tokens: 140000
82
+
83
+ concurrency:
84
+ test_case: 1
85
+ chunk: 1
File without changes